diff --git a/crates/goose-provider-types/src/retry.rs b/crates/goose-provider-types/src/retry.rs index 49afd9bfe..589d13555 100644 --- a/crates/goose-provider-types/src/retry.rs +++ b/crates/goose-provider-types/src/retry.rs @@ -88,6 +88,7 @@ impl RetryConfig { const PERMANENT_REQUEST_FAILURE_MARKERS: &[&str] = &[ "blocks in the latest assistant message cannot be modified", "must remain as they were in the original response", + "Reasoning is mandatory for this endpoint", ]; fn is_permanent_request_failure(message: &str) -> bool { diff --git a/crates/goose/src/providers/formats/openrouter.rs b/crates/goose/src/providers/formats/openrouter.rs index c8087d3d9..aa43b5554 100644 --- a/crates/goose/src/providers/formats/openrouter.rs +++ b/crates/goose/src/providers/formats/openrouter.rs @@ -99,25 +99,40 @@ fn reasoning_effort_for_openrouter(effort: ThinkingEffort) -> Option<&'static st } } -pub fn apply_reasoning_config(payload: &mut Value, model_config: &ModelConfig) { +/// Returns true when a reasoning disable request was inserted, which +/// mandatory-reasoning endpoints reject; the provider downgrades those to +/// the lowest effort on OpenRouter's mandatory-reasoning error. +pub fn apply_reasoning_config(payload: &mut Value, model_config: &ModelConfig) -> bool { let Some(effort) = model_config.thinking_effort() else { - return; + return false; }; if let Some(obj) = payload.as_object_mut() { if obj.contains_key("reasoning") { obj.remove("reasoning_effort"); - return; + return false; } let clamped_effort = obj .remove("reasoning_effort") .and_then(|value| value.as_str().map(str::to_owned)); if effort == ThinkingEffort::Off { - return; + if !model_config.is_reasoning_model() { + return false; + } + return match clamped_effort { + Some(clamped) => { + obj.insert("reasoning".to_string(), json!({ "effort": clamped })); + false + } + None => { + obj.insert("reasoning".to_string(), json!({ "enabled": false })); + true + } + }; } if clamped_effort.is_none() && !model_config.is_reasoning_model() { - return; + return false; } let effort = clamped_effort @@ -127,6 +142,7 @@ pub fn apply_reasoning_config(payload: &mut Value, model_config: &ModelConfig) { obj.insert("reasoning".to_string(), json!({ "effort": effort })); } } + false } #[cfg(test)] @@ -230,23 +246,6 @@ mod tests { assert!(payload.get("reasoning_effort").is_none()); } - #[test] - fn test_apply_reasoning_config_omits_off_reasoning_capable_model() { - let mut payload = json!({ - "model": "google/gemini-2.5-flash", - "messages": [] - }); - let mut model_config = ModelConfig::new("google/gemini-2.5-flash"); - model_config.reasoning = Some(true); - let mut params = HashMap::new(); - params.insert("thinking_effort".to_string(), json!("off")); - model_config.request_params = Some(params); - - apply_reasoning_config(&mut payload, &model_config); - - assert!(payload.get("reasoning").is_none()); - } - #[test] fn test_apply_reasoning_config_uses_reasoning_metadata() { let mut payload = json!({ @@ -298,24 +297,7 @@ mod tests { } #[test] - fn test_apply_reasoning_config_off_omits_reasoning() { - let mut payload = json!({ - "model": "x-ai/grok-4", - "messages": [] - }); - let mut model_config = ModelConfig::new("x-ai/grok-4"); - let mut params = HashMap::new(); - params.insert("thinking_effort".to_string(), json!("off")); - model_config.request_params = Some(params); - model_config.reasoning = Some(true); - - apply_reasoning_config(&mut payload, &model_config); - - assert!(payload.get("reasoning").is_none()); - } - - #[test] - fn test_apply_reasoning_config_off_ignores_clamped_effort() { + fn test_apply_reasoning_config_off_keeps_clamped_effort() { let mut payload = json!({ "model": "openai/gpt-5", "messages": [], @@ -327,9 +309,67 @@ mod tests { model_config.request_params = Some(params); model_config.reasoning = Some(true); + let sent_disable = apply_reasoning_config(&mut payload, &model_config); + + assert!(!sent_disable); + assert_eq!(payload["reasoning"], json!({ "effort": "low" })); + assert!(payload.get("reasoning_effort").is_none()); + } + + #[test] + fn test_apply_reasoning_config_kimi_k3_off_disables_reasoning() { + let mut payload = json!({ + "model": "moonshotai/kimi-k3", + "messages": [] + }); + let mut model_config = + ModelConfig::new("moonshotai/kimi-k3").with_canonical_limits("openrouter"); + let mut params = HashMap::new(); + params.insert("thinking_effort".to_string(), json!("off")); + model_config.request_params = Some(params); + + let sent_disable = apply_reasoning_config(&mut payload, &model_config); + + assert!(sent_disable); + assert_eq!(payload["reasoning"], json!({ "enabled": false })); + } + + #[test] + fn test_apply_reasoning_config_off_skips_non_reasoning_model() { + // OpenAI-shaped but canonically non-reasoning; off must drop the clamp. + let mut payload = json!({ + "model": "openai/gpt-5.1-chat", + "messages": [], + "reasoning_effort": "low" + }); + let mut model_config = ModelConfig::new("openai/gpt-5.1-chat"); + let mut params = HashMap::new(); + params.insert("thinking_effort".to_string(), json!("off")); + model_config.request_params = Some(params); + model_config.reasoning = Some(false); + apply_reasoning_config(&mut payload, &model_config); assert!(payload.get("reasoning").is_none()); assert!(payload.get("reasoning_effort").is_none()); } + + #[test] + fn test_apply_reasoning_config_off_preserves_user_reasoning() { + let mut payload = json!({ + "model": "x-ai/grok-4", + "messages": [], + "reasoning": { "max_tokens": 2000 } + }); + let mut model_config = ModelConfig::new("x-ai/grok-4"); + let mut params = HashMap::new(); + params.insert("thinking_effort".to_string(), json!("off")); + model_config.request_params = Some(params); + model_config.reasoning = Some(true); + + let sent_disable = apply_reasoning_config(&mut payload, &model_config); + + assert!(!sent_disable); + assert_eq!(payload["reasoning"], json!({ "max_tokens": 2000 })); + } } diff --git a/crates/goose/src/providers/openrouter.rs b/crates/goose/src/providers/openrouter.rs index 177a6356d..b3a679f42 100644 --- a/crates/goose/src/providers/openrouter.rs +++ b/crates/goose/src/providers/openrouter.rs @@ -74,6 +74,28 @@ impl OpenRouterProvider { configured_parameters, }) } + + async fn post_chat_completions( + &self, + model_config: &ModelConfig, + payload: &Value, + ) -> Result { + self.with_retry(|| async { + let resp = self + .api_client + .request("api/v1/chat/completions") + .model_headers(model_config)? + .streaming(true) + .response_post(payload) + .await?; + handle_status(resp).await + }) + .await + } +} + +fn is_mandatory_reasoning_error(error: &ProviderError) -> bool { + matches!(error, ProviderError::RequestFailed(message) if message.contains("Reasoning is mandatory")) } /// Update the request when using anthropic model. @@ -335,7 +357,8 @@ impl Provider for OpenRouterProvider { if is_gemini_model(&model_config.model_name) { openrouter_format::add_reasoning_details_to_request(&mut payload, messages); } - openrouter_format::apply_reasoning_config(&mut payload, model_config); + let sent_reasoning_disable = + openrouter_format::apply_reasoning_config(&mut payload, model_config); if let Some(obj) = payload.as_object_mut() { obj.insert("transforms".to_string(), json!(["middle-out"])); @@ -344,21 +367,20 @@ impl Provider for OpenRouterProvider { let mut log = start_log(model_config, &payload)?; - let response = self - .with_retry(|| async { - let resp = self - .api_client - .request("api/v1/chat/completions") - .model_headers(model_config)? - .streaming(true) - .response_post(&payload) - .await?; - handle_status(resp).await - }) - .await - .inspect_err(|e| { - let _ = log.error(e); - })?; + let response = match self.post_chat_completions(model_config, &payload).await { + // Mandatory-reasoning endpoints reject the disable request, so + // downgrade to the lowest effort they all accept and retry once. + Err(error) if sent_reasoning_disable && is_mandatory_reasoning_error(&error) => { + let _ = log.error(&error); + payload["reasoning"] = json!({ "effort": "low" }); + log = start_log(model_config, &payload)?; + self.post_chat_completions(model_config, &payload).await + } + result => result, + } + .inspect_err(|e| { + let _ = log.error(e); + })?; stream_openai_compat(response, log) } @@ -448,4 +470,60 @@ mod tests { assert_eq!(request_params["plugins"], json!([{ "id": "web" }])); assert_eq!(request_params["verbosity"], json!("xhigh")); } + + #[tokio::test] + async fn stream_downgrades_reasoning_disable_on_mandatory_endpoint() { + use wiremock::matchers::{body_partial_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/chat/completions")) + .and(body_partial_json( + json!({ "reasoning": { "enabled": false } }), + )) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": { "message": "Reasoning is mandatory for this endpoint and cannot be disabled." } + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/v1/chat/completions")) + .and(body_partial_json( + json!({ "reasoning": { "effort": "low" } }), + )) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string("data: [DONE]\n\n"), + ) + .expect(1) + .mount(&server) + .await; + + let provider = OpenRouterProvider { + api_client: ApiClient::new_with_tls( + server.uri(), + AuthMethod::BearerToken("test-key".to_string()), + None, + ) + .unwrap(), + supports_streaming: true, + name: OPENROUTER_PROVIDER_NAME.to_string(), + configured_parameters: None, + }; + + let mut config = model_config("google/gemini-3.5-flash"); + config.reasoning = Some(true); + config.request_params = Some(HashMap::from([( + "thinking_effort".to_string(), + json!("off"), + )])); + + let _stream = provider + .stream(&config, "system", &[Message::user().with_text("hi")], &[]) + .await + .unwrap(); + } }