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
+299 -24
View File
@@ -15,6 +15,33 @@ pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom";
pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json";
const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5";
/// Which Codex tool surface the generated model catalog should target.
///
/// - `ProxyChat`: cc-switch's proxy takes over and converts Responses<->Chat,
/// so the catalog keeps Codex's default tool set (incl. the freeform
/// `apply_patch` custom tool, which the proxy rewrites to a function tool).
/// - `NativeResponses`: Codex talks directly to a provider's native
/// `/responses` endpoint (no proxy). Such gateways (e.g. Xiaomi MiMo,
/// MiniMax) reject `type=="custom"` tools, so the catalog must suppress the
/// freeform `apply_patch` and rely on `shell_type="shell_command"` for edits.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexCatalogToolProfile {
ProxyChat,
NativeResponses,
}
impl CodexCatalogToolProfile {
/// Pick the catalog tool profile from a provider's `apiFormat` meta value.
/// Native (direct) Responses providers must suppress the custom apply_patch
/// tool; everything else keeps the proxy-chat behavior.
pub fn from_api_format(api_format: Option<&str>) -> Self {
match api_format {
Some("openai_responses") => CodexCatalogToolProfile::NativeResponses,
_ => CodexCatalogToolProfile::ProxyChat,
}
}
}
/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
/// removed provider aliases.
@@ -313,27 +340,63 @@ fn extract_codex_top_level_u64(config_text: &str, field: &str) -> Option<u64> {
fn codex_catalog_model_entry(
template: &Value,
model: &str,
display_name: &str,
context_window: u64,
spec: &CodexCatalogModelSpec,
priority: usize,
profile: CodexCatalogToolProfile,
) -> Value {
let mut entry = template.clone();
let Some(entry_obj) = entry.as_object_mut() else {
return json!({});
};
entry_obj.insert("slug".to_string(), json!(model));
entry_obj.insert("display_name".to_string(), json!(display_name));
entry_obj.insert("description".to_string(), json!(display_name));
entry_obj.insert("context_window".to_string(), json!(context_window));
entry_obj.insert("max_context_window".to_string(), json!(context_window));
entry_obj.insert("slug".to_string(), json!(spec.model));
entry_obj.insert("display_name".to_string(), json!(spec.display_name));
entry_obj.insert("description".to_string(), json!(spec.display_name));
entry_obj.insert("context_window".to_string(), json!(spec.context_window));
entry_obj.insert("max_context_window".to_string(), json!(spec.context_window));
entry_obj.insert("priority".to_string(), json!(1000 + priority));
entry_obj.insert("additional_speed_tiers".to_string(), json!([]));
entry_obj.insert("service_tiers".to_string(), json!([]));
entry_obj.insert("availability_nux".to_string(), Value::Null);
entry_obj.insert("upgrade".to_string(), Value::Null);
if profile == CodexCatalogToolProfile::NativeResponses {
// Native `/responses` gateways reject Codex's freeform `apply_patch`
// (type=="custom") tool. Strip any key that would make Codex emit a
// custom/freeform tool, and rely on shell_type="shell_command" for
// edits. Defensive even though the native template is already clean
// (guards against template drift / an accidental gpt-5.5 clone).
//
// NOTE: `base_instructions` is NOT stripped — Codex's catalog parser
// treats it as a REQUIRED field and refuses to load the file without
// it ("missing field `base_instructions`"). The template carries a
// neutral identity default; per-vendor official text overrides below.
for key in [
"apply_patch_tool_type",
"web_search_tool_type",
"tools",
"model_messages",
] {
entry_obj.remove(key);
}
entry_obj.insert("shell_type".to_string(), json!("shell_command"));
if let Some(base_instructions) = spec
.base_instructions
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
entry_obj.insert("base_instructions".to_string(), json!(base_instructions));
}
if let Some(parallel) = spec.supports_parallel_tool_calls {
entry_obj.insert("supports_parallel_tool_calls".to_string(), json!(parallel));
}
if let Some(modalities) = &spec.input_modalities {
entry_obj.insert("input_modalities".to_string(), json!(modalities));
}
}
entry
}
@@ -342,6 +405,18 @@ struct CodexCatalogModelSpec {
model: String,
display_name: String,
context_window: u64,
/// Per-row override for the native template's `supports_parallel_tool_calls`
/// (e.g. MiniMax=true, MiMo=false). Only consulted for `NativeResponses`.
supports_parallel_tool_calls: Option<bool>,
/// Per-row override for the native template's `input_modalities`
/// (e.g. `["text","image"]`). Only consulted for `NativeResponses`.
input_modalities: Option<Vec<String>>,
/// Per-row override for the native template's `base_instructions` (the
/// model identity / system preamble). Carries each vendor's OFFICIAL value
/// (e.g. MiMo "developed by Xiaomi", MiniMax "based on MiniMax-M3"); falls
/// back to the template default when absent. Only consulted for
/// `NativeResponses`.
base_instructions: Option<String>,
}
fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCatalogModelSpec> {
@@ -386,10 +461,38 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
)
.unwrap_or(default_context_window);
let supports_parallel_tool_calls = model_config
.get("supportsParallelToolCalls")
.or_else(|| model_config.get("supports_parallel_tool_calls"))
.and_then(|value| value.as_bool());
let input_modalities = model_config
.get("inputModalities")
.or_else(|| model_config.get("input_modalities"))
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str())
.map(str::to_string)
.collect::<Vec<_>>()
})
.filter(|items| !items.is_empty());
let base_instructions = model_config
.get("baseInstructions")
.or_else(|| model_config.get("base_instructions"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string);
specs.push(CodexCatalogModelSpec {
model: model.to_string(),
display_name: display_name.to_string(),
context_window,
supports_parallel_tool_calls,
input_modalities,
base_instructions,
});
}
@@ -628,6 +731,18 @@ fn load_codex_model_template_static() -> Option<Value> {
}
}
/// Bundled clean template for native `/responses` providers. Unlike the
/// gpt-5.5 template it carries NO freeform `apply_patch` / `web_search` tool
/// declarations and no GPT-5 base_instructions, so Codex never emits a
/// `type=="custom"` tool that native gateways (MiMo/MiniMax/…) reject. Edits
/// flow through `shell_type="shell_command"` instead. We deliberately do NOT
/// fall back to `models_cache.json` here (that would reintroduce gpt-5.5's
/// freeform apply_patch).
fn load_codex_native_responses_template() -> Value {
let text = include_str!("resources/codex_native_responses_template.json");
serde_json::from_str(text).expect("bundled codex native responses template must be valid JSON")
}
fn load_codex_model_catalog_template() -> Result<Value, AppError> {
// ① models_cache.json (created by Codex when it connects to OpenAI)
if let Some(template) = load_codex_model_template_from_cache()? {
@@ -647,19 +762,15 @@ fn load_codex_model_catalog_template() -> Result<Value, AppError> {
)))
}
fn codex_model_catalog_from_specs(specs: &[CodexCatalogModelSpec], template: &Value) -> Value {
fn codex_model_catalog_from_specs(
specs: &[CodexCatalogModelSpec],
template: &Value,
profile: CodexCatalogToolProfile,
) -> Value {
let entries: Vec<Value> = specs
.iter()
.enumerate()
.map(|(index, spec)| {
codex_catalog_model_entry(
template,
&spec.model,
&spec.display_name,
spec.context_window,
index,
)
})
.map(|(index, spec)| codex_catalog_model_entry(template, spec, index, profile))
.collect();
json!({ "models": entries })
@@ -668,14 +779,23 @@ fn codex_model_catalog_from_specs(specs: &[CodexCatalogModelSpec], template: &Va
fn codex_model_catalog_from_settings(
settings: &Value,
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Result<Option<Value>, AppError> {
let specs = codex_catalog_model_specs(settings, config_text);
if specs.is_empty() {
return Ok(None);
}
let template = load_codex_model_catalog_template()?;
Ok(Some(codex_model_catalog_from_specs(&specs, &template)))
// Native providers use the bundled clean template (no freeform apply_patch,
// no cache dependency); proxy-chat providers keep cloning Codex's gpt-5.5
// entry so the proxy can rewrite custom<->function tools as before.
let template = match profile {
CodexCatalogToolProfile::NativeResponses => load_codex_native_responses_template(),
CodexCatalogToolProfile::ProxyChat => load_codex_model_catalog_template()?,
};
Ok(Some(codex_model_catalog_from_specs(
&specs, &template, profile,
)))
}
fn set_codex_model_catalog_json_field(
@@ -713,10 +833,11 @@ fn set_codex_model_catalog_json_field(
pub fn prepare_codex_config_text_with_model_catalog(
settings: &Value,
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Result<String, AppError> {
let catalog_path = get_codex_model_catalog_path();
if let Some(catalog) = codex_model_catalog_from_settings(settings, config_text)? {
if let Some(catalog) = codex_model_catalog_from_settings(settings, config_text, profile)? {
let config_text = set_codex_model_catalog_json_field(config_text, Some(&catalog_path))?;
write_json_file(&catalog_path, &catalog)?;
Ok(config_text)
@@ -839,6 +960,26 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) ->
obj.insert("contextWindow".to_string(), json!(context_window));
}
// Preserve native-profile per-row overrides so a DB-SSOT-missing
// fallback round-trip doesn't silently drop them (they are ignored by
// the ProxyChat profile, so carrying them is harmless).
if let Some(parallel) = entry
.get("supports_parallel_tool_calls")
.and_then(|v| v.as_bool())
{
obj.insert("supportsParallelToolCalls".to_string(), json!(parallel));
}
if let Some(modalities) = entry.get("input_modalities").and_then(|v| v.as_array()) {
let mods: Vec<String> = modalities
.iter()
.filter_map(|m| m.as_str())
.map(str::to_string)
.collect();
if !mods.is_empty() {
obj.insert("inputModalities".to_string(), json!(mods));
}
}
entries.push(Value::Object(obj));
}
@@ -876,9 +1017,10 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) ->
pub fn prepare_codex_live_config_text_with_optional_catalog(
settings: &Value,
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Result<String, AppError> {
if settings.get("modelCatalog").is_some() {
prepare_codex_config_text_with_model_catalog(settings, config_text)
prepare_codex_config_text_with_model_catalog(settings, config_text, profile)
} else {
Ok(config_text.to_string())
}
@@ -889,9 +1031,10 @@ pub fn write_codex_provider_live_with_catalog(
category: Option<&str>,
auth: &Value,
config_text: Option<&str>,
profile: CodexCatalogToolProfile,
) -> Result<(), AppError> {
let prepared_config = config_text
.map(|text| prepare_codex_config_text_with_model_catalog(settings, text))
.map(|text| prepare_codex_config_text_with_model_catalog(settings, text, profile))
.transpose()?;
write_codex_live_for_provider(category, auth, prepared_config.as_deref())
@@ -2070,7 +2213,8 @@ base_url = "https://production.api/v1"
}
});
let specs = codex_catalog_model_specs(&settings, r#"model_context_window = 128000"#);
let catalog = codex_model_catalog_from_specs(&specs, &template);
let catalog =
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
let models = catalog
.get("models")
.and_then(|value| value.as_array())
@@ -2121,6 +2265,137 @@ base_url = "https://production.api/v1"
);
}
#[test]
fn native_responses_profile_suppresses_apply_patch_and_keeps_shell() {
// Native (direct) /responses providers must NOT emit a freeform
// apply_patch (type=="custom") tool — gateways like MiMo reject it.
// The native profile uses the bundled clean template and relies on
// shell_type="shell_command" for edits, plus per-row overrides.
let settings = json!({
"modelCatalog": {
"models": [
{
"model": "MiniMax-M3",
"displayName": "MiniMax-M3",
"contextWindow": 1_000_000,
"supportsParallelToolCalls": true,
"inputModalities": ["text", "image"],
"baseInstructions": "You are Codex, a coding agent based on MiniMax-M3."
}
]
}
});
let catalog = codex_model_catalog_from_settings(
&settings,
"",
CodexCatalogToolProfile::NativeResponses,
)
.expect("native catalog generation should not error")
.expect("non-empty modelCatalog must yield a catalog");
let entry = &catalog["models"][0];
assert_eq!(
entry.get("slug").and_then(|v| v.as_str()),
Some("MiniMax-M3")
);
assert_eq!(
entry.get("shell_type").and_then(|v| v.as_str()),
Some("shell_command"),
"native entries edit via shell, not the custom apply_patch tool"
);
assert!(
entry.get("apply_patch_tool_type").is_none(),
"native entries must NOT declare a freeform apply_patch tool"
);
// `base_instructions` is REQUIRED by Codex's catalog parser, so it must
// be present — and the per-row official override must win over the
// template default.
assert_eq!(
entry.get("base_instructions").and_then(|v| v.as_str()),
Some("You are Codex, a coding agent based on MiniMax-M3."),
"per-row baseInstructions override must apply (and field must exist)"
);
assert!(
entry.get("model_messages").is_none(),
"native entries must not carry the gpt-5.5 model_messages persona text"
);
assert_eq!(
entry.get("supports_parallel_tool_calls"),
Some(&json!(true)),
"per-row supportsParallelToolCalls override must apply"
);
assert_eq!(
entry.get("input_modalities"),
Some(&json!(["text", "image"])),
"per-row inputModalities override must apply"
);
assert_eq!(
entry.get("context_window").and_then(|v| v.as_u64()),
Some(1_000_000)
);
}
#[test]
fn native_responses_catalog_always_carries_base_instructions() {
// Regression guard for the "missing field `base_instructions`" parse
// error: Codex refuses to load a model catalog whose entries lack
// base_instructions. Synthesized presets carry no per-row override, so
// the entry MUST inherit the template's neutral default rather than
// dropping the field entirely.
let settings = json!({
"modelCatalog": { "models": [{ "model": "qwen3-coder-plus" }] }
});
let catalog = codex_model_catalog_from_settings(
&settings,
"",
CodexCatalogToolProfile::NativeResponses,
)
.expect("native catalog generation should not error")
.expect("non-empty modelCatalog must yield a catalog");
let base = catalog["models"][0]
.get("base_instructions")
.and_then(|v| v.as_str());
assert!(
base.is_some_and(|s| !s.trim().is_empty()),
"every native entry must carry a non-empty base_instructions (Codex requires it)"
);
}
#[test]
fn proxy_chat_profile_still_keeps_apply_patch() {
// Regression guard for Mode A: the proxy-chat profile must keep the
// freeform apply_patch tool (the proxy rewrites custom<->function).
let template = load_codex_native_responses_template();
let specs = vec![CodexCatalogModelSpec {
model: "x".to_string(),
display_name: "x".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
}];
// Using a gpt-5.5-shaped template under ProxyChat must NOT strip
// apply_patch_tool_type. (The native template lacks it, so synthesize
// one with the field present to prove ProxyChat leaves it intact.)
let mut proxy_template = template.clone();
proxy_template["apply_patch_tool_type"] = json!("freeform");
let catalog = codex_model_catalog_from_specs(
&specs,
&proxy_template,
CodexCatalogToolProfile::ProxyChat,
);
assert_eq!(
catalog["models"][0]
.get("apply_patch_tool_type")
.and_then(|v| v.as_str()),
Some("freeform"),
"ProxyChat must preserve apply_patch_tool_type (no native stripping)"
);
}
#[test]
fn model_catalog_json_field_writes_relative_filename() {
let input = r#"model_provider = "any"
@@ -0,0 +1,38 @@
{
"slug": "native-responses-template",
"display_name": "native-responses-template",
"description": "native-responses-template",
"base_instructions": "You are Codex, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals.",
"default_reasoning_level": "high",
"supported_reasoning_levels": [
{
"effort": "none",
"description": "Disable Thinking"
},
{
"effort": "high",
"description": "Enabled Thinking"
}
],
"shell_type": "shell_command",
"visibility": "list",
"supported_in_api": true,
"priority": 0,
"supports_reasoning_summaries": true,
"default_reasoning_summary": "none",
"support_verbosity": false,
"truncation_policy": {
"mode": "bytes",
"limit": 10000
},
"supports_parallel_tool_calls": false,
"supports_image_detail_original": false,
"context_window": 262144,
"max_context_window": 262144,
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": [
"text"
],
"supports_search_tool": false
}
+5
View File
@@ -159,11 +159,16 @@ impl ConfigService {
}
let cfg_text = settings.get("config").and_then(Value::as_str);
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
crate::codex_config::write_codex_provider_live_with_catalog(
&provider.settings_config,
provider.category.as_deref(),
auth,
cfg_text,
profile,
)?;
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
+8
View File
@@ -760,11 +760,19 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let config_str = obj.get("config").and_then(|v| v.as_str());
// Native (direct) Responses providers must suppress Codex's freeform
// apply_patch custom tool via the generated catalog; chat/proxy
// providers keep the default tool set. Keyed on provider.meta.apiFormat.
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
crate::codex_config::write_codex_provider_live_with_catalog(
&provider.settings_config,
provider.category.as_deref(),
auth,
config_str,
profile,
)?;
}
AppType::Gemini => {
+21 -2
View File
@@ -2145,12 +2145,16 @@ impl ProxyService {
.get("auth")
.ok_or_else(|| "Codex 供应商缺少 auth 配置".to_string())?;
let config_str = effective_settings.get("config").and_then(|v| v.as_str());
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
crate::codex_config::write_codex_provider_live_with_catalog(
&effective_settings,
provider.category.as_deref(),
auth,
config_str,
profile,
)
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
}
@@ -2410,12 +2414,16 @@ impl ProxyService {
.get("auth")
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
let config_str = config.get("config").and_then(|v| v.as_str());
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
crate::codex_config::write_codex_provider_live_with_catalog(
config,
provider.category.as_deref(),
auth,
config_str,
profile,
)
.map_err(|e| format!("写入 Codex 配置失败: {e}"))
}
@@ -2435,9 +2443,12 @@ impl ProxyService {
.filter(|auth| Self::codex_auth_has_proxy_placeholder(auth))
{
let config_str = config.get("config").and_then(|v| v.as_str()).unwrap_or("");
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.and_then(|p| p.meta.as_ref()?.api_format.as_deref()),
);
let prepared_config =
crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(
config, config_str,
config, config_str, profile,
)
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
let live_config =
@@ -2471,10 +2482,18 @@ impl ProxyService {
// living in the config's `experimental_bearer_token`). Computing it up here
// keeps every config-writing branch — write-auth, delete-auth, no-auth —
// consistent instead of letting the empty-auth path skip projection.
// Verbatim restore has no Provider in hand (we only have the stored
// backup config), so the catalog tool profile can't be recovered here.
// Default to ProxyChat: a restored native-direct backup keeps its inline
// modelCatalog but would not get apply_patch re-stripped until the next
// provider switch rewrites it via write_live_snapshot. Acceptable known
// limitation (restore-of-deleted-provider-backup only).
let prepared_cfg = config_str
.map(|cfg| {
crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(
config, cfg,
config,
cfg,
crate::codex_config::CodexCatalogToolProfile::ProxyChat,
)
})
.transpose()
+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,
+109 -1
View File
@@ -71,7 +71,20 @@ requires_openai_auth = true`;
function modelCatalog(
models: Array<
string | { model: string; displayName?: string; contextWindow?: number }
| string
| {
model: string;
displayName?: string;
contextWindow?: number;
// Native Responses (direct) overrides for the generated
// model-catalogs.json; omit to inherit the native template defaults
// (supports_parallel_tool_calls=false, input_modalities=["text"]).
supportsParallelToolCalls?: boolean;
inputModalities?: string[];
// Vendor's OFFICIAL base_instructions; omit to inherit the neutral
// template default. Required by Codex, so the backend always emits one.
baseInstructions?: string;
}
>,
): CodexCatalogModel[] {
return models.map((entry) =>
@@ -81,6 +94,9 @@ function modelCatalog(
model: entry.model,
displayName: entry.displayName,
contextWindow: entry.contextWindow,
supportsParallelToolCalls: entry.supportsParallelToolCalls,
inputModalities: entry.inputModalities,
baseInstructions: entry.baseInstructions,
},
);
}
@@ -204,6 +220,15 @@ export const codexProviderPresets: CodexProviderPreset[] = [
endpointCandidates: ["https://ark.cn-beijing.volces.com/api/v3"],
// 火山方舟主数据面 /api/v3 原生支持 Responses API/api/v3/responses),无需路由接管转换
apiFormat: "openai_responses",
// 无官方 catalog:合成 MiMo 式(shell_command 编辑、不发 freeform apply_patch),
// 让 Codex 直连显示模型并避免 custom 工具被网关拒绝
modelCatalog: modelCatalog([
{
model: "doubao-seed-2-1-pro",
displayName: "Doubao Seed 2.1 Pro",
contextWindow: 262144,
},
]),
category: "cn_official",
isPartner: true,
partnerPromotionKey: "doubaoseed",
@@ -411,6 +436,14 @@ requires_openai_auth = true`,
endpointCandidates: ["https://dashscope.aliyuncs.com/compatible-mode/v1"],
// 阿里百炼 DashScope 原生支持 OpenAI Responses API/compatible-mode/v1/responses,同一 base_url),无需路由接管转换
apiFormat: "openai_responses",
// 无官方 catalog:合成 MiMo 式(shell_command 编辑、不发 freeform apply_patch
modelCatalog: modelCatalog([
{
model: "qwen3-coder-plus",
displayName: "Qwen3 Coder Plus",
contextWindow: 1048576,
},
]),
category: "cn_official",
icon: "bailian",
iconColor: "#624AFF",
@@ -586,6 +619,15 @@ requires_openai_auth = true`,
endpointCandidates: ["https://api.longcat.chat/openai/v1"],
// 美团 LongCat 官方 Codex 文档用 wire_api=responses 对自家 base_url,原生 Responses,无需路由接管转换
apiFormat: "openai_responses",
// 无官方 catalog:合成 MiMo 式(shell_command 编辑、不发 freeform apply_patch)。
// 注:LongCat 的 /responses 工具类型契约文档化程度最低,建议真机冒烟一次
modelCatalog: modelCatalog([
{
model: "LongCat-2.0-Preview",
displayName: "LongCat 2.0 Preview",
contextWindow: 131072,
},
]),
category: "cn_official",
icon: "longcat",
iconColor: "#29E154",
@@ -603,6 +645,19 @@ requires_openai_auth = true`,
endpointCandidates: ["https://api.minimaxi.com/v1"],
// MiniMax 官方 API 参考已列 /v1/responses 为正式端点(CN/intl 双区,POST /v1/responses),原生 Responses,无需路由接管转换
apiFormat: "openai_responses",
// 官方 Codex catalogplatform.minimaxi.com/docs/token-plan/codex-cli):
// shell_command 编辑、并行工具、文本+图像,不声明 freeform apply_patch
modelCatalog: modelCatalog([
{
model: "MiniMax-M3",
displayName: "MiniMax-M3",
contextWindow: 1000000,
supportsParallelToolCalls: true,
inputModalities: ["text", "image"],
baseInstructions:
"You are Codex, a coding agent based on MiniMax-M3. You and the user share the same workspace and collaborate to achieve the user's goals.",
},
]),
category: "cn_official",
partnerPromotionKey: "minimax_cn",
theme: {
@@ -625,6 +680,19 @@ requires_openai_auth = true`,
endpointCandidates: ["https://api.minimax.io/v1"],
// MiniMax 官方 API 参考已列 /v1/responses 为正式端点(CN/intl 双区,POST /v1/responses),原生 Responses,无需路由接管转换
apiFormat: "openai_responses",
// 官方 Codex catalogplatform.minimax.io/docs/token-plan/codex):
// shell_command 编辑、并行工具、文本+图像,不声明 freeform apply_patch
modelCatalog: modelCatalog([
{
model: "MiniMax-M3",
displayName: "MiniMax-M3",
contextWindow: 1000000,
supportsParallelToolCalls: true,
inputModalities: ["text", "image"],
baseInstructions:
"You are Codex, a coding agent based on MiniMax-M3. You and the user share the same workspace and collaborate to achieve the user's goals.",
},
]),
category: "cn_official",
partnerPromotionKey: "minimax_en",
theme: {
@@ -668,6 +736,26 @@ requires_openai_auth = true`,
endpointCandidates: ["https://api.xiaomimimo.com/v1"],
// 小米 MiMo 官方 Codex 文档已声明原生支持 Responses APIwire_api=responses 对自家 base_url),无需路由接管转换
apiFormat: "openai_responses",
// 官方 Codex catalogmimo.mi.com/.../codex-configuration):
// shell_command 编辑、不声明 freeform apply_patch
modelCatalog: modelCatalog([
{
model: "mimo-v2.5-pro",
displayName: "MiMo V2.5 Pro",
contextWindow: 1048576,
inputModalities: ["text"],
baseInstructions:
"You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.",
},
{
model: "mimo-v2.5",
displayName: "MiMo V2.5",
contextWindow: 1048576,
inputModalities: ["text", "image"],
baseInstructions:
"You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.",
},
]),
category: "cn_official",
icon: "xiaomimimo",
iconColor: "#000000",
@@ -685,6 +773,26 @@ requires_openai_auth = true`,
endpointCandidates: ["https://token-plan-cn.xiaomimimo.com/v1"],
// 小米 MiMo 官方 Codex 文档已声明原生支持 Responses APIwire_api=responses 对自家 base_url),无需路由接管转换
apiFormat: "openai_responses",
// 官方 Codex catalogmimo.mi.com/.../codex-configuration):
// shell_command 编辑、不声明 freeform apply_patch
modelCatalog: modelCatalog([
{
model: "mimo-v2.5-pro",
displayName: "MiMo V2.5 Pro",
contextWindow: 1048576,
inputModalities: ["text"],
baseInstructions:
"You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.",
},
{
model: "mimo-v2.5",
displayName: "MiMo V2.5",
contextWindow: 1048576,
inputModalities: ["text", "image"],
baseInstructions:
"You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.",
},
]),
category: "cn_official",
icon: "xiaomimimo",
iconColor: "#000000",
+9
View File
@@ -258,6 +258,15 @@ export interface CodexCatalogModel {
model: string;
displayName?: string;
contextWindow?: string | number;
// Native Responses (direct) profile overrides for the generated
// model-catalogs.json. Ignored by the chat/proxy profile.
// e.g. MiniMax: supportsParallelToolCalls=true, inputModalities=["text","image"].
supportsParallelToolCalls?: boolean;
inputModalities?: string[];
// Vendor's OFFICIAL base_instructions (model identity / system preamble).
// Codex requires this field in every catalog entry; when omitted the backend
// falls back to a neutral default. e.g. MiMo "developed by Xiaomi".
baseInstructions?: string;
}
// Claude 认证字段类型
@@ -15,4 +15,38 @@ describe("ProviderForm Codex catalog helpers", () => {
{ model: "kimi-k2", contextWindow: 128000 },
]);
});
it("preserves native-profile overrides (parallel tool calls + input modalities + base instructions)", () => {
expect(
normalizeCodexCatalogModelsForSave([
{
model: "MiniMax-M3",
displayName: "MiniMax-M3",
contextWindow: 1000000,
supportsParallelToolCalls: true,
inputModalities: ["text", "image"],
baseInstructions:
" You are Codex, a coding agent based on MiniMax-M3. ",
},
// false must be preserved (not dropped as falsy); empty modalities dropped;
// empty/whitespace baseInstructions dropped
{
model: "mimo-v2.5-pro",
supportsParallelToolCalls: false,
inputModalities: [],
baseInstructions: " ",
},
]),
).toEqual([
{
model: "MiniMax-M3",
displayName: "MiniMax-M3",
contextWindow: 1000000,
supportsParallelToolCalls: true,
inputModalities: ["text", "image"],
baseInstructions: "You are Codex, a coding agent based on MiniMax-M3.",
},
{ model: "mimo-v2.5-pro", supportsParallelToolCalls: false },
]);
});
});
@@ -169,8 +169,12 @@ describe("Codex Chat provider presets", () => {
expect(preset, `${name} preset`).toBeDefined();
expect(preset?.apiFormat).toBe("openai_responses");
// 原生 Responses 预设必须不带 modelCatalog,否则“本地路由映射”开关会默认勾选
expect(preset?.modelCatalog ?? []).toHaveLength(0);
// 原生 Responses 预设现在带 modelCatalogcc-switch 直连时据此生成
// ~/.codex 的 model-catalogs.jsonshell_command 编辑、不发 freeform
// apply_patch)。带 catalog 不再强制开“本地路由映射”——前端已按
// apiFormat 解耦(openai_responses 默认不开接管)。
expect((preset?.modelCatalog ?? []).length).toBeGreaterThan(0);
// 原生(直连)不走 Chat 转换,因此不需要 codexChatReasoning。
expect(preset?.codexChatReasoning).toBeUndefined();
}
});