diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 7f36b11cc..456475f1f 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -101,7 +101,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ systemTypePromptResult := systemPromptResult.Get("type") if systemTypePromptResult.Type == gjson.String && systemTypePromptResult.String() == "text" { systemPrompt := systemPromptResult.Get("text").String() - if strings.HasPrefix(systemPrompt, "x-anthropic-billing-header:") { + if util.IsClaudeCodeAttributionSystemText(systemPrompt) { continue } partJSON := []byte(`{}`) @@ -112,7 +112,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ hasSystemInstruction = true } } - } else if systemResult.Type == gjson.String { + } else if systemResult.Type == gjson.String && !util.IsClaudeCodeAttributionSystemText(systemResult.String()) { systemInstructionJSON = []byte(`{"role":"user","parts":[{"text":""}]}`) systemInstructionJSON, _ = sjson.SetBytes(systemInstructionJSON, "parts.0.text", systemResult.String()) hasSystemInstruction = true diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index bb3cdf4f3..f4ffa3e41 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -70,6 +70,28 @@ func uint64Ptr(v uint64) *uint64 { return &v } +func TestConvertClaudeRequestToAntigravity_StripsClaudeCodeAttribution(t *testing.T) { + inputJSON := []byte(`{ + "model": "claude-sonnet-4-5", + "messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}], + "system": [ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.63.abc; cc_entrypoint=cli; cch=12345;"}, + {"type": "text", "text": "Antigravity system prompt"} + ] + }`) + + output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) + outputStr := string(output) + + parts := gjson.Get(outputStr, "request.systemInstruction.parts").Array() + if len(parts) != 1 { + t.Fatalf("Expected 1 system part after attribution strip, got %d: %s", len(parts), gjson.Get(outputStr, "request.systemInstruction.parts").Raw) + } + if got := parts[0].Get("text").String(); got != "Antigravity system prompt" { + t.Fatalf("Unexpected system part: %q", got) + } +} + func testNonAnthropicRawSignature(t *testing.T) string { t.Helper() diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 029db14e7..b74f35c90 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -50,7 +51,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) contentIndex := 0 appendSystemText := func(text string) { - if text == "" || strings.HasPrefix(text, "x-anthropic-billing-header: ") { + if text == "" || util.IsClaudeCodeAttributionSystemText(text) { return } diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index 3e77b3f75..b21936a95 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -49,6 +49,9 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] if systemPromptResult.Get("type").String() == "text" { textResult := systemPromptResult.Get("text") if textResult.Type == gjson.String { + if util.IsClaudeCodeAttributionSystemText(textResult.String()) { + return true + } part := []byte(`{"text":""}`) part, _ = sjson.SetBytes(part, "text", textResult.String()) systemInstruction, _ = sjson.SetRawBytes(systemInstruction, "parts.-1", part) @@ -60,7 +63,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] if hasSystemParts { out, _ = sjson.SetRawBytes(out, "request.systemInstruction", systemInstruction) } - } else if systemResult.Type == gjson.String { + } else if systemResult.Type == gjson.String && !util.IsClaudeCodeAttributionSystemText(systemResult.String()) { out, _ = sjson.SetBytes(out, "request.systemInstruction.parts.-1.text", systemResult.String()) } diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go index 10364e751..ff0cea657 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go @@ -40,3 +40,24 @@ func TestConvertClaudeRequestToCLI_ToolChoice_SpecificTool(t *testing.T) { t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.GetBytes(output, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Raw) } } + +func TestConvertClaudeRequestToCLI_StripsClaudeCodeAttribution(t *testing.T) { + inputJSON := []byte(`{ + "model": "claude-sonnet-4-5", + "system": [ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.63.abc; cc_entrypoint=cli; cch=12345;"}, + {"type": "text", "text": "User system prompt"} + ], + "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + }`) + + output := ConvertClaudeRequestToCLI("gemini-3-flash-preview", inputJSON, false) + + parts := gjson.GetBytes(output, "request.systemInstruction.parts").Array() + if len(parts) != 1 { + t.Fatalf("Expected 1 system part after attribution strip, got %d: %s", len(parts), gjson.GetBytes(output, "request.systemInstruction.parts").Raw) + } + if got := parts[0].Get("text").String(); got != "User system prompt" { + t.Fatalf("Unexpected system part: %q", got) + } +} diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 454668cbc..3beadea18 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -43,6 +43,9 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) if systemPromptResult.Get("type").String() == "text" { textResult := systemPromptResult.Get("text") if textResult.Type == gjson.String { + if util.IsClaudeCodeAttributionSystemText(textResult.String()) { + return true + } part := []byte(`{"text":""}`) part, _ = sjson.SetBytes(part, "text", textResult.String()) systemInstruction, _ = sjson.SetRawBytes(systemInstruction, "parts.-1", part) @@ -54,7 +57,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) if hasSystemParts { out, _ = sjson.SetRawBytes(out, "system_instruction", systemInstruction) } - } else if systemResult.Type == gjson.String { + } else if systemResult.Type == gjson.String && !util.IsClaudeCodeAttributionSystemText(systemResult.String()) { out, _ = sjson.SetBytes(out, "system_instruction.parts.-1.text", systemResult.String()) } diff --git a/internal/translator/gemini/claude/gemini_claude_request_test.go b/internal/translator/gemini/claude/gemini_claude_request_test.go index 10ad2d3af..0fd515e59 100644 --- a/internal/translator/gemini/claude/gemini_claude_request_test.go +++ b/internal/translator/gemini/claude/gemini_claude_request_test.go @@ -78,3 +78,31 @@ func TestConvertClaudeRequestToGemini_ImageContent(t *testing.T) { t.Fatalf("Expected image data 'aGVsbG8=', got '%s'", got) } } + +func TestConvertClaudeRequestToGemini_StripsClaudeCodeAttribution(t *testing.T) { + inputJSON := []byte(`{ + "model": "claude-sonnet-4-5", + "system": [ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.63.abc; cc_entrypoint=cli; cch=12345;"}, + {"type": "text", "text": "You are a Claude agent, built on Anthropic's Claude Agent SDK."}, + {"type": "text", "text": "User system prompt"} + ], + "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + }`) + + output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false) + + parts := gjson.GetBytes(output, "system_instruction.parts").Array() + if len(parts) != 2 { + t.Fatalf("Expected 2 system parts after attribution strip, got %d: %s", len(parts), gjson.GetBytes(output, "system_instruction.parts").Raw) + } + if got := parts[0].Get("text").String(); got != "You are a Claude agent, built on Anthropic's Claude Agent SDK." { + t.Fatalf("Unexpected first system part: %q", got) + } + if got := parts[1].Get("text").String(); got != "User system prompt" { + t.Fatalf("Unexpected second system part: %q", got) + } + if gjson.GetBytes(output, `system_instruction.parts.#(text%"x-anthropic-billing-header:*")`).Exists() { + t.Fatalf("Claude Code attribution block was forwarded: %s", gjson.GetBytes(output, "system_instruction.parts").Raw) + } +} diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index 99fc2763f..98954b383 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -103,7 +104,7 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream hasSystemContent := false if system := root.Get("system"); system.Exists() { if system.Type == gjson.String { - if system.String() != "" { + if system.String() != "" && !util.IsClaudeCodeAttributionSystemText(system.String()) { oldSystem := []byte(`{"type":"text","text":""}`) oldSystem, _ = sjson.SetBytes(oldSystem, "text", system.String()) systemMsgJSON, _ = sjson.SetRawBytes(systemMsgJSON, "content.-1", oldSystem) @@ -334,7 +335,7 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) { switch partType { case "text": text := part.Get("text").String() - if strings.TrimSpace(text) == "" { + if strings.TrimSpace(text) == "" || util.IsClaudeCodeAttributionSystemText(text) { return "", false } textContent := []byte(`{"type":"text","text":""}`) diff --git a/internal/translator/openai/claude/openai_claude_request_test.go b/internal/translator/openai/claude/openai_claude_request_test.go index 3fd4707f5..9c6ba77c3 100644 --- a/internal/translator/openai/claude/openai_claude_request_test.go +++ b/internal/translator/openai/claude/openai_claude_request_test.go @@ -696,3 +696,28 @@ func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *t t.Fatalf("Expected reasoning_content %q, got %q", "t1\n\nt2", got) } } + +func TestConvertClaudeRequestToOpenAI_StripsClaudeCodeAttribution(t *testing.T) { + inputJSON := []byte(`{ + "model": "claude-sonnet-4-5", + "system": [ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.63.abc; cc_entrypoint=cli; cch=12345;"}, + {"type": "text", "text": "User system prompt"} + ], + "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + }`) + + output := ConvertClaudeRequestToOpenAI("gpt-5", inputJSON, false) + messages := gjson.GetBytes(output, "messages").Array() + if len(messages) == 0 || messages[0].Get("role").String() != "system" { + t.Fatalf("Expected first message to be system, got: %s", gjson.GetBytes(output, "messages").Raw) + } + + content := messages[0].Get("content").Array() + if len(content) != 1 { + t.Fatalf("Expected 1 system content item after attribution strip, got %d: %s", len(content), messages[0].Get("content").Raw) + } + if got := content[0].Get("text").String(); got != "User system prompt" { + t.Fatalf("Unexpected system content: %q", got) + } +} diff --git a/internal/util/claude_attribution.go b/internal/util/claude_attribution.go new file mode 100644 index 000000000..ddfa1da58 --- /dev/null +++ b/internal/util/claude_attribution.go @@ -0,0 +1,15 @@ +package util + +import ( + "strings" + "unicode" +) + +const claudeCodeAttributionSystemPrefix = "x-anthropic-billing-header:" + +// IsClaudeCodeAttributionSystemText reports whether text is the Claude Code +// attribution block that carries per-request billing and prompt fingerprint data. +func IsClaudeCodeAttributionSystemText(text string) bool { + text = strings.TrimLeftFunc(text, unicode.IsSpace) + return strings.HasPrefix(text, claudeCodeAttributionSystemPrefix) +} diff --git a/internal/util/claude_attribution_test.go b/internal/util/claude_attribution_test.go new file mode 100644 index 000000000..02817ee1d --- /dev/null +++ b/internal/util/claude_attribution_test.go @@ -0,0 +1,40 @@ +package util + +import "testing" + +func TestIsClaudeCodeAttributionSystemText(t *testing.T) { + tests := []struct { + name string + text string + want bool + }{ + { + name: "Claude Code attribution block", + text: "x-anthropic-billing-header: cc_version=2.1.63.abc; cc_entrypoint=cli; cch=12345;", + want: true, + }, + { + name: "leading whitespace", + text: "\n\t x-anthropic-billing-header: cc_version=2.1.63.abc; cch=12345;", + want: true, + }, + { + name: "regular system prompt", + text: "You are helpful.", + want: false, + }, + { + name: "empty text", + text: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsClaudeCodeAttributionSystemText(tt.text); got != tt.want { + t.Fatalf("IsClaudeCodeAttributionSystemText(%q) = %v, want %v", tt.text, got, tt.want) + } + }) + } +}