diff --git a/headroom/proxy/models.py b/headroom/proxy/models.py index 401c212ee..8a5651fe9 100644 --- a/headroom/proxy/models.py +++ b/headroom/proxy/models.py @@ -69,6 +69,16 @@ class RequestLog: cache_hit: bool transforms_applied: list[str] + # Provider-side cache economics (Anthropic prompt caching, #2438). + # ``cache_hit`` alone is ambiguous: a call billed cache-*creation* (write) + # cannot be told apart from a real cache-*read* hit. These raw deltas — + # already carried on RequestOutcome from the upstream response usage — + # let the JSONL telemetry reflect true economics (uncached input + + # cache_creation), not just the proxy's boolean. + cache_read_tokens: int = 0 + cache_write_tokens: int = 0 + uncached_input_tokens: int = 0 + # Waste signals detected in original messages waste_signals: dict[str, int] | None = None diff --git a/headroom/proxy/outcome.py b/headroom/proxy/outcome.py index e61be614e..1fec166d6 100644 --- a/headroom/proxy/outcome.py +++ b/headroom/proxy/outcome.py @@ -454,6 +454,9 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None: total_latency_ms=outcome.total_latency_ms, tags=log_tags, cache_hit=outcome.cache_hit, + cache_read_tokens=outcome.cache_read_tokens, + cache_write_tokens=outcome.cache_write_tokens, + uncached_input_tokens=outcome.uncached_input_tokens, transforms_applied=list(outcome.transforms_applied), waste_signals=outcome.waste_signals, request_messages=outcome.request_messages, diff --git a/tests/test_proxy_cache_telemetry.py b/tests/test_proxy_cache_telemetry.py new file mode 100644 index 000000000..44e63c117 --- /dev/null +++ b/tests/test_proxy_cache_telemetry.py @@ -0,0 +1,82 @@ +"""Provider-side cache economics land in the per-request JSONL (#2438). + +`cache_hit` alone can't distinguish a call billed cache-*creation* (write) +from a real cache-*read* hit, which is exactly the telemetry gap the issue +reported (the proxy stamps `cache_hit: true` while the client is billed +cache-write tokens). The raw provider deltas already live on RequestOutcome; +this pins that they survive into the RequestLog feed. +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +from headroom.proxy.models import RequestLog # noqa: E402 +from headroom.proxy.outcome import RequestOutcome, emit_request_outcome # noqa: E402 +from headroom.proxy.server import ProxyConfig, create_app # noqa: E402 + + +def test_request_log_carries_provider_cache_deltas(tmp_path): + log_file = tmp_path / "proxy.jsonl" + config = ProxyConfig( + cache_enabled=False, + rate_limit_enabled=False, + log_requests=True, + log_file=str(log_file), + ) + + with TestClient(create_app(config)) as client: + proxy = client.app.state.proxy + + # A call billed cache-*creation* (write), zero reads: cache_hit would + # be False here, but the write/uncached deltas must still be recorded + # so the true economics are visible. + outcome = RequestOutcome( + request_id="req-cache", + provider="anthropic", + model="claude-sonnet-5", + original_tokens=1000, + optimized_tokens=1000, + output_tokens=20, + tokens_saved=0, + attempted_input_tokens=1000, + cache_read_tokens=0, + cache_write_tokens=800, + uncached_input_tokens=200, + ) + asyncio.run(emit_request_outcome(proxy, outcome)) + + lines = [json.loads(line) for line in log_file.read_text().splitlines() if line.strip()] + entry = next(e for e in lines if e["request_id"] == "req-cache") + assert entry["cache_read_tokens"] == 0 + assert entry["cache_write_tokens"] == 800 + assert entry["uncached_input_tokens"] == 200 + + +def test_request_log_cache_delta_fields_default_zero(): + # Backward-compatible: the new fields are optional and default to 0. + entry = RequestLog( + request_id="r", + timestamp="t", + provider="anthropic", + model="m", + input_tokens_original=0, + input_tokens_optimized=0, + output_tokens=0, + tokens_saved=0, + savings_percent=0.0, + optimization_latency_ms=0.0, + total_latency_ms=None, + tags={}, + cache_hit=False, + transforms_applied=[], + ) + assert entry.cache_read_tokens == 0 + assert entry.cache_write_tokens == 0 + assert entry.uncached_input_tokens == 0