headroom/tests/test_proxy_streaming_request_logger.py
gglucass 2269e40bde
feat(proxy): log compressed messages alongside original request (#261)
## Description

Expose the post-compression message list that was actually sent upstream
as a new `compressed_messages` field on `RequestLog`, paired with the
existing (now consistently pre-compression) `request_messages`.
Consumers of `/transformations/feed` — dashboards and any downstream
observability — can now diff the two sides of a compression to see
exactly what the pipeline stripped, replaced, or kept. Turns an abstract
"saved N tokens" into a legible before/after.

Gated by the same `log_full_messages` flag as `request_messages` so the
two sides stay in sync; it's pointless to store one without the other.

Also fixes a latent correctness bug: today's `request_messages` field is
inconsistent across the four `RequestLog` construction sites — sometimes
it's the pre-compression snapshot, sometimes it's the mutated
`body["messages"]` (which is the compressed list, because the proxy
mutates `body` in place before the log call). After this change,
`request_messages` always means pre-compression and
`compressed_messages` always means what went upstream.

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

Note on "breaking": strictly speaking this is a semantic correction of
an inconsistently-populated field, not a schema break. The field name
`request_messages` is unchanged and the JSON shape is unchanged; what
changes is that the field now consistently holds the pre-compression
list. Consumers that treated it as "whatever messages we have" continue
to work. Consumers that depended on the accidental post-compression
value (if any existed) would shift to `compressed_messages`.

## Changes Made

- **`headroom/proxy/models.py`**: `RequestLog` gains
`compressed_messages: list[dict] | None = None`. Doc comment explains
it's paired with `request_messages` and gated by the same
`log_full_messages` flag.
- **`headroom/proxy/handlers/anthropic.py`** (2 sites — Bedrock
non-streaming and main non-streaming): `request_messages` now
consistently sources from `original_messages` (the pre-compression
snapshot at line 724), `compressed_messages` sources from
`body["messages"]` (the compressed list after in-place mutation at line
1189). Both gated symmetrically.
- **`headroom/proxy/handlers/streaming.py`** (2 sites — main streaming
in `_finalize_stream_response`, Bedrock streaming in
`_stream_response_bedrock`): same treatment. `_stream_response_bedrock`
gains a new `original_messages: list[dict] | None = None` parameter so
it has access to the pre-compression snapshot; the sole caller in
`anthropic.py` now threads it through.
- **`headroom/proxy/server.py`**: `/transformations/feed` adds
`compressed_messages` to the JSON payload alongside the existing
`request_messages` / `response_content`. *Split into a separate
preceding commit is a one-time EOL normalization to LF — the file blob
in history carries CRLF but `.gitattributes` declares `*.py text
eol=lf`, so any contributor editing `server.py` triggers the same
whole-file renormalization. Separating the two commits keeps this
feature commit's diff at a single line.*
- **`headroom/proxy/request_logger.py`**: `compressed_messages` is
stripped from the JSONL file log and from `get_recent()` alongside the
existing `request_messages` / `response_content` stripping.
`get_memory_stats()` also counts it toward the deque's byte budget.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .` and `ruff format --check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed (via the Headroom Desktop client that
consumes `/transformations/feed` — confirmed both fields arrive and
render)

Test coverage added/extended:

- `tests/test_proxy/test_request_logger.py` (new file): round-trip unit
tests for `RequestLogger`. Confirms `get_recent` strips both sides (pre
+ post), `get_recent_with_messages` exposes both, and the JSONL file log
drops both when `log_full_messages=False`.
- `tests/test_proxy/test_transformations_feed.py`: extended to assert
`compressed_messages` appears in the endpoint payload alongside
`request_messages` / `response_content`.
- `tests/test_proxy_streaming_request_logger.py`: existing include/omit
tests updated to assert both sides populate when the flag is on and both
are `None` when it's off.

## Test Output

