feat(codex): decouple upstream format selector from model-mapping toggle

The Codex provider form tied Chat-format conversion and route takeover (model
mapping) to one toggle, so a provider serving a native Responses API could not
use model mapping without forcing Chat Completions conversion.

- Promote the upstream format (Chat Completions / Responses) to an independent,
  always-visible selector that triggers no sub-menus on its own.
- The local-routing toggle now solely gates the advanced sub-sections: model
  mapping catalog, plus reasoning capability when the format is Chat.
- Persist modelCatalog / codexChatReasoning based on the takeover toggle and
  derive its initial state from saved catalog presence (no new persisted field).
- Refresh codexConfig i18n (zh/en/ja/zh-TW): add upstreamFormat* keys and reword
  the routing/advanced hints to reflect the decoupling.
- Fix codexChatProviderPresets test expectation for the Doubao Seed 2.1 Pro rename.
This commit is contained in:
Jason
2026-06-24 23:35:42 +08:00
parent 213f55a6d2
commit a4eb5f3778
7 changed files with 168 additions and 67 deletions
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Collapsible,
CollapsibleContent,
@@ -62,6 +69,13 @@ 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;
@@ -132,6 +146,8 @@ export function CodexFormFields({
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
takeoverEnabled,
onTakeoverEnabledChange,
apiFormat,
onApiFormatChange,
codexChatReasoning = {},
@@ -150,7 +166,9 @@ export function CodexFormFields({
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
const needsLocalRouting = apiFormat === "openai_chat";
// takeoverEnabled 控制模型映射/思考能力的显示;isChatFormat 仅在选了
// Chat Completions 上游格式时为真(思考能力是 Chat 专属)。
const isChatFormat = apiFormat === "openai_chat";
const canEditCatalog = Boolean(onCatalogModelsChange);
const canEditReasoning = Boolean(onCodexChatReasoningChange);
const supportsThinking =
@@ -158,12 +176,13 @@ export function CodexFormFields({
codexChatReasoning.supportsEffort === true;
const supportsEffort = codexChatReasoning.supportsEffort === true;
// needsLocalRouting 非默认值说明预设/用户动过路由配置,需要让模型映射保持可见
// takeoverEnabled 取代了旧的 needsLocalRouting:上游格式已与路由解耦。
// takeoverEnabled 为真说明预设/用户启用了本地路由;请求头/请求体覆盖也算高级值。
const hasRequestOverrides = Boolean(
localProxyHeadersOverride.trim() || localProxyBodyOverride.trim(),
);
const hasAnyAdvancedValue =
!!customUserAgent || hasRequestOverrides || needsLocalRouting;
!!customUserAgent || hasRequestOverrides || takeoverEnabled;
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
@@ -204,13 +223,6 @@ export function CodexFormFields({
onCatalogModelsChange(next);
}, [catalogRows, onCatalogModelsChange]);
const handleLocalRoutingChange = useCallback(
(checked: boolean) => {
onApiFormatChange(checked ? "openai_chat" : "openai_responses");
},
[onApiFormatChange],
);
const handleReasoningThinkingChange = useCallback(
(checked: boolean) => {
if (!onCodexChatReasoningChange) return;
@@ -391,38 +403,83 @@ export function CodexFormFields({
</p>
)}
<CollapsibleContent className="space-y-3 pt-3">
{/* 本地路由映射开关 —— 沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换 */}
{/* 上游格式 + 本地路由映射 —— 两个平级、相互独立的控件。
格式不依赖路由:Responses 原生供应商无需开启路由即可直连;
沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换。 */}
{shouldShowSpeedTest && (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.localRoutingToggle", {
defaultValue: "需要本地路由映射",
<div className="space-y-3">
{/* 上游格式 —— 顶层独立选择,与路由开关解耦 */}
<div className="space-y-1.5">
<FormLabel htmlFor="codex-upstream-format">
{t("codexConfig.upstreamFormatLabel", {
defaultValue: "上游格式",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{needsLocalRouting
? t("codexConfig.localRoutingOnHint", {
defaultValue:
"Codex 目前仅原生支持 OpenAI Responses API 与 GPT 系列模型;如果您的供应商使用 Chat Completions 协议或非 GPT 模型(如 DeepSeek、Kimi),则需要打开本开关,并在使用过程中保持本地路由开启。",
})
: t("codexConfig.localRoutingOffHint", {
defaultValue:
"如果您的供应商不是原生 OpenAI Responses API,或者模型名不是 Codex 默认的 GPT 系列,请打开此开关。",
<Select
value={apiFormat}
onValueChange={(value) =>
onApiFormatChange(value as CodexApiFormat)
}
>
<SelectTrigger
id="codex-upstream-format"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai_chat">
{t("codexConfig.upstreamFormatChat", {
defaultValue: "Chat Completions(转换)",
})}
</SelectItem>
<SelectItem value="openai_responses">
{t("codexConfig.upstreamFormatResponses", {
defaultValue: "Responses(原生)",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.upstreamFormatHint", {
defaultValue:
"供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat(转换为 Chat Completions)。",
})}
</p>
</div>
<Switch
checked={needsLocalRouting}
onCheckedChange={handleLocalRoutingChange}
aria-label={t("codexConfig.localRoutingToggle", {
defaultValue: "需要本地路由映射",
})}
/>
{/* 需要本地路由映射 —— 纯模型映射门控,与上游格式无关 */}
<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>
)}
{needsLocalRouting && canEditReasoning && (
{takeoverEnabled && isChatFormat && canEditReasoning && (
<div
className={cn(
"space-y-3",
@@ -495,7 +552,7 @@ export function CodexFormFields({
className={cn(
"space-y-3",
(shouldShowSpeedTest ||
(needsLocalRouting && canEditReasoning)) &&
(takeoverEnabled && isChatFormat && canEditReasoning)) &&
"border-t border-border-default pt-3",
)}
>
@@ -514,8 +571,9 @@ export function CodexFormFields({
</div>
</div>
{/* 模型映射 —— 仅在本地路由 + 可编辑时显示;上方恒有 UA 字段,分隔线无需条件 */}
{needsLocalRouting && canEditCatalog && (
{/* 模型映射 —— 仅在本地路由开启 + 可编辑时显示(与上游格式解耦,
Responses 原生供应商同样可配置);上方恒有 UA 字段,分隔线无需条件 */}
{takeoverEnabled && 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">
+41 -18
View File
@@ -150,6 +150,16 @@ 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[] => {
@@ -568,24 +578,29 @@ function ProviderFormFull({
resetCodexConfig,
} = useCodexConfigState({ initialData });
const initialCodexApiFormat: CodexApiFormat =
initialData?.meta?.apiFormat === "openai_chat"
? "openai_chat"
: initialData?.meta?.apiFormat === "openai_responses"
? "openai_responses"
: (codexApiFormatFromWireApi(
extractCodexWireApi(
typeof initialData?.settingsConfig?.config === "string"
? initialData.settingsConfig.config
: "",
),
) ?? "openai_responses");
const [localCodexApiFormat, setLocalCodexApiFormat] =
useState<CodexApiFormat>(() => {
if (initialData?.meta?.apiFormat === "openai_chat") {
return "openai_chat";
}
if (initialData?.meta?.apiFormat === "openai_responses") {
return "openai_responses";
}
return (
codexApiFormatFromWireApi(
extractCodexWireApi(
typeof initialData?.settingsConfig?.config === "string"
? initialData.settingsConfig.config
: "",
),
) ?? "openai_responses"
);
});
useState<CodexApiFormat>(initialCodexApiFormat);
// 本地路由(接管)开关 —— 纯模型映射门控,与上游格式完全独立。
// 没有独立持久化字段,初值仅按「是否已配置模型映射」推断(有 catalog 即视为
// 接管已开)。只在 useState 初始化与预设重置点设置,跟 localCodexApiFormat
// 对称,避免漂移。
const [codexTakeoverEnabled, setCodexTakeoverEnabled] = useState<boolean>(
() => codexCatalogCountFromSettings(initialData?.settingsConfig) > 0,
);
const { configError: codexConfigError, debouncedValidate } =
useCodexTomlValidation();
@@ -616,6 +631,7 @@ function ProviderFormFull({
const template = getCodexCustomTemplate();
resetCodexConfig(template.auth, template.config);
setCodexChatReasoning({});
setCodexTakeoverEnabled(false);
}
}, [appId, initialData, selectedPresetId, resetCodexConfig]);
@@ -1262,7 +1278,7 @@ function ProviderFormFull({
? setCodexWireApi(codexConfig ?? "", "responses")
: (codexConfig ?? "");
const normalizedCatalogModels =
category !== "official" && localCodexApiFormat === "openai_chat"
category !== "official" && codexTakeoverEnabled
? normalizeCodexCatalogModelsForSave(codexCatalogModels)
: [];
// Sync first catalog row's model into config.toml so Codex uses it as default
@@ -1458,6 +1474,7 @@ function ProviderFormFull({
codexChatReasoning:
appId === "codex" &&
category !== "official" &&
codexTakeoverEnabled &&
localCodexApiFormat === "openai_chat"
? normalizeCodexChatReasoningForSave(codexChatReasoning)
: undefined,
@@ -1610,6 +1627,8 @@ function ProviderFormFull({
codexApiFormatFromWireApi(extractCodexWireApi(template.config)) ??
"openai_responses",
);
// 自定义模板无模型映射,路由默认关闭
setCodexTakeoverEnabled(false);
}
if (appId === "gemini") {
resetGeminiConfig({}, {});
@@ -1652,6 +1671,8 @@ function ProviderFormFull({
codexApiFormatFromWireApi(extractCodexWireApi(config)) ??
"openai_responses",
);
// 路由开关与格式无关,仅按预设是否带模型映射决定
setCodexTakeoverEnabled((preset.modelCatalog?.length ?? 0) > 0);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
@@ -2124,6 +2145,8 @@ function ProviderFormFull({
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
takeoverEnabled={codexTakeoverEnabled}
onTakeoverEnabledChange={setCodexTakeoverEnabled}
apiFormat={localCodexApiFormat}
onApiFormatChange={handleCodexApiFormatChange}
codexChatReasoning={codexChatReasoning}