Files
cc-switch/src/utils/providerConfigUtils.ts
T
Jason 083e48bf8c fix: use structural TOML merge/subset for Codex common config snippet
The text-based approach (string append + substring matching) failed to
detect already-merged snippets when config.toml was reformatted by
external tools (MCP sync, Codex CLI). Replace with smol-toml parse/
stringify + existing deepMerge/isSubset/deepRemove for correct
structural operations. Falls back to text matching on parse failure.
2026-03-04 23:01:40 +08:00

533 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 供应商配置处理工具函数
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
import { normalizeQuotes, normalizeTomlText } from "@/utils/textNormalization";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
const deepMerge = (
target: Record<string, any>,
source: Record<string, any>,
): Record<string, any> => {
Object.entries(source).forEach(([key, value]) => {
if (isPlainObject(value)) {
if (!isPlainObject(target[key])) {
target[key] = {};
}
deepMerge(target[key], value);
} else {
// 直接覆盖非对象字段(数组/基础类型)
target[key] = value;
}
});
return target;
};
const deepRemove = (
target: Record<string, any>,
source: Record<string, any>,
) => {
Object.entries(source).forEach(([key, value]) => {
if (!(key in target)) return;
if (isPlainObject(value) && isPlainObject(target[key])) {
// 只移除完全匹配的嵌套属性
deepRemove(target[key], value);
if (Object.keys(target[key]).length === 0) {
delete target[key];
}
} else if (isSubset(target[key], value)) {
// 只有当值完全匹配时才删除
delete target[key];
}
});
};
const isSubset = (target: any, source: any): boolean => {
if (isPlainObject(source)) {
if (!isPlainObject(target)) return false;
return Object.entries(source).every(([key, value]) =>
isSubset(target[key], value),
);
}
if (Array.isArray(source)) {
if (!Array.isArray(target) || target.length !== source.length) return false;
return source.every((item, index) => isSubset(target[index], item));
}
return target === source;
};
// 深拷贝函数
const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as T;
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
if (obj instanceof Object) {
const clonedObj = {} as T;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
}
return obj;
};
export interface UpdateCommonConfigResult {
updatedConfig: string;
error?: string;
}
// 验证JSON配置格式
export const validateJsonConfig = (
value: string,
fieldName: string = "配置",
): string => {
if (!value.trim()) {
return "";
}
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return `${fieldName}必须是 JSON 对象`;
}
return "";
} catch {
return `${fieldName}JSON格式错误,请检查语法`;
}
};
// 将通用配置片段写入/移除 settingsConfig
export const updateCommonConfigSnippet = (
jsonString: string,
snippetString: string,
enabled: boolean,
): UpdateCommonConfigResult => {
let config: Record<string, any>;
try {
config = jsonString ? JSON.parse(jsonString) : {};
} catch (err) {
return {
updatedConfig: jsonString,
error: "配置 JSON 解析失败,无法写入通用配置",
};
}
if (!snippetString.trim()) {
return {
updatedConfig: JSON.stringify(config, null, 2),
};
}
// 使用统一的验证函数
const snippetError = validateJsonConfig(snippetString, "通用配置片段");
if (snippetError) {
return {
updatedConfig: JSON.stringify(config, null, 2),
error: snippetError,
};
}
const snippet = JSON.parse(snippetString) as Record<string, any>;
if (enabled) {
const merged = deepMerge(deepClone(config), snippet);
return {
updatedConfig: JSON.stringify(merged, null, 2),
};
}
const cloned = deepClone(config);
deepRemove(cloned, snippet);
return {
updatedConfig: JSON.stringify(cloned, null, 2),
};
};
// 检查当前配置是否已包含通用配置片段
export const hasCommonConfigSnippet = (
jsonString: string,
snippetString: string,
): boolean => {
try {
if (!snippetString.trim()) return false;
const config = jsonString ? JSON.parse(jsonString) : {};
const snippet = JSON.parse(snippetString);
if (!isPlainObject(snippet)) return false;
return isSubset(config, snippet);
} catch (err) {
return false;
}
};
// 读取配置中的 API Key(支持 Claude, Codex, Gemini
export const getApiKeyFromConfig = (
jsonString: string,
appType?: string,
): string => {
try {
const config = JSON.parse(jsonString);
// 优先检查顶层 apiKey 字段(用于 Bedrock API Key 等预设)
if (
typeof config?.apiKey === "string" &&
config.apiKey &&
!config.apiKey.includes("${")
) {
return config.apiKey;
}
const env = config?.env;
if (!env) return "";
// Gemini API Key
if (appType === "gemini") {
const geminiKey = env.GEMINI_API_KEY;
return typeof geminiKey === "string" ? geminiKey : "";
}
// Codex API Key
if (appType === "codex") {
const codexKey = env.CODEX_API_KEY;
return typeof codexKey === "string" ? codexKey : "";
}
// Claude API Key (优先 ANTHROPIC_AUTH_TOKEN,其次 ANTHROPIC_API_KEY)
const token = env.ANTHROPIC_AUTH_TOKEN;
const apiKey = env.ANTHROPIC_API_KEY;
const value =
typeof token === "string"
? token
: typeof apiKey === "string"
? apiKey
: "";
return value;
} catch (err) {
return "";
}
};
// 模板变量替换
export const applyTemplateValues = (
config: any,
templateValues: Record<string, TemplateValueConfig> | undefined,
): any => {
const resolvedValues = Object.fromEntries(
Object.entries(templateValues ?? {}).map(([key, value]) => {
const resolvedValue =
value.editorValue !== undefined
? value.editorValue
: (value.defaultValue ?? "");
return [key, resolvedValue];
}),
);
const replaceInString = (str: string): string => {
return Object.entries(resolvedValues).reduce((acc, [key, value]) => {
const placeholder = `\${${key}}`;
if (!acc.includes(placeholder)) {
return acc;
}
return acc.split(placeholder).join(value ?? "");
}, str);
};
const traverse = (obj: any): any => {
if (typeof obj === "string") {
return replaceInString(obj);
}
if (Array.isArray(obj)) {
return obj.map(traverse);
}
if (obj && typeof obj === "object") {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = traverse(value);
}
return result;
}
return obj;
};
return traverse(config);
};
// 判断配置中是否存在 API Key 字段
export const hasApiKeyField = (
jsonString: string,
appType?: string,
): boolean => {
try {
const config = JSON.parse(jsonString);
// 检查顶层 apiKey 字段(用于 Bedrock API Key 等预设)
if (Object.prototype.hasOwnProperty.call(config, "apiKey")) {
return true;
}
const env = config?.env ?? {};
if (appType === "gemini") {
return Object.prototype.hasOwnProperty.call(env, "GEMINI_API_KEY");
}
if (appType === "codex") {
return Object.prototype.hasOwnProperty.call(env, "CODEX_API_KEY");
}
return (
Object.prototype.hasOwnProperty.call(env, "ANTHROPIC_AUTH_TOKEN") ||
Object.prototype.hasOwnProperty.call(env, "ANTHROPIC_API_KEY")
);
} catch (err) {
return false;
}
};
// 写入/更新配置中的 API Key,默认不新增缺失字段
export const setApiKeyInConfig = (
jsonString: string,
apiKey: string,
options: { createIfMissing?: boolean; appType?: string } = {},
): string => {
const { createIfMissing = false, appType } = options;
try {
const config = JSON.parse(jsonString);
// 优先检查顶层 apiKey 字段(用于 Bedrock API Key 等预设)
if (Object.prototype.hasOwnProperty.call(config, "apiKey")) {
config.apiKey = apiKey;
return JSON.stringify(config, null, 2);
}
if (!config.env) {
if (!createIfMissing) return jsonString;
config.env = {};
}
const env = config.env as Record<string, any>;
// Gemini API Key
if (appType === "gemini") {
if ("GEMINI_API_KEY" in env) {
env.GEMINI_API_KEY = apiKey;
} else if (createIfMissing) {
env.GEMINI_API_KEY = apiKey;
} else {
return jsonString;
}
return JSON.stringify(config, null, 2);
}
// Codex API Key
if (appType === "codex") {
if ("CODEX_API_KEY" in env) {
env.CODEX_API_KEY = apiKey;
} else if (createIfMissing) {
env.CODEX_API_KEY = apiKey;
} else {
return jsonString;
}
return JSON.stringify(config, null, 2);
}
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则默认创建 AUTH_TOKEN 字段)
if ("ANTHROPIC_AUTH_TOKEN" in env) {
env.ANTHROPIC_AUTH_TOKEN = apiKey;
} else if ("ANTHROPIC_API_KEY" in env) {
env.ANTHROPIC_API_KEY = apiKey;
} else if (createIfMissing) {
env.ANTHROPIC_AUTH_TOKEN = apiKey;
} else {
return jsonString;
}
return JSON.stringify(config, null, 2);
} catch (err) {
return jsonString;
}
};
// ========== TOML Config Utilities ==========
export interface UpdateTomlCommonConfigResult {
updatedConfig: string;
error?: string;
}
// Write/remove common config snippet to/from TOML config (structural merge)
export const updateTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
enabled: boolean,
): UpdateTomlCommonConfigResult => {
if (!snippetString.trim()) {
return { updatedConfig: tomlString };
}
try {
const config = parseToml(normalizeTomlText(tomlString || ""));
const snippet = parseToml(normalizeTomlText(snippetString));
if (enabled) {
const merged = deepMerge(
deepClone(config) as Record<string, any>,
deepClone(snippet) as Record<string, any>,
);
return { updatedConfig: stringifyToml(merged) };
} else {
const result = deepClone(config) as Record<string, any>;
deepRemove(result, snippet as Record<string, any>);
return { updatedConfig: stringifyToml(result) };
}
} catch (e) {
return { updatedConfig: tomlString, error: String(e) };
}
};
// Check if TOML config already contains the common config snippet (structural subset check)
export const hasTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
): boolean => {
if (!snippetString.trim()) return false;
try {
const config = parseToml(normalizeTomlText(tomlString || ""));
const snippet = parseToml(normalizeTomlText(snippetString));
return isSubset(config, snippet);
} catch {
// Fallback to text-based matching if TOML parsing fails
const norm = (s: string) => s.replace(/\s+/g, " ").trim();
return norm(tomlString).includes(norm(snippetString));
}
};
// ========== Codex base_url utils ==========
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
export const extractCodexBaseUrl = (
configText: string | undefined | null,
): string | undefined => {
try {
const raw = typeof configText === "string" ? configText : "";
// 归一化中文/全角引号,避免正则提取失败
const text = normalizeQuotes(raw);
if (!text) return undefined;
const m = text.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
return m && m[2] ? m[2] : undefined;
} catch {
return undefined;
}
};
// 从 Provider 对象中提取 Codex base_url(当 settingsConfig.config 为 TOML 字符串时)
export const getCodexBaseUrl = (
provider: { settingsConfig?: Record<string, any> } | undefined | null,
): string | undefined => {
try {
const text =
typeof provider?.settingsConfig?.config === "string"
? (provider as any).settingsConfig.config
: "";
return extractCodexBaseUrl(text);
} catch {
return undefined;
}
};
// 在 Codex 的 TOML 配置文本中写入或更新 base_url 字段
export const setCodexBaseUrl = (
configText: string,
baseUrl: string,
): string => {
const trimmed = baseUrl.trim();
if (!trimmed) {
return configText;
}
// 归一化原文本中的引号(既能匹配,也能输出稳定格式)
const normalizedText = normalizeQuotes(configText);
const normalizedUrl = trimmed.replace(/\s+/g, "");
const replacementLine = `base_url = "${normalizedUrl}"`;
const pattern = /base_url\s*=\s*(["'])([^"']+)\1/;
if (pattern.test(normalizedText)) {
return normalizedText.replace(pattern, replacementLine);
}
const prefix =
normalizedText && !normalizedText.endsWith("\n")
? `${normalizedText}\n`
: normalizedText;
return `${prefix}${replacementLine}\n`;
};
// ========== Codex model name utils ==========
// 从 Codex 的 TOML 配置文本中提取 model 字段(支持单/双引号)
export const extractCodexModelName = (
configText: string | undefined | null,
): string | undefined => {
try {
const raw = typeof configText === "string" ? configText : "";
// 归一化中文/全角引号,避免正则提取失败
const text = normalizeQuotes(raw);
if (!text) return undefined;
// 匹配 model = "xxx" 或 model = 'xxx'
const m = text.match(/^model\s*=\s*(['"])([^'"]+)\1/m);
return m && m[2] ? m[2] : undefined;
} catch {
return undefined;
}
};
// 在 Codex 的 TOML 配置文本中写入或更新 model 字段
export const setCodexModelName = (
configText: string,
modelName: string,
): string => {
const trimmed = modelName.trim();
if (!trimmed) {
return configText;
}
// 归一化原文本中的引号(既能匹配,也能输出稳定格式)
const normalizedText = normalizeQuotes(configText);
const replacementLine = `model = "${trimmed}"`;
const pattern = /^model\s*=\s*["']([^"']+)["']/m;
if (pattern.test(normalizedText)) {
return normalizedText.replace(pattern, replacementLine);
}
// 如果不存在 model 字段,尝试在 model_provider 之后插入
// 如果 model_provider 也不存在,则插入到开头
const providerPattern = /^model_provider\s*=\s*["'][^"']+["']/m;
const match = normalizedText.match(providerPattern);
if (match && match.index !== undefined) {
// 在 model_provider 行之后插入
const endOfLine = normalizedText.indexOf("\n", match.index);
if (endOfLine !== -1) {
return (
normalizedText.slice(0, endOfLine + 1) +
replacementLine +
"\n" +
normalizedText.slice(endOfLine + 1)
);
}
}
// 在文件开头插入
const lines = normalizedText.split("\n");
return `${replacementLine}\n${lines.join("\n")}`;
};