diff --git a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs index 582c23342..4bd50da98 100644 --- a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request.rs @@ -314,6 +314,20 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts( return Ok(None); } + // Same-format requests skip `apply_transport_request_body_semantics`, so the opt-in + // Claude Code body mimicry has to be applied here as well. + if crate::ai_serving::transport::claude_code::apply_claude_code_body_mimicry_for_transport( + &mut base_provider_request_body, + &transport, + prepared.provider_api_format.as_str(), + ) { + compatibility_edits.push(SameFormatProviderCompatibilityEdit { + field: "body".to_string(), + action: SameFormatProviderCompatibilityEditAction::ProviderCompatibilityRewrite, + detail: "applied Claude Code body mimicry for provider compatibility".to_string(), + }); + } + let antigravity_auth = if prepared.is_antigravity { let mut antigravity_support = classify_local_antigravity_request_support( &transport, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs index ce719f126..87d17fd6d 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs @@ -140,7 +140,7 @@ fn finalize_openai_chat_provider_request_body( mapped_model, source_model, ); - crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( + let finalization_failure = crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy( provider_request_body, crate::ai_serving::OpenAiProviderRequestFinalization { source_api_format: "openai:chat", @@ -170,7 +170,17 @@ fn finalize_openai_chat_provider_request_body( provider_api_format, "openai_chat_request_finalization", ) - }) + }); + if finalization_failure.is_none() { + // This builder does not go through `apply_transport_request_body_semantics`, so the + // Claude Code body mimicry must be applied here for Chat -> claude_code requests. + crate::ai_serving::transport::claude_code::apply_claude_code_body_mimicry_for_transport( + provider_request_body, + transport, + provider_api_format, + ); + } + finalization_failure } #[allow(clippy::too_many_arguments)] diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs index a9170ce59..0a52493bc 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs @@ -635,6 +635,13 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts_with_ { log_responses_to_chat_tool_conversion(trace_id, body_json, &base_provider_request_body); } + // This builder does not go through `apply_transport_request_body_semantics`, so the + // Claude Code body mimicry must be applied here for Responses -> claude_code requests. + crate::ai_serving::transport::claude_code::apply_claude_code_body_mimicry_for_transport( + &mut base_provider_request_body, + &transport, + provider_api_format, + ); let provider_request_body = base_provider_request_body; if let Some(kiro_auth) = kiro_auth.as_ref() { diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_provider.rs b/apps/aether-gateway/src/tests/ai_execute/stream_provider.rs index 3f5963bc1..27c72990b 100644 --- a/apps/aether-gateway/src/tests/ai_execute/stream_provider.rs +++ b/apps/aether-gateway/src/tests/ai_execute/stream_provider.rs @@ -1585,7 +1585,7 @@ async fn gateway_executes_claude_code_cli_stream_via_local_decision_gate_with_lo ); assert_eq!( seen_execution_runtime_request.user_agent, - "claude-cli/2.1.161 (external, cli)" + "claude-cli/2.1.284 (external, cli)" ); assert_eq!( seen_execution_runtime_request.endpoint_tag, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs b/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs index 9d97ebd83..71465d9f3 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs @@ -2028,14 +2028,30 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn "claude-code-upstream" ); assert_eq!(seen_execution_runtime_request.body["max_tokens"], 64); + // claude_code providers get the Claude Code body shape: billing header + identity + + // generic prompt, with the client's own system moved into the message history. + let system = seen_execution_runtime_request.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!( - seen_execution_runtime_request.body["system"], - "You are terse." + system[1]["text"], + "You are Claude Code, Anthropic's official CLI for Claude." ); assert_eq!( seen_execution_runtime_request.body["messages"], - json!([{"role":"user","content":"Say hello"}]) + json!([ + {"role":"user","content":[{"type":"text","text":"[System Instructions]\nYou are terse."}]}, + {"role":"assistant","content":[{"type":"text","text":"Understood. I will follow these instructions."}]}, + {"role":"user","content":"Say hello"} + ]) ); + assert!(seen_execution_runtime_request.body["metadata"]["user_id"] + .as_str() + .is_some_and(|user_id| user_id.contains("\"session_id\""))); assert_eq!( seen_execution_runtime_request.client_api_format, "openai:chat" diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs b/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs index 4665f0b51..cf62688b4 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs @@ -567,11 +567,11 @@ async fn gateway_executes_claude_code_cli_sync_via_local_decision_gate_with_loca assert_eq!(seen_execution_runtime_request.x_stainless_helper_method, ""); assert_eq!( seen_execution_runtime_request.x_stainless_package_version, - "0.94.0" + "0.112.1" ); assert_eq!( seen_execution_runtime_request.user_agent, - "claude-cli/2.1.161 (external, cli)" + "claude-cli/2.1.284 (external, cli)" ); assert_eq!( seen_execution_runtime_request.endpoint_tag, 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( diff --git a/crates/aether-provider/transport/src/claude_code/fingerprint.rs b/crates/aether-provider/transport/src/claude_code/fingerprint.rs index c72e58e93..6796cdb9d 100644 --- a/crates/aether-provider/transport/src/claude_code/fingerprint.rs +++ b/crates/aether-provider/transport/src/claude_code/fingerprint.rs @@ -117,11 +117,11 @@ mod tests { let map = header_fingerprint_from_fingerprint(&fp).expect("header fingerprint"); assert_eq!(map["identity_profile_version"], "2026-04"); - assert_eq!(map["cli_version"], "2.1.161"); - assert_eq!(map["billing_cli_version"], "2.1.161"); - assert_eq!(map["stainless_package_version"], "0.94.0"); - assert_eq!(map["stainless_runtime_version"], "v24.3.0"); - assert_eq!(map["user_agent"], "claude-cli/2.1.161 (external, cli)"); + assert_eq!(map["cli_version"], "2.1.284"); + assert_eq!(map["billing_cli_version"], "2.1.284"); + assert_eq!(map["stainless_package_version"], "0.112.1"); + assert_eq!(map["stainless_runtime_version"], "v26.3.0"); + assert_eq!(map["user_agent"], "claude-cli/2.1.284 (external, cli)"); assert_eq!( fp["transport_profile"]["extra"]["claude_code_identity_profile_version"], "2026-04" @@ -144,9 +144,9 @@ mod tests { let sanitized = sanitize_fingerprint(&raw, "test-key"); let map = header_fingerprint_from_fingerprint(&sanitized).expect("header fingerprint"); - assert_eq!(map["stainless_package_version"], "0.94.0"); - assert_eq!(map["stainless_runtime_version"], "v24.3.0"); - assert_eq!(map["user_agent"], "claude-cli/2.1.161 (external, cli)"); + assert_eq!(map["stainless_package_version"], "0.112.1"); + assert_eq!(map["stainless_runtime_version"], "v26.3.0"); + assert_eq!(map["user_agent"], "claude-cli/2.1.284 (external, cli)"); assert_eq!(map["vscode_session_id"], "existing-session"); } diff --git a/crates/aether-provider/transport/src/claude_code/mimicry.rs b/crates/aether-provider/transport/src/claude_code/mimicry.rs new file mode 100644 index 000000000..4ead9b97a --- /dev/null +++ b/crates/aether-provider/transport/src/claude_code/mimicry.rs @@ -0,0 +1,674 @@ +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +use super::profile::current_claude_code_transport_identity_profile; +use crate::snapshot::GatewayProviderTransportSnapshot; + +const BILLING_HEADER_PREFIX: &str = "x-anthropic-billing-header"; +const BILLING_ENTRYPOINT_MARKER: &str = "cc_entrypoint="; +const CLAUDE_CODE_SYSTEM_PROMPT: &str = "You are Claude Code, Anthropic's official CLI for Claude."; +const FINGERPRINT_SALT: &str = "59cf53e54c78"; +const FINGERPRINT_CHAR_INDICES: [usize; 3] = [4, 7, 20]; +const DEFAULT_MAX_TOKENS: u64 = 128_000; +const MAX_CACHE_CONTROL_BLOCKS: usize = 4; +const SYSTEM_INSTRUCTIONS_ACK: &str = "Understood. I will follow these instructions."; +const DEVICE_ID_SEED_NAMESPACE: &str = "aether-claude-code-device"; + +const CLAUDE_CODE_PROMPT_PREFIXES: [&str; 4] = [ + "You are Claude Code, Anthropic's official CLI for Claude", + "You are a Claude agent, built on Anthropic's Claude Agent SDK", + "You are a file search specialist for Claude Code", + "You are a helpful AI assistant tasked with summarizing conversations", +]; + +/// Tool-agnostic part of the real Claude Code system prompt. It brings the system block +/// count and size close to genuine CLI traffic without injecting tool-specific instructions. +const CLAUDE_CODE_SYSTEM_PROMPT_EXPANSION: &str = r#"You are an interactive agent that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. + +IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases. +IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. + +# Tone and style + - Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. + - Your responses should be short and concise. + - When referencing specific functions or pieces of code include the pattern file_path:line_number to allow the user to easily navigate to the source code location. + - When referencing GitHub issues or pull requests, use the owner/repo#123 format (e.g. anthropics/claude-code#100) so they render as clickable links. + - Do not use a colon before tool calls. Your tool calls may not be shown directly in the output, so text like "Let me read the file:" followed by a read tool call should just be "Let me read the file." with a period."#; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClaudeCodeBodyMimicryContext<'a> { + pub key_id: &'a str, + pub device_id: Option<&'a str>, + pub account_uuid: Option<&'a str>, +} + +/// Applies Claude Code body mimicry for a claude_code provider transport (claude:messages only). +/// Returns whether the body was changed. +pub fn apply_claude_code_body_mimicry_for_transport( + provider_request_body: &mut Value, + transport: &GatewayProviderTransportSnapshot, + provider_api_format: &str, +) -> bool { + if !transport + .provider + .provider_type + .trim() + .eq_ignore_ascii_case("claude_code") + || !aether_ai_formats::normalize_api_format_alias(provider_api_format) + .eq_ignore_ascii_case("claude:messages") + { + return false; + } + + let auth_config = transport + .key + .decrypted_auth_config + .as_deref() + .and_then(|raw| serde_json::from_str::(raw).ok()); + let auth_config_str = |field: &str| { + auth_config + .as_ref() + .and_then(|config| config.get(field)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }; + let device_id = auth_config_str("device_id"); + let account_uuid = auth_config_str("account_uuid"); + + apply_claude_code_body_mimicry( + provider_request_body, + ClaudeCodeBodyMimicryContext { + key_id: transport.key.id.as_str(), + device_id: device_id.as_deref(), + account_uuid: account_uuid.as_deref(), + }, + ) +} + +/// Makes a non-Claude-Code request body look like genuine Claude Code traffic so it is +/// accepted together with the Claude Code identity headers on OAuth credentials. +/// The operation is idempotent: a body that already carries the billing block and +/// `metadata.user_id` is treated as genuine and left untouched. +pub fn apply_claude_code_body_mimicry( + body: &mut Value, + context: ClaudeCodeBodyMimicryContext<'_>, +) -> bool { + let before = body.clone(); + let Some(object) = body.as_object_mut() else { + return false; + }; + if is_genuine_claude_code_body(object) { + return false; + } + + let profile = *current_claude_code_transport_identity_profile(); + let model = object + .get("model") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + let first_user_text = first_user_text(object); + + rewrite_system_and_migrate_instructions( + object, + profile.billing_cli_version(), + &first_user_text, + model.contains("fable"), + ); + inject_metadata_user_id(object, &context, &first_user_text); + fill_fixed_fields(object, &model); + enforce_cache_control_limit(object); + + *body != before +} + +fn is_genuine_claude_code_body(body: &Map) -> bool { + let has_user_id = body + .get("metadata") + .and_then(|metadata| metadata.get("user_id")) + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + has_user_id + && body + .get("system") + .and_then(Value::as_array) + .is_some_and(|system| { + system.iter().any(|block| { + block + .get("text") + .and_then(Value::as_str) + .is_some_and(|text| { + text.starts_with(BILLING_HEADER_PREFIX) + && text.contains(BILLING_ENTRYPOINT_MARKER) + }) + }) + }) +} + +fn has_claude_code_prefix(text: &str) -> bool { + let text = text.trim_start(); + CLAUDE_CODE_PROMPT_PREFIXES + .iter() + .any(|prefix| text.starts_with(prefix)) +} + +fn first_user_text(body: &Map) -> String { + let Some(message) = body + .get("messages") + .and_then(Value::as_array) + .and_then(|messages| { + messages + .iter() + .find(|message| message.get("role").and_then(Value::as_str) == Some("user")) + }) + else { + return String::new(); + }; + match message.get("content") { + Some(Value::String(text)) => text.clone(), + Some(Value::Array(blocks)) => blocks + .iter() + .find(|block| block.get("type").and_then(Value::as_str) == Some("text")) + .and_then(|block| block.get("text")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + _ => String::new(), + } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Three hex chars derived from the first user text, matching the Claude Code CLI +/// billing attribution fingerprint. Indexing is by byte, out-of-range yields `'0'`. +fn claude_code_fingerprint(first_user_text: &str, cli_version: &str) -> String { + let bytes = first_user_text.as_bytes(); + let picked = FINGERPRINT_CHAR_INDICES + .iter() + .map(|index| bytes.get(*index).copied().unwrap_or(b'0')) + .collect::>(); + let mut hasher = Sha256::new(); + hasher.update(FINGERPRINT_SALT.as_bytes()); + hasher.update(&picked); + hasher.update(cli_version.as_bytes()); + hex(&hasher.finalize())[..3].to_string() +} + +fn billing_header_text(first_user_text: &str, cli_version: &str) -> String { + format!( + "{BILLING_HEADER_PREFIX}: cc_version={cli_version}.{}; cc_entrypoint=cli;", + claude_code_fingerprint(first_user_text, cli_version) + ) +} + +/// Returns the joined original system text plus the cache_control of its last block that has one. +fn extract_system_text(system: Option<&Value>) -> (String, Option) { + match system { + Some(Value::String(text)) => (text.clone(), None), + Some(Value::Array(blocks)) => { + let mut parts = Vec::new(); + let mut cache_control = None; + for block in blocks { + if let Some(text) = block.get("text").and_then(Value::as_str) { + if !text.trim().is_empty() { + parts.push(text.to_string()); + } + } + if let Some(value) = block.get("cache_control").filter(|value| !value.is_null()) { + cache_control = Some(value.clone()); + } + } + (parts.join("\n\n"), cache_control) + } + _ => (String::new(), None), + } +} + +fn rewrite_system_and_migrate_instructions( + body: &mut Map, + cli_version: &str, + first_user_text: &str, + identity_only: bool, +) { + let (original_text, original_cache_control) = extract_system_text(body.get("system")); + + let mut system = vec![ + json!({"type": "text", "text": billing_header_text(first_user_text, cli_version)}), + json!({"type": "text", "text": CLAUDE_CODE_SYSTEM_PROMPT}), + ]; + if !identity_only { + system.push(json!({ + "type": "text", + "text": CLAUDE_CODE_SYSTEM_PROMPT_EXPANSION, + "cache_control": {"type": "ephemeral", "ttl": "5m"}, + })); + } + body.insert("system".to_string(), Value::Array(system)); + + let original_text = original_text.trim(); + if original_text.is_empty() + || original_text == CLAUDE_CODE_SYSTEM_PROMPT + || has_claude_code_prefix(original_text) + { + return; + } + + let mut instruction_block = json!({ + "type": "text", + "text": format!("[System Instructions]\n{original_text}"), + }); + if let Some(cache_control) = original_cache_control { + instruction_block["cache_control"] = cache_control; + } + let mut messages = vec![ + json!({"role": "user", "content": [instruction_block]}), + json!({"role": "assistant", "content": [{"type": "text", "text": SYSTEM_INSTRUCTIONS_ACK}]}), + ]; + if let Some(Value::Array(original)) = body.remove("messages") { + messages.extend(original); + } + body.insert("messages".to_string(), Value::Array(messages)); +} + +fn stable_device_id(context: &ClaudeCodeBodyMimicryContext<'_>) -> String { + if let Some(device_id) = context.device_id.filter(|value| !value.is_empty()) { + return device_id.to_string(); + } + let mut hasher = Sha256::new(); + hasher.update(format!("{DEVICE_ID_SEED_NAMESPACE}::{}", context.key_id).as_bytes()); + hex(&hasher.finalize()) +} + +/// Session id that stays stable while a conversation grows: seeded by key and first user text. +fn stable_session_id(key_id: &str, first_user_text: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(format!("{key_id}::{first_user_text}").as_bytes()); + let digest = hasher.finalize(); + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&digest[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + format!( + "{}-{}-{}-{}-{}", + hex(&bytes[0..4]), + hex(&bytes[4..6]), + hex(&bytes[6..8]), + hex(&bytes[8..10]), + hex(&bytes[10..16]), + ) +} + +fn inject_metadata_user_id( + body: &mut Map, + context: &ClaudeCodeBodyMimicryContext<'_>, + first_user_text: &str, +) { + let has_user_id = body + .get("metadata") + .and_then(|metadata| metadata.get("user_id")) + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + if has_user_id { + return; + } + // Built by hand to keep the key order of the real CLI (serde_json sorts keys). + let json_string = |value: &str| Value::String(value.to_string()).to_string(); + let user_id = format!( + "{{\"device_id\":{},\"account_uuid\":{},\"session_id\":{}}}", + json_string(&stable_device_id(context)), + json_string(context.account_uuid.unwrap_or_default()), + json_string(&stable_session_id(context.key_id, first_user_text)), + ); + match body.get_mut("metadata").and_then(Value::as_object_mut) { + Some(metadata) => { + metadata.insert("user_id".to_string(), Value::String(user_id)); + } + None => { + body.insert("metadata".to_string(), json!({"user_id": user_id})); + } + } +} + +fn is_opus_5_5(model: &str) -> bool { + model.contains("opus-5-5") || model.contains("opus-5.5") +} + +fn is_signed_thinking_5_5(model: &str) -> bool { + is_opus_5_5(model) || model.contains("sonnet-5-5") || model.contains("sonnet-5.5") +} + +fn fill_fixed_fields(body: &mut Map, model: &str) { + body.entry("tools".to_string()).or_insert_with(|| json!([])); + if !body.contains_key("temperature") && !is_opus_5_5(model) { + body.insert("temperature".to_string(), json!(1)); + } + body.entry("max_tokens".to_string()) + .or_insert_with(|| json!(DEFAULT_MAX_TOKENS)); + + let tools_empty = body + .get("tools") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty); + if tools_empty && !is_signed_thinking_5_5(model) { + body.remove("tool_choice"); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CacheControlSlot { + Tool(usize), + Message(usize, usize), + System(usize), +} + +fn cache_control_slots(body: &Map) -> Vec { + let has = |value: &Value| value.get("cache_control").is_some_and(|cc| !cc.is_null()); + let mut slots = Vec::new(); + let collect = |key: &str, make: &dyn Fn(usize) -> CacheControlSlot, slots: &mut Vec<_>| { + if let Some(items) = body.get(key).and_then(Value::as_array) { + for (index, item) in items.iter().enumerate() { + if has(item) { + slots.push(make(index)); + } + } + } + }; + collect("tools", &CacheControlSlot::Tool, &mut slots); + if let Some(messages) = body.get("messages").and_then(Value::as_array) { + for (message_index, message) in messages.iter().enumerate() { + if let Some(blocks) = message.get("content").and_then(Value::as_array) { + for (block_index, block) in blocks.iter().enumerate() { + if has(block) { + slots.push(CacheControlSlot::Message(message_index, block_index)); + } + } + } + } + } + collect("system", &CacheControlSlot::System, &mut slots); + slots +} + +fn remove_cache_control(body: &mut Map, slot: &CacheControlSlot) { + let target = match slot { + CacheControlSlot::Tool(index) => body + .get_mut("tools") + .and_then(|tools| tools.get_mut(*index)), + CacheControlSlot::Message(message, block) => body + .get_mut("messages") + .and_then(|messages| messages.get_mut(*message)) + .and_then(|message| message.get_mut("content")) + .and_then(|content| content.get_mut(*block)), + CacheControlSlot::System(index) => body + .get_mut("system") + .and_then(|system| system.get_mut(*index)), + }; + if let Some(object) = target.and_then(Value::as_object_mut) { + object.remove("cache_control"); + } +} + +/// Anthropic accepts at most 4 cache breakpoints. Drop the excess in the same order the +/// upstream-friendly proxy does: tools (last first), messages (first first), system (last first). +fn enforce_cache_control_limit(body: &mut Map) { + let slots = cache_control_slots(body); + if slots.len() <= MAX_CACHE_CONTROL_BLOCKS { + return; + } + let mut excess = slots.len() - MAX_CACHE_CONTROL_BLOCKS; + let tools = slots + .iter() + .filter(|slot| matches!(slot, CacheControlSlot::Tool(_))) + .rev(); + let messages = slots + .iter() + .filter(|slot| matches!(slot, CacheControlSlot::Message(..))); + let system = slots + .iter() + .filter(|slot| matches!(slot, CacheControlSlot::System(_))) + .rev(); + for slot in tools.chain(messages).chain(system) { + if excess == 0 { + break; + } + remove_cache_control(body, slot); + excess -= 1; + } +} + +#[cfg(test)] +mod tests { + use serde_json::{json, Value}; + + use super::{ + apply_claude_code_body_mimicry, claude_code_fingerprint, stable_session_id, + ClaudeCodeBodyMimicryContext, CLAUDE_CODE_SYSTEM_PROMPT, + }; + + const CONTEXT: ClaudeCodeBodyMimicryContext<'static> = ClaudeCodeBodyMimicryContext { + key_id: "key-1", + device_id: None, + account_uuid: Some("acct-1"), + }; + + fn pi_body() -> Value { + json!({ + "model": "claude-sonnet-5-5", + "max_tokens": 4096, + "stream": true, + "system": [{ + "type": "text", + "text": "You are an expert coding assistant operating inside pi.", + "cache_control": {"type": "ephemeral"} + }], + "messages": [{"role": "user", "content": "hello world, please help"}], + "tools": [{"name": "fabric_exec", "input_schema": {"type": "object"}}], + "thinking": {"type": "adaptive"} + }) + } + + #[test] + fn rewrites_system_into_claude_code_blocks_and_migrates_original_instructions() { + let mut body = pi_body(); + assert!(apply_claude_code_body_mimicry(&mut body, CONTEXT)); + + let system = body["system"].as_array().unwrap(); + assert_eq!(system.len(), 3); + let billing = system[0]["text"].as_str().unwrap(); + assert!(billing.starts_with("x-anthropic-billing-header: cc_version=2.1.284.")); + assert!(billing.ends_with("; cc_entrypoint=cli;")); + assert!(system[0].get("cache_control").is_none()); + assert_eq!(system[1]["text"], CLAUDE_CODE_SYSTEM_PROMPT); + assert!(system[1].get("cache_control").is_none()); + assert_eq!(system[2]["cache_control"]["type"], "ephemeral"); + assert_eq!(system[2]["cache_control"]["ttl"], "5m"); + + let messages = body["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 3); + assert_eq!( + messages[0]["content"][0]["text"], + "[System Instructions]\nYou are an expert coding assistant operating inside pi." + ); + assert_eq!( + messages[0]["content"][0]["cache_control"]["type"], + "ephemeral" + ); + assert_eq!(messages[1]["role"], "assistant"); + assert_eq!(messages[2]["content"], "hello world, please help"); + } + + #[test] + fn fingerprint_uses_original_first_user_text_not_migrated_instructions() { + let mut body = pi_body(); + apply_claude_code_body_mimicry(&mut body, CONTEXT); + let expected = claude_code_fingerprint("hello world, please help", "2.1.284"); + assert!(body["system"][0]["text"] + .as_str() + .unwrap() + .contains(&format!("cc_version=2.1.284.{expected};"))); + } + + #[test] + fn fingerprint_is_three_hex_chars_and_pads_short_text_with_zero() { + let fp = claude_code_fingerprint("", "2.1.284"); + assert_eq!(fp.len(), 3); + assert!(fp.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(fp, claude_code_fingerprint("abc", "2.1.284")); + assert_ne!( + claude_code_fingerprint("0123456789012345678901234", "2.1.284"), + claude_code_fingerprint("", "2.1.284") + ); + } + + #[test] + fn injects_metadata_user_id_in_cli_key_order_with_stable_ids() { + let mut first = pi_body(); + let mut second = pi_body(); + apply_claude_code_body_mimicry(&mut first, CONTEXT); + apply_claude_code_body_mimicry(&mut second, CONTEXT); + + let user_id = first["metadata"]["user_id"].as_str().unwrap(); + assert!(user_id.starts_with("{\"device_id\":\"")); + let parsed: Value = serde_json::from_str(user_id).unwrap(); + assert_eq!(parsed["device_id"].as_str().unwrap().len(), 64); + assert_eq!(parsed["account_uuid"], "acct-1"); + assert_eq!(parsed["session_id"].as_str().unwrap().len(), 36); + assert_eq!(first["metadata"], second["metadata"]); + assert_eq!( + parsed["session_id"], + stable_session_id("key-1", "hello world, please help") + ); + } + + #[test] + fn keeps_client_metadata_user_id() { + let mut body = pi_body(); + body["metadata"] = json!({"user_id": "client-provided"}); + apply_claude_code_body_mimicry(&mut body, CONTEXT); + assert_eq!(body["metadata"]["user_id"], "client-provided"); + } + + #[test] + fn is_idempotent_and_leaves_genuine_claude_code_bodies_untouched() { + let mut body = pi_body(); + assert!(apply_claude_code_body_mimicry(&mut body, CONTEXT)); + let once = body.clone(); + assert!(!apply_claude_code_body_mimicry(&mut body, CONTEXT)); + assert_eq!(body, once); + + let mut genuine = json!({ + "model": "claude-sonnet-5-5", + "system": [ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.284.abc; cc_entrypoint=cli;"}, + {"type": "text", "text": CLAUDE_CODE_SYSTEM_PROMPT} + ], + "metadata": {"user_id": "{\"device_id\":\"d\",\"account_uuid\":\"\",\"session_id\":\"s\"}"}, + "messages": [{"role": "user", "content": "hi"}] + }); + let before = genuine.clone(); + assert!(!apply_claude_code_body_mimicry(&mut genuine, CONTEXT)); + assert_eq!(genuine, before); + } + + #[test] + fn does_not_duplicate_system_that_already_carries_claude_code_identity() { + let mut body = pi_body(); + body["system"] = json!(CLAUDE_CODE_SYSTEM_PROMPT); + apply_claude_code_body_mimicry(&mut body, CONTEXT); + assert_eq!(body["messages"].as_array().unwrap().len(), 1); + assert_eq!(body["system"].as_array().unwrap().len(), 3); + } + + #[test] + fn fable_models_only_get_billing_and_identity_blocks() { + let mut body = pi_body(); + body["model"] = json!("claude-fable-5-1"); + apply_claude_code_body_mimicry(&mut body, CONTEXT); + assert_eq!(body["system"].as_array().unwrap().len(), 2); + } + + #[test] + fn fills_fixed_fields_only_when_missing() { + let mut body = json!({ + "model": "claude-sonnet-5-5", + "messages": [{"role": "user", "content": "hi"}], + "tool_choice": {"type": "auto"} + }); + apply_claude_code_body_mimicry(&mut body, CONTEXT); + assert_eq!(body["tools"], json!([])); + assert_eq!(body["temperature"], 1); + assert_eq!(body["max_tokens"], 128000); + // Sonnet 5.5 keeps tool_choice even without tools. + assert!(body.get("tool_choice").is_some()); + + let mut body = json!({ + "model": "claude-opus-5-5", + "temperature": 0.2, + "max_tokens": 10, + "messages": [{"role": "user", "content": "hi"}] + }); + apply_claude_code_body_mimicry(&mut body, CONTEXT); + assert_eq!(body["temperature"], 0.2); + assert_eq!(body["max_tokens"], 10); + + let mut body = json!({ + "model": "claude-opus-5-5", + "messages": [{"role": "user", "content": "hi"}] + }); + apply_claude_code_body_mimicry(&mut body, CONTEXT); + assert!(body.get("temperature").is_none()); + + let mut body = json!({ + "model": "claude-opus-4-6", + "messages": [{"role": "user", "content": "hi"}], + "tool_choice": {"type": "auto"} + }); + apply_claude_code_body_mimicry(&mut body, CONTEXT); + assert!(body.get("tool_choice").is_none()); + } + + #[test] + fn caps_cache_control_breakpoints_at_four() { + let mut body = pi_body(); + body["tools"] = json!([ + {"name": "a", "cache_control": {"type": "ephemeral"}}, + {"name": "b", "cache_control": {"type": "ephemeral"}} + ]); + body["messages"] = json!([ + {"role": "user", "content": [{"type": "text", "text": "hello world, please help", "cache_control": {"type": "ephemeral"}}]}, + {"role": "user", "content": [{"type": "text", "text": "again", "cache_control": {"type": "ephemeral"}}]} + ]); + apply_claude_code_body_mimicry(&mut body, CONTEXT); + + let count = |value: &Value| value.get("cache_control").is_some() as usize; + let total: usize = body["tools"] + .as_array() + .unwrap() + .iter() + .map(count) + .sum::() + + body["system"] + .as_array() + .unwrap() + .iter() + .map(count) + .sum::() + + body["messages"] + .as_array() + .unwrap() + .iter() + .flat_map(|message| message["content"].as_array().cloned().unwrap_or_default()) + .map(|block| count(&block)) + .sum::(); + assert_eq!(total, 4); + // 6 breakpoints (2 tools, 3 messages incl. migrated instructions, 1 system): the two + // excess ones come from the tools, last first, so both tool breakpoints go. + assert!(body["tools"][0].get("cache_control").is_none()); + assert!(body["tools"][1].get("cache_control").is_none()); + assert!(body["system"][2].get("cache_control").is_some()); + } +} diff --git a/crates/aether-provider/transport/src/claude_code/mod.rs b/crates/aether-provider/transport/src/claude_code/mod.rs index 7317f54a0..28df0f09e 100644 --- a/crates/aether-provider/transport/src/claude_code/mod.rs +++ b/crates/aether-provider/transport/src/claude_code/mod.rs @@ -1,5 +1,6 @@ mod auth; mod fingerprint; +mod mimicry; mod policy; mod profile; mod request; @@ -10,6 +11,10 @@ pub use fingerprint::{ generate_fingerprint, generate_random_fingerprint, header_fingerprint_from_fingerprint, sanitize_fingerprint, }; +pub use mimicry::{ + apply_claude_code_body_mimicry, apply_claude_code_body_mimicry_for_transport, + ClaudeCodeBodyMimicryContext, +}; pub use policy::{ local_claude_code_transport_unsupported_reason_with_network, supports_local_claude_code_transport_with_network, diff --git a/crates/aether-provider/transport/src/claude_code/profile.rs b/crates/aether-provider/transport/src/claude_code/profile.rs index 45d15d340..8778ea8a2 100644 --- a/crates/aether-provider/transport/src/claude_code/profile.rs +++ b/crates/aether-provider/transport/src/claude_code/profile.rs @@ -82,13 +82,13 @@ pub const CLAUDE_CODE_TRANSPORT_IDENTITY_2026_04: ClaudeCodeTransportIdentityPro version: ClaudeCodeTransportIdentityProfileVersion::V2026_04, transport_profile_id: "claude_code_nodejs", anthropic_version: "2023-06-01", - cli_version: "2.1.161", + cli_version: "2.1.284", stainless_lang: "js", - stainless_package_version: "0.94.0", + stainless_package_version: "0.112.1", stainless_os: "Linux", stainless_arch: "arm64", stainless_runtime: "node", - stainless_runtime_version: "v24.3.0", + stainless_runtime_version: "v26.3.0", stainless_retry_count: "0", stainless_timeout: "600", message_required_betas: MESSAGE_BETAS_2026_04, @@ -286,14 +286,14 @@ mod tests { let profile = *current_claude_code_transport_identity_profile(); assert_eq!(profile.version().as_str(), "2026-04"); - assert_eq!(profile.cli_version(), "2.1.161"); + assert_eq!(profile.cli_version(), "2.1.284"); assert_eq!(profile.billing_cli_version(), profile.cli_version()); assert_eq!( profile.user_agent(), format!("claude-cli/{} (external, cli)", profile.cli_version()) ); - assert_eq!(profile.stainless_package_version(), "0.94.0"); - assert_eq!(profile.stainless_runtime_version(), "v24.3.0"); + assert_eq!(profile.stainless_package_version(), "0.112.1"); + assert_eq!(profile.stainless_runtime_version(), "v26.3.0"); } #[test] diff --git a/crates/aether-provider/transport/src/claude_code/request.rs b/crates/aether-provider/transport/src/claude_code/request.rs index 8e07352c7..8e2e15c6d 100644 --- a/crates/aether-provider/transport/src/claude_code/request.rs +++ b/crates/aether-provider/transport/src/claude_code/request.rs @@ -252,11 +252,11 @@ mod tests { assert_eq!(built.get("x-app").map(String::as_str), Some("cli")); assert_eq!( built.get("x-stainless-package-version").map(String::as_str), - Some("0.94.0") + Some("0.112.1") ); assert_eq!( built.get("x-stainless-runtime-version").map(String::as_str), - Some("v24.3.0") + Some("v26.3.0") ); assert_eq!( built.get("x-stainless-timeout").map(String::as_str), @@ -264,7 +264,7 @@ mod tests { ); assert_eq!( built.get("user-agent").map(String::as_str), - Some("claude-cli/2.1.161 (external, cli)") + Some("claude-cli/2.1.284 (external, cli)") ); assert_eq!( built.get("authorization").map(String::as_str), @@ -367,7 +367,7 @@ mod tests { assert_eq!( body["system"][0]["text"], - "x-anthropic-billing-header: cc_version=2.1.161.abc; cc_entrypoint=cli;" + "x-anthropic-billing-header: cc_version=2.1.284.abc; cc_entrypoint=cli;" ); } } diff --git a/crates/aether-provider/transport/src/request_body.rs b/crates/aether-provider/transport/src/request_body.rs index 185180985..9994f0563 100644 --- a/crates/aether-provider/transport/src/request_body.rs +++ b/crates/aether-provider/transport/src/request_body.rs @@ -1,6 +1,8 @@ use serde_json::{Map, Value}; -use crate::claude_code::sanitize_claude_code_request_body; +use crate::claude_code::{ + apply_claude_code_body_mimicry_for_transport, sanitize_claude_code_request_body, +}; use crate::snapshot::GatewayProviderTransportSnapshot; use crate::vertex::is_vertex_transport_context; @@ -40,6 +42,11 @@ pub fn apply_transport_request_body_semantics( .trim() .eq_ignore_ascii_case("claude_code") { + apply_claude_code_body_mimicry_for_transport( + provider_request_body, + transport, + provider_api_format.as_str(), + ); sanitize_claude_code_request_body(provider_request_body); } aether_ai_formats::apply_xai_upstream_payload_edits( @@ -451,4 +458,37 @@ mod tests { assert!(error.message().contains("cannot be mapped")); assert!(body.get("input").is_some()); } + + #[test] + fn claude_code_body_mimicry_is_applied_by_default_to_claude_code_only() { + let pi_body = || { + json!({ + "model": "claude-sonnet-5-5", + "max_tokens": 1024, + "system": "You are an expert coding assistant operating inside pi.", + "messages": [{"role": "user", "content": "hello world, please help"}] + }) + }; + + let transport = sample_transport("claude_code", "https://api.anthropic.com"); + let mut body = pi_body(); + apply_transport_request_body_semantics(&mut body, &transport, "claude:messages") + .expect("semantics should apply"); + assert_eq!(body["system"].as_array().map(Vec::len), Some(3)); + assert!(body["metadata"]["user_id"].as_str().is_some()); + assert_eq!(body["messages"].as_array().map(Vec::len), Some(3)); + + // Running the semantics twice (cross-format planners do) must not stack the rewrite. + let once = body.clone(); + apply_transport_request_body_semantics(&mut body, &transport, "claude:messages") + .expect("semantics should apply"); + assert_eq!(body, once); + + // Non-claude_code providers are never touched. + let other = sample_transport("custom", "https://example.com"); + let mut untouched = pi_body(); + apply_transport_request_body_semantics(&mut untouched, &other, "claude:messages") + .expect("semantics should apply"); + assert_eq!(untouched, pi_body()); + } } diff --git a/crates/aether-provider/transport/src/same_format_provider/mod.rs b/crates/aether-provider/transport/src/same_format_provider/mod.rs index d84b3d519..3f3fa6358 100644 --- a/crates/aether-provider/transport/src/same_format_provider/mod.rs +++ b/crates/aether-provider/transport/src/same_format_provider/mod.rs @@ -1189,7 +1189,7 @@ mod tests { ); assert_eq!( build_body(compat_behavior)["system"][0]["text"], - "x-anthropic-billing-header: cc_version=2.1.161.abc; cc_entrypoint=cli;" + "x-anthropic-billing-header: cc_version=2.1.284.abc; cc_entrypoint=cli;" ); let mut legacy = sample_transport("claude_code");