From 5d070f030c26c040c1a866610b9746d8944af0b3 Mon Sep 17 00:00:00 2001 From: warelik Date: Fri, 21 Aug 2026 22:49:11 +0300 Subject: [PATCH 1/2] fix(executor): recursively strip cache_control for non-Anthropic embedders stripCacheControls previously only deleted top-level cache_control fields in tools, system, and messages. A Claude tool_result with structured content blocks that themselves carry cache_control would still forward the Anthropic-only field to Kimi and cause rejection. - Make stripCacheControls only target protocol-level cache_control markers on system/tool/message blocks and nested content arrays (e.g. tool_result content). It leaves arbitrary JSON like tool input_schema properties named "cache_control" untouched. - Add stripContentCacheControls to recurse into nested content arrays without wandering into sibling objects such as tool input_schema or tool_use input. - Add TestStripCacheControls and TestStripCacheControls_NestedToolResultContent. Refs: https://github.com/router-for-me/CLIProxyAPI/pull/5154 --- .../executor/claude_executor_cloaking.go | 36 +++++++++++---- .../runtime/executor/claude_executor_test.go | 46 +++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index af1e9afd1..ac4cb068b 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -1247,20 +1247,40 @@ func countCacheControls(payload []byte) int { // stripCacheControls removes Anthropic-only prompt-caching fields before a // delegated Claude-format request is sent to a provider that does not support -// them, such as Kimi. +// them, such as Kimi. It only targets protocol-level cache_control markers on +// system/tool/message blocks and nested content blocks (e.g. tool_result +// content); arbitrary JSON like tool input_schema properties named +// "cache_control" are left untouched. func stripCacheControls(payload []byte) []byte { result := payload + for i := range gjson.GetBytes(result, "system").Array() { + result, _ = sjson.DeleteBytes(result, fmt.Sprintf("system.%d.cache_control", i)) + result = stripContentCacheControls(result, fmt.Sprintf("system.%d.content", i)) + } for i := range gjson.GetBytes(result, "tools").Array() { result, _ = sjson.DeleteBytes(result, fmt.Sprintf("tools.%d.cache_control", i)) } - for i := range gjson.GetBytes(result, "system").Array() { - result, _ = sjson.DeleteBytes(result, fmt.Sprintf("system.%d.cache_control", i)) + for i := range gjson.GetBytes(result, "messages").Array() { + result, _ = sjson.DeleteBytes(result, fmt.Sprintf("messages.%d.cache_control", i)) + result = stripContentCacheControls(result, fmt.Sprintf("messages.%d.content", i)) } - for messageIndex, message := range gjson.GetBytes(result, "messages").Array() { - result, _ = sjson.DeleteBytes(result, fmt.Sprintf("messages.%d.cache_control", messageIndex)) - for contentIndex := range message.Get("content").Array() { - result, _ = sjson.DeleteBytes(result, fmt.Sprintf("messages.%d.content.%d.cache_control", messageIndex, contentIndex)) - } + return result +} + +// stripContentCacheControls removes cache_control from each block in a content +// array and recurses into nested content arrays (e.g. a tool_result whose +// content is an array of text/image blocks). It does not walk into siblings of +// content such as tool_use input or tool input_schema. +func stripContentCacheControls(payload []byte, contentPath string) []byte { + result := payload + arr := gjson.GetBytes(result, contentPath) + if !arr.IsArray() { + return result + } + for i := range arr.Array() { + itemPath := fmt.Sprintf("%s.%d", contentPath, i) + result, _ = sjson.DeleteBytes(result, fmt.Sprintf("%s.cache_control", itemPath)) + result = stripContentCacheControls(result, fmt.Sprintf("%s.content", itemPath)) } return result } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 5b1c7a715..a8d2aa87b 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -6452,3 +6452,49 @@ func TestClaudeExecutor_CacheTTLIsPairedWithExtendedCacheTTLBeta(t *testing.T) { }) } } + +func TestStripCacheControls(t *testing.T) { + payload := []byte(`{"model":"claude-opus-4","system":[{"type":"text","text":"sys","cache_control":{"type":"ephemeral"}}],"tools":[{"name":"t","cache_control":{"type":"ephemeral"},"input_schema":{"type":"object","properties":{"cache_control":{"type":"string"}}}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}],"cache_control":{"type":"ephemeral"}}]}`) + got := stripCacheControls(payload) + + for _, path := range []string{ + "system.0.cache_control", + "tools.0.cache_control", + "messages.0.cache_control", + "messages.0.content.0.cache_control", + } { + if gjson.GetBytes(got, path).Exists() { + t.Fatalf("cache_control still present at %q: %s", path, got) + } + } + // cache_control inside a tool input_schema is data, not an Anthropic marker. + if gjson.GetBytes(got, "tools.0.input_schema.properties.cache_control.type").String() != "string" { + t.Fatalf("tool input_schema property cache_control should be preserved, got %s", got) + } + if gjson.GetBytes(got, "system.0.text").String() != "sys" { + t.Fatalf("system text not preserved, got %s", got) + } + if gjson.GetBytes(got, "messages.0.content.0.text").String() != "hi" { + t.Fatalf("message content not preserved, got %s", got) + } +} + +func TestStripCacheControls_NestedToolResultContent(t *testing.T) { + payload := []byte(`{"model":"claude-opus-4","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_1","content":[{"type":"text","text":"result","cache_control":{"type":"ephemeral"}}],"cache_control":{"type":"ephemeral"}}]}]}`) + got := stripCacheControls(payload) + + for _, path := range []string{ + "messages.0.content.0.cache_control", + "messages.0.content.0.content.0.cache_control", + } { + if gjson.GetBytes(got, path).Exists() { + t.Fatalf("cache_control still present at %q: %s", path, got) + } + } + if gjson.GetBytes(got, "messages.0.content.0.tool_use_id").String() != "tu_1" { + t.Fatalf("tool_use_id not preserved, got %s", got) + } + if gjson.GetBytes(got, "messages.0.content.0.content.0.text").String() != "result" { + t.Fatalf("nested tool_result content not preserved, got %s", got) + } +} From 3d3a89a2e985e76175335c40c8bae2fa54c69baa Mon Sep 17 00:00:00 2001 From: warelik Date: Sat, 22 Aug 2026 00:05:53 +0300 Subject: [PATCH 2/2] fix(executor): recursively strip cache_control for non-Anthropic embedders stripCacheControls previously only deleted top-level cache_control fields in tools, system, and messages. A Claude tool_result with structured content blocks that themselves carry cache_control would still forward the Anthropic-only field to Kimi and cause rejection. - Make stripCacheControls only target protocol-level cache_control markers on system/tool/message blocks and nested content arrays (e.g. tool_result content). It leaves arbitrary JSON like tool input_schema properties and tool_use input named "cache_control" untouched. - Add stripContentCacheControls to recurse into nested content arrays without wandering into sibling objects such as tool input_schema or tool_use input. - Add TestStripCacheControls and TestStripCacheControls_NestedToolResultContent. Refs: https://github.com/router-for-me/CLIProxyAPI/pull/5154 --- internal/runtime/executor/claude_executor_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index a8d2aa87b..e126ee898 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -6454,7 +6454,7 @@ func TestClaudeExecutor_CacheTTLIsPairedWithExtendedCacheTTLBeta(t *testing.T) { } func TestStripCacheControls(t *testing.T) { - payload := []byte(`{"model":"claude-opus-4","system":[{"type":"text","text":"sys","cache_control":{"type":"ephemeral"}}],"tools":[{"name":"t","cache_control":{"type":"ephemeral"},"input_schema":{"type":"object","properties":{"cache_control":{"type":"string"}}}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}],"cache_control":{"type":"ephemeral"}}]}`) + payload := []byte(`{"model":"claude-opus-4","system":[{"type":"text","text":"sys","cache_control":{"type":"ephemeral"}}],"tools":[{"name":"t","cache_control":{"type":"ephemeral"},"input_schema":{"type":"object","properties":{"cache_control":{"type":"string"}}}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}},{"type":"tool_use","tool_use_id":"tu_1","name":"tool","input":{"cache_control":{"type":"string"}}}],"cache_control":{"type":"ephemeral"}}]}`) got := stripCacheControls(payload) for _, path := range []string{ @@ -6467,10 +6467,14 @@ func TestStripCacheControls(t *testing.T) { t.Fatalf("cache_control still present at %q: %s", path, got) } } - // cache_control inside a tool input_schema is data, not an Anthropic marker. + // cache_control inside a tool input_schema or tool_use input is data, + // not an Anthropic marker. if gjson.GetBytes(got, "tools.0.input_schema.properties.cache_control.type").String() != "string" { t.Fatalf("tool input_schema property cache_control should be preserved, got %s", got) } + if gjson.GetBytes(got, "messages.0.content.1.input.cache_control.type").String() != "string" { + t.Fatalf("tool_use input property cache_control should be preserved, got %s", got) + } if gjson.GetBytes(got, "system.0.text").String() != "sys" { t.Fatalf("system text not preserved, got %s", got) }