From 0174ea33b1173f52f3cb9aa66866824d275ea587 Mon Sep 17 00:00:00 2001 From: Erin McNulty Date: Mon, 6 Jul 2026 10:48:09 -0400 Subject: [PATCH 1/6] Strip sampling params for Sonnet 5+ and emit disabled thinking for effort=none claude-sonnet-5 rejects sampling params (temperature/top_p/top_k) and uses the modern reasoning contract (adaptive thinking + output_config.effort, rejecting legacy thinking.type=enabled) - verified live against the Anthropic API. - Extend OPUS_4_7_OR_LATER_RE, which gates apply_model_transforms, supports_adaptive_thinking, and supports_output_config_effort for Opus 4.7+ and Fable, to also cover Sonnet 5+ (and provider-wrapped Bedrock/Vertex IDs). Add focused tests. - Sonnet 5 (and Opus 4.7+) think by default when the `thinking` field is omitted, so a cross-provider `reasoning_effort: "none"` was silently dropping the opt-out and running thinking with its token cost. Emit `thinking: {type: "disabled"}` for an explicit opt-out (effort=none / enabled=false) even when the model supports output_config.effort. Confirmed live: thinking.disabled yields thinking_tokens=0 on claude-sonnet-5. Add unit and cross-provider tests. Co-Authored-By: Claude --- .../lingua/src/providers/anthropic/adapter.rs | 101 +++++++++++++++++- .../src/providers/anthropic/capabilities.rs | 88 ++++++++++++++- 2 files changed, 185 insertions(+), 4 deletions(-) diff --git a/crates/lingua/src/providers/anthropic/adapter.rs b/crates/lingua/src/providers/anthropic/adapter.rs index ac20c041..a18ec5f4 100644 --- a/crates/lingua/src/providers/anthropic/adapter.rs +++ b/crates/lingua/src/providers/anthropic/adapter.rs @@ -145,6 +145,10 @@ fn reasoning_is_enabled(config: &ReasoningConfig) -> bool { || config.budget_tokens.is_some()) } +fn reasoning_is_disabled(config: &ReasoningConfig) -> bool { + config.effort == Some(ReasoningEffort::None) || config.enabled == Some(false) +} + fn reasoning_effort_level( config: Option<&ReasoningConfig>, max_tokens: Option, @@ -438,6 +442,7 @@ impl ProviderAdapter for AnthropicAdapter { // - All other cases → thinking object (legacy, broad model support) // Both branches use output_config.format for structured output (never output_format). let reasoning_config = req.params.reasoning.as_ref(); + let reasoning_is_disabled = reasoning_config.is_some_and(reasoning_is_disabled); let use_adaptive_thinking = capabilities::supports_adaptive_thinking(model) && reasoning_config.is_some_and(reasoning_is_enabled); let use_effort = capabilities::supports_output_config_effort(model) @@ -455,7 +460,20 @@ impl ProviderAdapter for AnthropicAdapter { .map_err(|e| TransformError::SerializationFailed(e.to_string()))?, ) } else if use_effort { - None + // These models think by default when `thinking` is omitted, so an explicit + // opt-out (effort=none / enabled=false) must emit `thinking: {type: "disabled"}`. + if reasoning_is_disabled { + Some( + serde_json::to_value(&Thinking { + budget_tokens: None, + display: None, + thinking_type: ThinkingType::Disabled, + }) + .map_err(|e| TransformError::SerializationFailed(e.to_string()))?, + ) + } else { + None + } } else { req.params.reasoning_for(ProviderFormat::Anthropic) }; @@ -1879,6 +1897,87 @@ mod tests { assert_eq!(output_config.effort, Some(EffortLevel::Medium)); } + #[test] + fn test_anthropic_emits_disabled_thinking_for_effort_none() { + use crate::universal::message::UserContent; + use crate::universal::request::ReasoningConfig; + + // These models think by default when `thinking` is omitted, so a cross-provider + // `reasoning_effort: "none"` (universal ReasoningEffort::None) must be translated + // to `thinking: {type: "disabled"}` rather than dropped. + for model in ["claude-sonnet-5", "claude-opus-4-7", "claude-opus-4-8"] { + let adapter = AnthropicAdapter; + + let req = UniversalRequest { + model: Some(model.to_string()), + messages: vec![Message::User { + content: UserContent::String("What is 2+2?".to_string()), + }], + params: UniversalParams { + reasoning: Some(ReasoningConfig { + effort: Some(ReasoningEffort::None), + canonical: Some(ReasoningCanonical::Effort), + ..Default::default() + }), + token_budget: Some(TokenBudget::OutputTokens(4096)), + ..Default::default() + }, + }; + + let result: CreateMessageParams = + serde_json::from_value(adapter.request_from_universal(&req).unwrap()).unwrap(); + + let thinking = result.thinking.expect("thinking should be disabled"); + assert_eq!( + thinking.thinking_type, + ThinkingType::Disabled, + "{model} should emit disabled thinking for effort=none" + ); + assert!( + result + .output_config + .as_ref() + .and_then(|c| c.effort.clone()) + .is_none(), + "{model} should not set output_config.effort for effort=none" + ); + } + } + + #[test] + fn test_anthropic_cross_provider_reasoning_effort_none_emits_disabled() { + use crate::processing::adapters::ProviderAdapter; + use crate::providers::openai::adapter::OpenAIAdapter; + + // OpenAI `reasoning_effort: "none"` flowing into a Sonnet 5 / Opus 4.7+ target + // must arrive as `thinking: {type: "disabled"}` so thinking doesn't run by default. + for model in ["claude-sonnet-5", "claude-opus-4-7", "claude-opus-4-8"] { + let openai_adapter = OpenAIAdapter; + let anthropic_adapter = AnthropicAdapter; + + let openai_payload = json!({ + "model": "gpt-5", + "messages": [{"role": "user", "content": "What is 2+2?"}], + "reasoning_effort": "none" + }); + + let mut universal = openai_adapter.request_to_universal(openai_payload).unwrap(); + universal.model = Some(model.to_string()); + + let anthropic_payload = anthropic_adapter + .request_from_universal(&universal) + .unwrap(); + let thinking = anthropic_payload + .get("thinking") + .expect("thinking should be emitted for reasoning_effort none"); + assert_eq!( + thinking.get("type").and_then(Value::as_str), + Some("disabled"), + "{model}: reasoning_effort=none must produce thinking.type=disabled" + ); + } + } + #[test] fn test_anthropic_opus_4_8_uses_adaptive_thinking_with_budget() { use crate::universal::message::UserContent; diff --git a/crates/lingua/src/providers/anthropic/capabilities.rs b/crates/lingua/src/providers/anthropic/capabilities.rs index 0e2ab0a7..4364c65e 100644 --- a/crates/lingua/src/providers/anthropic/capabilities.rs +++ b/crates/lingua/src/providers/anthropic/capabilities.rs @@ -8,9 +8,9 @@ use std::sync::LazyLock; const OUTPUT_CONFIG_EFFORT_MODEL_PREFIXES: &[&str] = &["claude-opus-4-5", "claude-opus-4-6"]; static OPUS_4_7_OR_LATER_RE: LazyLock = LazyLock::new(|| { Regex::new( - r"(^|[./:@])claude-(opus-(4[-.]([7-9]|[1-9]\d)|([5-9]|[1-9]\d)[-.]\d{1,2})|fable-[a-z0-9][a-z0-9.-]*)($|[-./:@])", + r"(^|[./:@])claude-(opus-(4[-.]([7-9]|[1-9]\d)|([5-9]|[1-9]\d)[-.]\d{1,2})|sonnet-([5-9]|[1-9]\d)([-.]\d{1,2})?|fable-[a-z0-9][a-z0-9.-]*)($|[-./:@])", ) - .expect("valid Opus 4.7+ or Fable model regex") + .expect("valid Opus 4.7+ / Sonnet 5+ / Fable model regex") }); static OPUS_4_8_OR_LATER_RE: LazyLock = LazyLock::new(|| { Regex::new(r"^(?:[a-z0-9-]+\.)?anthropic\.claude-(?:opus-(4[-.]([8-9]|[1-9]\d)|([5-9]|[1-9]\d)[-.]\d{1,2})|fable-\d{1,2})($|[-.:])|^claude-(?:opus-(4[-.]([8-9]|[1-9]\d)|([5-9]|[1-9]\d)[-.]\d{1,2})|fable-\d{1,2})($|[-.])") @@ -18,7 +18,7 @@ static OPUS_4_8_OR_LATER_RE: LazyLock = LazyLock::new(|| { }); /// Check if a model supports `output_config.effort` (vs legacy `thinking`). /// -/// Only Opus 4.5+ models support this. All models support `thinking` as fallback. +/// Opus 4.5+ and Sonnet 5+ models support this. All models support `thinking` as fallback. pub fn supports_output_config_effort(model: &str) -> bool { let lower = model.to_ascii_lowercase(); // Bedrock/Vertex model IDs wrap the Anthropic model token with provider-specific @@ -148,6 +148,17 @@ mod tests { assert!(supports_output_config_effort( "anthropic/claude-opus-5-0@20260701" )); + assert!(supports_output_config_effort("claude-sonnet-5")); + assert!(supports_output_config_effort("claude-sonnet-5-20260701")); + assert!(supports_output_config_effort("CLAUDE-SONNET-5")); + assert!(supports_output_config_effort( + "us.anthropic.claude-sonnet-5-v1:0" + )); + assert!(supports_output_config_effort( + "anthropic/claude-sonnet-5@20260701" + )); + assert!(supports_output_config_effort("claude-sonnet-5.0")); + assert!(supports_output_config_effort("claude-sonnet-5-1-20260715")); // Other models do not assert!(!supports_output_config_effort("claude-opus-4-4")); @@ -178,6 +189,12 @@ mod tests { "claude-opus-5.0", "claude-opus-5-1-20260701", "anthropic/claude-opus-5-0@20260701", + "claude-sonnet-5", + "claude-sonnet-5-20260701", + "CLAUDE-SONNET-5", + "us.anthropic.claude-sonnet-5-v1:0", + "anthropic/claude-sonnet-5@20260701", + "claude-sonnet-5.0", ]; let legacy_models = [ "claude-opus-4-20250514", @@ -282,6 +299,19 @@ mod tests { "anthropic/claude-opus-5-0@20260701", &[StripSamplingParams][..], ), + ("claude-sonnet-5", &[StripSamplingParams][..]), + ("claude-sonnet-5-20260701", &[StripSamplingParams][..]), + ("CLAUDE-SONNET-5", &[StripSamplingParams][..]), + ( + "us.anthropic.claude-sonnet-5-v1:0", + &[StripSamplingParams][..], + ), + ( + "anthropic/claude-sonnet-5@20260701", + &[StripSamplingParams][..], + ), + ("claude-sonnet-5.0", &[StripSamplingParams][..]), + ("claude-sonnet-5-1-20260715", &[StripSamplingParams][..]), ("claude-fable-5", &[StripSamplingParams][..]), ("claude-fable-5-20260601", &[StripSamplingParams][..]), ("CLAUDE-FABLE-5", &[StripSamplingParams][..]), @@ -319,6 +349,12 @@ mod tests { "claude-opus-5-0", "claude-opus-5.0", "claude-opus-5-1-20260701", + "claude-sonnet-5", + "claude-sonnet-5-20260701", + "CLAUDE-SONNET-5", + "us.anthropic.claude-sonnet-5-v1:0", + "anthropic/claude-sonnet-5@20260701", + "claude-sonnet-5.0", "claude-fable-5", "claude-fable-5-20260601", "CLAUDE-FABLE-5", @@ -359,6 +395,12 @@ mod tests { "claude-opus-5.0", "claude-opus-5-1-20260701", "anthropic/claude-opus-5-0@20260701", + "claude-sonnet-5", + "claude-sonnet-5-20260701", + "CLAUDE-SONNET-5", + "us.anthropic.claude-sonnet-5-v1:0", + "anthropic/claude-sonnet-5@20260701", + "claude-sonnet-5.0", ]; let preserve_models = [ "claude-opus-4-20250514", @@ -414,4 +456,44 @@ mod tests { assert!(!obj.contains_key("top_k"), "{} should strip top_k", model); } } + + #[test] + fn test_sonnet_5_strips_sampling_params() { + for model in [ + "claude-sonnet-5", + "claude-sonnet-5-20260701", + "CLAUDE-SONNET-5", + "claude-sonnet-5.0", + "claude-sonnet-5-1-20260715", + "us.anthropic.claude-sonnet-5-v1:0", + "anthropic/claude-sonnet-5@20260701", + ] { + let mut obj = object_with_sampling_params(); + apply_model_transforms(model, &mut obj); + assert!( + !obj.contains_key("temperature"), + "{} should strip temperature", + model + ); + assert!(!obj.contains_key("top_p"), "{} should strip top_p", model); + assert!(!obj.contains_key("top_k"), "{} should strip top_k", model); + } + + // Sonnet 4 and earlier must keep sampling params. + for model in [ + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-20250514", + "claude-3-5-sonnet-20241022", + ] { + let mut obj = object_with_sampling_params(); + apply_model_transforms(model, &mut obj); + assert!( + obj.contains_key("temperature"), + "{} should preserve temperature", + model + ); + assert!(obj.contains_key("top_p"), "{} should preserve top_p", model); + assert!(obj.contains_key("top_k"), "{} should preserve top_k", model); + } + } } From e5897ec3c07234847ad49a2bb1d1d23f00358e7d Mon Sep 17 00:00:00 2001 From: Erin McNulty Date: Mon, 6 Jul 2026 11:39:15 -0400 Subject: [PATCH 2/6] Preserve raw output_config effort and stop collapsing xhigh to max Routing claude-sonnet-5 through the adaptive/effort path rerouted same-provider Anthropic round-trips through the reconstructed OutputConfig, which serializes the universal ReasoningEffort enum back to EffortLevel. That enum has no Max variant, so reasoning_effort_level() collapsed ReasoningEffort::Xhigh to EffortLevel::Max - silently bumping Anthropic's distinct "xhigh" and "max" effort values (verified live: both accepted on claude-sonnet-5, distinct thinking depths). - Prefer the raw Anthropic `output_config` extras verbatim on same-provider round-trips when present (the pre-Sonnet-5 behavior), only reconstructing when there is no raw output_config. - Fix reasoning_effort_level() to map Xhigh -> EffortLevel::Xhigh (not Max) so the reconstructed cross-provider path no longer bumps the requested effort. - Add unit (raw round-trip preserves xhigh/max) and cross-provider (reasoning_effort=xhigh stays xhigh) tests. --- .../lingua/src/providers/anthropic/adapter.rs | 95 ++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/crates/lingua/src/providers/anthropic/adapter.rs b/crates/lingua/src/providers/anthropic/adapter.rs index a18ec5f4..4e477bc6 100644 --- a/crates/lingua/src/providers/anthropic/adapter.rs +++ b/crates/lingua/src/providers/anthropic/adapter.rs @@ -165,7 +165,7 @@ fn reasoning_effort_level( ReasoningEffort::Minimal | ReasoningEffort::Low => Some(EffortLevel::Low), ReasoningEffort::Medium => Some(EffortLevel::Medium), ReasoningEffort::High => Some(EffortLevel::High), - ReasoningEffort::Xhigh => Some(EffortLevel::Max), + ReasoningEffort::Xhigh => Some(EffortLevel::Xhigh), } } @@ -581,7 +581,27 @@ impl ProviderAdapter for AnthropicAdapter { let raw_thinking = anthropic_extras_view.thinking.as_ref(); if use_adaptive_thinking { - if effort_level.is_some() || format.is_some() { + // Same-provider Anthropic round-trips carry the original `output_config` in + // extras. Prefer it verbatim so distinct effort values (e.g. "xhigh" vs "max", + // which the universal ReasoningEffort enum collapses) survive unchanged. Only + // reconstruct when there is no raw output_config. + if let Some(raw_output_config) = raw_output_config { + let mut output_config = raw_output_config.clone(); + if let Some(format) = format { + let format_value = serde_json::to_value(&format) + .map_err(|e| TransformError::SerializationFailed(e.to_string()))?; + output_config + .as_object_mut() + .ok_or_else(|| { + TransformError::FromUniversalFailed( + "output_config extras is not an object".to_string(), + ) + })? + .entry("format") + .or_insert(format_value); + } + obj.insert("output_config".into(), output_config); + } else if effort_level.is_some() || format.is_some() { let output_config = OutputConfig { effort: effort_level, format, @@ -1978,6 +1998,77 @@ mod tests { } } + #[test] + fn test_anthropic_preserves_raw_output_config_effort_for_sonnet_5() { + // Same-provider Anthropic round-trip: a raw `output_config.effort` of "xhigh" + // must survive verbatim and not collapse to "max" through the universal enum. + for (model, effort) in [ + ("claude-sonnet-5", "xhigh"), + ("claude-sonnet-5", "max"), + ("claude-opus-4-7", "xhigh"), + ] { + let adapter = AnthropicAdapter; + let payload = json!({ + "model": model, + "max_tokens": 4096, + "messages": [{"role": "user", "content": "What is 2+2?"}], + "output_config": {"effort": effort} + }); + + let universal = adapter.request_to_universal(payload).unwrap(); + let result: CreateMessageParams = + serde_json::from_value(adapter.request_from_universal(&universal).unwrap()) + .unwrap(); + + let output_config = result + .output_config + .expect("output_config should be present"); + let result_effort = output_config + .effort + .as_ref() + .map(|e| serde_json::to_value(e).unwrap()) + .map(|v| v.as_str().unwrap_or_default().to_string()); + assert_eq!( + result_effort.as_deref(), + Some(effort), + "{model}: output_config.effort should round-trip {effort}, got {result_effort:?}" + ); + } + } + + #[test] + fn test_anthropic_cross_provider_reasoning_effort_xhigh_not_collapsed_to_max() { + use crate::processing::adapters::ProviderAdapter; + use crate::providers::openai::adapter::OpenAIAdapter; + + // OpenAI `reasoning_effort: "xhigh"` flowing into Sonnet 5 must map to `xhigh`, + // not collapse to `max`. + let openai_adapter = OpenAIAdapter; + let anthropic_adapter = AnthropicAdapter; + + let openai_payload = json!({ + "model": "gpt-5", + "messages": [{"role": "user", "content": "What is 2+2?"}], + "reasoning_effort": "xhigh" + }); + + let mut universal = openai_adapter.request_to_universal(openai_payload).unwrap(); + universal.model = Some("claude-sonnet-5".to_string()); + + let anthropic_payload = anthropic_adapter + .request_from_universal(&universal) + .unwrap(); + let effort = anthropic_payload + .get("output_config") + .and_then(|oc| oc.get("effort")) + .and_then(Value::as_str); + assert_eq!( + effort, + Some("xhigh"), + "reasoning_effort=xhigh must map to output_config.effort=xhigh, not max" + ); + } + #[test] fn test_anthropic_opus_4_8_uses_adaptive_thinking_with_budget() { use crate::universal::message::UserContent; From 9bfe4eeca6b05fc87b877406cd5e78d26e3d28e4 Mon Sep 17 00:00:00 2001 From: Erin McNulty Date: Mon, 6 Jul 2026 11:43:20 -0400 Subject: [PATCH 3/6] Drop output_config.effort alongside thinking under forced tool_choice Now that claude-sonnet-5 routes through the adaptive/effort path, requests with reasoning effort and forced tool_choice (tool_choice type "tool" with a name, including the JSON-object shim) hit the forced-tool guard. The guard already drops the `thinking` object because forced tool use is incompatible with active thinking, but it still emitted output_config.effort, leaving a dangling effort that asks for reasoning the guard just disabled. Drop output_config.effort when forced_tool_choice is set on the adaptive path, both for the reconstructed OutputConfig and the raw same-provider extras round-trip, while keeping output_config.format (independent of thinking). Confirmed live against claude-sonnet-5: the post-fix payload (forced tool, no thinking, no effort) returns 200 tool_use, and output_config with format only is accepted under forced tool_choice. Add tests covering the constructed path, the auto (non-forced) regression, and the raw round-trip path. Co-Authored-By: Claude --- .../lingua/src/providers/anthropic/adapter.rs | 141 +++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/crates/lingua/src/providers/anthropic/adapter.rs b/crates/lingua/src/providers/anthropic/adapter.rs index 4e477bc6..79e45c66 100644 --- a/crates/lingua/src/providers/anthropic/adapter.rs +++ b/crates/lingua/src/providers/anthropic/adapter.rs @@ -563,7 +563,10 @@ impl ProviderAdapter for AnthropicAdapter { insert_opt_bool(&mut obj, "stream", req.params.stream); // Build output_config (always used for structured output format, and for effort on Opus 4.5+) - let effort_level = if use_effort { + // Forced tool_choice is incompatible with active thinking. The thinking guard below + // drops the `thinking` object in that case; drop `effort` too so the request does not + // ask for reasoning the guard just disabled. `format` is independent of thinking. + let effort_level = if use_effort && !forced_tool_choice { reasoning_effort_level(reasoning_config, Some(max_tokens)) } else { None @@ -587,6 +590,11 @@ impl ProviderAdapter for AnthropicAdapter { // reconstruct when there is no raw output_config. if let Some(raw_output_config) = raw_output_config { let mut output_config = raw_output_config.clone(); + if forced_tool_choice { + if let Some(obj) = output_config.as_object_mut() { + obj.remove("effort"); + } + } if let Some(format) = format { let format_value = serde_json::to_value(&format) .map_err(|e| TransformError::SerializationFailed(e.to_string()))?; @@ -2069,6 +2077,137 @@ mod tests { ); } + #[test] + fn test_anthropic_drops_effort_for_forced_tool_choice_on_sonnet_5() { + use crate::universal::message::UserContent; + use crate::universal::request::{ReasoningConfig, ToolChoiceConfig, ToolChoiceMode}; + + // Forced tool_choice is incompatible with active thinking. The adapter drops the + // `thinking` object in that case; output_config.effort must also be dropped so the + // request does not request reasoning the guard just disabled. + let adapter = AnthropicAdapter; + let req = UniversalRequest { + model: Some("claude-sonnet-5".to_string()), + messages: vec![Message::User { + content: UserContent::String("Use calc with x=2".to_string()), + }], + params: UniversalParams { + token_budget: Some(TokenBudget::OutputTokens(4096)), + reasoning: Some(ReasoningConfig { + enabled: Some(true), + effort: Some(ReasoningEffort::High), + canonical: Some(ReasoningCanonical::Effort), + ..Default::default() + }), + tool_choice: Some(ToolChoiceConfig { + mode: Some(ToolChoiceMode::Tool), + tool_name: Some("calc".to_string()), + }), + ..Default::default() + }, + }; + + let result: CreateMessageParams = + serde_json::from_value(adapter.request_from_universal(&req).unwrap()).unwrap(); + + // thinking is dropped by the forced-tool guard + assert!( + result.thinking.is_none(), + "thinking should be dropped for forced tool_choice" + ); + // effort is dropped too + let effort = result + .output_config + .as_ref() + .and_then(|c| c.effort.as_ref().map(|e| serde_json::to_value(e).unwrap())); + assert!( + effort.is_none(), + "output_config.effort should be dropped for forced tool_choice, got {effort:?}" + ); + // tool_choice is still emitted + assert!( + result.tool_choice.is_some(), + "tool_choice should be emitted" + ); + } + + #[test] + fn test_anthropic_preserves_effort_when_tool_choice_not_forced() { + use crate::universal::message::UserContent; + use crate::universal::request::{ReasoningConfig, ToolChoiceConfig, ToolChoiceMode}; + + // Auto tool_choice is not forced, so effort and adaptive thinking are preserved. + let adapter = AnthropicAdapter; + let req = UniversalRequest { + model: Some("claude-sonnet-5".to_string()), + messages: vec![Message::User { + content: UserContent::String("Use calc with x=2".to_string()), + }], + params: UniversalParams { + token_budget: Some(TokenBudget::OutputTokens(4096)), + reasoning: Some(ReasoningConfig { + enabled: Some(true), + effort: Some(ReasoningEffort::High), + canonical: Some(ReasoningCanonical::Effort), + ..Default::default() + }), + tool_choice: Some(ToolChoiceConfig { + mode: Some(ToolChoiceMode::Auto), + tool_name: None, + }), + ..Default::default() + }, + }; + + let result: CreateMessageParams = + serde_json::from_value(adapter.request_from_universal(&req).unwrap()).unwrap(); + + let thinking = result.thinking.expect("thinking should be present"); + assert_eq!(thinking.thinking_type, ThinkingType::Adaptive); + let output_config = result + .output_config + .expect("output_config should be present"); + assert_eq!(output_config.effort, Some(EffortLevel::High)); + } + + #[test] + fn test_anthropic_drops_raw_effort_for_forced_tool_choice_round_trip() { + // Same-provider Anthropic round-trip: raw output_config.effort must be dropped when + // tool_choice is forced, while an attached format is retained. + let adapter = AnthropicAdapter; + let payload = json!({ + "model": "claude-sonnet-5", + "max_tokens": 2048, + "tool_choice": {"type": "tool", "name": "calc"}, + "tools": [{"name": "calc", "description": "calc", "input_schema": {"type": "object", "properties": {"x": {"type": "number"}}, "required": ["x"]}}], + "output_config": { + "effort": "xhigh", + "format": {"type": "json_schema", "schema": {"type": "object", "properties": {}, "additionalProperties": false}} + }, + "messages": [{"role": "user", "content": "Use calc x=2"}] + }); + + let universal = adapter.request_to_universal(payload).unwrap(); + let result: CreateMessageParams = + serde_json::from_value(adapter.request_from_universal(&universal).unwrap()).unwrap(); + + assert!( + result.thinking.is_none(), + "thinking should be dropped for forced tool_choice" + ); + let output_config = result + .output_config + .expect("output_config should be present"); + assert!( + output_config.effort.is_none(), + "raw output_config.effort should be dropped for forced tool_choice" + ); + assert!( + output_config.format.is_some(), + "output_config.format should be retained for forced tool_choice" + ); + } + #[test] fn test_anthropic_opus_4_8_uses_adaptive_thinking_with_budget() { use crate::universal::message::UserContent; From a6637d72c96702d49c55c2a06756a989fbb9cbca Mon Sep 17 00:00:00 2001 From: Erin McNulty Date: Mon, 6 Jul 2026 11:52:50 -0400 Subject: [PATCH 4/6] Use typed boundary assertions in the new Sonnet 5 tests The typed-boundary CI gate (make typed-boundary-check-branch) flags new direct serde_json::Value field access (.get(...)/.as_str()/.as_object(), etc.) in crates/lingua/src/providers and src/universal. The Sonnet 5 test assertions were reaching into the emitted payload Value directly; switch them to the existing test idiom of deserializing into CreateMessageParams and asserting on typed fields (ThinkingType, EffortLevel). No behavior change. --- .../lingua/src/providers/anthropic/adapter.rs | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/crates/lingua/src/providers/anthropic/adapter.rs b/crates/lingua/src/providers/anthropic/adapter.rs index 79e45c66..f7f9c5dc 100644 --- a/crates/lingua/src/providers/anthropic/adapter.rs +++ b/crates/lingua/src/providers/anthropic/adapter.rs @@ -1995,12 +1995,13 @@ mod tests { let anthropic_payload = anthropic_adapter .request_from_universal(&universal) .unwrap(); - let thinking = anthropic_payload - .get("thinking") + let result: CreateMessageParams = serde_json::from_value(anthropic_payload).unwrap(); + let thinking = result + .thinking .expect("thinking should be emitted for reasoning_effort none"); assert_eq!( - thinking.get("type").and_then(Value::as_str), - Some("disabled"), + thinking.thinking_type, + ThinkingType::Disabled, "{model}: reasoning_effort=none must produce thinking.type=disabled" ); } @@ -2031,15 +2032,11 @@ mod tests { let output_config = result .output_config .expect("output_config should be present"); - let result_effort = output_config - .effort - .as_ref() - .map(|e| serde_json::to_value(e).unwrap()) - .map(|v| v.as_str().unwrap_or_default().to_string()); + let expected_effort: EffortLevel = serde_json::from_value(json!(effort)).unwrap(); assert_eq!( - result_effort.as_deref(), - Some(effort), - "{model}: output_config.effort should round-trip {effort}, got {result_effort:?}" + output_config.effort, + Some(expected_effort), + "{model}: output_config.effort should round-trip {effort}" ); } } @@ -2066,13 +2063,13 @@ mod tests { let anthropic_payload = anthropic_adapter .request_from_universal(&universal) .unwrap(); - let effort = anthropic_payload - .get("output_config") - .and_then(|oc| oc.get("effort")) - .and_then(Value::as_str); + let result: CreateMessageParams = serde_json::from_value(anthropic_payload).unwrap(); + let output_config = result + .output_config + .expect("output_config should be present"); assert_eq!( - effort, - Some("xhigh"), + output_config.effort, + Some(EffortLevel::Xhigh), "reasoning_effort=xhigh must map to output_config.effort=xhigh, not max" ); } From b2597985cf766a6f8ef4d124f3563a97c6a4e589 Mon Sep 17 00:00:00 2001 From: Erin McNulty Date: Mon, 6 Jul 2026 12:16:02 -0400 Subject: [PATCH 5/6] Treat tool_choice any/required as forced when suppressing thinking and effort is_forced_tool_choice() only matched ToolChoiceType::Tool with a name, but Anthropic tool_choice {"type":"any"} (which round-trips to universal ToolChoiceMode::Required) also forces tool use. The forced-tool guard therefore left output_config.effort (and, on the adaptive path, the enabled thinking object) in place for any/required requests, and Anthropic rejects an enabled thinking object under forced tool use with a 400 ("Thinking may not be enabled when tool_choice forces tool use") - verified live on claude-sonnet-5 and claude-opus-4-6. Broaden is_forced_tool_choice() to return true for ToolChoiceType::Any so the existing effort/thinking suppression covers any as well as tool. Add tests for the Sonnet 5 adaptive path (effort + thinking dropped) and the legacy enabled-thinking path (thinking omitted so it no longer 400s). --- .../lingua/src/providers/anthropic/adapter.rs | 103 +++++++++++++++++- 1 file changed, 98 insertions(+), 5 deletions(-) diff --git a/crates/lingua/src/providers/anthropic/adapter.rs b/crates/lingua/src/providers/anthropic/adapter.rs index f7f9c5dc..11549940 100644 --- a/crates/lingua/src/providers/anthropic/adapter.rs +++ b/crates/lingua/src/providers/anthropic/adapter.rs @@ -119,13 +119,16 @@ fn validate_no_non_leading_system_messages(messages: &[Message]) -> Result<(), T fn is_forced_tool_choice(value: &Value) -> bool { let parsed: Result = serde_json::from_value(value.clone()); - parsed.ok().is_some_and(|tool_choice| { - tool_choice.tool_choice_type == ToolChoiceType::Tool - && tool_choice + parsed + .ok() + .is_some_and(|tool_choice| match tool_choice.tool_choice_type { + ToolChoiceType::Any => true, + ToolChoiceType::Tool => tool_choice .name .as_ref() - .is_some_and(|name| !name.is_empty()) - }) + .is_some_and(|name| !name.is_empty()), + ToolChoiceType::Auto | ToolChoiceType::None => false, + }) } fn is_enabled_thinking(value: &Value) -> bool { @@ -2128,6 +2131,96 @@ mod tests { ); } + #[test] + fn test_anthropic_drops_effort_and_thinking_for_any_tool_choice_on_sonnet_5() { + use crate::universal::message::UserContent; + use crate::universal::request::{ReasoningConfig, ToolChoiceConfig, ToolChoiceMode}; + + // tool_choice {"type":"any"} (universal Required) also forces tool use, so it is + // incompatible with active thinking just like {"type":"tool"}. Effort and thinking + // must both be dropped; otherwise the emitted thinking object 400s on Sonnet 5. + let adapter = AnthropicAdapter; + let req = UniversalRequest { + model: Some("claude-sonnet-5".to_string()), + messages: vec![Message::User { + content: UserContent::String("Use calc with x=2".to_string()), + }], + params: UniversalParams { + token_budget: Some(TokenBudget::OutputTokens(4096)), + reasoning: Some(ReasoningConfig { + enabled: Some(true), + effort: Some(ReasoningEffort::High), + canonical: Some(ReasoningCanonical::Effort), + ..Default::default() + }), + tool_choice: Some(ToolChoiceConfig { + mode: Some(ToolChoiceMode::Required), + tool_name: None, + }), + ..Default::default() + }, + }; + + let result: CreateMessageParams = + serde_json::from_value(adapter.request_from_universal(&req).unwrap()).unwrap(); + + assert!( + result.thinking.is_none(), + "thinking should be dropped for tool_choice any/required" + ); + assert!( + result + .output_config + .as_ref() + .and_then(|c| c.effort.as_ref()) + .is_none(), + "output_config.effort should be dropped for tool_choice any/required" + ); + // tool_choice round-trips back to {"type":"any"} + let tc: ToolChoice = serde_json::from_value( + serde_json::to_value(result.tool_choice.as_ref().unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(tc.tool_choice_type, ToolChoiceType::Any); + } + + #[test] + fn test_anthropic_drops_enabled_thinking_for_any_tool_choice_legacy() { + use crate::universal::message::UserContent; + use crate::universal::request::{ReasoningConfig, ToolChoiceConfig, ToolChoiceMode}; + + // On a legacy non-adaptive model, tool_choice any/required with enabled thinking would + // emit thinking.type=enabled and 400 ("Thinking may not be enabled when tool_choice + // forces tool use"). The forced-tool guard must drop it. + let adapter = AnthropicAdapter; + let req = UniversalRequest { + model: Some("claude-sonnet-4-5-20250929".to_string()), + messages: vec![Message::User { + content: UserContent::String("Use calc with x=2".to_string()), + }], + params: UniversalParams { + token_budget: Some(TokenBudget::OutputTokens(4096)), + reasoning: Some(ReasoningConfig { + enabled: Some(true), + budget_tokens: Some(2048), + ..Default::default() + }), + tool_choice: Some(ToolChoiceConfig { + mode: Some(ToolChoiceMode::Required), + tool_name: None, + }), + ..Default::default() + }, + }; + + let result: CreateMessageParams = + serde_json::from_value(adapter.request_from_universal(&req).unwrap()).unwrap(); + assert!( + result.thinking.is_none(), + "thinking should be dropped for tool_choice any/required + enabled thinking" + ); + } + #[test] fn test_anthropic_preserves_effort_when_tool_choice_not_forced() { use crate::universal::message::UserContent; From 0442b6694c83a4850cd6b964f238ddfde430854e Mon Sep 17 00:00:00 2001 From: Erin McNulty Date: Mon, 6 Jul 2026 12:33:39 -0400 Subject: [PATCH 6/6] Do not emit thinking: {type: "disabled"} for always-on thinking models The effort=none opt-out emitted thinking: {type: "disabled"} for every model in supports_output_config_effort(), which now includes claude-fable-5. Fable 5 keeps adaptive thinking always on and rejects thinking.type=disabled with a 400 ("thinking.type.disabled is not supported for this model") - verified live. The only supported opt-out there is to omit thinking, preserving the always-on adaptive default. Add a supports_disabling_thinking() capability (false for Fable/Mythos always-on models, true otherwise) and gate the disabled emission on it in the adapter, so reasoning_effort="none" omits thinking instead of emitting disabled on those models. Add capability and cross-provider adapter tests. --- .../lingua/src/providers/anthropic/adapter.rs | 36 +++++++++++- .../src/providers/anthropic/capabilities.rs | 57 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/crates/lingua/src/providers/anthropic/adapter.rs b/crates/lingua/src/providers/anthropic/adapter.rs index 11549940..60d5befe 100644 --- a/crates/lingua/src/providers/anthropic/adapter.rs +++ b/crates/lingua/src/providers/anthropic/adapter.rs @@ -464,8 +464,10 @@ impl ProviderAdapter for AnthropicAdapter { ) } else if use_effort { // These models think by default when `thinking` is omitted, so an explicit - // opt-out (effort=none / enabled=false) must emit `thinking: {type: "disabled"}`. - if reasoning_is_disabled { + // opt-out (effort=none / enabled=false) emits `thinking: {type: "disabled"}`. + // Fable 5 / Mythos 5 reject `disabled` (thinking is always on), so for those + // models omit `thinking` instead and preserve the always-on adaptive default. + if reasoning_is_disabled && capabilities::supports_disabling_thinking(model) { Some( serde_json::to_value(&Thinking { budget_tokens: None, @@ -2010,6 +2012,36 @@ mod tests { } } + #[test] + fn test_anthropic_omits_thinking_for_effort_none_on_fable_5() { + use crate::processing::adapters::ProviderAdapter; + use crate::providers::openai::adapter::OpenAIAdapter; + + // Fable 5 keeps adaptive thinking always on and rejects `thinking: {type: "disabled"}`, + // so a cross-provider `reasoning_effort: "none"` must omit `thinking` rather than emit + // disabled (verified live: claude-fable-5 returns 400 for thinking.type=disabled). + let openai_adapter = OpenAIAdapter; + let anthropic_adapter = AnthropicAdapter; + + let openai_payload = json!({ + "model": "gpt-5", + "messages": [{"role": "user", "content": "What is 2+2?"}], + "reasoning_effort": "none" + }); + + let mut universal = openai_adapter.request_to_universal(openai_payload).unwrap(); + universal.model = Some("claude-fable-5".to_string()); + + let anthropic_payload = anthropic_adapter + .request_from_universal(&universal) + .unwrap(); + let result: CreateMessageParams = serde_json::from_value(anthropic_payload).unwrap(); + assert!( + result.thinking.is_none(), + "Fable 5 must omit thinking (not disabled) for reasoning_effort none" + ); + } + #[test] fn test_anthropic_preserves_raw_output_config_effort_for_sonnet_5() { // Same-provider Anthropic round-trip: a raw `output_config.effort` of "xhigh" diff --git a/crates/lingua/src/providers/anthropic/capabilities.rs b/crates/lingua/src/providers/anthropic/capabilities.rs index 4364c65e..6665688e 100644 --- a/crates/lingua/src/providers/anthropic/capabilities.rs +++ b/crates/lingua/src/providers/anthropic/capabilities.rs @@ -16,6 +16,15 @@ static OPUS_4_8_OR_LATER_RE: LazyLock = LazyLock::new(|| { Regex::new(r"^(?:[a-z0-9-]+\.)?anthropic\.claude-(?:opus-(4[-.]([8-9]|[1-9]\d)|([5-9]|[1-9]\d)[-.]\d{1,2})|fable-\d{1,2})($|[-.:])|^claude-(?:opus-(4[-.]([8-9]|[1-9]\d)|([5-9]|[1-9]\d)[-.]\d{1,2})|fable-\d{1,2})($|[-.])") .expect("valid Opus 4.8+ / Fable model regex") }); + +// Fable 5 and Mythos 5 keep adaptive thinking always on; `thinking: {type: "disabled"}` +// is rejected, so a reasoning opt-out must omit `thinking` instead of emitting disabled. +static ALWAYS_ON_THINKING_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r"(^|[./:@])claude-(fable-[a-z0-9][a-z0-9.-]*|mythos-[a-z0-9][a-z0-9.-]*)($|[-./:@])", + ) + .expect("valid always-on thinking model regex") +}); /// Check if a model supports `output_config.effort` (vs legacy `thinking`). /// /// Opus 4.5+ and Sonnet 5+ models support this. All models support `thinking` as fallback. @@ -42,6 +51,15 @@ pub fn supports_adaptive_thinking(model: &str) -> bool { is_opus_4_7_or_later(&lower) } +/// Check if a model accepts `thinking: {type: "disabled"}`. +/// +/// Fable 5 / Mythos 5 keep adaptive thinking always on and reject `disabled`, so an +/// explicit reasoning opt-out on those models must omit `thinking` instead of emitting it. +pub fn supports_disabling_thinking(model: &str) -> bool { + let lower = model.to_ascii_lowercase(); + !is_always_on_thinking(&lower) +} + /// Check if an Anthropic model supports system-role entries in `messages`. /// /// Direct Anthropic and Bedrock Anthropic Opus 4.8+ and Fable model IDs support @@ -66,6 +84,10 @@ fn is_opus_4_7_or_later(model: &str) -> bool { OPUS_4_7_OR_LATER_RE.is_match(model) } +fn is_always_on_thinking(model: &str) -> bool { + ALWAYS_ON_THINKING_RE.is_match(model) +} + fn is_supported_mid_conversation_system_model(model: &str) -> bool { OPUS_4_8_OR_LATER_RE.is_match(model) } @@ -212,6 +234,41 @@ mod tests { } } + #[test] + fn test_supports_disabling_thinking() { + let disableable = [ + "claude-sonnet-5", + "claude-opus-4-7", + "claude-opus-4-8", + "claude-sonnet-4-5-20250929", + "claude-opus-4-6", + ]; + let always_on = [ + "claude-fable-5", + "claude-fable-5-20260601", + "CLAUDE-FABLE-5", + "claude-fable-6", + "us.anthropic.claude-fable-5-v1:0", + "anthropic/claude-fable-5@20260601", + "claude-mythos-5", + "us.anthropic.claude-mythos-5-v1:0", + ]; + for model in disableable { + assert!( + supports_disabling_thinking(model), + "should support disabling: {}", + model + ); + } + for model in always_on { + assert!( + !supports_disabling_thinking(model), + "should not support disabling: {}", + model + ); + } + } + #[test] fn test_supports_mid_conversation_system_messages() { assert!(supports_mid_conversation_system_messages("claude-opus-4-8"));