mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
1497 lines
42 KiB
TypeScript
1497 lines
42 KiB
TypeScript
// 供应商配置处理工具函数
|
||
|
||
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
||
import { deepClone } from "@/utils/deepClone";
|
||
import { 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;
|
||
};
|
||
|
||
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;
|
||
apiKeyField?: string;
|
||
} = {},
|
||
): string => {
|
||
const { createIfMissing = false, appType, apiKeyField } = 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 (优先写入已存在的字段;若两者均不存在且允许创建,则使用 apiKeyField 或默认 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[apiKeyField ?? "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 ==========
|
||
|
||
const TOML_SECTION_HEADER_PATTERN = /^\s*\[([^\]\r\n]+)\]\s*$/;
|
||
const TOML_BASE_URL_PATTERN =
|
||
/^\s*base_url\s*=\s*(?:"((?:\\.|[^"\\\r\n])*)"|'([^'\r\n]*)')\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_PROVIDER_NAME_PATTERN =
|
||
/^\s*name\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
|
||
const TOML_PROVIDER_NAME_REPLACE_PATTERN =
|
||
/^(\s*name\s*=\s*)(?:"(?:\\.|[^"\\\r\n])*"|'[^'\r\n]*')(\s*(?:#.*)?)$/;
|
||
const TOML_GOALS_FEATURE_PATTERN = /^\s*goals\s*=\s*(true|false)\s*(?:#.*)?$/;
|
||
const TOML_GOALS_FEATURE_REPLACE_PATTERN =
|
||
/^(\s*goals\s*=\s*)(true|false)(\s*(?:#.*)?)$/;
|
||
const CODEX_RESERVED_MODEL_PROVIDER_IDS = new Set([
|
||
"amazon-bedrock",
|
||
"openai",
|
||
"ollama",
|
||
"lmstudio",
|
||
"oss",
|
||
"ollama-chat",
|
||
]);
|
||
|
||
interface TomlSectionRange {
|
||
bodyEndIndex: number;
|
||
bodyStartIndex: number;
|
||
headerLineIndex: number;
|
||
}
|
||
|
||
interface TomlAssignmentMatch {
|
||
index: number;
|
||
sectionName?: string;
|
||
value: string;
|
||
}
|
||
|
||
const finalizeTomlText = (lines: string[]): string =>
|
||
lines
|
||
.join("\n")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.replace(/^\n+/, "");
|
||
|
||
const getTomlSectionRange = (
|
||
lines: string[],
|
||
sectionName: string,
|
||
): TomlSectionRange | undefined => {
|
||
let headerLineIndex = -1;
|
||
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const match = lines[index].match(TOML_SECTION_HEADER_PATTERN);
|
||
if (!match) {
|
||
continue;
|
||
}
|
||
|
||
if (headerLineIndex === -1) {
|
||
if (match[1] === sectionName) {
|
||
headerLineIndex = index;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
return {
|
||
bodyStartIndex: headerLineIndex + 1,
|
||
bodyEndIndex: index,
|
||
headerLineIndex,
|
||
};
|
||
}
|
||
|
||
if (headerLineIndex === -1) {
|
||
return undefined;
|
||
}
|
||
|
||
return {
|
||
bodyStartIndex: headerLineIndex + 1,
|
||
bodyEndIndex: lines.length,
|
||
headerLineIndex,
|
||
};
|
||
};
|
||
|
||
const getTopLevelEndIndex = (lines: string[]): number => {
|
||
const firstSectionIndex = lines.findIndex((line) =>
|
||
TOML_SECTION_HEADER_PATTERN.test(line),
|
||
);
|
||
return firstSectionIndex === -1 ? lines.length : firstSectionIndex;
|
||
};
|
||
|
||
const getTomlSectionInsertIndex = (
|
||
lines: string[],
|
||
sectionRange: TomlSectionRange,
|
||
): number => {
|
||
let insertIndex = sectionRange.bodyEndIndex;
|
||
while (
|
||
insertIndex > sectionRange.bodyStartIndex &&
|
||
lines[insertIndex - 1].trim() === ""
|
||
) {
|
||
insertIndex -= 1;
|
||
}
|
||
return insertIndex;
|
||
};
|
||
|
||
const getCodexModelProviderName = (configText: string): string | undefined => {
|
||
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;
|
||
};
|
||
|
||
const getCodexProviderSectionName = (
|
||
configText: string,
|
||
): string | undefined => {
|
||
const providerName = getCodexModelProviderName(configText);
|
||
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,
|
||
startIndex: number,
|
||
endIndex: number,
|
||
sectionName?: string,
|
||
): TomlAssignmentMatch | undefined => {
|
||
for (let index = startIndex; index < endIndex; index += 1) {
|
||
const match = lines[index].match(pattern);
|
||
const value = match?.[2] ?? match?.[1];
|
||
if (value) {
|
||
return {
|
||
index,
|
||
sectionName,
|
||
value,
|
||
};
|
||
}
|
||
}
|
||
|
||
return undefined;
|
||
};
|
||
|
||
const findTomlAssignmentsInRange = (
|
||
lines: string[],
|
||
pattern: RegExp,
|
||
startIndex: number,
|
||
endIndex: number,
|
||
sectionName?: string,
|
||
): TomlAssignmentMatch[] => {
|
||
const matches: TomlAssignmentMatch[] = [];
|
||
|
||
for (let index = startIndex; index < endIndex; index += 1) {
|
||
const match = lines[index].match(pattern);
|
||
const value = match?.[2] ?? match?.[1];
|
||
if (value) {
|
||
matches.push({ index, sectionName, value });
|
||
}
|
||
}
|
||
|
||
return matches;
|
||
};
|
||
|
||
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,
|
||
): TomlAssignmentMatch[] => {
|
||
const assignments: TomlAssignmentMatch[] = [];
|
||
let currentSectionName: string | undefined;
|
||
|
||
lines.forEach((line, index) => {
|
||
const sectionMatch = line.match(TOML_SECTION_HEADER_PATTERN);
|
||
if (sectionMatch) {
|
||
currentSectionName = sectionMatch[1];
|
||
return;
|
||
}
|
||
|
||
const match = line.match(pattern);
|
||
const value = match?.[2] ?? match?.[1];
|
||
if (!value) {
|
||
return;
|
||
}
|
||
|
||
assignments.push({
|
||
index,
|
||
sectionName: currentSectionName,
|
||
value,
|
||
});
|
||
});
|
||
|
||
return assignments;
|
||
};
|
||
|
||
const isMcpServerSection = (sectionName?: string): boolean =>
|
||
sectionName === "mcp_servers" ||
|
||
sectionName?.startsWith("mcp_servers.") === true;
|
||
|
||
const isOtherProviderSection = (
|
||
sectionName: string | undefined,
|
||
targetSectionName: string | undefined,
|
||
): boolean =>
|
||
Boolean(
|
||
sectionName &&
|
||
sectionName !== targetSectionName &&
|
||
(sectionName === "model_providers" ||
|
||
sectionName.startsWith("model_providers.")),
|
||
);
|
||
|
||
const getRecoverableBaseUrlAssignments = (
|
||
assignments: TomlAssignmentMatch[],
|
||
targetSectionName: string | undefined,
|
||
): TomlAssignmentMatch[] =>
|
||
assignments.filter(
|
||
({ sectionName }) =>
|
||
sectionName !== targetSectionName &&
|
||
!isMcpServerSection(sectionName) &&
|
||
!isOtherProviderSection(sectionName, targetSectionName),
|
||
);
|
||
|
||
const getRecoverableCodexProviderAssignments = getRecoverableBaseUrlAssignments;
|
||
|
||
const getTopLevelModelProviderLineIndex = (lines: string[]): number => {
|
||
const topLevelEndIndex = getTopLevelEndIndex(lines);
|
||
|
||
for (let index = 0; index < topLevelEndIndex; index += 1) {
|
||
if (TOML_MODEL_PROVIDER_LINE_PATTERN.test(lines[index])) {
|
||
return index;
|
||
}
|
||
}
|
||
|
||
return -1;
|
||
};
|
||
|
||
const hasTomlSectionBodyContent = (
|
||
lines: string[],
|
||
sectionRange: TomlSectionRange,
|
||
): boolean =>
|
||
lines
|
||
.slice(sectionRange.bodyStartIndex, sectionRange.bodyEndIndex)
|
||
.some((line) => line.trim() !== "");
|
||
|
||
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 tomlBasicString = (value: string): string =>
|
||
`"${escapeTomlBasicString(value)}"`;
|
||
|
||
const CODEX_CHAT_WIRE_API_VALUES = new Set([
|
||
"chat",
|
||
"chat_completions",
|
||
"chat-completions",
|
||
"openai_chat",
|
||
"openai-chat",
|
||
"openai_chat_completions",
|
||
]);
|
||
|
||
// 判断给定的 wire_api 字符串是否表示 Codex 的 Chat Completions 协议
|
||
export const isCodexChatWireApi = (
|
||
wireApi: string | undefined | null,
|
||
): boolean =>
|
||
CODEX_CHAT_WIRE_API_VALUES.has((wireApi ?? "").trim().toLowerCase());
|
||
|
||
// 从 Codex 的 TOML 配置文本中提取 wire_api(支持单/双引号)
|
||
export const extractCodexWireApi = (
|
||
configText: string | undefined | null,
|
||
): string | undefined => {
|
||
try {
|
||
const raw = typeof configText === "string" ? configText : "";
|
||
const text = normalizeTomlText(raw);
|
||
if (!text) return undefined;
|
||
|
||
const lines = text.split("\n");
|
||
const targetSectionName = getCodexProviderSectionName(text);
|
||
|
||
if (targetSectionName) {
|
||
const sectionRange = getTomlSectionRange(lines, targetSectionName);
|
||
if (sectionRange) {
|
||
const match = findTomlAssignmentInRange(
|
||
lines,
|
||
TOML_WIRE_API_PATTERN,
|
||
sectionRange.bodyStartIndex,
|
||
sectionRange.bodyEndIndex,
|
||
targetSectionName,
|
||
);
|
||
if (match?.value) {
|
||
return match.value;
|
||
}
|
||
}
|
||
}
|
||
|
||
const topLevelMatch = findTomlAssignmentInRange(
|
||
lines,
|
||
TOML_WIRE_API_PATTERN,
|
||
0,
|
||
getTopLevelEndIndex(lines),
|
||
);
|
||
if (topLevelMatch?.value) {
|
||
return topLevelMatch.value;
|
||
}
|
||
|
||
const fallbackAssignments = getRecoverableCodexProviderAssignments(
|
||
findTomlAssignments(lines, TOML_WIRE_API_PATTERN),
|
||
targetSectionName,
|
||
);
|
||
return fallbackAssignments.length === 1
|
||
? fallbackAssignments[0].value
|
||
: undefined;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
// 在 Codex 的 TOML 配置文本中写入或更新 wire_api 字段
|
||
export const setCodexWireApi = (
|
||
configText: string,
|
||
wireApi: "responses" | "chat",
|
||
): string => {
|
||
const normalizedText = normalizeTomlText(configText);
|
||
const lines = normalizedText ? normalizedText.split("\n") : [];
|
||
const targetSectionName = getCodexProviderSectionName(normalizedText);
|
||
const replacementLine = `wire_api = "${wireApi}"`;
|
||
const allAssignments = findTomlAssignments(lines, TOML_WIRE_API_PATTERN);
|
||
const recoverableAssignments = getRecoverableCodexProviderAssignments(
|
||
allAssignments,
|
||
targetSectionName,
|
||
);
|
||
|
||
if (targetSectionName) {
|
||
let targetSectionRange = getTomlSectionRange(lines, targetSectionName);
|
||
const targetMatch = targetSectionRange
|
||
? findTomlAssignmentInRange(
|
||
lines,
|
||
TOML_WIRE_API_PATTERN,
|
||
targetSectionRange.bodyStartIndex,
|
||
targetSectionRange.bodyEndIndex,
|
||
targetSectionName,
|
||
)
|
||
: undefined;
|
||
|
||
if (targetMatch) {
|
||
lines[targetMatch.index] = replacementLine;
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
if (recoverableAssignments.length === 1) {
|
||
lines.splice(recoverableAssignments[0].index, 1);
|
||
targetSectionRange = getTomlSectionRange(lines, targetSectionName);
|
||
}
|
||
|
||
if (targetSectionRange) {
|
||
const insertIndex = getTomlSectionInsertIndex(lines, targetSectionRange);
|
||
lines.splice(insertIndex, 0, replacementLine);
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
if (lines.length > 0 && lines[lines.length - 1].trim() !== "") {
|
||
lines.push("");
|
||
}
|
||
lines.push(`[${targetSectionName}]`, replacementLine);
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
const topLevelEndIndex = getTopLevelEndIndex(lines);
|
||
const topLevelMatch = findTomlAssignmentInRange(
|
||
lines,
|
||
TOML_WIRE_API_PATTERN,
|
||
0,
|
||
topLevelEndIndex,
|
||
);
|
||
if (topLevelMatch) {
|
||
lines[topLevelMatch.index] = replacementLine;
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
const modelProviderIndex = getTopLevelModelProviderLineIndex(lines);
|
||
if (modelProviderIndex !== -1) {
|
||
lines.splice(modelProviderIndex + 1, 0, replacementLine);
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
if (lines.length === 0) {
|
||
return `${replacementLine}\n`;
|
||
}
|
||
|
||
lines.splice(topLevelEndIndex, 0, replacementLine);
|
||
return finalizeTomlText(lines);
|
||
};
|
||
|
||
export const isCodexGoalModeEnabled = (
|
||
configText: string | undefined | null,
|
||
): boolean => {
|
||
try {
|
||
const raw = typeof configText === "string" ? configText : "";
|
||
const text = normalizeTomlText(raw);
|
||
if (!text) return false;
|
||
|
||
try {
|
||
const parsed = parseToml(text) as Record<string, any>;
|
||
return parsed.features?.goals === true;
|
||
} catch {
|
||
// Fall back to line scanning while the user is editing invalid TOML.
|
||
}
|
||
|
||
const lines = text.split("\n");
|
||
const featureRange = getTomlSectionRange(lines, "features");
|
||
if (!featureRange) return false;
|
||
|
||
const index = findTomlLineInRange(
|
||
lines,
|
||
TOML_GOALS_FEATURE_PATTERN,
|
||
featureRange.bodyStartIndex,
|
||
featureRange.bodyEndIndex,
|
||
);
|
||
if (index === -1) return false;
|
||
|
||
return lines[index].match(TOML_GOALS_FEATURE_PATTERN)?.[1] === "true";
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
export const setCodexGoalMode = (
|
||
configText: string,
|
||
enabled: boolean,
|
||
): string => {
|
||
const normalizedText = normalizeTomlText(configText);
|
||
const lines = normalizedText ? normalizedText.split("\n") : [];
|
||
let featureRange = getTomlSectionRange(lines, "features");
|
||
|
||
if (featureRange) {
|
||
const goalLineIndex = findTomlLineInRange(
|
||
lines,
|
||
TOML_GOALS_FEATURE_REPLACE_PATTERN,
|
||
featureRange.bodyStartIndex,
|
||
featureRange.bodyEndIndex,
|
||
);
|
||
|
||
if (enabled) {
|
||
if (goalLineIndex !== -1) {
|
||
lines[goalLineIndex] = lines[goalLineIndex].replace(
|
||
TOML_GOALS_FEATURE_REPLACE_PATTERN,
|
||
"$1true$3",
|
||
);
|
||
} else {
|
||
lines.splice(
|
||
getTomlSectionInsertIndex(lines, featureRange),
|
||
0,
|
||
"goals = true",
|
||
);
|
||
}
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
if (goalLineIndex !== -1) {
|
||
lines.splice(goalLineIndex, 1);
|
||
featureRange = getTomlSectionRange(lines, "features");
|
||
if (featureRange && !hasTomlSectionBodyContent(lines, featureRange)) {
|
||
lines.splice(
|
||
featureRange.headerLineIndex,
|
||
featureRange.bodyEndIndex - featureRange.headerLineIndex,
|
||
);
|
||
}
|
||
}
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
if (!enabled) return normalizedText;
|
||
|
||
const topLevelEndIndex = getTopLevelEndIndex(lines);
|
||
const sectionLines: string[] = [];
|
||
if (topLevelEndIndex > 0 && lines[topLevelEndIndex - 1].trim() !== "") {
|
||
sectionLines.push("");
|
||
}
|
||
sectionLines.push("[features]", "goals = true");
|
||
if (
|
||
topLevelEndIndex < lines.length &&
|
||
lines[topLevelEndIndex]?.trim() !== ""
|
||
) {
|
||
sectionLines.push("");
|
||
}
|
||
|
||
lines.splice(topLevelEndIndex, 0, ...sectionLines);
|
||
return finalizeTomlText(lines);
|
||
};
|
||
|
||
export const isCodexRemoteCompactionEnabled = (
|
||
configText: string | undefined | null,
|
||
): boolean => {
|
||
try {
|
||
const raw = typeof configText === "string" ? configText : "";
|
||
const text = normalizeTomlText(raw);
|
||
if (!text) return false;
|
||
|
||
try {
|
||
const parsed = parseToml(text) as Record<string, any>;
|
||
const providerId =
|
||
typeof parsed.model_provider === "string"
|
||
? parsed.model_provider.trim()
|
||
: "";
|
||
if (!providerId || !isCustomCodexModelProviderId(providerId)) {
|
||
return false;
|
||
}
|
||
|
||
return parsed.model_providers?.[providerId]?.name === "OpenAI";
|
||
} catch {
|
||
// Fall back to line scanning while the user is editing invalid TOML.
|
||
}
|
||
|
||
const lines = text.split("\n");
|
||
const targetSectionName = getCodexCustomProviderSectionName(text);
|
||
if (!targetSectionName) return false;
|
||
|
||
const sectionRange = getTomlSectionRange(lines, targetSectionName);
|
||
if (!sectionRange) return false;
|
||
|
||
const nameAssignment = findTomlAssignmentInRange(
|
||
lines,
|
||
TOML_PROVIDER_NAME_PATTERN,
|
||
sectionRange.bodyStartIndex,
|
||
sectionRange.bodyEndIndex,
|
||
targetSectionName,
|
||
);
|
||
|
||
return nameAssignment?.value === "OpenAI";
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
export const setCodexRemoteCompaction = (
|
||
configText: string,
|
||
enabled: boolean,
|
||
fallbackProviderName?: string,
|
||
): string => {
|
||
const normalizedText = normalizeTomlText(configText);
|
||
const lines = normalizedText ? normalizedText.split("\n") : [];
|
||
const targetSectionName = getCodexCustomProviderSectionName(normalizedText);
|
||
|
||
if (!targetSectionName) {
|
||
return normalizedText;
|
||
}
|
||
|
||
let targetSectionRange = getTomlSectionRange(lines, targetSectionName);
|
||
const replacementName = enabled
|
||
? "OpenAI"
|
||
: fallbackProviderName?.trim() ||
|
||
getCodexModelProviderName(normalizedText) ||
|
||
"custom";
|
||
const replacementLine = `name = ${tomlBasicString(replacementName)}`;
|
||
|
||
if (targetSectionRange) {
|
||
const nameLine = findTomlLineInRange(
|
||
lines,
|
||
TOML_PROVIDER_NAME_REPLACE_PATTERN,
|
||
targetSectionRange.bodyStartIndex,
|
||
targetSectionRange.bodyEndIndex,
|
||
);
|
||
|
||
if (nameLine !== -1) {
|
||
lines[nameLine] = lines[nameLine].replace(
|
||
TOML_PROVIDER_NAME_REPLACE_PATTERN,
|
||
`$1${tomlBasicString(replacementName)}$2`,
|
||
);
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
lines.splice(
|
||
getTomlSectionInsertIndex(lines, targetSectionRange),
|
||
0,
|
||
replacementLine,
|
||
);
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
if (!enabled) return normalizedText;
|
||
|
||
if (lines.length > 0 && lines[lines.length - 1].trim() !== "") {
|
||
lines.push("");
|
||
}
|
||
|
||
lines.push(`[${targetSectionName}]`, replacementLine);
|
||
targetSectionRange = getTomlSectionRange(lines, targetSectionName);
|
||
return targetSectionRange ? finalizeTomlText(lines) : normalizedText;
|
||
};
|
||
|
||
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
|
||
export const extractCodexBaseUrl = (
|
||
configText: string | undefined | null,
|
||
): string | undefined => {
|
||
try {
|
||
const raw = typeof configText === "string" ? configText : "";
|
||
const text = normalizeTomlText(raw);
|
||
if (!text) return undefined;
|
||
|
||
const lines = text.split("\n");
|
||
const targetSectionName = getCodexProviderSectionName(text);
|
||
|
||
if (targetSectionName) {
|
||
const sectionRange = getTomlSectionRange(lines, targetSectionName);
|
||
if (sectionRange) {
|
||
const match = findTomlAssignmentInRange(
|
||
lines,
|
||
TOML_BASE_URL_PATTERN,
|
||
sectionRange.bodyStartIndex,
|
||
sectionRange.bodyEndIndex,
|
||
targetSectionName,
|
||
);
|
||
if (match?.value) {
|
||
return match.value;
|
||
}
|
||
}
|
||
}
|
||
|
||
const topLevelMatch = findTomlAssignmentInRange(
|
||
lines,
|
||
TOML_BASE_URL_PATTERN,
|
||
0,
|
||
getTopLevelEndIndex(lines),
|
||
);
|
||
if (topLevelMatch?.value) {
|
||
return topLevelMatch.value;
|
||
}
|
||
|
||
const fallbackAssignments = getRecoverableBaseUrlAssignments(
|
||
findTomlAssignments(lines, TOML_BASE_URL_PATTERN),
|
||
targetSectionName,
|
||
);
|
||
return fallbackAssignments.length === 1
|
||
? fallbackAssignments[0].value
|
||
: undefined;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
// 从 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,
|
||
): 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();
|
||
const normalizedText = normalizeTomlText(configText);
|
||
const lines = normalizedText ? normalizedText.split("\n") : [];
|
||
const targetSectionName = getCodexProviderSectionName(normalizedText);
|
||
const allAssignments = findTomlAssignments(lines, TOML_BASE_URL_PATTERN);
|
||
const recoverableAssignments = getRecoverableBaseUrlAssignments(
|
||
allAssignments,
|
||
targetSectionName,
|
||
);
|
||
|
||
if (!trimmed) {
|
||
if (!normalizedText) return normalizedText;
|
||
|
||
if (targetSectionName) {
|
||
const sectionRange = getTomlSectionRange(lines, targetSectionName);
|
||
const targetMatches = sectionRange
|
||
? findTomlAssignmentsInRange(
|
||
lines,
|
||
TOML_BASE_URL_PATTERN,
|
||
sectionRange.bodyStartIndex,
|
||
sectionRange.bodyEndIndex,
|
||
targetSectionName,
|
||
)
|
||
: [];
|
||
|
||
if (targetMatches.length > 0) {
|
||
for (const match of [...targetMatches].reverse()) {
|
||
lines.splice(match.index, 1);
|
||
}
|
||
return finalizeTomlText(lines);
|
||
}
|
||
}
|
||
|
||
if (recoverableAssignments.length === 1) {
|
||
lines.splice(recoverableAssignments[0].index, 1);
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
const normalizedUrl = trimmed.replace(/\s+/g, "");
|
||
const replacementLine = `base_url = "${normalizedUrl}"`;
|
||
|
||
if (targetSectionName) {
|
||
let targetSectionRange = getTomlSectionRange(lines, targetSectionName);
|
||
const targetMatches = targetSectionRange
|
||
? findTomlAssignmentsInRange(
|
||
lines,
|
||
TOML_BASE_URL_PATTERN,
|
||
targetSectionRange.bodyStartIndex,
|
||
targetSectionRange.bodyEndIndex,
|
||
targetSectionName,
|
||
)
|
||
: [];
|
||
|
||
if (targetMatches.length > 0) {
|
||
const [firstMatch, ...duplicateMatches] = targetMatches;
|
||
lines[firstMatch.index] = replacementLine;
|
||
for (const match of [...duplicateMatches].reverse()) {
|
||
lines.splice(match.index, 1);
|
||
}
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
if (recoverableAssignments.length === 1) {
|
||
lines.splice(recoverableAssignments[0].index, 1);
|
||
targetSectionRange = getTomlSectionRange(lines, targetSectionName);
|
||
}
|
||
|
||
if (targetSectionRange) {
|
||
const insertIndex = getTomlSectionInsertIndex(lines, targetSectionRange);
|
||
lines.splice(insertIndex, 0, replacementLine);
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
if (lines.length > 0 && lines[lines.length - 1].trim() !== "") {
|
||
lines.push("");
|
||
}
|
||
lines.push(`[${targetSectionName}]`, replacementLine);
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
const topLevelEndIndex = getTopLevelEndIndex(lines);
|
||
const topLevelMatches = findTomlAssignmentsInRange(
|
||
lines,
|
||
TOML_BASE_URL_PATTERN,
|
||
0,
|
||
topLevelEndIndex,
|
||
);
|
||
if (topLevelMatches.length > 0) {
|
||
const [firstMatch, ...duplicateMatches] = topLevelMatches;
|
||
lines[firstMatch.index] = replacementLine;
|
||
for (const match of [...duplicateMatches].reverse()) {
|
||
lines.splice(match.index, 1);
|
||
}
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
const modelProviderIndex = getTopLevelModelProviderLineIndex(lines);
|
||
if (modelProviderIndex !== -1) {
|
||
lines.splice(modelProviderIndex + 1, 0, replacementLine);
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
if (lines.length === 0) {
|
||
return `${replacementLine}\n`;
|
||
}
|
||
|
||
const insertIndex = topLevelEndIndex;
|
||
lines.splice(insertIndex, 0, replacementLine);
|
||
return finalizeTomlText(lines);
|
||
};
|
||
|
||
// ========== 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 = normalizeTomlText(raw);
|
||
if (!text) return undefined;
|
||
const lines = text.split("\n");
|
||
const topLevelMatch = findTomlAssignmentInRange(
|
||
lines,
|
||
TOML_MODEL_PATTERN,
|
||
0,
|
||
getTopLevelEndIndex(lines),
|
||
);
|
||
return topLevelMatch?.value;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
// 在 Codex 的 TOML 配置文本中写入或更新 model 字段
|
||
export const setCodexModelName = (
|
||
configText: string,
|
||
modelName: string,
|
||
): string => {
|
||
const trimmed = modelName.trim();
|
||
const normalizedText = normalizeTomlText(configText);
|
||
const lines = normalizedText ? normalizedText.split("\n") : [];
|
||
const topLevelEndIndex = getTopLevelEndIndex(lines);
|
||
const topLevelMatch = findTomlAssignmentInRange(
|
||
lines,
|
||
TOML_MODEL_PATTERN,
|
||
0,
|
||
topLevelEndIndex,
|
||
);
|
||
|
||
if (!trimmed) {
|
||
if (!normalizedText) return normalizedText;
|
||
if (topLevelMatch) {
|
||
lines.splice(topLevelMatch.index, 1);
|
||
}
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
const replacementLine = `model = "${trimmed}"`;
|
||
if (topLevelMatch) {
|
||
lines[topLevelMatch.index] = replacementLine;
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
const modelProviderIndex = getTopLevelModelProviderLineIndex(lines);
|
||
if (modelProviderIndex !== -1) {
|
||
lines.splice(modelProviderIndex + 1, 0, replacementLine);
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
if (lines.length === 0) {
|
||
return `${replacementLine}\n`;
|
||
}
|
||
|
||
lines.splice(topLevelEndIndex, 0, replacementLine);
|
||
return finalizeTomlText(lines);
|
||
};
|
||
|
||
// ========== Codex top-level integer field utils ==========
|
||
|
||
const tomlTopLevelIntPattern = (field: string) =>
|
||
new RegExp(`^\\s*${field}\\s*=\\s*(\\d+)\\s*(?:#.*)?$`);
|
||
|
||
const findTopLevelIntMatch = (
|
||
lines: string[],
|
||
fieldName: string,
|
||
topLevelEndIndex: number,
|
||
): { index: number; value: number } | undefined => {
|
||
const pattern = tomlTopLevelIntPattern(fieldName);
|
||
for (let i = 0; i < topLevelEndIndex; i += 1) {
|
||
const m = lines[i].match(pattern);
|
||
if (m) {
|
||
return { index: i, value: Number(m[1]) };
|
||
}
|
||
}
|
||
return undefined;
|
||
};
|
||
|
||
// 从 Codex TOML 配置中提取顶级整数字段
|
||
export const extractCodexTopLevelInt = (
|
||
configText: string | undefined | null,
|
||
fieldName: string,
|
||
): number | undefined => {
|
||
try {
|
||
const raw = typeof configText === "string" ? configText : "";
|
||
const text = normalizeTomlText(raw);
|
||
if (!text) return undefined;
|
||
const lines = text.split("\n");
|
||
return findTopLevelIntMatch(lines, fieldName, getTopLevelEndIndex(lines))
|
||
?.value;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
// 在 Codex TOML 配置中设置或更新顶级整数字段
|
||
export const setCodexTopLevelInt = (
|
||
configText: string,
|
||
fieldName: string,
|
||
value: number,
|
||
): string => {
|
||
const normalizedText = normalizeTomlText(configText);
|
||
const lines = normalizedText ? normalizedText.split("\n") : [];
|
||
const topLevelEndIndex = getTopLevelEndIndex(lines);
|
||
const existing = findTopLevelIntMatch(lines, fieldName, topLevelEndIndex);
|
||
const replacementLine = `${fieldName} = ${value}`;
|
||
|
||
if (existing) {
|
||
lines[existing.index] = replacementLine;
|
||
return finalizeTomlText(lines);
|
||
}
|
||
|
||
// 插入位置:顶级区域末尾(section header 之前)
|
||
if (lines.length === 0) {
|
||
return `${replacementLine}\n`;
|
||
}
|
||
|
||
lines.splice(topLevelEndIndex, 0, replacementLine);
|
||
return finalizeTomlText(lines);
|
||
};
|
||
|
||
// 从 Codex TOML 配置中移除顶级字段行
|
||
export const removeCodexTopLevelField = (
|
||
configText: string,
|
||
fieldName: string,
|
||
): string => {
|
||
const normalizedText = normalizeTomlText(configText);
|
||
if (!normalizedText) return normalizedText;
|
||
const lines = normalizedText.split("\n");
|
||
const topLevelEndIndex = getTopLevelEndIndex(lines);
|
||
const existing = findTopLevelIntMatch(lines, fieldName, topLevelEndIndex);
|
||
if (existing) {
|
||
lines.splice(existing.index, 1);
|
||
}
|
||
return finalizeTomlText(lines);
|
||
};
|