mirror of
https://github.com/alibaba/higress.git
synced 2026-05-28 06:37:26 +08:00
feat(ai-proxy): strip dynamic cch field from billing header to enable caching (#3518)
This commit is contained in:
@@ -145,7 +145,8 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]b
|
|||||||
if claudeRequest.System != nil {
|
if claudeRequest.System != nil {
|
||||||
systemMsg := chatMessage{Role: roleSystem}
|
systemMsg := chatMessage{Role: roleSystem}
|
||||||
if !claudeRequest.System.IsArray {
|
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 {
|
} else {
|
||||||
conversionResult := c.convertContentArray(claudeRequest.System.ArrayValue)
|
conversionResult := c.convertContentArray(claudeRequest.System.ArrayValue)
|
||||||
systemMsg.Content = conversionResult.openaiContents
|
systemMsg.Content = conversionResult.openaiContents
|
||||||
@@ -832,10 +833,12 @@ func (c *ClaudeToOpenAIConverter) convertContentArray(claudeContents []claudeCha
|
|||||||
switch claudeContent.Type {
|
switch claudeContent.Type {
|
||||||
case "text":
|
case "text":
|
||||||
if claudeContent.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{
|
result.openaiContents = append(result.openaiContents, chatMessageContent{
|
||||||
Type: contentTypeText,
|
Type: contentTypeText,
|
||||||
Text: claudeContent.Text,
|
Text: processedText,
|
||||||
CacheControl: claudeContent.CacheControl,
|
CacheControl: claudeContent.CacheControl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -954,3 +957,42 @@ func (c *ClaudeToOpenAIConverter) startToolCall(toolState *toolCallInfo) []*clau
|
|||||||
|
|
||||||
return responses
|
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=<any-non-semicolon-chars> 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user