diff --git a/src-tauri/src/commands/codex_oauth.rs b/src-tauri/src/commands/codex_oauth.rs index 7e83e0dc7..eb1ceb663 100644 --- a/src-tauri/src/commands/codex_oauth.rs +++ b/src-tauri/src/commands/codex_oauth.rs @@ -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, + state: State<'_, CodexOAuthState>, +) -> Result, 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 +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9abae6a28..312c4835e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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) diff --git a/src-tauri/src/services/codex_oauth_models.rs b/src-tauri/src/services/codex_oauth_models.rs new file mode 100644 index 000000000..04f788966 --- /dev/null +++ b/src-tauri/src/services/codex_oauth_models.rs @@ -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, 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 { + 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, 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, keys: &[&str]) -> Option { + 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!["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!["gpt-5.4".to_string(), "gpt-5.5".to_string()] + ); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 8748792e2..5edf4a8a3 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod balance; +pub mod codex_oauth_models; pub mod coding_plan; pub mod config; pub mod env_checker; diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index a6189773e..d0f1d749b 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -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([]); const [modelsLoading, setModelsLoading] = useState(false); + const copilotModelsRequestRef = useRef(0); + + // Codex OAuth 可用模型列表 + const [codexOauthModels, setCodexOauthModels] = useState([]); + const [codexOauthModelsLoading, setCodexOauthModelsLoading] = useState(false); + const codexOauthModelsRequestRef = useRef(0); // 通用模型获取(非 Copilot 供应商) const [fetchedModels, setFetchedModels] = useState([]); 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 ( + + ); + } + if (isCopilotPreset && copilotModels.length > 0) { // 按 vendor 分组 const grouped: Record = {}; @@ -368,7 +460,20 @@ export function ClaudeFormFields({ ); } - // 非 Copilot 供应商: 使用 ModelInputWithFetch(获取按钮在 section 标题旁) + if (isCopilotPreset) { + return ( + updateValue(e.target.value)} + placeholder={placeholder} + autoComplete="off" + /> + ); + } + + // 普通供应商: 使用 ModelInputWithFetch(获取按钮在 section 标题旁) return ( - {!isCopilotPreset && ( - - )} +

diff --git a/src/lib/api/model-fetch.ts b/src/lib/api/model-fetch.ts index 2b8cc1b71..4f346972d 100644 --- a/src/lib/api/model-fetch.ts +++ b/src/lib/api/model-fetch.ts @@ -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 { + return invoke("get_codex_oauth_models", { + accountId: accountId || null, + }); +} + /** * 根据错误类型显示对应的 toast 提示 */ diff --git a/tests/components/ClaudeFormFields.test.tsx b/tests/components/ClaudeFormFields.test.tsx new file mode 100644 index 000000000..e18a15ca9 --- /dev/null +++ b/tests/components/ClaudeFormFields.test.tsx @@ -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: () =>

, +})); + +vi.mock("@/components/providers/forms/CodexOAuthSection", () => ({ + CodexOAuthSection: () =>
, +})); + +type ClaudeFormFieldsProps = ComponentProps; + +const FormShell = ({ children }: PropsWithChildren) => { + const form = useForm(); + + return
{children}
; +}; + +const renderCopilotForm = (overrides: Partial = {}) => { + 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( + + + , + ); +}; + +const renderCodexOauthForm = (overrides: Partial = {}) => + 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", + ); + }); + }); +});