From ce360e0ff340653038c60cac70a591c7cc3bc5d4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 7 Apr 2026 14:21:50 +0900 Subject: [PATCH] fix(api): strip anthropic beta fields from non-beta requests mikejiang: 'betas: Extra inputs are not permitted' 400 error. Only include beta headers when request targets beta endpoint. --- rust/crates/api/src/providers/anthropic.rs | 85 ++++++++++++++++++++- rust/crates/api/tests/client_integration.rs | 16 ++-- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index e19a589..f209137 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -466,7 +466,8 @@ impl AnthropicClient { request: &MessageRequest, ) -> Result { let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/')); - let request_body = self.request_profile.render_json_body(request)?; + let mut request_body = self.request_profile.render_json_body(request)?; + strip_unsupported_beta_body_fields(&mut request_body); let request_builder = self.build_request(&request_url).json(&request_body); request_builder.send().await.map_err(ApiError::from) } @@ -513,7 +514,8 @@ impl AnthropicClient { } let request_url = format!("{}/v1/messages/count_tokens", self.base_url.trim_end_matches('/')); - let request_body = self.request_profile.render_json_body(request)?; + let mut request_body = self.request_profile.render_json_body(request)?; + strip_unsupported_beta_body_fields(&mut request_body); let response = self .build_request(&request_url) .json(&request_body) @@ -880,6 +882,16 @@ const fn is_retryable_status(status: reqwest::StatusCode) -> bool { matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504) } +/// Remove beta-only body fields that the standard `/v1/messages` and +/// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not +/// permitted`. The `betas` opt-in is communicated via the `anthropic-beta` +/// HTTP header on these endpoints, never as a JSON body field. +fn strip_unsupported_beta_body_fields(body: &mut Value) { + if let Some(object) = body.as_object_mut() { + object.remove("betas"); + } +} + #[derive(Debug, Deserialize)] struct AnthropicErrorEnvelope { error: AnthropicErrorBody, @@ -1296,4 +1308,73 @@ mod tests { Some("Bearer proxy-token") ); } + + #[test] + fn strip_unsupported_beta_body_fields_removes_betas_array() { + let mut body = serde_json::json!({ + "model": "claude-sonnet-4-6", + "max_tokens": 1024, + "betas": ["claude-code-20250219", "prompt-caching-scope-2026-01-05"], + "metadata": {"source": "test"}, + }); + + super::strip_unsupported_beta_body_fields(&mut body); + + assert!( + body.get("betas").is_none(), + "betas body field must be stripped before sending to /v1/messages" + ); + assert_eq!( + body.get("model").and_then(serde_json::Value::as_str), + Some("claude-sonnet-4-6") + ); + assert_eq!(body["max_tokens"], serde_json::json!(1024)); + assert_eq!(body["metadata"]["source"], serde_json::json!("test")); + } + + #[test] + fn strip_unsupported_beta_body_fields_is_a_noop_when_betas_absent() { + let mut body = serde_json::json!({ + "model": "claude-sonnet-4-6", + "max_tokens": 1024, + }); + let original = body.clone(); + + super::strip_unsupported_beta_body_fields(&mut body); + + assert_eq!(body, original); + } + + #[test] + fn rendered_request_body_strips_betas_for_standard_messages_endpoint() { + let client = AnthropicClient::new("test-key").with_beta("tools-2026-04-01"); + let request = MessageRequest { + model: "claude-sonnet-4-6".to_string(), + max_tokens: 64, + messages: vec![], + system: None, + tools: None, + tool_choice: None, + stream: false, + }; + + let mut rendered = client + .request_profile() + .render_json_body(&request) + .expect("body should render"); + assert!( + rendered.get("betas").is_some(), + "render_json_body still emits betas; the strip helper guards the wire format", + ); + super::strip_unsupported_beta_body_fields(&mut rendered); + + assert!( + rendered.get("betas").is_none(), + "betas must not appear in /v1/messages request bodies" + ); + assert_eq!( + rendered.get("model").and_then(serde_json::Value::as_str), + Some("claude-sonnet-4-6") + ); + } } diff --git a/rust/crates/api/tests/client_integration.rs b/rust/crates/api/tests/client_integration.rs index c86378a..2f42a79 100644 --- a/rust/crates/api/tests/client_integration.rs +++ b/rust/crates/api/tests/client_integration.rs @@ -97,9 +97,9 @@ async fn send_message_posts_json_and_parses_response() { assert!(body.get("stream").is_none()); assert_eq!(body["tools"][0]["name"], json!("get_weather")); assert_eq!(body["tool_choice"]["type"], json!("auto")); - assert_eq!( - body["betas"], - json!(["claude-code-20250219", "prompt-caching-scope-2026-01-05"]) + assert!( + body.get("betas").is_none(), + "betas must travel via the anthropic-beta header, not the request body" ); } @@ -191,13 +191,9 @@ async fn send_message_applies_request_profile_and_records_telemetry() { let body: serde_json::Value = serde_json::from_str(&request.body).expect("request body should be json"); assert_eq!(body["metadata"]["source"], json!("clawd-code")); - assert_eq!( - body["betas"], - json!([ - "claude-code-20250219", - "prompt-caching-scope-2026-01-05", - "tools-2026-04-01" - ]) + assert!( + body.get("betas").is_none(), + "betas must travel via the anthropic-beta header, not the request body" ); let events = sink.events();