fix(openrouter): stop silently ignoring thinking effort off (#10991)
This commit is contained in:
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user