diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 47bf634d9e..e05c47becc 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -6,15 +6,17 @@ use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, - LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse, - MessageContent, ProviderSettingsView, RateLimiter, Role, StopReason, TokenUsage, env_var, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, + LanguageModelToolUse, MessageContent, ProviderSettingsView, RateLimiter, Role, StopReason, + TokenUsage, env_var, }; use open_ai::completion::ReasoningDetailsAccumulator; use open_router::{ - Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ResponseStreamEvent, list_models, + Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ReasoningEffort, + ResponseStreamEvent, list_models, }; use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsStore}; use sha2::{Digest as _, Sha256}; @@ -222,13 +224,27 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider { let mut settings_models = Vec::new(); for model in &Self::settings(cx).available_models { + let mode = model.mode.unwrap_or_default(); + let (supported_efforts, default_effort) = + if matches!(mode, OpenRouterModelMode::Adaptive) { + ( + model.reasoning_effort.into_iter().collect(), + model.reasoning_effort, + ) + } else { + (Vec::new(), None) + }; settings_models.push(open_router::Model { name: model.name.clone(), display_name: model.display_name.clone(), max_tokens: model.max_tokens, supports_tools: model.supports_tools, supports_images: model.supports_images, - mode: model.mode.unwrap_or_default(), + mode, + supported_efforts, + default_effort, + supports_max_tokens: false, + mandatory_reasoning: false, provider: model.provider.clone(), }); } @@ -351,7 +367,37 @@ impl LanguageModel for OpenRouterLanguageModel { } fn supports_thinking(&self) -> bool { - matches!(self.model.mode, OpenRouterModelMode::Thinking { .. }) + matches!( + self.model.mode, + OpenRouterModelMode::Thinking { .. } | OpenRouterModelMode::Adaptive + ) + } + + fn supports_disabling_thinking(&self) -> bool { + !self.model.mandatory_reasoning + } + + fn supported_effort_levels(&self) -> Vec { + let efforts: &[ReasoningEffort] = if !self.model.supported_efforts.is_empty() { + &self.model.supported_efforts + } else if self.model.supports_max_tokens { + &ReasoningEffort::OPENAI_COMPATIBLE_SELECTABLE + } else { + return Vec::new(); + }; + let default_effort = self.model.default_effort.or_else(|| { + self.model + .supports_max_tokens + .then_some(ReasoningEffort::Medium) + }); + efforts + .iter() + .map(|&effort| LanguageModelEffortLevel { + name: effort.label().into(), + value: effort.value().into(), + is_default: Some(effort) == default_effort, + }) + .collect() } fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { @@ -575,23 +621,40 @@ pub fn into_open_router( stop: request.stop, temperature: request.temperature.unwrap_or(0.4), max_tokens: max_output_tokens, - parallel_tool_calls: if model.supports_parallel_tool_calls() && !request.tools.is_empty() { - Some(false) - } else { - None - }, + parallel_tool_calls: (!request.tools.is_empty() && !model.supports_parallel_tool_calls()) + .then_some(false), usage: open_router::RequestUsage { include: true }, - reasoning: if request.thinking_allowed - && let OpenRouterModelMode::Thinking { budget_tokens } = model.mode - { - Some(open_router::Reasoning { - effort: None, - max_tokens: budget_tokens, - exclude: Some(false), - enabled: Some(true), - }) - } else { - None + reasoning: match model.mode { + OpenRouterModelMode::Adaptive if request.thinking_allowed => { + Some(open_router::Reasoning { + enabled: Some(true), + effort: request + .thinking_effort + .as_deref() + .and_then(|e| e.parse::().ok()), + max_tokens: None, + exclude: None, + }) + } + OpenRouterModelMode::Thinking { budget_tokens } if request.thinking_allowed => { + Some(open_router::Reasoning { + enabled: Some(true), + effort: None, + max_tokens: budget_tokens, + exclude: None, + }) + } + OpenRouterModelMode::Adaptive | OpenRouterModelMode::Thinking { .. } + if !model.mandatory_reasoning => + { + Some(open_router::Reasoning { + enabled: Some(false), + effort: None, + max_tokens: None, + exclude: None, + }) + } + _ => None, }, tools: request .tools @@ -1098,6 +1161,9 @@ mod tests { Some(false), None, None, + None, + false, + None, ); let thread_id = "internal-thread-id"; let request = LanguageModelRequest { @@ -1134,6 +1200,9 @@ mod tests { Some(false), None, None, + None, + false, + None, ); let request = LanguageModelRequest { @@ -1285,6 +1354,9 @@ mod tests { Some(false), None, None, + None, + false, + None, ); let request = LanguageModelRequest { @@ -1348,6 +1420,9 @@ mod tests { Some(false), None, None, + None, + false, + None, ); let request = LanguageModelRequest { @@ -1400,4 +1475,83 @@ mod tests { } } } + + #[gpui::test] + async fn test_into_open_router_sends_requested_effort() { + let model = open_router::Model::new( + "z-ai/glm-5.2", + Some("GLM 5.2"), + Some(1_048_576), + Some(true), + Some(false), + Some(OpenRouterModelMode::Adaptive), + Some(vec![ + open_router::ReasoningEffort::XHigh, + open_router::ReasoningEffort::High, + ]), + Some(open_router::ReasoningEffort::High), + false, + None, + ); + let request = LanguageModelRequest { + messages: vec![language_model::LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello".to_string())], + cache: false, + reasoning_details: None, + }], + thinking_allowed: true, + thinking_effort: Some("xhigh".to_string()), + ..Default::default() + }; + + let result = into_open_router(request, &model, None).unwrap(); + let reasoning = result.reasoning.expect("reasoning should be set"); + assert_eq!(reasoning.effort, Some(open_router::ReasoningEffort::XHigh)); + assert_eq!( + reasoning.max_tokens, None, + "max_tokens should not be sent when effort is used" + ); + assert_eq!(reasoning.exclude, None); + assert_eq!(reasoning.enabled, Some(true)); + } + + #[gpui::test] + async fn test_into_open_router_disables_reasoning_when_thinking_not_allowed() { + let model = open_router::Model::new( + "z-ai/glm-5.2", + Some("GLM 5.2"), + Some(1_048_576), + Some(true), + Some(false), + Some(OpenRouterModelMode::Adaptive), + Some(vec![ + open_router::ReasoningEffort::XHigh, + open_router::ReasoningEffort::High, + ]), + Some(open_router::ReasoningEffort::High), + false, + None, + ); + let request = LanguageModelRequest { + messages: vec![language_model::LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello".to_string())], + cache: false, + reasoning_details: None, + }], + thinking_allowed: false, + thinking_effort: Some("high".to_string()), + ..Default::default() + }; + + let result = into_open_router(request, &model, None).unwrap(); + let reasoning = result.reasoning.expect("reasoning should be set"); + assert_eq!(reasoning.enabled, Some(false)); + assert_eq!( + reasoning.effort, None, + "effort should not be sent when reasoning is disabled" + ); + assert_eq!(reasoning.max_tokens, None); + } } diff --git a/crates/open_router/src/open_router.rs b/crates/open_router/src/open_router.rs index 36ac81a2b6..14cb90079e 100644 --- a/crates/open_router/src/open_router.rs +++ b/crates/open_router/src/open_router.rs @@ -3,6 +3,7 @@ use futures::{AsyncReadExt, StreamExt, stream::BoxStream}; use http_client::{ AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http, }; +pub use language_model_core::ReasoningEffort; use open_ai::ChatCompletionStreamEvent; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -81,6 +82,12 @@ pub struct Model { pub supports_images: Option, #[serde(default)] pub mode: ModelMode, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub supported_efforts: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_effort: Option, + pub supports_max_tokens: bool, + pub mandatory_reasoning: bool, pub provider: Option, } @@ -94,6 +101,9 @@ impl Model { Some(false), Some(ModelMode::Default), None, + None, + false, + None, ) } @@ -104,6 +114,9 @@ impl Model { supports_tools: Option, supports_images: Option, mode: Option, + supported_efforts: Option>, + default_effort: Option, + supports_max_tokens: bool, provider: Option, ) -> Self { Self { @@ -113,6 +126,10 @@ impl Model { supports_tools, supports_images, mode: mode.unwrap_or(ModelMode::Default), + supported_efforts: supported_efforts.unwrap_or_default(), + default_effort, + supports_max_tokens, + mandatory_reasoning: false, provider, } } @@ -138,7 +155,7 @@ impl Model { } pub fn supports_parallel_tool_calls(&self) -> bool { - false + true } } @@ -200,7 +217,7 @@ pub struct FunctionDefinition { #[derive(Debug, Serialize, Deserialize)] pub struct Reasoning { #[serde(skip_serializing_if = "Option::is_none")] - pub effort: Option, + pub effort: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -481,6 +498,8 @@ pub struct ModelEntry { pub supported_parameters: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub architecture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, } #[derive(Default, Debug, Clone, PartialEq, Deserialize)] @@ -489,6 +508,20 @@ pub struct ModelArchitecture { pub input_modalities: Vec, } +#[derive(Default, Debug, Clone, PartialEq, Deserialize)] +pub struct ModelReasoning { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mandatory: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub supported_efforts: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supports_max_tokens: Option, +} + pub async fn stream_completion( client: &dyn HttpClient, api_url: &str, @@ -610,12 +643,29 @@ pub async fn list_models( .supported_parameters .contains(&"reasoning".to_string()) { - ModelMode::Thinking { - budget_tokens: Some(4_096), - } + ModelMode::Adaptive } else { ModelMode::Default }, + supported_efforts: entry + .reasoning + .as_ref() + .map(|r| r.supported_efforts.clone()) + .unwrap_or_default() + .into_iter() + .rev() + .collect(), + default_effort: entry.reasoning.as_ref().and_then(|r| r.default_effort), + supports_max_tokens: entry + .reasoning + .as_ref() + .and_then(|r| r.supports_max_tokens) + .unwrap_or(false), + mandatory_reasoning: entry + .reasoning + .as_ref() + .and_then(|r| r.mandatory) + .unwrap_or(false), provider: None, }) .collect(); diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index 06239c76c2..94d49f90ec 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -590,6 +590,7 @@ pub struct OpenRouterAvailableModel { pub supports_tools: Option, pub supports_images: Option, pub mode: Option, + pub reasoning_effort: Option, pub provider: Option, }