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],
);
+202 -3
View File
@@ -417,13 +417,23 @@ export const hasTomlCommonConfigSnippet = (
const TOML_SECTION_HEADER_PATTERN = /^\s*\[([^\]\r\n]+)\]\s*$/;
const TOML_BASE_URL_PATTERN =
/^\s*base_url\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_EXPERIMENTAL_BEARER_TOKEN_PATTERN =
/^\s*experimental_bearer_token\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN =
/^(\s*experimental_bearer_token\s*=\s*)(?:"(?:\\.|[^"\\\r\n])*"|'[^'\r\n]*')(\s*(?:#.*)?)$/;
const TOML_MODEL_PATTERN = /^\s*model\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_WIRE_API_PATTERN =
/^\s*wire_api\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_MODEL_PROVIDER_LINE_PATTERN =
/^\s*model_provider\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_MODEL_PROVIDER_PATTERN =
/^\s*model_provider\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/m;
const CODEX_RESERVED_MODEL_PROVIDER_IDS = new Set([
"amazon-bedrock",
"openai",
"ollama",
"lmstudio",
"oss",
"ollama-chat",
]);
interface TomlSectionRange {
bodyEndIndex: number;
@@ -499,7 +509,22 @@ const getTomlSectionInsertIndex = (
};
const getCodexModelProviderName = (configText: string): string | undefined => {
const match = configText.match(TOML_MODEL_PROVIDER_PATTERN);
const normalized = normalizeTomlText(configText);
try {
const parsed = parseToml(normalized) as Record<string, any>;
const providerName =
typeof parsed.model_provider === "string"
? parsed.model_provider.trim()
: undefined;
if (providerName) return providerName;
} catch {
// Fall back to a top-level line scan while the user is editing invalid TOML.
}
const lines = normalized.split("\n");
const index = getTopLevelModelProviderLineIndex(lines);
if (index === -1) return undefined;
const match = lines[index].match(TOML_MODEL_PROVIDER_LINE_PATTERN);
const providerName = match?.[2]?.trim();
return providerName || undefined;
};
@@ -511,6 +536,20 @@ const getCodexProviderSectionName = (
return providerName ? `model_providers.${providerName}` : undefined;
};
const isCustomCodexModelProviderId = (providerName: string): boolean => {
const id = providerName.trim().toLowerCase();
return Boolean(id) && !CODEX_RESERVED_MODEL_PROVIDER_IDS.has(id);
};
const getCodexCustomProviderSectionName = (
configText: string,
): string | undefined => {
const providerName = getCodexModelProviderName(configText);
return providerName && isCustomCodexModelProviderId(providerName)
? `model_providers.${providerName}`
: undefined;
};
const findTomlAssignmentInRange = (
lines: string[],
pattern: RegExp,
@@ -532,6 +571,21 @@ const findTomlAssignmentInRange = (
return undefined;
};
const findTomlLineInRange = (
lines: string[],
pattern: RegExp,
startIndex: number,
endIndex: number,
): number => {
for (let index = startIndex; index < endIndex; index += 1) {
if (pattern.test(lines[index])) {
return index;
}
}
return -1;
};
const findTomlAssignments = (
lines: string[],
pattern: RegExp,
@@ -601,6 +655,23 @@ const getTopLevelModelProviderLineIndex = (lines: string[]): number => {
return -1;
};
const TOML_BASIC_STRING_ESCAPES: Record<string, string> = {
'"': '\\"',
"\\": "\\\\",
"\b": "\\b",
"\t": "\\t",
"\n": "\\n",
"\f": "\\f",
"\r": "\\r",
};
const escapeTomlBasicString = (value: string): string =>
value.replace(/["\\\u0000-\u001f]/g, (ch) => {
const escaped = TOML_BASIC_STRING_ESCAPES[ch];
if (escaped) return escaped;
return `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
});
const CODEX_CHAT_WIRE_API_VALUES = new Set([
"chat",
"chat_completions",
@@ -792,6 +863,134 @@ export const extractCodexBaseUrl = (
}
};
// 从 Codex 的 TOML 配置文本中提取 experimental_bearer_token(兼容 Mobile 模式)
export const extractCodexExperimentalBearerToken = (
configText: string | undefined | null,
): string | undefined => {
try {
const raw = typeof configText === "string" ? configText : "";
const text = normalizeTomlText(raw);
if (!text) return undefined;
try {
const parsed = parseToml(text) as Record<string, any>;
const providerName =
typeof parsed.model_provider === "string"
? parsed.model_provider.trim()
: undefined;
const providerToken =
providerName &&
isCustomCodexModelProviderId(providerName) &&
parsed.model_providers &&
typeof parsed.model_providers === "object" &&
typeof parsed.model_providers[providerName]
?.experimental_bearer_token === "string"
? parsed.model_providers[
providerName
].experimental_bearer_token.trim()
: undefined;
if (providerToken) return providerToken;
const topLevelToken =
typeof parsed.experimental_bearer_token === "string"
? parsed.experimental_bearer_token.trim()
: undefined;
if (topLevelToken) return topLevelToken;
} catch {
// Fall back to the line scanner for partially edited TOML.
}
const lines = text.split("\n");
const targetSectionName = getCodexCustomProviderSectionName(text);
if (targetSectionName) {
const sectionRange = getTomlSectionRange(lines, targetSectionName);
if (sectionRange) {
const match = findTomlAssignmentInRange(
lines,
TOML_EXPERIMENTAL_BEARER_TOKEN_PATTERN,
sectionRange.bodyStartIndex,
sectionRange.bodyEndIndex,
targetSectionName,
);
if (match?.value) {
return match.value;
}
}
}
const topLevelMatch = findTomlAssignmentInRange(
lines,
TOML_EXPERIMENTAL_BEARER_TOKEN_PATTERN,
0,
getTopLevelEndIndex(lines),
);
return topLevelMatch?.value;
} catch {
return undefined;
}
};
// 同步更新 Codex config.toml 中已有的 experimental_bearer_token
// 仅修改已存在的条目, 不主动新增——避免破坏未使用 Mobile 兼容模式的普通 third-party 配置
// token 为空时删除该行 (让用户能真正清空 API key, 而不是被 pickCodexApiKey 的 fallback 又填回去)
export const updateCodexExperimentalBearerToken = (
configText: string,
token: string,
): string => {
const normalizedText = normalizeTomlText(configText);
if (
!normalizedText ||
!normalizedText.includes("experimental_bearer_token")
) {
return configText;
}
const lines = normalizedText.split("\n");
const targetSectionName = getCodexCustomProviderSectionName(normalizedText);
let tokenLineIndex = -1;
if (targetSectionName) {
const sectionRange = getTomlSectionRange(lines, targetSectionName);
if (sectionRange) {
const index = findTomlLineInRange(
lines,
TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN,
sectionRange.bodyStartIndex,
sectionRange.bodyEndIndex,
);
if (index !== -1) tokenLineIndex = index;
}
}
if (tokenLineIndex === -1) {
const topLevelIndex = findTomlLineInRange(
lines,
TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN,
0,
getTopLevelEndIndex(lines),
);
if (topLevelIndex !== -1) tokenLineIndex = topLevelIndex;
}
if (tokenLineIndex === -1) return configText;
const trimmed = token.trim();
if (!trimmed) {
lines.splice(tokenLineIndex, 1);
} else {
const escaped = escapeTomlBasicString(trimmed);
const existingLine = lines[tokenLineIndex];
lines[tokenLineIndex] = TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN.test(
existingLine,
)
? existingLine.replace(
TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN,
`$1"${escaped}"$2`,
)
: `experimental_bearer_token = "${escaped}"`;
}
return finalizeTomlText(lines);
};
// 从 Provider 对象中提取 Codex base_url(当 settingsConfig.config 为 TOML 字符串时)
export const getCodexBaseUrl = (
provider: { settingsConfig?: Record<string, any> } | undefined | null,