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,