feat(gateway): harden provider request execution

Preserve exact request payloads and model client surface and API operation explicitly.

Add Anthropic compatibility profiles, bounded stream commitment, and scoped OAuth retry behavior across provider transports.
This commit is contained in:
elky
2026-07-27 09:36:31 +08:00
parent 79b70f7b5c
commit 531cf11025
152 changed files with 13984 additions and 2075 deletions
@@ -511,7 +511,13 @@ async fn assert_ai_route_locally_denied_after_execution_runtime_miss_with_reques
None
);
let payload: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(payload["error"]["type"], "http_error");
if request_path.trim_end_matches('/') == "/v1/messages" {
assert_eq!(payload["type"], "error");
assert_eq!(payload["error"]["type"], "overloaded_error");
} else {
assert!(payload.get("type").is_none());
assert_eq!(payload["error"]["type"], "http_error");
}
assert_eq!(payload["error"]["message"], expected_message);
assert_eq!(*control_execute_hits.lock().expect("mutex should lock"), 0);
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
@@ -992,6 +992,7 @@ async fn gateway_executes_kiro_claude_cli_sync_upstream_stream_via_local_finaliz
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-kiro-cli-finalize-local",
@@ -1326,6 +1326,7 @@ async fn gateway_executes_claude_cli_sync_upstream_stream_via_local_finalize_res
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-claude-cli-stream-sync-local",
@@ -516,6 +516,7 @@ async fn gateway_executes_kiro_claude_cli_stream_via_local_provider_catalog_cand
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-kiro-cli-local-stream",
@@ -927,6 +928,9 @@ async fn gateway_executes_claude_cli_stream_via_local_decision_gate_without_wait
b"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\"}\\n\\n\"}}\n"
));
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
yield Ok::<Bytes, std::convert::Infallible>(Bytes::from_static(
b"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\"}}\n"
));
yield Ok::<Bytes, std::convert::Infallible>(Bytes::from_static(
b"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":31,\"ttfb_ms\":11,\"upstream_bytes\":37}}}\n"
));
@@ -980,6 +984,7 @@ async fn gateway_executes_claude_cli_stream_via_local_decision_gate_without_wait
let mut response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-claude-cli-local",
@@ -1004,7 +1009,7 @@ async fn gateway_executes_claude_cli_stream_via_local_decision_gate_without_wait
);
assert_eq!(
response.text().await.expect("remaining body should read"),
""
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
);
let seen_execution_runtime_request = seen_execution_runtime
@@ -1438,6 +1443,7 @@ async fn gateway_executes_claude_code_cli_stream_via_local_decision_gate_with_lo
let frames = concat!(
"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"text/event-stream\"}}}\n",
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\"}\\n\\n\"}}\n",
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\"}}\n",
"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":31,\"ttfb_ms\":11,\"upstream_bytes\":37}}}\n",
"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n"
);
@@ -1490,6 +1496,7 @@ async fn gateway_executes_claude_code_cli_stream_via_local_decision_gate_with_lo
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-claude-code-cli-local",
@@ -1522,7 +1529,10 @@ async fn gateway_executes_claude_code_cli_stream_via_local_decision_gate_with_lo
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
strip_sse_keepalive_comments(&response.text().await.expect("body should read")),
"event: message_start\ndata: {\"type\":\"message_start\"}\n\n"
concat!(
"event: message_start\ndata: {\"type\":\"message_start\"}\n\n",
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
)
);
let seen_execution_runtime_request = seen_execution_runtime
@@ -1551,14 +1561,17 @@ async fn gateway_executes_claude_code_cli_stream_via_local_decision_gate_with_lo
);
assert_eq!(
seen_execution_runtime_request.anthropic_beta,
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,custom-beta"
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05,effort-2025-11-24,context-management-2025-06-27,extended-cache-ttl-2025-04-11,context-1m-2025-08-07,custom-beta"
);
assert_eq!(seen_execution_runtime_request.x_app, "cli");
assert_eq!(
seen_execution_runtime_request.x_stainless_helper_method,
"stream"
);
assert_eq!(seen_execution_runtime_request.user_agent, "Claude-Code/9.9");
assert_eq!(
seen_execution_runtime_request.user_agent,
"claude-cli/2.1.161 (external, cli)"
);
assert_eq!(
seen_execution_runtime_request.endpoint_tag,
"claude-code-cli-local"
@@ -1921,6 +1934,7 @@ async fn gateway_executes_claude_chat_stream_via_local_decision_gate_with_local_
let frames = concat!(
"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"text/event-stream\"}}}\n",
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\"}\\n\\n\"}}\n",
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\"}}\n",
"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":31,\"ttfb_ms\":11,\"upstream_bytes\":37}}}\n",
"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n"
);
@@ -1985,7 +1999,10 @@ async fn gateway_executes_claude_chat_stream_via_local_decision_gate_with_local_
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
strip_sse_keepalive_comments(&response.text().await.expect("body should read")),
"event: message_start\ndata: {\"type\":\"message_start\"}\n\n"
concat!(
"event: message_start\ndata: {\"type\":\"message_start\"}\n\n",
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
)
);
let seen_execution_runtime_request = seen_execution_runtime
@@ -469,6 +469,7 @@ async fn gateway_executes_claude_code_cli_sync_via_local_decision_gate_with_loca
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-claude-code-cli-local",
@@ -535,15 +536,18 @@ async fn gateway_executes_claude_code_cli_sync_via_local_decision_gate_with_loca
);
assert_eq!(
seen_execution_runtime_request.anthropic_beta,
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,custom-beta"
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05,effort-2025-11-24,context-management-2025-06-27,extended-cache-ttl-2025-04-11,context-1m-2025-08-07,custom-beta"
);
assert_eq!(seen_execution_runtime_request.x_app, "cli");
assert_eq!(seen_execution_runtime_request.x_stainless_helper_method, "");
assert_eq!(
seen_execution_runtime_request.x_stainless_package_version,
"1.0.5"
"0.94.0"
);
assert_eq!(
seen_execution_runtime_request.user_agent,
"claude-cli/2.1.161 (external, cli)"
);
assert_eq!(seen_execution_runtime_request.user_agent, "Claude-Code/9.9");
assert_eq!(
seen_execution_runtime_request.endpoint_tag,
"claude-code-cli-local"
@@ -545,6 +545,7 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-kiro-cli-local-sync",
@@ -1153,6 +1154,7 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-kiro-cli-local-refresh",
@@ -49,7 +49,9 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
trace_id: String,
url: String,
model: String,
stream: Option<bool>,
auth_header_value: String,
accept: String,
anthropic_version: String,
anthropic_beta: String,
endpoint_tag: String,
@@ -270,6 +272,12 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
let payload: serde_json::Value = serde_json::from_slice(&raw_body)
.expect("execution runtime payload should parse");
let upstream_url = payload
.get("url")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let is_count_tokens = upstream_url.ends_with("/v1/messages/count_tokens");
*seen_execution_runtime_inner
.lock()
.expect("mutex should lock") = Some(SeenExecutionRuntimeSyncRequest {
@@ -279,11 +287,7 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
url: payload
.get("url")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
url: upstream_url,
model: payload
.get("body")
.and_then(|value| value.get("json_body"))
@@ -291,12 +295,23 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
stream: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("stream"))
.and_then(|value| value.as_bool()),
auth_header_value: payload
.get("headers")
.and_then(|value| value.get("x-api-key"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
accept: payload
.get("headers")
.and_then(|value| value.get("accept"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
anthropic_version: payload
.get("headers")
.and_then(|value| value.get("anthropic-version"))
@@ -344,29 +359,39 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
.unwrap_or_default()
.to_string(),
});
Json(json!({
"request_id": "trace-claude-chat-local-123",
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"id": "msg-local-claude-123",
"type": "message",
"model": "claude-sonnet-4-5-upstream",
"role": "assistant",
"content": [],
"usage": {
"input_tokens": 2,
"output_tokens": 3
if is_count_tokens {
Json(json!({
"request_id": "trace-claude-count-tokens-local-123",
"status_code": 200,
"headers": {"content-type": "application/json"},
"body": {"json_body": {"input_tokens": 17}},
"telemetry": {"elapsed_ms": 11}
}))
} else {
Json(json!({
"request_id": "trace-claude-chat-local-123",
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"id": "msg-local-claude-123",
"type": "message",
"model": "claude-sonnet-4-5-upstream",
"role": "assistant",
"content": [],
"usage": {
"input_tokens": 2,
"output_tokens": 3
}
}
},
"telemetry": {
"elapsed_ms": 29
}
},
"telemetry": {
"elapsed_ms": 29
}
}))
}))
}
}
}),
);
@@ -484,6 +509,125 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
assert_eq!(*plan_hits.lock().expect("mutex should lock"), 0);
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
for (case, body, expected_message) in [
("missing-body", None, "Request body is required"),
("invalid-json", Some("{"), "Invalid JSON body"),
(
"missing-model",
Some(r#"{"messages":[]}"#),
"model: Field required",
),
(
"missing-messages",
Some(r#"{"model":"claude-sonnet-4-5"}"#),
"messages: Field required",
),
] {
let mut request = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages/count_tokens"))
.header(http::header::CONTENT_TYPE, "application/json")
.header("x-api-key", "sk-client-claude-chat-local")
.header("anthropic-version", "2023-06-01")
.header(TRACE_ID_HEADER, format!("trace-claude-count-tokens-{case}"));
if let Some(body) = body {
request = request.body(body);
}
let invalid_response = request
.send()
.await
.expect("invalid count_tokens request should complete locally");
assert_eq!(invalid_response.status(), StatusCode::BAD_REQUEST);
let invalid_json: serde_json::Value = invalid_response
.json()
.await
.expect("Anthropic error body should parse");
assert_eq!(invalid_json["type"], "error");
assert_eq!(invalid_json["error"]["type"], "invalid_request_error");
assert_eq!(invalid_json["error"]["message"], expected_message);
assert_eq!(
seen_execution_runtime
.lock()
.expect("mutex should lock")
.as_ref()
.map(|request| request.url.as_str()),
Some("https://api.anthropic.example/custom/v1/messages"),
"invalid count_tokens request must not reach the execution runtime"
);
}
let count_tokens_response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages/count_tokens"))
.header(http::header::CONTENT_TYPE, "application/json")
.header("x-api-key", "sk-client-claude-chat-local")
.header("anthropic-version", "2023-06-01")
.header(TRACE_ID_HEADER, "trace-claude-count-tokens-local-123")
.body(
"{\"model\":\"claude-sonnet-4-5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}],\"stream\":true}",
)
.send()
.await
.expect("count_tokens request should succeed");
assert_eq!(count_tokens_response.status(), StatusCode::OK);
assert_eq!(
count_tokens_response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok()),
Some(EXECUTION_PATH_EXECUTION_RUNTIME_SYNC)
);
let seen_count_tokens = seen_execution_runtime
.lock()
.expect("mutex should lock")
.clone()
.expect("count_tokens execution request should be captured");
assert_eq!(
seen_count_tokens.url,
"https://api.anthropic.example/custom/v1/messages/count_tokens"
);
assert_eq!(seen_count_tokens.model, "claude-sonnet-4-5-upstream");
assert_eq!(seen_count_tokens.stream, None);
assert_eq!(seen_count_tokens.accept, "application/json");
assert_eq!(
seen_count_tokens.auth_header_value,
"sk-upstream-claude-chat"
);
let count_tokens_json: serde_json::Value = count_tokens_response
.json()
.await
.expect("count_tokens response should parse");
assert_eq!(
count_tokens_json["input_tokens"], 17,
"unexpected count_tokens response: {count_tokens_json}"
);
use std::io::Write as _;
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder
.write_all(
br#"{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}]}"#,
)
.expect("gzip request body should encode");
let gzip_body = encoder.finish().expect("gzip request body should finish");
let gzip_count_tokens_response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages/count_tokens"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::CONTENT_ENCODING, "gzip")
.header("x-api-key", "sk-client-claude-chat-local")
.header("anthropic-version", "2023-06-01")
.header(TRACE_ID_HEADER, "trace-claude-count-tokens-gzip-123")
.body(gzip_body)
.send()
.await
.expect("gzip count_tokens request should succeed");
assert_eq!(gzip_count_tokens_response.status(), StatusCode::OK);
let gzip_count_tokens_json: serde_json::Value = gzip_count_tokens_response
.json()
.await
.expect("gzip count_tokens response should parse");
assert_eq!(gzip_count_tokens_json["input_tokens"], 17);
gateway_handle.abort();
execution_runtime_handle.abort();
upstream_handle.abort();
@@ -603,7 +747,8 @@ async fn gateway_surfaces_candidate_list_empty_reason_for_claude_chat_runtime_mi
Some("candidate_list_empty")
);
let payload: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(payload["type"], "error");
assert_eq!(payload["error"]["type"], "overloaded_error");
assert_eq!(
payload["error"]["message"],
"没有可用提供商支持模型 claude-sonnet-4-5 的同步请求"
@@ -404,6 +404,7 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_syn
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-claude-cli-local",
@@ -730,6 +731,7 @@ async fn gateway_returns_claude_cli_error_for_local_sync_failure_impl() {
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-claude-cli-local-error",
@@ -985,6 +987,7 @@ async fn gateway_marks_claude_cli_cross_format_runtime_miss_when_format_conversi
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages?beta=true"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-claude-cli-openai-local-miss",
@@ -1011,7 +1014,8 @@ async fn gateway_marks_claude_cli_cross_format_runtime_miss_when_format_conversi
Some("all_candidates_skipped")
);
let response_json: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(response_json["error"]["type"], "http_error");
assert_eq!(response_json["type"], "error");
assert_eq!(response_json["error"]["type"], "overloaded_error");
assert_eq!(
response_json["error"]["message"],
"没有可用提供商支持模型 gpt-5.4 的同步请求"
@@ -60,6 +60,9 @@ fn sample_decision() -> crate::control::GatewayControlDecision {
route_class: Some("ai_public".to_string()),
route_family: Some("openai".to_string()),
route_kind: Some("chat".to_string()),
client_surface: None,
api_operation: None,
gateway_credential_carrier: None,
request_auth_channel: None,
auth_endpoint_signature: None,
execution_runtime_candidate: true,
@@ -1604,7 +1604,7 @@ async fn gateway_refresh_quota_reconciles_unsupported_fixed_provider_endpoints_b
(
"provider-vertex-ai-reconcile",
"vertex_ai",
3usize,
2usize,
"gemini:generate_content",
"https://aiplatform.googleapis.com",
"Vertex AI 暂不支持自动刷新额度",
@@ -48,6 +48,45 @@ async fn gateway_blocks_blacklisted_ip_before_routing() {
assert_eq!(payload["error"]["message"], "当前 IP 已被禁止访问");
}
#[tokio::test]
async fn gateway_shapes_blacklist_rejections_for_claude_routes_before_routing() {
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_admin_security_blacklist_for_tests([(
"127.0.0.1".to_string(),
"blocked".to_string(),
)]),
);
for path in ["/v1/messages", "/v1/messages/count_tokens"] {
let request = Request::builder()
.method("POST")
.uri(path)
.header(http::header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"model":"claude-sonnet-4","messages":[]}"#))
.expect("request should build");
let response = send_request(gateway.clone(), request).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN, "path: {path}");
let payload = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: serde_json::Value =
serde_json::from_slice(&payload).expect("response should be json");
assert_eq!(payload["type"], "error", "path: {path}");
assert_eq!(payload["error"]["type"], "permission_error", "path: {path}");
assert_eq!(
payload["error"]["message"], "当前 IP 已被禁止访问",
"path: {path}"
);
}
}
#[tokio::test]
async fn gateway_blocks_forwarded_ip_from_trusted_proxy() {
let gateway = build_router_with_state(
@@ -1758,6 +1758,144 @@ async fn gateway_reports_field_path_for_invalid_admin_system_config_import_shape
gateway_handle.abort();
}
#[test]
fn gateway_rejects_invalid_anthropic_profiles_during_admin_system_config_import() {
run_admin_system_import_test(
"gateway_rejects_invalid_anthropic_profiles_during_admin_system_config_import",
gateway_rejects_invalid_anthropic_profiles_during_admin_system_config_import_impl,
);
}
async fn gateway_rejects_invalid_anthropic_profiles_during_admin_system_config_import_impl() {
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(build_empty_admin_system_data_state()),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
for config_scope in ["provider", "endpoint"] {
let mut payload = sample_system_import_payload();
let invalid_config = json!({
"anthropic": {"compatibility_profile": "claude_cod_typo"}
});
if config_scope == "provider" {
payload["providers"][0]["config"] = invalid_config;
} else {
payload["providers"][0]["endpoints"][0]["config"] = invalid_config;
}
let response = client
.post(format!("{gateway_url}/api/admin/system/config/import"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&payload)
.send()
.await
.expect("invalid Anthropic profile import should complete locally");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body: Value = response.json().await.expect("json body should parse");
assert_eq!(
body["detail"], "无效的 Anthropic compatibility profile",
"unexpected {config_scope} validation response: {body}"
);
}
gateway_handle.abort();
}
#[test]
fn gateway_does_not_restore_retired_vertex_claude_endpoint_from_system_import() {
run_admin_system_import_test(
"gateway_does_not_restore_retired_vertex_claude_endpoint_from_system_import",
gateway_does_not_restore_retired_vertex_claude_endpoint_from_system_import_impl,
);
}
async fn gateway_does_not_restore_retired_vertex_claude_endpoint_from_system_import_impl() {
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
Vec::new(),
Vec::new(),
Vec::new(),
));
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(Vec::<
StoredPublicGlobalModel,
>::new()));
let data_state = build_admin_system_data_state_with_repositories(
Arc::clone(&provider_catalog_repository),
Arc::clone(&global_model_repository),
);
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let mut payload = sample_system_import_payload();
payload["providers"][0]["name"] = json!("legacy-vertex-backup");
payload["providers"][0]["provider_type"] = json!("vertex_ai");
payload["providers"][0]["endpoints"] = json!([
{
"api_format": "gemini:generate_content",
"base_url": "https://aiplatform.googleapis.com",
"max_retries": 2,
"is_active": true
},
{
"api_format": "claude:messages",
"base_url": "https://aiplatform.googleapis.com",
"max_retries": 2,
"is_active": true
}
]);
payload["providers"][0]["api_keys"] = json!([]);
payload["providers"][0]["models"] = json!([]);
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/admin/system/config/import"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&payload)
.send()
.await
.expect("legacy Vertex import should complete");
let status = response.status();
let response_body: Value = response.json().await.expect("json body should parse");
assert_eq!(status, StatusCode::OK, "payload={response_body}");
assert_eq!(response_body["stats"]["endpoints"]["created"], json!(1));
assert_eq!(response_body["stats"]["endpoints"]["skipped"], json!(1));
assert!(response_body["stats"]["errors"]
.as_array()
.is_some_and(|errors| errors.iter().any(|error| {
error
.as_str()
.is_some_and(|error| error.contains("claude:messages"))
})));
let providers = provider_catalog_repository
.list_providers(false)
.await
.expect("providers should load");
assert_eq!(providers.len(), 1);
let endpoints = provider_catalog_repository
.list_endpoints_by_provider_ids(std::slice::from_ref(&providers[0].id))
.await
.expect("endpoints should load");
assert_eq!(endpoints.len(), 1, "unexpected endpoints: {endpoints:?}");
assert_eq!(endpoints[0].api_format, "gemini:generate_content");
assert!(endpoints[0].is_active);
gateway_handle.abort();
}
#[test]
fn gateway_imports_admin_system_config_with_numeric_string_prices() {
run_admin_system_import_test(
@@ -322,6 +322,7 @@ async fn gateway_locally_denies_invalid_bearer_api_key_without_hitting_control_o
Some(EXECUTION_PATH_LOCAL_AUTH_DENIED)
);
let payload: serde_json::Value = response.json().await.expect("response json should parse");
assert!(payload.get("type").is_none());
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(payload["error"]["message"], "无效的API密钥");
assert_eq!(*auth_context_hits.lock().expect("mutex should lock"), 0);
@@ -331,6 +332,57 @@ async fn gateway_locally_denies_invalid_bearer_api_key_without_hitting_control_o
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_claude_routes_use_anthropic_authentication_error_for_invalid_api_key() {
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-other-claude-key")),
sample_currently_usable_auth_snapshot("key-claude-other", "user-claude-other"),
)]));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway state should build")
.with_auth_api_key_data_reader_for_tests(repository),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
for (path, trace_id) in [
("/v1/messages", "trace-control-claude-invalid-key-messages"),
(
"/v1/messages/count_tokens",
"trace-control-claude-invalid-key-count-tokens",
),
] {
let response = client
.post(format!("{gateway_url}{path}"))
.header(http::header::CONTENT_TYPE, "application/json")
.header("x-api-key", "sk-missing-claude-key")
.header(TRACE_ID_HEADER, trace_id)
.body("{\"model\":\"claude-sonnet-4-5\",\"messages\":[]}")
.send()
.await
.expect("request should complete locally");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "path: {path}");
assert_eq!(
response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok()),
Some(EXECUTION_PATH_LOCAL_AUTH_DENIED),
"path: {path}"
);
let payload: serde_json::Value = response.json().await.expect("response json should parse");
assert_eq!(payload["type"], "error", "path: {path}");
assert_eq!(
payload["error"]["type"], "authentication_error",
"path: {path}"
);
}
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_locally_denies_admin_proxy_without_admin_principal_and_without_hitting_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
@@ -498,7 +550,8 @@ async fn gateway_locally_denies_disallowed_claude_api_format_without_hitting_con
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let payload: serde_json::Value = response.json().await.expect("response json should parse");
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(payload["type"], "error");
assert_eq!(payload["error"]["type"], "permission_error");
assert_eq!(
payload["error"]["message"],
"当前用户、用户组或密钥的访问控制策略不允许访问 claude:messages 格式"
@@ -581,7 +634,8 @@ async fn gateway_locally_denies_disallowed_provider_without_hitting_control_or_u
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let payload: serde_json::Value = response.json().await.expect("response json should parse");
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(payload["type"], "error");
assert_eq!(payload["error"]["type"], "permission_error");
assert_eq!(
payload["error"]["message"],
"当前用户、用户组或密钥的访问控制策略不允许访问 claude 提供商"
@@ -817,3 +871,52 @@ async fn gateway_locally_denies_disallowed_openai_model_without_hitting_control_
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_locally_denies_disallowed_claude_model_with_anthropic_permission_error() {
let mut snapshot =
sample_currently_usable_auth_snapshot("key-claude-model-123", "user-claude-model-123");
snapshot.api_key_allowed_providers = Some(vec!["claude".to_string()]);
snapshot.user_allowed_providers = Some(vec!["claude".to_string()]);
snapshot.api_key_allowed_api_formats = Some(vec!["claude:messages".to_string()]);
snapshot.user_allowed_api_formats = Some(vec!["claude:messages".to_string()]);
snapshot.api_key_allowed_models = Some(vec!["claude-haiku-4-5".to_string()]);
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-claude-model-guard-123")),
snapshot,
)]));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway state should build")
.with_auth_api_key_data_reader_for_tests(repository),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages"))
.header(http::header::CONTENT_TYPE, "application/json")
.header("x-api-key", "sk-claude-model-guard-123")
.header(TRACE_ID_HEADER, "trace-control-claude-model-guard-1")
.body("{\"model\":\"claude-sonnet-4-5\",\"messages\":[]}")
.send()
.await
.expect("request should complete locally");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok()),
Some(EXECUTION_PATH_LOCAL_AUTH_DENIED)
);
let payload: serde_json::Value = response.json().await.expect("response json should parse");
assert_eq!(payload["type"], "error");
assert_eq!(payload["error"]["type"], "permission_error");
assert_eq!(
payload["error"]["message"],
"当前用户、用户组或密钥的访问控制策略不允许访问模型 claude-sonnet-4-5"
);
gateway_handle.abort();
}
@@ -990,136 +990,6 @@ async fn gateway_handles_public_gemini_models_without_hitting_fallback_probe() {
fallback_probe_handle.abort();
}
#[tokio::test]
async fn gateway_handles_claude_count_tokens_without_hitting_fallback_probe() {
let fallback_probe_hits = Arc::new(Mutex::new(0usize));
let fallback_probe_hits_clone = Arc::clone(&fallback_probe_hits);
let fallback_probe = Router::new().route(
"/{*path}",
any(move |_request: Request| {
let fallback_probe_hits_inner = Arc::clone(&fallback_probe_hits_clone);
async move {
*fallback_probe_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Json(json!({"proxied": true}))).into_response()
}
}),
);
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-claude-count")),
unrestricted_models_snapshot("key-claude-count", "user-claude-count"),
)]));
let (_unused_fallback_probe_url, fallback_probe_handle) = start_server(fallback_probe).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_auth_api_key_data_reader_for_tests(auth_repository),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages/count_tokens"))
.header("x-api-key", "sk-claude-count")
.header("anthropic-version", "2023-06-01")
.body(
serde_json::to_vec(&json!({
"model": "claude-sonnet-4-5",
"system": [{"type": "text", "text": "abcdefghijklmnop"}],
"messages": [
{
"role": "user",
"content": "abcdefghijkl"
},
{
"role": "assistant",
"content": [
{"type": "text", "text": "abcdefgh"},
{"type": "tool_use", "name": "ignored", "input": {"city": "SF"}}
]
}
]
}))
.expect("request body should encode"),
)
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok()),
Some(EXECUTION_PATH_LOCAL_AI_PUBLIC)
);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["input_tokens"], 17);
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
fallback_probe_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_invalid_claude_count_tokens_payload_without_hitting_fallback_probe() {
let fallback_probe_hits = Arc::new(Mutex::new(0usize));
let fallback_probe_hits_clone = Arc::clone(&fallback_probe_hits);
let fallback_probe = Router::new().route(
"/{*path}",
any(move |_request: Request| {
let fallback_probe_hits_inner = Arc::clone(&fallback_probe_hits_clone);
async move {
*fallback_probe_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Json(json!({"proxied": true}))).into_response()
}
}),
);
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-claude-count-invalid")),
unrestricted_models_snapshot("key-claude-count-invalid", "user-claude-count-invalid"),
)]));
let (_unused_fallback_probe_url, fallback_probe_handle) = start_server(fallback_probe).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_auth_api_key_data_reader_for_tests(auth_repository),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages/count_tokens"))
.header("x-api-key", "sk-claude-count-invalid")
.body(
serde_json::to_vec(&json!({
"model": "claude-sonnet-4-5",
"messages": [{"role": "system", "content": "bad"}]
}))
.expect("request body should encode"),
)
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok()),
Some(EXECUTION_PATH_LOCAL_AI_PUBLIC)
);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "Invalid token count payload");
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
fallback_probe_handle.abort();
}
#[tokio::test]
async fn gateway_handles_antigravity_v1internal_control_plane_without_proxying() {
let fallback_probe_hits = Arc::new(Mutex::new(0usize));
+80
View File
@@ -383,6 +383,86 @@ async fn gateway_rejects_execution_runtime_loop_guarded_ai_request() {
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_shapes_execution_loop_rejections_for_claude_routes() {
let gateway = build_router().expect("gateway should build");
let (gateway_url, gateway_handle) = start_server(gateway).await;
for path in ["/v1/messages", "/v1/messages/count_tokens"] {
let response = reqwest::Client::new()
.post(format!("{gateway_url}{path}"))
.header(
EXECUTION_RUNTIME_LOOP_GUARD_HEADER,
EXECUTION_RUNTIME_LOOP_GUARD_VALUE,
)
.header(http::header::CONTENT_TYPE, "application/json")
.body(r#"{"model":"claude-sonnet-4","messages":[]}"#)
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::LOOP_DETECTED, "path: {path}");
assert_eq!(
response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok()),
Some(EXECUTION_PATH_LOCAL_EXECUTION_LOOP_DETECTED),
"path: {path}"
);
let payload: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(payload["type"], "error", "path: {path}");
assert_eq!(payload["error"]["type"], "api_error", "path: {path}");
assert_eq!(
payload["error"]["message"],
"Gateway detected an execution runtime request loop back into the local frontdoor",
"path: {path}"
);
}
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_shapes_wrong_method_rejections_for_claude_routes() {
let gateway = build_router().expect("gateway should build");
let (gateway_url, gateway_handle) = start_server(gateway).await;
for path in ["/v1/messages", "/v1/messages/count_tokens"] {
let response = reqwest::Client::new()
.get(format!("{gateway_url}{path}"))
.send()
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::METHOD_NOT_ALLOWED,
"path: {path}"
);
assert_eq!(
response
.headers()
.get(http::header::ALLOW)
.and_then(|value| value.to_str().ok()),
Some("POST"),
"path: {path}"
);
let payload: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(payload["type"], "error", "path: {path}");
assert_eq!(
payload["error"]["type"], "invalid_request_error",
"path: {path}"
);
assert_eq!(
payload["error"]["message"], "Method not allowed",
"path: {path}"
);
}
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_execution_runtime_via_guarded_ai_request() {
let gateway = build_router().expect("gateway should build");
+7 -3
View File
@@ -1121,7 +1121,8 @@ async fn gateway_records_failed_usage_for_claude_runtime_miss_without_execution_
Some("candidate_list_empty")
);
let body_json: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(body_json["error"]["type"], "http_error");
assert_eq!(body_json["type"], "error");
assert_eq!(body_json["error"]["type"], "overloaded_error");
assert_eq!(
body_json["error"]["message"],
"没有可用提供商支持模型 claude-sonnet-4-5 的同步请求"
@@ -1169,7 +1170,7 @@ async fn gateway_records_failed_usage_for_claude_runtime_miss_without_execution_
.and_then(|value| value.get("error"))
.and_then(|value| value.get("type"))
.and_then(|value| value.as_str()),
Some("http_error")
Some("overloaded_error")
);
let stored_candidates = request_candidate_repository
@@ -1749,6 +1750,7 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages?beta=true"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-claude-cli-usage-local-miss",
@@ -1768,7 +1770,8 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s
Some("all_candidates_skipped")
);
let body_json: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(body_json["error"]["type"], "http_error");
assert_eq!(body_json["type"], "error");
assert_eq!(body_json["error"]["type"], "overloaded_error");
assert_eq!(
body_json["error"]["message"],
"没有可用提供商支持模型 gpt-5.4 的同步请求"
@@ -2062,6 +2065,7 @@ fn gateway_keeps_failed_usage_request_capture_lightweight_for_large_local_claude
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/messages?beta=true"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-claude-cli-usage-local-miss-large",