diff --git a/src/llm/model.rs b/src/llm/model.rs index 9dadcd93f..f23690dad 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -50,6 +50,7 @@ pub struct SpacebotModel { provider: String, full_model_name: String, routing: Option, + configured_thinking_effort: Option, agent_id: Option, process_type: Option, worker_type: Option, @@ -72,6 +73,14 @@ impl SpacebotModel { self } + pub fn with_configured_thinking_effort( + mut self, + configured_thinking_effort: impl Into, + ) -> Self { + self.configured_thinking_effort = Some(configured_thinking_effort.into()); + self + } + /// Attach agent context for per-agent metric labels. pub fn with_context( mut self, @@ -89,6 +98,17 @@ impl SpacebotModel { self } + fn configured_thinking_effort(&self) -> &str { + self.configured_thinking_effort + .as_deref() + .or_else(|| { + self.routing + .as_ref() + .map(|routing| routing.thinking_effort_for_model(&self.model_name)) + }) + .unwrap_or("auto") + } + async fn provider_config_for_current_model(&self) -> Result { let provider_id = self .full_model_name @@ -187,11 +207,14 @@ impl SpacebotModel { &self, model_name: &str, request: &CompletionRequest, + configured_effort: &str, ) -> Result, (CompletionError, bool)> { let model = if model_name == self.full_model_name { self.clone() + .with_configured_thinking_effort(configured_effort.to_string()) } else { SpacebotModel::make(&self.llm_manager, model_name) + .with_configured_thinking_effort(configured_effort.to_string()) }; let mut last_error = None; @@ -263,6 +286,7 @@ impl CompletionModel for SpacebotModel { provider, full_model_name, routing: None, + configured_thinking_effort: None, agent_id: None, process_type: None, worker_type: None, @@ -282,6 +306,9 @@ impl CompletionModel for SpacebotModel { return self.attempt_completion(request).await; }; + let configured_effort = routing + .thinking_effort_for_model(&self.model_name) + .to_string(); let cooldown = routing.rate_limit_cooldown_secs; let fallbacks = routing.get_fallbacks(&self.full_model_name); let mut last_error: Option = None; @@ -302,7 +329,7 @@ impl CompletionModel for SpacebotModel { ); } else { match self - .attempt_with_retries(&self.full_model_name, &request) + .attempt_with_retries(&self.full_model_name, &request, &configured_effort) .await { Ok(response) => return Ok(response), @@ -339,7 +366,10 @@ impl CompletionModel for SpacebotModel { continue; } - match self.attempt_with_retries(fallback_name, &request).await { + match self + .attempt_with_retries(fallback_name, &request, &configured_effort) + .await + { Ok(response) => { tracing::info!( original = %self.full_model_name, @@ -552,11 +582,7 @@ impl SpacebotModel { ) -> Result, CompletionError> { let api_key = provider_config.api_key.as_str(); - let effort = self - .routing - .as_ref() - .map(|r| r.thinking_effort_for_model(&self.model_name)) - .unwrap_or("auto"); + let effort = self.configured_thinking_effort(); let anthropic_request = crate::llm::anthropic::build_anthropic_request( self.llm_manager.http_client(), api_key, @@ -745,6 +771,11 @@ impl SpacebotModel { "input": input, }); + let configured_effort = self.configured_thinking_effort(); + if let Some(effort) = openai_reasoning_effort(&api_model_name, configured_effort) { + body["reasoning"] = serde_json::json!({ "effort": effort }); + } + if let Some(preamble) = &request.preamble { body["instructions"] = serde_json::json!(preamble); } else if is_chatgpt_codex { @@ -2871,6 +2902,29 @@ fn remap_model_name_for_api(provider: &str, model_name: &str) -> String { } } +fn openai_reasoning_effort(model_name: &str, configured_effort: &str) -> Option<&'static str> { + if configured_effort == "auto" || !model_name.starts_with("gpt-5") { + return None; + } + + let is_pro_model = model_name.contains("-pro"); + match configured_effort { + "max" => Some("xhigh"), + "high" => Some("high"), + "medium" => Some("medium"), + "low" if is_pro_model => Some("medium"), + "low" => Some("low"), + _ => { + tracing::warn!( + model = %model_name, + configured_effort = %configured_effort, + "ignoring invalid OpenAI reasoning effort" + ); + None + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -2933,6 +2987,29 @@ mod tests { assert_eq!(remap_model_name_for_api("openai", "zai/glm-5"), "zai/glm-5"); } + #[test] + fn openai_reasoning_effort_omits_auto_and_non_gpt5_models() { + assert_eq!(openai_reasoning_effort("gpt-5.4", "auto"), None); + assert_eq!(openai_reasoning_effort("gpt-4.1", "high"), None); + } + + #[test] + fn openai_reasoning_effort_maps_standard_gpt5_values() { + assert_eq!(openai_reasoning_effort("gpt-5.4", "low"), Some("low")); + assert_eq!(openai_reasoning_effort("gpt-5.4", "medium"), Some("medium")); + assert_eq!(openai_reasoning_effort("gpt-5.4", "high"), Some("high")); + assert_eq!(openai_reasoning_effort("gpt-5.4", "max"), Some("xhigh")); + } + + #[test] + fn openai_reasoning_effort_raises_low_floor_for_pro_models() { + assert_eq!( + openai_reasoning_effort("gpt-5.4-pro", "low"), + Some("medium") + ); + assert_eq!(openai_reasoning_effort("gpt-5.4-pro", "max"), Some("xhigh")); + } + #[test] fn parse_anthropic_response_drops_empty_text_blocks() { let body = serde_json::json!({ diff --git a/src/tools/send_message_to_another_channel.rs b/src/tools/send_message_to_another_channel.rs index abf72e239..bb0681fab 100644 --- a/src/tools/send_message_to_another_channel.rs +++ b/src/tools/send_message_to_another_channel.rs @@ -150,14 +150,13 @@ impl Tool for SendMessageTool { // If explicit prefix returned default "signal" adapter but we're in a named // Signal adapter conversation (e.g., signal:gvoice1), use the current adapter // to ensure the message goes through the correct account. - if target.adapter == "signal" { - if let Some(current_adapter) = self + if target.adapter == "signal" + && let Some(current_adapter) = self .current_adapter .as_ref() .filter(|adapter| adapter.starts_with("signal:")) - { - target.adapter = current_adapter.clone(); - } + { + target.adapter = current_adapter.clone(); } self.messaging_manager @@ -189,31 +188,28 @@ impl Tool for SendMessageTool { .current_adapter .as_ref() .filter(|adapter| adapter.starts_with("signal")) + && let Some(target) = parse_implicit_signal_shorthand(&args.target, current_adapter) { - if let Some(target) = parse_implicit_signal_shorthand(&args.target, current_adapter) { - self.messaging_manager - .broadcast( - &target.adapter, - &target.target, - crate::OutboundResponse::Text(args.message), - ) - .await - .map_err(|error| { - SendMessageError(format!("failed to send message: {error}")) - })?; - - tracing::info!( - adapter = %target.adapter, - broadcast_target = %"[REDACTED]", - "message sent via implicit Signal shorthand" - ); - - return Ok(SendMessageOutput { - success: true, - target: target.target, - platform: target.adapter, - }); - } + self.messaging_manager + .broadcast( + &target.adapter, + &target.target, + crate::OutboundResponse::Text(args.message), + ) + .await + .map_err(|error| SendMessageError(format!("failed to send message: {error}")))?; + + tracing::info!( + adapter = %target.adapter, + broadcast_target = %"[REDACTED]", + "message sent via implicit Signal shorthand" + ); + + return Ok(SendMessageOutput { + success: true, + target: target.target, + platform: target.adapter, + }); } // Check for explicit email target