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..e126ee898 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -6452,3 +6452,53 @@ 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"}},{"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{ + "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 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) + } + 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) + } +}