test(claude-code): 补充 Responses → claude_code 网关测试,验证上游请求体已伪装

This commit is contained in:
dalamudx
2026-09-29 20:27:43 +08:00
parent 5d5880d75e
commit fa06e9695d
@@ -2473,6 +2473,416 @@ async fn gateway_returns_openai_responses_error_for_local_cross_format_claude_sy
upstream_handle.abort();
}
#[test]
fn gateway_returns_openai_responses_to_claude_code_applies_body_mimicry() {
run_cli_sync_test(
"gateway_returns_openai_responses_to_claude_code_applies_body_mimicry",
gateway_returns_openai_responses_to_claude_code_applies_body_mimicry_impl,
);
}
async fn gateway_returns_openai_responses_to_claude_code_applies_body_mimicry_impl() {
#[derive(Debug, Clone)]
struct SeenExecutionRuntimeSyncRequest {
trace_id: String,
url: String,
authorization: String,
endpoint_tag: String,
body: serde_json::Value,
}
fn hash_api_key(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
format!("{:x}", hasher.finalize())
}
fn sample_auth_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
StoredAuthApiKeySnapshot::new(
user_id.to_string(),
"alice".to_string(),
Some("[email protected]".to_string()),
"user".to_string(),
"local".to_string(),
true,
false,
Some(serde_json::json!(["openai", "claude", "claude_code"])),
Some(serde_json::json!(["openai:responses"])),
Some(serde_json::json!(["gpt-5"])),
api_key_id.to_string(),
Some("default".to_string()),
true,
false,
false,
Some(60),
Some(5),
Some(4_102_444_800_i64),
Some(serde_json::json!(["openai", "claude", "claude_code"])),
Some(serde_json::json!(["openai:responses"])),
Some(serde_json::json!(["gpt-5"])),
)
.expect("auth snapshot should build")
}
fn sample_candidate_row() -> StoredMinimalCandidateSelectionRow {
StoredMinimalCandidateSelectionRow {
provider_id: "provider-openai-cli-claude-code-local-1".to_string(),
provider_name: "claude_code".to_string(),
provider_type: "claude_code".to_string(),
provider_priority: 10,
provider_is_active: true,
endpoint_id: "endpoint-openai-cli-claude-code-local-1".to_string(),
endpoint_api_format: "claude:messages".to_string(),
endpoint_api_family: Some("claude".to_string()),
endpoint_kind: Some("cli".to_string()),
endpoint_is_active: true,
key_id: "key-openai-cli-claude-code-local-1".to_string(),
key_name: "prod".to_string(),
key_auth_type: "oauth".to_string(),
key_is_active: true,
key_api_formats: Some(vec!["claude:messages".to_string()]),
key_allowed_models: None,
key_capabilities: None,
key_internal_priority: 5,
key_global_priority_by_format: Some(serde_json::json!({"claude:messages": 1})),
model_id: "model-openai-cli-claude-code-local-1".to_string(),
global_model_id: "global-model-openai-cli-claude-code-local-1".to_string(),
global_model_name: "gpt-5".to_string(),
global_model_mappings: None,
global_model_supports_streaming: Some(true),
model_provider_model_name: "claude-code-upstream".to_string(),
model_provider_model_mappings: Some(vec![StoredProviderModelMapping {
name: "claude-code-upstream".to_string(),
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
model_is_available: true,
}
}
fn sample_provider_catalog_provider() -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new(
"provider-openai-cli-claude-code-local-1".to_string(),
"claude_code".to_string(),
Some("https://example.com".to_string()),
"claude_code".to_string(),
)
.expect("provider should build")
.with_transport_fields(
true,
false,
true,
None,
Some(2),
None,
Some(20.0),
None,
Some(serde_json::json!({
"claude_code_advanced": {"cli_only_enabled": false},
"failover_rules": {
"stop_on_status_codes": [429]
}
})),
)
}
fn sample_provider_catalog_endpoint() -> StoredProviderCatalogEndpoint {
StoredProviderCatalogEndpoint::new(
"endpoint-openai-cli-claude-code-local-1".to_string(),
"provider-openai-cli-claude-code-local-1".to_string(),
"claude:messages".to_string(),
Some("claude".to_string()),
Some("cli".to_string()),
true,
)
.expect("endpoint should build")
.with_transport_fields(
"https://api.anthropic.com/v1".to_string(),
Some(serde_json::json!([
{"action":"set","key":"x-endpoint-tag","value":"openai-cli-claude-code-cross-format"}
])),
None,
Some(2),
None,
None,
None,
None,
)
.expect("endpoint transport should build")
}
fn sample_provider_catalog_key() -> StoredProviderCatalogKey {
StoredProviderCatalogKey::new(
"key-openai-cli-claude-code-local-1".to_string(),
"provider-openai-cli-claude-code-local-1".to_string(),
"prod".to_string(),
"oauth".to_string(),
None,
true,
)
.expect("key should build")
.with_transport_fields(
Some(serde_json::json!(["claude:messages"])),
encrypt_python_fernet_plaintext(
DEVELOPMENT_ENCRYPTION_KEY,
"sk-upstream-openai-cli-claude-code",
)
.expect("api key should encrypt"),
None,
None,
Some(serde_json::json!({"claude:messages": 1})),
None,
None,
None,
None,
)
.expect("key transport should build")
}
let seen_execution_runtime = Arc::new(Mutex::new(None::<SeenExecutionRuntimeSyncRequest>));
let seen_execution_runtime_clone = Arc::clone(&seen_execution_runtime);
let seen_report = Arc::new(Mutex::new(false));
let seen_report_clone = Arc::clone(&seen_report);
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
let upstream = Router::new()
.route(
"/api/internal/gateway/resolve",
any(|_request: Request| async move {
Json(json!({
"action": "proxy_public",
"route_class": "ai_public",
"route_family": "openai",
"route_kind": "cli",
"auth_endpoint_signature": "openai:responses",
"execution_runtime_candidate": true,
"auth_context": {
"user_id": "user-openai-cli-claude-code-local-error-123",
"api_key_id": "key-openai-cli-claude-code-local-error-123",
"access_allowed": true
},
"public_path": "/v1/responses"
}))
}),
)
.route(
"/api/internal/gateway/report-sync",
any(move |request: Request| {
let seen_report_inner = Arc::clone(&seen_report_clone);
async move {
let (_parts, body) = request.into_parts();
let _raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
*seen_report_inner.lock().expect("mutex should lock") = true;
Json(json!({"ok": true}))
}
}),
);
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(move |request: Request| {
let seen_execution_runtime_inner = Arc::clone(&seen_execution_runtime_clone);
async move {
let (parts, body) = request.into_parts();
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");
*seen_execution_runtime_inner
.lock()
.expect("mutex should lock") = Some(SeenExecutionRuntimeSyncRequest {
trace_id: parts
.headers
.get(TRACE_ID_HEADER)
.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(),
authorization: payload
.get("headers")
.and_then(|value| value.get("authorization"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
endpoint_tag: payload
.get("headers")
.and_then(|value| value.get("x-endpoint-tag"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
body: payload
.get("body")
.and_then(|value| value.get("json_body"))
.cloned()
.unwrap_or(serde_json::Value::Null),
});
Json(json!({
"request_id": "trace-openai-cli-claude-code-local-error-123",
"status_code": 429,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"type": "error",
"error": {
"type": "rate_limit_error",
"message": "slow down"
}
}
},
"telemetry": {
"elapsed_ms": 28
}
}))
}
}),
);
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-client-openai-cli-claude-code-error")),
sample_auth_snapshot(
"key-openai-cli-claude-code-local-error-123",
"user-openai-cli-claude-code-local-error-123",
),
)]));
let candidate_selection_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
sample_candidate_row(),
]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider_catalog_provider()],
vec![sample_provider_catalog_endpoint()],
vec![sample_provider_catalog_key()],
));
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let gateway_state =
build_state_with_execution_runtime_override(execution_runtime_url.clone())
.with_data_state_for_tests(
crate::data::GatewayDataState::with_auth_candidate_selection_provider_catalog_and_request_candidate_repository_for_tests(
auth_repository,
candidate_selection_repository,
provider_catalog_repository,
Arc::clone(&request_candidate_repository),
DEVELOPMENT_ENCRYPTION_KEY,
)
.attach_proxy_node_repository_for_tests(
crate::tests::ai_execute::ai_execute_proxy_node_repository([
"proxy-node-openai-cli-local",
]),
),
);
let gateway = build_router_with_state(gateway_state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/responses"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-openai-cli-claude-code-error",
)
.header(TRACE_ID_HEADER, "trace-openai-cli-claude-code-local-error-123")
.body(
"{\"model\":\"gpt-5\",\"instructions\":\"You are terse.\",\"input\":\"hello\",\"max_output_tokens\":64,\"store\":false}",
)
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok()),
Some(EXECUTION_PATH_EXECUTION_RUNTIME_SYNC)
);
let response_json: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(
response_json,
json!({
"error": {
"message": "slow down",
"type": "rate_limit_error"
}
})
);
let seen_execution_runtime_request = seen_execution_runtime
.lock()
.expect("mutex should lock")
.clone()
.expect("execution runtime sync should be captured");
assert_eq!(
seen_execution_runtime_request.trace_id,
"trace-openai-cli-claude-code-local-error-123"
);
assert_eq!(
seen_execution_runtime_request.url,
"https://api.anthropic.com/v1/messages"
);
assert_eq!(
seen_execution_runtime_request.authorization,
"Bearer sk-upstream-openai-cli-claude-code"
);
assert_eq!(
seen_execution_runtime_request.endpoint_tag,
"openai-cli-claude-code-cross-format"
);
let body = &seen_execution_runtime_request.body;
assert_eq!(body["model"], "claude-code-upstream");
// claude_code providers get the Claude Code body shape regardless of client format.
let system = body["system"]
.as_array()
.expect("claude_code system should be rewritten into blocks");
assert_eq!(system.len(), 3);
assert!(system[0]["text"]
.as_str()
.is_some_and(|text| text.starts_with("x-anthropic-billing-header: cc_version=")));
assert_eq!(
system[1]["text"],
"You are Claude Code, Anthropic's official CLI for Claude."
);
let messages = body["messages"].as_array().expect("messages should exist");
assert_eq!(messages.len(), 3);
assert_eq!(messages[0]["role"], "user");
assert!(messages[0].to_string().contains("[System Instructions]"));
assert!(messages[0].to_string().contains("You are terse."));
assert_eq!(messages[1]["role"], "assistant");
assert_eq!(messages[2]["role"], "user");
assert!(messages[2].to_string().contains("hello"));
assert!(body["metadata"]["user_id"]
.as_str()
.is_some_and(|user_id| user_id.contains("\"session_id\"")));
let stored_candidates = request_candidate_repository
.list_by_request_id("trace-openai-cli-claude-code-local-error-123")
.await
.expect("request candidate trace should read");
assert_eq!(stored_candidates.len(), 1);
assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Failed);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(
!*seen_report.lock().expect("mutex should lock"),
"report-sync should stay local when request candidate persistence is available"
);
gateway_handle.abort();
execution_runtime_handle.abort();
upstream_handle.abort();
}
#[test]
fn gateway_returns_openai_responses_error_for_local_cross_format_claude_chat_sync_failure() {
run_cli_sync_test(