From 1c847dd5532ea3863995af3233c22dc16950e57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Sun, 15 Feb 2026 23:57:08 +0800 Subject: [PATCH] feat(ai-proxy): strip dynamic cch field from billing header to enable caching (#3518) --- .../ai-proxy/provider/claude_to_openai.go | 48 ++++++- .../provider/claude_to_openai_test.go | 122 ++++++++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go index 4739f830e..7a772305d 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai.go @@ -145,7 +145,8 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]b if claudeRequest.System != nil { systemMsg := chatMessage{Role: roleSystem} if !claudeRequest.System.IsArray { - systemMsg.Content = claudeRequest.System.StringValue + // Strip dynamic cch field from billing header to enable caching + systemMsg.Content = stripCchFromBillingHeader(claudeRequest.System.StringValue) } else { conversionResult := c.convertContentArray(claudeRequest.System.ArrayValue) systemMsg.Content = conversionResult.openaiContents @@ -832,10 +833,12 @@ func (c *ClaudeToOpenAIConverter) convertContentArray(claudeContents []claudeCha switch claudeContent.Type { case "text": if claudeContent.Text != "" { - result.textParts = append(result.textParts, claudeContent.Text) + // Strip dynamic cch field from billing header to enable caching + processedText := stripCchFromBillingHeader(claudeContent.Text) + result.textParts = append(result.textParts, processedText) result.openaiContents = append(result.openaiContents, chatMessageContent{ Type: contentTypeText, - Text: claudeContent.Text, + Text: processedText, CacheControl: claudeContent.CacheControl, }) } @@ -954,3 +957,42 @@ func (c *ClaudeToOpenAIConverter) startToolCall(toolState *toolCallInfo) []*clau return responses } + +// stripCchFromBillingHeader removes the dynamic cch field from x-anthropic-billing-header text +// to enable caching. The cch value changes on every request, which would break prompt caching. +// Example input: "x-anthropic-billing-header: cc_version=2.1.37.3a3; cc_entrypoint=claude-vscode; cch=abc123;" +// Example output: "x-anthropic-billing-header: cc_version=2.1.37.3a3; cc_entrypoint=claude-vscode;" +func stripCchFromBillingHeader(text string) string { + const billingHeaderPrefix = "x-anthropic-billing-header:" + + // Check if this is a billing header + if !strings.HasPrefix(text, billingHeaderPrefix) { + return text + } + + // Remove cch=xxx pattern (may appear with or without trailing semicolon) + // Pattern: ; cch= followed by ; or end of string + result := text + + // Try to find and remove ; cch=... pattern + // We need to handle both "; cch=xxx;" and "; cch=xxx" (at end) + for { + cchIdx := strings.Index(result, "; cch=") + if cchIdx == -1 { + break + } + + // Find the end of cch value (next semicolon or end of string) + start := cchIdx + 2 // skip "; " + end := strings.Index(result[start:], ";") + if end == -1 { + // cch is at the end, remove from "; cch=" to end + result = result[:cchIdx] + } else { + // cch is followed by more content, remove "; cch=xxx" part + result = result[:cchIdx] + result[start+end:] + } + } + + return result +} diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai_test.go b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai_test.go index fdba0710b..d007b5974 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai_test.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/claude_to_openai_test.go @@ -859,3 +859,125 @@ func TestClaudeToOpenAIConverter_ConvertReasoningResponseToClaude(t *testing.T) }) } } + +func TestClaudeToOpenAIConverter_StripCchFromSystemMessage(t *testing.T) { + converter := &ClaudeToOpenAIConverter{} + + t.Run("string_system_with_billing_header", func(t *testing.T) { + // Test that cch field is stripped from string format system message + claudeRequest := `{ + "model": "claude-sonnet-4", + "max_tokens": 1024, + "system": [ + { + "type": "text", + "text": "x-anthropic-billing-header: cc_version=2.1.37.3a3; cc_entrypoint=claude-vscode; cch=abc123;" + } + ], + "messages": [{ + "role": "user", + "content": "Hello" + }] + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + require.Len(t, openaiRequest.Messages, 2) + + // First message should be system with cch stripped + systemMsg := openaiRequest.Messages[0] + assert.Equal(t, "system", systemMsg.Role) + + // The system content should have cch removed + contentArray, ok := systemMsg.Content.([]interface{}) + require.True(t, ok, "System content should be an array") + require.Len(t, contentArray, 1) + + contentMap, ok := contentArray[0].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "text", contentMap["type"]) + assert.Equal(t, "x-anthropic-billing-header: cc_version=2.1.37.3a3; cc_entrypoint=claude-vscode;", contentMap["text"]) + assert.NotContains(t, contentMap["text"], "cch=") + }) + + t.Run("plain_string_system_unchanged", func(t *testing.T) { + // Test that normal system messages are not modified + claudeRequest := `{ + "model": "claude-sonnet-4", + "max_tokens": 1024, + "system": "You are a helpful assistant.", + "messages": [{ + "role": "user", + "content": "Hello" + }] + }` + + result, err := converter.ConvertClaudeRequestToOpenAI([]byte(claudeRequest)) + require.NoError(t, err) + + var openaiRequest chatCompletionRequest + err = json.Unmarshal(result, &openaiRequest) + require.NoError(t, err) + + // First message should be system with original content + systemMsg := openaiRequest.Messages[0] + assert.Equal(t, "system", systemMsg.Role) + assert.Equal(t, "You are a helpful assistant.", systemMsg.Content) + }) +} + +func TestStripCchFromBillingHeader(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "billing header with cch at end", + input: "x-anthropic-billing-header: cc_version=2.1.37.3a3; cc_entrypoint=claude-vscode; cch=abc123;", + expected: "x-anthropic-billing-header: cc_version=2.1.37.3a3; cc_entrypoint=claude-vscode;", + }, + { + name: "billing header with cch at end without trailing semicolon", + input: "x-anthropic-billing-header: cc_version=2.1.37.3a3; cc_entrypoint=claude-vscode; cch=abc123", + expected: "x-anthropic-billing-header: cc_version=2.1.37.3a3; cc_entrypoint=claude-vscode", + }, + { + name: "billing header with cch in middle", + input: "x-anthropic-billing-header: cc_version=2.1.37.3a3; cch=abc123; cc_entrypoint=claude-vscode;", + expected: "x-anthropic-billing-header: cc_version=2.1.37.3a3; cc_entrypoint=claude-vscode;", + }, + { + name: "billing header without cch", + input: "x-anthropic-billing-header: cc_version=2.1.37.3a3; cc_entrypoint=claude-vscode;", + expected: "x-anthropic-billing-header: cc_version=2.1.37.3a3; cc_entrypoint=claude-vscode;", + }, + { + name: "non-billing header text unchanged", + input: "This is a normal system prompt", + expected: "This is a normal system prompt", + }, + { + name: "empty string unchanged", + input: "", + expected: "", + }, + { + name: "billing header with multiple cch fields", + input: "x-anthropic-billing-header: cc_version=2.1.37.3a3; cch=first; cc_entrypoint=claude-vscode; cch=second;", + expected: "x-anthropic-billing-header: cc_version=2.1.37.3a3; cc_entrypoint=claude-vscode;", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := stripCchFromBillingHeader(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +}