feat(codex): support native Responses direct-connect with generated model catalog

Codex providers can now run in two modes per provider:

- Proxy-Chat (route takeover): apiFormat=openai_chat, existing Responses<->Chat conversion. Unchanged.

- Native-Responses (direct): apiFormat=openai_responses, no proxy. cc-switch generates ~/.codex/cc-switch-model-catalog.json so Codex shows the custom models and tools work without the freeform apply_patch (type=custom) tool that native gateways like MiMo reject; editing falls back to shell_command.

Catalog generation is keyed on apiFormat (CodexCatalogToolProfile), decoupled from the takeover toggle, so native providers persist a catalog without enabling local route mapping.

Codex's catalog parser requires base_instructions on every entry; the native template carries a neutral default and per-vendor official text overrides it (MiMo, MiniMax). Synthesized catalogs for Qwen/Doubao/LongCat use the neutral default.

Existing native providers must be re-saved once to regenerate a valid catalog (no DB migration).
This commit is contained in:
Jason
2026-06-27 23:12:30 +08:00
parent 61d7ac01fb
commit 15e712e779
11 changed files with 692 additions and 154 deletions
+131 -121
View File
@@ -107,6 +107,15 @@ function createCatalogRow(seed?: Partial<CodexCatalogModel>): CodexCatalogRow {
model: seed?.model ?? "",
displayName: seed?.displayName ?? "",
contextWindow: seed?.contextWindow ?? "",
// Carry native-profile overrides verbatim (not user-editable in the row UI,
// but must survive load->save so the official catalog fidelity is kept).
...(seed?.supportsParallelToolCalls !== undefined
? { supportsParallelToolCalls: seed.supportsParallelToolCalls }
: {}),
...(seed?.inputModalities ? { inputModalities: seed.inputModalities } : {}),
...(seed?.baseInstructions
? { baseInstructions: seed.baseInstructions }
: {}),
};
}
@@ -571,148 +580,149 @@ export function CodexFormFields({
</div>
</div>
{/* 模型映射 —— 仅在本地路由开启 + 可编辑时显示(与上游格式解耦,
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">
<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: "菜单显示名",
{/* 模型映射 / 模型目录 —— 接管开启(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: "模型映射",
})}
</span>
<span>
{t("codexConfig.catalogColumnModel", {
defaultValue: "实际请求模型",
})}
</span>
<span>
{t("codexConfig.catalogColumnContext", {
defaultValue: "上下文窗口",
})}
</span>
<span />
</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.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", {
{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: "菜单显示名",
})}
/>
<div className="flex gap-1">
</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.model}
value={row.displayName ?? ""}
onChange={(event) =>
handleUpdateCatalogRow(index, {
model: event.target.value,
displayName: event.target.value,
})
}
placeholder={t(
"codexConfig.catalogModelPlaceholder",
"codexConfig.catalogDisplayNamePlaceholder",
{
defaultValue: "例如: deepseek-v4-flash",
defaultValue: "例如: DeepSeek V4 Flash",
},
)}
aria-label={t("codexConfig.catalogColumnModel", {
defaultValue: "实际请求模型",
aria-label={t("codexConfig.catalogColumnDisplay", {
defaultValue: "菜单显示名",
})}
className="flex-1"
/>
{fetchedModels.length > 0 && (
<ModelDropdown
models={fetchedModels}
onSelect={(id) =>
<div className="flex gap-1">
<Input
value={row.model}
onChange={(event) =>
handleUpdateCatalogRow(index, {
model: id,
displayName: row.displayName?.trim()
? row.displayName
: id,
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>
<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>
)}
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
@@ -180,10 +180,24 @@ export const normalizeCodexCatalogModelsForSave = (
? Number.parseInt(rawContextWindow, 10)
: undefined;
const inputModalities = item.inputModalities?.filter(
(m) => typeof m === "string" && m.trim(),
);
const baseInstructions = item.baseInstructions?.trim();
normalized.push({
model,
...(displayName ? { displayName } : {}),
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
// Native Responses profile overrides (ignored by the chat/proxy profile).
...(typeof item.supportsParallelToolCalls === "boolean"
? { supportsParallelToolCalls: item.supportsParallelToolCalls }
: {}),
...(inputModalities && inputModalities.length > 0
? { inputModalities }
: {}),
...(baseInstructions ? { baseInstructions } : {}),
});
}
@@ -599,7 +613,12 @@ function ProviderFormFull({
// 接管已开)。只在 useState 初始化与预设重置点设置,跟 localCodexApiFormat
// 对称,避免漂移。
const [codexTakeoverEnabled, setCodexTakeoverEnabled] = useState<boolean>(
() => codexCatalogCountFromSettings(initialData?.settingsConfig) > 0,
() =>
// 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 } =
@@ -1277,8 +1296,13 @@ 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.
const normalizedCatalogModels =
category !== "official" && codexTakeoverEnabled
category !== "official" &&
(codexTakeoverEnabled || localCodexApiFormat === "openai_responses")
? normalizeCodexCatalogModelsForSave(codexCatalogModels)
: [];
// Sync first catalog row's model into config.toml so Codex uses it as default
@@ -1671,8 +1695,12 @@ function ProviderFormFull({
codexApiFormatFromWireApi(extractCodexWireApi(config)) ??
"openai_responses",
);
// 路由开关与格式无关,仅按预设是否带模型映射决定
setCodexTakeoverEnabled((preset.modelCatalog?.length ?? 0) > 0);
// 接管开关:原生(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,