diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 95ac69c97d..b447ee1bd7 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -14,7 +14,9 @@ use language_model::{ LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, }; -use open_router::{Model, ResponseStreamEvent, list_models, stream_completion}; +use open_router::{ + Model, ModelMode as OpenRouterModelMode, ResponseStreamEvent, list_models, stream_completion, +}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; @@ -45,6 +47,39 @@ pub struct AvailableModel { pub max_completion_tokens: Option, pub supports_tools: Option, pub supports_images: Option, + pub mode: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum ModelMode { + #[default] + Default, + Thinking { + budget_tokens: Option, + }, +} + +impl From for OpenRouterModelMode { + fn from(value: ModelMode) -> Self { + match value { + ModelMode::Default => OpenRouterModelMode::Default, + ModelMode::Thinking { budget_tokens } => { + OpenRouterModelMode::Thinking { budget_tokens } + } + } + } +} + +impl From for ModelMode { + fn from(value: OpenRouterModelMode) -> Self { + match value { + OpenRouterModelMode::Default => ModelMode::Default, + OpenRouterModelMode::Thinking { budget_tokens } => { + ModelMode::Thinking { budget_tokens } + } + } + } } pub struct OpenRouterLanguageModelProvider { @@ -242,6 +277,7 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider { max_tokens: model.max_tokens, supports_tools: model.supports_tools, supports_images: model.supports_images, + mode: model.mode.clone().unwrap_or_default().into(), }); } @@ -403,13 +439,12 @@ pub fn into_open_router( for message in request.messages { for content in message.content { match content { - MessageContent::Text(text) | MessageContent::Thinking { text, .. } => { - add_message_content_part( - open_router::MessagePart::Text { text }, - message.role, - &mut messages, - ) - } + MessageContent::Text(text) => add_message_content_part( + open_router::MessagePart::Text { text }, + message.role, + &mut messages, + ), + MessageContent::Thinking { .. } => {} MessageContent::RedactedThinking(_) => {} MessageContent::Image(image) => { add_message_content_part( @@ -479,6 +514,16 @@ pub fn into_open_router( None }, usage: open_router::RequestUsage { include: true }, + reasoning: if 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 + }, tools: request .tools .into_iter() @@ -569,8 +614,19 @@ impl OpenRouterEventMapper { }; let mut events = Vec::new(); + if let Some(reasoning) = choice.delta.reasoning.clone() { + events.push(Ok(LanguageModelCompletionEvent::Thinking { + text: reasoning, + signature: None, + })); + } + if let Some(content) = choice.delta.content.clone() { - events.push(Ok(LanguageModelCompletionEvent::Text(content))); + // OpenRouter send empty content string with the reasoning content + // This is a workaround for the OpenRouter API bug + if !content.is_empty() { + events.push(Ok(LanguageModelCompletionEvent::Text(content))); + } } if let Some(tool_calls) = choice.delta.tool_calls.as_ref() { diff --git a/crates/open_router/src/open_router.rs b/crates/open_router/src/open_router.rs index f0cb30e7aa..4128426a7f 100644 --- a/crates/open_router/src/open_router.rs +++ b/crates/open_router/src/open_router.rs @@ -53,6 +53,18 @@ pub struct Model { pub max_tokens: u64, pub supports_tools: Option, pub supports_images: Option, + #[serde(default)] + pub mode: ModelMode, +} + +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub enum ModelMode { + #[default] + Default, + Thinking { + budget_tokens: Option, + }, } impl Model { @@ -63,6 +75,7 @@ impl Model { Some(2000000), Some(true), Some(false), + Some(ModelMode::Default), ) } @@ -76,6 +89,7 @@ impl Model { max_tokens: Option, supports_tools: Option, supports_images: Option, + mode: Option, ) -> Self { Self { name: name.to_owned(), @@ -83,6 +97,7 @@ impl Model { max_tokens: max_tokens.unwrap_or(2000000), supports_tools, supports_images, + mode: mode.unwrap_or(ModelMode::Default), } } @@ -127,6 +142,8 @@ pub struct Request { pub parallel_tool_calls: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tools: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, pub usage: RequestUsage, } @@ -160,6 +177,18 @@ pub struct FunctionDefinition { pub parameters: Option, } +#[derive(Debug, Serialize, Deserialize)] +pub struct Reasoning { + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exclude: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(tag = "role", rename_all = "lowercase")] pub enum RequestMessage { @@ -299,6 +328,7 @@ pub struct FunctionContent { pub struct ResponseMessageDelta { pub role: Option, pub content: Option, + pub reasoning: Option, #[serde(default, skip_serializing_if = "is_none_or_empty")] pub tool_calls: Option>, } @@ -591,6 +621,16 @@ pub async fn list_models(client: &dyn HttpClient, api_url: &str) -> Result