From cd0241b7e3aba1c4be22dc7b866c807538f529e6 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 1 Sep 2026 18:58:47 +0000 Subject: [PATCH] anthropic: Handle Claude Fable 5.1 API changes (#63571) Claude Fable 5.1 introduces two breaking Messages API changes that produce 400 errors with request shapes Zed currently sends. This change adapts the shared Anthropic request path so the model works out of the box, without restructuring how requests are built. The first change is thinking-block prefix binding. Fable 5.1 binds each thinking block to the exact conversation prefix that produced it, and replaying a block after anything earlier changed (the system prompt, the tools array, or an edited turn) fails the whole request with a 400 reading "The block is bound to a different conversation". Zed routinely rebuilds the system prompt and tool list between requests (rules file changes, profile switches, MCP servers coming online), so this would surface as a hard error mid-thread. Requests to Fable 5.1 now send the `thinking-binding-controls-2026-08-01` beta header with `thinking.block_binding.prefix_mismatch_behavior: "drop_block"`, which makes the API drop invalidated blocks and continue instead of rejecting the request. Claude Mythos 5.1 doesn't run this check and is excluded. The cloud and Copilot Chat paths pass `block_binding: None` since their proxies control the upstream beta headers; opting in there belongs server-side. The second change is that forced tool use (`tool_choice: "any"` or `"tool"`) now returns a 400. Zed sends `"any"` from inline assist's streaming-tools path, which is on by default. The Anthropic and Zed-cloud providers now report `tool_choice: any` as unsupported for Fable/Mythos 5.1, and inline assist already degrades gracefully to prompt-guided auto tool choice, which is Anthropic's recommended migration. Finally, `claude-fable-5-1` and `claude-mythos-5-1` are added to the compaction model list. This matters more than a routine table update: with prefix binding, server-side compaction is the sanctioned way to trim history without invalidating thinking blocks. Everything else already carries over via the existing `claude-fable-5` id prefix logic (data-retention consent, Opus 4.8 refusal fallback, which remains a permitted fallback target for 5.1). Bedrock is intentionally untouched. Deeper follow-up work is deferred: keeping conversation history append-only so blocks aren't invalidated in the first place (turn-scoped system messages, mid-conversation tool changes) and monitoring `input_transformations` for drop reporting. Testing: - New unit tests covering the `block_binding` wire format (sent for `claude-fable-5-1` only) and beta-header wiring in `Model::from_listed`. - `cargo nextest run` for `anthropic`, `language_models`, `language_models_cloud`, and `copilot_chat`; `cargo fmt --check`. Release Notes: - Improved support for Claude Fable 5.1 --- crates/anthropic/src/anthropic.rs | 59 +++++++++++++- crates/anthropic/src/completion.rs | 80 ++++++++++++++++++- crates/copilot_chat/src/model.rs | 5 ++ .../language_models/src/provider/anthropic.rs | 12 ++- .../src/language_models_cloud.rs | 12 ++- 5 files changed, 158 insertions(+), 10 deletions(-) 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