From f60a7c393a4ef8ae0722ae44c0e4d92ff47b3b36 Mon Sep 17 00:00:00 2001 From: Rat0323 <261020116+Rat0323@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:00:18 +0800 Subject: [PATCH 1/2] fix DeepSeek Responses compatibility after catalog takeover --- .../src-tauri/src/commands.rs | 89 +++- apps/codex-plus-manager/src/presets.test.ts | 13 + apps/codex-plus-manager/src/presets.ts | 6 +- assets/deepseek-model-metadata.json | 82 ++++ crates/codex-plus-core/src/model_suffix.rs | 50 ++- crates/codex-plus-core/src/relay_config.rs | 118 +++++- crates/codex-plus-core/tests/relay_config.rs | 391 ++++++++++++++++++ 7 files changed, 730 insertions(+), 19 deletions(-) create mode 100644 apps/codex-plus-manager/src/presets.test.ts create mode 100644 assets/deepseek-model-metadata.json diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index c70022a21..22c5b2701 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -3102,7 +3102,26 @@ pub fn save_relay_file(request: SaveRelayFileRequest) -> CommandResult contents, + Err(error) => { + return failed( + &format!("保存配置文件失败:{error}"), + relay_files_payload_from_home(&home).unwrap_or_else(|_| RelayFilesPayload { + config_path: home.join("config.toml").to_string_lossy().to_string(), + auth_path: home.join("auth.json").to_string_lossy().to_string(), + config_contents: String::new(), + auth_contents: String::new(), + }), + ); + } + }; + match save_relay_file_in_home(&home, &request.kind, &contents) .and_then(|_| relay_files_payload_from_home(&home)) { Ok(payload) => ok("配置文件已保存。", payload), @@ -4324,6 +4343,19 @@ fn save_relay_file_in_home( Ok(()) } +fn prepare_relay_file_contents( + kind: &str, + contents: &str, + profile: &RelayProfile, +) -> anyhow::Result { + if kind == "config" { + return codex_plus_core::relay_config::apply_deepseek_responses_compatibility( + profile, contents, + ); + } + Ok(contents.to_string()) +} + fn read_optional_text_file(path: &std::path::Path) -> anyhow::Result { match std::fs::read_to_string(path) { Ok(contents) => Ok(contents), @@ -5629,6 +5661,61 @@ mod tests { assert!(save_relay_file_in_home(temp.path(), "../bad", "").is_err()); } + #[test] + fn config_save_applies_official_deepseek_responses_compatibility() { + let profile = RelayProfile { + id: "custom-deepseek".to_string(), + base_url: "https://api.deepseek.com/".to_string(), + upstream_base_url: "https://api.deepseek.com/".to_string(), + protocol: codex_plus_core::settings::RelayProtocol::Responses, + ..RelayProfile::default() + }; + let config = r#"[features] +unified_exec = true +code_mode_only = true + +[features.code_mode] +enabled = true +"#; + + let prepared = prepare_relay_file_contents("config", config, &profile).unwrap(); + + assert!(prepared.contains("unified_exec = true")); + assert!(prepared.contains("code_mode_only = false")); + assert!(prepared.contains("enabled = false")); + } + + #[test] + fn config_save_preserves_third_party_responses_and_deepseek_chat_completions() { + let config = r#"[features] +code_mode_only = true + +[features.code_mode] +enabled = true +"#; + let third_party = RelayProfile { + base_url: "https://relay.example/v1".to_string(), + upstream_base_url: "https://relay.example/v1".to_string(), + protocol: codex_plus_core::settings::RelayProtocol::Responses, + ..RelayProfile::default() + }; + let chat_completions = RelayProfile { + base_url: "https://api.deepseek.com/".to_string(), + upstream_base_url: "https://api.deepseek.com/".to_string(), + protocol: codex_plus_core::settings::RelayProtocol::ChatCompletions, + ..RelayProfile::default() + }; + + assert_eq!( + prepare_relay_file_contents("config", config, &third_party).unwrap(), + config + ); + assert_eq!( + prepare_relay_file_contents("config", config, &chat_completions).unwrap(), + config + ); + } + #[test] fn normalize_settings_before_save_preserves_profile_context_until_manual_extract() { let settings = BackendSettings { diff --git a/apps/codex-plus-manager/src/presets.test.ts b/apps/codex-plus-manager/src/presets.test.ts new file mode 100644 index 000000000..33f59af90 --- /dev/null +++ b/apps/codex-plus-manager/src/presets.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PRESETS } from "./presets.ts"; + +test("DeepSeek preset uses the official Responses integration", () => { + const preset = PRESETS.find((candidate) => candidate.id === "deepseek"); + assert.ok(preset); + assert.equal(preset.baseUrl, "https://api.deepseek.com/"); + assert.equal(preset.protocol, "responses"); + assert.equal(preset.model, "deepseek-v4-flash"); + assert.deepEqual(preset.modelList, ["deepseek-v4-flash"]); +}); diff --git a/apps/codex-plus-manager/src/presets.ts b/apps/codex-plus-manager/src/presets.ts index 07b40e050..2178c25c9 100644 --- a/apps/codex-plus-manager/src/presets.ts +++ b/apps/codex-plus-manager/src/presets.ts @@ -50,10 +50,10 @@ export const PRESETS: ProviderPreset[] = [ websiteUrl: "https://platform.deepseek.com", apiKeyUrl: "https://platform.deepseek.com/api_keys", category: "cn_official", - baseUrl: "https://api.deepseek.com", - protocol: "chatCompletions", + baseUrl: "https://api.deepseek.com/", + protocol: "responses", model: "deepseek-v4-flash", - modelList: ["deepseek-v4-flash", "deepseek-v4-pro"], + modelList: ["deepseek-v4-flash"], }, { id: "zhipu-glm", diff --git a/assets/deepseek-model-metadata.json b/assets/deepseek-model-metadata.json new file mode 100644 index 000000000..27be13246 --- /dev/null +++ b/assets/deepseek-model-metadata.json @@ -0,0 +1,82 @@ +{ + "models": [ + { + "slug": "deepseek-v4-flash", + "display_name": "DeepSeek-V4-Flash", + "description": "Latest frontier agentic coding model.", + "prefer_websockets": false, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text", + "input_modalities": ["text"], + "supports_image_detail_original": false, + "truncation_policy": { "mode": "tokens", "limit": 10000 }, + "supports_parallel_tool_calls": true, + "additional_speed_tiers": [], + "service_tiers": [], + "tool_mode": null, + "supports_search_tool": false, + "multi_agent_version": "v2", + "use_responses_lite": false, + "include_skills_usage_instructions": false, + "context_window": 1048576, + "max_context_window": 1048576, + "effective_context_window_percent": 95, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "reasoning_summary_format": "experimental", + "default_reasoning_summary": "none", + "default_reasoning_level": "high", + "supported_reasoning_levels": [ + { "effort": "low", "description": "Fast responses with lighter reasoning" }, + { "effort": "high", "description": "Extra high reasoning depth for complex problems" }, + { "effort": "max", "description": "Maximum reasoning depth for the hardest problems" } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.144.0", + "supported_in_api": true, + "priority": 1 + }, + { + "slug": "deepseek-v4-pro", + "display_name": "DeepSeek-V4-Pro", + "description": "Most capable frontier agentic coding model.", + "prefer_websockets": false, + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text", + "input_modalities": ["text"], + "supports_image_detail_original": false, + "truncation_policy": { "mode": "tokens", "limit": 10000 }, + "supports_parallel_tool_calls": true, + "additional_speed_tiers": [], + "service_tiers": [], + "tool_mode": null, + "supports_search_tool": false, + "multi_agent_version": "v2", + "use_responses_lite": false, + "include_skills_usage_instructions": false, + "context_window": 1048576, + "max_context_window": 1048576, + "effective_context_window_percent": 95, + "auto_compact_token_limit": null, + "comp_hash": "3000", + "reasoning_summary_format": "experimental", + "default_reasoning_summary": "none", + "default_reasoning_level": "high", + "supported_reasoning_levels": [ + { "effort": "low", "description": "Fast responses with lighter reasoning" }, + { "effort": "high", "description": "Extra high reasoning depth for complex problems" }, + { "effort": "max", "description": "Maximum reasoning depth for the hardest problems" } + ], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": "0.144.0", + "supported_in_api": false, + "priority": 2 + } + ] +} diff --git a/crates/codex-plus-core/src/model_suffix.rs b/crates/codex-plus-core/src/model_suffix.rs index 873c0c24a..785728e12 100644 --- a/crates/codex-plus-core/src/model_suffix.rs +++ b/crates/codex-plus-core/src/model_suffix.rs @@ -144,6 +144,11 @@ const GPT56_METADATA_JSON: &str = include_str!(concat!( "/../../assets/gpt56-model-metadata-compat.json" )); +const DEEPSEEK_METADATA_JSON: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../assets/deepseek-model-metadata.json" +)); + pub fn requires_bundled_metadata_catalog(slug: &str) -> bool { gpt56_metadata_entry(slug).is_some() } @@ -204,7 +209,7 @@ pub fn build_model_catalog_json( entries: &[ModelCatalogEntry], fallback_window: Option, ) -> String { - build_model_catalog_json_with_capabilities(entries, fallback_window, None, None) + build_model_catalog_json_with_capabilities(entries, fallback_window, None, None, false) } /// 使用指定模板(或内置 bundled 模板)构建 catalog。 @@ -214,7 +219,7 @@ pub fn build_model_catalog_json_with_template( fallback_window: Option, template: Option<&Value>, ) -> String { - build_model_catalog_json_with_capabilities(entries, fallback_window, template, None) + build_model_catalog_json_with_capabilities(entries, fallback_window, template, None, false) } /// 使用显式 provider capability 构建 catalog。 @@ -225,15 +230,21 @@ pub(crate) fn build_model_catalog_json_with_capabilities( fallback_window: Option, template: Option<&Value>, use_responses_lite_override: Option, + deepseek_metadata: bool, ) -> String { let models: Vec = entries .iter() .enumerate() .map(|(index, entry)| { - let (mut model, has_model_metadata) = template - .cloned() - .map(|template| (template, false)) - .unwrap_or_else(|| model_template_entry(&entry.slug)); + let (mut model, has_model_metadata) = if deepseek_metadata { + deepseek_model_template_entry(&entry.slug) + .unwrap_or_else(|| model_template_entry(&entry.slug)) + } else { + template + .cloned() + .map(|template| (template, false)) + .unwrap_or_else(|| model_template_entry(&entry.slug)) + }; let metadata_window = model.get("context_window").and_then(Value::as_u64); let context_window = entry .suffix_window @@ -247,12 +258,16 @@ pub(crate) fn build_model_catalog_json_with_capabilities( } model["context_window"] = json!(context_window); model["max_context_window"] = json!(context_window); - // 默认 95 会让 1M 显示为 950K,显式写 100 以显示真实窗口。 - model["effective_context_window_percent"] = json!(100); + // 通用自定义模型显示完整窗口;DeepSeek Responses 保留官方目录的 95%。 + if !deepseek_metadata { + model["effective_context_window_percent"] = json!(100); + } model["auto_compact_token_limit"] = Value::Null; model["priority"] = json!(1000 + index); model["visibility"] = json!("list"); - model["supported_in_api"] = json!(true); + if !deepseek_metadata { + model["supported_in_api"] = json!(true); + } if let Some(use_responses_lite) = use_responses_lite_override { model["use_responses_lite"] = json!(use_responses_lite); } @@ -268,6 +283,17 @@ pub(crate) fn build_model_catalog_json_with_capabilities( serde_json::to_string_pretty(&json!({ "models": models })).unwrap_or_default() } +fn deepseek_model_template_entry(slug: &str) -> Option<(Value, bool)> { + let compatibility = catalog_metadata_entry(DEEPSEEK_METADATA_JSON, slug)?; + let mut template = first_bundled_template_entry().unwrap_or_else(|| json!({})); + if let (Some(target), Some(source)) = (template.as_object_mut(), compatibility.as_object()) { + for (key, value) in source { + target.insert(key.clone(), value.clone()); + } + } + Some((template, true)) +} + fn model_template_entry(slug: &str) -> (Value, bool) { if let Some(entry) = bundled_template_entry(slug) { return (entry, true); @@ -304,7 +330,11 @@ fn first_bundled_template_entry() -> Option { } fn gpt56_metadata_entry(slug: &str) -> Option { - let catalog: Value = serde_json::from_str(GPT56_METADATA_JSON).ok()?; + catalog_metadata_entry(GPT56_METADATA_JSON, slug) +} + +fn catalog_metadata_entry(catalog_json: &str, slug: &str) -> Option { + let catalog: Value = serde_json::from_str(catalog_json).ok()?; catalog .get("models")? .as_array()? diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 9a90373e8..f8e0009ae 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -390,7 +390,8 @@ pub fn apply_relay_profile_files_to_home_with_context( &profile.auto_compact_limit, )?; let config_with_catalog = apply_model_catalog_to_config(home, profile, &config_with_limits)?; - apply_relay_files_to_home(home, &config_with_catalog, &profile.auth_contents) + let compatible_config = apply_deepseek_responses_compatibility(profile, &config_with_catalog)?; + apply_relay_files_to_home(home, &compatible_config, &profile.auth_contents) } pub fn apply_relay_profile_to_home_with_switch_rules( @@ -427,11 +428,12 @@ pub fn apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard( &profile.auto_compact_limit, )?; let config_with_catalog = apply_model_catalog_to_config(home, profile, &config_with_limits)?; + let compatible_config = apply_deepseek_responses_compatibility(profile, &config_with_catalog)?; if profile.relay_mode == crate::settings::RelayMode::PureApi { apply_relay_files_to_home_with_computer_use_guard( home, - &config_with_catalog, + &compatible_config, &profile.auth_contents, preserve_computer_use_guard, ) @@ -439,7 +441,7 @@ pub fn apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard( let auth_contents = official_profile_auth_for_switch(home, &profile.auth_contents)?; apply_relay_files_to_home_with_computer_use_guard( home, - &config_with_catalog, + &compatible_config, &auth_contents, preserve_computer_use_guard, ) @@ -464,7 +466,8 @@ pub fn apply_relay_profile_config_to_home_with_context( &profile.auto_compact_limit, )?; let config_with_catalog = apply_model_catalog_to_config(home, profile, &config_with_limits)?; - apply_relay_config_file_to_home(home, &config_with_catalog) + let compatible_config = apply_deepseek_responses_compatibility(profile, &config_with_catalog)?; + apply_relay_config_file_to_home(home, &compatible_config) } pub fn apply_relay_config_file_to_home( @@ -1572,6 +1575,7 @@ fn apply_model_catalog_to_config( ); let custom_responses = custom_responses_provider(config_text); let mut config_text = config_text.to_string(); + let official_deepseek_responses = uses_official_deepseek_responses(profile); // 用户已手写 model_catalog_json 指针时保留,不覆盖(保 preserves_user_model_catalog_json 测试) // 仅当现有指针指向本 profile 自己生成的 catalog 时才重新生成。 // cc-switch 的固定文件名属于已知的其他管理器投影,不视为用户手写 catalog; @@ -1580,6 +1584,8 @@ fn apply_model_catalog_to_config( if existing != catalog_relative { if is_cc_switch_model_catalog(&existing) { config_text = remove_root_key(&config_text, "model_catalog_json"); + } else if official_deepseek_responses { + return Ok(config_text.to_string()); } else if custom_responses && copy_standard_responses_catalog(home, &existing, &catalog_relative)? { @@ -1591,7 +1597,9 @@ fn apply_model_catalog_to_config( } } } - if let Some(external_catalog) = live_external_model_catalog(home) { + if !official_deepseek_responses + && let Some(external_catalog) = live_external_model_catalog(home) + { let mut doc = parse_toml_document(&config_text)?; if custom_responses && copy_standard_responses_catalog(home, &external_catalog, &catalog_relative)? @@ -1617,6 +1625,7 @@ fn apply_model_catalog_to_config( if !entries.iter().any(|entry| { entry.suffix_window.is_some() || crate::model_suffix::requires_bundled_metadata_catalog(&entry.slug) + || (official_deepseek_responses && entry.slug.starts_with("deepseek-v4-")) }) { return Ok(config_text); } @@ -1632,6 +1641,7 @@ fn apply_model_catalog_to_config( fallback, None, custom_responses.then_some(false), + official_deepseek_responses, ); std::fs::write(&catalog_path, catalog_json)?; let mut doc = parse_toml_document(&config_text)?; @@ -1639,6 +1649,104 @@ fn apply_model_catalog_to_config( Ok(normalize_optional_toml(doc)) } +pub(crate) fn uses_official_deepseek_responses(profile: &RelayProfile) -> bool { + if profile.protocol != RelayProtocol::Responses { + return false; + } + let resolved_base_url = relay_profile_base_url(profile); + [ + profile.base_url.as_str(), + profile.upstream_base_url.as_str(), + resolved_base_url.as_str(), + ] + .iter() + .any(|base_url| deepseek_api_base_url(base_url)) +} + +fn deepseek_api_base_url(base_url: &str) -> bool { + let host = base_url + .trim() + .split("://") + .nth(1) + .unwrap_or(base_url) + .split(['/', '?', '#']) + .next() + .unwrap_or_default() + .split(':') + .next() + .unwrap_or_default() + .trim_end_matches('.') + .to_ascii_lowercase(); + host == "deepseek.com" || host.ends_with(".deepseek.com") +} + +pub fn apply_deepseek_responses_compatibility( + profile: &RelayProfile, + config_text: &str, +) -> anyhow::Result { + if !uses_official_deepseek_responses_for_config(profile, config_text) { + return Ok(config_text.to_string()); + } + + let mut doc = parse_toml_document(config_text)?; + if doc.get("features").and_then(Item::as_table_like).is_none() { + doc["features"] = toml_edit::table(); + } + // DeepSeek Responses rejects Code Mode's custom `exec` tool. Unified Exec uses the + // supported function tools `exec_command` and `write_stdin`, so preserve that setting. + let features = doc + .get_mut("features") + .and_then(Item::as_table_like_mut) + .expect("features table-like item was created above"); + features.insert("code_mode_only", toml_edit::value(false)); + if features + .get("code_mode") + .and_then(Item::as_table_like) + .is_none() + { + features.insert("code_mode", toml_edit::table()); + } + features + .get_mut("code_mode") + .and_then(Item::as_table_like_mut) + .expect("code_mode table-like item was created above") + .insert("enabled", toml_edit::value(false)); + Ok(normalize_optional_toml(doc)) +} + +fn uses_official_deepseek_responses_for_config(profile: &RelayProfile, config_text: &str) -> bool { + if let Ok(doc) = parse_toml_document(config_text) { + if let Some(provider_id) = active_provider_id(&doc) { + if let Some(provider) = doc + .get("model_providers") + .and_then(Item::as_table) + .and_then(|providers| providers.get(&provider_id)) + .and_then(Item::as_table_like) + { + let uses_responses = provider + .get("wire_api") + .and_then(Item::as_str) + .map(|wire_api| wire_api.trim().eq_ignore_ascii_case("responses")) + .unwrap_or(profile.protocol == RelayProtocol::Responses); + if !uses_responses { + return false; + } + if let Some(base_url) = provider.get("base_url").and_then(Item::as_str) { + return deepseek_api_base_url(base_url); + } + } + } + + if profile.protocol == RelayProtocol::Responses + && let Some(base_url) = root_key_string(config_text, "base_url") + { + return deepseek_api_base_url(&base_url); + } + } + + uses_official_deepseek_responses(profile) +} + fn custom_responses_provider(config_text: &str) -> bool { let Ok(doc) = parse_toml_document(config_text) else { return false; diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 326672789..f49acc8fc 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -3652,6 +3652,397 @@ experimental_bearer_token = "sk-new" assert_eq!(sol["use_responses_lite"], false); } +#[test] +fn apply_deepseek_responses_official_mix_writes_official_tool_compatibility() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "deepseek-responses".to_string(), + name: "DeepSeek".to_string(), + model: "deepseek-v4-flash".to_string(), + base_url: "https://api.deepseek.com/".to_string(), + upstream_base_url: "https://api.deepseek.com/".to_string(), + protocol: RelayProtocol::Responses, + relay_mode: RelayMode::Official, + official_mix_api_key: true, + api_key: "sk-deepseek".to_string(), + config_contents: r#"model = "deepseek-v4-flash" +model_provider = "deepseek" +experimental_use_unified_exec_tool = true + +[features] +goals = true +unified_exec = true +code_mode_only = true + +[features.code_mode] +enabled = true +direct_only_tool_namespaces = ["mcp__node_repl"] + +[model_providers.deepseek] +name = "deepseek" +base_url = "https://api.deepseek.com/" +wire_api = "responses" +requires_openai_auth = true +experimental_bearer_token = "sk-deepseek" +"# + .to_string(), + model_list: "deepseek-v4-flash\ndeepseek-v4-pro".to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + let parsed: toml::Value = toml::from_str(&config).unwrap(); + assert_eq!( + parsed["experimental_use_unified_exec_tool"].as_bool(), + Some(true) + ); + assert_eq!(parsed["features"]["unified_exec"].as_bool(), Some(true)); + assert_eq!(parsed["features"]["code_mode_only"].as_bool(), Some(false)); + assert_eq!( + parsed["features"]["code_mode"]["enabled"].as_bool(), + Some(false) + ); + assert_eq!( + parsed["features"]["code_mode"]["direct_only_tool_namespaces"][0].as_str(), + Some("mcp__node_repl") + ); + assert_eq!(parsed["features"]["goals"].as_bool(), Some(true)); + assert_eq!( + parsed["model_catalog_json"].as_str(), + Some("model-catalogs/deepseek-responses.json") + ); + + let catalog: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string( + temp.path() + .join("model-catalogs") + .join("deepseek-responses.json"), + ) + .unwrap(), + ) + .unwrap(); + let model = &catalog["models"][0]; + assert_eq!(model["slug"], "deepseek-v4-flash"); + assert_eq!(model["shell_type"], "shell_command"); + assert_eq!(model["apply_patch_tool_type"], "freeform"); + assert!(model["tool_mode"].is_null()); + assert_eq!(model["use_responses_lite"], false); + assert_eq!(model["context_window"], 1_048_576); + assert_eq!(model["effective_context_window_percent"], 95); + assert_eq!(model["default_reasoning_level"], "high"); + assert_eq!(model["supports_search_tool"], false); + assert_eq!(model["additional_speed_tiers"], serde_json::json!([])); + assert_eq!(model["service_tiers"], serde_json::json!([])); + let pro = catalog["models"] + .as_array() + .unwrap() + .iter() + .find(|model| model["slug"] == "deepseek-v4-pro") + .unwrap(); + assert_eq!(pro["supported_in_api"], false); +} + +#[test] +fn deepseek_responses_compatibility_preserves_inline_feature_tables() { + let profile = RelayProfile { + base_url: "https://api.deepseek.com/".to_string(), + upstream_base_url: "https://api.deepseek.com/".to_string(), + protocol: RelayProtocol::Responses, + ..RelayProfile::default() + }; + let config = r#"features = { unified_exec = true, goals = true, code_mode = { enabled = true, direct_only_tool_namespaces = ["mcp__node_repl"] } } +"#; + + let prepared = + codex_plus_core::relay_config::apply_deepseek_responses_compatibility(&profile, config) + .unwrap(); + let parsed: toml::Value = toml::from_str(&prepared).unwrap(); + assert_eq!(parsed["features"]["unified_exec"].as_bool(), Some(true)); + assert_eq!(parsed["features"]["goals"].as_bool(), Some(true)); + assert_eq!(parsed["features"]["code_mode_only"].as_bool(), Some(false)); + assert_eq!( + parsed["features"]["code_mode"]["enabled"].as_bool(), + Some(false) + ); + assert_eq!( + parsed["features"]["code_mode"]["direct_only_tool_namespaces"][0].as_str(), + Some("mcp__node_repl") + ); +} + +#[test] +fn deepseek_responses_save_uses_configured_endpoint_over_profile_url() { + let official_profile = RelayProfile { + base_url: "https://api.deepseek.com/".to_string(), + upstream_base_url: "https://api.deepseek.com/".to_string(), + protocol: RelayProtocol::Responses, + ..RelayProfile::default() + }; + let third_party_config = r#"model_provider = "custom" + +[model_providers.custom] +wire_api = "responses" +base_url = "https://relay.example/v1" +"#; + let unchanged = codex_plus_core::relay_config::apply_deepseek_responses_compatibility( + &official_profile, + third_party_config, + ) + .unwrap(); + assert_eq!(unchanged, third_party_config); + + let third_party_profile = RelayProfile { + base_url: "https://relay.example/v1".to_string(), + upstream_base_url: "https://relay.example/v1".to_string(), + protocol: RelayProtocol::ChatCompletions, + ..RelayProfile::default() + }; + let official_config = r#"model_provider = "deepseek" + +[model_providers.deepseek] +wire_api = "responses" +base_url = "https://api.deepseek.com/v1" +"#; + let prepared = codex_plus_core::relay_config::apply_deepseek_responses_compatibility( + &third_party_profile, + official_config, + ) + .unwrap(); + let parsed: toml::Value = toml::from_str(&prepared).unwrap(); + assert_eq!(parsed["features"]["code_mode_only"].as_bool(), Some(false)); + assert_eq!( + parsed["features"]["code_mode"]["enabled"].as_bool(), + Some(false) + ); +} + +#[test] +fn official_deepseek_responses_preserves_explicit_model_catalog() { + let temp = tempfile::tempdir().unwrap(); + let custom_catalog = temp.path().join("custom-catalog.json"); + std::fs::write( + &custom_catalog, + serde_json::json!({"models": [{"slug": "custom-model"}]}).to_string(), + ) + .unwrap(); + let profile = RelayProfile { + id: "deepseek-explicit-catalog".to_string(), + model: "deepseek-v4-flash".to_string(), + base_url: "https://api.deepseek.com/".to_string(), + upstream_base_url: "https://api.deepseek.com/".to_string(), + protocol: RelayProtocol::Responses, + relay_mode: RelayMode::Official, + config_contents: format!( + "model = \"deepseek-v4-flash\"\nmodel_catalog_json = \"{}\"\n", + custom_catalog.to_string_lossy().replace('\\', "/") + ), + model_list: "deepseek-v4-flash".to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + assert!(config.contains("model_catalog_json")); + assert!(config.contains("custom-catalog.json")); + assert!( + !temp + .path() + .join("model-catalogs") + .join("deepseek-explicit-catalog.json") + .exists() + ); +} + +#[test] +fn official_deepseek_responses_replaces_cc_switch_catalog() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "deepseek-cc-switch".to_string(), + model: "deepseek-v4-flash".to_string(), + base_url: "https://api.deepseek.com/".to_string(), + upstream_base_url: "https://api.deepseek.com/".to_string(), + protocol: RelayProtocol::Responses, + relay_mode: RelayMode::Official, + config_contents: r#"model = "deepseek-v4-flash" +model_catalog_json = "cc-switch-model-catalog.json" +model_provider = "deepseek" + +[model_providers.deepseek] +name = "deepseek" +base_url = "https://api.deepseek.com/" +wire_api = "responses" +"# + .to_string(), + model_list: "deepseek-v4-flash".to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + assert!(config.contains(r#"model_catalog_json = "model-catalogs/deepseek-cc-switch.json""#)); + assert!(!config.contains("cc-switch-model-catalog.json")); + assert!( + temp.path() + .join("model-catalogs/deepseek-cc-switch.json") + .exists() + ); +} + +#[test] +fn apply_third_party_deepseek_responses_preserves_code_mode_and_catalog_tool_mode() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("relay-catalog.json"), + serde_json::json!({ + "models": [{ + "slug": "relay-deepseek", + "display_name": "Relay DeepSeek", + "context_window": 262_144, + "tool_mode": "code_mode_only", + "use_responses_lite": true, + "service_tiers": [{"id": "relay-fast"}] + }] + }) + .to_string(), + ) + .unwrap(); + let profile = RelayProfile { + id: "deepseek-relay-catalog".to_string(), + model: "relay-deepseek".to_string(), + base_url: "https://relay.example/v1".to_string(), + upstream_base_url: "https://relay.example/v1".to_string(), + protocol: RelayProtocol::Responses, + config_contents: r#"model = "relay-deepseek" +model_provider = "custom" +model_catalog_json = "relay-catalog.json" + +[features] +unified_exec = true +code_mode_only = true + +[features.code_mode] +enabled = true + +[model_providers.custom] +name = "custom" +base_url = "https://relay.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + .to_string(), + model_list: "relay-deepseek".to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + let parsed: toml::Value = toml::from_str(&config).unwrap(); + assert_eq!(parsed["features"]["unified_exec"].as_bool(), Some(true)); + assert_eq!(parsed["features"]["code_mode_only"].as_bool(), Some(true)); + assert_eq!( + parsed["features"]["code_mode"]["enabled"].as_bool(), + Some(true) + ); + assert_eq!( + parsed["model_catalog_json"].as_str(), + Some("model-catalogs/deepseek-relay-catalog.json") + ); + let catalog: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string( + temp.path() + .join("model-catalogs") + .join("deepseek-relay-catalog.json"), + ) + .unwrap(), + ) + .unwrap(); + let model = &catalog["models"][0]; + assert_eq!(model["display_name"], "Relay DeepSeek"); + assert_eq!(model["context_window"], 262_144); + assert_eq!(model["service_tiers"][0]["id"], "relay-fast"); + assert_eq!(model["tool_mode"], "code_mode_only"); + assert_eq!(model["use_responses_lite"], false); +} + +#[test] +fn apply_non_deepseek_responses_profile_preserves_unified_exec() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "agentrouter".to_string(), + model: "gpt-5.6-sol".to_string(), + base_url: "https://agentrouter.org/v1".to_string(), + upstream_base_url: "https://agentrouter.org/v1".to_string(), + protocol: RelayProtocol::Responses, + config_contents: r#"model = "gpt-5.6-sol" +model_provider = "agentrouter" + +[features] +unified_exec = true +code_mode_only = true + +[features.code_mode] +enabled = true +direct_only_tool_namespaces = ["mcp__node_repl"] + +[model_providers.agentrouter] +name = "agentrouter" +base_url = "https://agentrouter.org/v1" +wire_api = "responses" +requires_openai_auth = true +"# + .to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + let parsed: toml::Value = toml::from_str(&config).unwrap(); + assert_eq!(parsed["features"]["unified_exec"].as_bool(), Some(true)); + assert_eq!(parsed["features"]["code_mode_only"].as_bool(), Some(true)); + assert_eq!( + parsed["features"]["code_mode"]["enabled"].as_bool(), + Some(true) + ); +} + +#[test] +fn apply_deepseek_chat_completions_profile_preserves_unified_exec() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "deepseek-chat".to_string(), + model: "deepseek-chat".to_string(), + base_url: "https://api.deepseek.com/".to_string(), + upstream_base_url: "https://api.deepseek.com/".to_string(), + protocol: RelayProtocol::ChatCompletions, + config_contents: r#"model = "deepseek-chat" + +[features] +unified_exec = true +code_mode_only = true + +[features.code_mode] +enabled = true +direct_only_tool_namespaces = ["mcp__node_repl"] +"# + .to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + let parsed: toml::Value = toml::from_str(&config).unwrap(); + assert_eq!(parsed["features"]["unified_exec"].as_bool(), Some(true)); + assert_eq!(parsed["features"]["code_mode_only"].as_bool(), Some(true)); + assert_eq!( + parsed["features"]["code_mode"]["enabled"].as_bool(), + Some(true) + ); +} + #[test] fn apply_custom_chat_profile_preserves_generated_catalog_lite_behavior() { let temp = tempfile::tempdir().unwrap(); From 7941ba448e7d189a6639221027555cec81f0338a Mon Sep 17 00:00:00 2001 From: Rat0323 <261020116+Rat0323@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:04:41 +0800 Subject: [PATCH 2/2] fix DeepSeek catalog detection and enable V4 Pro --- apps/codex-plus-manager/src/presets.test.ts | 2 +- apps/codex-plus-manager/src/presets.ts | 2 +- assets/deepseek-model-metadata.json | 2 +- crates/codex-plus-core/src/relay_config.rs | 3 +- crates/codex-plus-core/tests/relay_config.rs | 60 +++++++++++++++++++- 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/apps/codex-plus-manager/src/presets.test.ts b/apps/codex-plus-manager/src/presets.test.ts index 33f59af90..31887e80d 100644 --- a/apps/codex-plus-manager/src/presets.test.ts +++ b/apps/codex-plus-manager/src/presets.test.ts @@ -9,5 +9,5 @@ test("DeepSeek preset uses the official Responses integration", () => { assert.equal(preset.baseUrl, "https://api.deepseek.com/"); assert.equal(preset.protocol, "responses"); assert.equal(preset.model, "deepseek-v4-flash"); - assert.deepEqual(preset.modelList, ["deepseek-v4-flash"]); + assert.deepEqual(preset.modelList, ["deepseek-v4-flash", "deepseek-v4-pro"]); }); diff --git a/apps/codex-plus-manager/src/presets.ts b/apps/codex-plus-manager/src/presets.ts index 2178c25c9..fa70ffae6 100644 --- a/apps/codex-plus-manager/src/presets.ts +++ b/apps/codex-plus-manager/src/presets.ts @@ -53,7 +53,7 @@ export const PRESETS: ProviderPreset[] = [ baseUrl: "https://api.deepseek.com/", protocol: "responses", model: "deepseek-v4-flash", - modelList: ["deepseek-v4-flash"], + modelList: ["deepseek-v4-flash", "deepseek-v4-pro"], }, { id: "zhipu-glm", diff --git a/assets/deepseek-model-metadata.json b/assets/deepseek-model-metadata.json index 27be13246..6103fcfde 100644 --- a/assets/deepseek-model-metadata.json +++ b/assets/deepseek-model-metadata.json @@ -75,7 +75,7 @@ "shell_type": "shell_command", "visibility": "list", "minimal_client_version": "0.144.0", - "supported_in_api": false, + "supported_in_api": true, "priority": 2 } ] diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index f8e0009ae..714a249bc 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -1575,7 +1575,8 @@ fn apply_model_catalog_to_config( ); let custom_responses = custom_responses_provider(config_text); let mut config_text = config_text.to_string(); - let official_deepseek_responses = uses_official_deepseek_responses(profile); + let official_deepseek_responses = + uses_official_deepseek_responses_for_config(profile, &config_text); // 用户已手写 model_catalog_json 指针时保留,不覆盖(保 preserves_user_model_catalog_json 测试) // 仅当现有指针指向本 profile 自己生成的 catalog 时才重新生成。 // cc-switch 的固定文件名属于已知的其他管理器投影,不视为用户手写 catalog; diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index f49acc8fc..70e2276bf 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -3741,7 +3741,7 @@ experimental_bearer_token = "sk-deepseek" .iter() .find(|model| model["slug"] == "deepseek-v4-pro") .unwrap(); - assert_eq!(pro["supported_in_api"], false); + assert_eq!(pro["supported_in_api"], true); } #[test] @@ -3818,6 +3818,64 @@ base_url = "https://api.deepseek.com/v1" ); } +#[test] +fn third_party_responses_config_overrides_stale_deepseek_profile_for_catalog() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "stale-deepseek-profile".to_string(), + model: "relay-deepseek".to_string(), + base_url: "https://api.deepseek.com/".to_string(), + upstream_base_url: "https://api.deepseek.com/".to_string(), + protocol: RelayProtocol::Responses, + config_contents: r#"model = "relay-deepseek" +model_provider = "custom" + +[features] +code_mode_only = true + +[features.code_mode] +enabled = true + +[model_providers.custom] +name = "custom" +base_url = "https://relay.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + .to_string(), + model_list: "relay-deepseek[1M]".to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + let parsed: toml::Value = toml::from_str(&config).unwrap(); + assert_eq!(parsed["features"]["code_mode_only"].as_bool(), Some(true)); + assert_eq!( + parsed["features"]["code_mode"]["enabled"].as_bool(), + Some(true) + ); + assert_eq!( + parsed["model_catalog_json"].as_str(), + Some("model-catalogs/stale-deepseek-profile.json") + ); + + let catalog: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string( + temp.path() + .join("model-catalogs") + .join("stale-deepseek-profile.json"), + ) + .unwrap(), + ) + .unwrap(); + let model = &catalog["models"][0]; + assert_eq!(model["slug"], "relay-deepseek"); + assert_eq!(model["effective_context_window_percent"], 100); + assert_eq!(model["supported_in_api"], true); +} + #[test] fn official_deepseek_responses_preserves_explicit_model_catalog() { let temp = tempfile::tempdir().unwrap();