Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions internal/runtime/executor/claude_executor_cloaking.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
50 changes: 50 additions & 0 deletions internal/runtime/executor/claude_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading