mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
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:
@@ -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") {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user