fix(openrouter): stop silently ignoring thinking effort off (#10991)

This commit is contained in:
filip
2026-08-05 19:11:14 -07:00
committed by GitHub
parent 1c1bd5299a
commit 86eec2a8ed
3 changed files with 175 additions and 56 deletions
+1
View File
@@ -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 {
@@ -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 }));
}
}
+94 -16
View File
@@ -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.
@@ -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();
}
}