diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -245,6 +245,9 @@ // 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 @@ // 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 { @@ -889,6 +895,27 @@ body, _ = sjson.DeleteBytes(body, "top_k") } 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 { diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -2940,3 +2940,42 @@ 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) + } +} diff --git a/internal/translator/common/cache_control.go b/internal/translator/common/cache_control.go new file mode 100644 --- /dev/null +++ b/internal/translator/common/cache_control.go @@ -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 +} diff --git a/internal/translator/common/cache_control_test.go b/internal/translator/common/cache_control_test.go new file mode 100644 --- /dev/null +++ b/internal/translator/common/cache_control_test.go @@ -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) + } +} diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -16,6 +16,7 @@ "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 @@ 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 @@ }) } + msg = common.AttachMessageCacheControl(msg, message) out, _ = sjson.SetRawBytes(out, "messages.-1", msg) messageIndex++ @@ -257,6 +275,7 @@ } else { msg, _ = sjson.SetBytes(msg, "content.0.content", toolResultContent) } + msg = common.AttachMessageCacheControl(msg, message) out, _ = sjson.SetRawBytes(out, "messages.-1", msg) messageIndex++ } @@ -289,6 +308,10 @@ anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", []byte(parameters.Raw)) } 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) @@ -331,14 +354,15 @@ } 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 @@ 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 { diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go --- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go @@ -302,3 +302,107 @@ 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) + } +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -12,6 +12,7 @@ "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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 } diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go --- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go @@ -321,3 +321,33 @@ } 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) + } +}