feat(proxy): let extensions report cost savings and their own latency - #3051
Open
chopratejas wants to merge 1 commit into
Open
feat(proxy): let extensions report cost savings and their own latency#3051chopratejas wants to merge 1 commit into
chopratejas wants to merge 1 commit into
Conversation
An extension that changes the bill has to be able to say so, or the operator
sees a different total with nothing to explain it. Two gaps stopped that.
SAVINGS WERE DROPPED ON GEMINI TRAFFIC
`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. Bound at all four Gemini tag
sites.
AN EXTENSION'S OWN LATENCY WAS INVISIBLE
`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 200ms
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 the
existing `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. It lands in `/stats.pipeline_timing`,
the dashboard's Performance panel, and `headroom_transform_timing_ms_*`.
Stage names are extension-supplied, so they are capped (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, which is unreachable while the prefix stands and is the
safe way round if it ever goes.
Both ledgers are now stripped by `public_tags`: they 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.
`extensions.py` documents both calls. `record_scope_savings` already accepted
`usd`, which is the one channel in the proxy that can express savings WITHOUT
tokens -- routing a request to a cheaper model sends the same tokens for less
money -- but nothing in the extension-facing contract said so, and the module
is where extension authors look.
Real behavior proof in the PR body: a demo ASGI extension reporting
`tokens=0, usd=0.173` shows up on /stats as `savings.by_source`, in
`pipeline_timing` as `ext:routemegood`, and in /metrics as
`headroom_savings_attributed_usd_total{source="routemegood"} 0.519`.
Test suite: 10,989 passed, 578 skipped. The 3 failures on this branch
(test_graceful_shutdown ordering, test_learn integration, release-workflow
cargo) reproduce identically on clean main.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chopratejas
requested review from
DevanshiVyas and
JerrettDavis
as code owners
August 16, 2026 00:58
Contributor
PR governanceThis PR does not yet satisfy the required template fields:
Please update the PR body, or move the PR back to draft while it is still in progress. |
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_savingsalready existed and already acceptedusd— 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_scopeshares 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_msis 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 torecord_scope_savings, carried on the same bound ledger and merged intoRequestOutcome.pipeline_timingat the outcome funnel — one place, so every provider picks it up at once.API surface
Both take the ASGI
scope, because middleware has no other way in. Documented inextensions.py— the module extension authors actually read, and the stability contract for this interface./statssavings.by_source, dashboard card,headroom_savings_attributed_usd_total{source=...}/statspipeline_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_tagsnow strips_headroom_stage_timingas well as_headroom_savings_attribution. Both ride ontagsbecause 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_timingpassed tometrics.record_requestis 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).ext:—deep_copyreported by a plugin must never accumulate into the same series asdeep_copymeasured 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_tagstolerates junk on the tag.Test-double fix
Three Gemini test fakes (
FakeRequest,_FakeRequest,_VertexGeminiImageRequest) had no.scope, which every real StarletteRequesthas. 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, realcreate_appproxy withrespx-mocked Anthropic upstream, a demo ASGI extension added viaapp.add_middleware.The extension — written as a third party would, reporting
tokens=0because it re-routedclaude-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 computesaved = before - after.Ran: three POSTs to
/v1/messages, thenGET /statsandGET /metrics.Observed:
$0.519 = 3 × $0.173— three requests, correctly accumulated, withtokens: 0throughout.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_outcomefunnel via the same descriptor-binding harnesstest_request_outcome.pyuses.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, andtest_release_workflows.py::test_no_native_tls_in_wheel_build_tree(needscargo) — reproduce identically on cleanmain(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 Geminibind_scopefix 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