From 1b1d8e86a733cb31cc8f182008e712c42743336b Mon Sep 17 00:00:00 2001 From: Adam Miller Date: Thu, 4 Jun 2026 10:23:06 -0500 Subject: [PATCH 1/3] fix(router): strip Vertex AI-incompatible body fields on rawPredict routes Claude Code 2.1.x sends a `context_management` field in /v1/messages request bodies. Vertex AI rawPredict rejects unknown body fields with HTTP 400 "Extra inputs are not permitted". Extend the existing Vertex AI body rewriting logic (which already strips the `model` field) to also strip a defined list of Anthropic SDK extension body fields. Mirrors the existing `anthropic-beta` header stripping. Fixes the need for CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 workaround. --- crates/openshell-router/src/backend.rs | 155 +++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/crates/openshell-router/src/backend.rs b/crates/openshell-router/src/backend.rs index 9fbd538c06..48cbf9be06 100644 --- a/crates/openshell-router/src/backend.rs +++ b/crates/openshell-router/src/backend.rs @@ -79,6 +79,19 @@ enum StreamingBody { /// See: const VERTEX_ANTHROPIC_VERSION: &str = "vertex-2023-10-16"; +/// Request body fields that are Anthropic SDK extension features not +/// supported by Vertex AI rawPredict endpoints. These cause HTTP 400 +/// "Extra inputs are not permitted" if forwarded to Vertex AI. +/// +/// Add new fields here as Claude Code and other Anthropic SDK clients +/// introduce new extension-only body fields. +const VERTEX_INCOMPATIBLE_BODY_FIELDS: &[&str] = &[ + // Claude Code 2.1.x context management feature (USE_API_CONTEXT_MANAGEMENT). + // Disabled in clients by CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1. + // Vertex AI schema does not include this field; it causes HTTP 400. + "context_management", +]; + const COMMON_INFERENCE_REQUEST_HEADERS: [&str; 4] = ["content-type", "accept", "accept-encoding", "user-agent"]; @@ -286,6 +299,13 @@ 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 Anthropic SDK extension body fields not supported by Vertex AI rawPredict. + // Vertex AI schema-validates the request body and rejects unknown fields with + // HTTP 400 "Extra inputs are not permitted". Header-level betas are already + // stripped above (`strip_anthropic_beta`); this handles the body-field equivalent. + for field in VERTEX_INCOMPATIBLE_BODY_FIELDS { + obj.remove(*field); + } } else if route_is_bedrock(route) { // AWS Bedrock InvokeModel encodes the model in the URL // path; the request body is the raw provider-specific @@ -2491,4 +2511,139 @@ mod tests { other => panic!("expected RouterError::Internal, got {other:?}"), } } + + #[tokio::test] + async fn vertex_ai_body_strips_context_management_field() { + // Claude Code 2.1.x sends "context_management" in the request body when + // USE_API_CONTEXT_MANAGEMENT is active. Vertex AI rawPredict rejects this + // with HTTP 400 "Extra inputs are not permitted". The router must strip it. + 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".to_string(), + api_key: "ya29.token".to_string(), + protocols: vec!["anthropic_messages".to_string()], + auth: AuthHeader::Bearer, + default_headers: Vec::new(), + passthrough_headers: Vec::new(), + 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: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!({ + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "ping"}], + "max_tokens": 32, + "context_management": { + "enabled": true, + "strategy": "auto" + } + })) + .unwrap(), + ); + let headers = vec![("content-type".to_string(), "application/json".to_string())]; + + let (builder, _url) = 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(); + assert!( + !received_body + .as_object() + .unwrap() + .contains_key("context_management"), + "context_management must be stripped from Vertex AI rawPredict body, got: {received_body}" + ); + assert!( + !received_body.as_object().unwrap().contains_key("model"), + "model must also be stripped from Vertex AI rawPredict body, got: {received_body}" + ); + } + + #[tokio::test] + async fn direct_anthropic_preserves_context_management_field() { + // context_management must only be stripped for Vertex AI rawPredict routes. + // Direct Anthropic API routes must forward it intact. + let mock_server = MockServer::start().await; + + let route = ResolvedRoute { + name: "direct-anthropic".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![("anthropic-version".to_string(), "2023-06-01".to_string())], + passthrough_headers: vec!["anthropic-beta".to_string()], + timeout: DEFAULT_ROUTE_TIMEOUT, + model_in_path: false, + request_path_override: None, + }; + + Mock::given(method("POST")) + .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!({ + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "ping"}], + "max_tokens": 32, + "context_management": {"enabled": true} + })) + .unwrap(), + ); + let headers = vec![("content-type".to_string(), "application/json".to_string())]; + + let (builder, _url) = prepare_backend_request( + &client, + &route, + "POST", + "/v1/messages", + &headers, + body, + false, + ) + .unwrap(); + + let _ = 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"), + "context_management must be preserved for direct Anthropic API routes, got: {received_body}" + ); + } } From 26190303db46fb761c9e6a53b4d391202c3fe641 Mon Sep 17 00:00:00 2001 From: Adam Miller Date: Thu, 4 Jun 2026 10:29:04 -0500 Subject: [PATCH 2/3] fix(server): log warning when injecting ANTHROPIC_VERTEX_PROJECT_ID ANTHROPIC_VERTEX_PROJECT_ID in the sandbox environment can activate the Anthropic SDK's native Vertex AI mode, bypassing inference.local. Add a diagnostic warning so operators are aware when this var is present and ANTHROPIC_BASE_URL may need to be set explicitly. --- crates/openshell-server/src/grpc/provider.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index d5a5f5c909..3a67698a10 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -489,6 +489,20 @@ pub(super) async fn resolve_provider_environment( } } + if openshell_core::inference::normalize_inference_provider_type(&provider.r#type) + == Some("google-vertex-ai") + && provider + .config + .get(openshell_core::inference::VERTEX_AI_PROJECT_ID_KEY) + .is_some_and(|project_id| !project_id.trim().is_empty()) + { + warn!( + provider = %provider.metadata.as_ref().map_or("?", |m| m.name.as_str()), + "injecting ANTHROPIC_VERTEX_PROJECT_ID into sandbox; if using inference.local, \ + ensure ANTHROPIC_BASE_URL=https://inference.local is set to prevent Anthropic \ + SDK from routing directly to Vertex AI" + ); + } registry.inject_env(&provider, &mut env); } From 75ec567b5d483e9c1fd24439ffc4a978ad1663bc Mon Sep 17 00:00:00 2001 From: Adam Miller Date: Fri, 5 Jun 2026 16:41:44 -0500 Subject: [PATCH 3/3] refactor(router): consolidate model field into VERTEX_INCOMPATIBLE_BODY_FIELDS Move the standalone obj.remove("model") into the shared stripping array so all Vertex AI rawPredict body-field removals are handled in one place, per reviewer feedback. --- crates/openshell-router/src/backend.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/openshell-router/src/backend.rs b/crates/openshell-router/src/backend.rs index 48cbf9be06..fcd3bd9969 100644 --- a/crates/openshell-router/src/backend.rs +++ b/crates/openshell-router/src/backend.rs @@ -86,6 +86,9 @@ const VERTEX_ANTHROPIC_VERSION: &str = "vertex-2023-10-16"; /// Add new fields here as Claude Code and other Anthropic SDK clients /// introduce new extension-only body fields. const VERTEX_INCOMPATIBLE_BODY_FIELDS: &[&str] = &[ + // Vertex AI rawPredict encodes the model in the URL path, not the request body. + // Clients using the standard Anthropic API always send "model" in the body; strip it. + "model", // Claude Code 2.1.x context management feature (USE_API_CONTEXT_MANAGEMENT). // Disabled in clients by CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1. // Vertex AI schema does not include this field; it causes HTTP 400. @@ -293,13 +296,7 @@ fn prepare_backend_request( // Anthropic publisher endpoints, not for arbitrary model-in-path routes. let needs_vertex_anthropic_version = is_vertex_anthropic_rawpredict_route(route); if needs_vertex_anthropic_version { - // Vertex AI rawPredict encodes the model in the URL path, not - // the request body. Clients using the standard Anthropic API - // (e.g. Claude Code via inference.local) always send "model" - // in the body; strip it so Vertex AI does not reject the - // request with "Extra inputs are not permitted". - obj.remove("model"); - // Strip Anthropic SDK extension body fields not supported by Vertex AI rawPredict. + // Strip body fields not supported by Vertex AI rawPredict. // Vertex AI schema-validates the request body and rejects unknown fields with // HTTP 400 "Extra inputs are not permitted". Header-level betas are already // stripped above (`strip_anthropic_beta`); this handles the body-field equivalent.