headroom/tests/test_cache
Abhay Singh ec6e60ea3e
fix(proxy/anthropic): scope session id by top-level system prompt (#2070)
## Description

`SessionTrackerStore.compute_session_id`
(`headroom/cache/prefix_tracker.py`) computes a fallback
session id (when no `x-headroom-session-id` header is present) from
`model` + system-prompt text.
But it harvests system text **only** from `messages` entries with `role
== "system"`:

```python
for msg in messages:
    if msg.get("role") == "system":
        ...  # collect system text
system_content = json.dumps(system_parts, ...)
key = f"{model}:{system_content}"
```

Anthropic's `/v1/messages` carries the system prompt as a **top-level**
`body["system"]` field —
it never sends `role:"system"` entries inside `messages`. And
`x-headroom-session-id` is a
Headroom-internal header no client sends. So for every genuine Anthropic
request `system_parts`
is empty and the id collapses to `md5(f"{model}:[]")` — **every
conversation on the same model
shares one session id**, and therefore one `PrefixCacheTracker` and all
session-sticky state.

The colliding state cross-contaminates across conversations
(`anthropic.py:1052`):
- sticky `headroom_retrieve` / memory tools keyed purely on `session_id`
(no content guard) get
injected into another conversation's tool list — busting its tools cache
and adding tools its
  client never requested;
- sticky `anthropic-beta` header tokens leak across conversations;
- `frozen_message_count` and the per-session compression cache
cross-contaminate.

(The sibling `StreamingMixin._get_session_key` already reads
`body.get("system")` and its docstring
claims to mirror `compute_session_id` — which it did not.)

Closes: no issue filed — found while auditing the session/prefix
tracker.

## Fix

Add an optional `system` parameter to `compute_session_id` and fold its
text (a plain string or a
list of `{"type":"text"}` blocks) into the hash. The Anthropic handler
passes `body.get("system")`.
OpenAI callers don't pass it (defaults to `None`), so their behavior is
unchanged.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/cache/prefix_tracker.py`: `compute_session_id` accepts an
optional `system` and folds it into the id.
- `headroom/proxy/handlers/anthropic.py`: pass
`system=body.get("system")` when computing the session id.
- `tests/test_cache/test_prefix_tracker.py`: add
`test_compute_session_id_distinguishes_top_level_system` (distinct
systems → distinct ids; list-form == string-form; `system=None`
unchanged).

## Testing

- [x] New regression test added
(`tests/test_cache/test_prefix_tracker.py`)
- [x] Linting/formatting clean — run with the CI-pinned `ruff==0.15.17`
- [ ] Full `pytest` deferred to CI (local-OOM reason below).

```text
$ uvx ruff@0.15.17 check headroom/cache/prefix_tracker.py headroom/proxy/handlers/anthropic.py tests/test_cache/test_prefix_tracker.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the hash logic with
a dependency-free script and left the full pytest to CI.
- Exact command / steps: computed ids for two conversations with the
same model and messages but different top-level `system` prompts,
through the old (never-folds-system) and new logic.
- Observed result: the old logic collapses both to one id (the leak);
the new logic separates them, folds list-form system the same as
string-form, and leaves the `system=None` (OpenAI) path unchanged:

```text
OLD: A=97d8857ba27010bb  B=97d8857ba27010bb  same=True
NEW: A=1e838c0f6e3980a6  B=18ec49bfa8240852  same=False
SESSION-ID SYSTEM FIX VERIFIED (old collapses Anthropic convos; new separates them)
```

- Not tested: a full two-conversation proxy run asserting no sticky-tool
leakage (needs the heavy stack). The fix is confined to
`compute_session_id` + the one handler call site, and the new test
drives the method directly. Full local `pytest` deferred to CI (OOM, per
above).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Backward-compatible: the new `system` parameter defaults to `None`, so
the OpenAI call sites (`openai.py`) need no change and their session ids
are identical.
- @JerrettDavis tagging you — this one lets one Anthropic conversation's
sticky tools/headers leak into another on the same model, so it seemed
worth surfacing. Thanks!
2026-07-12 19:40:58 -04:00
..
__init__.py Add cache optimization module with scalable dynamic content detection 2026-01-07 14:07:49 -08:00
test_anthropic.py Fix all ruff lint and format errors for CI 2026-01-10 15:33:44 -08:00
test_backends.py Add pluggable storage backend abstraction for CompressionStore 2026-01-20 23:25:28 -08:00
test_base.py Fix all ruff lint and format errors for CI 2026-01-10 15:33:44 -08:00
test_client_integration.py Fix ruff formatting 2026-01-17 17:14:12 -08:00
test_dynamic_detector.py fix(cache): name the missing piece in semantic detector guard (#1018) 2026-06-15 16:29:52 -05:00
test_google.py Fix all ruff lint and format errors for CI 2026-01-10 15:33:44 -08:00
test_openai.py Fix all ruff lint and format errors for CI 2026-01-10 15:33:44 -08:00
test_prefix_tracker.py fix(proxy/anthropic): scope session id by top-level system prompt (#2070) 2026-07-12 19:40:58 -04:00
test_registry.py Fix all ruff lint and format errors for CI 2026-01-10 15:33:44 -08:00
test_semantic.py fix(cache/semantic): key entries by context hash, not query text (#2022) 2026-07-11 10:11:09 -05:00