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
@@ -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 提示
*/