refactor(codex): decouple model mapping from local-routing toggle

Align the Codex provider form with Claude Code. Model mapping (the
catalog) is independent from route takeover: native Responses providers
(MiMo, Doubao, MiniMax) need it for direct connect with no proxy, while
Chat providers use the proxy regardless of any per-provider flag.

- Remove the "Needs Local Routing" toggle. It had no backend field and
  only gated catalog/reasoning persistence, which is equivalent to
  "is the mapping filled".
- Always show model mapping for non-official providers; persist when the
  list is non-empty (backend already keys off modelCatalog.models).
- Gate reasoning visibility/persistence on Chat format, not the toggle.
- Mark the Chat upstream-format option "routing required" and refresh the
  advanced-section hint (zh/en/ja/zh-TW); drop dead localRouting* keys.
- Move the model-mapping block above the custom User-Agent block.

fix(codex): preserve native-profile hidden catalog fields on edit

useCodexConfigState dropped supportsParallelToolCalls / inputModalities /
baseInstructions when loading a saved provider, so editing and saving a
native Responses provider lost parallel tools, image input and the
official base instructions from the generated catalog. Preserve them on
load (camelCase + snake_case) and compare them in the row sync. Add a
regression test.
This commit is contained in:
Jason
2026-06-28 22:39:26 +08:00
parent 26f0d221c0
commit 85b38c1bed
8 changed files with 319 additions and 281 deletions
+179 -199
View File
@@ -69,13 +69,6 @@ interface CodexFormFieldsProps {
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Local routing / takeover
// takeoverEnabled gates model mapping + reasoning visibility; it is decoupled
// from the wire format so a native Responses provider can use model mapping
// without Chat Completions conversion.
takeoverEnabled: boolean;
onTakeoverEnabledChange: (enabled: boolean) => void;
// API Format
// Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion
apiFormat: CodexApiFormat;
@@ -120,9 +113,11 @@ function createCatalogRow(seed?: Partial<CodexCatalogModel>): CodexCatalogRow {
}
// Compares rows (with rowId) to incoming models (without) by data fields only,
// so both sync effects can use the same equality definition.
// so both sync effects can use the same equality definition. Hidden native-profile
// fields are included so switching between providers with identical visible fields
// but different base_instructions / tools / modalities still rebuilds the rows.
function catalogRowsMatchModels(
rows: Array<Pick<CodexCatalogRow, "model" | "displayName" | "contextWindow">>,
rows: CodexCatalogModel[],
models: CodexCatalogModel[],
): boolean {
if (rows.length !== models.length) return false;
@@ -131,7 +126,13 @@ function catalogRowsMatchModels(
return (
row.model === (incoming.model ?? "") &&
(row.displayName ?? "") === (incoming.displayName ?? "") &&
String(row.contextWindow ?? "") === String(incoming.contextWindow ?? "")
String(row.contextWindow ?? "") ===
String(incoming.contextWindow ?? "") &&
(row.supportsParallelToolCalls ?? null) ===
(incoming.supportsParallelToolCalls ?? null) &&
(row.baseInstructions ?? "") === (incoming.baseInstructions ?? "") &&
JSON.stringify(row.inputModalities ?? []) ===
JSON.stringify(incoming.inputModalities ?? [])
);
});
}
@@ -155,8 +156,6 @@ export function CodexFormFields({
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
takeoverEnabled,
onTakeoverEnabledChange,
apiFormat,
onApiFormatChange,
codexChatReasoning = {},
@@ -175,8 +174,8 @@ export function CodexFormFields({
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
// takeoverEnabled 控制模型映射/思考能力的显示;isChatFormat 仅在选了
// Chat Completions 上游格式时为真(思考能力是 Chat 专属)
// 思考能力随 Chat 格式显示(仅 Chat Completions 转换路径用得上);模型映射常驻
//(填了才生成 catalog)。两者都已与「路由接管」概念解耦
const isChatFormat = apiFormat === "openai_chat";
const canEditCatalog = Boolean(onCatalogModelsChange);
const canEditReasoning = Boolean(onCodexChatReasoningChange);
@@ -185,13 +184,18 @@ export function CodexFormFields({
codexChatReasoning.supportsEffort === true;
const supportsEffort = codexChatReasoning.supportsEffort === true;
// takeoverEnabled 取代了旧的 needsLocalRouting:上游格式已与路由解耦。
// takeoverEnabled 为真说明预设/用户启用了本地路由;请求头/请求体覆盖也算高级值
// 高级区在有任何可见配置时自动展开(仅折叠→展开,不会自动折叠):自定义 UA /
// 请求覆盖 / 已填模型映射 / 原生 Responses(需维护 catalog/ 已配置思考能力
const hasRequestOverrides = Boolean(
localProxyHeadersOverride.trim() || localProxyBodyOverride.trim(),
);
const hasAnyAdvancedValue =
!!customUserAgent || hasRequestOverrides || takeoverEnabled;
!!customUserAgent ||
hasRequestOverrides ||
catalogModels.length > 0 ||
apiFormat === "openai_responses" ||
supportsThinking ||
supportsEffort;
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
@@ -379,7 +383,7 @@ export function CodexFormFields({
/>
)}
{/* 高级选项 —— 本地路由映射/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */}
{/* 高级选项 —— 上游格式/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */}
{category !== "official" && (
<Collapsible
open={advancedExpanded}
@@ -407,17 +411,15 @@ export function CodexFormFields({
<p className="mt-1 ml-1 text-xs text-muted-foreground">
{t("codexConfig.advancedSectionHint", {
defaultValue:
"包含本地路由映射、模型映射、思考能力与自定义 User-Agent。供应商使用 Chat Completions 协议或非 GPT 模型时,需在此开启本地路由映射。",
"包含上游格式、模型映射、思考能力与自定义 User-Agent。使用 Chat Completions 协议的供应商需开启路由接管才能使用。",
})}
</p>
)}
<CollapsibleContent className="space-y-3 pt-3">
{/* 上游格式 + 本地路由映射 —— 两个平级、相互独立的控件
格式不依赖路由:Responses 原生供应商无需开启路由即可直连;
{/* 上游格式 —— Chat 需开启路由接管(走代理转换),Responses 原生直连
沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换。 */}
{shouldShowSpeedTest && (
<div className="space-y-3">
{/* 上游格式 —— 顶层独立选择,与路由开关解耦 */}
<div className="space-y-1.5">
<FormLabel htmlFor="codex-upstream-format">
{t("codexConfig.upstreamFormatLabel", {
@@ -439,7 +441,7 @@ export function CodexFormFields({
<SelectContent>
<SelectItem value="openai_chat">
{t("codexConfig.upstreamFormatChat", {
defaultValue: "Chat Completions转换",
defaultValue: "Chat Completions需开启路由",
})}
</SelectItem>
<SelectItem value="openai_responses">
@@ -452,43 +454,14 @@ export function CodexFormFields({
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.upstreamFormatHint", {
defaultValue:
"供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat(转换为 Chat Completions)。",
"供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat需开启路由接管才能转换为 Chat Completions)。",
})}
</p>
</div>
{/* 需要本地路由映射 —— 纯模型映射门控,与上游格式无关 */}
<div className="flex items-center justify-between gap-4 border-t border-border-default pt-3">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.localRoutingToggle", {
defaultValue: "需要本地路由映射",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{takeoverEnabled
? t("codexConfig.localRoutingOnHint", {
defaultValue:
"打开后可在下方配置模型映射:让 Codex 的 /model 菜单显示自定义模型名,并把请求映射到真实上游模型。",
})
: t("codexConfig.localRoutingOffHint", {
defaultValue:
"供应商模型名无需改写、也无需在 /model 菜单展示自定义名称时,可保持关闭;需要模型映射时打开。",
})}
</p>
</div>
<Switch
checked={takeoverEnabled}
onCheckedChange={onTakeoverEnabledChange}
aria-label={t("codexConfig.localRoutingToggle", {
defaultValue: "需要本地路由映射",
})}
/>
</div>
</div>
)}
{takeoverEnabled && isChatFormat && canEditReasoning && (
{isChatFormat && canEditReasoning && (
<div
className={cn(
"space-y-3",
@@ -557,11 +530,162 @@ export function CodexFormFields({
</div>
)}
{/* 模型映射 / 模型目录 —— 与「路由接管」解耦,常驻显示(可编辑即渲染)。
填了才生成 catalog:Chat 模式生成兼容路由、原生 Responses 生成
model-catalogs.json;留空则不生成。排在自定义 UA 之前。 */}
{canEditCatalog && (
<div
className={cn(
"space-y-4",
(shouldShowSpeedTest || (isChatFormat && canEditReasoning)) &&
"border-t border-border-default pt-3",
)}
>
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<FormLabel>
{t("codexConfig.modelMappingTitle", {
defaultValue: "模型映射",
})}
</FormLabel>
{renderCatalogActionButtons(
handleAddCatalogRow,
t("codexConfig.addCatalogModel", {
defaultValue: "添加模型",
}),
)}
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.modelMappingHint", {
defaultValue:
"选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
})}
</p>
</div>
{catalogRows.length > 0 && (
<div className="space-y-2">
{/* 列头:md+ 显示 */}
<div className="hidden grid-cols-[1fr_1fr_140px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
<span>
{t("codexConfig.catalogColumnDisplay", {
defaultValue: "菜单显示名",
})}
</span>
<span>
{t("codexConfig.catalogColumnModel", {
defaultValue: "实际请求模型",
})}
</span>
<span>
{t("codexConfig.catalogColumnContext", {
defaultValue: "上下文窗口",
})}
</span>
<span />
</div>
{catalogRows.map((row, index) => (
<div
key={row.rowId}
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_140px_36px]"
>
<Input
value={row.displayName ?? ""}
onChange={(event) =>
handleUpdateCatalogRow(index, {
displayName: event.target.value,
})
}
placeholder={t(
"codexConfig.catalogDisplayNamePlaceholder",
{
defaultValue: "例如: DeepSeek V4 Flash",
},
)}
aria-label={t("codexConfig.catalogColumnDisplay", {
defaultValue: "菜单显示名",
})}
/>
<div className="flex gap-1">
<Input
value={row.model}
onChange={(event) =>
handleUpdateCatalogRow(index, {
model: event.target.value,
})
}
placeholder={t(
"codexConfig.catalogModelPlaceholder",
{
defaultValue: "例如: deepseek-v4-flash",
},
)}
aria-label={t("codexConfig.catalogColumnModel", {
defaultValue: "实际请求模型",
})}
className="flex-1"
/>
{fetchedModels.length > 0 && (
<ModelDropdown
models={fetchedModels}
onSelect={(id) =>
handleUpdateCatalogRow(index, {
model: id,
displayName: row.displayName?.trim()
? row.displayName
: id,
})
}
/>
)}
</div>
<Input
type="number"
min={1}
inputMode="numeric"
value={row.contextWindow ?? ""}
onChange={(event) =>
handleUpdateCatalogRow(index, {
contextWindow: event.target.value.replace(
/[^\d]/g,
"",
),
})
}
placeholder={t(
"codexConfig.contextWindowPlaceholder",
{
defaultValue: "例如: 128000",
},
)}
aria-label={t("codexConfig.catalogColumnContext", {
defaultValue: "上下文窗口",
})}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveCatalogRow(index)}
title={t("common.delete", { defaultValue: "删除" })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
)}
<div
className={cn(
"space-y-3",
(shouldShowSpeedTest ||
(takeoverEnabled && isChatFormat && canEditReasoning)) &&
(isChatFormat && canEditReasoning) ||
canEditCatalog) &&
"border-t border-border-default pt-3",
)}
>
@@ -579,150 +703,6 @@ export function CodexFormFields({
/>
</div>
</div>
{/* 模型映射 / 模型目录 —— 接管开启(Chat 模式生成兼容路由)或
原生 Responses 模式(生成 model-catalogs.json)时显示;可编辑即渲染。 */}
{(takeoverEnabled || apiFormat === "openai_responses") &&
canEditCatalog && (
<div className="space-y-4 border-t border-border-default pt-3">
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<FormLabel>
{t("codexConfig.modelMappingTitle", {
defaultValue: "模型映射",
})}
</FormLabel>
{renderCatalogActionButtons(
handleAddCatalogRow,
t("codexConfig.addCatalogModel", {
defaultValue: "添加模型",
}),
)}
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.modelMappingHint", {
defaultValue:
"选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
})}
</p>
</div>
{catalogRows.length > 0 && (
<div className="space-y-2">
{/* 列头:md+ 显示 */}
<div className="hidden grid-cols-[1fr_1fr_140px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
<span>
{t("codexConfig.catalogColumnDisplay", {
defaultValue: "菜单显示名",
})}
</span>
<span>
{t("codexConfig.catalogColumnModel", {
defaultValue: "实际请求模型",
})}
</span>
<span>
{t("codexConfig.catalogColumnContext", {
defaultValue: "上下文窗口",
})}
</span>
<span />
</div>
{catalogRows.map((row, index) => (
<div
key={row.rowId}
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_140px_36px]"
>
<Input
value={row.displayName ?? ""}
onChange={(event) =>
handleUpdateCatalogRow(index, {
displayName: event.target.value,
})
}
placeholder={t(
"codexConfig.catalogDisplayNamePlaceholder",
{
defaultValue: "例如: DeepSeek V4 Flash",
},
)}
aria-label={t("codexConfig.catalogColumnDisplay", {
defaultValue: "菜单显示名",
})}
/>
<div className="flex gap-1">
<Input
value={row.model}
onChange={(event) =>
handleUpdateCatalogRow(index, {
model: event.target.value,
})
}
placeholder={t(
"codexConfig.catalogModelPlaceholder",
{
defaultValue: "例如: deepseek-v4-flash",
},
)}
aria-label={t("codexConfig.catalogColumnModel", {
defaultValue: "实际请求模型",
})}
className="flex-1"
/>
{fetchedModels.length > 0 && (
<ModelDropdown
models={fetchedModels}
onSelect={(id) =>
handleUpdateCatalogRow(index, {
model: id,
displayName: row.displayName?.trim()
? row.displayName
: id,
})
}
/>
)}
</div>
<Input
type="number"
min={1}
inputMode="numeric"
value={row.contextWindow ?? ""}
onChange={(event) =>
handleUpdateCatalogRow(index, {
contextWindow: event.target.value.replace(
/[^\d]/g,
"",
),
})
}
placeholder={t(
"codexConfig.contextWindowPlaceholder",
{
defaultValue: "例如: 128000",
},
)}
aria-label={t("codexConfig.catalogColumnContext", {
defaultValue: "上下文窗口",
})}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveCatalogRow(index)}
title={t("common.delete", { defaultValue: "删除" })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
@@ -150,16 +150,6 @@ const codexApiFormatFromWireApi = (
}
};
// 从已保存的 settingsConfig 推断 Codex 模型映射条目数(用于决定本地路由初始开关)。
const codexCatalogCountFromSettings = (settingsConfig: unknown): number => {
if (settingsConfig && typeof settingsConfig === "object") {
const models = (settingsConfig as { modelCatalog?: { models?: unknown } })
.modelCatalog?.models;
return Array.isArray(models) ? models.length : 0;
}
return 0;
};
export const normalizeCodexCatalogModelsForSave = (
models: CodexCatalogModel[],
): CodexCatalogModel[] => {
@@ -608,19 +598,6 @@ function ProviderFormFull({
const [localCodexApiFormat, setLocalCodexApiFormat] =
useState<CodexApiFormat>(initialCodexApiFormat);
// 本地路由(接管)开关 —— 纯模型映射门控,与上游格式完全独立。
// 没有独立持久化字段,初值仅按「是否已配置模型映射」推断(有 catalog 即视为
// 接管已开)。只在 useState 初始化与预设重置点设置,跟 localCodexApiFormat
// 对称,避免漂移。
const [codexTakeoverEnabled, setCodexTakeoverEnabled] = useState<boolean>(
() =>
// Native (openai_responses) providers run DIRECT (no proxy takeover) yet
// still carry a modelCatalog so cc-switch can generate model-catalogs.json.
// So a catalog must NOT auto-enable takeover for native providers.
initialCodexApiFormat !== "openai_responses" &&
codexCatalogCountFromSettings(initialData?.settingsConfig) > 0,
);
const { configError: codexConfigError, debouncedValidate } =
useCodexTomlValidation();
@@ -650,7 +627,6 @@ function ProviderFormFull({
const template = getCodexCustomTemplate();
resetCodexConfig(template.auth, template.config);
setCodexChatReasoning({});
setCodexTakeoverEnabled(false);
}
}, [appId, initialData, selectedPresetId, resetCodexConfig]);
@@ -1296,13 +1272,11 @@ function ProviderFormFull({
category !== "official" && (codexConfig ?? "").trim()
? setCodexWireApi(codexConfig ?? "", "responses")
: (codexConfig ?? "");
// Persist the catalog for proxy-takeover (Mode A) OR native-direct
// (Mode B, apiFormat=openai_responses). Native providers run without the
// proxy but still need cc-switch to generate model-catalogs.json, so the
// catalog must survive even when the takeover toggle is off.
// 模型映射与「路由接管」解耦:对所有非官方供应商,填了就持久化
//(Chat 生成兼容路由、原生 Responses 生成 model-catalogs.json),
// 留空归一化为 [] 即不写。后端只看 modelCatalog.models 是否非空。
const normalizedCatalogModels =
category !== "official" &&
(codexTakeoverEnabled || localCodexApiFormat === "openai_responses")
category !== "official"
? normalizeCodexCatalogModelsForSave(codexCatalogModels)
: [];
// Sync first catalog row's model into config.toml so Codex uses it as default
@@ -1498,7 +1472,6 @@ function ProviderFormFull({
codexChatReasoning:
appId === "codex" &&
category !== "official" &&
codexTakeoverEnabled &&
localCodexApiFormat === "openai_chat"
? normalizeCodexChatReasoningForSave(codexChatReasoning)
: undefined,
@@ -1651,8 +1624,6 @@ function ProviderFormFull({
codexApiFormatFromWireApi(extractCodexWireApi(template.config)) ??
"openai_responses",
);
// 自定义模板无模型映射,路由默认关闭
setCodexTakeoverEnabled(false);
}
if (appId === "gemini") {
resetGeminiConfig({}, {});
@@ -1695,12 +1666,6 @@ function ProviderFormFull({
codexApiFormatFromWireApi(extractCodexWireApi(config)) ??
"openai_responses",
);
// 接管开关:原生(openai_responses)预设走直连、不开接管,即便带 catalog
//catalog 用于生成 model-catalogs.json);只有 chat 预设按是否带映射开启。
setCodexTakeoverEnabled(
(preset.apiFormat ?? "openai_responses") !== "openai_responses" &&
(preset.modelCatalog?.length ?? 0) > 0,
);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
@@ -2173,8 +2138,6 @@ function ProviderFormFull({
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
takeoverEnabled={codexTakeoverEnabled}
onTakeoverEnabledChange={setCodexTakeoverEnabled}
apiFormat={localCodexApiFormat}
onApiFormatChange={handleCodexApiFormatChange}
codexChatReasoning={codexChatReasoning}
@@ -66,23 +66,51 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
: [];
setCodexCatalogModels(
rawCatalogModels
.map((item: any) => ({
model: typeof item?.model === "string" ? item.model : "",
displayName:
typeof item?.displayName === "string"
? item.displayName
: typeof item?.display_name === "string"
? item.display_name
: "",
contextWindow:
typeof item?.contextWindow === "string" ||
typeof item?.contextWindow === "number"
? item.contextWindow
: typeof item?.context_window === "string" ||
typeof item?.context_window === "number"
? item.context_window
: "",
}))
.map((item: any) => {
// 隐藏字段(原生 Responses profile 用)不在行 UI 暴露,但必须 load→save
// 原样保留,否则编辑保存 MiMo/MiniMax 等会丢官方 base_instructions、
// 并行工具、图像模态。DB SSOT 为 camelCase、live 反解兜底可能为 snake_case
// 双格式兼容(与 displayName/contextWindow 一致)。
const supportsParallelToolCalls =
typeof item?.supportsParallelToolCalls === "boolean"
? item.supportsParallelToolCalls
: typeof item?.supports_parallel_tool_calls === "boolean"
? item.supports_parallel_tool_calls
: undefined;
const inputModalities = Array.isArray(item?.inputModalities)
? item.inputModalities
: Array.isArray(item?.input_modalities)
? item.input_modalities
: undefined;
const baseInstructions =
typeof item?.baseInstructions === "string"
? item.baseInstructions
: typeof item?.base_instructions === "string"
? item.base_instructions
: undefined;
return {
model: typeof item?.model === "string" ? item.model : "",
displayName:
typeof item?.displayName === "string"
? item.displayName
: typeof item?.display_name === "string"
? item.display_name
: "",
contextWindow:
typeof item?.contextWindow === "string" ||
typeof item?.contextWindow === "number"
? item.contextWindow
: typeof item?.context_window === "string" ||
typeof item?.context_window === "number"
? item.context_window
: "",
...(supportsParallelToolCalls !== undefined
? { supportsParallelToolCalls }
: {}),
...(inputModalities ? { inputModalities } : {}),
...(baseInstructions ? { baseInstructions } : {}),
};
})
.filter((item: CodexCatalogModel) => item.model.trim()),
);
+3 -6
View File
@@ -1263,12 +1263,9 @@
"saveFailed": "Save failed: {{error}}",
"modelNameHint": "Specify the model to use, will be auto-updated in config.toml",
"modelName": "Model Name",
"localRoutingToggle": "Needs Local Routing",
"localRoutingOffHint": "Keep off when the provider's model names need no rewriting and you don't need custom names shown in the /model menu; enable it when you need model mapping.",
"localRoutingOnHint": "When on, configure model mapping below: show custom model names in Codex's /model menu and map requests to the real upstream model.",
"upstreamFormatLabel": "Upstream Format",
"upstreamFormatHint": "Pick Responses when your provider is natively a Responses API (direct, no format conversion); pick Chat when it uses the Chat Completions protocol (converts to Chat Completions).",
"upstreamFormatChat": "Chat Completions (convert)",
"upstreamFormatHint": "Pick Responses when your provider is natively a Responses API (direct, no format conversion); pick Chat when it uses the Chat Completions protocol (requires routing takeover to convert to Chat Completions).",
"upstreamFormatChat": "Chat Completions (routing required)",
"upstreamFormatResponses": "Responses (native)",
"upstreamModelName": "Upstream Model Name",
"upstreamModelNameHint": "For Chat format, enter the real upstream model here; routing converts Codex Responses requests to Chat Completions while keeping this model.",
@@ -1297,7 +1294,7 @@
"reasoningEffortHint": "Enable when the upstream supports thinking-depth control such as low/high/max. Enabling this also turns on thinking mode and converts Codex's reasoning.effort into the upstream Chat parameter.",
"reasoningGroupTitle": "Reasoning Capability",
"reasoningSectionHint": "Preset providers are configured automatically; custom providers are inferred from name/URL. Override manually only when auto-detection is wrong.",
"advancedSectionHint": "Includes local routing, upstream format, model mapping, reasoning overrides and custom User-Agent. Enable local routing here when your provider uses the Chat Completions protocol or non-GPT models."
"advancedSectionHint": "Includes upstream format, model mapping, reasoning overrides and custom User-Agent. Providers using the Chat Completions protocol require routing takeover to be enabled."
},
"geminiConfig": {
"envFile": "Environment Variables (.env)",
+3 -6
View File
@@ -1263,12 +1263,9 @@
"saveFailed": "保存に失敗しました: {{error}}",
"modelNameHint": "使用するモデルを指定します。config.toml に自動更新されます",
"modelName": "モデル名",
"localRoutingToggle": "ローカルルーティングが必要",
"localRoutingOffHint": "プロバイダーのモデル名を書き換える必要がなく、/model メニューにカスタム名を表示する必要もない場合はオフのままで構いません。モデルマッピングが必要なときにオンにしてください。",
"localRoutingOnHint": "オンにすると下でモデルマッピングを設定できます。Codex の /model メニューにカスタムモデル名を表示し、リクエストを実際の上流モデルにマッピングします。",
"upstreamFormatLabel": "上流フォーマット",
"upstreamFormatHint": "プロバイダーがネイティブ Responses API なら Responses を選択(直結、フォーマット変換なし)。Chat Completions プロトコルなら Chat を選択(Chat Completions へ変換)。",
"upstreamFormatChat": "Chat Completions変換",
"upstreamFormatHint": "プロバイダーがネイティブ Responses API なら Responses を選択(直結、フォーマット変換なし)。Chat Completions プロトコルなら Chat を選択(Chat Completions へ変換するにはルーティング引き継ぎを有効化する必要があります)。",
"upstreamFormatChat": "Chat Completionsルーティング必須",
"upstreamFormatResponses": "Responses(ネイティブ)",
"upstreamModelName": "上流モデル名",
"upstreamModelNameHint": "Chat 形式ではここに実際の上流モデルを入力します。ルーティングで Codex の Responses リクエストを Chat Completions に変換し、このモデルを維持します。",
@@ -1297,7 +1294,7 @@
"reasoningEffortHint": "上流が low/high/max などの思考の深さの制御に対応している場合に有効化します。有効にすると思考モードも自動的にオンになり、Codex の reasoning.effort を上流の Chat パラメータに変換します。",
"reasoningGroupTitle": "思考能力",
"reasoningSectionHint": "プリセットの供給元は自動的に設定され、カスタム供給元は名前/URL から自動推論されます。自動識別が正しくない場合のみ手動で上書きしてください。",
"advancedSectionHint": "ローカルルーティング、上流フォーマット、モデルマッピング、思考能力、カスタム User-Agent の設定を含みます。供給元が Chat Completions プロトコルまたは GPT 以外のモデルを使用する場合は、ここでローカルルーティングを有効にしてください。"
"advancedSectionHint": "上流フォーマット、モデルマッピング、思考能力、カスタム User-Agent の設定を含みます。Chat Completions プロトコルを使用するプロバイダーはルーティング引き継ぎの有効化が必要です。"
},
"geminiConfig": {
"envFile": "環境変数 (.env)",
+3 -6
View File
@@ -1240,12 +1240,9 @@
"autoCompactLimit": "壓縮閾值",
"autoCompactLimitHint": "上下文 token 數達到此閾值時自動壓縮歷史",
"autoCompactLimitPlaceholder": "例如: 90000",
"localRoutingToggle": "需要本地路由映射",
"localRoutingOnHint": "打開後可在下方設定模型映射:讓 Codex 的 /model 選單顯示自訂模型名,並把請求映射到真實上游模型。",
"localRoutingOffHint": "供應商模型名無需改寫、也無需在 /model 選單顯示自訂名稱時,可保持關閉;需要模型映射時打開。",
"upstreamFormatLabel": "上游格式",
"upstreamFormatHint": "供應商原生為 Responses API 就選 Responses(直連,不轉換格式);使用 Chat Completions 協定就選 Chat(轉換為 Chat Completions)。",
"upstreamFormatChat": "Chat Completions轉換",
"upstreamFormatHint": "供應商原生為 Responses API 就選 Responses(直連,不轉換格式);使用 Chat Completions 協定就選 Chat需開啟路由接管才能轉換為 Chat Completions)。",
"upstreamFormatChat": "Chat Completions需開啟路由",
"upstreamFormatResponses": "Responses(原生)",
"modelMappingTitle": "模型映射",
"modelMappingHint": "產生 Codex model_catalog_json,讓 /model 指令顯示這些第三方模型名;表中條目按填寫內容原樣儲存。修改後需要重新啟動 Codex 才能重新整理模型清單。",
@@ -1269,7 +1266,7 @@
"reasoningEffortHint": "上游支援 low/high/max 等思考深度控制時啟用。啟用後會自動啟用思考模式,並把 Codex 的 reasoning.effort 轉成上游 Chat 參數。",
"reasoningGroupTitle": "思考能力",
"reasoningSectionHint": "預設供應商已自動設定;自訂供應商會按名稱/位址自動推斷。僅當自動識別不準時才需手動覆寫。",
"advancedSectionHint": "包含本地路由映射、上游格式、模型映射、思考能力與自訂 User-Agent。供應商使用 Chat Completions 協定或非 GPT 模型時,需在此開啟本地路由映射。"
"advancedSectionHint": "包含上游格式、模型映射、思考能力與自訂 User-Agent。使用 Chat Completions 協定的供應商需開啟路由接管才能使用。"
},
"geminiConfig": {
"envFile": "環境變數 (.env)",
+3 -6
View File
@@ -1263,12 +1263,9 @@
"saveFailed": "保存失败: {{error}}",
"modelNameHint": "指定使用的模型,将自动更新到 config.toml 中",
"modelName": "模型名称",
"localRoutingToggle": "需要本地路由映射",
"localRoutingOffHint": "供应商模型名无需改写、也无需在 /model 菜单展示自定义名称时,可保持关闭;需要模型映射时打开。",
"localRoutingOnHint": "打开后可在下方配置模型映射:让 Codex 的 /model 菜单显示自定义模型名,并把请求映射到真实上游模型。",
"upstreamFormatLabel": "上游格式",
"upstreamFormatHint": "供应商原生为 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat(转换为 Chat Completions)。",
"upstreamFormatChat": "Chat Completions转换",
"upstreamFormatHint": "供应商原生为 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat需开启路由接管才能转换为 Chat Completions)。",
"upstreamFormatChat": "Chat Completions需开启路由",
"upstreamFormatResponses": "Responses(原生)",
"upstreamModelName": "上游模型名称",
"upstreamModelNameHint": "Chat 格式下这里填写真实上游模型;路由会把 Codex 的 Responses 请求转换为 Chat Completions 并保持该模型。",
@@ -1297,7 +1294,7 @@
"reasoningEffortHint": "上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
"reasoningGroupTitle": "思考能力",
"reasoningSectionHint": "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。",
"advancedSectionHint": "包含本地路由映射、上游格式、模型映射、思考能力与自定义 User-Agent。供应商使用 Chat Completions 协议或非 GPT 模型时,需在此开启本地路由映射。"
"advancedSectionHint": "包含上游格式、模型映射、思考能力与自定义 User-Agent。使用 Chat Completions 协议的供应商需开启路由接管才能使用。"
},
"geminiConfig": {
"envFile": "环境变量 (.env)",
@@ -0,0 +1,79 @@
import { renderHook } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { useCodexConfigState } from "@/components/providers/forms/hooks/useCodexConfigState";
// 回归:编辑已存在的原生 Responses 供应商时,读回 modelCatalog 必须保留隐藏字段
// (supportsParallelToolCalls / inputModalities / baseInstructions),否则保存会
// 把它们剥掉,导致生成的 Codex catalog 丢官方 base_instructions、并行工具、图像模态。
//
// 注意:initialData 必须是稳定引用(hook 的 init effect 依赖 [initialData])。
// 写成内联字面量会每次 re-render 产生新引用 → effect 反复 setState → 死循环 OOM。
describe("useCodexConfigState catalog load", () => {
it("preserves native-profile hidden fields (camelCase, DB SSOT)", () => {
const initialData = {
settingsConfig: {
auth: { OPENAI_API_KEY: "sk-x" },
config: "",
modelCatalog: {
models: [
{
model: "MiniMax-M3",
displayName: "MiniMax-M3",
contextWindow: 1000000,
supportsParallelToolCalls: true,
inputModalities: ["text", "image"],
baseInstructions: "You are Codex, based on MiniMax-M3.",
},
],
},
},
};
const { result } = renderHook(() => useCodexConfigState({ initialData }));
expect(result.current.codexCatalogModels).toEqual([
{
model: "MiniMax-M3",
displayName: "MiniMax-M3",
contextWindow: 1000000,
supportsParallelToolCalls: true,
inputModalities: ["text", "image"],
baseInstructions: "You are Codex, based on MiniMax-M3.",
},
]);
});
it("maps snake_case hidden fields (live reverse-parse fallback) to camelCase", () => {
const initialData = {
settingsConfig: {
auth: {},
config: "",
modelCatalog: {
models: [
{
model: "mimo-v2.5-pro",
display_name: "MiMo V2.5 Pro",
context_window: 262144,
supports_parallel_tool_calls: false,
input_modalities: ["text"],
base_instructions: "You are MiMo, developed by Xiaomi.",
},
],
},
},
};
const { result } = renderHook(() => useCodexConfigState({ initialData }));
expect(result.current.codexCatalogModels).toEqual([
{
model: "mimo-v2.5-pro",
displayName: "MiMo V2.5 Pro",
contextWindow: 262144,
supportsParallelToolCalls: false,
inputModalities: ["text"],
baseInstructions: "You are MiMo, developed by Xiaomi.",
},
]);
});
});