Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 84 additions & 7 deletions src/llm/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub struct SpacebotModel {
provider: String,
full_model_name: String,
routing: Option<RoutingConfig>,
configured_thinking_effort: Option<String>,
agent_id: Option<String>,
process_type: Option<String>,
worker_type: Option<String>,
Expand All @@ -72,6 +73,14 @@ impl SpacebotModel {
self
}

pub fn with_configured_thinking_effort(
mut self,
configured_thinking_effort: impl Into<String>,
) -> 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,
Expand All @@ -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<ProviderConfig, CompletionError> {
let provider_id = self
.full_model_name
Expand Down Expand Up @@ -187,11 +207,14 @@ impl SpacebotModel {
&self,
model_name: &str,
request: &CompletionRequest,
configured_effort: &str,
) -> Result<completion::CompletionResponse<RawResponse>, (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;
Expand Down Expand Up @@ -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,
Expand All @@ -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<CompletionError> = None;
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -552,11 +582,7 @@ impl SpacebotModel {
) -> Result<completion::CompletionResponse<RawResponse>, 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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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::*;
Expand Down Expand Up @@ -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!({
Expand Down
54 changes: 25 additions & 29 deletions src/tools/send_message_to_another_channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading