Skip to content
Merged
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
1 change: 1 addition & 0 deletions crates/goose-provider-types/src/retry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
104 changes: 72 additions & 32 deletions crates/goose/src/providers/formats/openrouter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)]
Expand Down Expand Up @@ -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!({
Expand Down Expand Up @@ -298,38 +297,79 @@ mod tests {
}

#[test]
fn test_apply_reasoning_config_off_omits_reasoning() {
fn test_apply_reasoning_config_off_keeps_clamped_effort() {
let mut payload = json!({
"model": "x-ai/grok-4",
"messages": []
"model": "openai/gpt-5",
"messages": [],
"reasoning_effort": "low"
});
let mut model_config = ModelConfig::new("x-ai/grok-4");
let mut model_config = ModelConfig::new("openai/gpt-5");
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);
let sent_disable = apply_reasoning_config(&mut payload, &model_config);

assert!(payload.get("reasoning").is_none());
assert!(!sent_disable);
assert_eq!(payload["reasoning"], json!({ "effort": "low" }));
assert!(payload.get("reasoning_effort").is_none());
}

#[test]
fn test_apply_reasoning_config_off_ignores_clamped_effort() {
fn test_apply_reasoning_config_kimi_k3_off_disables_reasoning() {
let mut payload = json!({
"model": "openai/gpt-5",
"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");
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(true);
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 }));
}
}
110 changes: 94 additions & 16 deletions crates/goose/src/providers/openrouter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,28 @@ impl OpenRouterProvider {
configured_parameters,
})
}

async fn post_chat_completions(
&self,
model_config: &ModelConfig,
payload: &Value,
) -> Result<reqwest::Response, ProviderError> {
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.
Expand Down Expand Up @@ -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"]));
Expand All @@ -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)
}
Expand Down Expand Up @@ -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();
}
}
Loading