From df48a139ce9c292738b523252c12f044697e673f Mon Sep 17 00:00:00 2001 From: Alex Gunnarson Date: Fri, 9 Oct 2026 14:57:03 +0000 Subject: [PATCH] bedrock: Add Claude Opus 5.5 BYOK support (#64685) ## Summary Add Claude Opus 5.5 to the Amazon Bedrock BYOK provider. Upstream #64627 added Opus 5.5 to the Anthropic provider; this brings the same model to Bedrock. The model mirrors the Opus 5 definition: 1M context window, 128K max output, tool use, image input, prompt caching, and adaptive thinking (including xhigh effort). Cross-region inference profiles cover global, us, ca, eu, au, and jp. Opus 5.5 publishes a jp geo profile that Opus 5 does not. ## Model-level behavior Opus 5.5 carries model-level constraints, matching the Anthropic provider: - Opus 5.5 always runs adaptive thinking and rejects an explicit thinking.type.disabled opt-out, so the provider never sends one. This also fixes a substring bug where claude-opus-5-5 matched the Opus 5 request id and incorrectly received the opt-out. - Adaptive thinking does not accept sampling controls, so temperature is omitted (top_k and top_p were already unset), as the Anthropic provider does. - Forced tool use (tool_choice any) is reported as unsupported by reusing the shared anthropic::supports_forced_tool_use list, since Bedrock friendly ids match Anthropic model ids. Automatic tool use is unaffected, and no other Bedrock model changes behavior. ## Tests - bedrock: model id, request id, display name, token limits, thinking modes, and cross-region inference (including jp) for Opus 5.5. - language_models: Opus 5.5 omits the thinking opt-out and temperature, and rejects only forced tool choice. Release Notes: - Added Claude Opus 5.5 support to the Amazon Bedrock provider --------- Co-authored-by: Smit Barmase --- crates/bedrock/src/bedrock.rs | 2 +- crates/bedrock/src/models.rs | 104 ++++++++- .../language_models/src/provider/bedrock.rs | 216 ++++++++++++++++-- 3 files changed, 298 insertions(+), 24 deletions(-) diff --git a/crates/bedrock/src/bedrock.rs b/crates/bedrock/src/bedrock.rs index c010c639f2f..b3b4bace911 100644 --- a/crates/bedrock/src/bedrock.rs +++ b/crates/bedrock/src/bedrock.rs @@ -238,7 +238,7 @@ fn thinking_request_fields(thinking: &Thinking) -> HashMap { ), ]); if let Some(beta_header) = binding_controls_beta { - // Fable 5.1 binds replayed thinking to the prefix, which Zed can change. + // Opus 5.5 and Fable 5.1 bind replayed thinking to the prefix, which Zed can change. thinking_fields.insert( "block_binding".to_string(), value_to_aws_document(&serde_json::json!({ diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index 410df26a80c..2bd699ae699 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -76,6 +76,13 @@ pub enum ConverseModel { alias = "claude-fable-5-thinking-latest" )] ClaudeFable5, + #[serde( + rename = "claude-opus-5-5", + alias = "claude-opus-5-5-latest", + alias = "claude-opus-5-5-thinking", + alias = "claude-opus-5-5-thinking-latest" + )] + ClaudeOpus5_5, #[serde( rename = "claude-opus-5", alias = "claude-opus-5-latest", @@ -275,6 +282,8 @@ impl ConverseModel { Ok(Self::ClaudeFable5_1) } else if id.starts_with("claude-fable-5") { Ok(Self::ClaudeFable5) + } else if id.starts_with("claude-opus-5-5") { + Ok(Self::ClaudeOpus5_5) } else if id.starts_with("claude-opus-5") { Ok(Self::ClaudeOpus5) } else if id.starts_with("claude-opus-4-8") { @@ -306,6 +315,7 @@ impl ConverseModel { match self { Self::ClaudeFable5_1 => "claude-fable-5-1", Self::ClaudeFable5 => "claude-fable-5", + Self::ClaudeOpus5_5 => "claude-opus-5-5", Self::ClaudeOpus5 => "claude-opus-5", Self::ClaudeOpus4_8 => "claude-opus-4-8", Self::ClaudeOpus4_7 => "claude-opus-4-7", @@ -362,6 +372,7 @@ impl ConverseModel { match self { Self::ClaudeFable5_1 => "anthropic.claude-fable-5-1", Self::ClaudeFable5 => "anthropic.claude-fable-5", + Self::ClaudeOpus5_5 => "anthropic.claude-opus-5-5", Self::ClaudeOpus5 => "anthropic.claude-opus-5", Self::ClaudeOpus4_8 => "anthropic.claude-opus-4-8", Self::ClaudeOpus4_7 => "anthropic.claude-opus-4-7", @@ -418,6 +429,7 @@ impl ConverseModel { match self { Self::ClaudeFable5_1 => "Claude Fable 5.1", Self::ClaudeFable5 => "Claude Fable 5", + Self::ClaudeOpus5_5 => "Claude Opus 5.5", Self::ClaudeOpus5 => "Claude Opus 5", Self::ClaudeOpus4_8 => "Claude Opus 4.8", Self::ClaudeOpus4_7 => "Claude Opus 4.7", @@ -476,6 +488,7 @@ impl ConverseModel { match self { Self::ClaudeFable5_1 | Self::ClaudeFable5 + | Self::ClaudeOpus5_5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 @@ -516,6 +529,7 @@ impl ConverseModel { match self { Self::ClaudeFable5_1 | Self::ClaudeFable5 + | Self::ClaudeOpus5_5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 @@ -571,6 +585,7 @@ impl ConverseModel { match self { Self::ClaudeFable5_1 | Self::ClaudeFable5 + | Self::ClaudeOpus5_5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 @@ -611,6 +626,7 @@ impl ConverseModel { match self { Self::ClaudeFable5_1 | Self::ClaudeFable5 + | Self::ClaudeOpus5_5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 @@ -638,6 +654,7 @@ impl ConverseModel { match self { Self::ClaudeFable5_1 | Self::ClaudeFable5 + | Self::ClaudeOpus5_5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 @@ -662,6 +679,7 @@ impl ConverseModel { Self::Gpt6Astra | Self::ClaudeFable5_1 | Self::ClaudeFable5 + | Self::ClaudeOpus5_5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 @@ -682,6 +700,7 @@ impl ConverseModel { match self { Self::ClaudeFable5_1 | Self::ClaudeFable5 + | Self::ClaudeOpus5_5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 @@ -699,6 +718,7 @@ impl ConverseModel { match self { Self::ClaudeFable5_1 | Self::ClaudeFable5 + | Self::ClaudeOpus5_5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet5 => true, @@ -819,6 +839,7 @@ impl ConverseModel { let supports_global = matches!( self, Self::ClaudeFable5 + | Self::ClaudeOpus5_5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 @@ -881,6 +902,7 @@ impl ConverseModel { // Global inference profiles ( Self::ClaudeFable5 + | Self::ClaudeOpus5_5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 @@ -896,11 +918,14 @@ impl ConverseModel { ) => Ok(format!("{}.{}", region_group, model_id)), // US Government region inference profiles - (Self::ClaudeSonnet4_5, "us-gov") => Ok(format!("{}.{}", region_group, model_id)), + (Self::ClaudeOpus5_5 | Self::ClaudeSonnet4_5, "us-gov") => { + Ok(format!("{}.{}", region_group, model_id)) + } // US region inference profiles ( Self::ClaudeFable5 + | Self::ClaudeOpus5_5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 @@ -933,13 +958,15 @@ impl ConverseModel { // // // - (Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7, "ca") => { - Ok(format!("us.{}", model_id)) - } + ( + Self::ClaudeOpus5_5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7, + "ca", + ) => Ok(format!("us.{}", model_id)), // EU region inference profiles ( - Self::ClaudeOpus5 + Self::ClaudeOpus5_5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -955,7 +982,8 @@ impl ConverseModel { // Australia region inference profiles ( - Self::ClaudeOpus5 + Self::ClaudeOpus5_5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 @@ -967,10 +995,13 @@ impl ConverseModel { // Japan region inference profiles. Claude Opus 5 is deliberately // absent: its model card lists only `us.`/`eu.`/`au.` geo profiles - // (plus `global.`): + // (plus `global.`). Claude Opus 5.5, unlike Opus 5, does publish a + // `jp.` geo profile: // + // ( - Self::ClaudeOpus4_8 + Self::ClaudeOpus5_5 + | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 @@ -990,7 +1021,9 @@ impl ConverseModel { "apac", ) => Ok(format!("{}.{}", region_group, model_id)), - (Self::ClaudeFable5 | Self::ClaudeSonnet5, _) => Ok(format!("global.{}", model_id)), + (Self::ClaudeFable5 | Self::ClaudeOpus5_5 | Self::ClaudeSonnet5, _) => { + Ok(format!("global.{}", model_id)) + } // Default: use model ID directly _ => Ok(model_id.into()), @@ -1365,6 +1398,10 @@ mod tests { ConverseModel::ClaudeOpus5.cross_region_inference_id("us-east-1", false)?, "us.anthropic.claude-opus-5" ); + assert_eq!( + ConverseModel::ClaudeOpus5_5.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-opus-5-5" + ); assert_eq!( ConverseModel::NovaPro.cross_region_inference_id("us-east-2", false)?, "us.amazon.nova-pro-v1:0" @@ -1406,6 +1443,10 @@ mod tests { ConverseModel::ClaudeOpus5.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-5" ); + assert_eq!( + ConverseModel::ClaudeOpus5_5.cross_region_inference_id("eu-west-1", false)?, + "eu.anthropic.claude-opus-5-5" + ); Ok(()) } @@ -1427,6 +1468,15 @@ mod tests { ConverseModel::ClaudeSonnet5.cross_region_inference_id("ap-northeast-1", false)?, "global.anthropic.claude-sonnet-5" ); + // Opus 5.5 has no APAC geo profile outside AU and JP. + assert_eq!( + ConverseModel::ClaudeOpus5_5.cross_region_inference_id("ap-south-1", false)?, + "global.anthropic.claude-opus-5-5" + ); + assert_eq!( + ConverseModel::ClaudeOpus5_5.cross_region_inference_id("ap-northeast-2", false)?, + "global.anthropic.claude-opus-5-5" + ); Ok(()) } @@ -1469,6 +1519,10 @@ mod tests { ConverseModel::ClaudeOpus5.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-opus-5" ); + assert_eq!( + ConverseModel::ClaudeOpus5_5.cross_region_inference_id("ap-southeast-2", false)?, + "au.anthropic.claude-opus-5-5" + ); Ok(()) } @@ -1485,6 +1539,11 @@ mod tests { ConverseModel::ClaudeOpus5.cross_region_inference_id("ap-northeast-1", false)?, "anthropic.claude-opus-5" ); + // Claude Opus 5.5, unlike Opus 5, publishes a `jp.` geo profile. + assert_eq!( + ConverseModel::ClaudeOpus5_5.cross_region_inference_id("ap-northeast-1", false)?, + "jp.anthropic.claude-opus-5-5" + ); assert_eq!( ConverseModel::ClaudeOpus4_8.cross_region_inference_id("ap-northeast-1", false)?, "jp.anthropic.claude-opus-4-8" @@ -1516,6 +1575,10 @@ mod tests { ConverseModel::ClaudeOpus5.cross_region_inference_id("ca-central-1", false)?, "us.anthropic.claude-opus-5" ); + assert_eq!( + ConverseModel::ClaudeOpus5_5.cross_region_inference_id("ca-central-1", false)?, + "us.anthropic.claude-opus-5-5" + ); assert_eq!( ConverseModel::ClaudeOpus4_8.cross_region_inference_id("ca-west-1", false)?, "us.anthropic.claude-opus-4-8" @@ -1537,6 +1600,10 @@ mod tests { ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-gov-west-1", false)?, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0" ); + assert_eq!( + ConverseModel::ClaudeOpus5_5.cross_region_inference_id("us-gov-west-1", true)?, + "us-gov.anthropic.claude-opus-5-5" + ); Ok(()) } @@ -1570,6 +1637,10 @@ mod tests { ConverseModel::ClaudeOpus5.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-5" ); + assert_eq!( + ConverseModel::ClaudeOpus5_5.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-opus-5-5" + ); assert_eq!( ConverseModel::ClaudeFable5.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-fable-5" @@ -1723,6 +1794,7 @@ mod tests { 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::ClaudeOpus5_5.id(), "claude-opus-5-5"); assert_eq!(ConverseModel::ClaudeSonnet5.id(), "claude-sonnet-5"); assert_eq!( @@ -1746,6 +1818,10 @@ mod tests { ConverseModel::ClaudeOpus5.request_id(), "anthropic.claude-opus-5" ); + assert_eq!( + ConverseModel::ClaudeOpus5_5.request_id(), + "anthropic.claude-opus-5-5" + ); assert_eq!( ConverseModel::ClaudeSonnet5.request_id(), "anthropic.claude-sonnet-5" @@ -1771,6 +1847,12 @@ mod tests { .id(), "claude-opus-5" ); + assert_eq!( + ConverseModel::from_id("claude-opus-5-5-thinking") + .unwrap() + .id(), + "claude-opus-5-5" + ); assert_eq!( ConverseModel::from_id("claude-sonnet-5-thinking") .unwrap() @@ -1797,6 +1879,8 @@ mod tests { assert!(!ConverseModel::ClaudeOpus4_7.supports_xhigh_adaptive_thinking()); assert!(ConverseModel::ClaudeFable5.supports_xhigh_adaptive_thinking()); assert!(ConverseModel::ClaudeOpus5.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeOpus5_5.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeOpus5_5.supports_adaptive_thinking()); assert!(ConverseModel::ClaudeSonnet5.supports_xhigh_adaptive_thinking()); assert!(ConverseModel::ClaudeOpus4_8.supports_xhigh_adaptive_thinking()); assert_eq!(BedrockAdaptiveThinkingEffort::XHigh.as_str(), "xhigh"); @@ -1827,6 +1911,7 @@ mod tests { 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::ClaudeOpus5_5.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); @@ -1838,6 +1923,7 @@ mod tests { 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::ClaudeOpus5_5.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 2e3c6264930..e69d0a6dc35 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -992,7 +992,7 @@ fn converse_language_model(model: &ConverseModel) -> LanguageModel { supports_tools: model.supports_tool_use(), supports_images: model.supports_images(), supports_thinking: model.supports_thinking(), - // Astra and Fable 5.1 always reason, so only offer effort levels. + // These models always reason, so only offer effort levels. supports_disabling_thinking: reasoning_model.is_none(), refusal_fallback_model_id: model .id() @@ -2058,9 +2058,13 @@ fn deny_tool_use_events( /// Matches by Bedrock model ID, so custom entries such as /// `us.anthropic.claude-fable-5-1` get the same handling as the built-in model. fn converse_reasoning_model(model_id: &str) -> Option { - [ConverseModel::Gpt6Astra, ConverseModel::ClaudeFable5_1] - .into_iter() - .find(|model| model_id.ends_with(model.request_id())) + [ + ConverseModel::Gpt6Astra, + ConverseModel::ClaudeFable5_1, + ConverseModel::ClaudeOpus5_5, + ] + .into_iter() + .find(|model| model_id.ends_with(model.request_id())) } fn prepare_bedrock_content( @@ -2120,10 +2124,13 @@ pub fn into_bedrock( } let reasoning_model = converse_reasoning_model(&model); - let is_fable_5_1 = reasoning_model == Some(ConverseModel::ClaudeFable5_1); + let always_adaptive = matches!( + reasoning_model, + Some(ConverseModel::ClaudeFable5_1 | ConverseModel::ClaudeOpus5_5) + ); let is_gpt_6_astra = reasoning_model == Some(ConverseModel::Gpt6Astra); - if is_fable_5_1 && request.tool_choice == Some(LanguageModelToolChoice::Any) { - anyhow::bail!("Claude Fable 5.1 does not support forced tool use"); + if always_adaptive && request.tool_choice == Some(LanguageModelToolChoice::Any) { + anyhow::bail!("{model} does not support forced tool use"); } let mut new_messages: Vec = Vec::new(); @@ -2446,10 +2453,11 @@ pub fn into_bedrock( } let selected_effort = |default_effort| { - // Astra and Fable 5.1 keep reasoning enabled, so suppressed requests use the default - // effort, matching the Anthropic and OpenAI providers. + // Astra, Fable 5.1, and Opus 5.5 keep reasoning enabled, so suppressed requests use the + // default effort, matching the Anthropic and OpenAI providers. // // + // if !request.thinking_allowed { return default_effort; } @@ -2471,7 +2479,7 @@ pub fn into_bedrock( effort: selected_effort(effort), }), BedrockModelMode::AdaptiveThinking { effort } - if request.thinking_allowed || is_fable_5_1 => + if request.thinking_allowed || always_adaptive => { Some(bedrock::Thinking::Adaptive { effort: selected_effort(effort), @@ -2485,7 +2493,7 @@ pub fn into_bedrock( Some(bedrock::Thinking::Enabled { budget_tokens }) } _ if !request.thinking_allowed - && model.contains(ConverseModel::ClaudeOpus5.request_id()) => + && model.ends_with(ConverseModel::ClaudeOpus5.request_id()) => { // Opus 5 defaults to adaptive thinking, so turning it off requires `disabled`. // @@ -2493,7 +2501,7 @@ pub fn into_bedrock( } _ => None, }; - let temperature = if is_fable_5_1 || is_gpt_6_astra { + let temperature = if always_adaptive || is_gpt_6_astra { None } else { request.temperature.or(default_temperature) @@ -2541,7 +2549,7 @@ pub fn map_to_language_model_completion_events( ); let preserve_redacted_thinking = matches!( model, - ConverseModel::Gpt6Astra | ConverseModel::ClaudeFable5_1 + ConverseModel::Gpt6Astra | ConverseModel::ClaudeFable5_1 | ConverseModel::ClaudeOpus5_5 ); let initial_state = State { events, @@ -3487,7 +3495,7 @@ mod tests { } #[test] - fn test_gpt_6_astra_and_fable_5_1_defaults() -> Result<()> { + fn test_always_reasoning_model_defaults() -> Result<()> { for (model, expected_thinking) in [ ( ConverseModel::Gpt6Astra, @@ -3499,6 +3507,12 @@ mod tests { "effort": "High", "binding_controls_beta": "thinking-binding-controls-2026-08-01" }}), ), + ( + ConverseModel::ClaudeOpus5_5, + serde_json::json!({"Adaptive": { + "effort": "High", "binding_controls_beta": "thinking-binding-controls-2026-08-01" + }}), + ), ] { for thinking_allowed in [true, false] { let request = into_bedrock( @@ -3644,6 +3658,73 @@ mod tests { Ok(()) } + #[test] + fn test_opus_5_5_replays_only_its_own_reasoning() -> Result<()> { + use bedrock::bedrock_client::types::MessageStartEvent; + + let response = futures::stream::iter(vec![Ok(ConverseStreamOutput::MessageStart( + MessageStartEvent::builder() + .role(bedrock::BedrockRole::Assistant) + .build()?, + ))]); + let events = futures::executor::block_on( + map_to_language_model_completion_events( + Box::pin(response), + ConverseModel::ClaudeOpus5_5, + ) + .collect::>(), + ) + .into_iter() + .collect::, _>>()?; + let details = events.iter().find_map(|event| match event { + LanguageModelCompletionEvent::ReasoningDetails(value) => Some(Arc::new(value.clone())), + _ => None, + }); + assert!(details.is_some()); + + for (model, replays_reasoning) in [ + (ConverseModel::ClaudeOpus5_5, true), + (ConverseModel::ClaudeFable5_1, false), + (ConverseModel::ClaudeOpus4_8, false), + ] { + let request = into_bedrock( + LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![ + MessageContent::Thinking { + text: "thinking".into(), + signature: Some("signature".into()), + }, + MessageContent::Text("answer".into()), + ], + cache: false, + reasoning_details: details.clone(), + }], + ..Default::default() + }, + model.cross_region_inference_id("us-east-1", false)?, + model.default_temperature(), + model.max_output_tokens(), + model.thinking_mode(), + model.supports_caching(), + model.supports_tool_use(), + None, + None, + )?; + assert_eq!( + matches!( + request.messages[0].content().first(), + Some(BedrockInnerContent::ReasoningContent(_)) + ), + replays_reasoning, + "{}", + model.id() + ); + } + Ok(()) + } + #[test] fn test_astra_parallel_tool_results_precede_their_images() -> Result<()> { let results = (1..=2) @@ -3921,6 +4002,113 @@ mod tests { assert_eq!(configured.temperature, Some(0.7)); } + #[test] + fn test_opus_5_5_rejects_only_forced_tool_choice() -> Result<()> { + let opus_5_5 = ConverseModel::ClaudeOpus5_5; + let custom = ConverseModel::Custom { + name: "us.anthropic.claude-opus-5-5".into(), + max_tokens: 1_000_000, + display_name: None, + max_output_tokens: Some(128_000), + default_temperature: None, + cache_configuration: None, + supports_tool_use: Some(true), + supports_images: Some(true), + thinking: None, + }; + for model in [&opus_5_5, &custom] { + let language_model = converse_language_model(model); + assert!(language_model.supports_tools); + assert!(language_model.tool_choice_support.auto); + assert!(language_model.tool_choice_support.none); + assert!( + !language_model.tool_choice_support.any, + "{} rejects forced tool use", + model.id() + ); + } + + let forced = into_bedrock( + LanguageModelRequest { + tool_choice: Some(LanguageModelToolChoice::Any), + ..Default::default() + }, + opus_5_5.cross_region_inference_id("us-east-1", false)?, + opus_5_5.default_temperature(), + opus_5_5.max_output_tokens(), + opus_5_5.thinking_mode(), + opus_5_5.supports_caching(), + opus_5_5.supports_tool_use(), + None, + None, + ); + assert!(forced.is_err()); + + for model in [ + ConverseModel::ClaudeOpus5, + ConverseModel::ClaudeFable5, + ConverseModel::NovaPro, + ] { + assert!( + converse_language_model(&model).tool_choice_support.any, + "{} should still support forced tool use", + model.id() + ); + } + Ok(()) + } + + #[test] + fn test_opus_5_5_omits_sampling_controls() { + // Opus 5.5's always-on adaptive thinking rejects sampling controls, so + // its request must omit temperature (top_k/top_p are always unset). + // Other adaptive-thinking Claude models keep their default temperature. + for (model, expects_temperature) in [ + ("global.anthropic.claude-opus-5-5", false), + ("us.anthropic.claude-opus-5-5", false), + ("us.anthropic.claude-opus-5", true), + ("us.anthropic.claude-sonnet-5", true), + ] { + let request = into_bedrock( + LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hi".into())], + cache: false, + reasoning_details: None, + }], + ..Default::default() + }, + model.to_string(), + Some(1.0), + 128_000, + BedrockModelMode::AdaptiveThinking { + effort: bedrock::BedrockAdaptiveThinkingEffort::High, + }, + true, + true, + None, + None, + ) + .unwrap(); + + assert_eq!(request.top_k, None, "{model} must not send top_k"); + assert_eq!(request.top_p, None, "{model} must not send top_p"); + if expects_temperature { + assert_eq!( + request.temperature, + Some(1.0), + "{model} should send its default temperature" + ); + } else { + assert_eq!( + request.temperature, None, + "{model} must omit temperature because adaptive thinking is always on" + ); + } + } + } + #[test] fn test_cache_marked_message_that_filters_to_empty_is_dropped() { let request = into_bedrock_request(vec![