Skip to content
Open
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
243 changes: 191 additions & 52 deletions crates/api/src/routes/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -602,7 +595,7 @@ fn prepare_request(
query: Option<&str>,
body: &[u8],
endpoint: AnthropicRawEndpoint,
additional_allowed_betas: &[String],
denied_betas: &[String],
) -> RouteResult<PreparedRequest> {
reject_unsupported_anthropic_headers(headers)?;
reject_e2ee(headers)?;
Expand Down Expand Up @@ -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)?,
},
})
}
Expand Down Expand Up @@ -824,13 +817,45 @@ fn single_header(headers: &HeaderMap, name: &'static str) -> RouteResult<Option<
})
}

/// Whether a token is safe to relay verbatim in the upstream header.
///
/// Anthropic's published names are lowercase alphanumerics and dashes; upper
/// case, `_` and `.` are admitted so a plausible future spelling is not refused
/// here. Everything else (whitespace, quotes, separators, control characters)
/// is rejected to keep the relayed header value unambiguous.
fn beta_token_is_well_formed(token: &str) -> 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<Option<String>> {
// 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::<String>::new();
for value in headers.get_all("anthropic-beta") {
let value = value.to_str().map_err(|_| {
Expand All @@ -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(",")))
Expand Down Expand Up @@ -1222,45 +1262,53 @@ 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",
HeaderValue::from_static("claude-code-20250219,interleaved-thinking-2025-05-14"),
);
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]
fn current_claude_code_beta_set_and_query_are_accepted() {
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,
Expand All @@ -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::<Vec<_>>()
.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]
Expand Down
Loading
Loading