mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(codex-oauth): fetch model list from ChatGPT backend on demand
- Add `get_codex_oauth_models` Tauri command reusing the managed OAuth access token to hit `chatgpt.com/backend-api/codex/models`; HTTP and multi-shape JSON parsing live in `services::codex_oauth_models` so the command stays thin. - Unify the Claude form's "fetch models" button across normal / Copilot / Codex OAuth presets, drop the auto-load effect for Copilot in favor of explicit clicks, and guard against stale responses with a requestId ref. - Add Vitest coverage for both Copilot and Codex OAuth paths asserting no request on mount and the correct account id on click; add Rust unit tests for the four model-list payload shapes.
This commit is contained in:
@@ -3,9 +3,10 @@
|
||||
//! 提供 OpenAI ChatGPT Plus/Pro OAuth 认证相关的 Tauri 命令。
|
||||
//!
|
||||
//! 大部分认证命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端,
|
||||
//! 此处定义 State wrapper 以及 Codex OAuth 专属的订阅额度查询命令。
|
||||
//! 此处定义 State wrapper 以及 Codex OAuth 专属的订阅额度和模型列表查询命令。
|
||||
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use crate::services::model_fetch::FetchedModel;
|
||||
use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
@@ -56,3 +57,34 @@ pub async fn get_codex_oauth_quota(
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
/// 获取 Codex OAuth (ChatGPT Plus/Pro) 可用模型列表
|
||||
///
|
||||
/// ChatGPT Codex 反代使用 `chatgpt.com/backend-api/codex/*`,不是 OpenAI 兼容
|
||||
/// `/v1/models`。这里复用托管 OAuth 账号的 access_token,直接读取 Codex 后端
|
||||
/// 暴露的模型列表端点。
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn get_codex_oauth_models(
|
||||
account_id: Option<String>,
|
||||
state: State<'_, CodexOAuthState>,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
let manager = state.0.read().await;
|
||||
let resolved = match account_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
{
|
||||
Some(id) => Some(id.to_string()),
|
||||
None => manager.default_account_id().await,
|
||||
};
|
||||
let Some(id) = resolved else {
|
||||
return Err("No ChatGPT account available".to_string());
|
||||
};
|
||||
|
||||
let token = manager
|
||||
.get_valid_token_for_account(&id)
|
||||
.await
|
||||
.map_err(|e| format!("Codex OAuth token unavailable: {e}"))?;
|
||||
|
||||
crate::services::codex_oauth_models::fetch_models_with_token(&token, &id).await
|
||||
}
|
||||
|
||||
@@ -1105,6 +1105,7 @@ pub fn run() {
|
||||
// subscription quota
|
||||
commands::get_subscription_quota,
|
||||
commands::get_codex_oauth_quota,
|
||||
commands::get_codex_oauth_models,
|
||||
commands::get_coding_plan_quota,
|
||||
commands::get_balance,
|
||||
// New MCP via config.json (SSOT)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Codex OAuth model list service.
|
||||
//!
|
||||
//! ChatGPT Codex exposes models through `chatgpt.com/backend-api/codex/models`,
|
||||
//! which is not an OpenAI-compatible `/v1/models` endpoint.
|
||||
|
||||
use crate::services::model_fetch::FetchedModel;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
const CODEX_OAUTH_MODELS_URL: &str = "https://chatgpt.com/backend-api/codex/models";
|
||||
const CODEX_OAUTH_FETCH_TIMEOUT_SECS: u64 = 15;
|
||||
const ERROR_BODY_MAX_CHARS: usize = 512;
|
||||
const CODEX_OAUTH_CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub async fn fetch_models_with_token(
|
||||
token: &str,
|
||||
account_id: &str,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
let client = crate::proxy::http_client::get();
|
||||
let response = client
|
||||
.get(CODEX_OAUTH_MODELS_URL)
|
||||
.query(&[("client_version", CODEX_OAUTH_CLIENT_VERSION)])
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("originator", "cc-switch")
|
||||
.header("chatgpt-account-id", account_id)
|
||||
.timeout(Duration::from_secs(CODEX_OAUTH_FETCH_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = truncate_body(response.text().await.unwrap_or_default());
|
||||
return Err(format!("HTTP {status}: {body}"));
|
||||
}
|
||||
|
||||
let value: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||
|
||||
Ok(parse_models(value))
|
||||
}
|
||||
|
||||
fn parse_models(value: Value) -> Vec<FetchedModel> {
|
||||
let entries = value
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.or_else(|| value.get("models").and_then(Value::as_array))
|
||||
.or_else(|| value.get("items").and_then(Value::as_array))
|
||||
.or_else(|| value.as_array());
|
||||
|
||||
let mut models = Vec::new();
|
||||
|
||||
if let Some(entries) = entries {
|
||||
for entry in entries {
|
||||
push_model_entry(&mut models, entry, None);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(model_map) = value.get("models").and_then(Value::as_object) {
|
||||
for (key, entry) in model_map {
|
||||
push_model_entry(&mut models, entry, Some(key));
|
||||
}
|
||||
}
|
||||
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
models.dedup_by(|a, b| a.id == b.id);
|
||||
models
|
||||
}
|
||||
|
||||
fn push_model_entry(models: &mut Vec<FetchedModel>, entry: &Value, fallback_id: Option<&str>) {
|
||||
if let Some(id) = entry.as_str().map(str::trim).filter(|id| !id.is_empty()) {
|
||||
models.push(FetchedModel {
|
||||
id: id.to_string(),
|
||||
owned_by: Some("Codex".to_string()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(obj) = entry.as_object() else {
|
||||
if let Some(id) = fallback_id.map(str::trim).filter(|id| !id.is_empty()) {
|
||||
models.push(FetchedModel {
|
||||
id: id.to_string(),
|
||||
owned_by: Some("Codex".to_string()),
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(id) = string_field(obj, &["slug", "id", "model", "name"]).or_else(|| {
|
||||
fallback_id
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let owned_by = string_field(
|
||||
obj,
|
||||
&[
|
||||
"owned_by", "ownedBy", "provider", "vendor", "category", "owner",
|
||||
],
|
||||
)
|
||||
.or_else(|| Some("Codex".to_string()));
|
||||
|
||||
models.push(FetchedModel { id, owned_by });
|
||||
}
|
||||
|
||||
fn string_field(obj: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.filter_map(|key| obj.get(*key))
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn truncate_body(body: String) -> String {
|
||||
if body.chars().count() <= ERROR_BODY_MAX_CHARS {
|
||||
body
|
||||
} else {
|
||||
let mut s: String = body.chars().take(ERROR_BODY_MAX_CHARS).collect();
|
||||
s.push_str("...");
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parse_codex_oauth_models_accepts_openai_style_data() {
|
||||
let models = parse_models(json!({
|
||||
"data": [
|
||||
{ "id": "gpt-5.4", "owned_by": "openai" },
|
||||
{ "id": "gpt-5.4-mini", "ownedBy": "openai" }
|
||||
]
|
||||
}));
|
||||
|
||||
assert_eq!(models.len(), 2);
|
||||
assert_eq!(models[0].id, "gpt-5.4");
|
||||
assert_eq!(models[0].owned_by.as_deref(), Some("openai"));
|
||||
assert_eq!(models[1].id, "gpt-5.4-mini");
|
||||
assert_eq!(models[1].owned_by.as_deref(), Some("openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_codex_oauth_models_accepts_model_list_shape() {
|
||||
let models = parse_models(json!({
|
||||
"models": [
|
||||
{ "slug": "gpt-5.3-codex", "display_name": "GPT-5.3 Codex" },
|
||||
"gpt-5.5"
|
||||
]
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
models.into_iter().map(|model| model.id).collect::<Vec<_>>(),
|
||||
vec!["gpt-5.3-codex".to_string(), "gpt-5.5".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_codex_oauth_models_deduplicates_ids() {
|
||||
let models = parse_models(json!({
|
||||
"data": [
|
||||
{ "id": "gpt-5.4" },
|
||||
{ "model": "gpt-5.4" }
|
||||
]
|
||||
}));
|
||||
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].id, "gpt-5.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_codex_oauth_models_accepts_model_map_shape() {
|
||||
let models = parse_models(json!({
|
||||
"models": {
|
||||
"gpt-5.4": { "display_name": "GPT-5.4" },
|
||||
"gpt-5.5": { "slug": "gpt-5.5" }
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
models.into_iter().map(|model| model.id).collect::<Vec<_>>(),
|
||||
vec!["gpt-5.4".to_string(), "gpt-5.5".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod balance;
|
||||
pub mod codex_oauth_models;
|
||||
pub mod coding_plan;
|
||||
pub mod config;
|
||||
pub mod env_checker;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "@/lib/api/copilot";
|
||||
import type { CopilotModel } from "@/lib/api/copilot";
|
||||
import {
|
||||
fetchCodexOauthModels,
|
||||
fetchModelsForConfig,
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
@@ -155,6 +156,7 @@ export function ClaudeFormFields({
|
||||
selectedGitHubAccountId,
|
||||
onGitHubAccountSelect,
|
||||
isCodexOauthPreset,
|
||||
isCodexOauthAuthenticated,
|
||||
selectedCodexAccountId,
|
||||
onCodexAccountSelect,
|
||||
codexFastMode,
|
||||
@@ -210,11 +212,28 @@ export function ClaudeFormFields({
|
||||
// Copilot 可用模型列表
|
||||
const [copilotModels, setCopilotModels] = useState<CopilotModel[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const copilotModelsRequestRef = useRef(0);
|
||||
|
||||
// Codex OAuth 可用模型列表
|
||||
const [codexOauthModels, setCodexOauthModels] = useState<FetchedModel[]>([]);
|
||||
const [codexOauthModelsLoading, setCodexOauthModelsLoading] = useState(false);
|
||||
const codexOauthModelsRequestRef = useRef(0);
|
||||
|
||||
// 通用模型获取(非 Copilot 供应商)
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
|
||||
const showModelFetchResult = useCallback(
|
||||
(count: number) => {
|
||||
if (count === 0) {
|
||||
toast.info(t("providerForm.fetchModelsEmpty"));
|
||||
} else {
|
||||
toast.success(t("providerForm.fetchModelsSuccess", { count }));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleFetchModels = useCallback(() => {
|
||||
if (!baseUrl || !apiKey) {
|
||||
showFetchModelsError(null, t, {
|
||||
@@ -235,31 +254,27 @@ export function ClaudeFormFields({
|
||||
fetchModelsForConfig(baseUrl, apiKey, isFullUrl, modelsUrl)
|
||||
.then((models) => {
|
||||
setFetchedModels(models);
|
||||
if (models.length === 0) {
|
||||
toast.info(t("providerForm.fetchModelsEmpty"));
|
||||
} else {
|
||||
toast.success(
|
||||
t("providerForm.fetchModelsSuccess", { count: models.length }),
|
||||
);
|
||||
}
|
||||
showModelFetchResult(models.length);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[ModelFetch] Failed:", err);
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => setIsFetchingModels(false));
|
||||
}, [baseUrl, apiKey, isFullUrl, t]);
|
||||
}, [baseUrl, apiKey, isFullUrl, showModelFetchResult, t]);
|
||||
|
||||
// 当 Copilot 预设且已认证时,加载可用模型
|
||||
useEffect(() => {
|
||||
// 如果不是 Copilot 预设或未认证,清空模型列表
|
||||
if (!isCopilotPreset || !isCopilotAuthenticated) {
|
||||
setCopilotModels([]);
|
||||
setModelsLoading(false);
|
||||
const handleFetchCopilotModels = useCallback(() => {
|
||||
if (!isCopilotAuthenticated) {
|
||||
toast.error(
|
||||
t("copilot.loginRequired", {
|
||||
defaultValue: "请先登录 GitHub Copilot",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const requestId = copilotModelsRequestRef.current + 1;
|
||||
copilotModelsRequestRef.current = requestId;
|
||||
setModelsLoading(true);
|
||||
const fetchModels = selectedGitHubAccountId
|
||||
? copilotGetModelsForAccount(selectedGitHubAccountId)
|
||||
@@ -267,26 +282,90 @@ export function ClaudeFormFields({
|
||||
|
||||
fetchModels
|
||||
.then((models) => {
|
||||
if (!cancelled) setCopilotModels(models);
|
||||
if (copilotModelsRequestRef.current !== requestId) return;
|
||||
setCopilotModels(models);
|
||||
showModelFetchResult(models.length);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (copilotModelsRequestRef.current !== requestId) return;
|
||||
console.warn("[Copilot] Failed to fetch models:", err);
|
||||
if (!cancelled) {
|
||||
toast.error(
|
||||
t("copilot.loadModelsFailed", {
|
||||
defaultValue: "加载 Copilot 模型列表失败",
|
||||
}),
|
||||
);
|
||||
}
|
||||
toast.error(
|
||||
t("copilot.loadModelsFailed", {
|
||||
defaultValue: "加载 Copilot 模型列表失败",
|
||||
}),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setModelsLoading(false);
|
||||
if (copilotModelsRequestRef.current === requestId) {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isCopilotAuthenticated,
|
||||
selectedGitHubAccountId,
|
||||
showModelFetchResult,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleFetchCodexOauthModels = useCallback(() => {
|
||||
if (!isCodexOauthAuthenticated) {
|
||||
toast.error(
|
||||
t("codexOauth.loginRequired", {
|
||||
defaultValue: "请先登录 ChatGPT 账号",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = codexOauthModelsRequestRef.current + 1;
|
||||
codexOauthModelsRequestRef.current = requestId;
|
||||
setCodexOauthModelsLoading(true);
|
||||
fetchCodexOauthModels(selectedCodexAccountId)
|
||||
.then((models) => {
|
||||
if (codexOauthModelsRequestRef.current !== requestId) return;
|
||||
setCodexOauthModels(models);
|
||||
showModelFetchResult(models.length);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (codexOauthModelsRequestRef.current !== requestId) return;
|
||||
console.warn("[CodexOAuth] Failed to fetch models:", err);
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => {
|
||||
if (codexOauthModelsRequestRef.current === requestId) {
|
||||
setCodexOauthModelsLoading(false);
|
||||
}
|
||||
});
|
||||
}, [
|
||||
isCodexOauthAuthenticated,
|
||||
selectedCodexAccountId,
|
||||
showModelFetchResult,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
copilotModelsRequestRef.current += 1;
|
||||
setCopilotModels([]);
|
||||
setModelsLoading(false);
|
||||
}, [isCopilotPreset, isCopilotAuthenticated, selectedGitHubAccountId]);
|
||||
|
||||
useEffect(() => {
|
||||
codexOauthModelsRequestRef.current += 1;
|
||||
setCodexOauthModels([]);
|
||||
setCodexOauthModelsLoading(false);
|
||||
}, [isCodexOauthPreset, isCodexOauthAuthenticated, selectedCodexAccountId]);
|
||||
|
||||
const modelFetchLoading = isCopilotPreset
|
||||
? modelsLoading
|
||||
: isCodexOauthPreset
|
||||
? codexOauthModelsLoading
|
||||
: isFetchingModels;
|
||||
const handleModelFetchClick = isCopilotPreset
|
||||
? handleFetchCopilotModels
|
||||
: isCodexOauthPreset
|
||||
? handleFetchCodexOauthModels
|
||||
: handleFetchModels;
|
||||
|
||||
// 模型输入框:支持手动输入 + 下拉选择
|
||||
const renderModelInput = (
|
||||
id: string,
|
||||
@@ -298,6 +377,19 @@ export function ClaudeFormFields({
|
||||
const updateValue =
|
||||
onValueChange ?? ((next: string) => onModelChange(field, next));
|
||||
|
||||
if (isCodexOauthPreset) {
|
||||
return (
|
||||
<ModelInputWithFetch
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={updateValue}
|
||||
placeholder={placeholder}
|
||||
fetchedModels={codexOauthModels}
|
||||
isLoading={codexOauthModelsLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCopilotPreset && copilotModels.length > 0) {
|
||||
// 按 vendor 分组
|
||||
const grouped: Record<string, CopilotModel[]> = {};
|
||||
@@ -368,7 +460,20 @@ export function ClaudeFormFields({
|
||||
);
|
||||
}
|
||||
|
||||
// 非 Copilot 供应商: 使用 ModelInputWithFetch(获取按钮在 section 标题旁)
|
||||
if (isCopilotPreset) {
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => updateValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 普通供应商: 使用 ModelInputWithFetch(获取按钮在 section 标题旁)
|
||||
return (
|
||||
<ModelInputWithFetch
|
||||
id={id}
|
||||
@@ -706,23 +811,21 @@ export function ClaudeFormFields({
|
||||
defaultValue: "一键设置",
|
||||
})}
|
||||
</Button>
|
||||
{!isCopilotPreset && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("providerForm.fetchModels")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleModelFetchClick}
|
||||
disabled={modelFetchLoading}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
{modelFetchLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("providerForm.fetchModels")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -27,6 +27,19 @@ export async function fetchModelsForConfig(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Codex OAuth (ChatGPT Plus/Pro 反代) 可用模型列表
|
||||
*
|
||||
* Codex OAuth 使用 ChatGPT 的 backend-api/codex 端点,不兼容普通 /v1/models。
|
||||
*/
|
||||
export async function fetchCodexOauthModels(
|
||||
accountId?: string | null,
|
||||
): Promise<FetchedModel[]> {
|
||||
return invoke("get_codex_oauth_models", {
|
||||
accountId: accountId || null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据错误类型显示对应的 toast 提示
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ComponentProps, PropsWithChildren } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClaudeFormFields } from "@/components/providers/forms/ClaudeFormFields";
|
||||
import { Form } from "@/components/ui/form";
|
||||
|
||||
const copilotApiMock = vi.hoisted(() => ({
|
||||
copilotGetModels: vi.fn(),
|
||||
copilotGetModelsForAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
const modelFetchApiMock = vi.hoisted(() => ({
|
||||
fetchCodexOauthModels: vi.fn(),
|
||||
fetchModelsForConfig: vi.fn(),
|
||||
showFetchModelsError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/copilot", () => ({
|
||||
copilotGetModels: copilotApiMock.copilotGetModels,
|
||||
copilotGetModelsForAccount: copilotApiMock.copilotGetModelsForAccount,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/model-fetch", () => ({
|
||||
fetchCodexOauthModels: modelFetchApiMock.fetchCodexOauthModels,
|
||||
fetchModelsForConfig: modelFetchApiMock.fetchModelsForConfig,
|
||||
showFetchModelsError: modelFetchApiMock.showFetchModelsError,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/forms/CopilotAuthSection", () => ({
|
||||
CopilotAuthSection: () => <div data-testid="copilot-auth-section" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/forms/CodexOAuthSection", () => ({
|
||||
CodexOAuthSection: () => <div data-testid="codex-oauth-section" />,
|
||||
}));
|
||||
|
||||
type ClaudeFormFieldsProps = ComponentProps<typeof ClaudeFormFields>;
|
||||
|
||||
const FormShell = ({ children }: PropsWithChildren) => {
|
||||
const form = useForm();
|
||||
|
||||
return <Form {...form}>{children}</Form>;
|
||||
};
|
||||
|
||||
const renderCopilotForm = (overrides: Partial<ClaudeFormFieldsProps> = {}) => {
|
||||
const props: ClaudeFormFieldsProps = {
|
||||
shouldShowApiKey: false,
|
||||
apiKey: "",
|
||||
onApiKeyChange: vi.fn(),
|
||||
category: "official",
|
||||
shouldShowApiKeyLink: false,
|
||||
websiteUrl: "",
|
||||
isCopilotPreset: true,
|
||||
usesOAuth: true,
|
||||
isCopilotAuthenticated: true,
|
||||
selectedGitHubAccountId: "gh-1",
|
||||
onGitHubAccountSelect: vi.fn(),
|
||||
isCodexOauthPreset: false,
|
||||
isCodexOauthAuthenticated: false,
|
||||
selectedCodexAccountId: null,
|
||||
onCodexAccountSelect: vi.fn(),
|
||||
codexFastMode: false,
|
||||
onCodexFastModeChange: vi.fn(),
|
||||
templateValueEntries: [],
|
||||
templateValues: {},
|
||||
templatePresetName: "",
|
||||
onTemplateValueChange: vi.fn(),
|
||||
shouldShowSpeedTest: false,
|
||||
baseUrl: "",
|
||||
onBaseUrlChange: vi.fn(),
|
||||
isEndpointModalOpen: false,
|
||||
onEndpointModalToggle: vi.fn(),
|
||||
onCustomEndpointsChange: vi.fn(),
|
||||
autoSelect: false,
|
||||
onAutoSelectChange: vi.fn(),
|
||||
showEndpointTools: true,
|
||||
shouldShowModelSelector: true,
|
||||
claudeModel: "",
|
||||
defaultHaikuModel: "",
|
||||
defaultHaikuModelName: "",
|
||||
defaultSonnetModel: "claude-sonnet",
|
||||
defaultSonnetModelName: "Claude Sonnet",
|
||||
defaultOpusModel: "",
|
||||
defaultOpusModelName: "",
|
||||
onModelChange: vi.fn(),
|
||||
speedTestEndpoints: [],
|
||||
apiFormat: "anthropic",
|
||||
onApiFormatChange: vi.fn(),
|
||||
apiKeyField: "ANTHROPIC_AUTH_TOKEN",
|
||||
onApiKeyFieldChange: vi.fn(),
|
||||
isFullUrl: false,
|
||||
onFullUrlChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<FormShell>
|
||||
<ClaudeFormFields {...props} />
|
||||
</FormShell>,
|
||||
);
|
||||
};
|
||||
|
||||
const renderCodexOauthForm = (overrides: Partial<ClaudeFormFieldsProps> = {}) =>
|
||||
renderCopilotForm({
|
||||
isCopilotPreset: false,
|
||||
isCopilotAuthenticated: false,
|
||||
selectedGitHubAccountId: null,
|
||||
isCodexOauthPreset: true,
|
||||
isCodexOauthAuthenticated: true,
|
||||
selectedCodexAccountId: "chatgpt-1",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("ClaudeFormFields", () => {
|
||||
beforeEach(() => {
|
||||
copilotApiMock.copilotGetModels.mockResolvedValue([]);
|
||||
copilotApiMock.copilotGetModelsForAccount.mockResolvedValue([]);
|
||||
modelFetchApiMock.fetchCodexOauthModels.mockResolvedValue([]);
|
||||
modelFetchApiMock.fetchModelsForConfig.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("不会在 Copilot 表单打开时自动获取模型列表", () => {
|
||||
renderCopilotForm();
|
||||
|
||||
expect(copilotApiMock.copilotGetModels).not.toHaveBeenCalled();
|
||||
expect(copilotApiMock.copilotGetModelsForAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("点击获取模型列表后才请求当前 Copilot 账号的模型", async () => {
|
||||
renderCopilotForm();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "providerForm.fetchModels",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(copilotApiMock.copilotGetModelsForAccount).toHaveBeenCalledWith(
|
||||
"gh-1",
|
||||
);
|
||||
});
|
||||
expect(copilotApiMock.copilotGetModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("不会在 Codex OAuth 表单打开时自动获取模型列表", () => {
|
||||
renderCodexOauthForm();
|
||||
|
||||
expect(modelFetchApiMock.fetchCodexOauthModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("点击获取模型列表后才请求当前 Codex OAuth 账号的模型", async () => {
|
||||
renderCodexOauthForm();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "providerForm.fetchModels",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(modelFetchApiMock.fetchCodexOauthModels).toHaveBeenCalledWith(
|
||||
"chatgpt-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user