mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
test(claude-code): 补充 Responses → claude_code 网关测试,验证上游请求体已伪装
This commit is contained in:
@@ -2473,6 +2473,416 @@ async fn gateway_returns_openai_responses_error_for_local_cross_format_claude_sy
|
|||||||
upstream_handle.abort();
|
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]
|
#[test]
|
||||||
fn gateway_returns_openai_responses_error_for_local_cross_format_claude_chat_sync_failure() {
|
fn gateway_returns_openai_responses_error_for_local_cross_format_claude_chat_sync_failure() {
|
||||||
run_cli_sync_test(
|
run_cli_sync_test(
|
||||||
|
|||||||
Reference in New Issue
Block a user