mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(proxy): strip leading billing header from system content (#2350)
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.
This commit is contained in:
@@ -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!({
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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!({
|
||||
|
||||
Reference in New Issue
Block a user