From c1aa5d618d568c38300c34c82e6eb82acafd07a3 Mon Sep 17 00:00:00 2001 From: dalamudx Date: Tue, 29 Sep 2026 18:35:23 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(claude-code):=20=E4=B8=BA=20claude=5Fc?= =?UTF-8?q?ode=20provider=20=E8=A1=A5=E5=85=A8=20Claude=20Code=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E4=BD=93=E7=89=B9=E5=BE=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非 Claude Code 客户端(如 pi)经 OAuth 的 claude_code provider 转发时, 只有请求头被伪装成 Claude Code,请求体仍是客户端原样,被上游以 429 rate_limit_error 拒绝(响应无 ratelimit 额度头,并非真实限流)。 参考 sub2api 的 OAuth 请求体伪装,在传输层补齐请求体: - system 重写为计费头 + 身份句 + 通用提示词三块(Fable 仅保留前两块) - 原 system 迁入 messages 开头,避免丢失客户端指令 - 缺失时补 metadata.user_id,device/session id 基于 key 稳定派生 - 缺失时补 tools/temperature/max_tokens,并限制 cache_control 不超过 4 个 - 已带计费块且有 metadata.user_id 的真实 Claude Code 请求原样放行, 重复应用幂等 接入点覆盖跨格式路径(apply_transport_request_body_semantics)和 原生 claude:messages 同格式路径,仅作用于 provider_type=claude_code。 --- .../passthrough/provider/family/request.rs | 14 + .../transport/src/claude_code/mimicry.rs | 674 ++++++++++++++++++ .../transport/src/claude_code/mod.rs | 5 + .../transport/src/request_body.rs | 42 +- 4 files changed, 734 insertions(+), 1 deletion(-) create mode 100644 crates/aether-provider/transport/src/claude_code/mimicry.rs 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..28f701b58 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::provider_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/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..0c24cf0eb --- /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.161.")); + 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.161"); + assert!(body["system"][0]["text"] + .as_str() + .unwrap() + .contains(&format!("cc_version=2.1.161.{expected};"))); + } + + #[test] + fn fingerprint_is_three_hex_chars_and_pads_short_text_with_zero() { + let fp = claude_code_fingerprint("", "2.1.161"); + assert_eq!(fp.len(), 3); + assert!(fp.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(fp, claude_code_fingerprint("abc", "2.1.161")); + assert_ne!( + claude_code_fingerprint("0123456789012345678901234", "2.1.161"), + claude_code_fingerprint("", "2.1.161") + ); + } + + #[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/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()); + } } From e8ee7b4ecfaeddf6804b9e7aeeb2916a4e2ee4b6 Mon Sep 17 00:00:00 2001 From: dalamudx Date: Tue, 29 Sep 2026 18:47:59 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(claude-code):=20=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E4=BC=AA=E8=A3=85=E7=9A=84=20Claude=20Code=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=88=B0=202.1.284?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游按模型校验 Claude Code 最低版本,claude-opus-5-5 要求 >= 2.1.280, 而 profile 里写死的 2.1.161 会被拒绝(claude_code_version_too_old, 400), 且 Aether 会统一改写 UA,真实 Claude Code 客户端也会受影响。 将 cli_version 升级为 2.1.284,并同步 stainless 包版本 (0.112.1) 与 node 运行时版本 (v26.3.0),保持指纹一致;相关测试断言同步更新。 --- .../src/tests/ai_execute/stream_provider.rs | 2 +- .../tests/ai_execute/sync/claude/claude_code.rs | 4 ++-- .../transport/src/claude_code/fingerprint.rs | 16 ++++++++-------- .../transport/src/claude_code/mimicry.rs | 14 +++++++------- .../transport/src/claude_code/profile.rs | 12 ++++++------ .../transport/src/claude_code/request.rs | 8 ++++---- .../transport/src/same_format_provider/mod.rs | 2 +- 7 files changed, 29 insertions(+), 29 deletions(-) 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/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/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 index 0c24cf0eb..4ead9b97a 100644 --- a/crates/aether-provider/transport/src/claude_code/mimicry.rs +++ b/crates/aether-provider/transport/src/claude_code/mimicry.rs @@ -479,7 +479,7 @@ mod tests { 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.161.")); + 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); @@ -505,22 +505,22 @@ mod tests { 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.161"); + 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.161.{expected};"))); + .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.161"); + 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.161")); + assert_eq!(fp, claude_code_fingerprint("abc", "2.1.284")); assert_ne!( - claude_code_fingerprint("0123456789012345678901234", "2.1.161"), - claude_code_fingerprint("", "2.1.161") + claude_code_fingerprint("0123456789012345678901234", "2.1.284"), + claude_code_fingerprint("", "2.1.284") ); } 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/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"); From 5d5880d75e2648a35a4c42b70066b9d82f6b1994 Mon Sep 17 00:00:00 2001 From: dalamudx Date: Tue, 29 Sep 2026 20:18:03 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(claude-code):=20=E8=AE=A9=20openai:resp?= =?UTF-8?q?onses/chat=20=E8=BD=AC=20claude=20=E7=9A=84=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=B9=9F=E5=BA=94=E7=94=A8=E8=AF=B7=E6=B1=82=E4=BD=93=E4=BC=AA?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openai:responses 与 openai:chat 的决策构建器各自构建上游 body,不经过 apply_transport_request_body_semantics,导致跨格式请求转发到 claude_code provider 时请求体伪装没有生效,上游仍返回 429。 - responses/decision/request.rs:finalize 之后应用伪装 - chat/decision/request.rs:finalize 成功后应用伪装 - 同格式路径改为经 ai_serving::transport 门面引用,满足架构守卫 (ai_serving 不得直接依赖 crate::provider_transport) - 更新 chat -> claude_code 用例断言为新的请求体形态 --- .../passthrough/provider/family/request.rs | 2 +- .../standard/openai/chat/decision/request.rs | 14 ++++++++++-- .../openai/responses/decision/request.rs | 7 ++++++ .../ai_execute/sync/chat/local_decision.rs | 22 ++++++++++++++++--- 4 files changed, 39 insertions(+), 6 deletions(-) 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 28f701b58..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 @@ -316,7 +316,7 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts( // 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::provider_transport::claude_code::apply_claude_code_body_mimicry_for_transport( + 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(), 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/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" From fa06e9695dc212583ab079d672e9db49ca9c91f5 Mon Sep 17 00:00:00 2001 From: dalamudx Date: Tue, 29 Sep 2026 20:27:43 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(claude-code):=20=E8=A1=A5=E5=85=85=20R?= =?UTF-8?q?esponses=20=E2=86=92=20claude=5Fcode=20=E7=BD=91=E5=85=B3?= =?UTF-8?q?=E6=B5=8B=E8=AF=95,=E9=AA=8C=E8=AF=81=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E4=BD=93=E5=B7=B2=E4=BC=AA=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tests/ai_execute/sync/cli.rs | 410 ++++++++++++++++++ 1 file changed, 410 insertions(+) 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(