From 38cfd0c116d8f0a5d4441b9eba94be6d05851cd9 Mon Sep 17 00:00:00 2001 From: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:15:29 +0000 Subject: [PATCH] Relay anthropic-beta tokens on the native Messages routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native Messages routes validated every anthropic-beta token against a static allowlist. Each client release adds dated tokens, so the first unknown one 400s the whole request and the native lane stops serving current clients. This is the third recurrence in three weeks (#970, #988, #1068) and the allowlist buys nothing: Anthropic already answers a name it does not recognise with its own 400, and that response relays unbilled. Treat the header as transport instead. Tokens are syntax-checked (ASCII letters, digits, '-', '_', '.'; 128 bytes per token, 64 distinct tokens), deduplicated in order, and forwarded verbatim. Billing and product policy stay where they already live — the body gates in reject_unsupported_features reject speed=fast, non-standard service tiers, inference_geo, mcp_servers, container, server-side fallbacks, typed Anthropic tools and one-hour cache TTL — so a beta token alone cannot unlock a premium or server-side feature. ANTHROPIC_ALLOWED_BETAS becomes ANTHROPIC_DENIED_BETAS: an operator kill-switch for a future header-only premium beta, settable without a release. The retired variable is ignored with a startup warning so the deploy window where it is still set explains itself. Fixes #988 Fixes #1068 --- crates/api/src/routes/anthropic.rs | 243 +++++++++++++++++++++------ crates/config/src/types.rs | 55 ++++-- env.example | 3 +- scripts/test_anthropic_cache_walk.py | 9 +- 4 files changed, 243 insertions(+), 67 deletions(-) diff --git a/crates/api/src/routes/anthropic.rs b/crates/api/src/routes/anthropic.rs index c792c10cc..cabdc1c77 100644 --- a/crates/api/src/routes/anthropic.rs +++ b/crates/api/src/routes/anthropic.rs @@ -3,6 +3,12 @@ //! The request body stays schema-free on purpose. Cloud API validates only the //! fields needed for routing, billing, and explicit feature gates, then the raw //! transport rewrites `model` and forwards the rest to Anthropic. +//! +//! `anthropic-beta` is transport, not policy: tokens are syntax-checked and +//! relayed verbatim, leaving Anthropic (which rejects names it does not know) +//! as the authority on validity. Billing and product policy are enforced on the +//! body instead — see [`reject_unsupported_features`] — so a beta token can +//! never by itself unlock a premium or server-side feature. use crate::middleware::auth::AuthenticatedApiKey; use crate::models::AnthropicErrorResponse; @@ -46,26 +52,13 @@ const MAX_STREAM_ID_PEEK_BYTES: usize = 64 * 1024; /// otherwise delay response start indefinitely. Generous because the peek /// ends at the first stream event, well before first content. const STREAM_ID_PEEK_TIMEOUT: Duration = Duration::from_secs(30); -const ALLOWED_ANTHROPIC_BETAS: &[&str] = &[ - // Current Claude Code transport marker and token-only request controls. - "claude-code-20250219", - "interleaved-thinking-2025-05-14", - "thinking-token-count-2026-05-13", - "context-management-2025-06-27", - "prompt-caching-scope-2026-01-05", - "effort-2025-11-24", - "structured-outputs-2025-12-15", - "fine-grained-tool-streaming-2025-05-14", - "token-efficient-tools-2025-02-19", - "prompt-caching-2024-07-31", - // Sent by Claude Code for [1m]-suffixed models. Upstream treats it as a - // no-op: every current 1M-window model serves the full window by default - // at standard per-token pricing, so relaying it has no billing effect. - "context-1m-2025-08-07", - // Claude Code currently sends this marker on ordinary requests. The - // separate typed-tool body gate still rejects invocation of the advisor. - "advisor-tool-2026-03-01", -]; +/// Upper bound on a single `anthropic-beta` token. Roughly triple the longest +/// name Anthropic has published, so the endpoint is never the reason a +/// plausible future token is refused. +const MAX_BETA_TOKEN_LEN: usize = 128; +/// Upper bound on distinct `anthropic-beta` tokens in one request. Current +/// Claude Code sends thirteen. +const MAX_BETA_TOKENS: usize = 64; struct PreparedRequest { body: serde_json::Value, @@ -456,7 +449,7 @@ async fn handle_request( query.as_deref(), &body, endpoint, - &app_state.config.external_providers.anthropic_allowed_betas, + &app_state.config.external_providers.anthropic_denied_betas, ) { Ok(prepared) => prepared, Err(error) => return error.into_response(), @@ -602,7 +595,7 @@ fn prepare_request( query: Option<&str>, body: &[u8], endpoint: AnthropicRawEndpoint, - additional_allowed_betas: &[String], + denied_betas: &[String], ) -> RouteResult { reject_unsupported_anthropic_headers(headers)?; reject_e2ee(headers)?; @@ -660,7 +653,7 @@ fn prepare_request( beta_query: normalize_query(query)?, headers: AnthropicRawHeaders { version: single_header(headers, "anthropic-version")?, - beta: normalized_beta_header(headers, additional_allowed_betas)?, + beta: normalized_beta_header(headers, denied_betas)?, }, }) } @@ -824,13 +817,45 @@ fn single_header(headers: &HeaderMap, name: &'static str) -> RouteResult bool { + !token.is_empty() + && token.len() <= MAX_BETA_TOKEN_LEN + && token + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn malformed_beta_token(token: &str) -> AnthropicRouteError { + // Echoing is safe: the value already passed `to_str`, and the error body is + // JSON-escaped. Never log it — beta tokens travel with customer requests. + let shown: String = token.chars().take(64).collect(); + AnthropicRouteError::new( + StatusCode::BAD_REQUEST, + "invalid_request_error", + format!( + "anthropic-beta token '{shown}' is malformed: use ASCII letters, digits, \ + '-', '_' or '.' (at most {MAX_BETA_TOKEN_LEN} characters)" + ), + ) +} + +/// Collect the client's `anthropic-beta` tokens for relay. +/// +/// Unknown tokens are forwarded on purpose. Anthropic answers a name it does +/// not recognise with its own 400, so an allowlist here adds no protection and +/// only goes stale on every client release (nearai/cloud-api#970, #988, #1068). +/// `ANTHROPIC_DENIED_BETAS` remains as an operator kill-switch, and the body +/// gates in [`reject_unsupported_features`] still enforce billing policy. fn normalized_beta_header( headers: &HeaderMap, - additional_allowed_betas: &[String], + denied_betas: &[String], ) -> RouteResult> { - // Keep the default surface narrow, while allowing operators to admit a - // newly released token through ANTHROPIC_ALLOWED_BETAS without a code - // deployment. Body policy still blocks unsupported server-side products. let mut betas = Vec::::new(); for value in headers.get_all("anthropic-beta") { let value = value.to_str().map_err(|_| { @@ -845,18 +870,33 @@ fn normalized_beta_header( .map(str::trim) .filter(|token| !token.is_empty()) { - if !ALLOWED_ANTHROPIC_BETAS.contains(&token) - && !additional_allowed_betas - .iter() - .any(|allowed| allowed == token) + if !beta_token_is_well_formed(token) { + return Err(malformed_beta_token(token)); + } + if denied_betas + .iter() + .any(|denied| denied.eq_ignore_ascii_case(token)) { - return Err(unsupported_feature(&format!( - "anthropic-beta token '{token}'" - ))); + return Err(AnthropicRouteError::new( + StatusCode::BAD_REQUEST, + "invalid_request_error", + format!( + "anthropic-beta token '{token}' is disabled on this endpoint by \ + operator policy" + ), + )); } - if !betas.iter().any(|existing| existing == token) { - betas.push(token.to_string()); + if betas.iter().any(|existing| existing == token) { + continue; } + if betas.len() == MAX_BETA_TOKENS { + return Err(AnthropicRouteError::new( + StatusCode::BAD_REQUEST, + "invalid_request_error", + format!("anthropic-beta lists more than {MAX_BETA_TOKENS} distinct tokens"), + )); + } + betas.push(token.to_string()); } } Ok((!betas.is_empty()).then(|| betas.join(","))) @@ -1222,8 +1262,17 @@ mod tests { assert!(!prepared.stream); } + /// The default `anthropic-beta` header sent by Claude Code 2.1.272, plus + /// `redact-thinking-2026-02-12`, which it adds conditionally. + const CLAUDE_CODE_BETAS: &str = "claude-code-20250219,interleaved-thinking-2025-05-14,\ +thinking-token-count-2026-05-13,context-management-2025-06-27,\ +prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,\ +advisor-tool-2026-03-01,effort-2025-11-24,fallback-credit-2026-06-01,afk-mode-2026-01-31,\ +per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,\ +redact-thinking-2026-02-12"; + #[test] - fn beta_tokens_are_preserved_and_deduplicated() { + fn beta_tokens_are_relayed_verbatim_and_deduplicated() { let mut headers = request_headers(); headers.append( "anthropic-beta", @@ -1231,26 +1280,27 @@ mod tests { ); headers.append( "anthropic-beta", - HeaderValue::from_static("claude-code-20250219"), + HeaderValue::from_static("claude-code-20250219, future-beta-2099-12-31"), ); assert_eq!( normalized_beta_header(&headers, &[]).unwrap().as_deref(), - Some("claude-code-20250219,interleaved-thinking-2025-05-14") + Some("claude-code-20250219,interleaved-thinking-2025-05-14,future-beta-2099-12-31") ); + // A token this endpoint has never heard of is Anthropic's call, not ours. headers.insert( "anthropic-beta", HeaderValue::from_static("fast-mode-2026-02-01"), ); - assert!(normalized_beta_header(&headers, &[]).is_err()); - - let additional = vec!["fast-mode-2026-02-01".to_string()]; assert_eq!( - normalized_beta_header(&headers, &additional) - .unwrap() - .as_deref(), + normalized_beta_header(&headers, &[]).unwrap().as_deref(), Some("fast-mode-2026-02-01") ); + + assert_eq!( + normalized_beta_header(&request_headers(), &[]).unwrap(), + None + ); } #[test] @@ -1258,9 +1308,7 @@ mod tests { let mut headers = request_headers(); headers.insert( "anthropic-beta", - HeaderValue::from_static( - "claude-code-20250219,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,effort-2025-11-24,structured-outputs-2025-12-15,context-1m-2025-08-07", - ), + HeaderValue::from_str(CLAUDE_CODE_BETAS).unwrap(), ); let prepared = prepare_request( &headers, @@ -1272,13 +1320,104 @@ mod tests { .unwrap(); assert!(prepared.beta_query); + assert_eq!(prepared.headers.beta.as_deref(), Some(CLAUDE_CODE_BETAS)); + assert!(normalize_query(Some("future=true")).is_err()); + } + + #[test] + fn denied_beta_tokens_are_rejected_case_insensitively() { + let denied = vec!["fast-mode-2026-02-01".to_string()]; + let mut headers = request_headers(); + headers.insert( + "anthropic-beta", + HeaderValue::from_static("Fast-Mode-2026-02-01,claude-code-20250219"), + ); + let error = normalized_beta_header(&headers, &denied).unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); + assert!(error.message.contains("disabled")); + + headers.insert( + "anthropic-beta", + HeaderValue::from_static("claude-code-20250219"), + ); + assert_eq!( + normalized_beta_header(&headers, &denied) + .unwrap() + .as_deref(), + Some("claude-code-20250219") + ); + } + + #[test] + fn malformed_beta_tokens_are_rejected() { + let over_long = "a".repeat(MAX_BETA_TOKEN_LEN + 1); + let too_many = (0..=MAX_BETA_TOKENS) + .map(|index| format!("beta-{index}")) + .collect::>() + .join(","); + for value in [ + "claude code", + "foo;bar", + "\"quoted\"", + &over_long, + &too_many, + ] { + let mut headers = request_headers(); + headers.insert("anthropic-beta", HeaderValue::from_str(value).unwrap()); + let error = normalized_beta_header(&headers, &[]).unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); + // Must not read like the retired allowlist: an operator or syntax + // refusal has to be distinguishable from a stale gate in triage. + assert!(!error.message.contains("is not supported"), "{value}"); + } + + let mut headers = request_headers(); + headers.insert("anthropic-beta", HeaderValue::from_bytes(b"\xff").unwrap()); + assert!(normalized_beta_header(&headers, &[]).is_err()); + + // One below each bound still passes. + let mut headers = request_headers(); + headers.insert( + "anthropic-beta", + HeaderValue::from_str(&"a".repeat(MAX_BETA_TOKEN_LEN)).unwrap(), + ); + assert!(normalized_beta_header(&headers, &[]).is_ok()); + } + + #[test] + fn beta_header_alone_never_unlocks_a_body_gated_feature() { + let mut headers = request_headers(); + headers.insert( + "anthropic-beta", + HeaderValue::from_static("fast-mode-2026-02-01"), + ); + let prepared = prepare_request( + &headers, + None, + &base_body(), + AnthropicRawEndpoint::Messages, + &[], + ) + .unwrap(); assert_eq!( prepared.headers.beta.as_deref(), - headers - .get("anthropic-beta") - .and_then(|value| value.to_str().ok()) + Some("fast-mode-2026-02-01") ); - assert!(normalize_query(Some("future=true")).is_err()); + + // `PreparedRequest` is deliberately not `Debug` (it holds the customer + // body), so inspect the error arm directly rather than `unwrap_err`. + let mut body: serde_json::Value = serde_json::from_slice(&base_body()).unwrap(); + body["speed"] = serde_json::json!("fast"); + let Err(error) = prepare_request( + &headers, + None, + &serde_json::to_vec(&body).unwrap(), + AnthropicRawEndpoint::Messages, + &[], + ) else { + panic!("speed=fast must stay rejected regardless of the beta header"); + }; + assert!(error.message.contains("speed=fast")); } #[test] diff --git a/crates/config/src/types.rs b/crates/config/src/types.rs index 8f4268701..469e181d8 100644 --- a/crates/config/src/types.rs +++ b/crates/config/src/types.rs @@ -2301,22 +2301,44 @@ mod tests { #[test] #[serial] - fn native_anthropic_beta_allowlist_is_trimmed_and_deduplicated() { - let previous = std::env::var_os("ANTHROPIC_ALLOWED_BETAS"); + fn native_anthropic_beta_denylist_is_trimmed_and_deduplicated() { + let previous = std::env::var_os("ANTHROPIC_DENIED_BETAS"); std::env::set_var( - "ANTHROPIC_ALLOWED_BETAS", - "future-beta-1, future-beta-2, future-beta-1, ", + "ANTHROPIC_DENIED_BETAS", + "premium-beta-1, premium-beta-2, premium-beta-1, ", ); assert_eq!( - ExternalProvidersConfig::from_env().anthropic_allowed_betas, - vec!["future-beta-1".to_string(), "future-beta-2".to_string()] + ExternalProvidersConfig::from_env().anthropic_denied_betas, + vec!["premium-beta-1".to_string(), "premium-beta-2".to_string()] ); match previous { + Some(value) => std::env::set_var("ANTHROPIC_DENIED_BETAS", value), + None => std::env::remove_var("ANTHROPIC_DENIED_BETAS"), + } + } + + #[test] + #[serial] + fn retired_anthropic_beta_allowlist_no_longer_gates_tokens() { + let previous_allowed = std::env::var_os("ANTHROPIC_ALLOWED_BETAS"); + let previous_denied = std::env::var_os("ANTHROPIC_DENIED_BETAS"); + std::env::set_var("ANTHROPIC_ALLOWED_BETAS", "legacy-beta-2026-01-01"); + std::env::remove_var("ANTHROPIC_DENIED_BETAS"); + + assert!(ExternalProvidersConfig::from_env() + .anthropic_denied_betas + .is_empty()); + + match previous_allowed { Some(value) => std::env::set_var("ANTHROPIC_ALLOWED_BETAS", value), None => std::env::remove_var("ANTHROPIC_ALLOWED_BETAS"), } + match previous_denied { + Some(value) => std::env::set_var("ANTHROPIC_DENIED_BETAS", value), + None => std::env::remove_var("ANTHROPIC_DENIED_BETAS"), + } } } @@ -2355,9 +2377,10 @@ pub struct ExternalProvidersConfig { /// Expose the native Anthropic Messages routes. Hard-off by default so the /// first rollout can be enabled on staging without changing production. pub enable_anthropic_messages: bool, - /// Additional native Anthropic beta tokens admitted by operations without - /// waiting for a Cloud API release. - pub anthropic_allowed_betas: Vec, + /// Native Anthropic beta tokens refused at the router. Unknown tokens are + /// otherwise relayed to Anthropic, so this is the operator kill-switch for a + /// future header-only premium beta — settable without a Cloud API release. + pub anthropic_denied_betas: Vec, /// Google Gemini API key pub gemini_api_key: Option, /// Default timeout for external provider requests (seconds) @@ -2409,7 +2432,13 @@ impl ExternalProvidersConfig { .ok() .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) .unwrap_or(false); - let mut anthropic_allowed_betas = env::var("ANTHROPIC_ALLOWED_BETAS") + if env::var_os("ANTHROPIC_ALLOWED_BETAS").is_some() { + eprintln!( + "WARN: ANTHROPIC_ALLOWED_BETAS is ignored; native Anthropic beta tokens are \ + relayed upstream (use ANTHROPIC_DENIED_BETAS to refuse one)" + ); + } + let mut anthropic_denied_betas = env::var("ANTHROPIC_DENIED_BETAS") .ok() .map(|value| { value @@ -2420,8 +2449,8 @@ impl ExternalProvidersConfig { .collect::>() }) .unwrap_or_default(); - anthropic_allowed_betas.sort_unstable(); - anthropic_allowed_betas.dedup(); + anthropic_denied_betas.sort_unstable(); + anthropic_denied_betas.dedup(); // Gemini API key let gemini_api_key = if let Ok(path) = env::var("GEMINI_API_KEY_FILE") { @@ -2533,7 +2562,7 @@ impl ExternalProvidersConfig { openai_api_key, anthropic_api_key, enable_anthropic_messages, - anthropic_allowed_betas, + anthropic_denied_betas, gemini_api_key, timeout_seconds, refresh_interval_secs, diff --git a/env.example b/env.example index eaeecd886..16df7c499 100644 --- a/env.example +++ b/env.example @@ -331,7 +331,8 @@ BRAVE_SEARCH_PRO_API_KEY=MY_KEY # until the native compatibility and billing gates have completed their soak. # The upstream credential can be supplied inline or through a mounted file. # ENABLE_ANTHROPIC_MESSAGES=false # "true"/"1" to enable -# ANTHROPIC_ALLOWED_BETAS= # optional comma-separated additions +# ANTHROPIC_DENIED_BETAS= # optional comma-separated tokens to refuse +# # (unknown tokens are relayed to Anthropic) # ANTHROPIC_API_KEY=sk-ant-... # secret # ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic_api_key diff --git a/scripts/test_anthropic_cache_walk.py b/scripts/test_anthropic_cache_walk.py index 93f48a185..3748d3010 100755 --- a/scripts/test_anthropic_cache_walk.py +++ b/scripts/test_anthropic_cache_walk.py @@ -31,14 +31,21 @@ "ANTHROPIC_BETA", ",".join( [ + # The default header sent by Claude Code 2.1.272, plus + # redact-thinking-2026-02-12, which it adds conditionally. "claude-code-20250219", "interleaved-thinking-2025-05-14", "thinking-token-count-2026-05-13", "context-management-2025-06-27", "prompt-caching-scope-2026-01-05", + "mid-conversation-system-2026-04-07", "advisor-tool-2026-03-01", "effort-2025-11-24", - "structured-outputs-2025-12-15", + "fallback-credit-2026-06-01", + "afk-mode-2026-01-31", + "per-turn-control-2026-07-01", + "mid-conversation-tool-changes-2026-07-01", + "redact-thinking-2026-02-12", ] ), )