feat(codex): preserve OAuth login state during third-party provider switching

Codex provider switches now only write config.toml for third-party providers,
injecting the API key as experimental_bearer_token. The user's auth.json
(ChatGPT OAuth tokens) is preserved. Official providers with login material
still write auth.json normally. Backfill restores bearer tokens into stored
provider auth.OPENAI_API_KEY to maintain canonical shape.
This commit is contained in:
Jason
2026-05-25 17:46:23 +08:00
parent 88ba908bc4
commit 95f2dd4126
17 changed files with 1664 additions and 154 deletions
+10 -2
View File
@@ -8,7 +8,10 @@ import { usageApi, settingsApi, type AppId } from "@/lib/api";
import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot";
import { useSettingsQuery } from "@/lib/query";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import {
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
} from "@/utils/providerConfigUtils";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
@@ -173,8 +176,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
// Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }
const auth = (config as any).auth || {};
const configToml = (config as any).config || "";
const apiKey =
typeof auth.OPENAI_API_KEY === "string" &&
auth.OPENAI_API_KEY.trim()
? auth.OPENAI_API_KEY
: extractCodexExperimentalBearerToken(configToml);
return {
apiKey: auth.OPENAI_API_KEY,
apiKey,
baseUrl: extractCodexBaseUrl(configToml),
};
} else if (appId === "gemini") {
+9 -1
View File
@@ -20,6 +20,7 @@ import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
import {
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
extractCodexWireApi,
isCodexChatWireApi,
} from "@/utils/providerConfigUtils";
@@ -78,7 +79,14 @@ function isOfficialProvider(provider: Provider, appId: AppId): boolean {
if (appId === "codex") {
// 无 OPENAI_API_KEY → 使用 Codex CLI 内置 OAuth(官方)
const apiKey = config?.auth?.OPENAI_API_KEY;
return !apiKey || (typeof apiKey === "string" && apiKey.trim() === "");
const bearerToken =
typeof config?.config === "string"
? extractCodexExperimentalBearerToken(config.config)
: undefined;
return (
!bearerToken &&
(!apiKey || (typeof apiKey === "string" && apiKey.trim() === ""))
);
}
if (appId === "gemini") {
// 无 GEMINI_API_KEY 且无 GOOGLE_GEMINI_BASE_URL → Google OAuth 官方模式
@@ -1,7 +1,9 @@
import { useState, useCallback, useEffect, useRef } from "react";
import {
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
setCodexBaseUrl as setCodexBaseUrlInConfig,
updateCodexExperimentalBearerToken,
} from "@/utils/providerConfigUtils";
import { normalizeTomlText } from "@/utils/textNormalization";
import type { CodexCatalogModel } from "@/types";
@@ -12,6 +14,19 @@ interface UseCodexConfigStateProps {
};
}
// auth.json 缺 OPENAI_API_KEY 时回退到 config.toml 的 experimental_bearer_token
// (Mobile 兼容形态:保留 ChatGPT 登录态但用第三方 token)
function pickCodexApiKey(
authObj: { OPENAI_API_KEY?: unknown } | null | undefined,
configText: string,
): string {
if (authObj && typeof authObj.OPENAI_API_KEY === "string") {
const key = authObj.OPENAI_API_KEY;
if (key) return key;
}
return extractCodexExperimentalBearerToken(configText) || "";
}
/**
* 管理 Codex 配置状态
* Codex 配置包含两部分:auth.json (JSON) 和 config.toml (TOML 字符串)
@@ -77,14 +92,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
setCodexBaseUrl(initialBaseUrl);
}
// 提取 API Key
try {
if (auth && typeof auth.OPENAI_API_KEY === "string") {
setCodexApiKey(auth.OPENAI_API_KEY);
}
} catch {
// ignore
}
setCodexApiKey(pickCodexApiKey(auth, configStr));
}
}, [initialData]);
@@ -109,11 +117,15 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
// 从 codexAuth 中提取并同步 API Key
useEffect(() => {
const extractedKey = getCodexAuthApiKey(codexAuth);
if (extractedKey !== codexApiKey) {
setCodexApiKey(extractedKey);
let parsed: { OPENAI_API_KEY?: unknown } | null = null;
try {
parsed = JSON.parse(codexAuth || "{}");
} catch {
parsed = null;
}
}, [codexAuth, codexApiKey]);
const extractedKey = pickCodexApiKey(parsed, codexConfig);
setCodexApiKey((prev) => (prev === extractedKey ? prev : extractedKey));
}, [codexAuth, codexConfig]);
// 验证 Codex Auth JSON
const validateCodexAuth = useCallback((value: string): string => {
@@ -151,6 +163,8 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
);
// 处理 Codex API Key 输入并写回 auth.json
// 同步: 若 config.toml 当前含 experimental_bearer_token (Mobile 兼容形态),
// 也一并更新/清除——否则用户清空输入框会被 pickCodexApiKey 的 fallback 又填回去
const handleCodexApiKeyChange = useCallback(
(key: string) => {
const trimmed = key.trim();
@@ -162,8 +176,11 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
} catch {
// ignore
}
setCodexConfig((prev) =>
updateCodexExperimentalBearerToken(prev, trimmed),
);
},
[codexAuth, setCodexAuth],
[codexAuth, setCodexAuth, setCodexConfig],
);
// 处理 Codex Base URL 变化
@@ -213,16 +230,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
const baseUrl = extractCodexBaseUrl(config);
setCodexBaseUrl(baseUrl || "");
// 提取 API Key
try {
if (auth && typeof auth.OPENAI_API_KEY === "string") {
setCodexApiKey(auth.OPENAI_API_KEY);
} else {
setCodexApiKey("");
}
} catch {
setCodexApiKey("");
}
setCodexApiKey(pickCodexApiKey(auth, config));
},
[setCodexAuth, setCodexConfig, setCodexCatalogModels],
);