mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
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:
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user