diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index b3c835141..ccc37ea40 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -406,6 +406,12 @@ pub struct CodexChatReasoningConfig { /// 靠穷举字段提取、并不读取本字段;保留作文档说明与未来按格式分发(如 think_tags)的预留。 #[serde(rename = "outputFormat", skip_serializing_if = "Option::is_none")] pub output_format: Option, + /// 运行时字段(不持久化、不进 meta):当前请求模型在平台侧声明的合法 effort + /// 档位,由 resolve 按请求模型从供应商 `settings_config.modelCatalog` 的 + /// `reasoningLevels`(逐模型声明,见 #6228)查表填充。仅 "zen" 值映射消费: + /// Some → 钳到合法档;None → 不发 effort 字段(模型未收录或为 toggle 型)。 + #[serde(skip)] + pub effort_levels: Option>, } /// Local proxy request overrides applied after route/protocol transforms. diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index b0e0dd8a0..2288bb1ba 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -339,15 +339,57 @@ pub fn resolve_codex_chat_reasoning_config( provider: &Provider, body: &JsonValue, ) -> Option { - if let Some(config) = provider + let mut config = if let Some(config) = provider .meta .as_ref() .and_then(|meta| meta.codex_chat_reasoning.clone()) { - return Some(normalize_codex_chat_reasoning_config(config)); + normalize_codex_chat_reasoning_config(config) + } else { + infer_codex_chat_reasoning_config(provider, body)? + }; + + // zen 的合法 effort 档位是逐模型的(models.dev:glm-5.2 仅 high|max、 + // kimi-k3 仅 max、qwen/glm-5.1 等为 toggle 型无 effort),opencode 客户端 + // 也严格按模型声明发值。按请求模型从 modelCatalog 的 reasoningLevels + // (#6228 引入的逐模型声明)查表附上;查不到(模型未收录 / 条目未声明 + // effort)→ None,转换层将完全不发 reasoning_effort。 + if config.effort_value_mode.as_deref() == Some("zen") { + config.effort_levels = zen_catalog_effort_levels(provider, body); } - infer_codex_chat_reasoning_config(provider, body) + Some(config) +} + +/// 按请求模型从供应商 modelCatalog 查 Zen 合法 effort 档位(逐模型数据镜像 +/// models.dev 的 reasoning_options effort values)。仅做档位查表,不参与平台 +/// 判定——平台身份仍只由 name/base_url 决定(见 infer_aggregator_platform_config)。 +/// DB SSOT 为 camelCase,手写/旧数据可能为 snake_case,双格式兼容(与表单加载侧一致)。 +fn zen_catalog_effort_levels(provider: &Provider, body: &JsonValue) -> Option> { + let model = body.get("model")?.as_str()?.trim(); + if model.is_empty() { + return None; + } + let entries = provider + .settings_config + .get("modelCatalog")? + .get("models")? + .as_array()?; + let entry = entries.iter().find(|entry| { + entry + .get("model") + .and_then(|value| value.as_str()) + .is_some_and(|name| name.eq_ignore_ascii_case(model)) + })?; + let levels_value = entry + .get("reasoningLevels") + .or_else(|| entry.get("reasoning_levels"))?; + let levels: Vec = levels_value + .as_array()? + .iter() + .filter_map(|level| level.as_str().map(str::to_string)) + .collect(); + (!levels.is_empty()).then_some(levels) } fn normalize_codex_chat_reasoning_config( @@ -403,6 +445,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("reasoning_effort".to_string()), effort_value_mode: Some("deepseek".to_string()), output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -428,6 +471,7 @@ fn infer_codex_chat_reasoning_config( .to_string(), ), output_format: Some("reasoning".to_string()), + effort_levels: None, }); } @@ -439,6 +483,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -450,6 +495,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -461,6 +507,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -472,6 +519,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_details".to_string()), + effort_levels: None, }); } @@ -483,6 +531,7 @@ fn infer_codex_chat_reasoning_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -512,6 +561,7 @@ fn infer_aggregator_platform_config( effort_param: Some("reasoning.effort".to_string()), effort_value_mode: Some("openrouter".to_string()), output_format: Some("auto".to_string()), + effort_levels: None, }); } @@ -526,6 +576,7 @@ fn infer_aggregator_platform_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -542,6 +593,26 @@ fn infer_aggregator_platform_config( effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, + }); + } + + // OpenCode Zen(opencode.ai 网关,issue #6112):其自家客户端对该传输发顶层 + // `reasoning_effort`(provider/transform.ts),平台归一参数;不发厂商原生 + // thinking 形状(glm 模型走 zen 时套智谱 thinking:{type} 网关不认)。 + // 合法档位逐模型(models.dev 的 reasoning_options,opencode 客户端同样严格 + // 按模型声明发值):具体档位表见供应商 modelCatalog 各条目的 reasoningLevels, + // 代理由此按请求模型查表钳制(resolve 处附上 effort_levels),无表不发字段。 + // 匹配域名而非裸 "opencode",避免误伤名字含 opencode 的无关供应商。 + if platform.contains("opencode.ai") { + return Some(CodexChatReasoningConfig { + supports_thinking: Some(true), + supports_effort: Some(true), + thinking_param: Some("none".to_string()), + effort_param: Some("reasoning_effort".to_string()), + effort_value_mode: Some("zen".to_string()), + output_format: Some("reasoning_content".to_string()), + effort_levels: None, }); } @@ -1526,6 +1597,7 @@ wire_api = "chat" effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("auto".to_string()), + effort_levels: None, }), ..Default::default() }); @@ -1617,6 +1689,139 @@ wire_api = "chat" assert_eq!(config.output_format.as_deref(), Some("reasoning_content")); } + #[test] + fn test_resolve_codex_chat_reasoning_opencode_zen_platform_overrides_model_vendor() { + let provider = create_provider(json!({ + "config": r#" +model_provider = "opencode" +model = "glm-5.2" + +[model_providers.opencode] +name = "OpenCode Go" +base_url = "https://opencode.ai/zen/go/v1" +wire_api = "chat" +"# + })); + + // 模型是 GLM(智谱自家用 thinking:{type} 方言),但平台是 OpenCode Zen —— + // 必须走平台配置:顶层 reasoning_effort + reasoning_content, + // 不得注入智谱 thinking 形状。 + let config = + resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "glm-5.2" })).unwrap(); + + assert_eq!(config.supports_thinking, Some(true)); + assert_eq!(config.supports_effort, Some(true)); + assert_eq!(config.thinking_param.as_deref(), Some("none")); + assert_eq!(config.effort_param.as_deref(), Some("reasoning_effort")); + assert_eq!(config.effort_value_mode.as_deref(), Some("zen")); + assert_eq!(config.output_format.as_deref(), Some("reasoning_content")); + } + + #[test] + fn test_resolve_codex_chat_reasoning_zen_attaches_per_model_effort_levels() { + let provider = create_provider(json!({ + "config": r#" +model_provider = "opencode" +model = "glm-5.2" + +[model_providers.opencode] +name = "OpenCode Go" +base_url = "https://opencode.ai/zen/go/v1" +wire_api = "chat" +"#, + "modelCatalog": { + "models": [ + { "model": "glm-5.2", "reasoningLevels": ["high", "max"] }, + { "model": "deepseek-v4-flash", "reasoningLevels": ["low", "high", "max"] }, + { "model": "glm-5.1" } + ] + } + })); + + // 逐模型查表:声明了 reasoningLevels 的模型附上各自档位(模型名大小写不敏感)。 + let config = + resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "GLM-5.2" })).unwrap(); + assert_eq!( + config.effort_levels, + Some(vec!["high".to_string(), "max".to_string()]) + ); + + let config = resolve_codex_chat_reasoning_config( + &provider, + &json!({ "model": "deepseek-v4-flash" }), + ) + .unwrap(); + assert_eq!( + config.effort_levels, + Some(vec![ + "low".to_string(), + "high".to_string(), + "max".to_string() + ]) + ); + + // toggle 型模型(目录条目未声明 reasoningLevels)→ None:转换层不发 effort 字段。 + let config = + resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "glm-5.1" })).unwrap(); + assert_eq!(config.effort_value_mode.as_deref(), Some("zen")); + assert!(config.effort_levels.is_none()); + + // 目录未收录的模型 → 同样 None。 + let config = + resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "kimi-k3" })).unwrap(); + assert!(config.effort_levels.is_none()); + } + + #[test] + fn test_resolve_codex_chat_reasoning_zen_levels_attach_on_explicit_meta_too() { + let mut provider = create_provider(json!({ + "config": r#" +model_provider = "opencode" +model = "deepseek-v4-flash" + +[model_providers.opencode] +name = "OpenCode Go" +base_url = "https://opencode.ai/zen/go/v1" +wire_api = "chat" +"#, + "modelCatalog": { + "models": [ + { "model": "deepseek-v4-flash", "reasoning_levels": ["low", "high", "max"] } + ] + } + })); + // 显式 meta(表单手选 zen 模式)同样要在 resolve 末端按请求模型附表; + // 且手写/旧数据可能是 snake_case 的 reasoning_levels(加载侧双格式兼容)。 + provider.meta = Some(crate::provider::ProviderMeta { + codex_chat_reasoning: Some(CodexChatReasoningConfig { + supports_thinking: Some(true), + supports_effort: Some(true), + thinking_param: Some("none".to_string()), + effort_param: Some("reasoning_effort".to_string()), + effort_value_mode: Some("zen".to_string()), + output_format: Some("reasoning_content".to_string()), + effort_levels: None, + }), + ..Default::default() + }); + + let config = resolve_codex_chat_reasoning_config( + &provider, + &json!({ "model": "deepseek-v4-flash" }), + ) + .unwrap(); + + assert_eq!(config.effort_value_mode.as_deref(), Some("zen")); + assert_eq!( + config.effort_levels, + Some(vec![ + "low".to_string(), + "high".to_string(), + "max".to_string() + ]) + ); + } + #[test] fn test_infer_codex_chat_reasoning_stepfun_per_model_effort() { let provider = create_provider(json!({ diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index 2db253d9d..a864740dd 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -424,7 +424,11 @@ fn apply_reasoning_options( let Some(effort) = body.pointer("/reasoning/effort").and_then(|v| v.as_str()) else { return; }; - let Some(mapped) = map_reasoning_effort(effort, config.effort_value_mode.as_deref()) else { + let Some(mapped) = map_reasoning_effort( + effort, + config.effort_value_mode.as_deref(), + config.effort_levels.as_deref(), + ) else { return; }; @@ -455,7 +459,11 @@ fn reasoning_requested(body: &Value) -> Option { body.get("reasoning").map(|value| !value.is_null()) } -fn map_reasoning_effort(effort: &str, mode: Option<&str>) -> Option<&'static str> { +fn map_reasoning_effort<'a>( + effort: &str, + mode: Option<&str>, + effort_levels: Option<&'a [String]>, +) -> Option<&'a str> { let effort = effort.trim().to_ascii_lowercase(); if matches!(effort.as_str(), "none" | "off" | "disabled") { return None; @@ -486,6 +494,31 @@ fn map_reasoning_effort(effort: &str, mode: Option<&str>) -> Option<&'static str "minimal" => Some("minimal"), _ => None, }, + // OpenCode Zen:合法档位逐模型(表数据 = 供应商 modelCatalog 各条目的 + // reasoningLevels,镜像 models.dev——glm-5.2 仅 high|max、deepseek-v4-flash + // 为 low|high|max、kimi-k3 仅 max),opencode 客户端也严格按模型声明发值, + // 故不能用统一并集映射。无表(模型未收录目录、或为 toggle/budget 型未声明 + // effort)→ None,完全不发 reasoning_effort;有表 → 钳到「不小于请求的 + // 最近合法档」,请求超出最高档则取最高合法档;请求值本身无法识别 → None + // (同其他模式的未知值丢弃策略)。 + "zen" => { + let levels = effort_levels?; + let requested = zen_effort_rank(&effort)?; + levels + .iter() + .filter_map(|level| zen_effort_rank(level).map(|rank| (rank, level.as_str()))) + .filter(|(rank, _)| *rank >= requested) + .min_by_key(|(rank, _)| *rank) + .or_else(|| { + levels + .iter() + .filter_map(|level| { + zen_effort_rank(level).map(|rank| (rank, level.as_str())) + }) + .max_by_key(|(rank, _)| *rank) + }) + .map(|(_, level)| level) + } _ => match effort.as_str() { "minimal" => Some("minimal"), "low" => Some("low"), @@ -499,6 +532,21 @@ fn map_reasoning_effort(effort: &str, mode: Option<&str>) -> Option<&'static str } } +/// Codex 规范档位序(minimal < low < medium < high < xhigh < max < ultra),供 zen +/// 逐模型钳制做大小比较;目录里的非法/扩展值(如 "none")返回 None,查表时被滤掉。 +fn zen_effort_rank(effort: &str) -> Option { + match effort.trim().to_ascii_lowercase().as_str() { + "minimal" => Some(0), + "low" => Some(1), + "medium" => Some(2), + "high" => Some(3), + "xhigh" => Some(4), + "max" => Some(5), + "ultra" => Some(6), + _ => None, + } +} + /// MiniMax 严格要求 messages 中只能首条出现 `role=system`, /// 否则返回 `invalid params, chat content has invalid message role: system (2013)`。 /// 把所有 system 消息合并到首位,避免中间 system(如 Codex 的 `developer` 指令)触发该约束; @@ -2029,19 +2077,22 @@ mod tests { fn map_reasoning_effort_handles_ultra_per_mode() { // passthrough 面向枚举未知的通用上游:ultra 与 max/xhigh 一样原值透传, // 让声明了该档位的上游能收到用户选择。 - assert_eq!(map_reasoning_effort("ultra", None), Some("ultra")); + assert_eq!(map_reasoning_effort("ultra", None, None), Some("ultra")); // 已知枚举的专用模式钳到自身最高合法档,而不是走 None 被静默丢弃。 - assert_eq!(map_reasoning_effort("ultra", Some("deepseek")), Some("max")); assert_eq!( - map_reasoning_effort("ultra", Some("low_high")), + map_reasoning_effort("ultra", Some("deepseek"), None), + Some("max") + ); + assert_eq!( + map_reasoning_effort("ultra", Some("low_high"), None), Some("high") ); assert_eq!( - map_reasoning_effort("ultra", Some("openrouter")), + map_reasoning_effort("ultra", Some("openrouter"), None), Some("xhigh") ); // 真正的未知值仍然丢弃,防上游 400。 - assert_eq!(map_reasoning_effort("turbo", None), None); + assert_eq!(map_reasoning_effort("turbo", None, None), None); } #[test] @@ -2535,6 +2586,7 @@ mod tests { effort_param: Some("reasoning_effort".to_string()), effort_value_mode: Some("deepseek".to_string()), output_format: Some("reasoning_content".to_string()), + effort_levels: None, }; let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); @@ -2576,6 +2628,7 @@ mod tests { effort_param: Some("reasoning.effort".to_string()), effort_value_mode: Some("openrouter".to_string()), output_format: Some("auto".to_string()), + effort_levels: None, }; // max 不在 OpenRouter 枚举内(见 openclaw#77350),必须钳成 xhigh, @@ -2617,6 +2670,7 @@ mod tests { effort_param: Some("reasoning.effort".to_string()), effort_value_mode: Some("openrouter".to_string()), output_format: Some("auto".to_string()), + effort_levels: None, }; let input = json!({ @@ -2644,6 +2698,7 @@ mod tests { effort_param: Some("reasoning_effort".to_string()), effort_value_mode: Some("deepseek".to_string()), output_format: Some("reasoning_content".to_string()), + effort_levels: None, }; let input = json!({ @@ -2659,6 +2714,138 @@ mod tests { assert!(result.get("reasoning").is_none()); } + #[test] + fn responses_request_to_chat_clamps_zen_effort_to_model_declared_levels() { + // OpenCode Zen 平台形态 + 逐模型档位钳制(表数据镜像 models.dev:glm-5.2 + // 仅声明 high|max)。锁定:统一并集映射会把 Codex 默认的 medium 发给只声明 + // high|max 的 glm-5.2(恰好是预设默认模型),严格校验的网关会报错; + // 低档一律上钳到最近合法档,超出最高档(含 Codex 扩展档 ultra)取最高合法档。 + let config = CodexChatReasoningConfig { + supports_thinking: Some(true), + supports_effort: Some(true), + thinking_param: Some("none".to_string()), + effort_param: Some("reasoning_effort".to_string()), + effort_value_mode: Some("zen".to_string()), + output_format: Some("reasoning_content".to_string()), + effort_levels: Some(vec!["high".to_string(), "max".to_string()]), + }; + + for (input_effort, expected) in [ + ("minimal", "high"), + ("low", "high"), + ("medium", "high"), + ("high", "high"), + ("xhigh", "max"), + ("max", "max"), + ("ultra", "max"), + ] { + let input = json!({ + "model": "glm-5.2", + "input": "hello", + "reasoning": {"effort": input_effort} + }); + let result = + responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); + assert_eq!( + result["reasoning_effort"], expected, + "effort={input_effort}" + ); + // 写成顶层 reasoning_effort;不发任何 thinking 字段 + // (网关只认平台归一参数,不认厂商 thinking 形状)。 + assert!(result.get("thinking").is_none()); + } + } + + #[test] + fn responses_request_to_chat_zen_preserves_low_when_model_declares_it() { + // deepseek-v4-flash 声明 low|high|max:low 合法原样透传,medium 上钳 high, + // xhigh 上钳 max——回归锁:旧 deepseek 厂商分支把非 max 一律归 high, + // 逐模型钳制不得比那更差,也不得发出声明外的值。 + let config = CodexChatReasoningConfig { + supports_thinking: Some(true), + supports_effort: Some(true), + thinking_param: Some("none".to_string()), + effort_param: Some("reasoning_effort".to_string()), + effort_value_mode: Some("zen".to_string()), + output_format: Some("reasoning_content".to_string()), + effort_levels: Some(vec![ + "low".to_string(), + "high".to_string(), + "max".to_string(), + ]), + }; + + for (input_effort, expected) in [ + ("minimal", "low"), + ("low", "low"), + ("medium", "high"), + ("xhigh", "max"), + ] { + let input = json!({ + "model": "deepseek-v4-flash", + "input": "hello", + "reasoning": {"effort": input_effort} + }); + let result = + responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); + assert_eq!( + result["reasoning_effort"], expected, + "effort={input_effort}" + ); + } + } + + #[test] + fn responses_request_to_chat_zen_single_level_model_clamps_everything_to_max() { + // kimi-k3 仅声明 max(全模型交集不存在,统一映射覆盖不了它——必须逐模型): + // 任何请求档都钳到 max。 + let config = CodexChatReasoningConfig { + supports_thinking: Some(true), + supports_effort: Some(true), + thinking_param: Some("none".to_string()), + effort_param: Some("reasoning_effort".to_string()), + effort_value_mode: Some("zen".to_string()), + output_format: Some("reasoning_content".to_string()), + effort_levels: Some(vec!["max".to_string()]), + }; + + for input_effort in ["minimal", "medium", "high", "max"] { + let input = json!({ + "model": "kimi-k3", + "input": "hello", + "reasoning": {"effort": input_effort} + }); + let result = + responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); + assert_eq!(result["reasoning_effort"], "max", "effort={input_effort}"); + } + } + + #[test] + fn responses_request_to_chat_omits_zen_effort_without_model_levels() { + // 模型未在目录声明 effort(toggle/budget 型如 glm-5.1、qwen 系,或目录未收录) + // → 完全不发 reasoning_effort,避免给严格校验的网关送无法核实的值。 + let config = CodexChatReasoningConfig { + supports_thinking: Some(true), + supports_effort: Some(true), + thinking_param: Some("none".to_string()), + effort_param: Some("reasoning_effort".to_string()), + effort_value_mode: Some("zen".to_string()), + output_format: Some("reasoning_content".to_string()), + effort_levels: None, + }; + + let input = json!({ + "model": "glm-5.1", + "input": "hello", + "reasoning": {"effort": "medium"} + }); + let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); + + assert!(result.get("reasoning_effort").is_none()); + assert!(result.get("thinking").is_none()); + } + #[test] fn responses_request_to_chat_maps_thinking_only_provider_without_effort() { let input = json!({ @@ -2673,6 +2860,7 @@ mod tests { effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }; let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); @@ -2695,6 +2883,7 @@ mod tests { effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), + effort_levels: None, }; let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 04e370491..ab2600ec6 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -178,9 +178,9 @@ export const normalizeCodexCatalogModelsForSave = ( ); const baseInstructions = item.baseInstructions?.trim(); - const reasoningLevels = item.reasoningLevels?.filter( - (level) => typeof level === "string" && level.trim(), - ); + const reasoningLevels = item.reasoningLevels + ?.filter((level) => typeof level === "string" && level.trim()) + .map((level) => level.trim()); const defaultReasoningLevel = item.defaultReasoningLevel?.trim(); normalized.push({ diff --git a/src/components/providers/forms/hooks/useCodexConfigState.ts b/src/components/providers/forms/hooks/useCodexConfigState.ts index e6aa1ccc1..f1ead9c1e 100644 --- a/src/components/providers/forms/hooks/useCodexConfigState.ts +++ b/src/components/providers/forms/hooks/useCodexConfigState.ts @@ -29,6 +29,71 @@ function pickCodexApiKey( return extractCodexExperimentalBearerToken(configText) || ""; } +// 目录行 load 映射(隐藏字段白名单重建)。抽成可导出的纯函数,与 +// normalizeCodexCatalogModelsForSave 成对做 load→save 回环测试—— +// 任一侧丢字段都会静默清空供应商的逐模型声明(reasoningLevels、 +// baseInstructions 等),且 UI 无可察觉。 +export const mapCodexCatalogModelForForm = (item: any): CodexCatalogModel => { + // 隐藏字段(原生 Responses profile 用)不在行 UI 暴露,但必须 load→save + // 原样保留,否则编辑保存 MiMo/MiniMax 等会丢官方 base_instructions、 + // 并行工具、图像模态。DB SSOT 为 camelCase、live 反解兜底可能为 snake_case, + // 双格式兼容(与 displayName/contextWindow 一致)。 + const supportsParallelToolCalls = + typeof item?.supportsParallelToolCalls === "boolean" + ? item.supportsParallelToolCalls + : typeof item?.supports_parallel_tool_calls === "boolean" + ? item.supports_parallel_tool_calls + : undefined; + const inputModalities = Array.isArray(item?.inputModalities) + ? item.inputModalities + : Array.isArray(item?.input_modalities) + ? item.input_modalities + : undefined; + const baseInstructions = + typeof item?.baseInstructions === "string" + ? item.baseInstructions + : typeof item?.base_instructions === "string" + ? item.base_instructions + : undefined; + const reasoningLevels = Array.isArray(item?.reasoningLevels) + ? item.reasoningLevels + : Array.isArray(item?.reasoning_levels) + ? item.reasoning_levels + : undefined; + const defaultReasoningLevel = + typeof item?.defaultReasoningLevel === "string" + ? item.defaultReasoningLevel + : typeof item?.default_reasoning_level === "string" + ? item.default_reasoning_level + : undefined; + return { + model: typeof item?.model === "string" ? item.model : "", + displayName: + typeof item?.displayName === "string" + ? item.displayName + : typeof item?.display_name === "string" + ? item.display_name + : "", + contextWindow: + typeof item?.contextWindow === "string" || + typeof item?.contextWindow === "number" + ? item.contextWindow + : typeof item?.context_window === "string" || + typeof item?.context_window === "number" + ? item.context_window + : "", + ...(supportsParallelToolCalls !== undefined + ? { supportsParallelToolCalls } + : {}), + ...(inputModalities ? { inputModalities } : {}), + ...(baseInstructions ? { baseInstructions } : {}), + ...(reasoningLevels && reasoningLevels.length > 0 + ? { reasoningLevels } + : {}), + ...(defaultReasoningLevel ? { defaultReasoningLevel } : {}), + }; +}; + /** * 管理 Codex 配置状态 * Codex 配置包含两部分:auth.json (JSON) 和 config.toml (TOML 字符串) @@ -70,66 +135,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { : []; setCodexCatalogModels( rawCatalogModels - .map((item: any) => { - // 隐藏字段(原生 Responses profile 用)不在行 UI 暴露,但必须 load→save - // 原样保留,否则编辑保存 MiMo/MiniMax 等会丢官方 base_instructions、 - // 并行工具、图像模态。DB SSOT 为 camelCase、live 反解兜底可能为 snake_case, - // 双格式兼容(与 displayName/contextWindow 一致)。 - const supportsParallelToolCalls = - typeof item?.supportsParallelToolCalls === "boolean" - ? item.supportsParallelToolCalls - : typeof item?.supports_parallel_tool_calls === "boolean" - ? item.supports_parallel_tool_calls - : undefined; - const inputModalities = Array.isArray(item?.inputModalities) - ? item.inputModalities - : Array.isArray(item?.input_modalities) - ? item.input_modalities - : undefined; - const baseInstructions = - typeof item?.baseInstructions === "string" - ? item.baseInstructions - : typeof item?.base_instructions === "string" - ? item.base_instructions - : undefined; - const reasoningLevels = Array.isArray(item?.reasoningLevels) - ? item.reasoningLevels - : Array.isArray(item?.reasoning_levels) - ? item.reasoning_levels - : undefined; - const defaultReasoningLevel = - typeof item?.defaultReasoningLevel === "string" - ? item.defaultReasoningLevel - : typeof item?.default_reasoning_level === "string" - ? item.default_reasoning_level - : undefined; - return { - model: typeof item?.model === "string" ? item.model : "", - displayName: - typeof item?.displayName === "string" - ? item.displayName - : typeof item?.display_name === "string" - ? item.display_name - : "", - contextWindow: - typeof item?.contextWindow === "string" || - typeof item?.contextWindow === "number" - ? item.contextWindow - : typeof item?.context_window === "string" || - typeof item?.context_window === "number" - ? item.context_window - : "", - ...(supportsParallelToolCalls !== undefined - ? { supportsParallelToolCalls } - : {}), - ...(inputModalities ? { inputModalities } : {}), - ...(baseInstructions ? { baseInstructions } : {}), - ...(reasoningLevels && reasoningLevels.length > 0 - ? { reasoningLevels } - : {}), - ...(defaultReasoningLevel ? { defaultReasoningLevel } : {}), - }; - }) + .map(mapCodexCatalogModelForForm) .filter((item: CodexCatalogModel) => item.model.trim()), ); diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index e4941cca9..c075b0d62 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -1840,8 +1840,25 @@ requires_openai_auth = true`, ), endpointCandidates: ["https://opencode.ai/zen/go/v1"], apiFormat: "openai_chat", + // OpenCode Zen 网关:统一接受顶层 reasoning_effort(其自家客户端同款参数), + // 但合法档位逐模型(见各条目 reasoningLevels,镜像 models.dev;opencode + // 客户端同样严格按模型声明发值)——代理转换层按表钳制,未声明 effort 的 + // 模型(toggle 型如 glm-5.1)不发该字段。不发厂商原生 thinking 字段。 + codexChatReasoning: { + supportsThinking: true, + supportsEffort: true, + thinkingParam: "none", + effortParam: "reasoning_effort", + effortValueMode: "zen", + outputFormat: "reasoning_content", + }, modelCatalog: modelCatalog([ - { model: "glm-5.2", displayName: "GLM 5.2", contextWindow: 204800 }, + { + model: "glm-5.2", + displayName: "GLM 5.2", + contextWindow: 204800, + reasoningLevels: ["high", "max"], + }, { model: "glm-5.1", displayName: "GLM 5.1", contextWindow: 204800 }, { model: "kimi-k2.7-code", @@ -1852,11 +1869,13 @@ requires_openai_auth = true`, model: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro", contextWindow: 1048576, + reasoningLevels: ["high", "max"], }, { model: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash", contextWindow: 1048576, + reasoningLevels: ["low", "high", "max"], }, { model: "mimo-v2.5-pro", diff --git a/src/types.ts b/src/types.ts index 9930ae4d8..a04af690f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -142,7 +142,10 @@ export type CodexChatEffortValueMode = | "low_high" | "deepseek" // OpenRouter effort 枚举 xhigh|high|medium|low|minimal(无 max,max 钳到 xhigh) - | "openrouter"; + | "openrouter" + // OpenCode Zen 网关:合法档位逐模型,见 modelCatalog 各条目 reasoningLevels + // (镜像 models.dev);代理转换层按请求模型查表钳制,无表不发 effort 字段 + | "zen"; export type CodexChatReasoningOutputFormat = | "auto" diff --git a/tests/components/ProviderForm.codexCatalog.test.ts b/tests/components/ProviderForm.codexCatalog.test.ts index 95ad6fa37..65c06b183 100644 --- a/tests/components/ProviderForm.codexCatalog.test.ts +++ b/tests/components/ProviderForm.codexCatalog.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { normalizeCodexCatalogModelsForSave } from "@/components/providers/forms/ProviderForm"; +import { mapCodexCatalogModelForForm } from "@/components/providers/forms/hooks/useCodexConfigState"; describe("ProviderForm Codex catalog helpers", () => { it("normalizes catalog rows and removes empty or duplicate models", () => { @@ -76,4 +77,43 @@ describe("ProviderForm Codex catalog helpers", () => { { model: "plain-model" }, ]); }); + + it("round-trips reasoning levels through load and save without loss", () => { + // load→save 回环:加载映射(mapCodexCatalogModelForForm)与保存归一化 + // (normalizeCodexCatalogModelsForSave)各锁半边时,回环丢字段两边都测不出—— + // 而编辑保存丢表会让依赖逐模型档位的功能(zen 钳制)静默失效且 UI 无可察觉。 + const stored = [ + { + model: "glm-5.2", + displayName: "GLM 5.2", + reasoningLevels: ["high", "max"], + }, + // 手写/旧数据可能是 snake_case,加载侧兼容后保存侧同样要留住 + { model: "deepseek-v4-flash", reasoning_levels: ["low", "high", "max"] }, + { model: "glm-5.1" }, // toggle 型:无表,全程不得凭空造表 + ]; + + const roundTripped = normalizeCodexCatalogModelsForSave( + stored.map(mapCodexCatalogModelForForm), + ); + + expect(roundTripped).toEqual([ + { + model: "glm-5.2", + displayName: "GLM 5.2", + reasoningLevels: ["high", "max"], + }, + { model: "deepseek-v4-flash", reasoningLevels: ["low", "high", "max"] }, + { model: "glm-5.1" }, + ]); + }); + + it("trims reasoning level values on save", () => { + // 手编 JSON 里的 " high " 不得原样落库/发给上游。 + expect( + normalizeCodexCatalogModelsForSave([ + { model: "glm-5.2", reasoningLevels: [" high ", "max"] }, + ]), + ).toEqual([{ model: "glm-5.2", reasoningLevels: ["high", "max"] }]); + }); }); diff --git a/tests/config/codexChatProviderPresets.test.ts b/tests/config/codexChatProviderPresets.test.ts index d71d0f1ca..f1d38ca27 100644 --- a/tests/config/codexChatProviderPresets.test.ts +++ b/tests/config/codexChatProviderPresets.test.ts @@ -242,4 +242,29 @@ describe("Codex Chat provider presets", () => { expect(preset?.codexChatReasoning).toBeUndefined(); } }); + + it("ships per-model reasoningLevels for OpenCode Go mirroring models.dev", () => { + // Zen 网关的合法 effort 档位是逐模型的(models.dev reasoning_options, + // 2026-08):统一并集映射会把 Codex 默认的 medium 发给只声明 high|max 的 + // glm-5.2(默认路径),此测试锁住逐模型表,防回退。 + const preset = codexProviderPresets.find((item) => item.name === "OpenCode Go"); + + expect(preset, "OpenCode Go preset").toBeDefined(); + expect(preset?.codexChatReasoning?.effortValueMode).toBe("zen"); + expect( + Object.fromEntries( + (preset?.modelCatalog ?? []).map((model) => [ + model.model, + model.reasoningLevels ?? null, + ]), + ), + ).toEqual({ + "glm-5.2": ["high", "max"], + "glm-5.1": null, // toggle 型,无 effort 声明 + "kimi-k2.7-code": null, + "deepseek-v4-pro": ["high", "max"], + "deepseek-v4-flash": ["low", "high", "max"], + "mimo-v2.5-pro": null, + }); + }); }); diff --git a/tests/config/codexReasoningLevelPresets.test.ts b/tests/config/codexReasoningLevelPresets.test.ts index 17031ad92..82676018d 100644 --- a/tests/config/codexReasoningLevelPresets.test.ts +++ b/tests/config/codexReasoningLevelPresets.test.ts @@ -103,6 +103,15 @@ describe("Codex preset pre-filled reasoning levels", () => { ["Kimi For Coding", "kimi-for-coding-highspeed", ["high"]], ["Kimi For Coding", "k3", ["low", "high", "max"], "high"], ["Kimi For Coding", "k3-256k", ["low", "high", "max"], "high"], + // OpenCode Go(Zen 网关):opencode 客户端 variants() 严格按各模型在 + // models.dev 的 reasoning_options 声明发 reasoning_effort(provider/ + // transform.ts)——glm-5.2 / deepseek-v4-pro 仅 high|max、v4-flash 另 + // 声明 low(均无 none:effort 档位非思考开关,none 会是假开关)。本 PR + // 已为该预设补上 codexChatReasoning(effortValueMode:"zen"),代理转换 + // 层按同一张表逐模型钳制,表即下发生效的依据 + ["OpenCode Go", "glm-5.2", ["high", "max"]], + ["OpenCode Go", "deepseek-v4-pro", ["high", "max"]], + ["OpenCode Go", "deepseek-v4-flash", ["low", "high", "max"]], ]; it.each(EXPECTED)( @@ -117,15 +126,16 @@ describe("Codex preset pre-filled reasoning levels", () => { ); it("keeps deliberately-unfilled presets unfilled", () => { - // Bailian qwen3-coder-plus 无 per-model 档位证据;OpenCode Go 是多厂商 - // Chat 网关且无 codexChatReasoning 声明(思考开关不生效,填 none 会是假 - // 开关),逐模型语义未经网关验证前保持不填。SiliconFlow .cn 的 M2.5 能否 - // 真正关思考无官方明文、ModelScope 是否透传思考字段未证实——真机验证前 - // 不造两态假开关(2026-08-15 盘点结论) + // Bailian qwen3-coder-plus 无 per-model 档位证据。OpenCode Go 的 + // toggle/未收录模型保持不填:glm-5.1 是 toggle 型(models.dev 无 effort + // 声明)、kimi-k2.7-code 官方标注不支持 effort、mimo-v2.5-pro 未收录 + // models.dev——与 opencode 客户端一致(代理侧无表不发 reasoning_effort + // 字段)。SiliconFlow .cn 的 M2.5 能否真正关思考无官方明文、ModelScope + // 是否透传思考字段未证实——真机验证前不造两态假开关(2026-08-15 盘点结论) const UNFILLED: Array<[string, string]> = [ ["Bailian", "qwen3-coder-plus"], - ["OpenCode Go", "glm-5.2"], - ["OpenCode Go", "deepseek-v4-flash"], + ["OpenCode Go", "glm-5.1"], + ["OpenCode Go", "kimi-k2.7-code"], ["OpenCode Go", "mimo-v2.5-pro"], ["SiliconFlow", "Pro/MiniMaxAI/MiniMax-M2.5"], ["ModelScope", "ZhipuAI/GLM-5.2"],