mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
2e351c6836
commit
084678df7c
2 changed files with 90 additions and 2 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue