fix(normalizer): 移除 Claude system 中的 billing header

This commit is contained in:
fawney19
2026-03-17 11:10:04 +08:00
parent dcbd7dc219
commit 5dae2a4792
3 changed files with 147 additions and 7 deletions

View File

@@ -66,6 +66,7 @@ from src.core.logger import logger
class ClaudeNormalizer(FormatNormalizer): class ClaudeNormalizer(FormatNormalizer):
FORMAT_ID = "claude:chat" FORMAT_ID = "claude:chat"
_ANTHROPIC_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:"
capabilities = FormatCapabilities( capabilities = FormatCapabilities(
supports_stream=True, supports_stream=True,
supports_error_conversion=True, supports_error_conversion=True,
@@ -1128,7 +1129,12 @@ class ClaudeNormalizer(FormatNormalizer):
if system_value is None: if system_value is None:
return None, dropped, None return None, dropped, None
if isinstance(system_value, str): if isinstance(system_value, str):
return (system_value or None), dropped, None sanitized_text, removed_header_count = self._strip_anthropic_billing_headers(
system_value
)
if removed_header_count:
dropped["claude_system_billing_header_removed"] = removed_header_count
return sanitized_text, dropped, None
if isinstance(system_value, list): if isinstance(system_value, list):
texts: list[str] = [] texts: list[str] = []
@@ -1143,14 +1149,26 @@ class ClaudeNormalizer(FormatNormalizer):
if item.get("type") == "text": if item.get("type") == "text":
text = item.get("text") text = item.get("text")
if text: if text:
texts.append(str(text)) normalized_text, removed_header_count = (
self._strip_anthropic_billing_headers(text)
)
if removed_header_count:
dropped["claude_system_billing_header_removed"] = (
dropped.get("claude_system_billing_header_removed", 0)
+ removed_header_count
)
if not normalized_text:
continue
texts.append(normalized_text)
seg_extra: dict[str, Any] = {} seg_extra: dict[str, Any] = {}
cc = item.get("cache_control") cc = item.get("cache_control")
if isinstance(cc, dict): if isinstance(cc, dict):
seg_extra["cache_control"] = cc seg_extra["cache_control"] = cc
has_cache_control = True has_cache_control = True
segments.append( segments.append(
InstructionSegment(role=Role.SYSTEM, text=str(text), extra=seg_extra) InstructionSegment(
role=Role.SYSTEM, text=normalized_text, extra=seg_extra
)
) )
else: else:
dropped_key = f"claude_system_item:{item.get('type')}" dropped_key = f"claude_system_item:{item.get('type')}"
@@ -1165,6 +1183,28 @@ class ClaudeNormalizer(FormatNormalizer):
dropped["claude_system_unsupported"] = dropped.get("claude_system_unsupported", 0) + 1 dropped["claude_system_unsupported"] = dropped.get("claude_system_unsupported", 0) + 1
return None, dropped, None return None, dropped, None
def _strip_anthropic_billing_headers(self, text: Any) -> tuple[str | None, int]:
normalized_text = str(text)
stripped_leading = normalized_text.lstrip()
if not stripped_leading.lower().startswith(self._ANTHROPIC_BILLING_HEADER_PREFIX):
return normalized_text, 0
lines = stripped_leading.splitlines(keepends=True)
idx = 0
removed_header_count = 0
while idx < len(lines) and lines[idx].strip().lower().startswith(
self._ANTHROPIC_BILLING_HEADER_PREFIX
):
removed_header_count += 1
idx += 1
while idx < len(lines) and not lines[idx].strip():
idx += 1
sanitized_text = "".join(lines[idx:])
if not sanitized_text.strip():
return None, removed_header_count
return sanitized_text, removed_header_count
def _join_instructions(self, instructions: list[InstructionSegment]) -> str | None: def _join_instructions(self, instructions: list[InstructionSegment]) -> str | None:
parts = [seg.text for seg in instructions if seg.text] parts = [seg.text for seg in instructions if seg.text]
joined = "\n\n".join(parts) joined = "\n\n".join(parts)

View File

@@ -369,8 +369,8 @@ def test_claude_request_metadata_preserved() -> None:
assert out["metadata"]["user_id"] == "user_abc123_session_xyz456" assert out["metadata"]["user_id"] == "user_abc123_session_xyz456"
def test_claude_system_array_format() -> None: def test_claude_system_array_format_drops_billing_header() -> None:
"""测试 Claude CLI 风格的 system 数组格式""" """Claude system 数组中的 Anthropic billing header 不应进入内部 instructions。"""
n = ClaudeNormalizer() n = ClaudeNormalizer()
req = { req = {
@@ -386,12 +386,51 @@ def test_claude_system_array_format() -> None:
internal = n.request_to_internal(req) internal = n.request_to_internal(req)
# system 数组中的多个 text 应该用 \n\n 连接 # billing header 应被移除,其余 system 文本仍按顺序保留
assert internal.system is not None assert internal.system is not None
assert "x-anthropic-billing-header" in internal.system assert "x-anthropic-billing-header" not in internal.system
assert "You are Claude Code" in internal.system assert "You are Claude Code" in internal.system
assert "Extract file paths" in internal.system assert "Extract file paths" in internal.system
assert "\n\n" in internal.system assert "\n\n" in internal.system
assert internal.extra["raw"]["dropped_blocks"]["claude_system_billing_header_removed"] == 1
def test_claude_system_string_strips_billing_header_and_keeps_following_prompt() -> None:
n = ClaudeNormalizer()
req = {
"model": "claude-3-opus",
"messages": [{"role": "user", "content": "hello"}],
"system": "x-anthropic-billing-header: cc_version=2.1.19\n\nYou are Claude Code.\nKeep answers terse.",
"max_tokens": 4096,
}
internal = n.request_to_internal(req)
assert internal.system == "You are Claude Code.\nKeep answers terse."
assert internal.extra["raw"]["dropped_blocks"]["claude_system_billing_header_removed"] == 1
def test_claude_system_array_strips_billing_header_prefix_but_keeps_same_block_prompt() -> None:
n = ClaudeNormalizer()
req = {
"model": "claude-3-opus",
"messages": [{"role": "user", "content": "hello"}],
"system": [
{
"type": "text",
"text": "x-anthropic-billing-header: cc_version=2.1.19\n\nYou are Claude Code.",
},
{"type": "text", "text": "Extract file paths."},
],
"max_tokens": 4096,
}
internal = n.request_to_internal(req)
assert internal.system == "You are Claude Code.\n\nExtract file paths."
assert internal.extra["raw"]["dropped_blocks"]["claude_system_billing_header_removed"] == 1
def test_claude_request_reuses_raw_tool_choice_and_stabilizes_message_sequence() -> None: def test_claude_request_reuses_raw_tool_choice_and_stabilizes_message_sequence() -> None:

View File

@@ -71,6 +71,67 @@ def test_claude_request_to_openai_cli_places_instructions_before_input() -> None
assert openai_cli_req["input"][0]["role"] == "user" assert openai_cli_req["input"][0]["role"] == "user"
def test_claude_request_to_openai_cli_drops_anthropic_billing_header() -> None:
reg = _make_registry_with_cli()
claude_req = {
"model": "claude-3-5-sonnet-latest",
"system": [
{
"type": "text",
"text": "x-anthropic-billing-header: cc_version=2.1.74.705; cc_entrypoint=claude-vscode; cch=4fb9f;",
},
{"type": "text", "text": "You are Claude Code."},
{"type": "text", "text": "Keep answers terse."},
],
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 12,
}
openai_cli_req = reg.convert_request(claude_req, "claude:chat", "openai:cli")
assert openai_cli_req["instructions"] == "You are Claude Code.\n\nKeep answers terse."
assert "x-anthropic-billing-header" not in openai_cli_req["instructions"]
def test_claude_request_to_openai_cli_keeps_prompt_after_billing_header_prefix() -> None:
reg = _make_registry_with_cli()
claude_req = {
"model": "claude-3-5-sonnet-latest",
"system": [
{
"type": "text",
"text": "x-anthropic-billing-header: cc_version=2.1.74.705\n\nYou are Claude Code.",
},
{"type": "text", "text": "Keep answers terse."},
],
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 12,
}
openai_cli_req = reg.convert_request(claude_req, "claude:chat", "openai:cli")
assert openai_cli_req["instructions"] == "You are Claude Code.\n\nKeep answers terse."
assert "x-anthropic-billing-header" not in openai_cli_req["instructions"]
def test_claude_request_to_openai_cli_strips_billing_header_from_string_system() -> None:
reg = _make_registry_with_cli()
claude_req = {
"model": "claude-3-5-sonnet-latest",
"system": "x-anthropic-billing-header: cc_version=2.1.74.705\n\nYou are Claude Code.\nKeep answers terse.",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 12,
}
openai_cli_req = reg.convert_request(claude_req, "claude:chat", "openai:cli")
assert openai_cli_req["instructions"] == "You are Claude Code.\nKeep answers terse."
assert "x-anthropic-billing-header" not in openai_cli_req["instructions"]
def test_claude_response_to_openai_cli() -> None: def test_claude_response_to_openai_cli() -> None:
reg = _make_registry_with_cli() reg = _make_registry_with_cli()