```
$ uv run ruff check headroom tests
All checks passed!
$ uv run ruff format --check headroom tests
614 files already formatted
$ uv run pytest tests/test_proxy/test_request_logger.py tests/test_proxy_streaming_request_logger.py tests/test_proxy/test_transformations_feed.py -v
...
tests/test_proxy/test_request_logger.py::test_get_recent_strips_compressed_messages_alongside_request_and_response PASSED
tests/test_proxy/test_request_logger.py::test_get_recent_with_messages_returns_compressed_messages PASSED
tests/test_proxy/test_request_logger.py::test_jsonl_file_strips_both_sides_when_log_full_messages_disabled PASSED
tests/test_proxy_streaming_request_logger.py::test_finalize_stream_response_logs_original_and_compressed_messages PASSED
tests/test_proxy_streaming_request_logger.py::test_finalize_stream_response_omits_messages_when_log_full_messages_disabled PASSED
tests/test_proxy/test_transformations_feed.py::test_transformations_feed_returns_messages PASSED
...
============================== 11 passed in 5.36s ==============================
```

## 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
(the two-sided gating at each log site, the `_stream_response_bedrock`
parameter addition, and the `get_memory_stats` accounting)
- [ ] 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

### Non-Anthropic backends

`handlers/openai.py` and `handlers/gemini.py` do not currently emit
`RequestLog` entries at all — only Anthropic and the shared streaming
paths do. This PR therefore only populates `compressed_messages` on
Anthropic traffic (which is what `/transformations/feed` shows today).
Wiring OpenAI and Gemini into `RequestLogger` end-to-end is a separate,
larger gap worth its own PR.

### `server.py` EOL normalization

The feature change in `server.py` is a single line. To keep the diff
readable, the preceding commit is a whitespace-only `chore(proxy):
normalize server.py to LF per .gitattributes` — the file blob was stored
with CRLF terminators but `.gitattributes` declares `*.py text eol=lf`.
Any contributor touching `server.py` triggers this renormalization;
isolating it here keeps the feature commit reviewable. Happy to rebase /
drop / reshape as preferred.

### Downstream desktop compatibility

The Headroom Desktop client I work on now consumes `compressed_messages`
and renders the pre/post pair side-by-side on the "Recent large
compression" card. The desktop was updated to handle both shapes:
proxies without the field render the legacy single "Request" block;
proxies with the field render "Request (original, N tokens)" + "Request
(compressed, M tokens)" where N/M come from `input_tokens_original` /
`input_tokens_optimized`. No changes needed downstream if this PR lands.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:02:54 -05:00

314 lines
11 KiB
Python

