diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index f2735f8ce..d6c591614 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -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 { 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, + /// Per-row override for the native template's `input_modalities` + /// (e.g. `["text","image"]`). Only consulted for `NativeResponses`. + input_modalities: Option>, + /// 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, } fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec { @@ -386,10 +461,38 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> 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 { } } +/// 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 { // ① 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 { ))) } -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 = 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, 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 { 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 = 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 { 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" diff --git a/src-tauri/src/resources/codex_native_responses_template.json b/src-tauri/src/resources/codex_native_responses_template.json new file mode 100644 index 000000000..a513fe1db --- /dev/null +++ b/src-tauri/src/resources/codex_native_responses_template.json @@ -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 +} diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index bb0f19190..93565d28f 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -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 结构,在新架构中为空 diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 945a7cf5f..b4af9fc01 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -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 => { diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 7327073f6..aa06bf459 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -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() diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index e165fb9b0..5cb779fe4 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -107,6 +107,15 @@ function createCatalogRow(seed?: Partial): 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({ - {/* 模型映射 —— 仅在本地路由开启 + 可编辑时显示(与上游格式解耦, - Responses 原生供应商同样可配置);上方恒有 UA 字段,分隔线无需条件 */} - {takeoverEnabled && canEditCatalog && ( -
-
-
- - {t("codexConfig.modelMappingTitle", { - defaultValue: "模型映射", - })} - - {renderCatalogActionButtons( - handleAddCatalogRow, - t("codexConfig.addCatalogModel", { - defaultValue: "添加模型", - }), - )} -
-

- {t("codexConfig.modelMappingHint", { - defaultValue: - "选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。", - })} -

-
- - {catalogRows.length > 0 && ( -
- {/* 列头:md+ 显示 */} -
- - {t("codexConfig.catalogColumnDisplay", { - defaultValue: "菜单显示名", + {/* 模型映射 / 模型目录 —— 接管开启(Chat 模式生成兼容路由)或 + 原生 Responses 模式(生成 model-catalogs.json)时显示;可编辑即渲染。 */} + {(takeoverEnabled || apiFormat === "openai_responses") && + canEditCatalog && ( +
+
+
+ + {t("codexConfig.modelMappingTitle", { + defaultValue: "模型映射", })} - - - {t("codexConfig.catalogColumnModel", { - defaultValue: "实际请求模型", - })} - - - {t("codexConfig.catalogColumnContext", { - defaultValue: "上下文窗口", - })} - - + + {renderCatalogActionButtons( + handleAddCatalogRow, + t("codexConfig.addCatalogModel", { + defaultValue: "添加模型", + }), + )}
+

+ {t("codexConfig.modelMappingHint", { + defaultValue: + "选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。", + })} +

+
- {catalogRows.map((row, index) => ( -
- - handleUpdateCatalogRow(index, { - displayName: event.target.value, - }) - } - placeholder={t( - "codexConfig.catalogDisplayNamePlaceholder", - { - defaultValue: "例如: DeepSeek V4 Flash", - }, - )} - aria-label={t("codexConfig.catalogColumnDisplay", { + {catalogRows.length > 0 && ( +
+ {/* 列头:md+ 显示 */} +
+ + {t("codexConfig.catalogColumnDisplay", { defaultValue: "菜单显示名", })} - /> -
+ + + {t("codexConfig.catalogColumnModel", { + defaultValue: "实际请求模型", + })} + + + {t("codexConfig.catalogColumnContext", { + defaultValue: "上下文窗口", + })} + + +
+ + {catalogRows.map((row, index) => ( +
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 && ( - +
+ 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 && ( + + handleUpdateCatalogRow(index, { + model: id, + displayName: row.displayName?.trim() + ? row.displayName + : id, + }) + } + /> + )} +
+ + handleUpdateCatalogRow(index, { + contextWindow: event.target.value.replace( + /[^\d]/g, + "", + ), + }) + } + placeholder={t( + "codexConfig.contextWindowPlaceholder", + { + defaultValue: "例如: 128000", + }, + )} + aria-label={t("codexConfig.catalogColumnContext", { + defaultValue: "上下文窗口", + })} + /> +
- - handleUpdateCatalogRow(index, { - contextWindow: event.target.value.replace( - /[^\d]/g, - "", - ), - }) - } - placeholder={t( - "codexConfig.contextWindowPlaceholder", - { - defaultValue: "例如: 128000", - }, - )} - aria-label={t("codexConfig.catalogColumnContext", { - defaultValue: "上下文窗口", - })} - /> - -
- ))} -
- )} -
- )} + ))} +
+ )} +
+ )} )} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index f28545385..c588f41ce 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -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( - () => 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, diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 01ef91039..0ab8a21c6 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -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 catalog(platform.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 catalog(platform.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 API(wire_api=responses 对自家 base_url),无需路由接管转换 apiFormat: "openai_responses", + // 官方 Codex catalog(mimo.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 API(wire_api=responses 对自家 base_url),无需路由接管转换 apiFormat: "openai_responses", + // 官方 Codex catalog(mimo.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", diff --git a/src/types.ts b/src/types.ts index a96d79f3b..7bbeceeeb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 认证字段类型 diff --git a/tests/components/ProviderForm.codexCatalog.test.ts b/tests/components/ProviderForm.codexCatalog.test.ts index 9154a6686..4c2f064fe 100644 --- a/tests/components/ProviderForm.codexCatalog.test.ts +++ b/tests/components/ProviderForm.codexCatalog.test.ts @@ -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 }, + ]); + }); }); diff --git a/tests/config/codexChatProviderPresets.test.ts b/tests/config/codexChatProviderPresets.test.ts index 84f15c086..4f4c4fa2c 100644 --- a/tests/config/codexChatProviderPresets.test.ts +++ b/tests/config/codexChatProviderPresets.test.ts @@ -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 预设现在带 modelCatalog:cc-switch 直连时据此生成 + // ~/.codex 的 model-catalogs.json(shell_command 编辑、不发 freeform + // apply_patch)。带 catalog 不再强制开“本地路由映射”——前端已按 + // apiFormat 解耦(openai_responses 默认不开接管)。 + expect((preset?.modelCatalog ?? []).length).toBeGreaterThan(0); + // 原生(直连)不走 Chat 转换,因此不需要 codexChatReasoning。 expect(preset?.codexChatReasoning).toBeUndefined(); } });