mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
fix: make Codex TOML base_url editing section-aware
Rewrite setCodexBaseUrl/extractCodexBaseUrl to understand TOML section boundaries, ensuring base_url is written into the correct [model_providers.<name>] section instead of being appended to file end. - Add section-aware TOML helpers in providerConfigUtils.ts - Extract shared update_codex_toml_field/remove_codex_toml_base_url_if in codex_config.rs, deduplicate proxy.rs TOML editing logic - Replace scattered inline base_url regexes with extractCodexBaseUrl() - Add comprehensive tests for both Rust and TypeScript implementations
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// 供应商配置处理工具函数
|
||||
|
||||
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
||||
import { normalizeQuotes, normalizeTomlText } from "@/utils/textNormalization";
|
||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
||||
@@ -414,17 +414,234 @@ export const hasTomlCommonConfigSnippet = (
|
||||
|
||||
// ========== 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]+)\1\s*(?:#.*)?$/;
|
||||
const TOML_MODEL_PATTERN = /^\s*model\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;
|
||||
|
||||
interface TomlSectionRange {
|
||||
bodyEndIndex: number;
|
||||
bodyStartIndex: 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,
|
||||
};
|
||||
}
|
||||
|
||||
if (headerLineIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
bodyStartIndex: headerLineIndex + 1,
|
||||
bodyEndIndex: lines.length,
|
||||
};
|
||||
};
|
||||
|
||||
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 match = configText.match(TOML_MODEL_PROVIDER_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 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);
|
||||
if (match?.[2]) {
|
||||
return {
|
||||
index,
|
||||
sectionName,
|
||||
value: match[2],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
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);
|
||||
if (!match?.[2]) {
|
||||
return;
|
||||
}
|
||||
|
||||
assignments.push({
|
||||
index,
|
||||
sectionName: currentSectionName,
|
||||
value: match[2],
|
||||
});
|
||||
});
|
||||
|
||||
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 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;
|
||||
};
|
||||
|
||||
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
|
||||
export const extractCodexBaseUrl = (
|
||||
configText: string | undefined | null,
|
||||
): string | undefined => {
|
||||
try {
|
||||
const raw = typeof configText === "string" ? configText : "";
|
||||
// 归一化中文/全角引号,避免正则提取失败
|
||||
const text = normalizeQuotes(raw);
|
||||
const text = normalizeTomlText(raw);
|
||||
if (!text) return undefined;
|
||||
const m = text.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
|
||||
return m && m[2] ? m[2] : 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;
|
||||
}
|
||||
@@ -451,36 +668,107 @@ export const setCodexBaseUrl = (
|
||||
baseUrl: string,
|
||||
): string => {
|
||||
const trimmed = baseUrl.trim();
|
||||
// 归一化原文本中的引号(既能匹配,也能输出稳定格式)
|
||||
const normalizedText = normalizeQuotes(configText);
|
||||
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,
|
||||
);
|
||||
|
||||
// 允许清空:当 baseUrl 为空时,移除 base_url 行
|
||||
if (!trimmed) {
|
||||
if (!normalizedText) return normalizedText;
|
||||
const next = normalizedText
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*base_url\s*=/.test(line))
|
||||
.join("\n")
|
||||
// 避免移除后留下过多空行
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
// 避免开头出现空行
|
||||
.replace(/^\n+/, "");
|
||||
return next;
|
||||
|
||||
if (targetSectionName) {
|
||||
const sectionRange = getTomlSectionRange(lines, targetSectionName);
|
||||
const targetMatch = sectionRange
|
||||
? findTomlAssignmentInRange(
|
||||
lines,
|
||||
TOML_BASE_URL_PATTERN,
|
||||
sectionRange.bodyStartIndex,
|
||||
sectionRange.bodyEndIndex,
|
||||
targetSectionName,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (targetMatch) {
|
||||
lines.splice(targetMatch.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}"`;
|
||||
const pattern = /base_url\s*=\s*(["'])([^"']+)\1/;
|
||||
|
||||
if (pattern.test(normalizedText)) {
|
||||
return normalizedText.replace(pattern, replacementLine);
|
||||
if (targetSectionName) {
|
||||
let targetSectionRange = getTomlSectionRange(lines, targetSectionName);
|
||||
const targetMatch = targetSectionRange
|
||||
? findTomlAssignmentInRange(
|
||||
lines,
|
||||
TOML_BASE_URL_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 prefix =
|
||||
normalizedText && !normalizedText.endsWith("\n")
|
||||
? `${normalizedText}\n`
|
||||
: normalizedText;
|
||||
return `${prefix}${replacementLine}\n`;
|
||||
const topLevelEndIndex = getTopLevelEndIndex(lines);
|
||||
const topLevelMatch = findTomlAssignmentInRange(
|
||||
lines,
|
||||
TOML_BASE_URL_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`;
|
||||
}
|
||||
|
||||
const insertIndex = topLevelEndIndex;
|
||||
lines.splice(insertIndex, 0, replacementLine);
|
||||
return finalizeTomlText(lines);
|
||||
};
|
||||
|
||||
// ========== Codex model name utils ==========
|
||||
@@ -491,13 +779,16 @@ export const extractCodexModelName = (
|
||||
): string | undefined => {
|
||||
try {
|
||||
const raw = typeof configText === "string" ? configText : "";
|
||||
// 归一化中文/全角引号,避免正则提取失败
|
||||
const text = normalizeQuotes(raw);
|
||||
const text = normalizeTomlText(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;
|
||||
const lines = text.split("\n");
|
||||
const topLevelMatch = findTomlAssignmentInRange(
|
||||
lines,
|
||||
TOML_MODEL_PATTERN,
|
||||
0,
|
||||
getTopLevelEndIndex(lines),
|
||||
);
|
||||
return topLevelMatch?.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -509,47 +800,40 @@ export const setCodexModelName = (
|
||||
modelName: string,
|
||||
): string => {
|
||||
const trimmed = modelName.trim();
|
||||
// 归一化原文本中的引号(既能匹配,也能输出稳定格式)
|
||||
const normalizedText = normalizeQuotes(configText);
|
||||
const normalizedText = normalizeTomlText(configText);
|
||||
const lines = normalizedText ? normalizedText.split("\n") : [];
|
||||
const topLevelEndIndex = getTopLevelEndIndex(lines);
|
||||
const topLevelMatch = findTomlAssignmentInRange(
|
||||
lines,
|
||||
TOML_MODEL_PATTERN,
|
||||
0,
|
||||
topLevelEndIndex,
|
||||
);
|
||||
|
||||
// 允许清空:当 modelName 为空时,移除 model 行
|
||||
if (!trimmed) {
|
||||
if (!normalizedText) return normalizedText;
|
||||
const next = normalizedText
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*model\s*=/.test(line))
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/^\n+/, "");
|
||||
return next;
|
||||
if (topLevelMatch) {
|
||||
lines.splice(topLevelMatch.index, 1);
|
||||
}
|
||||
return finalizeTomlText(lines);
|
||||
}
|
||||
|
||||
const replacementLine = `model = "${trimmed}"`;
|
||||
const pattern = /^model\s*=\s*["']([^"']+)["']/m;
|
||||
|
||||
if (pattern.test(normalizedText)) {
|
||||
return normalizedText.replace(pattern, replacementLine);
|
||||
if (topLevelMatch) {
|
||||
lines[topLevelMatch.index] = replacementLine;
|
||||
return finalizeTomlText(lines);
|
||||
}
|
||||
|
||||
// 如果不存在 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 modelProviderIndex = getTopLevelModelProviderLineIndex(lines);
|
||||
if (modelProviderIndex !== -1) {
|
||||
lines.splice(modelProviderIndex + 1, 0, replacementLine);
|
||||
return finalizeTomlText(lines);
|
||||
}
|
||||
|
||||
// 在文件开头插入
|
||||
const lines = normalizedText.split("\n");
|
||||
return `${replacementLine}\n${lines.join("\n")}`;
|
||||
if (lines.length === 0) {
|
||||
return `${replacementLine}\n`;
|
||||
}
|
||||
|
||||
lines.splice(topLevelEndIndex, 0, replacementLine);
|
||||
return finalizeTomlText(lines);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user