Skip to content

Commit f9807fd

Browse files
chopratejasTejas Chopraclaude
authored
feat(proxy): let extensions report cost savings and their own latency (#3051)
## What Two changes that let a proxy extension report **what it saved** and **what it cost**, so both show up under `/stats`, the dashboard, and Prometheus. `record_scope_savings` already existed and already accepted `usd` — the one channel in the proxy that can express savings *without* tokens. Two things stopped it working end to end. ### 1. Savings were silently dropped on Gemini traffic (bug) `bind_scope` shares one attribution ledger between ASGI middleware and the request handler. Anthropic and OpenAI call it; **Gemini never did**, so anything an extension recorded into the request scope was discarded for Gemini traffic only — silently, because an empty ledger and an unbound one are indistinguishable at the outcome funnel. Now bound at all four Gemini tag sites. ### 2. An extension's own latency was invisible (gap) `overhead_ms` is measured *inside* the handler, and an ASGI extension **wraps** that handler — so every millisecond it spends reaches the client while every timing surface stays flat. An extension that halves the bill and adds 200 ms per request is a trade the operator has to see both halves of, and only one half was reaching the dashboard. `record_scope_timing(scope, stage, ms)` is the symmetric counterpart to `record_scope_savings`, carried on the same bound ledger and merged into `RequestOutcome.pipeline_timing` at the outcome funnel — one place, so every provider picks it up at once. ## API surface ```python from headroom.proxy.savings_attribution import record_scope_savings, record_scope_timing record_scope_savings(scope, "my_extension", tokens=0, usd=0.004) # money without tokens record_scope_timing(scope, "my_extension", elapsed_ms) ``` Both take the ASGI `scope`, because middleware has no other way in. Documented in `extensions.py` — the module extension authors actually read, and the stability contract for this interface. - Savings → `/stats` `savings.by_source`, dashboard card, `headroom_savings_attributed_usd_total{source=...}` - Timing → `/stats` `pipeline_timing`, dashboard Performance panel, `headroom_transform_timing_ms_*` **Attribution only.** These rows explain the headline total; they are never added to it. ## Changes to existing behavior - `public_tags` now strips `_headroom_stage_timing` as well as `_headroom_savings_attribution`. Both ride on `tags` because that is the one dict reaching the outcome funnel from every handler, and a list and a dict must not land in a string-keyed label store. - `pipeline_timing` passed to `metrics.record_request` is merged rather than passed through **only when an extension contributed timings**; with no extension the handler's own dict is passed through unchanged (asserted by identity in the tests). - Stage names are extension-supplied, so they are capped at 16 and namespaced `ext:` — `deep_copy` reported by a plugin must never accumulate into the same series as `deep_copy` measured by the pipeline. A handler's own timing wins a collision (unreachable while the prefix stands; the safe way round if it ever goes). ## Failure modes Both calls are bounded (32 sources, 16 stages), never raise, and never change a response — telemetry from a plugin must not be able to break the request it is describing. Non-positive and non-numeric durations are ignored: a zero is a clock artifact, not an observation, and averaging it in would drag the mean down exactly where the stage is cheapest to skip. `timings_from_tags` tolerates junk on the tag. ## Test-double fix Three Gemini test fakes (`FakeRequest`, `_FakeRequest`, `_VertexGeminiImageRequest`) had no `.scope`, which every real Starlette `Request` has. They now do. This is a double that had drifted from the type it stands in for; the alternative was weakening the handler to tolerate a request shape that cannot occur in production. --- ## Real behavior proof **Setup:** macOS 15.4 (darwin 25.4.0), Python 3.12.13, this branch at `c814b950`, real `create_app` proxy with `respx`-mocked Anthropic upstream, a demo ASGI extension added via `app.add_middleware`. **The extension** — written as a third party would, reporting `tokens=0` because it re-routed `claude-opus-5` → `claude-haiku-4-5`: same tokens, cheaper model. That is precisely the case no existing Headroom savings channel can express, since all of them compute `saved = before - after`. ```python class DemoRouter: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope.get("type") != "http": return await self.app(scope, receive, send) started = time.perf_counter() record_scope_savings(scope, "routemegood", tokens=0, usd=0.173) record_scope_timing(scope, "routemegood", (time.perf_counter() - started) * 1000) await self.app(scope, receive, send) ``` **Ran:** three POSTs to `/v1/messages`, then `GET /stats` and `GET /metrics`. **Observed:** ``` upstream call -> 200 upstream call -> 200 upstream call -> 200 === /stats savings.by_source (what the dashboard renders) === [ { "source": "routemegood", "realized": true, "events": 3, "tokens": 0, "usd": 0.519 } ] === /stats pipeline_timing (dashboard Performance panel) === { "ext:routemegood": { "average_ms": 0.01, "max_ms": 0.02, "count": 3 } } === /metrics === # HELP headroom_savings_attributed_tokens_total Tokens attributed to a savings source # TYPE headroom_savings_attributed_tokens_total counter headroom_savings_attributed_tokens_total{realized="true",source="routemegood"} 0 # HELP headroom_savings_attributed_usd_total Cost savings attributed to a source; may be negative # TYPE headroom_savings_attributed_usd_total gauge headroom_savings_attributed_usd_total{realized="true",source="routemegood"} 0.519 headroom_transform_timing_ms_sum{transform="ext:routemegood"} 0.03 ``` `$0.519 = 3 × $0.173` — three requests, correctly accumulated, with `tokens: 0` throughout. **Also have (not a substitute for the above):** 22 new unit tests in `tests/test_extension_attribution.py`, including four that drive the real `_record_request_outcome` funnel via the same descriptor-binding harness `test_request_outcome.py` uses. Full suite on this branch: **10,989 passed, 578 skipped**. Three failures — `test_graceful_shutdown.py::test_run_server_installs_cancelled_error_filter` (full-suite ordering; passes in isolation), `test_learn/test_integration.py::TestCodexIntegration::test_full_pipeline`, and `test_release_workflows.py::test_no_native_tls_in_wheel_build_tree` (needs `cargo`) — **reproduce identically on clean `main`** (`2f4d001c`, 10,967 passed, same 3 failed). Verified by stashing this branch and re-running the full suite on main in the same tree. **What I did not test:** a live provider (upstream is `respx`-mocked); the Gemini `bind_scope` fix against real Google traffic (covered by the existing 114 Gemini tests, which all pass); the dashboard rendered in a browser — I verified the JSON shape its templates bind to (`stats.savings?.by_source`, `stats.pipeline_timing`) rather than the pixels. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 2f4d001 commit f9807fd

8 files changed

Lines changed: 607 additions & 6 deletions

headroom/proxy/extensions.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,41 @@
1818
OSS makes no assumptions about what extensions do. The interface is
1919
deliberately minimal; extensions own the complexity behind it.
2020
21+
Reporting what an extension saved, and what it cost
22+
---------------------------------------------------
23+
24+
An extension that changes the bill should say so, or the operator sees a
25+
different total with nothing to attribute it to. Two calls, both taking the
26+
ASGI ``scope`` so they work from middleware — which runs outside the request
27+
handler and has no other way in::
28+
29+
from headroom.proxy.savings_attribution import (
30+
record_scope_savings, record_scope_timing,
31+
)
32+
33+
record_scope_savings(scope, "my_extension", tokens=1200, usd=0.004)
34+
record_scope_timing(scope, "my_extension", elapsed_ms)
35+
36+
``record_scope_savings`` takes ``tokens``, ``usd``, or both, so an extension
37+
that saves money WITHOUT saving tokens — routing a request to a cheaper model,
38+
say — can report a real number instead of a token count nobody saved. Pass
39+
``realized=False`` for a projection rather than a measured amount; the two are
40+
kept apart everywhere they surface. Savings land on ``/stats`` under
41+
``savings.by_source``, on the dashboard as their own card, and in Prometheus as
42+
``headroom_savings_attributed_usd_total{source=...}``. **Attribution only** —
43+
these rows explain the headline total, they are never added to it.
44+
45+
``record_scope_timing`` is the other half of the trade: an extension's own
46+
latency, which is otherwise invisible because ``overhead_ms`` is measured
47+
inside the handler that the extension wraps. It lands in ``/stats`` under
48+
``pipeline_timing``, in the dashboard's Performance panel, and in
49+
``headroom_transform_timing_ms_*``, namespaced ``ext:<name>`` so it can never
50+
collide with a built-in transform.
51+
52+
Both are bounded (32 sources, 16 stages), never raise, and never change a
53+
response — telemetry from a plugin must not be able to break the request it is
54+
describing.
55+
2156
**Extensions are opt-in.** Discovery enumerates every registered extension,
2257
but ``install_all`` only invokes those explicitly enabled by the operator.
2358
This protects users from silent behavior changes when a package they didn't

headroom/proxy/handlers/gemini.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,13 @@ async def handle_gemini_generate_content(
316316
headers.pop("host", None)
317317
headers.pop("content-length", None)
318318
tags = extract_tags(headers)
319+
# Anthropic and OpenAI bind here; Gemini did not, so anything an ASGI
320+
# extension recorded into the request scope was dropped on the floor
321+
# for Gemini traffic only — silently, because an empty ledger and an
322+
# unbound one look identical at the outcome funnel.
323+
from headroom.proxy.savings_attribution import bind_scope
324+
325+
bind_scope(tags, request.scope)
319326
client = classify_client(headers)
320327
# PR-A5 (P5-49): strip internal x-headroom-* from upstream-bound
321328
# headers AFTER `_extract_tags` reads them. Memory user-id reads
@@ -1027,6 +1034,9 @@ async def handle_google_cloudcode_stream(
10271034
headers.pop("content-length", None)
10281035
headers.pop("accept-encoding", None)
10291036
tags = extract_tags(headers)
1037+
from headroom.proxy.savings_attribution import bind_scope
1038+
1039+
bind_scope(tags, request.scope)
10301040
# Note: streaming handlers delegate to _stream_response, which
10311041
# does its own classify_client. No need to compute here.
10321042
is_antigravity = self._is_cloudcode_antigravity_request(body, headers)
@@ -1180,6 +1190,9 @@ async def handle_gemini_stream_generate_content(
11801190
headers.pop("host", None)
11811191
headers.pop("content-length", None)
11821192
tags = extract_tags(headers)
1193+
from headroom.proxy.savings_attribution import bind_scope
1194+
1195+
bind_scope(tags, request.scope)
11831196
# Streaming variant — delegates to _stream_response which
11841197
# classifies the client itself from headers.
11851198
# PR-A5 (P5-49): strip internal x-headroom-* before forwarding upstream.
@@ -1328,6 +1341,9 @@ async def handle_gemini_count_tokens(
13281341
# outcome. Extract here so apply_to_tags below has a dict to
13291342
# mutate and the outcome at end-of-call inherits the tag.
13301343
tags = extract_tags(request.headers)
1344+
from headroom.proxy.savings_attribution import bind_scope
1345+
1346+
bind_scope(tags, request.scope)
13311347
_decision = CompressionDecision.decide(
13321348
headers=request.headers,
13331349
config=self.config,

headroom/proxy/outcome.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,12 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None:
399399
from headroom.proxy.cost import _summarize_transforms
400400
from headroom.proxy.models import RequestLog
401401
from headroom.proxy.project_context import get_current_project
402-
from headroom.proxy.savings_attribution import encode, from_tags, public_tags
402+
from headroom.proxy.savings_attribution import (
403+
encode,
404+
from_tags,
405+
public_tags,
406+
timings_from_tags,
407+
)
403408
from headroom.telemetry.session import record_outcome
404409

405410
# GitHub Copilot: requests routed to the Copilot API travel on the OpenAI or
@@ -467,6 +472,20 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None:
467472
tool_search_saved = tool_schema_saved_from_tags(outcome.tags or {})
468473
savings_breakdown = from_tags(outcome.tags)
469474

475+
# Stage timings contributed from OUTSIDE the handler, folded in here rather
476+
# than in each handler so every provider picks them up from one place.
477+
#
478+
# The handler's own timings win a name collision, which cannot happen while
479+
# extension stages carry the ``ext:`` prefix but is the safe way round if
480+
# that ever changes: a plugin must not be able to overwrite a measurement
481+
# the pipeline made of itself.
482+
extension_timing = timings_from_tags(outcome.tags)
483+
pipeline_timing = (
484+
{**extension_timing, **(outcome.pipeline_timing or {})}
485+
if extension_timing
486+
else outcome.pipeline_timing
487+
)
488+
470489
# Billed input volume. Prefer the provider's own count where it reported one
471490
# — that is what the invoice charges for, and it is the number cache math is
472491
# already expressed in. Falls back to our local ``optimized_tokens`` when the
@@ -488,7 +507,7 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None:
488507
cached=outcome.cache_hit,
489508
overhead_ms=outcome.overhead_ms,
490509
ttfb_ms=outcome.ttfb_ms,
491-
pipeline_timing=outcome.pipeline_timing,
510+
pipeline_timing=pipeline_timing,
492511
waste_signals=outcome.waste_signals,
493512
cache_read_tokens=outcome.cache_read_tokens,
494513
cache_write_tokens=outcome.cache_write_tokens,

headroom/proxy/savings_attribution.py

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import base64
66
import json
7+
import math
78
import re
89
from collections.abc import MutableMapping
910
from typing import Any
@@ -13,6 +14,43 @@
1314
MAX_SOURCES = 32
1415
_SCOPE_KEY = "headroom_savings_attribution"
1516

17+
# Per-request stage timings contributed from outside the handler, merged into
18+
# ``RequestOutcome.pipeline_timing`` at the outcome funnel.
19+
#
20+
# An ASGI middleware wraps the handler, so every millisecond it spends lands in
21+
# the client's latency while ``overhead_ms`` -- measured inside the handler --
22+
# stays flat. An extension that halves the bill and adds 200ms per request is a
23+
# trade the operator has to be able to see both halves of, and until now only
24+
# one half reached the dashboard.
25+
STAGE_TIMING_TAG = "_headroom_stage_timing"
26+
_TIMING_SCOPE_KEY = "headroom_stage_timing"
27+
28+
# Stage names are extension-supplied, so they are capped like every other
29+
# client-influenced label in this proxy (see MAX_DISTINCT_MODELS).
30+
MAX_STAGES = 16
31+
32+
# Namespace, so an extension can never shadow a built-in transform's timing --
33+
# ``deep_copy`` reported by a plugin and ``deep_copy`` reported by the pipeline
34+
# must not accumulate into the same series.
35+
STAGE_PREFIX = "ext:"
36+
37+
# NON-FINITE VALUES POISON EVERY CONSUMER DOWNSTREAM, and they do it long after
38+
# the call that introduced them. Starlette's JSONResponse encodes with
39+
# ``allow_nan=False``, so a single ``inf`` reaching ``/stats`` raises
40+
# ``ValueError: Out of range float values are not JSON compliant`` -- and the
41+
# value sits in the process-wide metrics totals, so the endpoint stays broken
42+
# until restart. Prometheus is no better: Python renders ``inf``, the exposition
43+
# format wants ``+Inf``, and the scrape fails to parse.
44+
#
45+
# The request itself still returns 200 throughout, which is the worst shape a
46+
# bug can have: the extension looks healthy while the operator's dashboard and
47+
# scrape are dead.
48+
#
49+
# One hour bounds a single stage inside one request -- unreachable in practice,
50+
# and it makes overflow-to-infinity on accumulation structurally impossible
51+
# (16 stages x 1h is nowhere near the float ceiling).
52+
MAX_STAGE_MS = 3_600_000.0
53+
1654

1755
def _source_name(value: object) -> str:
1856
name = _NAME_RE.sub("_", str(value or "other").strip().lower()).strip("_.-")
@@ -29,14 +67,25 @@ def _ledger(tags: MutableMapping[str, Any]) -> list[dict[str, Any]]:
2967

3068

3169
def bind_scope(tags: MutableMapping[str, Any], scope: MutableMapping[str, Any]) -> None:
32-
"""Share one ledger between ASGI middleware and the request handler."""
70+
"""Share the savings and timing ledgers between ASGI middleware and the handler.
71+
72+
Both are bound together because an extension that reports one usually
73+
reports the other, and a handler that binds only savings would drop the
74+
timings silently -- which is the failure this call is here to prevent.
75+
"""
3376
state = scope.setdefault("state", {})
3477
ledger = state.get(_SCOPE_KEY)
3578
if not isinstance(ledger, list):
3679
ledger = []
3780
state[_SCOPE_KEY] = ledger
3881
tags[SAVINGS_ATTRIBUTION_TAG] = ledger
3982

83+
timings = state.get(_TIMING_SCOPE_KEY)
84+
if not isinstance(timings, dict):
85+
timings = {}
86+
state[_TIMING_SCOPE_KEY] = timings
87+
tags[STAGE_TIMING_TAG] = timings
88+
4089

4190
def record_scope_savings(scope: MutableMapping[str, Any], source: object, **values: Any) -> None:
4291
state = scope.setdefault("state", {})
@@ -47,6 +96,63 @@ def record_scope_savings(scope: MutableMapping[str, Any], source: object, **valu
4796
record_savings({SAVINGS_ATTRIBUTION_TAG: ledger}, source, **values)
4897

4998

99+
def record_scope_timing(scope: MutableMapping[str, Any], stage: object, ms: float) -> None:
100+
"""Attribute milliseconds spent outside the handler to a named stage.
101+
102+
Additive within one request, so a middleware that works in two passes
103+
(before and after ``call_next``) reports each and gets their sum. Never
104+
raises and never changes a response: a plugin's telemetry must not be able
105+
to break the request it is describing.
106+
"""
107+
try:
108+
elapsed = float(ms)
109+
except (TypeError, ValueError):
110+
return
111+
# Non-positive is either a clock artifact or nothing happening; either way
112+
# it is not a measurement, and averaging it in would drag the mean toward
113+
# zero exactly where the stage is cheapest to ignore. Non-finite and
114+
# absurdly large are not measurements either, and they break consumers
115+
# rather than merely skewing them -- see MAX_STAGE_MS.
116+
if not math.isfinite(elapsed) or not 0.0 < elapsed <= MAX_STAGE_MS:
117+
return
118+
119+
state = scope.setdefault("state", {})
120+
timings = state.get(_TIMING_SCOPE_KEY)
121+
if not isinstance(timings, dict):
122+
timings = {}
123+
state[_TIMING_SCOPE_KEY] = timings
124+
125+
name = STAGE_PREFIX + _source_name(stage)
126+
if name not in timings and len(timings) >= MAX_STAGES:
127+
return
128+
timings[name] = round(float(timings.get(name, 0.0)) + elapsed, 4)
129+
130+
131+
def timings_from_tags(tags: MutableMapping[str, Any] | None) -> dict[str, float]:
132+
"""Extension stage timings carried on the request's tags, if any."""
133+
raw = (tags or {}).get(STAGE_TIMING_TAG)
134+
if not isinstance(raw, dict):
135+
return {}
136+
out: dict[str, float] = {}
137+
for name, value in list(raw.items())[:MAX_STAGES]:
138+
try:
139+
elapsed = float(value)
140+
except (TypeError, ValueError):
141+
continue
142+
# Re-checked rather than trusted: the ledger is a plain dict reachable
143+
# through ``tags``, so a handler can be handed one this module never
144+
# wrote. The guarantee has to hold at the read, not only at the write.
145+
#
146+
# Finiteness ONLY. ``MAX_STAGE_MS`` bounds a single sample at the write,
147+
# where it prevents overflow; applying it here would test it against an
148+
# ACCUMULATED total and silently discard a stage that legitimately ran
149+
# for longer across many samples -- throwing away real data to guard
150+
# against a value this path cannot produce.
151+
if math.isfinite(elapsed) and elapsed > 0.0:
152+
out[str(name)] = elapsed
153+
return out
154+
155+
50156
def record_savings(
51157
tags: MutableMapping[str, Any],
52158
source: object,
@@ -61,12 +167,25 @@ def record_savings(
61167
ledger = _ledger(tags)
62168
if len(ledger) >= MAX_SOURCES:
63169
return
170+
# Same hazard as MAX_STAGE_MS, on the amounts rather than the durations:
171+
# ``usd=inf`` reaches ``/stats`` and raises out of the JSON encoder, and
172+
# ``int(inf)`` raises OverflowError right here, inside the handler, on a
173+
# request that would otherwise have succeeded. Neither is a saving, so
174+
# neither is recorded -- the alternative is a plugin's arithmetic bug
175+
# taking down an endpoint it has nothing to do with.
176+
try:
177+
amount = float(usd or 0.0)
178+
count = int(tokens or 0)
179+
except (TypeError, ValueError, OverflowError):
180+
return
181+
if not math.isfinite(amount):
182+
return
64183
item: dict[str, Any] = {
65184
"source": _source_name(source),
66185
"realized": bool(realized),
67186
"estimated": bool(estimated),
68-
"tokens": max(0, int(tokens or 0)),
69-
"usd": round(float(usd or 0.0), 12),
187+
"tokens": max(0, count),
188+
"usd": round(amount, 12),
70189
}
71190
if details:
72191
item["details"] = {
@@ -84,8 +203,17 @@ def from_tags(tags: MutableMapping[str, Any] | None) -> list[dict[str, Any]]:
84203
return [dict(item) for item in raw[:MAX_SOURCES] if isinstance(item, dict)]
85204

86205

206+
_INTERNAL_TAGS = frozenset({SAVINGS_ATTRIBUTION_TAG, STAGE_TIMING_TAG})
207+
208+
87209
def public_tags(tags: MutableMapping[str, Any] | None) -> dict[str, Any]:
88-
return {key: value for key, value in (tags or {}).items() if key != SAVINGS_ATTRIBUTION_TAG}
210+
"""Tags minus the internal ledgers, which are structures rather than labels.
211+
212+
They are carried on ``tags`` because that is the one dict that reaches the
213+
outcome funnel from every handler; letting them through to ``RequestLog``
214+
would put a list and a dict into a string-keyed label store.
215+
"""
216+
return {key: value for key, value in (tags or {}).items() if key not in _INTERNAL_TAGS}
89217

90218

91219
def encode(items: list[dict[str, Any]]) -> str:

0 commit comments

Comments
 (0)