mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
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:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user