From e2ee05d5e1a09234414584b913b94d474a7e24a9 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 30 Oct 2026 16:02:26 +0800 Subject: [PATCH] refactor(ai): centralize provider reasoning rules Centralize provider and model reasoning rules so request construction, models.dev projection, and fallback descriptors share one capability map. - Add provider-specific mappings for DeepSeek, GLM, DashScope, SiliconFlow, Claude, Gemini, and Codex. - Preserve structurally valid reasoning config when provider metadata or presets are unresolved. - Cover format fallback and documented effort and budget fields with tests. --- src/crates/adapters/ai-adapters/src/client.rs | 70 +- .../adapters/ai-adapters/src/client/format.rs | 5 + .../adapters/ai-adapters/src/client/quirks.rs | 96 +- .../adapters/ai-adapters/src/models_dev.rs | 363 ++++---- .../src/providers/anthropic/request.rs | 145 ++- .../src/providers/gemini/request.rs | 57 +- .../adapters/ai-adapters/src/providers/mod.rs | 1 + .../src/providers/openai/common.rs | 169 ++-- .../src/providers/openai/responses.rs | 27 +- .../ai-adapters/src/providers/reasoning.rs | 836 ++++++++++++++++++ .../ai-adapters/src/providers/shared.rs | 9 - .../core/src/service/config/providers.rs | 114 +-- .../core/src/service/config/service.rs | 6 +- .../config-contracts/src/normalization.rs | 12 - 14 files changed, 1364 insertions(+), 546 deletions(-) create mode 100644 src/crates/adapters/ai-adapters/src/providers/reasoning.rs diff --git a/src/crates/adapters/ai-adapters/src/client.rs b/src/crates/adapters/ai-adapters/src/client.rs index 047710e529..e64f6f76da 100644 --- a/src/crates/adapters/ai-adapters/src/client.rs +++ b/src/crates/adapters/ai-adapters/src/client.rs @@ -1513,7 +1513,7 @@ mod tests { } #[test] - fn openai_chat_rejects_unmapped_model_config_toggle_for_an_unverified_endpoint() { + fn openai_chat_uses_format_fallback_for_an_unverified_endpoint() { let client = AIClient::new(AIConfig { name: "openai-compatible".to_string(), base_url: "https://example.com/v1".to_string(), @@ -1538,16 +1538,16 @@ mod tests { vec![ReasoningPresetAction::Toggle { enabled: true }], )); - let error = openai::chat::try_build_request_body( + let body = openai::chat::try_build_request_body( &client, &client.config.request_url, vec![json!({ "role": "user", "content": "hello" })], None, None, ) - .expect_err("an unverified compatible endpoint must reject generic toggle"); + .expect("an unverified compatible endpoint should use the format fallback"); - assert!(error.to_string().contains("unsupported")); + assert_eq!(body["reasoning_effort"], "medium"); } #[test] @@ -1587,7 +1587,7 @@ mod tests { ); assert_eq!(request_body["thinking"]["type"], "enabled"); - assert_eq!(request_body["reasoning_effort"], "max"); + assert_eq!(request_body["reasoning_effort"], "high"); } #[test] @@ -1795,7 +1795,7 @@ mod tests { } #[test] - fn build_openbitfun_openai_request_body_maps_deepseek_pro_xhigh_to_max() { + fn build_openbitfun_openai_request_body_maps_deepseek_pro_xhigh_to_high() { let mut client = make_test_client("openai", None); client.config.name = "openbitfun".to_string(); client.config.base_url = "https://api.openbitfun.com/v1".to_string(); @@ -1819,7 +1819,7 @@ mod tests { ); assert_eq!(request_body["thinking"]["type"], "enabled"); - assert_eq!(request_body["reasoning_effort"], "max"); + assert_eq!(request_body["reasoning_effort"], "high"); } #[test] @@ -1926,6 +1926,60 @@ mod tests { assert!(request_body.get("thinking").is_none()); } + #[test] + fn siliconflow_effort_and_budget_use_documented_fields() { + let base_config = || AIConfig { + name: "siliconflow".to_string(), + base_url: "https://api.siliconflow.cn/v1".to_string(), + request_url: "https://api.siliconflow.cn/v1/chat/completions".to_string(), + api_key: "test-key".to_string(), + model: "Pro/zai-org/GLM-5.2".to_string(), + format: "openai".to_string(), + context_window: 128000, + max_tokens: Some(4096), + max_images_per_request: None, + temperature: None, + top_p: None, + inline_think_in_text: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + custom_request_body: None, + custom_request_body_mode: None, + }; + + let effort_client = AIClient::new(base_config()).with_reasoning_preset(&reasoning_preset( + "high", + vec![ReasoningPresetAction::Effort { + value: "xhigh".to_string(), + }], + )); + let effort_body = openai::chat::build_request_body( + &effort_client, + &effort_client.config.request_url, + vec![json!({ "role": "user", "content": "hello" })], + None, + None, + ); + assert_eq!(effort_body["enable_thinking"], true); + assert_eq!(effort_body["reasoning_effort"], "max"); + assert!(effort_body.get("thinking_budget").is_none()); + + let budget_client = AIClient::new(base_config()).with_reasoning_preset(&reasoning_preset( + "budget", + vec![ReasoningPresetAction::BudgetTokens { value: 4096 }], + )); + let budget_body = openai::chat::build_request_body( + &budget_client, + &budget_client.config.request_url, + vec![json!({ "role": "user", "content": "hello" })], + None, + None, + ); + assert_eq!(budget_body["enable_thinking"], true); + assert_eq!(budget_body["thinking_budget"], 4096); + } + #[test] fn build_responses_request_body_applies_explicit_none_effort() { let client = AIClient::new(AIConfig { @@ -2205,7 +2259,7 @@ mod tests { assert_eq!(request_body["thinking"]["type"], "enabled"); assert!(request_body["thinking"].get("budget_tokens").is_none()); - assert_eq!(request_body["output_config"]["effort"], "max"); + assert_eq!(request_body["output_config"]["effort"], "high"); } #[test] diff --git a/src/crates/adapters/ai-adapters/src/client/format.rs b/src/crates/adapters/ai-adapters/src/client/format.rs index 736704fcdd..b3c4af2f72 100644 --- a/src/crates/adapters/ai-adapters/src/client/format.rs +++ b/src/crates/adapters/ai-adapters/src/client/format.rs @@ -44,4 +44,9 @@ mod tests { ); } } + + #[test] + fn unknown_api_formats_remain_rejected() { + assert!(ApiFormat::parse("unknown-gateway").is_err()); + } } diff --git a/src/crates/adapters/ai-adapters/src/client/quirks.rs b/src/crates/adapters/ai-adapters/src/client/quirks.rs index 5763304778..bcbb305caa 100644 --- a/src/crates/adapters/ai-adapters/src/client/quirks.rs +++ b/src/crates/adapters/ai-adapters/src/client/quirks.rs @@ -1,80 +1,4 @@ -pub(crate) fn is_dashscope_url(url: &str) -> bool { - url.contains("dashscope.aliyuncs.com") || url.contains("dashscope-intl.aliyuncs.com") -} - -pub(crate) fn is_siliconflow_url(url: &str) -> bool { - url.contains("api.siliconflow.cn") || url.contains("api.siliconflow.com") -} - -pub(crate) fn is_deepseek_url(url: &str) -> bool { - url.contains("api.deepseek.com") -} - -pub(crate) fn is_zhipuai_url(url: &str) -> bool { - reqwest::Url::parse(url.trim()).ok().is_some_and(|url| { - url.scheme() == "https" - && url.port_or_known_default() == Some(443) - && url.host_str().is_some_and(|host| { - matches!(host.trim_end_matches('.'), "open.bigmodel.cn" | "api.z.ai") - }) - }) -} - -pub(crate) fn is_deepseek_reasoning_effort_model(model_name: &str) -> bool { - matches!( - model_name.trim().to_ascii_lowercase().as_str(), - "deepseek-v4-flash" | "deepseek-v4-pro" - ) -} - -pub(crate) fn is_glm_52_reasoning_effort_model(model_name: &str) -> bool { - matches!( - model_name.trim().to_ascii_lowercase().as_str(), - "glm-5.2" | "glm-5-2" | "glm-5p2" - ) -} - -pub(crate) fn normalize_deepseek_reasoning_effort( - model_name: &str, - effort: &str, -) -> Option<&'static str> { - let is_flash = model_name.trim().eq_ignore_ascii_case("deepseek-v4-flash"); - match effort.trim().to_ascii_lowercase().as_str() { - "" => None, - "low" if is_flash => Some("low"), - "high" => Some("high"), - "max" => Some("max"), - "low" | "medium" => Some("high"), - "xhigh" => Some("max"), - "none" | "minimal" => None, - _ => None, - } -} - -pub(crate) fn normalize_glm_52_reasoning_effort(effort: &str) -> Option<&'static str> { - match effort.trim().to_ascii_lowercase().as_str() { - "high" | "low" | "medium" => Some("high"), - "max" | "xhigh" => Some("max"), - "" | "none" | "minimal" => None, - _ => None, - } -} - -pub(crate) fn parse_glm_major_minor(model_name: &str) -> Option<(u32, u32)> { - let lower = model_name.to_ascii_lowercase(); - let tail = lower.strip_prefix("glm-")?; - let mut parts = tail.split('-'); - let version = parts.next()?; - - let mut version_parts = version.split('.'); - let major = version_parts.next()?.parse().ok()?; - let minor = version_parts - .next() - .and_then(|value| value.parse().ok()) - .unwrap_or(0); - - Some((major, minor)) -} +use crate::providers::reasoning::parse_glm_major_minor; pub(crate) fn should_append_tool_stream(url: &str, model_name: &str) -> bool { if url.contains("bigmodel.cn") || url.contains("api.z.ai") { @@ -88,21 +12,3 @@ pub(crate) fn should_append_tool_stream(url: &str, model_name: &str) -> bool { parse_glm_major_minor(model_name) .is_some_and(|(major, minor)| major > 4 || (major == 4 && minor >= 5)) } - -pub(crate) fn apply_openai_compatible_toggle( - request_body: &mut serde_json::Value, - enabled: bool, - url: &str, -) -> bool { - if is_dashscope_url(url) || is_siliconflow_url(url) { - request_body["enable_thinking"] = serde_json::json!(enabled); - return true; - } - if is_deepseek_url(url) { - request_body["thinking"] = serde_json::json!({ - "type": if enabled { "enabled" } else { "disabled" } - }); - return true; - } - false -} diff --git a/src/crates/adapters/ai-adapters/src/models_dev.rs b/src/crates/adapters/ai-adapters/src/models_dev.rs index 76c6510850..6dd4f39001 100644 --- a/src/crates/adapters/ai-adapters/src/models_dev.rs +++ b/src/crates/adapters/ai-adapters/src/models_dev.rs @@ -11,14 +11,13 @@ use serde::Deserialize; use serde_json::Value; use std::collections::{BTreeMap, HashMap}; -use crate::client::quirks::{ - is_deepseek_reasoning_effort_model, is_deepseek_url, is_glm_52_reasoning_effort_model, - is_zhipuai_url, normalize_deepseek_reasoning_effort, normalize_glm_52_reasoning_effort, +use crate::providers::reasoning::{ + self, api_format_for_provider, is_codex_chatgpt_path, is_known_codex_reasoning_model, + is_responses_endpoint, normalize_dashscope_reasoning_effort, + normalize_deepseek_reasoning_effort, normalize_glm_52_reasoning_effort, + normalize_glm_53_reasoning_effort, normalize_siliconflow_reasoning_effort, + AnthropicThinkingCapability, ReasoningRule, }; -use crate::providers::anthropic::request::{ - anthropic_thinking_capability, AnthropicThinkingCapability, -}; -use crate::providers::openai::common::is_known_codex_reasoning_model; #[derive(Debug, Clone, PartialEq)] pub struct ModelsDevCatalog { @@ -681,8 +680,15 @@ pub fn project_reasoning_catalog_with_limit_and_auto_binding( // tested adapter gap for trusted auto bindings; explicit bindings // to arbitrary gateways do not gain adapter-inferred capability. if matches!(binding, ReasoningCatalogBinding::Auto) - && source_provider.eq_ignore_ascii_case("zhipuai") - && is_glm_52_reasoning_effort_model(&source_model.id) + && matches!( + reasoning::resolve_reasoning_rule( + api_format_for_provider(provider, base_url), + source_provider, + base_url, + &source_model.id, + ), + ReasoningRule::Glm52 + ) && support.toggle { for descriptor in toggle_descriptors( @@ -846,6 +852,17 @@ fn auto_provider_id(provider: &str, base_url: &str) -> Option<&'static str> { ("gemini" | "google", "generativelanguage.googleapis.com") => Some("google"), ("deepseek" | "openai" | "anthropic", "api.deepseek.com") => Some("deepseek"), ("openai" | "anthropic", "open.bigmodel.cn" | "api.z.ai") => Some("zhipuai"), + ( + "openai" | "siliconflow" | "silicon_flow", + "api.siliconflow.cn" | "api.siliconflow.com", + ) => Some("siliconflow"), + ("openai" | "dashscope" | "alibaba" | "aliyun", host) + if host.ends_with(".maas.aliyuncs.com") + || host == "dashscope.aliyuncs.com" + || host == "dashscope-intl.aliyuncs.com" => + { + Some("dashscope") + } _ => None, } } @@ -856,101 +873,18 @@ fn adapter_reasoning_support( execution_model: &str, execution_provider: &str, ) -> AdapterReasoningSupport { - let provider = provider.trim().to_ascii_lowercase(); - let execution_provider = execution_provider.trim().to_ascii_lowercase(); - let execution_model = execution_model.trim().to_ascii_lowercase(); - if provider == "deepseek" || base_url.to_ascii_lowercase().contains("api.deepseek.com") { - return AdapterReasoningSupport { - effort: true, - toggle: true, - ..Default::default() - }; - } - if execution_provider == "deepseek" - && is_deepseek_reasoning_effort_model(&execution_model) - && matches!(provider.as_str(), "openai" | "anthropic") - { - return AdapterReasoningSupport { - effort: true, - toggle: true, - ..Default::default() - }; - } - if (execution_provider == "zhipuai" - && is_glm_52_reasoning_effort_model(&execution_model) - && matches!(provider.as_str(), "openai" | "anthropic")) - || (is_zhipuai_url(base_url) - && is_glm_52_reasoning_effort_model(&execution_model) - && matches!(provider.as_str(), "openai" | "anthropic")) - { - return AdapterReasoningSupport { - effort: true, - toggle: true, - ..Default::default() - }; - } - if matches!(provider.as_str(), "response" | "responses") - || (provider == "openai" && is_responses_endpoint(base_url)) - { - return AdapterReasoningSupport { - effort: true, - nullable_effort: true, - ..Default::default() - }; - } - match provider.as_str() { - "anthropic" => { - let capability = anthropic_thinking_capability(&execution_model); - AdapterReasoningSupport { - effort: !matches!(capability, AnthropicThinkingCapability::ManualOnly), - toggle: !matches!( - capability, - AnthropicThinkingCapability::AdaptiveOnly - | AnthropicThinkingCapability::AdaptiveDefaultNoDisabled - ), - budget_tokens: !matches!( - capability, - AnthropicThinkingCapability::AdaptiveOnly - | AnthropicThinkingCapability::AdaptiveDefaultNoDisabled - ), - ..Default::default() - } - } - "gemini" | "google" => gemini_reasoning_support(&execution_model), - _ => Default::default(), + let format = api_format_for_provider(provider, base_url); + let rule = + reasoning::resolve_reasoning_rule(format, execution_provider, base_url, execution_model); + let capabilities = rule.capabilities(format); + AdapterReasoningSupport { + effort: capabilities.effort, + toggle: capabilities.toggle, + budget_tokens: capabilities.budget_tokens, + nullable_effort: capabilities.nullable_effort, } } -fn gemini_reasoning_support(model: &str) -> AdapterReasoningSupport { - let model = model.trim().to_ascii_lowercase(); - if model.starts_with("gemini-3-") { - return AdapterReasoningSupport { - effort: true, - ..Default::default() - }; - } - if model.starts_with("gemini-2.5-") { - return AdapterReasoningSupport { - toggle: model.contains("flash"), - budget_tokens: true, - ..Default::default() - }; - } - AdapterReasoningSupport::default() -} - -fn is_responses_endpoint(base_url: &str) -> bool { - base_url - .trim_end_matches('/') - .to_ascii_lowercase() - .ends_with("/responses") -} - -fn is_codex_chatgpt_path(path: &str) -> bool { - let path = path.trim_end_matches('/'); - path == "/backend-api/codex" || path == "/backend-api/codex/responses" -} - fn adapter_fallback_descriptors( provider: &str, model_id: &str, @@ -963,12 +897,16 @@ fn adapter_fallback_descriptors( }; let model_id = model_id.trim().to_ascii_lowercase(); let source = ReasoningPresetSource::AdapterFallback; + let format = api_format_for_provider(provider, base_url); + let rule = reasoning::resolve_reasoning_rule(format, provider_id, base_url, &model_id); match provider_id { - // Keep these tables deliberately conservative. A future model is not - // assumed compatible merely because the protocol has an effort field. + // Prefer tested provider/model mappings, then use the protocol-level + // fallback for models that are newer or gateway-specific. "openai" if support.effort => { - if is_codex_chatgpt_base_url(base_url) && is_known_codex_reasoning_model(&model_id) { + if matches!(rule, ReasoningRule::CodexChatGpt) + && is_known_codex_reasoning_model(&model_id) + { // The Codex adapter clamps unsupported `minimal` and uses // medium by default. low/medium/high is the tested common set // across its built-in model table. @@ -1013,12 +951,12 @@ fn adapter_fallback_descriptors( } } } - "anthropic" => match anthropic_thinking_capability(&model_id) { - AnthropicThinkingCapability::AdaptivePreferred - | AnthropicThinkingCapability::AdaptiveOnly - | AnthropicThinkingCapability::AdaptiveDefaultNoDisabled - if support.effort => - { + "anthropic" => match rule { + ReasoningRule::Claude( + AnthropicThinkingCapability::AdaptivePreferred + | AnthropicThinkingCapability::AdaptiveOnly + | AnthropicThinkingCapability::AdaptiveDefaultNoDisabled, + ) if support.effort => { // low/medium/high is the conservative common subset for the // adaptive families recognized by the request adapter. More // model-specific values such as max/xhigh remain models.dev @@ -1040,21 +978,52 @@ fn adapter_fallback_descriptors( // the unknown/default classification, so it must never become a // family-wide fallback. Budget choices are derived from max_tokens // at request time, so the catalog exposes only a safe on/off mode. - AnthropicThinkingCapability::ManualOnly + ReasoningRule::Claude(AnthropicThinkingCapability::ManualOnly) if matches!(model_id.as_str(), "claude-sonnet-4-5" | "claude-haiku-4-5") && support.toggle => { toggle_descriptors(source, provider, model_id.as_str()) } + ReasoningRule::Claude(AnthropicThinkingCapability::ManualEffort) + if support.effort || support.budget_tokens => + { + let mut descriptors = toggle_descriptors(source, provider, model_id.as_str()); + if support.effort { + descriptors.extend(effort_descriptors( + &[ + Some("low".into()), + Some("medium".into()), + Some("high".into()), + ], + false, + source, + provider, + model_id.as_str(), + )); + } + if support.budget_tokens { + descriptors.extend(budget_descriptors( + Some(1_024), + None, + None, + effective_max_output_tokens, + provider, + source, + provider, + model_id.as_str(), + )); + } + descriptors + } _ => Vec::new(), }, "deepseek" - if is_deepseek_reasoning_effort_model(&model_id) + if matches!(rule, ReasoningRule::DeepSeek { .. }) && support.effort && support.toggle => { let mut descriptors = toggle_descriptors(source, provider, model_id.as_str()); - let efforts = if model_id == "deepseek-v4-flash" { + let efforts = if matches!(rule, ReasoningRule::DeepSeek { flash: true }) { vec![Some("low".into()), Some("high".into()), Some("max".into())] } else { vec![Some("high".into()), Some("max".into())] @@ -1068,13 +1037,20 @@ fn adapter_fallback_descriptors( )); descriptors } - "zhipuai" if is_glm_52_reasoning_effort_model(&model_id) && support.effort => { + "zhipuai" + if matches!(rule, ReasoningRule::Glm52 | ReasoningRule::Glm53) && support.effort => + { let mut descriptors = Vec::new(); if support.toggle { descriptors.extend(toggle_descriptors(source, provider, model_id.as_str())); } + let efforts = if matches!(rule, ReasoningRule::Glm53) { + &[Some("low".into()), Some("high".into()), Some("max".into())][..] + } else { + &[Some("high".into()), Some("max".into())][..] + }; descriptors.extend(effort_descriptors( - &[Some("high".into()), Some("max".into())], + efforts, false, source, provider, @@ -1082,18 +1058,88 @@ fn adapter_fallback_descriptors( )); descriptors } - "google" if support.effort && model_id.starts_with("gemini-3-") => effort_descriptors( - &[ - Some("low".into()), - Some("medium".into()), - Some("high".into()), - ], - false, - source, - provider, - model_id.as_str(), - ), - "google" if support.budget_tokens && model_id.starts_with("gemini-2.5-") => { + "siliconflow" | "silicon_flow" + if matches!(rule, ReasoningRule::SiliconFlow { .. }) + && (support.effort || support.toggle || support.budget_tokens) => + { + let mut descriptors = toggle_descriptors(source, provider, model_id.as_str()); + if support.effort { + descriptors.extend(effort_descriptors( + &[Some("high".into()), Some("max".into())], + false, + source, + provider, + model_id.as_str(), + )); + } + if support.budget_tokens { + descriptors.extend(budget_descriptors( + Some(128), + Some(32_768), + None, + effective_max_output_tokens, + provider, + source, + provider, + model_id.as_str(), + )); + } + descriptors + } + "dashscope" | "alibaba" | "aliyun" + if matches!(rule, ReasoningRule::DashScope { .. }) + && (support.effort || support.toggle || support.budget_tokens) => + { + let mut descriptors = Vec::new(); + if support.toggle { + descriptors.extend(toggle_descriptors(source, provider, model_id.as_str())); + } + if support.effort { + let efforts = if matches!(rule, ReasoningRule::DashScope { forced: true, .. }) { + &[Some("low".into()), Some("high".into()), Some("max".into())][..] + } else { + &[Some("high".into()), Some("max".into())][..] + }; + descriptors.extend(effort_descriptors( + efforts, + false, + source, + provider, + model_id.as_str(), + )); + } + if support.budget_tokens { + descriptors.extend(budget_descriptors( + Some(1), + None, + None, + effective_max_output_tokens, + provider, + source, + provider, + model_id.as_str(), + )); + } + descriptors + } + "google" if support.effort && matches!(rule, ReasoningRule::Gemini3 { .. }) => { + let values = if matches!(rule, ReasoningRule::Gemini3 { minimal: true }) { + vec![ + Some("minimal".into()), + Some("low".into()), + Some("medium".into()), + Some("high".into()), + ] + } else { + vec![ + Some("low".into()), + Some("medium".into()), + Some("high".into()), + ] + }; + effort_descriptors(&values, false, source, provider, model_id.as_str()) + } + "google" if support.budget_tokens && matches!(rule, ReasoningRule::Gemini25 { .. }) => { budget_descriptors( Some(1_024), None, @@ -1158,19 +1204,6 @@ fn adapter_fallback_provider_id(provider: &str, base_url: &str) -> Option<&'stat } } -fn is_codex_chatgpt_base_url(base_url: &str) -> bool { - reqwest::Url::parse(base_url.trim()) - .ok() - .is_some_and(|url| { - url.scheme() == "https" - && url.port_or_known_default() == Some(443) - && url - .host_str() - .is_some_and(|host| host.trim_end_matches('.') == "chatgpt.com") - && is_codex_chatgpt_path(url.path()) - }) -} - // Display aliases follow the existing executing adapter without changing which // presets the catalog recognizes, or their persisted ids and request actions. fn reasoning_display_effort( @@ -1185,8 +1218,10 @@ fn reasoning_display_effort( return None; } let provider = provider.trim().to_ascii_lowercase(); - if !matches!(provider.as_str(), "openai" | "anthropic" | "deepseek") - || (provider == "openai" && is_responses_endpoint(base_url)) + if !matches!( + provider.as_str(), + "openai" | "anthropic" | "deepseek" | "zhipuai" | "siliconflow" | "dashscope" + ) || (provider == "openai" && is_responses_endpoint(base_url)) { return None; } @@ -1195,17 +1230,24 @@ fn reasoning_display_effort( ReasoningPresetAction::Effort { value } => Some(value.as_str()), _ => None, })?; - if execution_provider.eq_ignore_ascii_case("deepseek") - || is_deepseek_url(base_url) - || is_deepseek_reasoning_effort_model(execution_model) - { - normalize_deepseek_reasoning_effort(execution_model, effort).map(ToOwned::to_owned) - } else if is_glm_52_reasoning_effort_model(execution_model) - && (execution_provider.eq_ignore_ascii_case("zhipuai") || is_zhipuai_url(base_url)) - { - normalize_glm_52_reasoning_effort(effort).map(ToOwned::to_owned) - } else { - None + match reasoning::resolve_reasoning_rule( + api_format_for_provider(&provider, base_url), + execution_provider, + base_url, + execution_model, + ) { + ReasoningRule::DeepSeek { .. } => { + normalize_deepseek_reasoning_effort(execution_model, effort).map(ToOwned::to_owned) + } + ReasoningRule::Glm52 => normalize_glm_52_reasoning_effort(effort).map(ToOwned::to_owned), + ReasoningRule::Glm53 => normalize_glm_53_reasoning_effort(effort).map(ToOwned::to_owned), + ReasoningRule::SiliconFlow { effort: true } => { + normalize_siliconflow_reasoning_effort(effort).map(ToOwned::to_owned) + } + ReasoningRule::DashScope { effort: true, .. } => { + normalize_dashscope_reasoning_effort(execution_model, effort).map(ToOwned::to_owned) + } + _ => None, } } @@ -2185,7 +2227,7 @@ mod tests { } #[test] - fn official_google_endpoint_rejects_unverified_gemini_family_options() { + fn official_google_endpoint_uses_the_gemini_format_fallback_for_unknown_models() { let projection = project_reasoning_catalog( "gemini", "gemini-test", @@ -2194,8 +2236,15 @@ mod tests { Some(&catalog()), ); - assert_eq!(projection.status, ReasoningCapabilityStatus::Unknown); - assert!(projection.presets.is_empty()); + assert_eq!(projection.status, ReasoningCapabilityStatus::Known); + assert_eq!( + projection + .presets + .iter() + .map(|preset| preset.id.as_str()) + .collect::>(), + vec!["off", "on"] + ); } #[test] @@ -2280,7 +2329,7 @@ mod tests { ( "deepseek", "deepseek-v4-flash", - ["low", "high", "high", "max", "max"], + ["low", "high", "high", "high", "max"], ), ] { let source = ModelsDevCatalog::parse_str( diff --git a/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs b/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs index 540cffc319..f07aa8e431 100644 --- a/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs +++ b/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs @@ -1,11 +1,12 @@ use super::AnthropicMessageConverter; -use crate::client::quirks::{ - is_deepseek_reasoning_effort_model, is_deepseek_url, is_glm_52_reasoning_effort_model, - is_zhipuai_url, normalize_deepseek_reasoning_effort, normalize_glm_52_reasoning_effort, - should_append_tool_stream, -}; +use crate::client::quirks::should_append_tool_stream; use crate::client::sse::execute_sse_request; use crate::client::{AIClient, StreamResponse}; +use crate::providers::reasoning::{ + self, normalize_deepseek_reasoning_effort, normalize_glm_52_reasoning_effort, + normalize_glm_53_reasoning_effort, resolve_reasoning_rule, AnthropicThinkingCapability, + ReasoningApiFormat, ReasoningRule, +}; use crate::providers::shared; use crate::stream::handle_anthropic_stream; use crate::trace::ModelExchangeTraceConfig; @@ -16,19 +17,7 @@ use anyhow::{anyhow, Result}; use log::debug; use reqwest::RequestBuilder; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum AnthropicThinkingCapability { - ManualOnly, - AdaptivePreferred, - AdaptiveOnly, - AdaptiveDefaultNoDisabled, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ClaudeModelVersion { - major: u32, - minor: u32, -} +pub(crate) use crate::providers::reasoning::anthropic_thinking_capability; /// Anthropic-compatible endpoints whose vendors document `Authorization: Bearer` /// (ANTHROPIC_AUTH_TOKEN) instead of `x-api-key`: Zhipu bigmodel.cn / Z.AI, @@ -89,49 +78,13 @@ pub(crate) fn apply_headers( }) } -fn parse_claude_model_version(model_name: &str, family: &str) -> Option { - let prefix = format!("claude-{family}-"); - let rest = model_name.strip_prefix(&prefix)?; - let mut parts = rest.split('-'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next().and_then(|part| part.parse().ok()).unwrap_or(0); - Some(ClaudeModelVersion { major, minor }) -} - -pub(crate) fn anthropic_thinking_capability(model_name: &str) -> AnthropicThinkingCapability { - if model_name.starts_with("claude-mythos") || model_name.starts_with("claude-fable") { - return AnthropicThinkingCapability::AdaptiveDefaultNoDisabled; - } - - if let Some(version) = parse_claude_model_version(model_name, "opus") { - if version.major > 4 || (version.major == 4 && version.minor >= 7) { - return AnthropicThinkingCapability::AdaptiveOnly; - } - if version.major == 4 && version.minor >= 6 { - return AnthropicThinkingCapability::AdaptivePreferred; - } - } - - if let Some(version) = parse_claude_model_version(model_name, "sonnet") { - if version.major > 4 || (version.major == 4 && version.minor >= 7) { - return AnthropicThinkingCapability::AdaptiveOnly; - } - if version.major == 4 && version.minor >= 6 { - return AnthropicThinkingCapability::AdaptivePreferred; - } - } - - if let Some(version) = parse_claude_model_version(model_name, "haiku") { - if version.major > 4 || (version.major == 4 && version.minor >= 7) { - return AnthropicThinkingCapability::AdaptiveOnly; - } - } - - AnthropicThinkingCapability::ManualOnly -} - fn anthropic_supports_adaptive_reasoning(capability: AnthropicThinkingCapability) -> bool { - !matches!(capability, AnthropicThinkingCapability::ManualOnly) + matches!( + capability, + AnthropicThinkingCapability::AdaptivePreferred + | AnthropicThinkingCapability::AdaptiveOnly + | AnthropicThinkingCapability::AdaptiveDefaultNoDisabled + ) } fn default_anthropic_budget_tokens(max_tokens: u32) -> u32 { @@ -173,14 +126,14 @@ fn compile_reasoning_action( if is_generic_reasoning { return match action { - ReasoningPresetAction::Effort { value } => { - let normalized = - shared::normalize_generic_reasoning_effort(value).ok_or_else(|| { - anyhow!("Generic reasoning effort '{}' is unsupported", value) - })?; - apply_anthropic_adaptive_reasoning(request_body, Some(normalized)); - Ok(true) - } + ReasoningPresetAction::Effort { value } => reasoning::compile_format_fallback( + ReasoningApiFormat::Anthropic, + &ReasoningPresetAction::Effort { + value: value.clone(), + }, + request_body, + Some(max_tokens), + ), ReasoningPresetAction::Toggle { enabled: true } => { apply_anthropic_adaptive_reasoning(request_body, None); Ok(true) @@ -199,11 +152,14 @@ fn compile_reasoning_action( }; } - let is_deepseek_reasoning_target = execution_provider.eq_ignore_ascii_case("deepseek") - || is_deepseek_url(url) - || is_deepseek_reasoning_effort_model(&execution_model); + let rule = resolve_reasoning_rule( + ReasoningApiFormat::Anthropic, + execution_provider, + url, + &execution_model, + ); - if is_deepseek_reasoning_target { + if matches!(rule, ReasoningRule::DeepSeek { .. }) { return match action { ReasoningPresetAction::Toggle { enabled } => { request_body["thinking"] = serde_json::json!({ @@ -235,14 +191,15 @@ fn compile_reasoning_action( }; } - let is_glm_52_reasoning_target = is_glm_52_reasoning_effort_model(&execution_model) - && (execution_provider.eq_ignore_ascii_case("zhipuai") || is_zhipuai_url(url)); - if is_glm_52_reasoning_target { + if matches!(rule, ReasoningRule::Glm52 | ReasoningRule::Glm53) { return match action { ReasoningPresetAction::Effort { value } => { - let normalized = normalize_glm_52_reasoning_effort(value).ok_or_else(|| { - anyhow!("GLM-5.2 reasoning effort '{}' is unsupported", value) - })?; + let normalized = match rule { + ReasoningRule::Glm52 => normalize_glm_52_reasoning_effort(value), + ReasoningRule::Glm53 => normalize_glm_53_reasoning_effort(value), + _ => None, + } + .ok_or_else(|| anyhow!("GLM reasoning effort '{}' is unsupported", value))?; request_body["thinking"] = serde_json::json!({ "type": "adaptive" }); request_body["output_config"] = serde_json::json!({ "effort": normalized @@ -250,6 +207,10 @@ fn compile_reasoning_action( Ok(true) } ReasoningPresetAction::Toggle { enabled } => { + if matches!(rule, ReasoningRule::Glm53) && !enabled { + request_body["thinking"] = serde_json::json!({ "type": "adaptive" }); + return Ok(true); + } request_body["thinking"] = serde_json::json!({ "type": if *enabled { "adaptive" } else { "disabled" } }); @@ -267,15 +228,35 @@ fn compile_reasoning_action( }; } - let capability = anthropic_thinking_capability(&execution_model); + let capability = match rule { + ReasoningRule::Claude(capability) => capability, + _ => AnthropicThinkingCapability::ManualOnly, + }; match action { + ReasoningPresetAction::Effort { value } + if matches!(capability, AnthropicThinkingCapability::ManualEffort) => + { + request_body["thinking"] = serde_json::json!({ + "type": "enabled", + "budget_tokens": default_anthropic_budget_tokens(max_tokens) + }); + request_body["output_config"] = serde_json::json!({ "effort": value }); + Ok(true) + } ReasoningPresetAction::Effort { value } if anthropic_supports_adaptive_reasoning(capability) => { apply_anthropic_adaptive_reasoning(request_body, Some(value)); Ok(true) } - ReasoningPresetAction::Effort { .. } => Ok(false), + ReasoningPresetAction::Effort { value } => reasoning::compile_format_fallback( + ReasoningApiFormat::Anthropic, + &ReasoningPresetAction::Effort { + value: value.clone(), + }, + request_body, + Some(max_tokens), + ), ReasoningPresetAction::Toggle { enabled: false } if !matches!( capability, @@ -1056,6 +1037,10 @@ mod tests { anthropic_thinking_capability("claude-haiku-4-5"), ManualOnly ); + assert_eq!( + anthropic_thinking_capability("claude-opus-4-5"), + ManualEffort + ); } #[test] diff --git a/src/crates/adapters/ai-adapters/src/providers/gemini/request.rs b/src/crates/adapters/ai-adapters/src/providers/gemini/request.rs index 987896eeff..f8f319df43 100644 --- a/src/crates/adapters/ai-adapters/src/providers/gemini/request.rs +++ b/src/crates/adapters/ai-adapters/src/providers/gemini/request.rs @@ -1,6 +1,9 @@ use super::GeminiMessageConverter; use crate::client::sse::execute_sse_request; use crate::client::{AIClient, StreamResponse}; +use crate::providers::reasoning::{ + self, resolve_reasoning_rule, ReasoningApiFormat, ReasoningRule, +}; use crate::providers::shared; use crate::stream::handle_gemini_stream; use crate::trace::ModelExchangeTraceConfig; @@ -97,27 +100,36 @@ fn compile_reasoning_action( .trim() .to_ascii_lowercase(); let is_generic_reasoning = shared::is_generic_reasoning_preset(preset); + let rule = resolve_reasoning_rule(ReasoningApiFormat::Gemini, "google", "", &model); match action { ReasoningPresetAction::Effort { value } - if model.starts_with("gemini-3-") || is_generic_reasoning => + if matches!(rule, ReasoningRule::Gemini3 { .. }) => { - let value = if is_generic_reasoning { - shared::normalize_generic_reasoning_effort(value) - .ok_or_else(|| anyhow!("Generic reasoning effort '{}' is unsupported", value))? - } else { - value.trim() - }; + let normalized = reasoning::normalize_gemini_reasoning_effort(&model, value) + .ok_or_else(|| anyhow!("Gemini reasoning effort '{}' is unsupported", value))?; insert_generation_field( request_body, "thinkingConfig", serde_json::json!({ "includeThoughts": true, - "thinkingLevel": value.to_ascii_uppercase(), + "thinkingLevel": normalized, }), ); Ok(true) } - ReasoningPresetAction::BudgetTokens { value } if model.starts_with("gemini-2.5-") => { + ReasoningPresetAction::Effort { value } if is_generic_reasoning => { + reasoning::compile_format_fallback( + ReasoningApiFormat::Gemini, + &ReasoningPresetAction::Effort { + value: value.clone(), + }, + request_body, + max_tokens, + ) + } + ReasoningPresetAction::BudgetTokens { value } + if matches!(rule, ReasoningRule::Gemini25 { .. }) => + { if max_tokens.is_some_and(|limit| *value > limit) { return Err(anyhow!( "Gemini reasoning budget {} exceeds max output tokens {}", @@ -135,7 +147,9 @@ fn compile_reasoning_action( ); Ok(true) } - ReasoningPresetAction::Toggle { enabled } if model.starts_with("gemini-2.5-flash") => { + ReasoningPresetAction::Toggle { enabled } + if matches!(rule, ReasoningRule::Gemini25 { flash: true }) => + { insert_generation_field( request_body, "thinkingConfig", @@ -164,9 +178,26 @@ fn compile_reasoning_action( ); Ok(true) } - ReasoningPresetAction::Effort { .. } - | ReasoningPresetAction::Toggle { .. } - | ReasoningPresetAction::BudgetTokens { .. } => Ok(false), + ReasoningPresetAction::Effort { value } => reasoning::compile_format_fallback( + ReasoningApiFormat::Gemini, + &ReasoningPresetAction::Effort { + value: value.clone(), + }, + request_body, + max_tokens, + ), + ReasoningPresetAction::Toggle { enabled } => reasoning::compile_format_fallback( + ReasoningApiFormat::Gemini, + &ReasoningPresetAction::Toggle { enabled: *enabled }, + request_body, + max_tokens, + ), + ReasoningPresetAction::BudgetTokens { value } => reasoning::compile_format_fallback( + ReasoningApiFormat::Gemini, + &ReasoningPresetAction::BudgetTokens { value: *value }, + request_body, + max_tokens, + ), ReasoningPresetAction::RequestPatch { .. } => { unreachable!("patches are compiled by shared code") } diff --git a/src/crates/adapters/ai-adapters/src/providers/mod.rs b/src/crates/adapters/ai-adapters/src/providers/mod.rs index fe18f0ef4c..f4fd281cda 100644 --- a/src/crates/adapters/ai-adapters/src/providers/mod.rs +++ b/src/crates/adapters/ai-adapters/src/providers/mod.rs @@ -6,6 +6,7 @@ pub mod anthropic; pub mod gemini; pub mod openai; pub(crate) mod opencode; +pub(crate) mod reasoning; pub(crate) mod shared; pub use anthropic::AnthropicMessageConverter; diff --git a/src/crates/adapters/ai-adapters/src/providers/openai/common.rs b/src/crates/adapters/ai-adapters/src/providers/openai/common.rs index 94672763ac..e9ca60b222 100644 --- a/src/crates/adapters/ai-adapters/src/providers/openai/common.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/common.rs @@ -1,10 +1,11 @@ -use crate::client::quirks::{ - apply_openai_compatible_toggle, is_deepseek_reasoning_effort_model, is_deepseek_url, - is_glm_52_reasoning_effort_model, is_zhipuai_url, normalize_deepseek_reasoning_effort, - normalize_glm_52_reasoning_effort, -}; use crate::client::utils::{dedupe_remote_models, normalize_base_url_for_discovery}; use crate::client::AIClient; +use crate::providers::reasoning::{ + self, normalize_dashscope_reasoning_effort, normalize_deepseek_reasoning_effort, + normalize_glm_52_reasoning_effort, normalize_glm_53_reasoning_effort, + normalize_siliconflow_reasoning_effort, resolve_reasoning_rule, ReasoningApiFormat, + ReasoningRule, +}; use crate::providers::shared; use crate::types::{ ReasoningPresetAction, ReasoningPresetDescriptor, RemoteModelInfo, ToolDefinition, @@ -51,11 +52,14 @@ pub(crate) fn compile_chat_reasoning_action( .unwrap_or(configured_model) .trim() .to_ascii_lowercase(); - let is_deepseek_reasoning_target = execution_provider.eq_ignore_ascii_case("deepseek") - || is_deepseek_url(url) - || is_deepseek_reasoning_effort_model(&execution_model); - let is_glm_52_reasoning_target = is_glm_52_reasoning_effort_model(&execution_model) - && (execution_provider.eq_ignore_ascii_case("zhipuai") || is_zhipuai_url(url)); + let rule = resolve_reasoning_rule( + ReasoningApiFormat::OpenAiChat, + execution_provider, + url, + &execution_model, + ); + let is_deepseek_reasoning_target = matches!(rule, ReasoningRule::DeepSeek { .. }); + let is_glm_reasoning_target = matches!(rule, ReasoningRule::Glm52 | ReasoningRule::Glm53); let is_generic_reasoning = shared::is_generic_reasoning_preset(preset); match action { @@ -83,7 +87,11 @@ pub(crate) fn compile_chat_reasoning_action( request_body["reasoning_effort"] = serde_json::json!(normalized); Ok(true) } - ReasoningPresetAction::Toggle { enabled } if is_glm_52_reasoning_target => { + ReasoningPresetAction::Toggle { enabled } if is_glm_reasoning_target => { + if matches!(rule, ReasoningRule::Glm53) && !enabled { + request_body["thinking"] = serde_json::json!({ "type": "enabled" }); + return Ok(true); + } request_body["thinking"] = serde_json::json!({ "type": if *enabled { "enabled" } else { "disabled" } }); @@ -94,15 +102,67 @@ pub(crate) fn compile_chat_reasoning_action( } Ok(true) } - ReasoningPresetAction::Effort { value } if is_glm_52_reasoning_target => { - let normalized = normalize_glm_52_reasoning_effort(value) - .ok_or_else(|| anyhow!("GLM-5.2 reasoning effort '{}' is unsupported", value))?; + ReasoningPresetAction::Effort { value } if is_glm_reasoning_target => { + let normalized = match rule { + ReasoningRule::Glm52 => normalize_glm_52_reasoning_effort(value), + ReasoningRule::Glm53 => normalize_glm_53_reasoning_effort(value), + _ => None, + } + .ok_or_else(|| anyhow!("GLM reasoning effort '{}' is unsupported", value))?; request_body["thinking"] = serde_json::json!({ "type": "enabled" }); request_body["reasoning_effort"] = serde_json::json!(normalized); Ok(true) } + ReasoningPresetAction::Toggle { enabled } + if matches!(rule, ReasoningRule::DashScope { .. }) + || matches!(rule, ReasoningRule::SiliconFlow { .. }) => + { + if matches!(rule, ReasoningRule::DashScope { forced: true, .. }) && !enabled { + return Ok(true); + } + if !reasoning::apply_openai_compatible_toggle(request_body, *enabled, url) { + request_body["enable_thinking"] = serde_json::json!(*enabled); + } + if !enabled { + request_body + .as_object_mut() + .map(|body| body.remove("reasoning_effort")); + } + Ok(true) + } + ReasoningPresetAction::Effort { value } + if matches!(rule, ReasoningRule::DashScope { effort: true, .. }) + || matches!(rule, ReasoningRule::SiliconFlow { effort: true }) => + { + let normalized = match rule { + ReasoningRule::DashScope { .. } => { + normalize_dashscope_reasoning_effort(&execution_model, value) + } + ReasoningRule::SiliconFlow { .. } => normalize_siliconflow_reasoning_effort(value), + _ => None, + } + .ok_or_else(|| anyhow!("Reasoning effort '{}' is unsupported", value))?; + request_body["enable_thinking"] = serde_json::json!(true); + request_body["reasoning_effort"] = serde_json::json!(normalized); + request_body + .as_object_mut() + .map(|body| body.remove("thinking_budget")); + Ok(true) + } + ReasoningPresetAction::BudgetTokens { value } + if matches!( + rule, + ReasoningRule::DashScope { .. } | ReasoningRule::SiliconFlow { .. } + ) => + { + if !reasoning::apply_openai_compatible_budget(request_body, *value, url) { + request_body["enable_thinking"] = serde_json::json!(true); + request_body["thinking_budget"] = serde_json::json!(*value); + } + Ok(true) + } ReasoningPresetAction::Toggle { enabled } if is_generic_reasoning => { - if !apply_openai_compatible_toggle(request_body, *enabled, url) { + if !reasoning::apply_openai_compatible_toggle(request_body, *enabled, url) { request_body["thinking"] = serde_json::json!({ "type": if *enabled { "enabled" } else { "disabled" } }); @@ -115,17 +175,41 @@ pub(crate) fn compile_chat_reasoning_action( Ok(true) } ReasoningPresetAction::Effort { value } if is_generic_reasoning => { - let normalized = shared::normalize_generic_reasoning_effort(value) - .ok_or_else(|| anyhow!("Generic reasoning effort '{}' is unsupported", value))?; - request_body["reasoning_effort"] = serde_json::json!(normalized); - Ok(true) + reasoning::compile_format_fallback( + ReasoningApiFormat::OpenAiChat, + &ReasoningPresetAction::Effort { + value: value.clone(), + }, + request_body, + None, + ) } ReasoningPresetAction::Toggle { enabled } => { - Ok(apply_openai_compatible_toggle(request_body, *enabled, url)) - } - ReasoningPresetAction::Effort { .. } | ReasoningPresetAction::BudgetTokens { .. } => { - Ok(false) + if reasoning::apply_openai_compatible_toggle(request_body, *enabled, url) { + Ok(true) + } else { + reasoning::compile_format_fallback( + ReasoningApiFormat::OpenAiChat, + &ReasoningPresetAction::Toggle { enabled: *enabled }, + request_body, + None, + ) + } } + ReasoningPresetAction::Effort { value } => reasoning::compile_format_fallback( + ReasoningApiFormat::OpenAiChat, + &ReasoningPresetAction::Effort { + value: value.clone(), + }, + request_body, + None, + ), + ReasoningPresetAction::BudgetTokens { value } => reasoning::compile_format_fallback( + ReasoningApiFormat::OpenAiChat, + &ReasoningPresetAction::BudgetTokens { value: *value }, + request_body, + None, + ), ReasoningPresetAction::RequestPatch { .. } => { unreachable!("patches are compiled by shared code") } @@ -199,40 +283,6 @@ struct CodexBackendModelEntry { priority: Option, } -const DEFAULT_CODEX_MODELS: &[&str] = - &["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]; - -fn parsed_gpt_version(model_id: &str) -> Option<(u32, u32)> { - let version = model_id.strip_prefix("gpt-")?.split('-').next()?; - let (major, minor) = version.split_once('.')?; - if minor.contains('.') { - return None; - } - Some((major.parse().ok()?, minor.parse().ok()?)) -} - -/// Mirrors OpenCode's ChatGPT subscription allowlist while keeping future -/// versioned variants visible. Exact `gpt-5.6` and the Pro entitlement remain -/// excluded because the subscription endpoint does not offer those products. -fn codex_subscription_model_allowed(model_id: &str) -> bool { - let model_id = model_id.trim().to_ascii_lowercase(); - if DEFAULT_CODEX_MODELS.contains(&model_id.as_str()) { - return true; - } - if matches!(model_id.as_str(), "gpt-5.5-pro" | "gpt-5.6") { - return false; - } - parsed_gpt_version(&model_id).is_some_and(|version| version > (5, 4)) -} - -pub(crate) fn is_known_codex_reasoning_model(model_id: &str) -> bool { - let model_id = model_id.trim().to_ascii_lowercase(); - model_id == "gpt-5-codex" || codex_subscription_model_allowed(&model_id) -} - -/// The account's live catalog is authoritative, including subscription-only -/// models whose supported_in_api flag is false (that flag is for public API -/// billing). Do not apply the offline reasoning-model heuristic here. fn codex_models_from_entries(mut entries: Vec) -> Vec { entries.retain(|model| { !model.slug.trim().is_empty() @@ -359,7 +409,10 @@ pub(crate) fn convert_tools_flat( #[cfg(test)] mod tests { - use super::{attach_tools, codex_subscription_model_allowed, is_known_codex_reasoning_model}; + use super::attach_tools; + use crate::providers::reasoning::{ + codex_subscription_model_allowed, is_known_codex_reasoning_model, + }; use serde_json::json; #[test] diff --git a/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs b/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs index 05bda1fe7d..ebc08b765b 100644 --- a/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs @@ -1,6 +1,7 @@ use super::{common, OpenAIMessageConverter}; use crate::client::sse::execute_sse_request; use crate::client::{AIClient, StreamResponse}; +use crate::providers::reasoning::{self, ReasoningApiFormat}; use crate::providers::shared; use crate::stream::handle_responses_stream; use crate::trace::ModelExchangeTraceConfig; @@ -79,7 +80,7 @@ fn ensure_reasoning_summary_opt_in(request_body: &mut serde_json::Value) { } fn compile_reasoning_action( - preset: &ReasoningPresetDescriptor, + _preset: &ReasoningPresetDescriptor, action: &ReasoningPresetAction, body: &mut serde_json::Value, ) -> Result { @@ -91,18 +92,18 @@ fn compile_reasoning_action( body["reasoning"] = serde_json::json!({ "effort": value, "summary": "auto" }); Ok(true) } - ReasoningPresetAction::Toggle { enabled } - if shared::is_generic_reasoning_preset(preset) => - { - body["reasoning"] = serde_json::json!({ - "effort": if *enabled { "medium" } else { "none" }, - "summary": "auto" - }); - Ok(true) - } - ReasoningPresetAction::Toggle { .. } | ReasoningPresetAction::BudgetTokens { .. } => { - Ok(false) - } + ReasoningPresetAction::Toggle { enabled } => reasoning::compile_format_fallback( + ReasoningApiFormat::OpenAiResponses, + &ReasoningPresetAction::Toggle { enabled: *enabled }, + body, + None, + ), + ReasoningPresetAction::BudgetTokens { value } => reasoning::compile_format_fallback( + ReasoningApiFormat::OpenAiResponses, + &ReasoningPresetAction::BudgetTokens { value: *value }, + body, + None, + ), ReasoningPresetAction::RequestPatch { .. } => { unreachable!("patches are compiled by shared code") } diff --git a/src/crates/adapters/ai-adapters/src/providers/reasoning.rs b/src/crates/adapters/ai-adapters/src/providers/reasoning.rs new file mode 100644 index 0000000000..f5eb1b2c92 --- /dev/null +++ b/src/crates/adapters/ai-adapters/src/providers/reasoning.rs @@ -0,0 +1,836 @@ +use crate::types::ReasoningPresetAction; +use anyhow::{anyhow, Result}; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AnthropicThinkingCapability { + ManualOnly, + ManualEffort, + AdaptivePreferred, + AdaptiveOnly, + AdaptiveDefaultNoDisabled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReasoningRule { + DeepSeek { flash: bool }, + Glm52, + Glm53, + DashScope { effort: bool, forced: bool }, + SiliconFlow { effort: bool }, + Claude(AnthropicThinkingCapability), + Gemini3 { minimal: bool }, + Gemini25 { flash: bool }, + CodexChatGpt, + Incompatible, + FormatFallback, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct ReasoningRuleCapabilities { + pub(crate) effort: bool, + pub(crate) toggle: bool, + pub(crate) budget_tokens: bool, + pub(crate) nullable_effort: bool, +} + +/// Protocol-level defaults used when no provider/model-specific reasoning rule +/// is known. Provider quirks are handled before reaching this function. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReasoningApiFormat { + OpenAiChat, + OpenAiResponses, + Anthropic, + Gemini, +} + +pub(crate) fn resolve_reasoning_rule( + format: ReasoningApiFormat, + provider: &str, + base_url: &str, + model: &str, +) -> ReasoningRule { + let provider = provider.trim().to_ascii_lowercase(); + let model = model.trim().to_ascii_lowercase(); + + if matches!( + format, + ReasoningApiFormat::OpenAiChat | ReasoningApiFormat::Anthropic + ) && (provider == "deepseek" + || is_deepseek_url(base_url) + || is_deepseek_reasoning_effort_model(&model)) + { + return ReasoningRule::DeepSeek { + flash: is_deepseek_flash_model(&model), + }; + } + + if matches!( + format, + ReasoningApiFormat::OpenAiChat | ReasoningApiFormat::Anthropic + ) && is_glm_53_reasoning_effort_model(&model) + && (provider == "zhipuai" || is_zhipuai_url(base_url)) + { + return ReasoningRule::Glm53; + } + + if matches!( + format, + ReasoningApiFormat::OpenAiChat | ReasoningApiFormat::Anthropic + ) && is_glm_52_reasoning_effort_model(&model) + && (provider == "zhipuai" || is_zhipuai_url(base_url)) + { + return ReasoningRule::Glm52; + } + + match format { + ReasoningApiFormat::OpenAiChat => { + if matches!(provider.as_str(), "anthropic" | "gemini" | "google") { + ReasoningRule::Incompatible + } else if matches!(provider.as_str(), "dashscope" | "alibaba" | "aliyun") + || is_dashscope_url(base_url) + { + ReasoningRule::DashScope { + effort: is_dashscope_effort_model(&model), + forced: is_dashscope_forced_model(&model), + } + } else if matches!(provider.as_str(), "siliconflow" | "silicon_flow") + || is_siliconflow_url(base_url) + { + ReasoningRule::SiliconFlow { + effort: is_siliconflow_effort_model(&model), + } + } else { + ReasoningRule::FormatFallback + } + } + ReasoningApiFormat::OpenAiResponses => { + if is_codex_chatgpt_endpoint(base_url) { + ReasoningRule::CodexChatGpt + } else { + ReasoningRule::FormatFallback + } + } + ReasoningApiFormat::Anthropic => { + ReasoningRule::Claude(anthropic_thinking_capability(&model)) + } + ReasoningApiFormat::Gemini => { + if model.starts_with("gemini-3-") || model.starts_with("gemini-3.1-") { + ReasoningRule::Gemini3 { + minimal: gemini_supports_minimal_thinking_level(&model), + } + } else if model.starts_with("gemini-2.5-") { + ReasoningRule::Gemini25 { + flash: model.starts_with("gemini-2.5-flash"), + } + } else { + ReasoningRule::FormatFallback + } + } + } +} + +impl ReasoningRule { + pub(crate) fn capabilities(self, format: ReasoningApiFormat) -> ReasoningRuleCapabilities { + match self { + Self::DeepSeek { .. } | Self::Glm52 => ReasoningRuleCapabilities { + effort: true, + toggle: true, + ..Default::default() + }, + Self::Glm53 => ReasoningRuleCapabilities { + effort: true, + ..Default::default() + }, + Self::DashScope { effort, forced } => ReasoningRuleCapabilities { + effort, + toggle: !forced, + budget_tokens: true, + ..Default::default() + }, + Self::SiliconFlow { effort } => ReasoningRuleCapabilities { + effort, + toggle: true, + budget_tokens: true, + ..Default::default() + }, + Self::Claude(capability) => ReasoningRuleCapabilities { + effort: true, + toggle: !matches!( + capability, + AnthropicThinkingCapability::AdaptiveOnly + | AnthropicThinkingCapability::AdaptiveDefaultNoDisabled + ), + budget_tokens: !matches!( + capability, + AnthropicThinkingCapability::AdaptiveOnly + | AnthropicThinkingCapability::AdaptiveDefaultNoDisabled + ), + ..Default::default() + }, + Self::Gemini3 { .. } => ReasoningRuleCapabilities { + effort: true, + ..Default::default() + }, + Self::Gemini25 { flash } => ReasoningRuleCapabilities { + toggle: flash, + budget_tokens: true, + ..Default::default() + }, + Self::CodexChatGpt => ReasoningRuleCapabilities { + effort: true, + toggle: true, + nullable_effort: true, + ..Default::default() + }, + Self::Incompatible => ReasoningRuleCapabilities::default(), + Self::FormatFallback => match format { + ReasoningApiFormat::OpenAiChat => ReasoningRuleCapabilities { + effort: true, + toggle: true, + ..Default::default() + }, + ReasoningApiFormat::OpenAiResponses => ReasoningRuleCapabilities { + effort: true, + toggle: true, + nullable_effort: true, + ..Default::default() + }, + ReasoningApiFormat::Anthropic | ReasoningApiFormat::Gemini => { + ReasoningRuleCapabilities { + effort: true, + toggle: true, + budget_tokens: true, + ..Default::default() + } + } + }, + } + } +} + +pub(crate) fn api_format_for_provider(provider: &str, base_url: &str) -> ReasoningApiFormat { + let provider = provider.trim().to_ascii_lowercase(); + if matches!(provider.as_str(), "response" | "responses") + || (provider == "openai" && is_responses_endpoint(base_url)) + { + ReasoningApiFormat::OpenAiResponses + } else if provider == "anthropic" { + ReasoningApiFormat::Anthropic + } else if matches!(provider.as_str(), "gemini" | "google") { + ReasoningApiFormat::Gemini + } else { + ReasoningApiFormat::OpenAiChat + } +} + +pub(crate) fn is_responses_endpoint(base_url: &str) -> bool { + base_url + .trim_end_matches('/') + .to_ascii_lowercase() + .ends_with("/responses") +} + +pub(crate) fn anthropic_thinking_capability(model_name: &str) -> AnthropicThinkingCapability { + if model_name.starts_with("claude-mythos") || model_name.starts_with("claude-fable") { + return AnthropicThinkingCapability::AdaptiveDefaultNoDisabled; + } + + if let Some(version) = parse_claude_model_version(model_name, "opus") { + if version.major > 4 || (version.major == 4 && version.minor >= 7) { + return AnthropicThinkingCapability::AdaptiveOnly; + } + if version.major == 4 && version.minor >= 6 { + return AnthropicThinkingCapability::AdaptivePreferred; + } + if version.major == 4 && version.minor == 5 { + return AnthropicThinkingCapability::ManualEffort; + } + } + + if let Some(version) = parse_claude_model_version(model_name, "sonnet") { + if version.major > 4 || (version.major == 4 && version.minor >= 7) { + return AnthropicThinkingCapability::AdaptiveOnly; + } + if version.major == 4 && version.minor >= 6 { + return AnthropicThinkingCapability::AdaptivePreferred; + } + } + + if let Some(version) = parse_claude_model_version(model_name, "haiku") { + if version.major > 4 || (version.major == 4 && version.minor >= 7) { + return AnthropicThinkingCapability::AdaptiveOnly; + } + } + + AnthropicThinkingCapability::ManualOnly +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ClaudeModelVersion { + major: u32, + minor: u32, +} + +fn parse_claude_model_version(model_name: &str, family: &str) -> Option { + let prefix = format!("claude-{family}-"); + let rest = model_name.strip_prefix(&prefix)?; + let mut parts = rest.split('-'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().and_then(|part| part.parse().ok()).unwrap_or(0); + Some(ClaudeModelVersion { major, minor }) +} + +pub(crate) fn is_dashscope_url(url: &str) -> bool { + let url = url.to_ascii_lowercase(); + url.contains("dashscope.aliyuncs.com") + || url.contains("dashscope-intl.aliyuncs.com") + || url.contains(".maas.aliyuncs.com") +} + +pub(crate) fn is_siliconflow_url(url: &str) -> bool { + let url = url.to_ascii_lowercase(); + url.contains("api.siliconflow.cn") || url.contains("api.siliconflow.com") +} + +pub(crate) fn is_deepseek_url(url: &str) -> bool { + url.to_ascii_lowercase().contains("api.deepseek.com") +} + +pub(crate) fn is_zhipuai_url(url: &str) -> bool { + reqwest::Url::parse(url.trim()).ok().is_some_and(|url| { + url.scheme() == "https" + && url.port_or_known_default() == Some(443) + && url.host_str().is_some_and(|host| { + matches!(host.trim_end_matches('.'), "open.bigmodel.cn" | "api.z.ai") + }) + }) +} + +pub(crate) fn is_deepseek_reasoning_effort_model(model_name: &str) -> bool { + matches!( + model_name.trim().to_ascii_lowercase().as_str(), + "deepseek-flash" | "deepseek-v4-flash" | "deepseek-v4-pro" + ) +} + +fn is_deepseek_flash_model(model_name: &str) -> bool { + matches!( + model_name.trim().to_ascii_lowercase().as_str(), + "deepseek-flash" | "deepseek-v4-flash" + ) +} + +pub(crate) fn is_glm_52_reasoning_effort_model(model_name: &str) -> bool { + matches!( + model_name.trim().to_ascii_lowercase().as_str(), + "glm-5.2" | "glm-5-2" | "glm-5p2" + ) +} + +pub(crate) fn is_glm_53_reasoning_effort_model(model_name: &str) -> bool { + matches!( + model_name.trim().to_ascii_lowercase().as_str(), + "glm-5.3" | "glm-5-3" | "glm-5p3" | "glm-5.3-flash" | "glm-5-3-flash" + ) +} + +pub(crate) fn is_dashscope_effort_model(model_name: &str) -> bool { + let model = model_name.trim().to_ascii_lowercase(); + model.contains("deepseek-v4") || model.contains("glm-5.3") || model.contains("glm-5-3") +} + +pub(crate) fn is_dashscope_forced_model(model_name: &str) -> bool { + let model = model_name.trim().to_ascii_lowercase(); + model.contains("glm-5.3") || model.contains("glm-5-3") +} + +pub(crate) fn is_siliconflow_effort_model(model_name: &str) -> bool { + let model = model_name.trim().to_ascii_lowercase(); + model.contains("deepseek-v4") || model.contains("glm-5.2") || model.contains("glm-5-2") +} + +pub(crate) fn gemini_supports_minimal_thinking_level(model_name: &str) -> bool { + let model = model_name.trim().to_ascii_lowercase(); + model.starts_with("gemini-3.1-") && !model.contains("pro") +} + +pub(crate) fn normalize_gemini_reasoning_effort( + model_name: &str, + effort: &str, +) -> Option<&'static str> { + match effort.trim().to_ascii_lowercase().as_str() { + "minimal" if gemini_supports_minimal_thinking_level(model_name) => Some("MINIMAL"), + "minimal" => Some("LOW"), + "low" => Some("LOW"), + "medium" => Some("MEDIUM"), + "high" | "xhigh" | "max" => Some("HIGH"), + "" | "none" => None, + _ => None, + } +} + +pub(crate) fn parse_glm_major_minor(model_name: &str) -> Option<(u32, u32)> { + let lower = model_name.to_ascii_lowercase(); + let tail = lower.strip_prefix("glm-")?; + let mut parts = tail.split('-'); + let version = parts.next()?; + + let mut version_parts = version.split('.'); + let major = version_parts.next()?.parse().ok()?; + let minor = version_parts + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + + Some((major, minor)) +} + +pub(crate) fn normalize_deepseek_reasoning_effort( + model_name: &str, + effort: &str, +) -> Option<&'static str> { + let is_flash = is_deepseek_flash_model(model_name); + match effort.trim().to_ascii_lowercase().as_str() { + "" => None, + "low" if is_flash => Some("low"), + "high" => Some("high"), + "max" => Some("max"), + "medium" => Some("high"), + "low" | "xhigh" if !is_flash => Some("high"), + "xhigh" if is_flash => Some("high"), + "none" | "minimal" => None, + _ => None, + } +} + +pub(crate) fn normalize_glm_53_reasoning_effort(effort: &str) -> Option<&'static str> { + match effort.trim().to_ascii_lowercase().as_str() { + "low" => Some("low"), + "high" => Some("high"), + "max" | "xhigh" => Some("max"), + "" | "none" | "minimal" | "medium" => None, + _ => None, + } +} + +pub(crate) fn normalize_siliconflow_reasoning_effort(effort: &str) -> Option<&'static str> { + match effort.trim().to_ascii_lowercase().as_str() { + "high" | "low" | "medium" => Some("high"), + "max" | "xhigh" => Some("max"), + "" | "none" | "minimal" => None, + _ => None, + } +} + +pub(crate) fn normalize_dashscope_reasoning_effort( + model_name: &str, + effort: &str, +) -> Option<&'static str> { + if model_name.trim().to_ascii_lowercase().contains("glm-5.3") + || model_name.trim().to_ascii_lowercase().contains("glm-5-3") + { + return normalize_glm_53_reasoning_effort(effort); + } + normalize_siliconflow_reasoning_effort(effort) +} + +pub(crate) fn apply_openai_compatible_budget( + request_body: &mut Value, + value: u32, + url: &str, +) -> bool { + if is_dashscope_url(url) || is_siliconflow_url(url) { + request_body["enable_thinking"] = Value::Bool(true); + request_body["thinking_budget"] = serde_json::json!(value); + return true; + } + false +} + +pub(crate) fn normalize_glm_52_reasoning_effort(effort: &str) -> Option<&'static str> { + match effort.trim().to_ascii_lowercase().as_str() { + "high" | "low" | "medium" => Some("high"), + "max" | "xhigh" => Some("max"), + "" | "none" | "minimal" => None, + _ => None, + } +} + +const DEFAULT_CODEX_MODELS: &[&str] = + &["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]; + +fn parsed_gpt_version(model_id: &str) -> Option<(u32, u32)> { + let version = model_id.strip_prefix("gpt-")?.split('-').next()?; + let (major, minor) = version.split_once('.')?; + if minor.contains('.') { + return None; + } + Some((major.parse().ok()?, minor.parse().ok()?)) +} + +/// Mirrors the ChatGPT Codex subscription allowlist used for reasoning presets. +/// Exact `gpt-5.6` and the Pro entitlement remain excluded because the +/// subscription endpoint does not offer those products. +pub(crate) fn codex_subscription_model_allowed(model_id: &str) -> bool { + let model_id = model_id.trim().to_ascii_lowercase(); + if DEFAULT_CODEX_MODELS.contains(&model_id.as_str()) { + return true; + } + if matches!(model_id.as_str(), "gpt-5.5-pro" | "gpt-5.6") { + return false; + } + parsed_gpt_version(&model_id).is_some_and(|version| version > (5, 4)) +} + +pub(crate) fn is_known_codex_reasoning_model(model_id: &str) -> bool { + let model_id = model_id.trim().to_ascii_lowercase(); + model_id == "gpt-5-codex" || codex_subscription_model_allowed(&model_id) +} + +pub(crate) fn apply_openai_compatible_toggle( + request_body: &mut Value, + enabled: bool, + url: &str, +) -> bool { + if is_dashscope_url(url) || is_siliconflow_url(url) { + request_body["enable_thinking"] = serde_json::json!(enabled); + return true; + } + if is_deepseek_url(url) { + request_body["thinking"] = serde_json::json!({ + "type": if enabled { "enabled" } else { "disabled" } + }); + return true; + } + false +} + +fn is_codex_chatgpt_endpoint(url: &str) -> bool { + reqwest::Url::parse(url.trim()).ok().is_some_and(|url| { + url.scheme() == "https" + && url.port_or_known_default() == Some(443) + && url + .host_str() + .is_some_and(|host| host.trim_end_matches('.') == "chatgpt.com") + && is_codex_chatgpt_path(url.path()) + }) +} + +pub(crate) fn is_codex_chatgpt_path(path: &str) -> bool { + matches!( + path.trim_end_matches('/'), + "/backend-api/codex" | "/backend-api/codex/responses" + ) +} + +pub(crate) fn compile_format_fallback( + format: ReasoningApiFormat, + action: &ReasoningPresetAction, + request_body: &mut Value, + max_tokens: Option, +) -> Result { + match (format, action) { + (ReasoningApiFormat::OpenAiChat, ReasoningPresetAction::Effort { value }) => { + if value.trim().is_empty() { + return Err(anyhow!("OpenAI Chat reasoning effort must not be empty")); + } + if let Some(body) = request_body.as_object_mut() { + body.remove("thinking"); + body.remove("enable_thinking"); + } + request_body["reasoning_effort"] = Value::String(value.clone()); + Ok(true) + } + (ReasoningApiFormat::OpenAiChat, ReasoningPresetAction::Toggle { enabled }) => { + if let Some(body) = request_body.as_object_mut() { + body.remove("thinking"); + body.remove("enable_thinking"); + } + request_body["reasoning_effort"] = + Value::String(if *enabled { "medium" } else { "none" }.to_string()); + Ok(true) + } + (ReasoningApiFormat::OpenAiChat, ReasoningPresetAction::BudgetTokens { .. }) => Ok(false), + + (ReasoningApiFormat::OpenAiResponses, ReasoningPresetAction::Effort { value }) => { + if value.trim().is_empty() { + return Err(anyhow!("Responses reasoning effort must not be empty")); + } + request_body["reasoning"] = serde_json::json!({ + "effort": value, + "summary": "auto" + }); + Ok(true) + } + (ReasoningApiFormat::OpenAiResponses, ReasoningPresetAction::Toggle { enabled }) => { + request_body["reasoning"] = serde_json::json!({ + "effort": if *enabled { "medium" } else { "none" }, + "summary": "auto" + }); + Ok(true) + } + (ReasoningApiFormat::OpenAiResponses, ReasoningPresetAction::BudgetTokens { .. }) => { + Ok(false) + } + + (ReasoningApiFormat::Anthropic, ReasoningPresetAction::Effort { value }) => { + if value.trim().is_empty() { + return Err(anyhow!("Anthropic reasoning effort must not be empty")); + } + request_body["thinking"] = serde_json::json!({ "type": "adaptive" }); + request_body["output_config"] = serde_json::json!({ "effort": value }); + Ok(true) + } + (ReasoningApiFormat::Anthropic, ReasoningPresetAction::Toggle { enabled: true }) => { + request_body["thinking"] = serde_json::json!({ "type": "adaptive" }); + Ok(true) + } + (ReasoningApiFormat::Anthropic, ReasoningPresetAction::Toggle { enabled: false }) => { + request_body["thinking"] = serde_json::json!({ "type": "disabled" }); + request_body + .as_object_mut() + .map(|body| body.remove("output_config")); + Ok(true) + } + (ReasoningApiFormat::Anthropic, ReasoningPresetAction::BudgetTokens { value }) => { + if max_tokens.is_some_and(|limit| *value >= limit) { + return Err(anyhow!( + "Anthropic reasoning budget {} must be less than max_tokens {}", + value, + max_tokens.unwrap_or_default() + )); + } + request_body["thinking"] = serde_json::json!({ + "type": "enabled", + "budget_tokens": value + }); + request_body + .as_object_mut() + .map(|body| body.remove("output_config")); + Ok(true) + } + + (ReasoningApiFormat::Gemini, ReasoningPresetAction::Effort { value }) => { + if value.trim().is_empty() { + return Err(anyhow!("Gemini reasoning effort must not be empty")); + } + insert_generation_field( + request_body, + "thinkingConfig", + serde_json::json!({ + "includeThoughts": true, + "thinkingLevel": value.to_ascii_uppercase(), + }), + ); + Ok(true) + } + (ReasoningApiFormat::Gemini, ReasoningPresetAction::Toggle { enabled: true }) => { + insert_generation_field( + request_body, + "thinkingConfig", + serde_json::json!({ + "includeThoughts": true, + "thinkingLevel": "MEDIUM", + }), + ); + Ok(true) + } + (ReasoningApiFormat::Gemini, ReasoningPresetAction::Toggle { enabled: false }) => { + insert_generation_field( + request_body, + "thinkingConfig", + serde_json::json!({ + "includeThoughts": false, + "thinkingBudget": 0, + }), + ); + Ok(true) + } + (ReasoningApiFormat::Gemini, ReasoningPresetAction::BudgetTokens { value }) => { + if max_tokens.is_some_and(|limit| *value > limit) { + return Err(anyhow!( + "Gemini reasoning budget {} exceeds max output tokens {}", + value, + max_tokens.unwrap_or_default() + )); + } + insert_generation_field( + request_body, + "thinkingConfig", + serde_json::json!({ + "includeThoughts": true, + "thinkingBudget": value, + }), + ); + Ok(true) + } + + (_, ReasoningPresetAction::RequestPatch { .. }) => { + unreachable!("patches are compiled by shared code") + } + } +} + +fn insert_generation_field(request_body: &mut Value, key: &str, value: Value) { + if !request_body + .get("generationConfig") + .is_some_and(Value::is_object) + { + request_body["generationConfig"] = serde_json::json!({}); + } + request_body["generationConfig"][key] = value; +} + +#[cfg(test)] +mod tests { + use super::{ + compile_format_fallback, is_dashscope_url, normalize_deepseek_reasoning_effort, + normalize_gemini_reasoning_effort, resolve_reasoning_rule, AnthropicThinkingCapability, + ReasoningApiFormat, ReasoningRule, + }; + use crate::types::ReasoningPresetAction; + use serde_json::json; + + #[test] + fn unknown_provider_effort_uses_the_api_format_fallback() { + let mut chat = json!({}); + assert!(compile_format_fallback( + ReasoningApiFormat::OpenAiChat, + &ReasoningPresetAction::Effort { + value: "xhigh".to_string() + }, + &mut chat, + None, + ) + .unwrap()); + assert_eq!(chat["reasoning_effort"], "xhigh"); + + let mut gemini = json!({}); + assert!(compile_format_fallback( + ReasoningApiFormat::Gemini, + &ReasoningPresetAction::Effort { + value: "xhigh".to_string() + }, + &mut gemini, + None, + ) + .unwrap()); + assert_eq!( + gemini["generationConfig"]["thinkingConfig"]["thinkingLevel"], + "XHIGH" + ); + } + + #[test] + fn provider_and_model_rules_resolve_from_one_registry() { + assert_eq!( + resolve_reasoning_rule( + ReasoningApiFormat::OpenAiChat, + "openai", + "https://api.deepseek.com/v1", + "deepseek-v4-pro", + ), + ReasoningRule::DeepSeek { flash: false } + ); + assert_eq!( + resolve_reasoning_rule( + ReasoningApiFormat::Anthropic, + "zhipuai", + "https://api.z.ai/api/anthropic", + "glm-5.2", + ), + ReasoningRule::Glm52 + ); + assert_eq!( + resolve_reasoning_rule( + ReasoningApiFormat::Anthropic, + "anthropic", + "https://api.anthropic.com/v1/messages", + "claude-opus-4-7", + ), + ReasoningRule::Claude(AnthropicThinkingCapability::AdaptiveOnly) + ); + assert_eq!( + resolve_reasoning_rule( + ReasoningApiFormat::Gemini, + "google", + "https://generativelanguage.googleapis.com", + "gemini-2.5-flash", + ), + ReasoningRule::Gemini25 { flash: true } + ); + assert_eq!( + resolve_reasoning_rule( + ReasoningApiFormat::OpenAiChat, + "openai", + "https://gateway.example/v1", + "vendor-new-reasoning-model", + ), + ReasoningRule::FormatFallback + ); + } + + #[test] + fn current_vendor_model_rules_cover_documented_effort_shapes() { + assert!(is_dashscope_url( + "https://qwen.cn-beijing.maas.aliyuncs.com/v1/chat/completions" + )); + assert_eq!( + resolve_reasoning_rule( + ReasoningApiFormat::OpenAiChat, + "openai", + "https://api.deepseek.com/v1", + "deepseek-flash", + ), + ReasoningRule::DeepSeek { flash: true } + ); + assert_eq!( + normalize_deepseek_reasoning_effort("deepseek-flash", "xhigh"), + Some("high") + ); + assert_eq!( + resolve_reasoning_rule( + ReasoningApiFormat::OpenAiChat, + "zhipuai", + "https://api.z.ai/api/paas/v4", + "GLM-5.3-Flash", + ), + ReasoningRule::Glm53 + ); + assert_eq!( + resolve_reasoning_rule( + ReasoningApiFormat::OpenAiChat, + "openai", + "https://api.siliconflow.cn/v1", + "Pro/zai-org/GLM-5.2", + ), + ReasoningRule::SiliconFlow { effort: true } + ); + assert_eq!( + resolve_reasoning_rule( + ReasoningApiFormat::OpenAiChat, + "openai", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "glm-5.3", + ), + ReasoningRule::DashScope { + effort: true, + forced: true + } + ); + assert_eq!( + resolve_reasoning_rule( + ReasoningApiFormat::Gemini, + "google", + "", + "gemini-3.1-flash-lite-preview", + ), + ReasoningRule::Gemini3 { minimal: true } + ); + assert_eq!( + normalize_gemini_reasoning_effort("gemini-3.1-pro-preview", "minimal"), + Some("LOW") + ); + } +} diff --git a/src/crates/adapters/ai-adapters/src/providers/shared.rs b/src/crates/adapters/ai-adapters/src/providers/shared.rs index ecc39f0e5a..1372a21aa2 100644 --- a/src/crates/adapters/ai-adapters/src/providers/shared.rs +++ b/src/crates/adapters/ai-adapters/src/providers/shared.rs @@ -15,15 +15,6 @@ pub(crate) fn is_generic_reasoning_preset(preset: &ReasoningPresetDescriptor) -> preset.execution_provider.as_deref() == Some(GENERIC_REASONING_PROVIDER_ID) } -pub(crate) fn normalize_generic_reasoning_effort(value: &str) -> Option<&'static str> { - match value.trim().to_ascii_lowercase().as_str() { - "low" => Some("low"), - "medium" => Some("medium"), - "high" => Some("high"), - _ => None, - } -} - /// Attribution for APIs that accept third-party harnesses under their own name. #[cfg(feature = "subscription-auth")] pub(crate) fn product_user_agent() -> String { diff --git a/src/crates/assembly/core/src/service/config/providers.rs b/src/crates/assembly/core/src/service/config/providers.rs index 8f05b31a9d..087787cdf6 100644 --- a/src/crates/assembly/core/src/service/config/providers.rs +++ b/src/crates/assembly/core/src/service/config/providers.rs @@ -4,16 +4,10 @@ //! and change handling. use super::types::*; -#[cfg(feature = "ai-adapter-runtime")] -use crate::infrastructure::ai::reasoning_catalog::{ - load_models_dev_reasoning_catalog_without_refresh, project_model_reasoning_catalog, -}; -#[cfg(feature = "ai-adapter-runtime")] -use crate::infrastructure::ai::AIClient; use crate::util::errors::*; use async_trait::async_trait; use log::{error, info}; -#[cfg(any(feature = "ai-adapter-runtime", test))] +#[cfg(test)] use openbitfun_core_types::ReasoningCatalogBinding; #[cfg(test)] use openbitfun_core_types::{ReasoningConfig, ReasoningPreset, ReasoningPresetAction}; @@ -155,17 +149,6 @@ impl ConfigProvider for AIConfigProvider { let mut warnings = Vec::new(); if let Ok(ai_config) = serde_json::from_value::(config.clone()) { - #[cfg(feature = "ai-adapter-runtime")] - let models_dev = if ai_config.models.iter().any(|model| { - model.reasoning.as_ref().is_some_and(|reasoning| { - !matches!(reasoning.catalog, ReasoningCatalogBinding::Disabled) - }) - }) { - Some(load_models_dev_reasoning_catalog_without_refresh().await) - } else { - None - }; - if let Some(stream_idle_timeout_secs) = ai_config.stream_idle_timeout_secs { if stream_idle_timeout_secs == 0 { return Err(OpenBitFunError::validation( @@ -201,70 +184,6 @@ impl ConfigProvider for AIConfigProvider { } } } - #[cfg(feature = "ai-adapter-runtime")] - if let Some(reasoning) = model.reasoning.as_ref() { - #[cfg(feature = "ai-adapter-runtime")] - if let Some(default_preset) = reasoning.default_preset.as_deref() { - #[cfg(feature = "ai-adapter-runtime")] - { - let projection = project_model_reasoning_catalog( - model, - models_dev - .as_ref() - .and_then(|snapshot| snapshot.catalog.as_deref()), - ); - if projection.default_preset.as_deref() != Some(default_preset) { - return Err(OpenBitFunError::validation(format!( - "Model '{}' reasoning default preset '{}' is not available at index {}", - model.name, default_preset, index - ))); - } - } - } - - #[cfg(feature = "ai-adapter-runtime")] - { - let runtime_config = >::try_from(model.clone()) - .map_err(|message| { - OpenBitFunError::validation(format!( - "Model '{}' reasoning target is invalid at index {}: {}", - model.name, index, message - )) - })?; - let client = AIClient::new(runtime_config); - let projection = project_model_reasoning_catalog( - model, - models_dev - .as_ref() - .and_then(|snapshot| snapshot.catalog.as_deref()), - ); - for preset in reasoning - .presets - .iter() - .filter(|preset| !preset.disabled && !preset.actions.is_empty()) - { - let preset_id = preset.id.trim(); - let descriptor = projection - .presets - .iter() - .find(|descriptor| descriptor.id == preset_id) - .ok_or_else(|| { - OpenBitFunError::validation(format!( - "Model '{}' reasoning preset '{}' is not available at index {}", - model.name, preset_id, index - )) - })?; - client.validate_reasoning_preset(descriptor).map_err(|error| { - OpenBitFunError::validation(format!( - "Model '{}' reasoning preset '{}' is unsupported at index {}: {}", - model.name, preset_id, index, error - )) - })?; - } - } - } } let enabled_model_with_capability = |model_id: &str, capability: ModelCapability| { @@ -1123,26 +1042,28 @@ mod tests { } } - #[cfg(feature = "ai-adapter-runtime")] #[tokio::test] - async fn rejects_reasoning_actions_unsupported_by_the_configured_target() { - let error = validate_reasoning(ReasoningConfig { - catalog: ReasoningCatalogBinding::Disabled, - default_preset: Some("on".to_string()), + async fn accepts_structurally_valid_reasoning_without_adapter_capability() { + validate_reasoning(ReasoningConfig { + catalog: ReasoningCatalogBinding::ModelsDev { + provider: "unknown-gateway".to_string(), + model: "unknown-model".to_string(), + }, + default_preset: Some("vendor-xhigh".to_string()), presets: vec![ReasoningPreset { - id: "on".to_string(), - actions: vec![ReasoningPresetAction::Toggle { enabled: true }], + id: "vendor-xhigh".to_string(), + actions: vec![ReasoningPresetAction::Effort { + value: "xhigh".to_string(), + }], ..ReasoningPreset::default() }], }) .await - .expect_err("Responses must reject a generic toggle action"); - - assert!(error.to_string().contains("unsupported"), "{error}"); + .expect("adapter capability must not block a structurally valid config"); } #[tokio::test] - async fn rejects_duplicate_and_unavailable_default_presets() { + async fn rejects_duplicate_but_preserves_unresolved_default_presets() { let duplicate = validate_reasoning(ReasoningConfig { presets: vec![ ReasoningPreset { @@ -1162,16 +1083,13 @@ mod tests { .expect_err("duplicate preset IDs must be rejected"); assert!(duplicate.to_string().contains("duplicate preset ID 'same'")); - let unavailable = validate_reasoning(ReasoningConfig { + validate_reasoning(ReasoningConfig { catalog: ReasoningCatalogBinding::Disabled, default_preset: Some("missing".to_string()), ..ReasoningConfig::default() }) .await - .expect_err("missing default preset must be rejected"); - assert!(unavailable - .to_string() - .contains("default preset 'missing' is not available")); + .expect("unresolved defaults must remain saveable"); } #[cfg(feature = "ai-adapter-runtime")] diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 012aa6b6f9..f8871d291b 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -2601,7 +2601,7 @@ mod tests { } #[tokio::test] - async fn startup_disables_invalid_reasoning_model_without_mutating_the_file() { + async fn startup_preserves_unresolved_reasoning_model_without_mutating_the_file() { let dir = tempfile::tempdir().expect("tempdir"); let user_root = dir.path().join("invalid-reasoning-startup"); let path_manager = Arc::new(PathManager::with_user_root_for_tests(user_root)); @@ -2650,7 +2650,7 @@ mod tests { let service = ConfigService::with_settings(settings()).await.unwrap(); let models = service.get_ai_models().await.unwrap(); assert_eq!(models.len(), 1); - assert!(!models[0].enabled); + assert!(models[0].enabled); assert_eq!(models[0].api_key, "key"); assert_eq!( models[0] @@ -2661,7 +2661,7 @@ mod tests { .as_deref(), Some("missing") ); - assert!(service.load_diagnostics().await.iter().any(|d| d + assert!(!service.load_diagnostics().await.iter().any(|d| d .message .contains("default preset 'missing' is not available"))); assert_eq!( diff --git a/src/crates/contracts/config-contracts/src/normalization.rs b/src/crates/contracts/config-contracts/src/normalization.rs index 494aa0806a..42426cd6dd 100644 --- a/src/crates/contracts/config-contracts/src/normalization.rs +++ b/src/crates/contracts/config-contracts/src/normalization.rs @@ -89,18 +89,6 @@ pub fn validate_model_config(model: &crate::AIModelConfig, index: usize) -> Resu model.name ) })?; - if let Some(preset) = &reasoning.default_preset { - if matches!( - reasoning.catalog, - openbitfun_core_types::ReasoningCatalogBinding::Disabled - ) && reasoning.preset(preset).is_none() - { - return Err(format!( - "Model '{}' reasoning default preset '{}' is not available at index {index}", - model.name, preset - )); - } - } } Ok(()) }