diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/cli.rs b/apps/aether-gateway/src/tests/ai_execute/sync/cli.rs index 3fce6a6c7..a8e5c8acd 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/cli.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/cli.rs @@ -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("alice@example.com".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::)); + 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(