From 48afa9c1e354d7f145c80b5599dc275d571aaec9 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Fri, 24 Jul 2026 15:56:07 -0400 Subject: [PATCH 1/2] Opus 5 BYOK Support (#61596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2026-07-24 at 3 47 04 PM action support, and explicit thinking opt-out behavior. Adds Claude Opus 5 to the Anthropic and Amazon Bedrock BYOK providers, including its documented context and output limits, regional inference profiles, fast mode and comp Release Notes: - Added Claude Opus 5 support for Anthropic and Amazon Bedrock BYOK providers. --- crates/anthropic/src/anthropic.rs | 45 ++++-- crates/anthropic/src/completion.rs | 100 +++++++++++-- crates/bedrock/src/bedrock.rs | 132 ++++++++++++++---- crates/bedrock/src/models.rs | 88 +++++++++++- .../language_models/src/provider/bedrock.rs | 113 +++++++++++---- 5 files changed, 397 insertions(+), 81 deletions(-) diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 3531924f295e1a..bae23e68f2b913 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -22,7 +22,22 @@ pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; pub const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; pub fn supports_fast_mode(model_id: &str) -> bool { - matches!(model_id, "claude-opus-4-8") + matches!(model_id, "claude-opus-5" | "claude-opus-4-8") +} + +/// Model IDs where adaptive thinking runs by default when a request omits the +/// `thinking` field, and where thinking must instead be turned off with an +/// explicit `thinking: {"type": "disabled"}`. +/// +/// On earlier Opus models omitting `thinking` means thinking is off; Claude +/// Opus 5 flipped that default. Claude Fable 5 and Claude Mythos 5 also think +/// by default, but they reject `{"type": "disabled"}` with a 400 error, so +/// they are deliberately excluded here (thinking cannot be turned off for +/// them at all). +/// +/// +pub fn requires_explicit_thinking_opt_out(model_id: &str) -> bool { + matches!(model_id, "claude-opus-5") } pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable-5"; @@ -168,6 +183,7 @@ impl Model { "claude-fable-5" | "claude-mythos-5" | "claude-mythos-preview" + | "claude-opus-5" | "claude-opus-4-8" | "claude-opus-4-7" | "claude-opus-4-6" @@ -724,6 +740,10 @@ pub enum Thinking { #[serde(default, skip_serializing_if = "Option::is_none")] display: Option, }, + /// Explicitly turns thinking off. Required by models where thinking runs + /// by default (see [`requires_explicit_thinking_opt_out`]); only accepted + /// at effort `high` or below. + Disabled, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -1197,18 +1217,17 @@ mod tests { } #[test] - fn from_listed_enables_fast_mode_for_opus_4_8() { - let model = Model::from_listed(listed_entry( - "claude-opus-4-8", - ModelCapabilities::default(), - )); - - assert!(model.supports_speed); - let beta_headers = model - .beta_headers() - .expect("model should have beta headers"); - assert!(beta_headers.contains(FAST_MODE_BETA_HEADER)); - assert!(beta_headers.contains(COMPACTION_BETA_HEADER)); + fn from_listed_enables_fast_mode_and_compaction_for_supported_opus_models() { + for model_id in ["claude-opus-5", "claude-opus-4-8"] { + let model = Model::from_listed(listed_entry(model_id, ModelCapabilities::default())); + + assert!(model.supports_speed); + let beta_headers = model + .beta_headers() + .expect("model should have beta headers"); + assert!(beta_headers.contains(FAST_MODE_BETA_HEADER)); + assert!(beta_headers.contains(COMPACTION_BETA_HEADER)); + } } #[test] diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 884ed1a1ddde9b..2d7266d7edf8aa 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -289,6 +289,29 @@ pub fn into_anthropic( last_tool.cache_control = Some(cache_control); } + let thinking = if request.thinking_allowed { + match mode { + AnthropicModelMode::Thinking { budget_tokens } => { + Some(Thinking::Enabled { budget_tokens }) + } + AnthropicModelMode::AdaptiveThinking => Some(Thinking::Adaptive { + display: Some(AdaptiveThinkingDisplay::Summarized), + }), + AnthropicModelMode::Default => None, + } + } else if crate::requires_explicit_thinking_opt_out(&model) { + // On Claude Opus 5, omitting the `thinking` field no longer means + // "off": the model runs adaptive thinking by default, so features + // that suppress thinking (e.g. inline assist) must opt out + // explicitly. `disabled` is only accepted at effort `high` or below; + // that holds here because `output_config` is never sent when thinking + // is disallowed, and the server-side default effort is `high`. + // + Some(Thinking::Disabled) + } else { + None + }; + Ok(crate::Request { model, messages: new_messages, @@ -304,19 +327,7 @@ pub fn into_anthropic( cache_type: CacheControlType::Ephemeral, ttl: None, }), - thinking: if request.thinking_allowed { - match mode { - AnthropicModelMode::Thinking { budget_tokens } => { - Some(Thinking::Enabled { budget_tokens }) - } - AnthropicModelMode::AdaptiveThinking => Some(Thinking::Adaptive { - display: Some(AdaptiveThinkingDisplay::Summarized), - }), - AnthropicModelMode::Default => None, - } - } else { - None - }, + thinking, tools, tool_choice: request.tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => ToolChoice::Auto, @@ -808,6 +819,69 @@ mod tests { ); } + #[test] + fn test_thinking_disallowed_sends_explicit_opt_out_only_where_required() { + // (model, expects_explicit_opt_out): Claude Opus 5 thinks by default + // when the `thinking` field is omitted, so suppressing thinking + // requires sending `{"type": "disabled"}`. Earlier Opus models treat + // omission as "off", and Fable rejects `disabled` outright, so both + // must keep omitting the field. + for (model, expects_explicit_opt_out) in [ + ("claude-opus-5", true), + ("claude-opus-4-8", false), + ("claude-fable-5", false), + ] { + let request = LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hi".to_string())], + cache: false, + reasoning_details: None, + }], + thread_id: None, + prompt_id: None, + intent: None, + stop: vec![], + temperature: None, + tools: vec![], + tool_choice: None, + thinking_allowed: false, + thinking_effort: None, + speed: None, + compact_at_tokens: None, + }; + + let anthropic_request = into_anthropic( + request, + model.to_string(), + 1.0, + 128_000, + AnthropicModelMode::AdaptiveThinking, + AnthropicPromptCacheMode::Automatic, + &ANTHROPIC_PROVIDER_ID, + ) + .unwrap(); + + if expects_explicit_opt_out { + assert!( + matches!(anthropic_request.thinking, Some(Thinking::Disabled)), + "{model} should send an explicit thinking opt-out" + ); + // `disabled` combined with effort `xhigh`/`max` is a 400, so + // no effort may accompany the opt-out. + assert!( + anthropic_request.output_config.is_none(), + "{model} must not send output_config with thinking disabled" + ); + } else { + assert!( + anthropic_request.thinking.is_none(), + "{model} should omit the thinking field entirely" + ); + } + } + } + #[test] fn test_no_cache_control_when_caching_disabled() { let request = LanguageModelRequest { diff --git a/crates/bedrock/src/bedrock.rs b/crates/bedrock/src/bedrock.rs index e8ce37e8c8f423..19d210208e6536 100644 --- a/crates/bedrock/src/bedrock.rs +++ b/crates/bedrock/src/bedrock.rs @@ -43,30 +43,8 @@ pub async fn stream_completion( let mut additional_fields: HashMap = HashMap::new(); - match request.thinking { - Some(Thinking::Enabled { - budget_tokens: Some(budget_tokens), - }) => { - let thinking_config = HashMap::from([ - ("type".to_string(), Document::String("enabled".to_string())), - ( - "budget_tokens".to_string(), - Document::Number(AwsNumber::PosInt(budget_tokens)), - ), - ]); - additional_fields.insert("thinking".to_string(), Document::from(thinking_config)); - } - Some(Thinking::Adaptive { effort: _ }) => { - let thinking_config = HashMap::from([ - ("type".to_string(), Document::String("adaptive".to_string())), - ( - "display".to_string(), - Document::String("summarized".to_string()), - ), - ]); - additional_fields.insert("thinking".to_string(), Document::from(thinking_config)); - } - _ => {} + if let Some(thinking) = &request.thinking { + additional_fields.extend(thinking_request_fields(thinking)); } if !additional_fields.is_empty() { @@ -212,6 +190,64 @@ pub enum Thinking { Adaptive { effort: BedrockAdaptiveThinkingEffort, }, + /// Explicitly turns thinking off. Required by Claude Opus 5, where + /// adaptive thinking runs by default when the `thinking` field is + /// omitted; only accepted at effort `high` or below. + /// + /// + Disabled, +} + +/// Converts the request's thinking configuration into the +/// `additionalModelRequestFields` entries understood by Anthropic models on +/// the Converse API. +fn thinking_request_fields(thinking: &Thinking) -> HashMap { + let mut fields = HashMap::new(); + match thinking { + Thinking::Enabled { + budget_tokens: Some(budget_tokens), + } => { + fields.insert( + "thinking".to_string(), + Document::from(HashMap::from([ + ("type".to_string(), Document::String("enabled".to_string())), + ( + "budget_tokens".to_string(), + Document::Number(AwsNumber::PosInt(*budget_tokens)), + ), + ])), + ); + } + Thinking::Enabled { + budget_tokens: None, + } => {} + Thinking::Adaptive { effort: _ } => { + fields.insert( + "thinking".to_string(), + Document::from(HashMap::from([ + ("type".to_string(), Document::String("adaptive".to_string())), + ( + "display".to_string(), + Document::String("summarized".to_string()), + ), + ])), + ); + } + Thinking::Disabled => { + // On Claude Opus 5 omitting the `thinking` field means adaptive + // thinking runs by default, so turning it off requires this + // explicit opt-out. No effort is attached: `disabled` combined + // with effort `xhigh`/`max` is rejected with a 400. + fields.insert( + "thinking".to_string(), + Document::from(HashMap::from([( + "type".to_string(), + Document::String("disabled".to_string()), + )])), + ); + } + } + fields } #[derive(Debug)] @@ -255,3 +291,51 @@ pub enum BedrockError { #[error(transparent)] Other(#[from] anyhow::Error), } + +#[cfg(test)] +mod tests { + use super::*; + + fn string_field<'a>(document: &'a Document, key: &str) -> Option<&'a str> { + match document { + Document::Object(map) => match map.get(key) { + Some(Document::String(value)) => Some(value.as_str()), + _ => None, + }, + _ => None, + } + } + + #[test] + fn test_disabled_thinking_serializes_opt_out_without_effort() { + let fields = thinking_request_fields(&Thinking::Disabled); + + let thinking = fields.get("thinking").expect("thinking field"); + assert_eq!(string_field(thinking, "type"), Some("disabled")); + // `disabled` combined with an effort of `xhigh`/`max` is a 400, so no + // output_config may accompany the opt-out. + assert!(!fields.contains_key("output_config")); + } + + #[test] + fn test_enabled_thinking_serializes_budget_tokens() { + let fields = thinking_request_fields(&Thinking::Enabled { + budget_tokens: Some(4_096), + }); + + let thinking = fields.get("thinking").expect("thinking field"); + assert_eq!(string_field(thinking, "type"), Some("enabled")); + match thinking { + Document::Object(map) => assert_eq!( + map.get("budget_tokens"), + Some(&Document::Number(AwsNumber::PosInt(4_096))) + ), + _ => panic!("thinking field should be an object"), + } + + let fields = thinking_request_fields(&Thinking::Enabled { + budget_tokens: None, + }); + assert!(fields.is_empty()); + } +} diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index 9b8a25964018ca..eb5d40ac8fee75 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -55,6 +55,13 @@ pub enum ConverseModel { alias = "claude-fable-5-thinking-latest" )] ClaudeFable5, + #[serde( + rename = "claude-opus-5", + alias = "claude-opus-5-latest", + alias = "claude-opus-5-thinking", + alias = "claude-opus-5-thinking-latest" + )] + ClaudeOpus5, #[serde( rename = "claude-opus-4-8", alias = "claude-opus-4-8-latest", @@ -235,6 +242,8 @@ impl ConverseModel { pub fn from_id(id: &str) -> anyhow::Result { if id.starts_with("claude-fable-5") { Ok(Self::ClaudeFable5) + } else if id.starts_with("claude-opus-5") { + Ok(Self::ClaudeOpus5) } else if id.starts_with("claude-opus-4-8") { Ok(Self::ClaudeOpus4_8) } else if id.starts_with("claude-opus-4-7") { @@ -263,6 +272,7 @@ impl ConverseModel { pub fn id(&self) -> &str { match self { Self::ClaudeFable5 => "claude-fable-5", + Self::ClaudeOpus5 => "claude-opus-5", Self::ClaudeOpus4_8 => "claude-opus-4-8", Self::ClaudeOpus4_7 => "claude-opus-4-7", Self::ClaudeOpus4_6 => "claude-opus-4-6", @@ -316,6 +326,7 @@ impl ConverseModel { pub fn request_id(&self) -> &str { match self { Self::ClaudeFable5 => "anthropic.claude-fable-5", + Self::ClaudeOpus5 => "anthropic.claude-opus-5", Self::ClaudeOpus4_8 => "anthropic.claude-opus-4-8", Self::ClaudeOpus4_7 => "anthropic.claude-opus-4-7", Self::ClaudeOpus4_6 => "anthropic.claude-opus-4-6-v1", @@ -369,6 +380,7 @@ impl ConverseModel { pub fn display_name(&self) -> &str { match self { Self::ClaudeFable5 => "Claude Fable 5", + Self::ClaudeOpus5 => "Claude Opus 5", Self::ClaudeOpus4_8 => "Claude Opus 4.8", Self::ClaudeOpus4_7 => "Claude Opus 4.7", Self::ClaudeOpus4_6 => "Claude Opus 4.6", @@ -424,6 +436,7 @@ impl ConverseModel { pub fn max_token_count(&self) -> u64 { match self { Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -461,6 +474,7 @@ impl ConverseModel { pub fn max_output_tokens(&self) -> u64 { match self { Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -503,6 +517,7 @@ impl ConverseModel { pub fn default_temperature(&self) -> f32 { match self { Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -524,6 +539,7 @@ impl ConverseModel { pub fn supports_tool_use(&self) -> bool { match self { Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -558,6 +574,7 @@ impl ConverseModel { pub fn supports_images(&self) -> bool { match self { Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -579,6 +596,7 @@ impl ConverseModel { pub fn supports_caching(&self) -> bool { match self { Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -601,6 +619,7 @@ impl ConverseModel { matches!( self, Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -618,6 +637,7 @@ impl ConverseModel { matches!( self, Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -629,7 +649,7 @@ impl ConverseModel { pub fn supports_xhigh_adaptive_thinking(&self) -> bool { matches!( self, - Self::ClaudeFable5 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet5 + Self::ClaudeFable5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet5 ) } @@ -657,6 +677,7 @@ impl ConverseModel { let supports_global = matches!( self, Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -720,6 +741,7 @@ impl ConverseModel { // Global inference profiles ( Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -739,6 +761,7 @@ impl ConverseModel { // US region inference profiles ( Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -763,9 +786,17 @@ impl ConverseModel { // Canada region inference profiles (Self::NovaLite, "ca") => Ok(format!("{}.{}", region_group, model_id)), + // Canada has no Claude-specific `ca.` profiles. AWS instead lists + // ca-central-1 and ca-west-1 as source regions of the US geo + // profile for these models, which keeps data within US and Canada + // regions. See the per-model cards, e.g. + // . + (Self::ClaudeOpus5, "ca") => Ok(format!("us.{}", model_id)), + // EU region inference profiles ( - Self::ClaudeOpus4_8 + Self::ClaudeOpus5 + | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 @@ -780,7 +811,8 @@ impl ConverseModel { // Australia region inference profiles ( - Self::ClaudeOpus4_8 + Self::ClaudeOpus5 + | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 @@ -789,7 +821,10 @@ impl ConverseModel { "au", ) => Ok(format!("{}.{}", region_group, model_id)), - // Japan region inference profiles + // Japan region inference profiles. Claude Opus 5 is deliberately + // absent: its model card lists only `us.`/`eu.`/`au.` geo profiles + // (plus `global.`): + // ( Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 @@ -1083,6 +1118,10 @@ mod tests { ConverseModel::ClaudeSonnet5.cross_region_inference_id("us-east-1", false)?, "us.anthropic.claude-sonnet-5" ); + assert_eq!( + ConverseModel::ClaudeOpus5.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-opus-5" + ); assert_eq!( ConverseModel::NovaPro.cross_region_inference_id("us-east-2", false)?, "us.amazon.nova-pro-v1:0" @@ -1120,6 +1159,10 @@ mod tests { ConverseModel::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-8" ); + assert_eq!( + ConverseModel::ClaudeOpus5.cross_region_inference_id("eu-west-1", false)?, + "eu.anthropic.claude-opus-5" + ); Ok(()) } @@ -1179,6 +1222,10 @@ mod tests { ConverseModel::ClaudeOpus4_8.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-opus-4-8" ); + assert_eq!( + ConverseModel::ClaudeOpus5.cross_region_inference_id("ap-southeast-2", false)?, + "au.anthropic.claude-opus-5" + ); Ok(()) } @@ -1188,6 +1235,13 @@ mod tests { ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-northeast-1", false)?, "jp.anthropic.claude-haiku-4-5-20251001-v1:0" ); + // Claude Opus 5 has no `jp.` geo profile, so it falls through to the + // bare model id (which fails at request time with a region hint) + // rather than fabricating an undocumented profile. + assert_eq!( + ConverseModel::ClaudeOpus5.cross_region_inference_id("ap-northeast-1", false)?, + "anthropic.claude-opus-5" + ); assert_eq!( ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-northeast-3", false)?, "jp.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -1205,6 +1259,12 @@ mod tests { ConverseModel::NovaLite.cross_region_inference_id("ca-central-1", false)?, "ca.amazon.nova-lite-v1:0" ); + // Canadian regions are source regions of the US geo profile for + // recent Opus models; there are no `ca.` Claude profiles. + assert_eq!( + ConverseModel::ClaudeOpus5.cross_region_inference_id("ca-central-1", false)?, + "us.anthropic.claude-opus-5" + ); Ok(()) } @@ -1247,6 +1307,10 @@ mod tests { ConverseModel::ClaudeOpus4_8.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-4-8" ); + assert_eq!( + ConverseModel::ClaudeOpus5.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-opus-5" + ); assert_eq!( ConverseModel::ClaudeFable5.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-fable-5" @@ -1327,6 +1391,7 @@ mod tests { assert_eq!(ConverseModel::DeepSeekR1.id(), "deepseek-r1"); assert_eq!(ConverseModel::Llama4Scout17B.id(), "llama-4-scout-17b"); assert_eq!(ConverseModel::ClaudeFable5.id(), "claude-fable-5"); + assert_eq!(ConverseModel::ClaudeOpus5.id(), "claude-opus-5"); assert_eq!(ConverseModel::ClaudeSonnet5.id(), "claude-sonnet-5"); assert_eq!( @@ -1346,6 +1411,10 @@ mod tests { ConverseModel::ClaudeFable5.request_id(), "anthropic.claude-fable-5" ); + assert_eq!( + ConverseModel::ClaudeOpus5.request_id(), + "anthropic.claude-opus-5" + ); assert_eq!( ConverseModel::ClaudeSonnet5.request_id(), "anthropic.claude-sonnet-5" @@ -1365,6 +1434,12 @@ mod tests { .id(), "claude-fable-5" ); + assert_eq!( + ConverseModel::from_id("claude-opus-5-thinking") + .unwrap() + .id(), + "claude-opus-5" + ); assert_eq!( ConverseModel::from_id("claude-sonnet-5-thinking") .unwrap() @@ -1380,14 +1455,17 @@ mod tests { assert!(ConverseModel::ClaudeSonnet4_5.supports_thinking()); assert!(ConverseModel::ClaudeOpus4_6.supports_thinking()); assert!(ConverseModel::ClaudeFable5.supports_thinking()); + assert!(ConverseModel::ClaudeOpus5.supports_thinking()); assert!(!ConverseModel::ClaudeSonnet4.supports_adaptive_thinking()); assert!(ConverseModel::ClaudeOpus4_6.supports_adaptive_thinking()); assert!(ConverseModel::ClaudeSonnet4_6.supports_adaptive_thinking()); assert!(ConverseModel::ClaudeFable5.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeOpus5.supports_adaptive_thinking()); assert!(ConverseModel::ClaudeSonnet5.supports_adaptive_thinking()); assert!(!ConverseModel::ClaudeOpus4_7.supports_xhigh_adaptive_thinking()); assert!(ConverseModel::ClaudeFable5.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeOpus5.supports_xhigh_adaptive_thinking()); assert!(ConverseModel::ClaudeSonnet5.supports_xhigh_adaptive_thinking()); assert!(ConverseModel::ClaudeOpus4_8.supports_xhigh_adaptive_thinking()); assert_eq!(BedrockAdaptiveThinkingEffort::XHigh.as_str(), "xhigh"); @@ -1417,6 +1495,7 @@ mod tests { assert_eq!(ConverseModel::ClaudeSonnet4_5.max_token_count(), 1_000_000); assert_eq!(ConverseModel::ClaudeOpus4_6.max_token_count(), 1_000_000); assert_eq!(ConverseModel::ClaudeFable5.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeOpus5.max_token_count(), 1_000_000); assert_eq!(ConverseModel::ClaudeSonnet5.max_token_count(), 1_000_000); assert_eq!(ConverseModel::Llama4Scout17B.max_token_count(), 128_000); assert_eq!(ConverseModel::NovaPremier.max_token_count(), 1_000_000); @@ -1427,6 +1506,7 @@ mod tests { assert_eq!(ConverseModel::ClaudeSonnet4_5.max_output_tokens(), 64_000); assert_eq!(ConverseModel::ClaudeOpus4_6.max_output_tokens(), 128_000); assert_eq!(ConverseModel::ClaudeFable5.max_output_tokens(), 128_000); + assert_eq!(ConverseModel::ClaudeOpus5.max_output_tokens(), 128_000); assert_eq!(ConverseModel::ClaudeSonnet5.max_output_tokens(), 128_000); assert_eq!(ConverseModel::ClaudeOpus4_1.max_output_tokens(), 32_000); assert_eq!(ConverseModel::Gemma3_4B.max_output_tokens(), 8_192); diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 15a3d22fa9436e..a481b407151b04 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -2265,39 +2265,50 @@ pub fn into_bedrock( } } + let thinking = if request.thinking_allowed { + match thinking_mode { + BedrockModelMode::Thinking { budget_tokens } => { + Some(bedrock::Thinking::Enabled { budget_tokens }) + } + BedrockModelMode::AdaptiveThinking { + effort: default_effort, + } => { + let effort = request + .thinking_effort + .as_deref() + .and_then(|e| match e { + "low" => Some(bedrock::BedrockAdaptiveThinkingEffort::Low), + "medium" => Some(bedrock::BedrockAdaptiveThinkingEffort::Medium), + "high" => Some(bedrock::BedrockAdaptiveThinkingEffort::High), + "xhigh" => Some(bedrock::BedrockAdaptiveThinkingEffort::XHigh), + "max" => Some(bedrock::BedrockAdaptiveThinkingEffort::Max), + _ => None, + }) + .unwrap_or(default_effort); + Some(bedrock::Thinking::Adaptive { effort }) + } + BedrockModelMode::Default => None, + } + } else if model.contains(ConverseModel::ClaudeOpus5.request_id()) { + // On Claude Opus 5, omitting the `thinking` field no longer means + // "off": the model runs adaptive thinking by default, so features + // that suppress thinking (e.g. inline assist) must opt out + // explicitly. Earlier Claude models treat omission as "off" and must + // keep omitting the field. No effort accompanies the opt-out because + // `disabled` combined with effort `xhigh`/`max` is a 400. + // + Some(bedrock::Thinking::Disabled) + } else { + None + }; + Ok(bedrock::Request { model, messages: new_messages, max_tokens: max_output_tokens, system: system_blocks, tools: tool_config, - thinking: if request.thinking_allowed { - match thinking_mode { - BedrockModelMode::Thinking { budget_tokens } => { - Some(bedrock::Thinking::Enabled { budget_tokens }) - } - BedrockModelMode::AdaptiveThinking { - effort: default_effort, - } => { - let effort = request - .thinking_effort - .as_deref() - .and_then(|e| match e { - "low" => Some(bedrock::BedrockAdaptiveThinkingEffort::Low), - "medium" => Some(bedrock::BedrockAdaptiveThinkingEffort::Medium), - "high" => Some(bedrock::BedrockAdaptiveThinkingEffort::High), - "xhigh" => Some(bedrock::BedrockAdaptiveThinkingEffort::XHigh), - "max" => Some(bedrock::BedrockAdaptiveThinkingEffort::Max), - _ => None, - }) - .unwrap_or(default_effort); - Some(bedrock::Thinking::Adaptive { effort }) - } - BedrockModelMode::Default => None, - } - } else { - None - }, + thinking, metadata: None, stop_sequences: Vec::new(), temperature: request.temperature.or(Some(default_temperature)), @@ -2896,6 +2907,54 @@ mod tests { .unwrap() } + #[test] + fn test_thinking_disallowed_sends_explicit_opt_out_only_on_opus_5() { + // Claude Opus 5 runs adaptive thinking by default when the `thinking` + // field is omitted, so suppressing thinking requires an explicit + // `disabled` opt-out. Earlier Claude models treat omission as "off". + for (model, expects_explicit_opt_out) in [ + ("us.anthropic.claude-opus-5", true), + ("global.anthropic.claude-opus-5", true), + ("us.anthropic.claude-opus-4-8", false), + ] { + let request = into_bedrock( + LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hi".into())], + cache: false, + reasoning_details: None, + }], + thinking_allowed: false, + ..Default::default() + }, + model.to_string(), + 1.0, + 128_000, + BedrockModelMode::AdaptiveThinking { + effort: bedrock::BedrockAdaptiveThinkingEffort::High, + }, + true, + true, + None, + None, + ) + .unwrap(); + + if expects_explicit_opt_out { + assert!( + matches!(request.thinking, Some(bedrock::Thinking::Disabled)), + "{model} should send an explicit thinking opt-out" + ); + } else { + assert!( + request.thinking.is_none(), + "{model} should omit the thinking field entirely" + ); + } + } + } + #[test] fn test_cache_marked_message_that_filters_to_empty_is_dropped() { let request = into_bedrock_request(vec![ From cecf085b9f45578f6ea21383c7897c291e940d2d Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Fri, 24 Jul 2026 17:07:37 -0400 Subject: [PATCH 2/2] Remove extra into_anthropic argument --- crates/anthropic/src/completion.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 2d7266d7edf8aa..866fcbcfd0d539 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -858,7 +858,6 @@ mod tests { 128_000, AnthropicModelMode::AdaptiveThinking, AnthropicPromptCacheMode::Automatic, - &ANTHROPIC_PROVIDER_ID, ) .unwrap();