mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-21 06:11:31 +08:00
* feat(providers): add Google Vertex AI provider Adds Vertex AI provider profiles, routing, credential refresh plumbing, CLI support, docs, and regression coverage. Keeps the related NETLINK_ROUTE seccomp allowance needed by Vertex client tooling that calls getifaddrs. * docs: add Vertex AI sandbox usage for Claude Code and OpenCode Cover the full end-to-end setup for running Claude Code and OpenCode inside an OpenShell sandbox via inference.local with a Vertex AI backend: - google-vertex-ai.mdx: add 'Use from a Sandbox' section with tabbed examples for Claude Code (--bare flag, no /v1 suffix) and OpenCode (/v1 suffix required). Add providers_v2_enabled prerequisite and --no-verify note for global region. Document policy proposals table covering metadata.google.internal (always blocked), downloads.claude.ai, and storage.googleapis.com. - inference-routing.mdx: expand 'Use the Local Endpoint' section with tabbed examples for Claude Code, OpenCode, Python OpenAI SDK, and Python Anthropic SDK. Add notes explaining the /v1 path suffix difference between clients. - supported-agents.mdx: update Claude Code and OpenCode rows to mention inference.local support and correct base URL requirements. * fix: address vertex review findings * test(sandbox): retry on spurious Ok in fork-exec ambiguity test On arm64 under heavy CI load, the /proc fd scan in find_socket_inode_owners can transiently miss the parent process's socket fd entry, returning only the child as an owner. This causes resolve_process_identity to return Ok (single owner, no ambiguity check fires) instead of the expected ambiguous-ownership Err. Extend the retry loop to also handle unexpected Ok results, mirroring the existing retry for transient Err results. 10 retries at 50ms gives a 500ms settling window, which is sufficient for procfs to stabilize on loaded arm64 runners. * fix: address vertex review regressions * docs(router): clarify stream_response semantics for Vertex rawPredict routing Document the three call sites of prepare_backend_request and their stream_response values in a caller table: - send_backend_request: false → :rawPredict (unary endpoint) - send_backend_request_streaming: true → :streamRawPredict - verify_backend_endpoint: explicitly false to probe the unary endpoint Cross-reference the table from build_provider_url and is_vertex_anthropic_rawpredict_route so the stream_response=true guard in the suffix upgrade branch is understood in full context. Also note that is_vertex_anthropic_rawpredict_route is a structural predicate (model_in_path + anthropic_messages + :rawPredict suffix), not a named-provider check, so any future provider with the same route shape inherits the transforms automatically.
882 lines
30 KiB
Rust
882 lines
30 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
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};
|
|
|
|
fn mock_candidates(base_url: &str) -> Vec<ResolvedRoute> {
|
|
vec![ResolvedRoute {
|
|
name: "inference.local".to_string(),
|
|
endpoint: base_url.to_string(),
|
|
model: "meta/llama-3.1-8b-instruct".to_string(),
|
|
api_key: "test-api-key".to_string(),
|
|
protocols: vec!["openai_chat_completions".to_string()],
|
|
auth: AuthHeader::Bearer,
|
|
default_headers: Vec::new(),
|
|
passthrough_headers: vec!["openai-organization".to_string(), "x-model-id".to_string()],
|
|
timeout: openshell_router::config::DEFAULT_ROUTE_TIMEOUT,
|
|
model_in_path: false,
|
|
request_path_override: None,
|
|
}]
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_forwards_request_to_backend() {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
let response_body = serde_json::json!({
|
|
"id": "chatcmpl-123",
|
|
"object": "chat.completion",
|
|
"created": 1_700_000_000_i64,
|
|
"model": "meta/llama-3.1-8b-instruct",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "Hello! How can I help you?"
|
|
},
|
|
"finish_reason": "stop"
|
|
}],
|
|
"usage": {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 8,
|
|
"total_tokens": 18
|
|
}
|
|
});
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.and(bearer_token("test-api-key"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = mock_candidates(&mock_server.uri());
|
|
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"model": "test",
|
|
"messages": [{"role": "user", "content": "Hello"}]
|
|
}))
|
|
.unwrap();
|
|
|
|
let response = router
|
|
.proxy_with_candidates(
|
|
"openai_chat_completions",
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
vec![("content-type".to_string(), "application/json".to_string())],
|
|
bytes::Bytes::from(body),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status, 200);
|
|
let resp_body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
|
|
assert_eq!(resp_body["id"], "chatcmpl-123");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_upstream_401_returns_error() {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
|
|
"error": { "message": "Invalid API key" }
|
|
})))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = mock_candidates(&mock_server.uri());
|
|
|
|
let response = router
|
|
.proxy_with_candidates(
|
|
"openai_chat_completions",
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
vec![],
|
|
bytes::Bytes::new(),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Raw proxy returns the actual HTTP status, not a RouterError
|
|
assert_eq!(response.status, 401);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_no_compatible_route_returns_error() {
|
|
let router = Router::new().unwrap();
|
|
let candidates = vec![ResolvedRoute {
|
|
name: "inference.local".to_string(),
|
|
endpoint: "http://localhost:1234".to_string(),
|
|
model: "test".to_string(),
|
|
api_key: "key".to_string(),
|
|
protocols: vec!["anthropic_messages".to_string()],
|
|
auth: AuthHeader::Custom("x-api-key"),
|
|
default_headers: Vec::new(),
|
|
passthrough_headers: Vec::new(),
|
|
timeout: openshell_router::config::DEFAULT_ROUTE_TIMEOUT,
|
|
model_in_path: false,
|
|
request_path_override: None,
|
|
}];
|
|
|
|
let err = router
|
|
.proxy_with_candidates(
|
|
"openai_chat_completions",
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
vec![],
|
|
bytes::Bytes::new(),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(
|
|
matches!(err, openshell_router::RouterError::NoCompatibleRoute(_)),
|
|
"expected NoCompatibleRoute, got: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_strips_auth_header() {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.and(bearer_token("test-api-key"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = mock_candidates(&mock_server.uri());
|
|
|
|
// Client sends its own Authorization header — should be stripped and replaced
|
|
let response = router
|
|
.proxy_with_candidates(
|
|
"openai_chat_completions",
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
vec![("authorization".to_string(), "Bearer client-key".to_string())],
|
|
bytes::Bytes::new(),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status, 200);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_forwards_openai_organization_header() {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.and(bearer_token("test-api-key"))
|
|
.and(header("openai-organization", "org_123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = mock_candidates(&mock_server.uri());
|
|
|
|
let response = router
|
|
.proxy_with_candidates(
|
|
"openai_chat_completions",
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
vec![
|
|
("openai-organization".to_string(), "org_123".to_string()),
|
|
("cookie".to_string(), "session=abc".to_string()),
|
|
],
|
|
bytes::Bytes::new(),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status, 200);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_mock_route_returns_canned_response() {
|
|
let router = Router::new().unwrap();
|
|
let candidates = vec![ResolvedRoute {
|
|
name: "inference.local".to_string(),
|
|
endpoint: "mock://test".to_string(),
|
|
model: "mock/test-model".to_string(),
|
|
api_key: "unused".to_string(),
|
|
protocols: vec!["openai_chat_completions".to_string()],
|
|
auth: AuthHeader::Bearer,
|
|
default_headers: Vec::new(),
|
|
passthrough_headers: Vec::new(),
|
|
timeout: openshell_router::config::DEFAULT_ROUTE_TIMEOUT,
|
|
model_in_path: false,
|
|
request_path_override: None,
|
|
}];
|
|
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"model": "mock/test-model",
|
|
"messages": [{"role": "user", "content": "hello"}]
|
|
}))
|
|
.unwrap();
|
|
|
|
let response = router
|
|
.proxy_with_candidates(
|
|
"openai_chat_completions",
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
vec![("content-type".to_string(), "application/json".to_string())],
|
|
bytes::Bytes::from(body),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status, 200);
|
|
let resp_body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
|
|
assert_eq!(resp_body["model"], "mock/test-model");
|
|
assert_eq!(
|
|
resp_body["choices"][0]["message"]["content"],
|
|
"Hello from openshell mock backend"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_overrides_model_in_request_body() {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
// The mock expects the route's model, NOT the client's original model
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.and(body_partial_json(serde_json::json!({
|
|
"model": "meta/llama-3.1-8b-instruct"
|
|
})))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = mock_candidates(&mock_server.uri());
|
|
|
|
// Client sends "gpt-4o-mini" but route is configured with "meta/llama-3.1-8b-instruct"
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"model": "gpt-4o-mini",
|
|
"messages": [{"role": "user", "content": "Hello"}]
|
|
}))
|
|
.unwrap();
|
|
|
|
let response = router
|
|
.proxy_with_candidates(
|
|
"openai_chat_completions",
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
vec![("content-type".to_string(), "application/json".to_string())],
|
|
bytes::Bytes::from(body),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status, 200);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_inserts_model_when_absent_from_body() {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
// The mock expects the route's model to be inserted even though the client didn't send one
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.and(body_partial_json(serde_json::json!({
|
|
"model": "meta/llama-3.1-8b-instruct"
|
|
})))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = mock_candidates(&mock_server.uri());
|
|
|
|
// Client omits "model" entirely
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"messages": [{"role": "user", "content": "Hello"}]
|
|
}))
|
|
.unwrap();
|
|
|
|
let response = router
|
|
.proxy_with_candidates(
|
|
"openai_chat_completions",
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
vec![("content-type".to_string(), "application/json".to_string())],
|
|
bytes::Bytes::from(body),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status, 200);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_uses_x_api_key_for_anthropic_route() {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/messages"))
|
|
.and(header("x-api-key", "test-anthropic-key"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"id": "msg_123",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "Hello"}],
|
|
"model": "claude-sonnet-4-20250514",
|
|
"stop_reason": "end_turn",
|
|
"usage": {"input_tokens": 10, "output_tokens": 5}
|
|
})))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = vec![ResolvedRoute {
|
|
name: "inference.local".to_string(),
|
|
endpoint: mock_server.uri(),
|
|
model: "claude-sonnet-4-20250514".to_string(),
|
|
api_key: "test-anthropic-key".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-version".to_string(),
|
|
"anthropic-beta".to_string(),
|
|
],
|
|
timeout: openshell_router::config::DEFAULT_ROUTE_TIMEOUT,
|
|
model_in_path: false,
|
|
request_path_override: None,
|
|
}];
|
|
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"model": "claude-sonnet-4-20250514",
|
|
"max_tokens": 1,
|
|
"messages": [{"role": "user", "content": "hi"}]
|
|
}))
|
|
.unwrap();
|
|
|
|
let response = router
|
|
.proxy_with_candidates(
|
|
"anthropic_messages",
|
|
"POST",
|
|
"/v1/messages",
|
|
vec![
|
|
("content-type".to_string(), "application/json".to_string()),
|
|
("anthropic-version".to_string(), "2023-06-01".to_string()),
|
|
],
|
|
bytes::Bytes::from(body),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status, 200);
|
|
let resp_body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
|
|
assert_eq!(resp_body["type"], "message");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_anthropic_does_not_send_bearer_auth() {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
// This mock rejects requests that have a Bearer token — ensuring we DON'T send one
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/messages"))
|
|
.and(header("x-api-key", "anthropic-key"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
// Also mount a catch-all that returns 401 if Bearer is used
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/messages"))
|
|
.and(bearer_token("anthropic-key"))
|
|
.respond_with(ResponseTemplate::new(401).set_body_string("should not use bearer"))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = vec![ResolvedRoute {
|
|
name: "inference.local".to_string(),
|
|
endpoint: mock_server.uri(),
|
|
model: "claude-sonnet-4-20250514".to_string(),
|
|
api_key: "anthropic-key".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-version".to_string(),
|
|
"anthropic-beta".to_string(),
|
|
],
|
|
timeout: openshell_router::config::DEFAULT_ROUTE_TIMEOUT,
|
|
model_in_path: false,
|
|
request_path_override: None,
|
|
}];
|
|
|
|
let response = router
|
|
.proxy_with_candidates(
|
|
"anthropic_messages",
|
|
"POST",
|
|
"/v1/messages",
|
|
vec![("content-type".to_string(), "application/json".to_string())],
|
|
bytes::Bytes::from(b"{}".to_vec()),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status, 200);
|
|
}
|
|
|
|
/// Regression test: when the client sends `anthropic-version`, the header must
|
|
/// reach the upstream. Previously, the header was added to the strip list
|
|
/// (because it appeared in `default_headers`) AND the default injection was
|
|
/// skipped (because `already_sent` checked the *original* input), so neither
|
|
/// the client's value nor the default reached the backend.
|
|
#[tokio::test]
|
|
async fn proxy_forwards_client_anthropic_version_header() {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
// The upstream requires anthropic-version — wiremock will reject if missing.
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/messages"))
|
|
.and(header("x-api-key", "test-anthropic-key"))
|
|
.and(header("anthropic-version", "2024-10-22"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = vec![ResolvedRoute {
|
|
name: "inference.local".to_string(),
|
|
endpoint: mock_server.uri(),
|
|
model: "claude-sonnet-4-20250514".to_string(),
|
|
api_key: "test-anthropic-key".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-version".to_string(),
|
|
"anthropic-beta".to_string(),
|
|
],
|
|
timeout: openshell_router::config::DEFAULT_ROUTE_TIMEOUT,
|
|
model_in_path: false,
|
|
request_path_override: None,
|
|
}];
|
|
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"model": "claude-sonnet-4-20250514",
|
|
"max_tokens": 1,
|
|
"messages": [{"role": "user", "content": "hi"}]
|
|
}))
|
|
.unwrap();
|
|
|
|
// Client explicitly sends anthropic-version: 2024-10-22 — this value should
|
|
// reach the upstream, NOT be silently dropped.
|
|
let response = router
|
|
.proxy_with_candidates(
|
|
"anthropic_messages",
|
|
"POST",
|
|
"/v1/messages",
|
|
vec![
|
|
("content-type".to_string(), "application/json".to_string()),
|
|
("anthropic-version".to_string(), "2024-10-22".to_string()),
|
|
],
|
|
bytes::Bytes::from(body),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
response.status, 200,
|
|
"upstream should have received anthropic-version header"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_vertex_gemini_route_uses_chat_completions_override() {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path(
|
|
"/v1beta1/projects/my-project/locations/us-central1/endpoints/openapi/chat/completions",
|
|
))
|
|
.and(bearer_token("ya29.test-token"))
|
|
.and(body_partial_json(serde_json::json!({
|
|
"model": "gemini-2.0-flash-001",
|
|
})))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"id": "chatcmpl-vertex",
|
|
"object": "chat.completion",
|
|
"created": 1_700_000_000_i64,
|
|
"model": "gemini-2.0-flash-001",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": { "role": "assistant", "content": "pong" },
|
|
"finish_reason": "stop"
|
|
}]
|
|
})))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = vec![ResolvedRoute {
|
|
name: "inference.local".to_string(),
|
|
endpoint: format!(
|
|
"{}/v1beta1/projects/my-project/locations/us-central1/endpoints/openapi",
|
|
mock_server.uri()
|
|
),
|
|
model: "gemini-2.0-flash-001".to_string(),
|
|
api_key: "ya29.test-token".to_string(),
|
|
protocols: vec!["openai_chat_completions".to_string()],
|
|
auth: AuthHeader::Bearer,
|
|
default_headers: Vec::new(),
|
|
passthrough_headers: Vec::new(),
|
|
timeout: openshell_router::config::DEFAULT_ROUTE_TIMEOUT,
|
|
model_in_path: false,
|
|
request_path_override: Some("/chat/completions".to_string()),
|
|
}];
|
|
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"model": "client-model",
|
|
"messages": [{"role": "user", "content": "ping"}]
|
|
}))
|
|
.unwrap();
|
|
|
|
let response = router
|
|
.proxy_with_candidates(
|
|
"openai_chat_completions",
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
vec![("content-type".to_string(), "application/json".to_string())],
|
|
bytes::Bytes::from(body),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status, 200);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_vertex_anthropic_route_uses_model_path_suffix() {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path(
|
|
"/v1/projects/my-project/locations/us-east5/publishers/anthropic/models/claude-3-5-sonnet@20241022:rawPredict",
|
|
))
|
|
.and(bearer_token("ya29.vertex-token"))
|
|
.and(body_partial_json(serde_json::json!({
|
|
"anthropic_version": "vertex-2023-10-16",
|
|
})))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"id": "msg_vertex_1",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": "claude-3-5-sonnet@20241022",
|
|
"content": [{"type": "text", "text": "pong"}]
|
|
})))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = vec![ResolvedRoute {
|
|
name: "inference.local".to_string(),
|
|
endpoint: format!(
|
|
"{}/v1/projects/my-project/locations/us-east5/publishers/anthropic/models",
|
|
mock_server.uri()
|
|
),
|
|
model: "claude-3-5-sonnet@20241022".to_string(),
|
|
api_key: "ya29.vertex-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()),
|
|
}];
|
|
|
|
// Include "model" in the body, as Claude Code and other Anthropic SDK
|
|
// clients always do. The router must strip it for Vertex AI rawPredict.
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"model": "claude-3-5-sonnet-20241022",
|
|
"messages": [{"role": "user", "content": "ping"}],
|
|
"max_tokens": 32
|
|
}))
|
|
.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(), "tools-2024-05-16".to_string()),
|
|
("anthropic-version".to_string(), "2023-06-01".to_string()),
|
|
],
|
|
bytes::Bytes::from(body),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status, 200);
|
|
let received = mock_server.received_requests().await.unwrap();
|
|
assert_eq!(received.len(), 1);
|
|
let received_body: serde_json::Value = serde_json::from_slice(&received[0].body).unwrap();
|
|
assert_eq!(
|
|
received_body["anthropic_version"],
|
|
serde_json::json!("vertex-2023-10-16")
|
|
);
|
|
// "model" must be stripped: Vertex AI encodes the model in the URL path
|
|
// and rejects "model" in the body with "Extra inputs are not permitted".
|
|
assert!(
|
|
received_body.get("model").is_none(),
|
|
"Vertex Anthropic requests must not have model in the body, got: {received_body}"
|
|
);
|
|
// anthropic-beta must be stripped: Vertex AI rejects unknown beta values
|
|
// with HTTP 400 (e.g. prompt-caching-scope-2026-01-05).
|
|
assert!(
|
|
!received[0].headers.contains_key("anthropic-beta"),
|
|
"anthropic-beta must not reach the Vertex AI backend"
|
|
);
|
|
assert!(
|
|
!received[0].headers.contains_key("anthropic-version"),
|
|
"anthropic-version must be converted to body anthropic_version, not forwarded as a header"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_vertex_anthropic_streaming_route_uses_stream_rawpredict() {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path(
|
|
"/v1/projects/my-project/locations/us-east5/publishers/anthropic/models/claude-3-5-sonnet@20241022:streamRawPredict",
|
|
))
|
|
.and(bearer_token("ya29.vertex-token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("{\"id\":\"msg_vertex_stream\"}"))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = vec![ResolvedRoute {
|
|
name: "inference.local".to_string(),
|
|
endpoint: format!(
|
|
"{}/v1/projects/my-project/locations/us-east5/publishers/anthropic/models",
|
|
mock_server.uri()
|
|
),
|
|
model: "claude-3-5-sonnet@20241022".to_string(),
|
|
api_key: "ya29.vertex-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()),
|
|
}];
|
|
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"messages": [{"role": "user", "content": "ping"}],
|
|
"max_tokens": 32,
|
|
"stream": true
|
|
}))
|
|
.unwrap();
|
|
|
|
let mut response = router
|
|
.proxy_with_candidates_streaming(
|
|
"anthropic_messages",
|
|
"POST",
|
|
"/v1/messages",
|
|
vec![("content-type".to_string(), "application/json".to_string())],
|
|
bytes::Bytes::from(body),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status, 200);
|
|
let first_chunk = response.next_chunk().await.unwrap();
|
|
assert!(
|
|
first_chunk.is_some(),
|
|
"streaming response should yield a body chunk"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_resolves_routes_with_protocol() {
|
|
let config = RouterConfig {
|
|
routes: vec![RouteConfig {
|
|
name: "inference.local".to_string(),
|
|
endpoint: "http://localhost:8000".to_string(),
|
|
model: "test-model".to_string(),
|
|
provider_type: None,
|
|
protocols: vec!["openai_chat_completions".to_string()],
|
|
api_key: Some("key".to_string()),
|
|
api_key_env: None,
|
|
}],
|
|
};
|
|
let routes = config.resolve_routes().unwrap();
|
|
assert_eq!(routes[0].protocols, vec!["openai_chat_completions"]);
|
|
}
|
|
|
|
/// Streaming proxy must not apply a total request timeout to the body stream.
|
|
///
|
|
/// The backend delays its response longer than the route timeout. With the old
|
|
/// code this would fail (reqwest's total `.timeout()` fires), but the streaming
|
|
/// path now omits that timeout — only the client-level `connect_timeout` and
|
|
/// the sandbox idle timeout govern liveness.
|
|
#[tokio::test]
|
|
async fn streaming_proxy_completes_despite_exceeding_route_timeout() {
|
|
use std::time::Duration;
|
|
|
|
let mock_server = MockServer::start().await;
|
|
|
|
let sse_body = concat!(
|
|
"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n",
|
|
"data: {\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n",
|
|
"data: [DONE]\n\n",
|
|
);
|
|
|
|
// Delay the response 3s — longer than the 1s route timeout.
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.and(bearer_token("test-api-key"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.append_header("content-type", "text/event-stream")
|
|
.set_body_string(sse_body)
|
|
.set_delay(Duration::from_secs(3)),
|
|
)
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = vec![ResolvedRoute {
|
|
name: "inference.local".to_string(),
|
|
endpoint: mock_server.uri(),
|
|
model: "test-model".to_string(),
|
|
api_key: "test-api-key".to_string(),
|
|
protocols: vec!["openai_chat_completions".to_string()],
|
|
auth: AuthHeader::Bearer,
|
|
default_headers: Vec::new(),
|
|
passthrough_headers: Vec::new(),
|
|
// Route timeout shorter than the backend delay — streaming must
|
|
// NOT be constrained by this.
|
|
timeout: Duration::from_secs(1),
|
|
model_in_path: false,
|
|
request_path_override: None,
|
|
}];
|
|
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"model": "test-model",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"stream": true
|
|
}))
|
|
.unwrap();
|
|
|
|
// The streaming path should succeed despite the 3s delay exceeding
|
|
// the 1s route timeout.
|
|
let mut resp = router
|
|
.proxy_with_candidates_streaming(
|
|
"openai_chat_completions",
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
vec![("content-type".to_string(), "application/json".to_string())],
|
|
bytes::Bytes::from(body),
|
|
&candidates,
|
|
)
|
|
.await
|
|
.expect("streaming proxy should not be killed by route timeout");
|
|
|
|
assert_eq!(resp.status, 200);
|
|
|
|
// Drain all chunks to verify the full body is received.
|
|
let mut total_bytes = 0;
|
|
while let Ok(Some(chunk)) = resp.next_chunk().await {
|
|
total_bytes += chunk.len();
|
|
}
|
|
assert!(total_bytes > 0, "should have received body chunks");
|
|
}
|
|
|
|
/// Non-streaming (buffered) proxy must still enforce the route timeout.
|
|
#[tokio::test]
|
|
async fn buffered_proxy_enforces_route_timeout() {
|
|
use std::time::Duration;
|
|
|
|
let mock_server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/chat/completions"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_string("{}")
|
|
// Delay longer than the route timeout.
|
|
.set_delay(Duration::from_secs(5)),
|
|
)
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let router = Router::new().unwrap();
|
|
let candidates = vec![ResolvedRoute {
|
|
name: "inference.local".to_string(),
|
|
endpoint: mock_server.uri(),
|
|
model: "test-model".to_string(),
|
|
api_key: "test-api-key".to_string(),
|
|
protocols: vec!["openai_chat_completions".to_string()],
|
|
auth: AuthHeader::Bearer,
|
|
default_headers: Vec::new(),
|
|
passthrough_headers: Vec::new(),
|
|
timeout: Duration::from_secs(1),
|
|
model_in_path: false,
|
|
request_path_override: None,
|
|
}];
|
|
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"model": "test-model",
|
|
"messages": [{"role": "user", "content": "hi"}]
|
|
}))
|
|
.unwrap();
|
|
|
|
let result = router
|
|
.proxy_with_candidates(
|
|
"openai_chat_completions",
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
vec![("content-type".to_string(), "application/json".to_string())],
|
|
bytes::Bytes::from(body),
|
|
&candidates,
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_err(), "buffered proxy should timeout");
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("timed out"),
|
|
"error should mention timeout, got: {err}"
|
|
);
|
|
}
|