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
141 changes: 137 additions & 4 deletions crates/openshell-router/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ const VERTEX_ANTHROPIC_VERSION: &str = "vertex-2023-10-16";
const COMMON_INFERENCE_REQUEST_HEADERS: [&str; 4] =
["content-type", "accept", "accept-encoding", "user-agent"];

/// Anthropic-SDK-only body fields that Vertex AI rawPredict does not accept.
/// Vertex AI's rawPredict endpoint uses strict pydantic validation and rejects
/// any extra inputs not in its schema. The Anthropic SDKs' native Vertex
/// transport (`AnthropicVertex`) strips these automatically; we must do the same
/// when proxying through `inference.local`.
///
/// `model` is handled separately (rewritten or stripped depending on route type)
/// and is intentionally omitted here.
const VERTEX_UNSUPPORTED_BODY_FIELDS: &[&str] = &["context_management"];

impl StreamingProxyResponse {
/// Create from a fully-buffered [`ProxyResponse`] (for mock routes).
pub fn from_buffered(resp: ProxyResponse) -> Self {
Expand Down Expand Up @@ -281,11 +291,12 @@ fn prepare_backend_request(
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".
// the request body. Strip "model" and any Anthropic-SDK-only
// beta fields that Vertex's strict pydantic validation rejects.
obj.remove("model");
for field in VERTEX_UNSUPPORTED_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
Expand Down Expand Up @@ -1970,6 +1981,128 @@ mod tests {
);
}

#[tokio::test]
async fn vertex_ai_body_strips_unsupported_beta_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::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@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!({
"model": "claude-sonnet-4-6-20250514",
"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 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 for Vertex AI rawPredict, got: {received_body}"
);
assert!(
!obj.contains_key("model"),
"model must also be stripped for Vertex AI rawPredict, got: {received_body}"
);
assert!(
obj.contains_key("messages"),
"standard fields must be preserved, got: {received_body}"
);
}

#[tokio::test]
async fn direct_anthropic_preserves_beta_fields() {
let route = ResolvedRoute {
name: "direct-anthropic".to_string(),
endpoint: "https://api.anthropic.com/v1".to_string(),
model: "claude-sonnet-4-6-20250514".to_string(),
api_key: "sk-test".to_string(),
protocols: vec!["anthropic_messages".to_string()],
auth: AuthHeader::Custom("x-api-key"),
default_headers: Vec::new(),
passthrough_headers: Vec::new(),
timeout: DEFAULT_ROUTE_TIMEOUT,
model_in_path: false,
request_path_override: None,
};

let client = reqwest::Client::builder().build().unwrap();
let body = bytes::Bytes::from(
serde_json::to_vec(&serde_json::json!({
"model": "claude-sonnet-4-6-20250514",
"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 request = builder.build().unwrap();
let sent_body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
assert!(
sent_body
.as_object()
.unwrap()
.contains_key("context_management"),
"context_management must be preserved for direct Anthropic API routes"
);
}

#[tokio::test]
async fn vertex_ai_body_preserves_client_anthropic_version() {
// When the client already sends anthropic_version, the router must NOT overwrite it.
Expand Down
115 changes: 114 additions & 1 deletion crates/openshell-router/tests/backend_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
use openshell_router::Router;
use openshell_router::config::{AuthHeader, ResolvedRoute, RouteConfig, RouterConfig};
use wiremock::matchers::{bearer_token, body_partial_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate};

fn mock_candidates(base_url: &str) -> Vec<ResolvedRoute> {
vec![ResolvedRoute {
Expand Down Expand Up @@ -668,6 +668,119 @@ async fn proxy_vertex_anthropic_route_uses_model_path_suffix() {
);
}

/// Fields that Vertex AI rawPredict actually accepts for Anthropic models.
const VERTEX_ACCEPTED_FIELDS: &[&str] = &[
"anthropic_version",
"messages",
"max_tokens",
"stop_sequences",
"stream",
"system",
"temperature",
"thinking",
"tool_choice",
"tools",
"top_k",
"top_p",
"metadata",
];

/// Simulates Vertex AI's strict pydantic validation: rejects any body field not
/// in the known Anthropic Messages API schema. This is what causes the real 400
/// error described in #2444.
struct VertexStrictBodyValidator;

impl Match for VertexStrictBodyValidator {
fn matches(&self, request: &Request) -> bool {
let Ok(body) = serde_json::from_slice::<serde_json::Value>(&request.body) else {
return false;
};
let Some(obj) = body.as_object() else {
return false;
};
obj.keys()
.all(|k| VERTEX_ACCEPTED_FIELDS.contains(&k.as_str()))
}
}

/// End-to-end reproduction of #2444: Claude Code sends `context_management` in
/// the body, the router must strip it before it reaches Vertex. The mock uses
/// strict body validation (like Vertex's pydantic) so the request only succeeds
/// if the field was actually removed.
#[tokio::test]
async fn proxy_vertex_strips_beta_fields_e2e() {
let mock_server = MockServer::start().await;

let model_path = "/v1/projects/my-project/locations/us-east5/publishers/anthropic/models";
let model = "claude-sonnet-4-6@20250514";

// This mock only matches if the body passes strict validation —
// context_management would cause a mismatch (simulating a real 400).
Mock::given(method("POST"))
.and(path(format!("{model_path}/{model}:rawPredict")))
.and(VertexStrictBodyValidator)
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": model,
"content": [{"type": "text", "text": "Pong"}]
})))
.mount(&mock_server)
.await;

let router = Router::new().unwrap();
let candidates = vec![ResolvedRoute {
name: "vertex-test".to_string(),
endpoint: format!("{}{model_path}", mock_server.uri()),
model: model.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: openshell_router::config::DEFAULT_ROUTE_TIMEOUT,
model_in_path: true,
request_path_override: Some(":rawPredict".to_string()),
}];

// Exact payload Claude Code v2.1.156+ sends: includes model,
// context_management, and anthropic-beta header.
let body = serde_json::to_vec(&serde_json::json!({
"model": "claude-sonnet-4-6-20250514",
"messages": [{"role": "user", "content": "say pong"}],
"max_tokens": 128,
"context_management": {"enabled": true},
}))
.unwrap();

let response = router
.proxy_with_candidates(
"anthropic_messages",
"POST",
"/v1/messages",
vec![
("content-type".to_string(), "application/json".to_string()),
(
"anthropic-beta".to_string(),
"context-management-2025-06-27".to_string(),
),
],
bytes::Bytes::from(body),
&candidates,
)
.await
.unwrap();

// If context_management leaked through, the strict mock wouldn't match
// and wiremock would return 404.
assert_eq!(
response.status, 200,
"request must succeed — context_management should have been stripped. \
A 404 here means the strict body validator rejected an unsupported field."
);
}

#[tokio::test]
async fn proxy_vertex_anthropic_streaming_route_uses_stream_rawpredict() {
let mock_server = MockServer::start().await;
Expand Down
Loading