From 9734bcc0ff2a7c443a965c9a9cd216b8ad5f422f Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Thu, 4 Jun 2026 07:55:32 -0400 Subject: [PATCH] fix(router): strip beta body fields for Vertex AI rawPredict routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway already strips the `anthropic-beta` header when routing to Vertex AI rawPredict endpoints, but clients like Claude Code also send matching top-level body fields for enabled betas (e.g. `context_management`, `interleaved_thinking`). Vertex AI rejects these with HTTP 400: "Extra inputs are not permitted". Reproducer (inside an OpenShell sandbox with a Vertex AI provider): ANTHROPIC_BASE_URL=https://inference.local \ ANTHROPIC_API_KEY=unused \ claude -p "Hi" => API Error: 400 context_management: Extra inputs are not permitted A client-side workaround exists — setting CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 prevents Claude Code from sending beta fields entirely — but this disables all experimental betas including ones the user may want (e.g. extended thinking, structured outputs). The fix belongs in the gateway so that clients work transparently. Add an allowlist of stable Anthropic Messages API body fields and strip any unrecognized top-level field from the request body on Vertex AI rawPredict routes. This complements the existing header stripping and model field removal, ensuring the request body conforms to what Vertex AI accepts. The allowlist approach is preferred over mapping header values to body field names because the Anthropic beta naming convention is inconsistent (e.g. header `advanced-tool-use-2025-11-20` maps to body field `tool_search`, not `advanced_tool_use`). With this fix applied: ANTHROPIC_BASE_URL=https://inference.local \ ANTHROPIC_API_KEY=unused \ claude -p "Say hello in one word" => Hello! Assisted-by: Claude Signed-off-by: Stephen Benjamin --- crates/openshell-router/src/backend.rs | 199 +++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/crates/openshell-router/src/backend.rs b/crates/openshell-router/src/backend.rs index 272da265a8..d6b86e58dc 100644 --- a/crates/openshell-router/src/backend.rs +++ b/crates/openshell-router/src/backend.rs @@ -73,6 +73,41 @@ const VERTEX_ANTHROPIC_VERSION: &str = "vertex-2023-10-16"; const COMMON_INFERENCE_REQUEST_HEADERS: [&str; 4] = ["content-type", "accept", "accept-encoding", "user-agent"]; +/// Top-level request body fields accepted by Vertex AI's rawPredict endpoint +/// for Anthropic Claude models. The gateway strips `anthropic-beta` headers +/// for Vertex routes, but clients (e.g. Claude Code) may also send beta +/// feature fields in the body (e.g. `context_management`). Vertex AI rejects +/// unknown body fields with "Extra inputs are not permitted", so we strip any +/// field not in this allowlist. +/// +/// This list mirrors the stable Anthropic Messages API parameters plus the +/// `anthropic_version` field required by Vertex. The `model` field is +/// intentionally excluded — it is encoded in the URL path for Vertex routes +/// and stripped separately. When Anthropic promotes a beta feature to the +/// stable API, add it here. +/// +/// See: +const VERTEX_ANTHROPIC_ALLOWED_BODY_FIELDS: &[&str] = &[ + "anthropic_version", + "cache_control", + "container", + "inference_geo", + "max_tokens", + "messages", + "metadata", + "output_config", + "service_tier", + "stop_sequences", + "stream", + "system", + "temperature", + "thinking", + "tool_choice", + "tools", + "top_k", + "top_p", +]; + impl StreamingProxyResponse { /// Create from a fully-buffered [`ProxyResponse`] (for mock routes). pub fn from_buffered(resp: ProxyResponse) -> Self { @@ -243,6 +278,16 @@ fn prepare_backend_request( // in the body; strip it so Vertex AI does not reject the // request with "Extra inputs are not permitted". obj.remove("model"); + + // Strip body fields not accepted by Vertex AI. The gateway + // already strips the `anthropic-beta` header, but clients + // also send matching body fields for enabled betas (e.g. + // `context_management`). Vertex AI rejects these as extra + // inputs. Remove any top-level field not in the stable + // Anthropic Messages API allowlist. + obj.retain(|key, _| { + VERTEX_ANTHROPIC_ALLOWED_BODY_FIELDS.contains(&key.as_str()) + }); } else { obj.insert( "model".to_string(), @@ -1618,4 +1663,158 @@ mod tests { "Vertex Gemini route must still rewrite the model field, got: {received_body}" ); } + + /// Vertex AI rawPredict routes must strip body fields that are not part of + /// the stable Anthropic Messages API. Clients like Claude Code send beta + /// feature fields (e.g. `context_management`) alongside the `anthropic-beta` + /// header. The gateway strips the header, but the body fields also need to + /// be removed — Vertex AI rejects them with "Extra inputs are not permitted". + #[tokio::test] + async fn vertex_ai_body_strips_beta_feature_fields() { + let mock_server = MockServer::start().await; + + let base_path = "/v1/projects/my-project/locations/us-east5/publishers/anthropic/models"; + let route = ResolvedRoute { + name: "vertex-anthropic".to_string(), + endpoint: format!("{}{base_path}", mock_server.uri()), + model: "claude-sonnet-4-6@20250514".to_string(), + api_key: "ya29.token".to_string(), + protocols: vec!["anthropic_messages".to_string()], + auth: AuthHeader::Bearer, + default_headers: Vec::new(), + passthrough_headers: vec!["anthropic-beta".to_string()], + timeout: DEFAULT_ROUTE_TIMEOUT, + model_in_path: true, + request_path_override: Some(":rawPredict".to_string()), + }; + + Mock::given(method("POST")) + .and(path(format!( + "{base_path}/claude-sonnet-4-6@20250514:rawPredict" + ))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "msg_1"})), + ) + .mount(&mock_server) + .await; + + let client = reqwest::Client::builder().build().unwrap(); + let body = bytes::Bytes::from( + serde_json::to_vec(&serde_json::json!({ + "messages": [{"role": "user", "content": "ping"}], + "max_tokens": 32, + "context_management": {"type": "auto"}, + "interleaved_thinking": {"budget_tokens": 1024}, + })) + .unwrap(), + ); + let headers = vec![ + ("content-type".to_string(), "application/json".to_string()), + ( + "anthropic-beta".to_string(), + "context-management-2025-06-27,interleaved-thinking-2025-05-14".to_string(), + ), + ]; + + let (builder, _url) = super::prepare_backend_request( + &client, + &route, + "POST", + "/v1/messages", + &headers, + body, + false, + ) + .unwrap(); + + let response = builder.send().await.unwrap(); + assert_eq!(response.status().as_u16(), 200); + let received = mock_server.received_requests().await.unwrap(); + let received_body: serde_json::Value = serde_json::from_slice(&received[0].body).unwrap(); + let obj = received_body.as_object().unwrap(); + assert!( + !obj.contains_key("context_management"), + "context_management must be stripped from Vertex AI body, got: {received_body}" + ); + assert!( + !obj.contains_key("interleaved_thinking"), + "interleaved_thinking must be stripped from Vertex AI body, got: {received_body}" + ); + assert!( + obj.contains_key("messages"), + "stable API fields must be preserved, got: {received_body}" + ); + assert!( + obj.contains_key("max_tokens"), + "stable API fields must be preserved, got: {received_body}" + ); + } + + /// Standard Anthropic routes must NOT strip beta body fields. + #[tokio::test] + async fn standard_anthropic_body_preserves_beta_feature_fields() { + let mock_server = MockServer::start().await; + + let route = ResolvedRoute { + name: "anthropic-direct".to_string(), + endpoint: mock_server.uri(), + model: "claude-sonnet-4-6".to_string(), + api_key: "sk-ant-test".to_string(), + protocols: vec!["anthropic_messages".to_string()], + auth: AuthHeader::Custom("x-api-key"), + default_headers: Vec::new(), + passthrough_headers: vec!["anthropic-beta".to_string()], + timeout: DEFAULT_ROUTE_TIMEOUT, + model_in_path: false, + request_path_override: None, + }; + + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "msg_1"})), + ) + .mount(&mock_server) + .await; + + let client = reqwest::Client::builder().build().unwrap(); + let body = bytes::Bytes::from( + serde_json::to_vec(&serde_json::json!({ + "messages": [{"role": "user", "content": "ping"}], + "max_tokens": 32, + "context_management": {"type": "auto"}, + })) + .unwrap(), + ); + let headers = vec![ + ("content-type".to_string(), "application/json".to_string()), + ( + "anthropic-beta".to_string(), + "context-management-2025-06-27".to_string(), + ), + ]; + + let (builder, _url) = super::prepare_backend_request( + &client, + &route, + "POST", + "/v1/messages", + &headers, + body, + false, + ) + .unwrap(); + + builder.send().await.unwrap(); + + let received = mock_server.received_requests().await.unwrap(); + let received_body: serde_json::Value = serde_json::from_slice(&received[0].body).unwrap(); + assert!( + received_body + .as_object() + .unwrap() + .contains_key("context_management"), + "standard Anthropic route must preserve beta body fields, got: {received_body}" + ); + } }