From 97fdd2e0885e826189d9719d13e2f3ebcb637582 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 5 Mar 2026 22:28:01 +0300 Subject: [PATCH] fix: preserve original JSON bytes in normalizeCacheControlTTL when no TTL change needed normalizeCacheControlTTL unconditionally re-serializes the entire request body through json.Unmarshal/json.Marshal even when no TTL normalization is needed. Go's json.Marshal randomizes map key order and HTML-escapes <, >, & characters (to \u003c, \u003e, \u0026), producing different raw bytes on every call. Anthropic's prompt caching uses byte-prefix matching, so any byte-level difference causes a cache miss. This means the ~119K system prompt and tools are re-processed on every request when routed through CPA. The fix adds a bool return to normalizeTTLForBlock to indicate whether it actually modified anything, and skips the marshal step in normalizeCacheControlTTL when no blocks were changed. --- internal/runtime/executor/claude_executor.go | 26 ++++++++++++++----- .../runtime/executor/claude_executor_test.go | 13 ++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 7d0ddcf2..3dd4ca5e 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -1485,25 +1485,27 @@ func countCacheControlsMap(root map[string]any) int { return count } -func normalizeTTLForBlock(obj map[string]any, seen5m *bool) { +func normalizeTTLForBlock(obj map[string]any, seen5m *bool) bool { ccRaw, exists := obj["cache_control"] if !exists { - return + return false } cc, ok := asObject(ccRaw) if !ok { *seen5m = true - return + return false } ttlRaw, ttlExists := cc["ttl"] ttl, ttlIsString := ttlRaw.(string) if !ttlExists || !ttlIsString || ttl != "1h" { *seen5m = true - return + return false } if *seen5m { delete(cc, "ttl") + return true } + return false } func findLastCacheControlIndex(arr []any) int { @@ -1599,11 +1601,14 @@ func normalizeCacheControlTTL(payload []byte) []byte { } seen5m := false + modified := false if tools, ok := asArray(root["tools"]); ok { for _, tool := range tools { if obj, ok := asObject(tool); ok { - normalizeTTLForBlock(obj, &seen5m) + if normalizeTTLForBlock(obj, &seen5m) { + modified = true + } } } } @@ -1611,7 +1616,9 @@ func normalizeCacheControlTTL(payload []byte) []byte { if system, ok := asArray(root["system"]); ok { for _, item := range system { if obj, ok := asObject(item); ok { - normalizeTTLForBlock(obj, &seen5m) + if normalizeTTLForBlock(obj, &seen5m) { + modified = true + } } } } @@ -1628,12 +1635,17 @@ func normalizeCacheControlTTL(payload []byte) []byte { } for _, item := range content { if obj, ok := asObject(item); ok { - normalizeTTLForBlock(obj, &seen5m) + if normalizeTTLForBlock(obj, &seen5m) { + modified = true + } } } } } + if !modified { + return payload + } return marshalPayloadObject(payload, root) } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index c4a4d644..ead4e299 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -369,6 +369,19 @@ func TestNormalizeCacheControlTTL_DowngradesLaterOneHourBlocks(t *testing.T) { } } +func TestNormalizeCacheControlTTL_PreservesOriginalBytesWhenNoChange(t *testing.T) { + // Payload where no TTL normalization is needed (all blocks use 1h with no + // preceding 5m block). The text intentionally contains HTML chars (<, >, &) + // that json.Marshal would escape to \u003c etc., altering byte identity. + payload := []byte(`{"tools":[{"name":"t1","cache_control":{"type":"ephemeral","ttl":"1h"}}],"system":[{"type":"text","text":"foo & bar","cache_control":{"type":"ephemeral","ttl":"1h"}}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) + + out := normalizeCacheControlTTL(payload) + + if !bytes.Equal(out, payload) { + t.Fatalf("normalizeCacheControlTTL altered bytes when no change was needed.\noriginal: %s\ngot: %s", payload, out) + } +} + func TestEnforceCacheControlLimit_StripsNonLastToolBeforeMessages(t *testing.T) { payload := []byte(`{ "tools": [