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:
Jason
2026-05-14 15:03:31 +08:00
parent f93b935d5f
commit 73bc4eb65d
7 changed files with 557 additions and 47 deletions
+33 -1
View File
@@ -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
}
+1
View File
@@ -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
View File
@@ -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">
+13
View File
@@ -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 提示
*/
+168
View File
@@ -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",
);
});
});
});