diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index bb229c923c..e24f9fd927 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -42,6 +42,15 @@ pub fn requires_explicit_thinking_opt_out(model_id: &str) -> bool { pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable-5"; pub const FABLE_FALLBACK_MODEL_ID: &str = "claude-opus-4-8"; +pub const THINKING_BINDING_CONTROLS_BETA_HEADER: &str = "thinking-binding-controls-2026-08-01"; + +pub fn binds_thinking_blocks_to_prefix(model_id: &str) -> bool { + matches!(model_id, "claude-fable-5-1") +} + +pub fn supports_forced_tool_use(model_id: &str) -> bool { + !matches!(model_id, "claude-fable-5-1" | "claude-mythos-5-1") +} /// pub const COMPACTION_BETA_HEADER: &str = "compact-2026-01-12"; @@ -183,7 +192,9 @@ impl Model { // let supports_compaction = matches!( entry.id.as_str(), - "claude-fable-5" + "claude-fable-5-1" + | "claude-fable-5" + | "claude-mythos-5-1" | "claude-mythos-5" | "claude-mythos-preview" | "claude-opus-5" @@ -201,6 +212,9 @@ impl Model { if supports_compaction { extra_beta_headers.push(COMPACTION_BETA_HEADER.to_string()); } + if binds_thinking_blocks_to_prefix(&entry.id) { + extra_beta_headers.push(THINKING_BINDING_CONTROLS_BETA_HEADER.to_string()); + } Self { display_name: entry.display_name, @@ -812,6 +826,8 @@ pub enum Thinking { Adaptive { #[serde(default, skip_serializing_if = "Option::is_none")] display: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + block_binding: Option, }, /// Explicitly turns thinking off. Required by models where thinking runs /// by default (see [`requires_explicit_thinking_opt_out`]); only accepted @@ -826,6 +842,22 @@ pub enum AdaptiveThinkingDisplay { Summarized, } +/// Controls for models that bind thinking blocks to the request prefix (see +/// [`binds_thinking_blocks_to_prefix`]). Requires the +/// [`THINKING_BINDING_CONTROLS_BETA_HEADER`] beta header. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct ThinkingBlockBinding { + pub prefix_mismatch_behavior: PrefixMismatchBehavior, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PrefixMismatchBehavior { + /// Drop an invalidated thinking block and continue, instead of rejecting + /// the request with a 400. + DropBlock, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumString)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] @@ -1609,6 +1641,31 @@ mod tests { } } + #[test] + fn from_listed_enables_compaction_and_binding_controls_for_fable_5_1() { + let model = Model::from_listed(listed_entry( + "claude-fable-5-1", + ModelCapabilities::default(), + )); + let beta_headers = model + .beta_headers() + .expect("model should have beta headers"); + assert!(beta_headers.contains(COMPACTION_BETA_HEADER)); + assert!(beta_headers.contains(THINKING_BINDING_CONTROLS_BETA_HEADER)); + + // Mythos 5.1 supports compaction but doesn't run the prefix-binding + // check, so it must not get the binding-controls header. + let model = Model::from_listed(listed_entry( + "claude-mythos-5-1", + ModelCapabilities::default(), + )); + let beta_headers = model + .beta_headers() + .expect("model should have beta headers"); + assert!(beta_headers.contains(COMPACTION_BETA_HEADER)); + assert!(!beta_headers.contains(THINKING_BINDING_CONTROLS_BETA_HEADER)); + } + #[test] fn from_listed_collects_supported_effort_levels() { let entry = listed_entry( diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 7ede3459f8..d2a601ad55 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -17,9 +17,9 @@ use std::sync::Arc; use crate::{ AdaptiveThinkingDisplay, AnthropicError, AnthropicModelMode, CacheControl, CacheControlType, CacheTtl, CompactionTrigger, ContentDelta, ContextManagement, ContextManagementEdit, Event, - ImageSource, Message, RequestContent, ResponseContent, StringOrContents, Thinking, Tool, - ToolChoice, ToolResultContent, ToolResultPart, Usage, completion_error_from_anthropic, - completion_error_from_anthropic_api, + ImageSource, Message, PrefixMismatchBehavior, RequestContent, ResponseContent, + StringOrContents, Thinking, ThinkingBlockBinding, Tool, ToolChoice, ToolResultContent, + ToolResultPart, Usage, completion_error_from_anthropic, completion_error_from_anthropic_api, }; pub const COMPACTION_STATE_FORMAT: &str = "anthropic.messages.encrypted-content.v1"; @@ -405,6 +405,11 @@ pub fn into_anthropic( } AnthropicModelMode::AdaptiveThinking => Some(Thinking::Adaptive { display: Some(AdaptiveThinkingDisplay::Summarized), + block_binding: crate::binds_thinking_blocks_to_prefix(&model).then_some( + ThinkingBlockBinding { + prefix_mismatch_behavior: PrefixMismatchBehavior::DropBlock, + }, + ), }), AnthropicModelMode::Default => None, } @@ -1091,6 +1096,75 @@ mod tests { ); } + #[test] + fn test_block_binding_sent_only_for_prefix_binding_models() { + // (model, expects_block_binding): Claude Fable 5.1 rejects a replayed + // thinking block with a 400 when the conversation prefix changed, so + // its requests opt into dropping invalidated blocks instead. Models + // without the prefix-binding check must not receive the beta field. + for (model, expects_block_binding) in [ + ("claude-fable-5-1", true), + ("claude-fable-5", false), + ("claude-mythos-5-1", false), + ("claude-opus-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: true, + 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(); + + let thinking_json = + serde_json::to_value(anthropic_request.thinking.as_ref().unwrap()).unwrap(); + let Some(Thinking::Adaptive { block_binding, .. }) = anthropic_request.thinking else { + panic!("{model} should send adaptive thinking"); + }; + if expects_block_binding { + assert_eq!( + thinking_json, + serde_json::json!({ + "type": "adaptive", + "display": "summarized", + "block_binding": { + "prefix_mismatch_behavior": "drop_block", + }, + }), + "{model} should opt into dropping invalidated thinking blocks" + ); + } else { + assert_eq!( + block_binding, None, + "{model} must not send the block_binding beta field" + ); + } + } + } + #[test] fn test_thinking_disallowed_sends_explicit_opt_out_only_where_required() { // (model, expects_explicit_opt_out): Claude Opus 5 thinks by default diff --git a/crates/copilot_chat/src/model.rs b/crates/copilot_chat/src/model.rs index 385dae17d7..bbd12c4d5a 100644 --- a/crates/copilot_chat/src/model.rs +++ b/crates/copilot_chat/src/model.rs @@ -201,6 +201,11 @@ impl LanguageModel for CopilotChatLanguageModel { if anthropic_request.thinking.is_some() { anthropic_request.thinking = Some(anthropic::Thinking::Adaptive { display: Some(anthropic::AdaptiveThinkingDisplay::Summarized), + // Thinking block binding needs a beta header on + // the upstream Anthropic request, which the + // Copilot proxy controls, so opting in belongs + // server-side. + block_binding: None, }); anthropic_request.output_config = effort.map(|effort| anthropic::OutputConfig { diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 93a2d79c6c..73b374302f 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -347,6 +347,13 @@ fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic:: { extra_beta_headers.push(anthropic::FAST_MODE_BETA_HEADER.to_string()); } + if anthropic::binds_thinking_blocks_to_prefix(&available.name) + && !extra_beta_headers + .iter() + .any(|header| header.trim() == anthropic::THINKING_BINDING_CONTROLS_BETA_HEADER) + { + extra_beta_headers.push(anthropic::THINKING_BINDING_CONTROLS_BETA_HEADER.to_string()); + } anthropic::Model { display_name: available @@ -762,9 +769,8 @@ impl LanguageModel for AnthropicModel { fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { match choice { - LanguageModelToolChoice::Auto - | LanguageModelToolChoice::Any - | LanguageModelToolChoice::None => true, + LanguageModelToolChoice::Auto | LanguageModelToolChoice::None => true, + LanguageModelToolChoice::Any => anthropic::supports_forced_tool_use(&self.model.id), } } diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 50a69171e0..2923b80ee0 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -665,9 +665,11 @@ impl LanguageModel for CloudLanguageModel bool { match choice { - LanguageModelToolChoice::Auto - | LanguageModelToolChoice::Any - | LanguageModelToolChoice::None => true, + LanguageModelToolChoice::Auto | LanguageModelToolChoice::None => true, + LanguageModelToolChoice::Any => { + self.model.provider != cloud_llm_client::LanguageModelProvider::Anthropic + || anthropic::supports_forced_tool_use(self.id.0.as_ref()) + } } } @@ -745,6 +747,10 @@ impl LanguageModel for CloudLanguageModel