Opus 5 BYOK Support (#61596)

<img width="844" height="404" alt="Screenshot 2026-07-24 at 3 47 04 PM"
src="https://github.com/user-attachments/assets/4691a246-bab9-4426-aa0b-6a7cfa2aa486"
/>
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.
This commit is contained in:
Richard Feldman authored and GitHub committed 2026-07-24 19:56:07 +00:00
1 parent 76e43838aa
commit 8457a4ccf4
5 files changed
+396 -80

No files matched your search

+31 -12
View File
@@ -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).
///
/// <https://platform.claude.com/docs/en/about-claude/models/migration-guide#migrating-to-claude-opus-5>
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"
@@ -732,6 +748,10 @@ pub enum Thinking {
#[serde(default, skip_serializing_if = "Option::is_none")]
display: Option<AdaptiveThinkingDisplay>,
},
/// 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)]
@@ -1209,18 +1229,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(),
));
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));
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]
+87 -13
View File
@@ -341,6 +341,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`.
// <https://platform.claude.com/docs/en/about-claude/models/migration-guide#migrating-to-claude-opus-5>
Some(Thinking::Disabled)
} else {
None
};
Ok(crate::Request {
model,
messages: new_messages,
@@ -356,19 +379,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,
@@ -952,6 +963,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 {
+108 -24
View File
@@ -43,30 +43,8 @@ pub async fn stream_completion(
let mut additional_fields: HashMap<String, Document> = 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.
///
/// <https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-5.html>
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<String, Document> {
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());
}
}
+84 -4
View File
@@ -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<Self> {
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.
// <https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-5.html>.
(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.`):
// <https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-5.html>
(
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);
+86 -27
View File
@@ -2269,39 +2269,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.
// <https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-5.html>
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)),
@@ -2900,6 +2911,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![