Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 32 additions & 13 deletions crates/anthropic/src/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -724,6 +740,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)]
Expand Down Expand Up @@ -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]
Expand Down
99 changes: 86 additions & 13 deletions crates/anthropic/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
// <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,
Expand All @@ -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,
Expand Down Expand Up @@ -808,6 +819,68 @@ 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,
)
.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 {
Expand Down
132 changes: 108 additions & 24 deletions crates/bedrock/src/bedrock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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());
}
}
Loading
Loading