From dfdd2b9558d10d6871ede02a4d85af007a2429ef Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Sat, 21 Jun 2025 11:33:50 +0530 Subject: [PATCH] language_models: Add thinking support to OpenRouter provider (#32541) Did some bit cleanup of code for loading models for settings as that is not required as we are fetching all the models from openrouter so it's better to maintain one source of truth Release Notes: - Add thinking support to OpenRouter provider --- .../src/provider/open_router.rs | 74 ++++++++++++++++--- crates/open_router/src/open_router.rs | 40 ++++++++++ docs/src/ai/configuration.md | 43 +++++++++++ 3 files changed, 148 insertions(+), 9 deletions(-) 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