"""Tests that the Anthropic streaming finalizer logs requests for the feed.
Without this, the streaming Anthropic path (which is what Claude Code uses)
silently bypassed the request logger, leaving `/stats.recent_requests` and
`/transformations/feed` permanently empty even when `--log-messages` was set.
The non-streaming Anthropic path and the Bedrock streaming path were the
only ones that called `self.logger.log(...)`.
"""
import json
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from headroom.proxy.request_logger import RequestLogger
from headroom.proxy.server import HeadroomProxy
def _build_proxy_with_real_logger(*, log_full_messages: bool) -> HeadroomProxy:
"""Build a HeadroomProxy with mocks for everything except the request logger,
so we can assert what actually gets recorded."""
proxy = object.__new__(HeadroomProxy)
proxy.http_client = MagicMock(spec=httpx.AsyncClient)
proxy.metrics = MagicMock()
proxy.metrics.record_request = AsyncMock(return_value=None)
proxy.cost_tracker = MagicMock()
proxy.cost_tracker.record_tokens.return_value = None
proxy.memory_manager = None
proxy.memory_handler = None
proxy._config = MagicMock()
proxy._config.log_full_messages = log_full_messages
proxy._config.ccr_inject_tool = False
proxy.config = proxy._config
proxy.logger = RequestLogger(log_file=None, log_full_messages=log_full_messages)
return proxy
def _stream_state(output_tokens: int = 42) -> dict:
return {
"output_tokens": output_tokens,
"total_bytes": 200,
"ttfb_ms": 35.0,
"input_tokens": 1000,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_creation_ephemeral_5m_input_tokens": 0,
"cache_creation_ephemeral_1h_input_tokens": 0,
"sse_buffer": "",
}
def test_parse_openai_responses_completed_usage_from_sse_buffer():
proxy = _build_proxy_with_real_logger(log_full_messages=False)
completed = {
"type": "response.completed",
"response": {
"id": "resp_1",
"usage": {
"input_tokens": 844_000,
"input_tokens_details": {"cached_tokens": 657_400},
"output_tokens": 6_635,
},
},
}
state = {
"sse_buffer": bytearray(
f"event: response.completed\ndata: {json.dumps(completed)}\n\n".encode()
)
}
usage = proxy._parse_sse_usage_from_buffer(state, "openai")
assert usage == {
"input_tokens": 844_000,
"output_tokens": 6_635,
"cache_read_input_tokens": 657_400,
}
assert state["sse_buffer"] == bytearray()
@pytest.mark.asyncio
async def test_finalize_stream_response_logs_request_for_feed():
proxy = _build_proxy_with_real_logger(log_full_messages=False)
await proxy._finalize_stream_response(
body={"messages": [{"role": "user", "content": "hi"}]},
provider="anthropic",
model="claude-sonnet-4-6",
request_id="req-stream-1",
original_tokens=1000,
optimized_tokens=600,
tokens_saved=400,
transforms_applied=["smart_crusher"],
optimization_latency=12.0,
stream_state=_stream_state(),
start_time=0.0,
tags={"stack": "wrap_claude"},
)
entries = proxy.logger.get_recent(10)
assert len(entries) == 1, "streaming finalizer must log exactly one entry per request"
entry = entries[0]
assert entry["request_id"] == "req-stream-1"
assert entry["provider"] == "anthropic"
assert entry["model"] == "claude-sonnet-4-6"
assert entry["input_tokens_original"] == 1000
assert entry["input_tokens_optimized"] == 600
assert entry["tokens_saved"] == 400
assert entry["savings_percent"] == pytest.approx(40.0)
assert entry["transforms_applied"] == ["smart_crusher"]
assert entry["tags"] == {"stack": "wrap_claude"}
assert entry["cache_hit"] is False
@pytest.mark.asyncio
async def test_finalize_stream_response_logs_original_and_compressed_messages():
"""With log_full_messages enabled, both sides of the compression are
recorded: `request_messages` is the pre-compression snapshot the caller
threads in via `original_messages`, `compressed_messages` is what was
actually sent upstream (i.e. `body["messages"]` after in-place mutation)."""
proxy = _build_proxy_with_real_logger(log_full_messages=True)
# `body["messages"]` models the post-compression list - the proxy mutates
# `body` in place before calling `_finalize_stream_response`, so this is
# already what was shipped over the wire.
body = {"messages": [{"role": "user", "content": "[compressed]"}]}
original = [{"role": "user", "content": "[original, pre-compression]"}]
await proxy._finalize_stream_response(
body=body,
provider="anthropic",
model="claude-sonnet-4-6",
request_id="req-stream-2",
original_tokens=10,
optimized_tokens=8,
tokens_saved=2,
transforms_applied=[],
optimization_latency=1.0,
stream_state=_stream_state(output_tokens=5),
start_time=0.0,
original_messages=original,
)
entries = proxy.logger.get_recent_with_messages(10)
assert len(entries) == 1
assert entries[0]["request_messages"] == original
assert entries[0]["compressed_messages"] == body["messages"]
@pytest.mark.asyncio
async def test_finalize_stream_response_omits_messages_when_log_full_messages_disabled():
proxy = _build_proxy_with_real_logger(log_full_messages=False)
await proxy._finalize_stream_response(
body={"messages": [{"role": "user", "content": "hello"}]},
provider="anthropic",
model="claude-sonnet-4-6",
request_id="req-stream-3",
original_tokens=10,
optimized_tokens=8,
tokens_saved=2,
transforms_applied=[],
optimization_latency=1.0,
stream_state=_stream_state(output_tokens=5),
start_time=0.0,
original_messages=[{"role": "user", "content": "dropped"}],
)
entries = proxy.logger.get_recent_with_messages(10)
assert len(entries) == 1
# Both sides share the same gate - neither leaks when log_full_messages
# is off.
assert entries[0]["request_messages"] is None
assert entries[0]["compressed_messages"] is None
@pytest.mark.asyncio
async def test_finalize_stream_response_handles_zero_original_tokens():
proxy = _build_proxy_with_real_logger(log_full_messages=False)
await proxy._finalize_stream_response(
body={"messages": []},
provider="anthropic",
model="claude-sonnet-4-6",
request_id="req-stream-4",
original_tokens=0,
optimized_tokens=0,
tokens_saved=0,
transforms_applied=[],
optimization_latency=0.0,
stream_state=_stream_state(output_tokens=0),
start_time=0.0,
)
entries = proxy.logger.get_recent(10)
assert len(entries) == 1
assert entries[0]["savings_percent"] == 0
@pytest.mark.asyncio
async def test_finalize_openai_responses_stream_uses_provider_usage_for_dashboard():
proxy = _build_proxy_with_real_logger(log_full_messages=False)
state = _stream_state(output_tokens=6_635)
state["input_tokens"] = 844_000
state["cache_read_input_tokens"] = 657_400
await proxy._finalize_stream_response(
body={"model": "gpt-5.5", "input": [{"type": "message", "role": "user"}]},
provider="openai",
model="gpt-5.5",
request_id="req-openai-responses-stream",
original_tokens=0,
optimized_tokens=0,
tokens_saved=663_000,
transforms_applied=["openai_responses_live_zone"],
optimization_latency=26.0,
stream_state=state,
start_time=0.0,
)
entries = proxy.logger.get_recent(10)
assert len(entries) == 1
entry = entries[0]
assert entry["input_tokens_optimized"] == 844_000
assert entry["input_tokens_original"] == 1_507_000
assert entry["tokens_saved"] == 663_000
assert entry["savings_percent"] == pytest.approx(663_000 / 1_507_000 * 100)
assert entry["output_tokens"] == 6_635
proxy.metrics.record_request.assert_awaited_once()
metrics_kwargs = proxy.metrics.record_request.await_args.kwargs
assert metrics_kwargs["input_tokens"] == 844_000
assert metrics_kwargs["output_tokens"] == 6_635
assert metrics_kwargs["tokens_saved"] == 663_000
assert metrics_kwargs["cache_read_tokens"] == 657_400
assert metrics_kwargs["uncached_input_tokens"] == 186_600
proxy.cost_tracker.record_tokens.assert_called_once()
cost_args, cost_kwargs = proxy.cost_tracker.record_tokens.call_args
assert cost_args[:3] == ("gpt-5.5", 663_000, 844_000)
assert cost_kwargs["cache_read_tokens"] == 657_400
assert cost_kwargs["uncached_tokens"] == 186_600
@pytest.mark.asyncio
async def test_finalize_stream_response_recovers_usage_from_truncated_buffer() -> None:
"""When upstream truncates mid-event (no trailing \\n\\n), the per-chunk
parser leaves the message_start usage event sitting in sse_buffer and
PERF logs cache_read=cache_write=0 — which then poisons the freeze
heuristic on the next request. The finalizer must flush the residual
buffer so the real cache_read / cache_creation tokens still land in
the log even on aborted streams.
"""
proxy = _build_proxy_with_real_logger(log_full_messages=False)
partial_message_start = (
b"event: message_start\n"
b'data: {"type":"message_start","message":{"id":"msg_x",'
b'"type":"message","role":"assistant","model":"claude-sonnet-4-6",'
b'"content":[],"stop_reason":null,"usage":{'
b'"input_tokens":1234,"cache_read_input_tokens":50000,'
b'"cache_creation_input_tokens":2500,"output_tokens":1}}}'
)
state = {
"output_tokens": None,
"total_bytes": len(partial_message_start),
"ttfb_ms": 35.0,
"input_tokens": None,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_creation_ephemeral_5m_input_tokens": 0,
"cache_creation_ephemeral_1h_input_tokens": 0,
"sse_buffer": bytearray(partial_message_start),
}
await proxy._finalize_stream_response(
body={"messages": [{"role": "user", "content": "hi"}]},
provider="anthropic",
model="claude-sonnet-4-6",
request_id="req-stream-truncated",
original_tokens=2000,
optimized_tokens=1800,
tokens_saved=200,
transforms_applied=[],
optimization_latency=5.0,
stream_state=state,
start_time=0.0,
)
assert state["input_tokens"] == 1234
assert state["cache_read_input_tokens"] == 50000
assert state["cache_creation_input_tokens"] == 2500
@pytest.mark.asyncio
async def test_finalize_stream_response_no_op_when_logger_disabled():
proxy = _build_proxy_with_real_logger(log_full_messages=False)
proxy.logger = None # `--no-log-requests` would put us here
# Should not raise.
await proxy._finalize_stream_response(
body={"messages": []},
provider="anthropic",
model="claude-sonnet-4-6",
request_id="req-stream-5",
original_tokens=10,
optimized_tokens=8,
tokens_saved=2,
transforms_applied=[],
optimization_latency=1.0,
stream_state=_stream_state(),
start_time=0.0,
)