mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
fix(codex): match quoted provider table headers when writing base_url (#8276)
This commit is contained in:
1 parent
51ac86f1c5
commit
c1051f8e4e
2 files changed
+228
-16
No files matched your search
@@ -244,6 +244,10 @@ export const setApiKeyInConfig = (
|
||||
// ========== Codex base_url utils ==========
|
||||
|
||||
const TOML_SECTION_HEADER_PATTERN = /^\s*\[([^\]\r\n]+)\]\s*$/;
|
||||
const TOML_BARE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
// 点分键的一段(基本字符串、字面字符串或裸键)及其后的 "." 或结尾
|
||||
const TOML_DOTTED_KEY_SEGMENT_PATTERN =
|
||||
/\s*(?:"((?:\\.|[^"\\\r\n])*)"|'([^'\r\n]*)'|([A-Za-z0-9_-]+))\s*(\.|$)/y;
|
||||
const TOML_BASE_URL_PATTERN =
|
||||
/^\s*base_url\s*=\s*(?:"((?:\\.|[^"\\\r\n])*)"|'([^'\r\n]*)')\s*(?:#.*)?$/;
|
||||
const TOML_EXPERIMENTAL_BEARER_TOKEN_PATTERN =
|
||||
@@ -298,20 +302,103 @@ const finalizeTomlText = (lines: string[]): string =>
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/^\n+/, "");
|
||||
|
||||
// 表名的规范写法:能用裸键的段用裸键,其余用基本字符串
|
||||
const tomlTableName = (...segments: string[]): string =>
|
||||
segments
|
||||
.map((segment) =>
|
||||
TOML_BARE_KEY_PATTERN.test(segment) ? segment : tomlBasicString(segment),
|
||||
)
|
||||
.join(".");
|
||||
|
||||
// `[model_providers."custom"]`、`[ model_providers . custom ]` 与
|
||||
// `[model_providers.custom]` 是同一张表(#8271),比较表头前先统一成规范写法;
|
||||
// 认不出的表头原样返回
|
||||
const normalizeTomlTableName = (header: string): string => {
|
||||
const segments: string[] = [];
|
||||
let index = 0;
|
||||
for (;;) {
|
||||
TOML_DOTTED_KEY_SEGMENT_PATTERN.lastIndex = index;
|
||||
const match = TOML_DOTTED_KEY_SEGMENT_PATTERN.exec(header);
|
||||
if (!match) return header;
|
||||
const [, basic, literal, bare, separator] = match;
|
||||
segments.push(
|
||||
basic !== undefined ? unescapeTomlBasicString(basic) : (literal ?? bare),
|
||||
);
|
||||
if (separator !== ".") return tomlTableName(...segments);
|
||||
index = TOML_DOTTED_KEY_SEGMENT_PATTERN.lastIndex;
|
||||
}
|
||||
};
|
||||
|
||||
// 标出以多行字符串(""" 或 ''')内部开头的行。这些行是字符串内容,像表头或赋值也不算:
|
||||
// 说明文字里的 `[model_providers."custom"]` 示例不能被当成真正的 provider 表。
|
||||
// 逐字符跟踪字符串和注释状态;单行字符串和注释到行尾为止。
|
||||
const getTomlMultilineStringLines = (lines: string[]): boolean[] => {
|
||||
let delimiter: '"""' | "'''" | undefined;
|
||||
return lines.map((line) => {
|
||||
const insideString = delimiter !== undefined;
|
||||
let index = 0;
|
||||
while (index < line.length) {
|
||||
if (delimiter) {
|
||||
if (delimiter === '"""' && line[index] === "\\") {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(delimiter, index)) {
|
||||
// 结束符前最多可以再贴两个引号,它们属于字符串内容
|
||||
const quote = delimiter[0];
|
||||
let end = index + 3;
|
||||
while (end < index + 5 && line[end] === quote) end += 1;
|
||||
delimiter = undefined;
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ch = line[index];
|
||||
if (ch === "#") break;
|
||||
if (line.startsWith('"""', index) || line.startsWith("'''", index)) {
|
||||
delimiter = line.startsWith('"""', index) ? '"""' : "'''";
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
index += 1;
|
||||
while (index < line.length && line[index] !== '"') {
|
||||
index += line[index] === "\\" ? 2 : 1;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") {
|
||||
const end = line.indexOf("'", index + 1);
|
||||
index = end === -1 ? line.length : end + 1;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return insideString;
|
||||
});
|
||||
};
|
||||
|
||||
const getTomlSectionRange = (
|
||||
lines: string[],
|
||||
sectionName: string,
|
||||
): TomlSectionRange | undefined => {
|
||||
const targetName = normalizeTomlTableName(sectionName);
|
||||
const stringLines = getTomlMultilineStringLines(lines);
|
||||
let headerLineIndex = -1;
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (stringLines[index]) continue;
|
||||
const match = lines[index].match(TOML_SECTION_HEADER_PATTERN);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (headerLineIndex === -1) {
|
||||
if (match[1] === sectionName) {
|
||||
if (normalizeTomlTableName(match[1]) === targetName) {
|
||||
headerLineIndex = index;
|
||||
}
|
||||
continue;
|
||||
@@ -336,8 +423,10 @@ const getTomlSectionRange = (
|
||||
};
|
||||
|
||||
const getTopLevelEndIndex = (lines: string[]): number => {
|
||||
const firstSectionIndex = lines.findIndex((line) =>
|
||||
TOML_SECTION_HEADER_PATTERN.test(line),
|
||||
const stringLines = getTomlMultilineStringLines(lines);
|
||||
const firstSectionIndex = lines.findIndex(
|
||||
(line, index) =>
|
||||
!stringLines[index] && TOML_SECTION_HEADER_PATTERN.test(line),
|
||||
);
|
||||
return firstSectionIndex === -1 ? lines.length : firstSectionIndex;
|
||||
};
|
||||
@@ -414,7 +503,9 @@ const getCodexProviderSectionName = (
|
||||
configText: string,
|
||||
): string | undefined => {
|
||||
const providerName = getCodexModelProviderName(configText);
|
||||
return providerName ? `model_providers.${providerName}` : undefined;
|
||||
return providerName
|
||||
? tomlTableName("model_providers", providerName)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const isCustomCodexModelProviderId = (providerName: string): boolean => {
|
||||
@@ -430,7 +521,7 @@ const getCodexCustomProviderSectionName = (
|
||||
): string | undefined => {
|
||||
const providerName = getCodexModelProviderName(configText);
|
||||
return providerName && isCustomCodexModelProviderId(providerName)
|
||||
? `model_providers.${providerName}`
|
||||
? tomlTableName("model_providers", providerName)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
@@ -441,7 +532,9 @@ const findTomlAssignmentInRange = (
|
||||
endIndex: number,
|
||||
sectionName?: string,
|
||||
): TomlAssignmentMatch | undefined => {
|
||||
const stringLines = getTomlMultilineStringLines(lines);
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
if (stringLines[index]) continue;
|
||||
const match = lines[index].match(pattern);
|
||||
const value = match?.[2] ?? match?.[1];
|
||||
if (value) {
|
||||
@@ -464,8 +557,10 @@ const findTomlAssignmentsInRange = (
|
||||
sectionName?: string,
|
||||
): TomlAssignmentMatch[] => {
|
||||
const matches: TomlAssignmentMatch[] = [];
|
||||
const stringLines = getTomlMultilineStringLines(lines);
|
||||
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
if (stringLines[index]) continue;
|
||||
const match = lines[index].match(pattern);
|
||||
const value = match?.[2] ?? match?.[1];
|
||||
if (value) {
|
||||
@@ -482,8 +577,9 @@ const findTomlLineInRange = (
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
): number => {
|
||||
const stringLines = getTomlMultilineStringLines(lines);
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
if (pattern.test(lines[index])) {
|
||||
if (!stringLines[index] && pattern.test(lines[index])) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@@ -497,11 +593,13 @@ const findTomlAssignments = (
|
||||
): TomlAssignmentMatch[] => {
|
||||
const assignments: TomlAssignmentMatch[] = [];
|
||||
let currentSectionName: string | undefined;
|
||||
const stringLines = getTomlMultilineStringLines(lines);
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (stringLines[index]) return;
|
||||
const sectionMatch = line.match(TOML_SECTION_HEADER_PATTERN);
|
||||
if (sectionMatch) {
|
||||
currentSectionName = sectionMatch[1];
|
||||
currentSectionName = normalizeTomlTableName(sectionMatch[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -550,15 +648,12 @@ const getRecoverableBaseUrlAssignments = (
|
||||
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;
|
||||
return findTomlLineInRange(
|
||||
lines,
|
||||
TOML_MODEL_PROVIDER_LINE_PATTERN,
|
||||
0,
|
||||
getTopLevelEndIndex(lines),
|
||||
);
|
||||
};
|
||||
|
||||
const TOML_BASIC_STRING_ESCAPES: Record<string, string> = {
|
||||
|
||||
@@ -242,6 +242,123 @@ describe("Codex TOML utils", () => {
|
||||
expect(extractCodexBaseUrl(output)).toBe("https://relay.example/v1");
|
||||
});
|
||||
|
||||
it("finds the active provider table when its header quotes the key", () => {
|
||||
for (const header of [
|
||||
'[model_providers."custom"]',
|
||||
"[model_providers.'custom']",
|
||||
"[ model_providers . custom ]",
|
||||
'["model_providers".custom]',
|
||||
]) {
|
||||
const input = [
|
||||
'model_provider = "custom"',
|
||||
"",
|
||||
header,
|
||||
'name = "custom"',
|
||||
'wire_api = "responses"',
|
||||
"",
|
||||
"[features]",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const output = setCodexBaseUrl(input, "https://relay.example/v1");
|
||||
|
||||
const parsed = parseToml(output) as Record<string, any>;
|
||||
expect(parsed.model_providers.custom, header).toEqual({
|
||||
name: "custom",
|
||||
wire_api: "responses",
|
||||
base_url: "https://relay.example/v1",
|
||||
});
|
||||
expect(output.split(/^\s*\[/m), header).toHaveLength(3);
|
||||
expect(extractCodexBaseUrl(output), header).toBe(
|
||||
"https://relay.example/v1",
|
||||
);
|
||||
expect(
|
||||
extractCodexBaseUrl(setCodexBaseUrl(output, "")),
|
||||
header,
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("writes base_url into a quoted provider table whose id needs quoting", () => {
|
||||
const input = [
|
||||
'model_provider = "my.relay"',
|
||||
"",
|
||||
'[model_providers."my.relay"]',
|
||||
'name = "my.relay"',
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const output = setCodexBaseUrl(input, "https://relay.example/v1");
|
||||
|
||||
const parsed = parseToml(output) as Record<string, any>;
|
||||
expect(Object.keys(parsed.model_providers)).toEqual(["my.relay"]);
|
||||
expect(parsed.model_providers["my.relay"].base_url).toBe(
|
||||
"https://relay.example/v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the bearer token in a provider table whose header quotes the key", () => {
|
||||
const input = [
|
||||
'model_provider = "custom"',
|
||||
"",
|
||||
'[model_providers."custom"]',
|
||||
'name = "custom"',
|
||||
'experimental_bearer_token = "old"',
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const output = updateCodexExperimentalBearerToken(input, "new");
|
||||
|
||||
expect(extractCodexExperimentalBearerToken(output)).toBe("new");
|
||||
expect(
|
||||
(parseToml(output) as Record<string, any>).model_providers.custom
|
||||
.experimental_bearer_token,
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("skips provider tables quoted inside a multi-line string", () => {
|
||||
for (const delimiter of ['"""', "'''"]) {
|
||||
const instructions = [
|
||||
"Example:",
|
||||
'[model_providers."custom"]',
|
||||
'base_url = "https://example.invalid/v1"',
|
||||
'experimental_bearer_token = "example-token"',
|
||||
"",
|
||||
].join("\n");
|
||||
const input = [
|
||||
'model_provider = "custom"',
|
||||
`developer_instructions = ${delimiter}`,
|
||||
`${instructions}${delimiter}`,
|
||||
"",
|
||||
"[model_providers.custom]",
|
||||
'name = "custom"',
|
||||
'base_url = "https://old.invalid/v1"',
|
||||
'experimental_bearer_token = "old-token"',
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
expect(extractCodexBaseUrl(input), delimiter).toBe(
|
||||
"https://old.invalid/v1",
|
||||
);
|
||||
expect(extractCodexExperimentalBearerToken(input), delimiter).toBe(
|
||||
"old-token",
|
||||
);
|
||||
|
||||
const output = updateCodexExperimentalBearerToken(
|
||||
setCodexBaseUrl(input, "https://new.invalid/v1"),
|
||||
"new-token",
|
||||
);
|
||||
|
||||
const parsed = parseToml(output) as Record<string, any>;
|
||||
expect(parsed.developer_instructions, delimiter).toBe(instructions);
|
||||
expect(parsed.model_providers.custom, delimiter).toEqual({
|
||||
name: "custom",
|
||||
base_url: "https://new.invalid/v1",
|
||||
experimental_bearer_token: "new-token",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("never redefines a custom table the line scan cannot see", () => {
|
||||
for (const input of [
|
||||
'model = "m"\n\n[model_providers.custom] # mine\nname = "custom"\nwire_api = "responses"\n',
|
||||
|
||||
Reference in new issue
Block a user