feat(translator): add cache control handling for responses and messages

- Introduced `AttachCacheControl` and `AttachMessageCacheControl` utilities for consistent cache control injection in content parts and messages.
- Updated Claude translator modules to preserve and apply cache control metadata for responses, messages, tools, and parts.
- Added unit tests to validate proper cache control behavior across multiple scenarios.

Closes: #4146
This commit is contained in:
Luis Pater
2026-07-09 04:02:51 +08:00
parent 3fd189262d
commit bea9567051
8 changed files with 368 additions and 7 deletions
@@ -245,6 +245,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
body = disableThinkingIfToolChoiceForced(body)
body = normalizeClaudeSamplingForUpstream(body)
// Claude OAuth (and this executor's redact-thinking beta) returns signature-only
// thinking blocks unless display is set to "summarized".
body = ensureClaudeThinkingDisplay(body)
// Auto-inject cache_control if missing (optimization for ClawdBot/clients without caching support)
if countCacheControls(body) == 0 {
@@ -435,6 +438,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
body = disableThinkingIfToolChoiceForced(body)
body = normalizeClaudeSamplingForUpstream(body)
// Claude OAuth (and this executor's redact-thinking beta) returns signature-only
// thinking blocks unless display is set to "summarized".
body = ensureClaudeThinkingDisplay(body)
// Auto-inject cache_control if missing (optimization for ClawdBot/clients without caching support)
if countCacheControls(body) == 0 {
@@ -891,6 +897,27 @@ func normalizeClaudeSamplingForUpstream(body []byte) []byte {
return body
}
// ensureClaudeThinkingDisplay defaults thinking.display to "summarized" when thinking
// is active and the client did not set display. Without this, Claude backends that
// enable redact-thinking return signature-only thinking blocks (empty thinking text).
// Explicit client values such as "omitted" are preserved.
func ensureClaudeThinkingDisplay(body []byte) []byte {
thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String()))
switch thinkingType {
case "enabled", "adaptive", "auto":
default:
return body
}
if display := strings.TrimSpace(gjson.GetBytes(body, "thinking.display").String()); display != "" {
return body
}
out, err := sjson.SetBytes(body, "thinking.display", "summarized")
if err != nil {
return body
}
return out
}
type compositeReadCloser struct {
io.Reader
closers []func() error
@@ -2940,3 +2940,42 @@ func TestRestoreClaudeOAuthToolNamesFromStreamLine_MixedCaseWithPrefix(t *testin
t.Fatalf("Glob should be restored to glob, got: %s", string(out))
}
}
func TestEnsureClaudeThinkingDisplay_SetsSummarizedWhenMissing(t *testing.T) {
payload := []byte(`{"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`)
out := ensureClaudeThinkingDisplay(payload)
if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" {
t.Fatalf("thinking.display = %q, want summarized", got)
}
if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
t.Fatalf("thinking.type = %q, want adaptive", got)
}
}
func TestEnsureClaudeThinkingDisplay_PreservesExplicitValue(t *testing.T) {
payload := []byte(`{"thinking":{"type":"enabled","budget_tokens":2048,"display":"omitted"}}`)
out := ensureClaudeThinkingDisplay(payload)
if got := gjson.GetBytes(out, "thinking.display").String(); got != "omitted" {
t.Fatalf("thinking.display = %q, want omitted", got)
}
}
func TestEnsureClaudeThinkingDisplay_SkipsWhenThinkingDisabled(t *testing.T) {
payload := []byte(`{"thinking":{"type":"disabled"}}`)
out := ensureClaudeThinkingDisplay(payload)
if gjson.GetBytes(out, "thinking.display").Exists() {
t.Fatalf("thinking.display should not be set when thinking is disabled: %s", out)
}
}
func TestEnsureClaudeThinkingDisplay_SkipsWhenThinkingMissing(t *testing.T) {
payload := []byte(`{"messages":[{"role":"user","content":"hi"}]}`)
out := ensureClaudeThinkingDisplay(payload)
if gjson.GetBytes(out, "thinking").Exists() {
t.Fatalf("thinking should remain absent: %s", out)
}
}
@@ -16,6 +16,7 @@ import (
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -169,19 +170,35 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
switch role {
case "system":
systemStart := len(gjson.GetBytes(out, "system").Array())
if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" {
textPart := []byte(`{"type":"text","text":""}`)
textPart, _ = sjson.SetBytes(textPart, "text", contentResult.String())
textPart = common.AttachCacheControl(textPart, message)
out, _ = sjson.SetRawBytes(out, "system.-1", textPart)
} else if contentResult.Exists() && contentResult.IsArray() {
contentResult.ForEach(func(_, part gjson.Result) bool {
if part.Get("type").String() == "text" {
textPart := []byte(`{"type":"text","text":""}`)
textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String())
textPart = common.AttachCacheControl(textPart, part)
out, _ = sjson.SetRawBytes(out, "system.-1", textPart)
}
return true
})
// Message-level cache_control applies to the last system block from this message.
if message.Get("cache_control").Exists() {
systemArr := gjson.GetBytes(out, "system").Array()
if len(systemArr) > systemStart {
lastIdx := len(systemArr) - 1
if !systemArr[lastIdx].Get("cache_control").Exists() {
path := fmt.Sprintf("system.%d", lastIdx)
block := []byte(systemArr[lastIdx].Raw)
block = common.AttachCacheControl(block, message)
out, _ = sjson.SetRawBytes(out, path, block)
}
}
}
}
case "user", "assistant":
msg := []byte(`{"role":"","content":[]}`)
@@ -240,6 +257,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
})
}
msg = common.AttachMessageCacheControl(msg, message)
out, _ = sjson.SetRawBytes(out, "messages.-1", msg)
messageIndex++
@@ -257,6 +275,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
} else {
msg, _ = sjson.SetBytes(msg, "content.0.content", toolResultContent)
}
msg = common.AttachMessageCacheControl(msg, message)
out, _ = sjson.SetRawBytes(out, "messages.-1", msg)
messageIndex++
}
@@ -290,6 +309,10 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
} else if parameters := function.Get("parametersJsonSchema"); parameters.Exists() {
anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", []byte(parameters.Raw))
}
anthropicTool = common.AttachCacheControl(anthropicTool, tool)
if !gjson.GetBytes(anthropicTool, "cache_control").Exists() {
anthropicTool = common.AttachCacheControl(anthropicTool, function)
}
out, _ = sjson.SetRawBytes(out, "tools.-1", anthropicTool)
hasAnthropicTools = true
@@ -331,14 +354,15 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
}
func convertOpenAIContentPartToClaudePart(part gjson.Result) string {
var claudePart []byte
switch part.Get("type").String() {
case "text":
textPart := []byte(`{"type":"text","text":""}`)
textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String())
return string(textPart)
claudePart = textPart
case "image_url":
return convertOpenAIImageURLToClaudePart(part.Get("image_url.url").String())
claudePart = []byte(convertOpenAIImageURLToClaudePart(part.Get("image_url.url").String()))
case "file":
fileData := part.Get("file.file_data").String()
@@ -351,12 +375,15 @@ func convertOpenAIContentPartToClaudePart(part gjson.Result) string {
docPart := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`)
docPart, _ = sjson.SetBytes(docPart, "source.media_type", mediaType)
docPart, _ = sjson.SetBytes(docPart, "source.data", data)
return string(docPart)
claudePart = docPart
}
}
}
return ""
if len(claudePart) == 0 {
return ""
}
return string(common.AttachCacheControl(claudePart, part))
}
func convertOpenAIImageURLToClaudePart(imageURL string) string {
@@ -302,3 +302,107 @@ func TestConvertOpenAIRequestToClaude_SystemOnlyInputKeepsFallbackUserMessage(t
t.Fatalf("Expected fallback text %q, got %q", "", got)
}
}
func TestConvertOpenAIRequestToClaude_PreservesContentPartCacheControl(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "cached prefix", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "fresh question"}
]
}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
}
if resultJSON.Get("messages.0.content.1.cache_control").Exists() {
t.Fatalf("content.1 should not have cache_control. Output: %s", result)
}
if got := resultJSON.Get("messages.0.content.0.text").String(); got != "cached prefix" {
t.Fatalf("content.0.text = %q, want %q", got, "cached prefix")
}
}
func TestConvertOpenAIRequestToClaude_PreservesMessageLevelCacheControl(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{
"role": "user",
"content": "cache me",
"cache_control": {"type": "ephemeral", "ttl": "1h"}
}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
}
if got := resultJSON.Get("messages.0.content.0.cache_control.ttl").String(); got != "1h" {
t.Fatalf("content.0.cache_control.ttl = %q, want 1h. Output: %s", got, result)
}
}
func TestConvertOpenAIRequestToClaude_PreservesToolCacheControl(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [{"role": "user", "content": "hi"}],
"tools": [
{
"type": "function",
"function": {
"name": "lookup",
"description": "Lookup something",
"parameters": {"type": "object", "properties": {}}
},
"cache_control": {"type": "ephemeral"}
}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("tools.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("tools.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
}
if got := resultJSON.Get("tools.0.name").String(); got != "lookup" {
t.Fatalf("tools.0.name = %q, want lookup", got)
}
}
func TestConvertOpenAIRequestToClaude_PartCacheControlWinsOverMessageLevel(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"messages": [
{
"role": "user",
"cache_control": {"type": "ephemeral", "ttl": "1h"},
"content": [
{"type": "text", "text": "part cached", "cache_control": {"type": "ephemeral"}}
]
}
]
}`
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
}
if resultJSON.Get("messages.0.content.0.cache_control.ttl").Exists() {
t.Fatalf("part-level cache_control should win; unexpected ttl: %s", result)
}
}
@@ -12,6 +12,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -237,6 +238,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
textAggregate.WriteString(txt)
contentPart := []byte(`{"type":"text","text":""}`)
contentPart, _ = sjson.SetBytes(contentPart, "text", txt)
contentPart = common.AttachCacheControl(contentPart, part)
partsJSON = append(partsJSON, string(contentPart))
}
if ptype == "input_text" {
@@ -272,6 +274,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
contentPart, _ = sjson.SetBytes(contentPart, "source.url", url)
}
if len(contentPart) > 0 {
contentPart = common.AttachCacheControl(contentPart, part)
partsJSON = append(partsJSON, string(contentPart))
if role == "" {
role = "user"
@@ -297,6 +300,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
contentPart := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`)
contentPart, _ = sjson.SetBytes(contentPart, "source.media_type", mediaType)
contentPart, _ = sjson.SetBytes(contentPart, "source.data", data)
contentPart = common.AttachCacheControl(contentPart, part)
partsJSON = append(partsJSON, string(contentPart))
if role == "" {
role = "user"
@@ -343,21 +347,24 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
if len(partsJSON) > 0 {
msg := []byte(`{"role":"","content":[]}`)
msg, _ = sjson.SetBytes(msg, "role", role)
if len(partsJSON) == 1 && !hasImage && !hasFile && !hasReasoningParts {
// Preserve legacy behavior for single text content
textPart := gjson.Parse(partsJSON[0])
hasPartCacheControl := textPart.Get("cache_control").Exists()
if len(partsJSON) == 1 && !hasImage && !hasFile && !hasReasoningParts && !hasPartCacheControl && !item.Get("cache_control").Exists() {
// Preserve legacy behavior for single text content without cache markers.
msg, _ = sjson.DeleteBytes(msg, "content")
textPart := gjson.Parse(partsJSON[0])
msg, _ = sjson.SetBytes(msg, "content", textPart.Get("text").String())
} else {
for _, partJSON := range partsJSON {
msg, _ = sjson.SetRawBytes(msg, "content.-1", []byte(partJSON))
}
}
msg = common.AttachMessageCacheControl(msg, item)
appendMessage(msg)
} else if textAggregate.Len() > 0 || role == "system" {
msg := []byte(`{"role":"","content":""}`)
msg, _ = sjson.SetBytes(msg, "role", role)
msg, _ = sjson.SetBytes(msg, "content", textAggregate.String())
msg = common.AttachMessageCacheControl(msg, item)
appendMessage(msg)
}
@@ -682,6 +689,10 @@ func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string
tJSON, _ = sjson.SetBytes(tJSON, "description", d)
}
tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", normalizeClaudeToolInputSchema(responsesToolParameters(tool)))
tJSON = common.AttachCacheControl(tJSON, tool)
if !gjson.GetBytes(tJSON, "cache_control").Exists() {
tJSON = common.AttachCacheControl(tJSON, tool.Get("function"))
}
return tJSON, true
}
@@ -321,3 +321,33 @@ func testGPTResponsesReasoningSignature() string {
}
return base64.URLEncoding.EncodeToString(payload)
}
func TestConvertOpenAIResponsesRequestToClaude_PreservesContentPartCacheControl(t *testing.T) {
inputJSON := `{
"model": "gpt-4.1",
"input": [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "cached prefix", "cache_control": {"type": "ephemeral"}},
{"type": "input_text", "text": "fresh question"}
]
}
]
}`
result := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
content := resultJSON.Get("messages.0.content")
if !content.IsArray() {
t.Fatalf("expected content array when cache_control is present, got %s", result)
}
if got := content.Get("0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
}
if content.Get("1.cache_control").Exists() {
t.Fatalf("content.1 should not have cache_control. Output: %s", result)
}
}
@@ -0,0 +1,67 @@
package common
import (
"fmt"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// AttachCacheControl copies a Claude-compatible cache_control object from src onto dst.
// Returns dst unchanged when cache_control is missing or not an object.
func AttachCacheControl(dst []byte, src gjson.Result) []byte {
cc := src.Get("cache_control")
if !cc.Exists() || cc.Type == gjson.Null || !cc.IsObject() {
return dst
}
out, err := sjson.SetRawBytes(dst, "cache_control", []byte(cc.Raw))
if err != nil {
return dst
}
return out
}
// AttachMessageCacheControl applies message-level cache_control onto the last content block.
// Part-level cache_control wins when the last block already has one.
// String content is promoted to a content array so Claude can accept cache_control.
func AttachMessageCacheControl(msg []byte, src gjson.Result) []byte {
cc := src.Get("cache_control")
if !cc.Exists() || cc.Type == gjson.Null || !cc.IsObject() {
return msg
}
content := gjson.GetBytes(msg, "content")
if content.IsArray() {
arr := content.Array()
if len(arr) == 0 {
return msg
}
lastIdx := len(arr) - 1
if arr[lastIdx].Get("cache_control").Exists() {
return msg
}
path := fmt.Sprintf("content.%d.cache_control", lastIdx)
out, err := sjson.SetRawBytes(msg, path, []byte(cc.Raw))
if err != nil {
return msg
}
return out
}
if content.Type != gjson.String {
return msg
}
textPart := []byte(`{"type":"text","text":""}`)
textPart, _ = sjson.SetBytes(textPart, "text", content.String())
textPart, errSet := sjson.SetRawBytes(textPart, "cache_control", []byte(cc.Raw))
if errSet != nil {
return msg
}
out, err := sjson.SetRawBytes(msg, "content", []byte("[]"))
if err != nil {
return msg
}
out, _ = sjson.SetRawBytes(out, "content.-1", textPart)
return out
}
@@ -0,0 +1,56 @@
package common
import (
"testing"
"github.com/tidwall/gjson"
)
func TestAttachCacheControl_CopiesObject(t *testing.T) {
src := gjson.Parse(`{"text":"hi","cache_control":{"type":"ephemeral","ttl":"5m"}}`)
dst := []byte(`{"type":"text","text":"hi"}`)
out := AttachCacheControl(dst, src)
if got := gjson.GetBytes(out, "cache_control.type").String(); got != "ephemeral" {
t.Fatalf("cache_control.type = %q, want ephemeral; out=%s", got, out)
}
if got := gjson.GetBytes(out, "cache_control.ttl").String(); got != "5m" {
t.Fatalf("cache_control.ttl = %q, want 5m; out=%s", got, out)
}
}
func TestAttachCacheControl_IgnoresMissing(t *testing.T) {
src := gjson.Parse(`{"text":"hi"}`)
dst := []byte(`{"type":"text","text":"hi"}`)
out := AttachCacheControl(dst, src)
if gjson.GetBytes(out, "cache_control").Exists() {
t.Fatalf("cache_control should be absent; out=%s", out)
}
}
func TestAttachMessageCacheControl_PromotesStringContent(t *testing.T) {
src := gjson.Parse(`{"role":"user","content":"hi","cache_control":{"type":"ephemeral"}}`)
msg := []byte(`{"role":"user","content":"hi"}`)
out := AttachMessageCacheControl(msg, src)
if got := gjson.GetBytes(out, "content.0.type").String(); got != "text" {
t.Fatalf("content.0.type = %q, want text; out=%s", got, out)
}
if got := gjson.GetBytes(out, "content.0.text").String(); got != "hi" {
t.Fatalf("content.0.text = %q, want hi; out=%s", got, out)
}
if got := gjson.GetBytes(out, "content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral; out=%s", got, out)
}
}
func TestAttachMessageCacheControl_SkipsWhenLastPartHasCacheControl(t *testing.T) {
src := gjson.Parse(`{"cache_control":{"type":"ephemeral","ttl":"1h"}}`)
msg := []byte(`{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}`)
out := AttachMessageCacheControl(msg, src)
if gjson.GetBytes(out, "content.0.cache_control.ttl").Exists() {
t.Fatalf("part-level cache_control should win; out=%s", out)
}
}