From 35bce24633c24290248bad720be3b3575fd49382 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 30 Apr 2026 16:54:10 +0800 Subject: [PATCH] fix(proxy): strip leading billing header from system content (#2350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code injects a dynamic `x-anthropic-billing-header` line at the start of `system` content. Its rotating `cch=` token was forwarded into OpenAI Responses `instructions` and Chat system messages, which broke upstream prefix prompt cache reuse — a stable ~95k-token prefix was getting re-charged on every request. Strip only the leading occurrence in both anthropic_to_openai and anthropic_to_responses; later occurrences are preserved so user-authored prompt text containing the same string is not lost. --- src-tauri/src/proxy/providers/transform.rs | 124 +++++++++++++++++- .../proxy/providers/transform_responses.rs | 81 +++++++++++- 2 files changed, 202 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index d4eafd243..7dd620bea 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -6,6 +6,46 @@ use crate::proxy::error::ProxyError; use serde_json::{json, Value}; +const ANTHROPIC_BILLING_HEADER_PREFIX: &str = "x-anthropic-billing-header:"; + +/// Strip only a leading Claude Code attribution line from system text. +/// +/// Claude Code can send dynamic `x-anthropic-billing-header` metadata at the +/// start of `system`. If forwarded into OpenAI Chat messages or Responses +/// `instructions`, the rotating `cch=` value changes the prompt prefix on every +/// request and prevents prefix cache reuse (#2350). Later occurrences are kept +/// to avoid deleting user-authored prompt text. +pub(crate) fn strip_leading_anthropic_billing_header(text: &str) -> &str { + if !text.starts_with(ANTHROPIC_BILLING_HEADER_PREFIX) { + return text; + } + + let Some(line_end) = text + .as_bytes() + .iter() + .position(|byte| *byte == b'\n' || *byte == b'\r') + else { + return ""; + }; + + let bytes = text.as_bytes(); + let mut rest_start = line_end + 1; + if bytes[line_end] == b'\r' && bytes.get(line_end + 1) == Some(&b'\n') { + rest_start += 1; + } + + let rest = &text[rest_start..]; + if let Some(stripped) = rest.strip_prefix("\r\n") { + stripped + } else if let Some(stripped) = rest.strip_prefix('\n') { + stripped + } else if let Some(stripped) = rest.strip_prefix('\r') { + stripped + } else { + rest + } +} + /// Detect OpenAI o-series reasoning models (o1, o3, o4-mini, etc.) /// These models require `max_completion_tokens` instead of `max_tokens`. pub fn is_openai_o_series(model: &str) -> bool { @@ -97,12 +137,18 @@ pub fn anthropic_to_openai_with_reasoning_content( // 处理 system prompt if let Some(system) = body.get("system") { if let Some(text) = system.as_str() { - // 单个字符串 - messages.push(json!({"role": "system", "content": text})); + let text = strip_leading_anthropic_billing_header(text); + if !text.is_empty() { + messages.push(json!({"role": "system", "content": text})); + } } else if let Some(arr) = system.as_array() { // 多个 system message — preserve cache_control for compatible proxies for msg in arr { if let Some(text) = msg.get("text").and_then(|t| t.as_str()) { + let text = strip_leading_anthropic_billing_header(text); + if text.is_empty() { + continue; + } let mut sys_msg = json!({"role": "system", "content": text}); if let Some(cc) = msg.get("cache_control") { sys_msg["cache_control"] = cc.clone(); @@ -638,6 +684,80 @@ mod tests { assert_eq!(result["messages"][1]["role"], "user"); } + #[test] + fn test_anthropic_to_openai_strips_leading_billing_header_from_system_string() { + let input = json!({ + "model": "claude-3-sonnet", + "max_tokens": 1024, + "system": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n\nYou are a helpful assistant.", + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_openai(input).unwrap(); + assert_eq!(result["messages"][0]["role"], "system"); + assert_eq!( + result["messages"][0]["content"], + "You are a helpful assistant." + ); + assert_eq!(result["messages"][1]["role"], "user"); + } + + #[test] + fn test_anthropic_to_openai_strips_billing_header_from_system_array_parts() { + let input = json!({ + "model": "claude-3-sonnet", + "max_tokens": 1024, + "system": [ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n"}, + {"type": "text", "text": "Stable prompt"} + ], + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_openai(input).unwrap(); + assert_eq!(result["messages"][0]["role"], "system"); + assert_eq!(result["messages"][0]["content"], "Stable prompt"); + assert_eq!(result["messages"][1]["role"], "user"); + } + + #[test] + fn test_anthropic_to_openai_preserves_prompt_after_billing_header_in_same_part() { + let input = json!({ + "model": "claude-3-sonnet", + "max_tokens": 1024, + "system": [ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n\nStable prompt part 1"}, + {"type": "text", "text": "Stable prompt part 2"} + ], + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_openai(input).unwrap(); + assert_eq!(result["messages"][0]["role"], "system"); + assert_eq!( + result["messages"][0]["content"], + "Stable prompt part 1\nStable prompt part 2" + ); + assert_eq!(result["messages"][1]["role"], "user"); + } + + #[test] + fn test_anthropic_to_openai_keeps_non_leading_billing_header_text() { + let input = json!({ + "model": "claude-3-sonnet", + "max_tokens": 1024, + "system": "Keep this literal:\nx-anthropic-billing-header: example", + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_openai(input).unwrap(); + assert_eq!(result["messages"][0]["role"], "system"); + assert_eq!( + result["messages"][0]["content"], + "Keep this literal:\nx-anthropic-billing-header: example" + ); + } + #[test] fn test_anthropic_to_openai_with_tools() { let input = json!({ diff --git a/src-tauri/src/proxy/providers/transform_responses.rs b/src-tauri/src/proxy/providers/transform_responses.rs index 5a5d6dc2b..e2ec627dd 100644 --- a/src-tauri/src/proxy/providers/transform_responses.rs +++ b/src-tauri/src/proxy/providers/transform_responses.rs @@ -35,10 +35,12 @@ pub fn anthropic_to_responses( // system → instructions (Responses API 使用 instructions 字段) if let Some(system) = body.get("system") { let instructions = if let Some(text) = system.as_str() { - text.to_string() + super::transform::strip_leading_anthropic_billing_header(text).to_string() } else if let Some(arr) = system.as_array() { arr.iter() .filter_map(|msg| msg.get("text").and_then(|t| t.as_str())) + .map(super::transform::strip_leading_anthropic_billing_header) + .filter(|text| !text.is_empty()) .collect::>() .join("\n\n") } else { @@ -611,6 +613,48 @@ mod tests { assert_eq!(result["input"].as_array().unwrap().len(), 1); } + #[test] + fn test_anthropic_to_responses_strips_leading_billing_header_from_system_string() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "system": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n\nYou are a helpful assistant.", + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, false, false).unwrap(); + assert_eq!(result["instructions"], "You are a helpful assistant."); + } + + #[test] + fn test_anthropic_to_responses_strips_billing_header_with_crlf() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "system": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\r\n\r\nYou are a helpful assistant.", + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, false, false).unwrap(); + assert_eq!(result["instructions"], "You are a helpful assistant."); + } + + #[test] + fn test_anthropic_to_responses_keeps_non_leading_billing_header_text() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "system": "Keep this literal:\nx-anthropic-billing-header: example", + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, false, false).unwrap(); + assert_eq!( + result["instructions"], + "Keep this literal:\nx-anthropic-billing-header: example" + ); + } + #[test] fn test_anthropic_to_responses_with_system_array() { let input = json!({ @@ -627,6 +671,41 @@ mod tests { assert_eq!(result["instructions"], "Part 1\n\nPart 2"); } + #[test] + fn test_anthropic_to_responses_strips_billing_header_from_system_array_parts() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "system": [ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n"}, + {"type": "text", "text": "Stable prompt"} + ], + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, false, false).unwrap(); + assert_eq!(result["instructions"], "Stable prompt"); + } + + #[test] + fn test_anthropic_to_responses_preserves_prompt_after_billing_header_in_same_part() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "system": [ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n\nStable prompt part 1"}, + {"type": "text", "text": "Stable prompt part 2"} + ], + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, false, false).unwrap(); + assert_eq!( + result["instructions"], + "Stable prompt part 1\n\nStable prompt part 2" + ); + } + #[test] fn test_anthropic_to_responses_with_tools() { let input = json!({