2026-03-19 09:48:52 -07:00
|
|
|
"""Tests for CompressionCache with LRU eviction."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from headroom.cache.compression_cache import CompressionCache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def cache() -> CompressionCache:
|
|
|
|
|
return CompressionCache()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def small_cache() -> CompressionCache:
|
|
|
|
|
return CompressionCache(max_entries=3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCompressionCache:
|
|
|
|
|
def test_cache_miss_returns_none(self, cache: CompressionCache) -> None:
|
|
|
|
|
h = CompressionCache.content_hash("some content")
|
|
|
|
|
assert cache.get_compressed(h) is None
|
|
|
|
|
|
|
|
|
|
def test_store_and_retrieve(self, cache: CompressionCache) -> None:
|
|
|
|
|
content = "hello world this is a long message"
|
|
|
|
|
h = CompressionCache.content_hash(content)
|
|
|
|
|
cache.store_compressed(h, "hello world...compressed", tokens_saved=15)
|
|
|
|
|
assert cache.get_compressed(h) == "hello world...compressed"
|
|
|
|
|
|
|
|
|
|
def test_different_content_different_hash(self) -> None:
|
|
|
|
|
h1 = CompressionCache.content_hash("content A")
|
|
|
|
|
h2 = CompressionCache.content_hash("content B")
|
|
|
|
|
assert h1 != h2
|
|
|
|
|
|
|
|
|
|
def test_overwrite_same_hash(self, cache: CompressionCache) -> None:
|
|
|
|
|
h = CompressionCache.content_hash("some content")
|
|
|
|
|
cache.store_compressed(h, "v1", tokens_saved=10)
|
|
|
|
|
cache.store_compressed(h, "v2", tokens_saved=20)
|
|
|
|
|
assert cache.get_compressed(h) == "v2"
|
|
|
|
|
|
|
|
|
|
def test_stats_tracking(self, cache: CompressionCache) -> None:
|
|
|
|
|
h = CompressionCache.content_hash("content")
|
|
|
|
|
cache.store_compressed(h, "compressed", tokens_saved=5)
|
|
|
|
|
|
|
|
|
|
# One hit
|
|
|
|
|
cache.get_compressed(h)
|
|
|
|
|
# One miss
|
|
|
|
|
cache.get_compressed("nonexistent")
|
|
|
|
|
|
|
|
|
|
stats = cache.get_stats()
|
|
|
|
|
assert stats["hits"] == 1
|
|
|
|
|
assert stats["misses"] == 1
|
|
|
|
|
assert stats["entries"] == 1
|
|
|
|
|
assert stats["tokens_saved"] == 5
|
|
|
|
|
|
|
|
|
|
def test_eviction_at_max_entries(self, small_cache: CompressionCache) -> None:
|
|
|
|
|
h1 = CompressionCache.content_hash("a")
|
|
|
|
|
h2 = CompressionCache.content_hash("b")
|
|
|
|
|
h3 = CompressionCache.content_hash("c")
|
|
|
|
|
h4 = CompressionCache.content_hash("d")
|
|
|
|
|
|
|
|
|
|
small_cache.store_compressed(h1, "ca", tokens_saved=1)
|
|
|
|
|
small_cache.store_compressed(h2, "cb", tokens_saved=1)
|
|
|
|
|
small_cache.store_compressed(h3, "cc", tokens_saved=1)
|
|
|
|
|
|
|
|
|
|
# Adding a 4th should evict the oldest (h1)
|
|
|
|
|
small_cache.store_compressed(h4, "cd", tokens_saved=1)
|
|
|
|
|
|
|
|
|
|
assert small_cache.get_compressed(h1) is None
|
|
|
|
|
assert small_cache.get_compressed(h2) == "cb"
|
|
|
|
|
assert small_cache.get_compressed(h4) == "cd"
|
|
|
|
|
|
|
|
|
|
def test_access_refreshes_lru(self, small_cache: CompressionCache) -> None:
|
|
|
|
|
h1 = CompressionCache.content_hash("a")
|
|
|
|
|
h2 = CompressionCache.content_hash("b")
|
|
|
|
|
h3 = CompressionCache.content_hash("c")
|
|
|
|
|
h4 = CompressionCache.content_hash("d")
|
|
|
|
|
|
|
|
|
|
small_cache.store_compressed(h1, "ca", tokens_saved=1)
|
|
|
|
|
small_cache.store_compressed(h2, "cb", tokens_saved=1)
|
|
|
|
|
small_cache.store_compressed(h3, "cc", tokens_saved=1)
|
|
|
|
|
|
|
|
|
|
# Access h1 to refresh it
|
|
|
|
|
small_cache.get_compressed(h1)
|
|
|
|
|
|
|
|
|
|
# Adding h4 should evict h2 (oldest untouched), not h1
|
|
|
|
|
small_cache.store_compressed(h4, "cd", tokens_saved=1)
|
|
|
|
|
|
|
|
|
|
assert small_cache.get_compressed(h1) == "ca"
|
|
|
|
|
assert small_cache.get_compressed(h2) is None
|
|
|
|
|
assert small_cache.get_compressed(h4) == "cd"
|
|
|
|
|
|
|
|
|
|
def test_content_hash_list_content(self) -> None:
|
|
|
|
|
"""content_hash handles Anthropic-format list content."""
|
|
|
|
|
list_content = [
|
|
|
|
|
{"type": "text", "text": "hello"},
|
|
|
|
|
{"type": "text", "text": "world"},
|
|
|
|
|
]
|
|
|
|
|
h = CompressionCache.content_hash(list_content)
|
|
|
|
|
assert isinstance(h, str)
|
|
|
|
|
assert len(h) == 16
|
|
|
|
|
|
|
|
|
|
# Same content produces same hash
|
|
|
|
|
assert CompressionCache.content_hash(list_content) == h
|
|
|
|
|
|
|
|
|
|
def test_content_hash_string_length(self) -> None:
|
|
|
|
|
h = CompressionCache.content_hash("test")
|
|
|
|
|
assert len(h) == 16
|
2026-03-19 09:52:33 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCompressionCacheFrozenCount:
|
|
|
|
|
def test_empty_cache_returns_zero(self, cache: CompressionCache) -> None:
|
|
|
|
|
assert cache.compute_frozen_count([]) == 0
|
|
|
|
|
|
fix: complete /v1/responses compression telemetry, multi-frame WS, frozen-count cap
Builds on PR #406 (HTTP /v1/responses PyO3) and PR #410 (WS first-frame
PyO3) which landed the binding for `compress_openai_responses_live_zone`.
This change closes the remaining gaps so every (provider × endpoint ×
auth-mode × streaming) combination compresses AND surfaces in the
dashboard.
Telemetry: extend the PyO3 binding return tuple from `(bytes, modified)`
to `(bytes, modified, tokens_saved, transforms_applied)` by adding
`CompressionManifest::tokens_saved()` and `transforms_applied()`
accessors on the existing manifest. The Python proxy populates
request-log telemetry from the binding output instead of recounting
tokens. Updates the existing 2-tuple call sites in HTTP and WS
first-frame, plus the unpacks in tests.
WebSocket multi-frame compression: subscription Codex users keep a
long-lived WS open and send multiple `response.create` events per
session. PR #410 only compressed the first frame; subsequent frames
went raw. Added `_maybe_compress_response_create_frame` closure inside
`_client_to_upstream` that runs the same Rust dispatcher on every
client→upstream `response.create` text frame, passes other event
types (response.cancel, session.update, etc.) through unchanged, and
accumulates `tokens_saved` / `transforms_applied` /
`ws_frames_compressed` counters across the session.
Pre-existing dashboard gap: `streaming.py` and `anthropic.py` write
`RequestLog` entries; the non-streaming OpenAI HTTP and WS handlers
did not. Result: /transformations/feed was invisible for every Codex
turn and every Cline / OpenClaude / Aider turn. Added the same wiring
in `handle_openai_chat` (non-streaming), `handle_openai_responses`
(non-streaming HTTP), and `handle_openai_responses_ws` (session-end).
All three populate `auth_mode` + `endpoint` tags so the dashboard can
break compression activity down by client class (PAYG / OAuth /
Subscription) and surface (`chat_completions` / `responses_http` /
`responses_ws`). The WS metric record is now unconditional — was
previously gated on `tokens_saved > 0`, so first-frame no-changes
never registered.
compute_frozen_count over-freeze for prose-format clients:
`compute_frozen_count` walked until it found an unstable
`tool_result` / `role: "tool"` block. Cline / OpenClaude / Aider —
clients that embed tool calls as XML inside plain text — never
produce such a boundary, so the function returned `len(messages)` and
the pipeline froze 100% of messages including the brand-new user
turn. Live zone empty → `Transform content_router: 16414 → 16414
tokens (saved 0)`. Reported on Discord 2026-05-07 with Cline+DeepSeek.
Fix: cap at `max(0, len(messages) - 1)`. Updates 3 existing test
assertions whose expected values encoded the old over-freeze. Adds 6
new prose-format invariant tests.
CodeQL "clear-text logging of sensitive information" fix:
`tests/e2e_real_compression.py` previously stored API keys in local
variables in the same scope as diagnostic prints, which CodeQL flagged
via data-flow analysis. Refactored to read keys from `os.environ`
inside the request helper — the credentials never enter the runner's
main scope, so the taint flow never reaches the print.
End-to-end verification with real keys (.env):
/v1/messages (PAYG, non-stream) tok 14109 → 969 saved 13140
/v1/messages (PAYG, stream) tok 14109 → 969 saved 13140
/v1/chat/completions (PAYG, non-stream) tok 18460 → 1374 saved 17086
/v1/chat/completions (PAYG, stream) tok 18460 → 1374 saved 17086 (cache_hit=100%)
/v1/responses HTTP (PAYG, non-stream) bytes 50138 → 597 saved 18391
/v1/responses WS (frame 1) bytes 46429 → 488 saved 16791
/v1/responses WS (frame 2 multi) bytes 46429 → 488 saved 16791
/v1/responses WS (response.cancel) passthrough untouched
Tests: cargo workspace + pytest (4846 pass, 0 fail), make ci-precheck
passed, two E2E scripts (multi-turn HTTP, WS fake-upstream) all green.
2026-05-07 14:50:03 -07:00
|
|
|
def test_user_assistant_stable_with_live_zone_cap(self, cache: CompressionCache) -> None:
|
|
|
|
|
"""Plain user/assistant turns are individually stable, but the
|
|
|
|
|
trailing message is reserved as the live zone — the new turn
|
|
|
|
|
cannot be in any provider prefix cache. See docstring on
|
|
|
|
|
``CompressionCache.compute_frozen_count``."""
|
2026-03-19 09:52:33 -07:00
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "hello"},
|
|
|
|
|
{"role": "assistant", "content": "hi there"},
|
|
|
|
|
{"role": "user", "content": "how are you"},
|
|
|
|
|
]
|
fix: complete /v1/responses compression telemetry, multi-frame WS, frozen-count cap
Builds on PR #406 (HTTP /v1/responses PyO3) and PR #410 (WS first-frame
PyO3) which landed the binding for `compress_openai_responses_live_zone`.
This change closes the remaining gaps so every (provider × endpoint ×
auth-mode × streaming) combination compresses AND surfaces in the
dashboard.
Telemetry: extend the PyO3 binding return tuple from `(bytes, modified)`
to `(bytes, modified, tokens_saved, transforms_applied)` by adding
`CompressionManifest::tokens_saved()` and `transforms_applied()`
accessors on the existing manifest. The Python proxy populates
request-log telemetry from the binding output instead of recounting
tokens. Updates the existing 2-tuple call sites in HTTP and WS
first-frame, plus the unpacks in tests.
WebSocket multi-frame compression: subscription Codex users keep a
long-lived WS open and send multiple `response.create` events per
session. PR #410 only compressed the first frame; subsequent frames
went raw. Added `_maybe_compress_response_create_frame` closure inside
`_client_to_upstream` that runs the same Rust dispatcher on every
client→upstream `response.create` text frame, passes other event
types (response.cancel, session.update, etc.) through unchanged, and
accumulates `tokens_saved` / `transforms_applied` /
`ws_frames_compressed` counters across the session.
Pre-existing dashboard gap: `streaming.py` and `anthropic.py` write
`RequestLog` entries; the non-streaming OpenAI HTTP and WS handlers
did not. Result: /transformations/feed was invisible for every Codex
turn and every Cline / OpenClaude / Aider turn. Added the same wiring
in `handle_openai_chat` (non-streaming), `handle_openai_responses`
(non-streaming HTTP), and `handle_openai_responses_ws` (session-end).
All three populate `auth_mode` + `endpoint` tags so the dashboard can
break compression activity down by client class (PAYG / OAuth /
Subscription) and surface (`chat_completions` / `responses_http` /
`responses_ws`). The WS metric record is now unconditional — was
previously gated on `tokens_saved > 0`, so first-frame no-changes
never registered.
compute_frozen_count over-freeze for prose-format clients:
`compute_frozen_count` walked until it found an unstable
`tool_result` / `role: "tool"` block. Cline / OpenClaude / Aider —
clients that embed tool calls as XML inside plain text — never
produce such a boundary, so the function returned `len(messages)` and
the pipeline froze 100% of messages including the brand-new user
turn. Live zone empty → `Transform content_router: 16414 → 16414
tokens (saved 0)`. Reported on Discord 2026-05-07 with Cline+DeepSeek.
Fix: cap at `max(0, len(messages) - 1)`. Updates 3 existing test
assertions whose expected values encoded the old over-freeze. Adds 6
new prose-format invariant tests.
CodeQL "clear-text logging of sensitive information" fix:
`tests/e2e_real_compression.py` previously stored API keys in local
variables in the same scope as diagnostic prints, which CodeQL flagged
via data-flow analysis. Refactored to read keys from `os.environ`
inside the request helper — the credentials never enter the runner's
main scope, so the taint flow never reaches the print.
End-to-end verification with real keys (.env):
/v1/messages (PAYG, non-stream) tok 14109 → 969 saved 13140
/v1/messages (PAYG, stream) tok 14109 → 969 saved 13140
/v1/chat/completions (PAYG, non-stream) tok 18460 → 1374 saved 17086
/v1/chat/completions (PAYG, stream) tok 18460 → 1374 saved 17086 (cache_hit=100%)
/v1/responses HTTP (PAYG, non-stream) bytes 50138 → 597 saved 18391
/v1/responses WS (frame 1) bytes 46429 → 488 saved 16791
/v1/responses WS (frame 2 multi) bytes 46429 → 488 saved 16791
/v1/responses WS (response.cancel) passthrough untouched
Tests: cargo workspace + pytest (4846 pass, 0 fail), make ci-precheck
passed, two E2E scripts (multi-turn HTTP, WS fake-upstream) all green.
2026-05-07 14:50:03 -07:00
|
|
|
# 3 messages structurally stable; cap clamps to len-1 = 2.
|
|
|
|
|
assert cache.compute_frozen_count(messages) == 2
|
2026-03-19 09:52:33 -07:00
|
|
|
|
fix: complete /v1/responses compression telemetry, multi-frame WS, frozen-count cap
Builds on PR #406 (HTTP /v1/responses PyO3) and PR #410 (WS first-frame
PyO3) which landed the binding for `compress_openai_responses_live_zone`.
This change closes the remaining gaps so every (provider × endpoint ×
auth-mode × streaming) combination compresses AND surfaces in the
dashboard.
Telemetry: extend the PyO3 binding return tuple from `(bytes, modified)`
to `(bytes, modified, tokens_saved, transforms_applied)` by adding
`CompressionManifest::tokens_saved()` and `transforms_applied()`
accessors on the existing manifest. The Python proxy populates
request-log telemetry from the binding output instead of recounting
tokens. Updates the existing 2-tuple call sites in HTTP and WS
first-frame, plus the unpacks in tests.
WebSocket multi-frame compression: subscription Codex users keep a
long-lived WS open and send multiple `response.create` events per
session. PR #410 only compressed the first frame; subsequent frames
went raw. Added `_maybe_compress_response_create_frame` closure inside
`_client_to_upstream` that runs the same Rust dispatcher on every
client→upstream `response.create` text frame, passes other event
types (response.cancel, session.update, etc.) through unchanged, and
accumulates `tokens_saved` / `transforms_applied` /
`ws_frames_compressed` counters across the session.
Pre-existing dashboard gap: `streaming.py` and `anthropic.py` write
`RequestLog` entries; the non-streaming OpenAI HTTP and WS handlers
did not. Result: /transformations/feed was invisible for every Codex
turn and every Cline / OpenClaude / Aider turn. Added the same wiring
in `handle_openai_chat` (non-streaming), `handle_openai_responses`
(non-streaming HTTP), and `handle_openai_responses_ws` (session-end).
All three populate `auth_mode` + `endpoint` tags so the dashboard can
break compression activity down by client class (PAYG / OAuth /
Subscription) and surface (`chat_completions` / `responses_http` /
`responses_ws`). The WS metric record is now unconditional — was
previously gated on `tokens_saved > 0`, so first-frame no-changes
never registered.
compute_frozen_count over-freeze for prose-format clients:
`compute_frozen_count` walked until it found an unstable
`tool_result` / `role: "tool"` block. Cline / OpenClaude / Aider —
clients that embed tool calls as XML inside plain text — never
produce such a boundary, so the function returned `len(messages)` and
the pipeline froze 100% of messages including the brand-new user
turn. Live zone empty → `Transform content_router: 16414 → 16414
tokens (saved 0)`. Reported on Discord 2026-05-07 with Cline+DeepSeek.
Fix: cap at `max(0, len(messages) - 1)`. Updates 3 existing test
assertions whose expected values encoded the old over-freeze. Adds 6
new prose-format invariant tests.
CodeQL "clear-text logging of sensitive information" fix:
`tests/e2e_real_compression.py` previously stored API keys in local
variables in the same scope as diagnostic prints, which CodeQL flagged
via data-flow analysis. Refactored to read keys from `os.environ`
inside the request helper — the credentials never enter the runner's
main scope, so the taint flow never reaches the print.
End-to-end verification with real keys (.env):
/v1/messages (PAYG, non-stream) tok 14109 → 969 saved 13140
/v1/messages (PAYG, stream) tok 14109 → 969 saved 13140
/v1/chat/completions (PAYG, non-stream) tok 18460 → 1374 saved 17086
/v1/chat/completions (PAYG, stream) tok 18460 → 1374 saved 17086 (cache_hit=100%)
/v1/responses HTTP (PAYG, non-stream) bytes 50138 → 597 saved 18391
/v1/responses WS (frame 1) bytes 46429 → 488 saved 16791
/v1/responses WS (frame 2 multi) bytes 46429 → 488 saved 16791
/v1/responses WS (response.cancel) passthrough untouched
Tests: cargo workspace + pytest (4846 pass, 0 fail), make ci-precheck
passed, two E2E scripts (multi-turn HTTP, WS fake-upstream) all green.
2026-05-07 14:50:03 -07:00
|
|
|
def test_tool_result_with_cache_hit_capped_at_live_zone(self, cache: CompressionCache) -> None:
|
2026-03-19 09:52:33 -07:00
|
|
|
tool_content = "tool output data"
|
|
|
|
|
h = CompressionCache.content_hash(tool_content)
|
|
|
|
|
cache.store_compressed(h, "compressed tool output", tokens_saved=5)
|
|
|
|
|
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "do something"},
|
|
|
|
|
{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": [{"type": "tool_use", "id": "t1", "name": "my_tool", "input": {}}],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": tool_content}],
|
|
|
|
|
},
|
|
|
|
|
]
|
fix: complete /v1/responses compression telemetry, multi-frame WS, frozen-count cap
Builds on PR #406 (HTTP /v1/responses PyO3) and PR #410 (WS first-frame
PyO3) which landed the binding for `compress_openai_responses_live_zone`.
This change closes the remaining gaps so every (provider × endpoint ×
auth-mode × streaming) combination compresses AND surfaces in the
dashboard.
Telemetry: extend the PyO3 binding return tuple from `(bytes, modified)`
to `(bytes, modified, tokens_saved, transforms_applied)` by adding
`CompressionManifest::tokens_saved()` and `transforms_applied()`
accessors on the existing manifest. The Python proxy populates
request-log telemetry from the binding output instead of recounting
tokens. Updates the existing 2-tuple call sites in HTTP and WS
first-frame, plus the unpacks in tests.
WebSocket multi-frame compression: subscription Codex users keep a
long-lived WS open and send multiple `response.create` events per
session. PR #410 only compressed the first frame; subsequent frames
went raw. Added `_maybe_compress_response_create_frame` closure inside
`_client_to_upstream` that runs the same Rust dispatcher on every
client→upstream `response.create` text frame, passes other event
types (response.cancel, session.update, etc.) through unchanged, and
accumulates `tokens_saved` / `transforms_applied` /
`ws_frames_compressed` counters across the session.
Pre-existing dashboard gap: `streaming.py` and `anthropic.py` write
`RequestLog` entries; the non-streaming OpenAI HTTP and WS handlers
did not. Result: /transformations/feed was invisible for every Codex
turn and every Cline / OpenClaude / Aider turn. Added the same wiring
in `handle_openai_chat` (non-streaming), `handle_openai_responses`
(non-streaming HTTP), and `handle_openai_responses_ws` (session-end).
All three populate `auth_mode` + `endpoint` tags so the dashboard can
break compression activity down by client class (PAYG / OAuth /
Subscription) and surface (`chat_completions` / `responses_http` /
`responses_ws`). The WS metric record is now unconditional — was
previously gated on `tokens_saved > 0`, so first-frame no-changes
never registered.
compute_frozen_count over-freeze for prose-format clients:
`compute_frozen_count` walked until it found an unstable
`tool_result` / `role: "tool"` block. Cline / OpenClaude / Aider —
clients that embed tool calls as XML inside plain text — never
produce such a boundary, so the function returned `len(messages)` and
the pipeline froze 100% of messages including the brand-new user
turn. Live zone empty → `Transform content_router: 16414 → 16414
tokens (saved 0)`. Reported on Discord 2026-05-07 with Cline+DeepSeek.
Fix: cap at `max(0, len(messages) - 1)`. Updates 3 existing test
assertions whose expected values encoded the old over-freeze. Adds 6
new prose-format invariant tests.
CodeQL "clear-text logging of sensitive information" fix:
`tests/e2e_real_compression.py` previously stored API keys in local
variables in the same scope as diagnostic prints, which CodeQL flagged
via data-flow analysis. Refactored to read keys from `os.environ`
inside the request helper — the credentials never enter the runner's
main scope, so the taint flow never reaches the print.
End-to-end verification with real keys (.env):
/v1/messages (PAYG, non-stream) tok 14109 → 969 saved 13140
/v1/messages (PAYG, stream) tok 14109 → 969 saved 13140
/v1/chat/completions (PAYG, non-stream) tok 18460 → 1374 saved 17086
/v1/chat/completions (PAYG, stream) tok 18460 → 1374 saved 17086 (cache_hit=100%)
/v1/responses HTTP (PAYG, non-stream) bytes 50138 → 597 saved 18391
/v1/responses WS (frame 1) bytes 46429 → 488 saved 16791
/v1/responses WS (frame 2 multi) bytes 46429 → 488 saved 16791
/v1/responses WS (response.cancel) passthrough untouched
Tests: cargo workspace + pytest (4846 pass, 0 fail), make ci-precheck
passed, two E2E scripts (multi-turn HTTP, WS fake-upstream) all green.
2026-05-07 14:50:03 -07:00
|
|
|
# All 3 stable; cap clamps to len-1 = 2 (trailing tool_result is
|
|
|
|
|
# the live zone).
|
|
|
|
|
assert cache.compute_frozen_count(messages) == 2
|
2026-03-19 09:52:33 -07:00
|
|
|
|
|
|
|
|
def test_tool_result_cache_miss_stops_frozen(self, cache: CompressionCache) -> None:
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "hello"},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "t1", "content": "uncached stuff"}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{"role": "user", "content": "follow up"},
|
|
|
|
|
]
|
|
|
|
|
assert cache.compute_frozen_count(messages) == 1
|
|
|
|
|
|
|
|
|
|
def test_frozen_count_with_dropped_messages(self, cache: CompressionCache) -> None:
|
|
|
|
|
cached_content = "cached tool output"
|
|
|
|
|
h = CompressionCache.content_hash(cached_content)
|
|
|
|
|
cache.store_compressed(h, "compressed", tokens_saved=3)
|
|
|
|
|
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "start"},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "t1", "content": cached_content}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [{"type": "tool_result", "tool_use_id": "t2", "content": "not cached"}],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
assert cache.compute_frozen_count(messages) == 2
|
|
|
|
|
|
2026-04-07 17:10:20 -07:00
|
|
|
def test_stable_hash_allows_frozen_count_past_uncached_tool_result(
|
|
|
|
|
self, cache: CompressionCache
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Tool_results marked stable should not stop the frozen count walk."""
|
|
|
|
|
tool_content = "excluded Read output — big file contents"
|
|
|
|
|
h = CompressionCache.content_hash(tool_content)
|
|
|
|
|
cache.mark_stable(h)
|
|
|
|
|
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "hello"},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
2026-04-07 23:27:31 -05:00
|
|
|
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": tool_content}],
|
2026-04-07 17:10:20 -07:00
|
|
|
},
|
|
|
|
|
{"role": "user", "content": "follow up"},
|
|
|
|
|
]
|
fix: complete /v1/responses compression telemetry, multi-frame WS, frozen-count cap
Builds on PR #406 (HTTP /v1/responses PyO3) and PR #410 (WS first-frame
PyO3) which landed the binding for `compress_openai_responses_live_zone`.
This change closes the remaining gaps so every (provider × endpoint ×
auth-mode × streaming) combination compresses AND surfaces in the
dashboard.
Telemetry: extend the PyO3 binding return tuple from `(bytes, modified)`
to `(bytes, modified, tokens_saved, transforms_applied)` by adding
`CompressionManifest::tokens_saved()` and `transforms_applied()`
accessors on the existing manifest. The Python proxy populates
request-log telemetry from the binding output instead of recounting
tokens. Updates the existing 2-tuple call sites in HTTP and WS
first-frame, plus the unpacks in tests.
WebSocket multi-frame compression: subscription Codex users keep a
long-lived WS open and send multiple `response.create` events per
session. PR #410 only compressed the first frame; subsequent frames
went raw. Added `_maybe_compress_response_create_frame` closure inside
`_client_to_upstream` that runs the same Rust dispatcher on every
client→upstream `response.create` text frame, passes other event
types (response.cancel, session.update, etc.) through unchanged, and
accumulates `tokens_saved` / `transforms_applied` /
`ws_frames_compressed` counters across the session.
Pre-existing dashboard gap: `streaming.py` and `anthropic.py` write
`RequestLog` entries; the non-streaming OpenAI HTTP and WS handlers
did not. Result: /transformations/feed was invisible for every Codex
turn and every Cline / OpenClaude / Aider turn. Added the same wiring
in `handle_openai_chat` (non-streaming), `handle_openai_responses`
(non-streaming HTTP), and `handle_openai_responses_ws` (session-end).
All three populate `auth_mode` + `endpoint` tags so the dashboard can
break compression activity down by client class (PAYG / OAuth /
Subscription) and surface (`chat_completions` / `responses_http` /
`responses_ws`). The WS metric record is now unconditional — was
previously gated on `tokens_saved > 0`, so first-frame no-changes
never registered.
compute_frozen_count over-freeze for prose-format clients:
`compute_frozen_count` walked until it found an unstable
`tool_result` / `role: "tool"` block. Cline / OpenClaude / Aider —
clients that embed tool calls as XML inside plain text — never
produce such a boundary, so the function returned `len(messages)` and
the pipeline froze 100% of messages including the brand-new user
turn. Live zone empty → `Transform content_router: 16414 → 16414
tokens (saved 0)`. Reported on Discord 2026-05-07 with Cline+DeepSeek.
Fix: cap at `max(0, len(messages) - 1)`. Updates 3 existing test
assertions whose expected values encoded the old over-freeze. Adds 6
new prose-format invariant tests.
CodeQL "clear-text logging of sensitive information" fix:
`tests/e2e_real_compression.py` previously stored API keys in local
variables in the same scope as diagnostic prints, which CodeQL flagged
via data-flow analysis. Refactored to read keys from `os.environ`
inside the request helper — the credentials never enter the runner's
main scope, so the taint flow never reaches the print.
End-to-end verification with real keys (.env):
/v1/messages (PAYG, non-stream) tok 14109 → 969 saved 13140
/v1/messages (PAYG, stream) tok 14109 → 969 saved 13140
/v1/chat/completions (PAYG, non-stream) tok 18460 → 1374 saved 17086
/v1/chat/completions (PAYG, stream) tok 18460 → 1374 saved 17086 (cache_hit=100%)
/v1/responses HTTP (PAYG, non-stream) bytes 50138 → 597 saved 18391
/v1/responses WS (frame 1) bytes 46429 → 488 saved 16791
/v1/responses WS (frame 2 multi) bytes 46429 → 488 saved 16791
/v1/responses WS (response.cancel) passthrough untouched
Tests: cargo workspace + pytest (4846 pass, 0 fail), make ci-precheck
passed, two E2E scripts (multi-turn HTTP, WS fake-upstream) all green.
2026-05-07 14:50:03 -07:00
|
|
|
# Without mark_stable, the walk would stop at msg[1] → frozen=1.
|
|
|
|
|
# With stable hash, the walk continues past msg[1]; structural
|
|
|
|
|
# count = 3, then capped at len-1 = 2 (live-zone reservation).
|
|
|
|
|
assert cache.compute_frozen_count(messages) == 2
|
2026-04-07 17:10:20 -07:00
|
|
|
|
|
|
|
|
def test_update_from_result_identical_content_marks_stable(
|
|
|
|
|
self, cache: CompressionCache
|
|
|
|
|
) -> None:
|
|
|
|
|
"""When orig == compressed, update_from_result marks the hash as stable."""
|
|
|
|
|
tool_content = "unchanged tool output"
|
|
|
|
|
originals = [
|
|
|
|
|
{"role": "user", "content": "hi"},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
2026-04-07 23:27:31 -05:00
|
|
|
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": tool_content}],
|
2026-04-07 17:10:20 -07:00
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
# Compressed is identical to originals (no compression happened)
|
|
|
|
|
compressed = [
|
|
|
|
|
{"role": "user", "content": "hi"},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
2026-04-07 23:27:31 -05:00
|
|
|
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": tool_content}],
|
2026-04-07 17:10:20 -07:00
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
cache.update_from_result(originals, compressed)
|
|
|
|
|
|
|
|
|
|
h = CompressionCache.content_hash(tool_content)
|
|
|
|
|
assert h in cache._stable_hashes
|
|
|
|
|
|
fix: complete /v1/responses compression telemetry, multi-frame WS, frozen-count cap
Builds on PR #406 (HTTP /v1/responses PyO3) and PR #410 (WS first-frame
PyO3) which landed the binding for `compress_openai_responses_live_zone`.
This change closes the remaining gaps so every (provider × endpoint ×
auth-mode × streaming) combination compresses AND surfaces in the
dashboard.
Telemetry: extend the PyO3 binding return tuple from `(bytes, modified)`
to `(bytes, modified, tokens_saved, transforms_applied)` by adding
`CompressionManifest::tokens_saved()` and `transforms_applied()`
accessors on the existing manifest. The Python proxy populates
request-log telemetry from the binding output instead of recounting
tokens. Updates the existing 2-tuple call sites in HTTP and WS
first-frame, plus the unpacks in tests.
WebSocket multi-frame compression: subscription Codex users keep a
long-lived WS open and send multiple `response.create` events per
session. PR #410 only compressed the first frame; subsequent frames
went raw. Added `_maybe_compress_response_create_frame` closure inside
`_client_to_upstream` that runs the same Rust dispatcher on every
client→upstream `response.create` text frame, passes other event
types (response.cancel, session.update, etc.) through unchanged, and
accumulates `tokens_saved` / `transforms_applied` /
`ws_frames_compressed` counters across the session.
Pre-existing dashboard gap: `streaming.py` and `anthropic.py` write
`RequestLog` entries; the non-streaming OpenAI HTTP and WS handlers
did not. Result: /transformations/feed was invisible for every Codex
turn and every Cline / OpenClaude / Aider turn. Added the same wiring
in `handle_openai_chat` (non-streaming), `handle_openai_responses`
(non-streaming HTTP), and `handle_openai_responses_ws` (session-end).
All three populate `auth_mode` + `endpoint` tags so the dashboard can
break compression activity down by client class (PAYG / OAuth /
Subscription) and surface (`chat_completions` / `responses_http` /
`responses_ws`). The WS metric record is now unconditional — was
previously gated on `tokens_saved > 0`, so first-frame no-changes
never registered.
compute_frozen_count over-freeze for prose-format clients:
`compute_frozen_count` walked until it found an unstable
`tool_result` / `role: "tool"` block. Cline / OpenClaude / Aider —
clients that embed tool calls as XML inside plain text — never
produce such a boundary, so the function returned `len(messages)` and
the pipeline froze 100% of messages including the brand-new user
turn. Live zone empty → `Transform content_router: 16414 → 16414
tokens (saved 0)`. Reported on Discord 2026-05-07 with Cline+DeepSeek.
Fix: cap at `max(0, len(messages) - 1)`. Updates 3 existing test
assertions whose expected values encoded the old over-freeze. Adds 6
new prose-format invariant tests.
CodeQL "clear-text logging of sensitive information" fix:
`tests/e2e_real_compression.py` previously stored API keys in local
variables in the same scope as diagnostic prints, which CodeQL flagged
via data-flow analysis. Refactored to read keys from `os.environ`
inside the request helper — the credentials never enter the runner's
main scope, so the taint flow never reaches the print.
End-to-end verification with real keys (.env):
/v1/messages (PAYG, non-stream) tok 14109 → 969 saved 13140
/v1/messages (PAYG, stream) tok 14109 → 969 saved 13140
/v1/chat/completions (PAYG, non-stream) tok 18460 → 1374 saved 17086
/v1/chat/completions (PAYG, stream) tok 18460 → 1374 saved 17086 (cache_hit=100%)
/v1/responses HTTP (PAYG, non-stream) bytes 50138 → 597 saved 18391
/v1/responses WS (frame 1) bytes 46429 → 488 saved 16791
/v1/responses WS (frame 2 multi) bytes 46429 → 488 saved 16791
/v1/responses WS (response.cancel) passthrough untouched
Tests: cargo workspace + pytest (4846 pass, 0 fail), make ci-precheck
passed, two E2E scripts (multi-turn HTTP, WS fake-upstream) all green.
2026-05-07 14:50:03 -07:00
|
|
|
# Frozen count walks past this tool_result (its hash is stable),
|
|
|
|
|
# but the trailing message is still reserved as live zone.
|
2026-04-07 17:10:20 -07:00
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "hello"},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
2026-04-07 23:27:31 -05:00
|
|
|
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": tool_content}],
|
2026-04-07 17:10:20 -07:00
|
|
|
},
|
|
|
|
|
{"role": "user", "content": "more stuff"},
|
|
|
|
|
]
|
fix: complete /v1/responses compression telemetry, multi-frame WS, frozen-count cap
Builds on PR #406 (HTTP /v1/responses PyO3) and PR #410 (WS first-frame
PyO3) which landed the binding for `compress_openai_responses_live_zone`.
This change closes the remaining gaps so every (provider × endpoint ×
auth-mode × streaming) combination compresses AND surfaces in the
dashboard.
Telemetry: extend the PyO3 binding return tuple from `(bytes, modified)`
to `(bytes, modified, tokens_saved, transforms_applied)` by adding
`CompressionManifest::tokens_saved()` and `transforms_applied()`
accessors on the existing manifest. The Python proxy populates
request-log telemetry from the binding output instead of recounting
tokens. Updates the existing 2-tuple call sites in HTTP and WS
first-frame, plus the unpacks in tests.
WebSocket multi-frame compression: subscription Codex users keep a
long-lived WS open and send multiple `response.create` events per
session. PR #410 only compressed the first frame; subsequent frames
went raw. Added `_maybe_compress_response_create_frame` closure inside
`_client_to_upstream` that runs the same Rust dispatcher on every
client→upstream `response.create` text frame, passes other event
types (response.cancel, session.update, etc.) through unchanged, and
accumulates `tokens_saved` / `transforms_applied` /
`ws_frames_compressed` counters across the session.
Pre-existing dashboard gap: `streaming.py` and `anthropic.py` write
`RequestLog` entries; the non-streaming OpenAI HTTP and WS handlers
did not. Result: /transformations/feed was invisible for every Codex
turn and every Cline / OpenClaude / Aider turn. Added the same wiring
in `handle_openai_chat` (non-streaming), `handle_openai_responses`
(non-streaming HTTP), and `handle_openai_responses_ws` (session-end).
All three populate `auth_mode` + `endpoint` tags so the dashboard can
break compression activity down by client class (PAYG / OAuth /
Subscription) and surface (`chat_completions` / `responses_http` /
`responses_ws`). The WS metric record is now unconditional — was
previously gated on `tokens_saved > 0`, so first-frame no-changes
never registered.
compute_frozen_count over-freeze for prose-format clients:
`compute_frozen_count` walked until it found an unstable
`tool_result` / `role: "tool"` block. Cline / OpenClaude / Aider —
clients that embed tool calls as XML inside plain text — never
produce such a boundary, so the function returned `len(messages)` and
the pipeline froze 100% of messages including the brand-new user
turn. Live zone empty → `Transform content_router: 16414 → 16414
tokens (saved 0)`. Reported on Discord 2026-05-07 with Cline+DeepSeek.
Fix: cap at `max(0, len(messages) - 1)`. Updates 3 existing test
assertions whose expected values encoded the old over-freeze. Adds 6
new prose-format invariant tests.
CodeQL "clear-text logging of sensitive information" fix:
`tests/e2e_real_compression.py` previously stored API keys in local
variables in the same scope as diagnostic prints, which CodeQL flagged
via data-flow analysis. Refactored to read keys from `os.environ`
inside the request helper — the credentials never enter the runner's
main scope, so the taint flow never reaches the print.
End-to-end verification with real keys (.env):
/v1/messages (PAYG, non-stream) tok 14109 → 969 saved 13140
/v1/messages (PAYG, stream) tok 14109 → 969 saved 13140
/v1/chat/completions (PAYG, non-stream) tok 18460 → 1374 saved 17086
/v1/chat/completions (PAYG, stream) tok 18460 → 1374 saved 17086 (cache_hit=100%)
/v1/responses HTTP (PAYG, non-stream) bytes 50138 → 597 saved 18391
/v1/responses WS (frame 1) bytes 46429 → 488 saved 16791
/v1/responses WS (frame 2 multi) bytes 46429 → 488 saved 16791
/v1/responses WS (response.cancel) passthrough untouched
Tests: cargo workspace + pytest (4846 pass, 0 fail), make ci-precheck
passed, two E2E scripts (multi-turn HTTP, WS fake-upstream) all green.
2026-05-07 14:50:03 -07:00
|
|
|
assert cache.compute_frozen_count(messages) == 2
|
2026-04-07 17:10:20 -07:00
|
|
|
|
|
|
|
|
def test_mark_stable_from_messages(self, cache: CompressionCache) -> None:
|
|
|
|
|
"""mark_stable_from_messages records hashes for tool_results."""
|
|
|
|
|
content_a = "tool output A"
|
|
|
|
|
content_b = "tool output B"
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "hi"},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
2026-04-07 23:27:31 -05:00
|
|
|
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": content_a}],
|
2026-04-07 17:10:20 -07:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
2026-04-07 23:27:31 -05:00
|
|
|
"content": [{"type": "tool_result", "tool_use_id": "t2", "content": content_b}],
|
2026-04-07 17:10:20 -07:00
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
# Mark first 2 messages (msg[0] + msg[1])
|
|
|
|
|
cache.mark_stable_from_messages(messages, 2)
|
|
|
|
|
|
|
|
|
|
ha = CompressionCache.content_hash(content_a)
|
|
|
|
|
hb = CompressionCache.content_hash(content_b)
|
|
|
|
|
assert ha in cache._stable_hashes
|
|
|
|
|
assert hb not in cache._stable_hashes # msg[2] not included
|
|
|
|
|
|
|
|
|
|
def test_should_defer_compression_new_content(self, cache: CompressionCache) -> None:
|
fix(proxy): restore Anthropic compression on token mode (issue #327)
Three bugs combined to drive end-to-end compression on the Anthropic
backend to ~0% in token mode (the default). User report #327 saw a
~9× drop in dashboard savings from one day to the next on Claude
Code traffic; the dashboard headline was technically correct but the
underlying compression genuinely was not running. After this change
the same Claude Code-shape multi-turn conversation goes from
14987 → 14371 tokens at the request boundary on turn 1 and only
recompresses the freshest tool_result on subsequent turns, with the
prior turns frozen byte-identical to preserve the upstream prefix
cache.
Bug 1 — IntelligentContextManager inner ContentRouter has no observer
PR #302 (commit cf979958, 2026-04-28) wired CompressionObserver onto
the outer ContentRouter in proxy/server.py and onto SmartCrusher.
The inner ContentRouter constructed lazily inside
IntelligentContextManager._get_content_router (added Jan 18, 2026
in 57b2de5 alongside the COMPRESS_FIRST strategy) was missed. That
inner router handles the bulk of Claude Code's tool_result-block
compression, so per-strategy counters surfaced by PR #314 in v0.15.0
showed compressions_by_strategy={"text": 6} while
summary.compression.total_tokens_removed=1.3M — math-impossible.
Fix: add observer= parameter to IntelligentContextManager.__init__,
forward it to the inner ContentRouter at intelligent_context.py:525,
and pass observer=self.metrics from proxy/server.py.
Bug 2 — TTL deferral marks every fresh tool_result as stable
should_defer_compression in compression_cache.py returned True on
first-sight (added 2026-04-07 in commit 22dad13 with the intent of
batching first-time compressions near the 5-min cache TTL boundary
to trade many small busts for one). The token-mode walker at
anthropic.py:766-787 walks every message past frozen_message_count,
calls should_defer_compression on each fresh tool_result, gets True,
and advances ttl_frozen += 1 — every iteration. Result:
frozen_message_count grows to len(messages), the pipeline freezes
the entire request, and nothing reaches a real compressor.
The defer-first-sight rationale assumes recurring content within
TTL. Real Claude Code traffic produces unique content per turn, so
"defer until next sight" defers forever. Compressing fresh content
on first sight does not bust any prefix cache because Anthropic has
not cached that byte position yet — it's a cache write either way.
Fix: should_defer_compression returns False on first-sight (record
the timestamp; compress now). Subsequent sightings within TTL still
defer (batch window preserved for genuinely repeating content).
Updated tests in test_compression_cache.py to assert the corrected
semantics and verify _first_seen is recorded on first call.
Bug 3 — cross-tokenizer comparison in token-mode inflation guard
anthropic.py:634 sets original_tokens = tokenizer.count_messages(...)
using the proxy-side EstimatingTokenCounter. The token-mode branch
at line 816 set optimized_tokens = result.tokens_after from
pipeline, which uses the provider-side AnthropicProvider tiktoken
estimator. The two tokenizers disagree by ~25% on the same payload.
The inflation guard at line 901
(if optimized_tokens > original_tokens: revert to originals) treats
those two numbers as comparable. After a real 12% compression the
provider-tokenizer figure was still higher than the proxy-tokenizer
baseline, so the guard fired, optimized_messages was reset to the
original input, transforms_applied was emptied, and tokens_saved
went to 0. The dashboard showed no compression even when the
pipeline successfully compressed.
Fix: recount optimized_tokens with the proxy tokenizer right after
the pipeline returns, so the guard compares apples-to-apples. The
recount cost is a few ms on a 50K-token request and is dwarfed by
upstream call latency.
Verification
* 80 targeted tests across test_compression_cache,
test_compression_observability, test_proxy_anthropic_cache_stability,
test_proxy_intelligent_context pass.
* make ci-precheck clean.
* End-to-end real-API run against api.anthropic.com via local proxy:
- Turn 1 fresh: 14987 → 14371 (4.1%) on a 3-tool-round payload;
smart_crusher and diff strategies fired with non-zero savings.
- Turn 2 (turn 1 history + 1 new tool_result): 23161 → 21928 (5.3%);
only the new tool_result compressed; older turns marked
router:protected:user_message; Anthropic returned
cache_creation_input_tokens > 0 confirming the prefix was not
busted.
Two new regression tests in test_compression_observability lock down
the inner ContentRouter observer wiring so a future copy of Bug 1
fails the suite the day it lands.
2026-04-30 12:59:19 -07:00
|
|
|
"""First-time content should NOT be deferred — there is no
|
|
|
|
|
prefix-cache entry to preserve, so compression carries no bust
|
|
|
|
|
cost. Issue #327: prior behavior deferred first-sight, which
|
|
|
|
|
marked every fresh tool_result as stable and disabled
|
|
|
|
|
compression for typical Claude Code workloads.
|
|
|
|
|
"""
|
2026-04-07 17:10:20 -07:00
|
|
|
h = CompressionCache.content_hash("brand new content")
|
fix(proxy): restore Anthropic compression on token mode (issue #327)
Three bugs combined to drive end-to-end compression on the Anthropic
backend to ~0% in token mode (the default). User report #327 saw a
~9× drop in dashboard savings from one day to the next on Claude
Code traffic; the dashboard headline was technically correct but the
underlying compression genuinely was not running. After this change
the same Claude Code-shape multi-turn conversation goes from
14987 → 14371 tokens at the request boundary on turn 1 and only
recompresses the freshest tool_result on subsequent turns, with the
prior turns frozen byte-identical to preserve the upstream prefix
cache.
Bug 1 — IntelligentContextManager inner ContentRouter has no observer
PR #302 (commit cf979958, 2026-04-28) wired CompressionObserver onto
the outer ContentRouter in proxy/server.py and onto SmartCrusher.
The inner ContentRouter constructed lazily inside
IntelligentContextManager._get_content_router (added Jan 18, 2026
in 57b2de5 alongside the COMPRESS_FIRST strategy) was missed. That
inner router handles the bulk of Claude Code's tool_result-block
compression, so per-strategy counters surfaced by PR #314 in v0.15.0
showed compressions_by_strategy={"text": 6} while
summary.compression.total_tokens_removed=1.3M — math-impossible.
Fix: add observer= parameter to IntelligentContextManager.__init__,
forward it to the inner ContentRouter at intelligent_context.py:525,
and pass observer=self.metrics from proxy/server.py.
Bug 2 — TTL deferral marks every fresh tool_result as stable
should_defer_compression in compression_cache.py returned True on
first-sight (added 2026-04-07 in commit 22dad13 with the intent of
batching first-time compressions near the 5-min cache TTL boundary
to trade many small busts for one). The token-mode walker at
anthropic.py:766-787 walks every message past frozen_message_count,
calls should_defer_compression on each fresh tool_result, gets True,
and advances ttl_frozen += 1 — every iteration. Result:
frozen_message_count grows to len(messages), the pipeline freezes
the entire request, and nothing reaches a real compressor.
The defer-first-sight rationale assumes recurring content within
TTL. Real Claude Code traffic produces unique content per turn, so
"defer until next sight" defers forever. Compressing fresh content
on first sight does not bust any prefix cache because Anthropic has
not cached that byte position yet — it's a cache write either way.
Fix: should_defer_compression returns False on first-sight (record
the timestamp; compress now). Subsequent sightings within TTL still
defer (batch window preserved for genuinely repeating content).
Updated tests in test_compression_cache.py to assert the corrected
semantics and verify _first_seen is recorded on first call.
Bug 3 — cross-tokenizer comparison in token-mode inflation guard
anthropic.py:634 sets original_tokens = tokenizer.count_messages(...)
using the proxy-side EstimatingTokenCounter. The token-mode branch
at line 816 set optimized_tokens = result.tokens_after from
pipeline, which uses the provider-side AnthropicProvider tiktoken
estimator. The two tokenizers disagree by ~25% on the same payload.
The inflation guard at line 901
(if optimized_tokens > original_tokens: revert to originals) treats
those two numbers as comparable. After a real 12% compression the
provider-tokenizer figure was still higher than the proxy-tokenizer
baseline, so the guard fired, optimized_messages was reset to the
original input, transforms_applied was emptied, and tokens_saved
went to 0. The dashboard showed no compression even when the
pipeline successfully compressed.
Fix: recount optimized_tokens with the proxy tokenizer right after
the pipeline returns, so the guard compares apples-to-apples. The
recount cost is a few ms on a 50K-token request and is dwarfed by
upstream call latency.
Verification
* 80 targeted tests across test_compression_cache,
test_compression_observability, test_proxy_anthropic_cache_stability,
test_proxy_intelligent_context pass.
* make ci-precheck clean.
* End-to-end real-API run against api.anthropic.com via local proxy:
- Turn 1 fresh: 14987 → 14371 (4.1%) on a 3-tool-round payload;
smart_crusher and diff strategies fired with non-zero savings.
- Turn 2 (turn 1 history + 1 new tool_result): 23161 → 21928 (5.3%);
only the new tool_result compressed; older turns marked
router:protected:user_message; Anthropic returned
cache_creation_input_tokens > 0 confirming the prefix was not
busted.
Two new regression tests in test_compression_observability lock down
the inner ContentRouter observer wiring so a future copy of Bug 1
fails the suite the day it lands.
2026-04-30 12:59:19 -07:00
|
|
|
assert cache.should_defer_compression(h, ttl_seconds=300, batch_window=30) is False
|
|
|
|
|
# Subsequent sightings within TTL should defer (batch window).
|
2026-04-07 17:10:20 -07:00
|
|
|
assert cache.should_defer_compression(h, ttl_seconds=300, batch_window=30) is True
|
|
|
|
|
|
fix(proxy): restore Anthropic compression on token mode (issue #327)
Three bugs combined to drive end-to-end compression on the Anthropic
backend to ~0% in token mode (the default). User report #327 saw a
~9× drop in dashboard savings from one day to the next on Claude
Code traffic; the dashboard headline was technically correct but the
underlying compression genuinely was not running. After this change
the same Claude Code-shape multi-turn conversation goes from
14987 → 14371 tokens at the request boundary on turn 1 and only
recompresses the freshest tool_result on subsequent turns, with the
prior turns frozen byte-identical to preserve the upstream prefix
cache.
Bug 1 — IntelligentContextManager inner ContentRouter has no observer
PR #302 (commit cf979958, 2026-04-28) wired CompressionObserver onto
the outer ContentRouter in proxy/server.py and onto SmartCrusher.
The inner ContentRouter constructed lazily inside
IntelligentContextManager._get_content_router (added Jan 18, 2026
in 57b2de5 alongside the COMPRESS_FIRST strategy) was missed. That
inner router handles the bulk of Claude Code's tool_result-block
compression, so per-strategy counters surfaced by PR #314 in v0.15.0
showed compressions_by_strategy={"text": 6} while
summary.compression.total_tokens_removed=1.3M — math-impossible.
Fix: add observer= parameter to IntelligentContextManager.__init__,
forward it to the inner ContentRouter at intelligent_context.py:525,
and pass observer=self.metrics from proxy/server.py.
Bug 2 — TTL deferral marks every fresh tool_result as stable
should_defer_compression in compression_cache.py returned True on
first-sight (added 2026-04-07 in commit 22dad13 with the intent of
batching first-time compressions near the 5-min cache TTL boundary
to trade many small busts for one). The token-mode walker at
anthropic.py:766-787 walks every message past frozen_message_count,
calls should_defer_compression on each fresh tool_result, gets True,
and advances ttl_frozen += 1 — every iteration. Result:
frozen_message_count grows to len(messages), the pipeline freezes
the entire request, and nothing reaches a real compressor.
The defer-first-sight rationale assumes recurring content within
TTL. Real Claude Code traffic produces unique content per turn, so
"defer until next sight" defers forever. Compressing fresh content
on first sight does not bust any prefix cache because Anthropic has
not cached that byte position yet — it's a cache write either way.
Fix: should_defer_compression returns False on first-sight (record
the timestamp; compress now). Subsequent sightings within TTL still
defer (batch window preserved for genuinely repeating content).
Updated tests in test_compression_cache.py to assert the corrected
semantics and verify _first_seen is recorded on first call.
Bug 3 — cross-tokenizer comparison in token-mode inflation guard
anthropic.py:634 sets original_tokens = tokenizer.count_messages(...)
using the proxy-side EstimatingTokenCounter. The token-mode branch
at line 816 set optimized_tokens = result.tokens_after from
pipeline, which uses the provider-side AnthropicProvider tiktoken
estimator. The two tokenizers disagree by ~25% on the same payload.
The inflation guard at line 901
(if optimized_tokens > original_tokens: revert to originals) treats
those two numbers as comparable. After a real 12% compression the
provider-tokenizer figure was still higher than the proxy-tokenizer
baseline, so the guard fired, optimized_messages was reset to the
original input, transforms_applied was emptied, and tokens_saved
went to 0. The dashboard showed no compression even when the
pipeline successfully compressed.
Fix: recount optimized_tokens with the proxy tokenizer right after
the pipeline returns, so the guard compares apples-to-apples. The
recount cost is a few ms on a 50K-token request and is dwarfed by
upstream call latency.
Verification
* 80 targeted tests across test_compression_cache,
test_compression_observability, test_proxy_anthropic_cache_stability,
test_proxy_intelligent_context pass.
* make ci-precheck clean.
* End-to-end real-API run against api.anthropic.com via local proxy:
- Turn 1 fresh: 14987 → 14371 (4.1%) on a 3-tool-round payload;
smart_crusher and diff strategies fired with non-zero savings.
- Turn 2 (turn 1 history + 1 new tool_result): 23161 → 21928 (5.3%);
only the new tool_result compressed; older turns marked
router:protected:user_message; Anthropic returned
cache_creation_input_tokens > 0 confirming the prefix was not
busted.
Two new regression tests in test_compression_observability lock down
the inner ContentRouter observer wiring so a future copy of Bug 1
fails the suite the day it lands.
2026-04-30 12:59:19 -07:00
|
|
|
def test_should_defer_compression_records_first_seen(self, cache: CompressionCache) -> None:
|
|
|
|
|
"""First-sight call must record the timestamp so subsequent
|
|
|
|
|
in-window calls can defer. Without this the deferral pathway
|
|
|
|
|
for genuinely-repeated content stops working."""
|
|
|
|
|
h = CompressionCache.content_hash("seen-twice content")
|
|
|
|
|
cache.should_defer_compression(h) # first sight
|
|
|
|
|
assert h in cache._first_seen
|
|
|
|
|
|
2026-04-07 17:10:20 -07:00
|
|
|
def test_should_defer_compression_near_ttl(self, cache: CompressionCache) -> None:
|
|
|
|
|
"""Content near TTL boundary should NOT be deferred."""
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
h = CompressionCache.content_hash("old content")
|
|
|
|
|
# Backdate first_seen to simulate age near TTL
|
|
|
|
|
cache._first_seen[h] = time.time() - 280 # 280s old, TTL=300, window=30
|
|
|
|
|
assert cache.should_defer_compression(h, ttl_seconds=300, batch_window=30) is False
|
|
|
|
|
|
2026-03-19 09:52:33 -07:00
|
|
|
|
|
|
|
|
class TestCompressionCacheApplyAndUpdate:
|
|
|
|
|
def test_apply_cached_swaps_tool_results(self, cache: CompressionCache) -> None:
|
|
|
|
|
original_content = "big tool output"
|
|
|
|
|
h = CompressionCache.content_hash(original_content)
|
|
|
|
|
cache.store_compressed(h, "small output", tokens_saved=5)
|
|
|
|
|
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "hi"},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "t1", "content": original_content}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
result = cache.apply_cached(messages)
|
|
|
|
|
assert result[1]["content"][0]["content"] == "small output"
|
|
|
|
|
|
|
|
|
|
def test_apply_cached_preserves_uncached_messages(self, cache: CompressionCache) -> None:
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "hello"},
|
|
|
|
|
{"role": "assistant", "content": "world"},
|
|
|
|
|
]
|
|
|
|
|
result = cache.apply_cached(messages)
|
|
|
|
|
assert result[0] is messages[0]
|
|
|
|
|
assert result[1] is messages[1]
|
|
|
|
|
|
|
|
|
|
def test_apply_cached_never_adds_messages(self, cache: CompressionCache) -> None:
|
|
|
|
|
# Store something in cache that doesn't correspond to any message
|
|
|
|
|
cache.store_compressed("orphan_hash", "orphan_value", tokens_saved=1)
|
|
|
|
|
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "hello"},
|
|
|
|
|
{"role": "assistant", "content": "hi"},
|
|
|
|
|
]
|
|
|
|
|
result = cache.apply_cached(messages)
|
|
|
|
|
assert len(result) == len(messages)
|
|
|
|
|
|
|
|
|
|
def test_update_from_result_caches_changes(self, cache: CompressionCache) -> None:
|
|
|
|
|
originals = [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "t1", "content": "original output"}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
compressed = [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "t1", "content": "compressed output"}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
cache.update_from_result(originals, compressed)
|
|
|
|
|
|
|
|
|
|
h = CompressionCache.content_hash("original output")
|
|
|
|
|
assert cache.get_compressed(h) == "compressed output"
|
|
|
|
|
|
|
|
|
|
def test_update_from_result_ignores_unchanged(self, cache: CompressionCache) -> None:
|
|
|
|
|
originals = [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "t1", "content": "same content"}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
compressed = [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "t1", "content": "same content"}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
cache.update_from_result(originals, compressed)
|
|
|
|
|
h = CompressionCache.content_hash("same content")
|
|
|
|
|
assert cache.get_compressed(h) is None
|
|
|
|
|
|
|
|
|
|
def test_apply_does_not_modify_original_messages(self, cache: CompressionCache) -> None:
|
|
|
|
|
original_content = "big tool output"
|
|
|
|
|
h = CompressionCache.content_hash(original_content)
|
|
|
|
|
cache.store_compressed(h, "small output", tokens_saved=5)
|
|
|
|
|
|
|
|
|
|
msg = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": original_content}],
|
|
|
|
|
}
|
|
|
|
|
messages = [msg]
|
|
|
|
|
cache.apply_cached(messages)
|
|
|
|
|
|
|
|
|
|
# Original must be untouched
|
|
|
|
|
assert msg["content"][0]["content"] == original_content
|
|
|
|
|
|
|
|
|
|
def test_openai_format_tool_result(self, cache: CompressionCache) -> None:
|
|
|
|
|
original_content = "openai tool output"
|
|
|
|
|
h = CompressionCache.content_hash(original_content)
|
|
|
|
|
cache.store_compressed(h, "compressed openai", tokens_saved=4)
|
|
|
|
|
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "tool", "tool_call_id": "tc1", "content": original_content},
|
|
|
|
|
]
|
|
|
|
|
result = cache.apply_cached(messages)
|
|
|
|
|
assert result[0]["content"] == "compressed openai"
|
|
|
|
|
# Original untouched
|
|
|
|
|
assert messages[0]["content"] == original_content
|
fix(proxy): cache concurrency lock, multi-worker docs, bounded compression executor
Three audit follow-ups from issue #327's deep-dive review.
C1 — CompressionCache concurrency lock
======================================
`CompressionCache` instances are shared per `session_id` and accessed from
async-dispatched threadpool workers. Pre-fix, concurrent requests for the
same session raced on `_cache`, `_stable_hashes`, `_first_seen`, and
`_total_tokens_saved` with no synchronization. Observable failures:
* Lost-update on `_total_tokens_saved` (read-modify-write).
* `RuntimeError: OrderedDict mutated during iteration` from `apply_cached`
when a concurrent `store_compressed` evicts during the walk.
* Lost stable-hash records — next-turn compute_frozen_count reads
inconsistent state.
May also explain part of SvenMeyer's `_cache: 0 entries / 1003 misses`
observation: the cache was being clobbered concurrently.
Added `threading.RLock` guarding all mutating methods. `RLock` (not `Lock`)
so future code can call locked methods from inside another locked method
without self-deadlock. Also locked `HeadroomProxy._compression_caches`
dict-of-caches access via a separate `_compression_caches_lock` so two
concurrent calls for the same session_id can't each create distinct
CompressionCache objects (which would split the cache state between them).
The `/stats` endpoint snapshots the cache list under the dict lock before
iterating to avoid eviction-during-iteration.
C2 — Multi-worker CCR fragmentation: documented + startup warning
=================================================================
The in-memory `InMemoryCcrStore` (Rust), `_compression_caches` (Python),
`session_tracker_store` (Python), and TOIN learner state are ALL
per-process. Multi-worker uvicorn round-robins requests across workers,
so a session whose turn-1 lands on worker A may have turn-2 land on
worker B. Worker B has zero knowledge of A's CCR markers, replay cache,
or prefix-cache state. Result: `Retrieve original: hash=X` markers stay
in-context as opaque directives, every fresh tool_result is recompressed
from scratch, and `frozen_message_count=0` causes Anthropic prefix-cache
busts on every cross-worker turn.
Added a "Multi-worker deployment — CCR fragmentation" section in
`RUST_DEV.md` documenting the failure modes, the supported configuration
(`--workers 1`), and the sticky-session workaround for horizontal scale.
The proxy emits a `WARNING`-level log line on startup if `workers > 1` is
detected, pointing at the doc section.
C3 — Bounded compression executor with cancel-aware metrics
===========================================================
`asyncio.wait_for(asyncio.to_thread(pipeline.apply), timeout=...)`
cancellation does NOT propagate into the threadpool worker that's running
Rust code. Once the worker has picked up the task,
`concurrent.futures.Future.cancel()` returns False and the thread runs to
completion. Stuck threads accumulated invisibly on asyncio's default
executor, contending with unrelated `to_thread` callers (file IO, etc.).
Replaced all 7 `asyncio.to_thread` call sites for `pipeline.apply()`
across `proxy/handlers/anthropic.py` (3) and `proxy/handlers/openai.py` (4)
with a new `HeadroomProxy._run_compression_in_executor(fn, *, timeout)`
helper that:
1. Submits to a dedicated bounded `ThreadPoolExecutor` named
`headroom-compress` (configurable via
`ProxyConfig.compression_max_workers`; defaults to
`min(32, (cpu_count or 1) * 4)`).
2. Increments `_compression_in_flight` (gauge) when work starts and
decrements when work completes; tracks `_compression_in_flight_max`
as a high-water mark.
3. Detects "leaked threads" by comparing wall-clock elapsed against the
timeout in the worker's `finally` block. Increments
`_compression_leaked_threads` when a worker finishes after its
asyncio future was cancelled. Operators can see the leaked-thread
rate climbing in `/stats runtime.compression_executor` BEFORE the
pool fills up.
Tests
=====
* `TestCompressionCacheConcurrency` (3 tests) — many threads
store_compressed / apply_cached / update_from_result on a single
CompressionCache; assert no exceptions, no lost updates, no partial
state.
* `test_get_compression_cache_returns_same_instance_under_contention` —
32 concurrent `_get_compression_cache(same_id)` calls return the
identical instance (would split pre-lock).
* `test_proxy_compression_executor.py` (8 tests) — pool size respects
config, in-flight gauge tracks running compressions, high-water mark
is monotonic, timeout propagates to awaiter, leaked-thread counter
increments on post-deadline completion, `/stats` surfaces all three
gauges.
Verification
============
* All 123 targeted regression tests pass.
* `make ci-precheck` clean.
* No `Co-Authored-By` trailer; conventional `fix:` prefix; no
`--no-verify`.
2026-05-01 15:25:18 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── C1 (audit follow-up): concurrency regression suite ────────────────────
|
|
|
|
|
#
|
|
|
|
|
# CompressionCache must be safe under multi-threaded mutation. The proxy is
|
|
|
|
|
# async and dispatches multiple concurrent requests per `session_id` into
|
|
|
|
|
# `asyncio.to_thread` workers — a single CompressionCache instance therefore
|
|
|
|
|
# sees concurrent calls to `store_compressed` / `get_compressed` /
|
|
|
|
|
# `mark_stable_from_messages` / `apply_cached` / `update_from_result`.
|
|
|
|
|
# These tests provoke the race conditions that motivated adding `_lock`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCompressionCacheConcurrency:
|
|
|
|
|
"""Threading regression suite for the audit-followup lock."""
|
|
|
|
|
|
|
|
|
|
def test_concurrent_store_does_not_corrupt_total_tokens_saved(self) -> None:
|
|
|
|
|
"""Many threads each store_compressed with tokens_saved=N; the
|
|
|
|
|
bookkeeping field must equal SUM(N) when threads finish. Pre-lock
|
|
|
|
|
this races (read-modify-write of `_total_tokens_saved`)."""
|
|
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
cache = CompressionCache(max_entries=1_000_000)
|
|
|
|
|
n_threads = 32
|
|
|
|
|
per_thread = 100
|
|
|
|
|
per_thread_tokens = 7
|
|
|
|
|
|
|
|
|
|
def worker(tid: int) -> None:
|
|
|
|
|
for i in range(per_thread):
|
|
|
|
|
h = CompressionCache.content_hash(f"thread-{tid}-item-{i}")
|
|
|
|
|
cache.store_compressed(h, f"comp-{tid}-{i}", tokens_saved=per_thread_tokens)
|
|
|
|
|
|
|
|
|
|
threads = [threading.Thread(target=worker, args=(t,)) for t in range(n_threads)]
|
|
|
|
|
for t in threads:
|
|
|
|
|
t.start()
|
|
|
|
|
for t in threads:
|
|
|
|
|
t.join()
|
|
|
|
|
|
|
|
|
|
expected = n_threads * per_thread * per_thread_tokens
|
|
|
|
|
stats = cache.get_stats()
|
|
|
|
|
assert stats["entries"] == n_threads * per_thread
|
|
|
|
|
# The expected token count is exact only because each (thread, item)
|
|
|
|
|
# produces a unique hash → no overwrite path. Pre-lock this would be
|
|
|
|
|
# < expected due to lost updates.
|
|
|
|
|
assert stats["tokens_saved"] == expected
|
|
|
|
|
|
|
|
|
|
def test_concurrent_apply_cached_with_concurrent_store_does_not_raise(self) -> None:
|
|
|
|
|
"""`apply_cached` iterates `_cache` (via `get_compressed`); if a
|
|
|
|
|
concurrent `store_compressed` mutates the OrderedDict during the
|
|
|
|
|
iteration, pre-lock you'd get `RuntimeError: OrderedDict mutated
|
|
|
|
|
during iteration`. Locks make this a single critical section."""
|
|
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
cache = CompressionCache()
|
|
|
|
|
|
|
|
|
|
# Pre-populate so apply_cached has work to do.
|
|
|
|
|
for i in range(50):
|
|
|
|
|
h = CompressionCache.content_hash(f"seed-{i}")
|
|
|
|
|
cache.store_compressed(h, f"comp-{i}", tokens_saved=1)
|
|
|
|
|
|
|
|
|
|
msgs = [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_result",
|
|
|
|
|
"tool_use_id": f"t{i}",
|
|
|
|
|
"content": f"seed-{i}",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
for i in range(50)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
stop = threading.Event()
|
|
|
|
|
errors: list[Exception] = []
|
|
|
|
|
|
|
|
|
|
def reader() -> None:
|
|
|
|
|
try:
|
|
|
|
|
while not stop.is_set():
|
|
|
|
|
_ = cache.apply_cached(msgs)
|
|
|
|
|
except Exception as e: # pragma: no cover
|
|
|
|
|
errors.append(e)
|
|
|
|
|
|
|
|
|
|
def writer() -> None:
|
|
|
|
|
try:
|
|
|
|
|
for i in range(500):
|
|
|
|
|
h = CompressionCache.content_hash(f"writer-{i}")
|
|
|
|
|
cache.store_compressed(h, f"w-{i}", tokens_saved=1)
|
|
|
|
|
except Exception as e: # pragma: no cover
|
|
|
|
|
errors.append(e)
|
|
|
|
|
|
|
|
|
|
readers = [threading.Thread(target=reader) for _ in range(4)]
|
|
|
|
|
writers = [threading.Thread(target=writer) for _ in range(4)]
|
|
|
|
|
for t in readers + writers:
|
|
|
|
|
t.start()
|
|
|
|
|
for t in writers:
|
|
|
|
|
t.join()
|
|
|
|
|
stop.set()
|
|
|
|
|
for t in readers:
|
|
|
|
|
t.join()
|
|
|
|
|
|
|
|
|
|
assert errors == [], f"Concurrent ops raised: {errors}"
|
|
|
|
|
|
|
|
|
|
def test_concurrent_update_from_result_no_partial_state(self) -> None:
|
|
|
|
|
"""update_from_result must be all-or-nothing per call. With many
|
|
|
|
|
threads calling update_from_result in parallel on the same cache,
|
|
|
|
|
the final state must reflect every call's full effect (no partial
|
|
|
|
|
writes)."""
|
|
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
cache = CompressionCache()
|
|
|
|
|
|
|
|
|
|
n_threads = 16
|
|
|
|
|
per_thread_calls = 20
|
|
|
|
|
|
|
|
|
|
def worker(tid: int) -> None:
|
|
|
|
|
for i in range(per_thread_calls):
|
|
|
|
|
orig_text = f"orig-{tid}-{i}-" + "X" * 200
|
|
|
|
|
comp_text = f"comp-{tid}-{i}-" + "X" * 50
|
|
|
|
|
originals = [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_result",
|
|
|
|
|
"tool_use_id": f"t-{tid}-{i}",
|
|
|
|
|
"content": orig_text,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
compressed = [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_result",
|
|
|
|
|
"tool_use_id": f"t-{tid}-{i}",
|
|
|
|
|
"content": comp_text,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
cache.update_from_result(originals, compressed)
|
|
|
|
|
|
|
|
|
|
threads = [threading.Thread(target=worker, args=(t,)) for t in range(n_threads)]
|
|
|
|
|
for t in threads:
|
|
|
|
|
t.start()
|
|
|
|
|
for t in threads:
|
|
|
|
|
t.join()
|
|
|
|
|
|
|
|
|
|
stats = cache.get_stats()
|
|
|
|
|
# Each (tid, i) is a unique hash → cache entries == n_threads * per_thread_calls.
|
|
|
|
|
assert stats["entries"] == n_threads * per_thread_calls
|
|
|
|
|
assert stats["tokens_saved"] > 0
|
|
|
|
|
|
refactor: DRY cache logic, add thread safety, fix Bash exclusion (#704)
## Description
Four targeted improvements to ContentRouter and configuration,
refactoring ~120 lines of duplicated cache logic into a shared helper
and fixing several correctness issues.
### 1. DRY: Extract `_compress_block_content` helper
The two-tier cache lookup + compression logic was duplicated ~60 lines
per path (tool_result blocks and text blocks in
`_process_content_blocks`). Extracted into a single, shared helper
method. Net reduction of ~80 lines; no behavioural change.
### 2. Thread-safe `CompressionCache`
`CompressionCache` is read/modified from `ThreadPoolExecutor` workers
during parallel compression in `apply()`. Added a `threading.Lock`
guarding all read-modify-write operations so concurrent cache misses for
the same content do not produce duplicate compression work and metrics
counters stay consistent.
### 3. Remove duplicate Kompress fallback for SmartCrusher
The SMART_CRUSHER strategy block had an inline Kompress fallback that
ran when SmartCrusher produced no savings. The unified post-strategy
fallback block already covers the same case — the inline copy was a
duplicate Kompress invocation. Removed it; the post-strategy handler now
owns all fallback decisions for both SMART_CRUSHER and CODE_AWARE. Also
added a guard preventing duplicate Kompress when CODE_AWARE's inline
fallback fires alongside the unified block.
### 4. Fix Bash exclusion contradiction in `DEFAULT_EXCLUDE_TOOLS`
The docstring on `DEFAULT_EXCLUDE_TOOLS` explicitly states "Bash is NOT
excluded — its outputs (build logs, test output) are ideal compression
targets." But both "Bash" and "bash" were still in the frozenset.
Removed them so code matches the documented intent.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/config.py`: Remove Bash/bash from `DEFAULT_EXCLUDE_TOOLS`
- `headroom/transforms/content_router.py`: Extract
`_compress_block_content` helper; unified post-strategy fallback block;
threading.Lock on CompressionCache; CODE_AWARE duplicate guard
- `headroom/client.py`: Replace silent `except Exception: pass` with
`logger.debug(..., exc_info=True)`
- `tests/test_compression_cache.py`: Add 2 concurrency regression tests
- `tests/test_transforms/test_content_router.py`: Add 14 tests covering
Bash exclusion, SmartCrusher fallback chain, and
`_compress_block_content` shared path
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Formatting passes (`ruff format --check .`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
# 14 new tests added across 3 test classes:
# TestExcludeTools: 3 tests (Bash not in DEFAULT_EXCLUDE_TOOLS)
# TestSmartCrusherFallback: 4 tests (fallback chain, no duplicate Kompress, JSON direct hit, CODE_AWARE path)
# TestCompressBlockContent: 5 tests (skip set, result cache, ratio gating, route counts, transforms tracking)
# TestCompressionCache: 2 tests (concurrent hits/misses consistency, stable hash ops no race)
# Local run (43 tests pass):
$ pytest tests/test_compression_cache.py tests/test_transforms/test_content_router.py -v
...43 passed...
# ruff check:
$ ruff check headroom/client.py headroom/config.py headroom/transforms/content_router.py tests/test_compression_cache.py tests/test_transforms/test_content_router.py
All checks passed!
# ruff format:
$ ruff format --check headroom/client.py headroom/config.py headroom/transforms/content_router.py tests/test_compression_cache.py tests/test_transforms/test_content_router.py
5 files already formatted
```
## Real Behavior Proof
- Environment: Python 3.12, Linux (CI), headroom with headroom._core
Rust extension compiled
- Exact command / steps: CI run
https://github.com/chopratejas/headroom/actions/runs/27326150021 — 13/16
jobs pass; 2 failures were lint+commitlint (both fixed in subsequent
commits); 1 failure is pre-existing test(4) which monkeypatches
time.time() but the CompressionCache uses time.monotonic() — unrelated
to our changes
- Observed result: All 14 new tests pass in CI; SmartCrusher fallback
chain deterministically shows [smart_crusher, kompress] or
[smart_crusher, kompress, log] when SmartCrusher produces no savings,
with no duplicate entries
- Not tested: fork-PR CI path where GitHub secrets are not available;
local Windows environment where headroom._core Rust extension is not
built
## 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
- [x] 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
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Additional Notes
The pre-existing CI failure in `test (4)` is
`test_compression_cache_handles_hits_skips_evictions_and_clear` in
`tests/test_transforms_content_router.py`. It monkeypatches
`time.time()` but the `CompressionCache` (content_router-local, line
191) uses `time.monotonic()` for TTL — the monkeypatched clock never
advances, and `is_skipped()` always returns True. This failure exists on
`main` and is unrelated to our changes (we only modified the other
CompressionCache in `headroom/cache/compression_cache.py`).
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 03:50:04 +08:00
|
|
|
def test_concurrent_hits_misses_consistent(self) -> None:
|
|
|
|
|
"""Under concurrent reads + writes, hits+misses must be bounded by
|
|
|
|
|
total lookups (hits ≤ entries, misses ≥ 0 at all moments)."""
|
|
|
|
|
import random
|
|
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
cache = CompressionCache(max_entries=1_000_000)
|
|
|
|
|
n_threads = 16
|
|
|
|
|
per_thread = 50
|
|
|
|
|
|
|
|
|
|
# Pre-populate so reads have something to hit
|
|
|
|
|
for i in range(per_thread):
|
|
|
|
|
h = CompressionCache.content_hash(f"hit-{i}")
|
|
|
|
|
cache.store_compressed(h, f"comp-{i}", tokens_saved=3)
|
|
|
|
|
|
|
|
|
|
errors: list[Exception] = []
|
|
|
|
|
barrier = threading.Barrier(n_threads)
|
|
|
|
|
|
|
|
|
|
def worker(tid: int) -> None:
|
|
|
|
|
try:
|
|
|
|
|
barrier.wait()
|
|
|
|
|
for i in range(per_thread):
|
|
|
|
|
if random.random() < 0.6:
|
|
|
|
|
# Read path
|
|
|
|
|
_ = cache.get_compressed(
|
|
|
|
|
CompressionCache.content_hash(
|
|
|
|
|
f"hit-{random.randint(0, per_thread - 1)}"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# Write path
|
|
|
|
|
h = CompressionCache.content_hash(f"write-{tid}-{i}")
|
|
|
|
|
cache.store_compressed(h, f"w-{tid}-{i}", tokens_saved=1)
|
|
|
|
|
except Exception as e: # pragma: no cover
|
|
|
|
|
errors.append(e)
|
|
|
|
|
|
|
|
|
|
threads = [threading.Thread(target=worker, args=(t,)) for t in range(n_threads)]
|
|
|
|
|
for t in threads:
|
|
|
|
|
t.start()
|
|
|
|
|
for t in threads:
|
|
|
|
|
t.join()
|
|
|
|
|
|
|
|
|
|
assert errors == [], f"Concurrent reads+writes raised: {errors}"
|
|
|
|
|
stats = cache.get_stats()
|
|
|
|
|
# hits + misses should be non-negative (sanity)
|
|
|
|
|
assert stats["hits"] >= 0
|
|
|
|
|
assert stats["misses"] >= 0
|
|
|
|
|
assert stats["entries"] > 0
|
|
|
|
|
|
|
|
|
|
def test_concurrent_stable_hash_ops_no_race(self) -> None:
|
|
|
|
|
"""Concurrent mark_stable_from_messages + compute_frozen_count must
|
|
|
|
|
not race — stable_hashes must remain self-consistent."""
|
|
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
cache = CompressionCache()
|
|
|
|
|
n_threads = 12
|
|
|
|
|
per_thread = 30
|
|
|
|
|
|
|
|
|
|
# Each thread has its own content; produce tool_result messages
|
|
|
|
|
# and mark them stable, then verify frozen count.
|
|
|
|
|
errors: list[Exception] = []
|
|
|
|
|
barrier = threading.Barrier(n_threads)
|
|
|
|
|
|
|
|
|
|
def worker(tid: int) -> None:
|
|
|
|
|
try:
|
|
|
|
|
barrier.wait()
|
|
|
|
|
for i in range(per_thread):
|
|
|
|
|
content = f"stable-content-{tid}-{i}"
|
|
|
|
|
h = CompressionCache.content_hash(content)
|
|
|
|
|
# Also store to make it appear cached
|
|
|
|
|
cache.store_compressed(h, f"comp-{tid}-{i}", tokens_saved=2)
|
|
|
|
|
# Mark stable
|
|
|
|
|
cache.mark_stable(h)
|
|
|
|
|
except Exception as e: # pragma: no cover
|
|
|
|
|
errors.append(e)
|
|
|
|
|
|
|
|
|
|
threads = [threading.Thread(target=worker, args=(t,)) for t in range(n_threads)]
|
|
|
|
|
for t in threads:
|
|
|
|
|
t.start()
|
|
|
|
|
for t in threads:
|
|
|
|
|
t.join()
|
|
|
|
|
|
|
|
|
|
assert errors == [], f"Concurrent stable-hash ops raised: {errors}"
|
|
|
|
|
stats = cache.get_stats()
|
|
|
|
|
# All entries should be recorded; stable_hashes should match entries
|
|
|
|
|
# (every store_compressed was followed by mark_stable in our test)
|
|
|
|
|
assert stats["entries"] == n_threads * per_thread
|
|
|
|
|
|
fix(proxy): cache concurrency lock, multi-worker docs, bounded compression executor
Three audit follow-ups from issue #327's deep-dive review.
C1 — CompressionCache concurrency lock
======================================
`CompressionCache` instances are shared per `session_id` and accessed from
async-dispatched threadpool workers. Pre-fix, concurrent requests for the
same session raced on `_cache`, `_stable_hashes`, `_first_seen`, and
`_total_tokens_saved` with no synchronization. Observable failures:
* Lost-update on `_total_tokens_saved` (read-modify-write).
* `RuntimeError: OrderedDict mutated during iteration` from `apply_cached`
when a concurrent `store_compressed` evicts during the walk.
* Lost stable-hash records — next-turn compute_frozen_count reads
inconsistent state.
May also explain part of SvenMeyer's `_cache: 0 entries / 1003 misses`
observation: the cache was being clobbered concurrently.
Added `threading.RLock` guarding all mutating methods. `RLock` (not `Lock`)
so future code can call locked methods from inside another locked method
without self-deadlock. Also locked `HeadroomProxy._compression_caches`
dict-of-caches access via a separate `_compression_caches_lock` so two
concurrent calls for the same session_id can't each create distinct
CompressionCache objects (which would split the cache state between them).
The `/stats` endpoint snapshots the cache list under the dict lock before
iterating to avoid eviction-during-iteration.
C2 — Multi-worker CCR fragmentation: documented + startup warning
=================================================================
The in-memory `InMemoryCcrStore` (Rust), `_compression_caches` (Python),
`session_tracker_store` (Python), and TOIN learner state are ALL
per-process. Multi-worker uvicorn round-robins requests across workers,
so a session whose turn-1 lands on worker A may have turn-2 land on
worker B. Worker B has zero knowledge of A's CCR markers, replay cache,
or prefix-cache state. Result: `Retrieve original: hash=X` markers stay
in-context as opaque directives, every fresh tool_result is recompressed
from scratch, and `frozen_message_count=0` causes Anthropic prefix-cache
busts on every cross-worker turn.
Added a "Multi-worker deployment — CCR fragmentation" section in
`RUST_DEV.md` documenting the failure modes, the supported configuration
(`--workers 1`), and the sticky-session workaround for horizontal scale.
The proxy emits a `WARNING`-level log line on startup if `workers > 1` is
detected, pointing at the doc section.
C3 — Bounded compression executor with cancel-aware metrics
===========================================================
`asyncio.wait_for(asyncio.to_thread(pipeline.apply), timeout=...)`
cancellation does NOT propagate into the threadpool worker that's running
Rust code. Once the worker has picked up the task,
`concurrent.futures.Future.cancel()` returns False and the thread runs to
completion. Stuck threads accumulated invisibly on asyncio's default
executor, contending with unrelated `to_thread` callers (file IO, etc.).
Replaced all 7 `asyncio.to_thread` call sites for `pipeline.apply()`
across `proxy/handlers/anthropic.py` (3) and `proxy/handlers/openai.py` (4)
with a new `HeadroomProxy._run_compression_in_executor(fn, *, timeout)`
helper that:
1. Submits to a dedicated bounded `ThreadPoolExecutor` named
`headroom-compress` (configurable via
`ProxyConfig.compression_max_workers`; defaults to
`min(32, (cpu_count or 1) * 4)`).
2. Increments `_compression_in_flight` (gauge) when work starts and
decrements when work completes; tracks `_compression_in_flight_max`
as a high-water mark.
3. Detects "leaked threads" by comparing wall-clock elapsed against the
timeout in the worker's `finally` block. Increments
`_compression_leaked_threads` when a worker finishes after its
asyncio future was cancelled. Operators can see the leaked-thread
rate climbing in `/stats runtime.compression_executor` BEFORE the
pool fills up.
Tests
=====
* `TestCompressionCacheConcurrency` (3 tests) — many threads
store_compressed / apply_cached / update_from_result on a single
CompressionCache; assert no exceptions, no lost updates, no partial
state.
* `test_get_compression_cache_returns_same_instance_under_contention` —
32 concurrent `_get_compression_cache(same_id)` calls return the
identical instance (would split pre-lock).
* `test_proxy_compression_executor.py` (8 tests) — pool size respects
config, in-flight gauge tracks running compressions, high-water mark
is monotonic, timeout propagates to awaiter, leaked-thread counter
increments on post-deadline completion, `/stats` surfaces all three
gauges.
Verification
============
* All 123 targeted regression tests pass.
* `make ci-precheck` clean.
* No `Co-Authored-By` trailer; conventional `fix:` prefix; no
`--no-verify`.
2026-05-01 15:25:18 -07:00
|
|
|
|
|
|
|
|
def test_get_compression_cache_returns_same_instance_under_contention() -> None:
|
|
|
|
|
"""`HeadroomProxy._get_compression_cache(session_id)` must return the
|
|
|
|
|
SAME `CompressionCache` instance for concurrent calls with the same
|
|
|
|
|
session_id. Pre-lock, two concurrent calls could both see "not in dict"
|
|
|
|
|
and each create a new instance, splitting the cache state across them.
|
|
|
|
|
"""
|
|
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
pytest.importorskip("fastapi")
|
|
|
|
|
from headroom.proxy.server import ProxyConfig, create_app
|
|
|
|
|
|
|
|
|
|
config = ProxyConfig(
|
|
|
|
|
optimize=False,
|
|
|
|
|
cache_enabled=False,
|
|
|
|
|
rate_limit_enabled=False,
|
|
|
|
|
cost_tracking_enabled=False,
|
|
|
|
|
log_requests=False,
|
|
|
|
|
ccr_inject_tool=False,
|
|
|
|
|
ccr_handle_responses=False,
|
|
|
|
|
ccr_context_tracking=False,
|
|
|
|
|
image_optimize=False,
|
|
|
|
|
)
|
|
|
|
|
app = create_app(config)
|
|
|
|
|
proxy = app.state.proxy
|
|
|
|
|
|
|
|
|
|
n_threads = 32
|
|
|
|
|
results: list[CompressionCache] = []
|
|
|
|
|
results_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
def worker() -> None:
|
|
|
|
|
c = proxy._get_compression_cache("shared-session-id")
|
|
|
|
|
with results_lock:
|
|
|
|
|
results.append(c)
|
|
|
|
|
|
|
|
|
|
threads = [threading.Thread(target=worker) for _ in range(n_threads)]
|
|
|
|
|
for t in threads:
|
|
|
|
|
t.start()
|
|
|
|
|
for t in threads:
|
|
|
|
|
t.join()
|
|
|
|
|
|
|
|
|
|
assert len(results) == n_threads
|
|
|
|
|
first = results[0]
|
|
|
|
|
for c in results[1:]:
|
|
|
|
|
assert c is first, "Concurrent _get_compression_cache returned different instances"
|