fix(proxy): strip cache_control before hashing turn_id

compute_turn_id hashed the raw message dicts, which meant the same
user-text message produced a different hash on each call of one agent
loop because clients (notably Claude Code) move the cache_control
breakpoint to the newest message per call. The user-text block carries
cache_control on call 1 and not on call 2, so the serialized prefix
differs and the turn_id rolls over. Effect downstream: every API call
becomes its own "turn" and any prompt-level aggregation (e.g. the
Headroom desktop app's prompt all-time record) collapses to the
largest single call, not the sum across the prompt.

Add a small recursive normalization pass that strips cache_control from
the hashed prefix and from list-shaped system prompts before hashing.
Two new tests cover cache_control moving between calls on both the
messages array and the system prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garm 2026-04-23 16:04:57 +02:00
parent 2e351c6836
commit 084678df7c
2 changed files with 90 additions and 2 deletions

View file

@ -281,6 +281,28 @@ async def _read_request_json(request: Request) -> dict[str, Any]:
return result
def _strip_per_call_annotations(obj: Any) -> Any:
"""Remove annotations that clients mutate between calls in one agent loop.
``cache_control`` is the main offender: clients (notably Claude Code)
move the cache breakpoint to the newest message on each call, which
means the exact same user-text message carries ``cache_control`` on
call 1 and not on call 2. Hashing the raw message dicts therefore
produces a different turn_id for every iteration of a single agent
loop, collapsing ``turn_id`` to effectively ``request_id`` and
breaking prompt-level aggregation downstream.
"""
if isinstance(obj, dict):
return {
k: _strip_per_call_annotations(v)
for k, v in obj.items()
if k != "cache_control"
}
if isinstance(obj, list):
return [_strip_per_call_annotations(item) for item in obj]
return obj
def compute_turn_id(
model: str,
system: Any,
@ -324,7 +346,7 @@ def compute_turn_id(
if last_text_user_idx is None:
return None
prefix = messages[: last_text_user_idx + 1]
prefix = _strip_per_call_annotations(messages[: last_text_user_idx + 1])
try:
prefix_json = json.dumps(prefix, sort_keys=True, default=str)
except (TypeError, ValueError):
@ -337,7 +359,10 @@ def compute_turn_id(
h.update(system.encode("utf-8", errors="replace"))
elif system is not None:
try:
h.update(json.dumps(system, sort_keys=True, default=str).encode("utf-8"))
normalized_system = _strip_per_call_annotations(system)
h.update(
json.dumps(normalized_system, sort_keys=True, default=str).encode("utf-8")
)
except (TypeError, ValueError):
pass
h.update(b"\0")

View file

@ -154,3 +154,66 @@ def test_none_system_hashes_without_system_segment():
assert a == b
# Different-system values must still produce a different id than None.
assert a != compute_turn_id(MODEL, "some system", messages)
def test_stable_when_cache_control_moves_between_calls():
# Clients like Claude Code move the cache_control breakpoint to the
# newest message on each call: the user-text message carries it on
# call 1 and not on call 2 (where a later tool_result carries it).
# The turn_id must be stable across those calls — otherwise the
# prompt-level aggregator in the desktop app never gets more than one
# call per "turn" and the prompt record degenerates to the biggest
# single call.
call_1_messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": "fix the bug",
"cache_control": {"type": "ephemeral"},
}
],
}
]
call_2_messages = [
{
"role": "user",
"content": [{"type": "text", "text": "fix the bug"}],
},
_assistant_tool_use("t1", "read"),
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "t1",
"content": "file contents",
"cache_control": {"type": "ephemeral"},
}
],
},
]
id1 = compute_turn_id(MODEL, SYSTEM, call_1_messages)
id2 = compute_turn_id(MODEL, SYSTEM, call_2_messages)
assert id1 is not None
assert id1 == id2
def test_stable_when_cache_control_moves_on_system_prompt():
# Same cache-breakpoint mechanic but applied to a list-shaped system
# prompt: the annotation moves between system text blocks across
# calls. The turn_id must ignore it.
system_call_1 = [
{"type": "text", "text": "You are helpful.", "cache_control": {"type": "ephemeral"}}
]
system_call_2 = [{"type": "text", "text": "You are helpful."}]
messages = [_user("hi")]
id1 = compute_turn_id(MODEL, system_call_1, messages)
id2 = compute_turn_id(MODEL, system_call_2, messages)
assert id1 is not None
assert id1 == id2