Skip to content

Commit b4f479b

Browse files
committed
fix(ccr): scope the continuation-usage fold to the cost view
The fold added the dropped CCR continuation rounds onto the RequestOutcome, but three consumers of the outcome funnel are not cost surfaces: - metrics.record_request forwards cache_read_tokens to SavingsTracker, which credits it as cache *savings* (cache_savings_usd -> SavingsSnapshot .total_savings and `headroom doctor`), so retrieval overhead RAISED the reported savings: 80k continuation cache reads on a 1k-read turn moved cache_savings_usd from $0.0027 to $0.2187 in a synthetic check. - the output-shaper counterfactual reads outcome.output_tokens as this turn's completion length; folded continuation output biased the per-arm estimate (the recorder saw 1180 tokens for an 80-token turn). - the RequestLog row and the PERF line describe the single round the client received. Move the fold to the cost_tracker.record_tokens call, which is where the billed input breakdown behind cost_with_headroom_usd lands and where _costs is appended for budget enforcement. Cost accounting keeps the original fix's intent; the savings, log and PERF surfaces stay on the final round. Also: - clear both pending side channels in the caller's context after the shielded emit. asyncio.shield runs the funnel in a child task with a copied context, so its consume_* cleared the copy, and a second outcome emitted from the same context re-booked the same continuation usage and proactive drawback. - keep the original_tokens floor on the Responses-path usage recompute. Falling to 0 priced that round's input at nothing, because the folded continuation tokens keep record_tokens' "no breakdown -> use tokens_sent" fallback from firing. - cover the Responses recompute end-to-end (initial round 50/10, final 60/5) and pin each non-cost surface against re-folding.
1 parent 086d7c8 commit b4f479b

6 files changed

Lines changed: 295 additions & 80 deletions

File tree

headroom/proxy/cost.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -671,15 +671,18 @@ def _entry_number(entry: Any, attr: str) -> int | float:
671671
summary["compression"]["net_tokens_saved"] = net_tokens_saved
672672

673673
# Attribution view only. The dropped CCR continuation rounds' usage is now
674-
# folded into the billed totals at the outcome funnel
675-
# (set_pending_ccr_continuation_usage -> uncached_input_tokens /
676-
# cache_read_tokens / cache_write_tokens, which cost_with_headroom prices, and
677-
# output_tokens for the budget/metrics total). So the dropped rounds ARE
678-
# inside cost_with_headroom (their input side). This block surfaces them
679-
# again purely as a breakdown; do NOT add ccr_overhead into a second cost
674+
# folded into the cost view at the outcome funnel
675+
# (set_pending_ccr_continuation_usage -> the cost_tracker.record_tokens
676+
# cache_read / cache_write / uncached args that cost_with_headroom prices,
677+
# plus output_tokens for the budget). So the dropped rounds ARE inside
678+
# cost_with_headroom (their input side). This block surfaces them again
679+
# purely as a token breakdown; do NOT add ccr_overhead into a second cost
680680
# total, that double-counts. (The earlier "already inside cost_with_headroom"
681681
# claim was inaccurate: handle_response returns only the final round, so
682682
# without the funnel fold the dropped rounds were never costed at all.)
683+
# Note the funnel deliberately leaves metrics / SavingsTracker / RequestLog
684+
# on the final round's numbers — continuation cache reads must not be
685+
# credited as cache savings.
683686
summary["cost"]["retrieval_cost_usd"] = round(
684687
estimate_cost_usd(primary_model, tokens_retrieved + overhead_tokens), 6
685688
)

headroom/proxy/handlers/openai.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5255,12 +5255,22 @@ async def api_call_fn(
52555255
# outcome carries the initial round's usage (captured
52565256
# above, before CCR): it under-counts the final round
52575257
# and, combined with the continuation-cost fold,
5258-
# double-books the initial round. Absent fields
5259-
# default to 0 so a partial final payload cannot
5260-
# leave the stale initial usage in place.
5258+
# double-books the initial round. A partial final
5259+
# payload must not leave the stale initial usage in
5260+
# place either, so absent fields are re-derived, not
5261+
# kept: output/cached fall to 0, and the input side
5262+
# keeps the same ``original_tokens`` floor the
5263+
# pre-CCR capture above used — dropping to 0 there
5264+
# would price this round's input at nothing, since
5265+
# the folded continuation tokens keep
5266+
# ``record_tokens``' "no breakdown -> use
5267+
# tokens_sent" fallback from firing.
52615268
_final_usage = resp_json.get("usage") or {}
52625269
if isinstance(_final_usage, dict):
5263-
total_input_tokens = _usage_int(_final_usage.get("input_tokens"))
5270+
total_input_tokens = _usage_int(
5271+
_final_usage.get("input_tokens"),
5272+
original_tokens,
5273+
)
52645274
output_tokens = _usage_int(_final_usage.get("output_tokens"))
52655275
_final_details = _final_usage.get("input_tokens_details") or {}
52665276
if isinstance(_final_details, dict):

headroom/proxy/outcome.py

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,21 @@ def consume_pending_ccr_continuation_usage() -> tuple[int, int, int, int] | None
8585
return value
8686

8787

88+
def clear_pending_outcome_side_channels() -> None:
89+
"""Drop both pending side-channel values in the CALLER's context.
90+
91+
``emit_request_outcome`` consumes them itself, but production reaches it
92+
through ``asyncio.shield`` (``HeadroomProxy._record_request_outcome``), which
93+
runs the funnel in a child task holding a *copy* of the caller's context —
94+
so the funnel's ``set(None)`` never reaches the caller. Any call site that
95+
shields must clear here afterwards; otherwise a second outcome emitted from
96+
the same context (a batch item, a re-driven turn) books the same continuation
97+
usage and proactive drawback a second time.
98+
"""
99+
_pending_proactive_retrieval.set(None)
100+
_pending_ccr_continuation_usage.set(None)
101+
102+
88103
@dataclass(frozen=True)
89104
class RequestOutcome:
90105
"""Immutable, value-equal snapshot of a completed request.
@@ -391,8 +406,6 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None:
391406
and is awaitable-compatible. We could lift this to a typing.Protocol
392407
if/when another contract surface emerges, but YAGNI.
393408
"""
394-
import dataclasses
395-
396409
from headroom.copilot_auth import consume_request_routed_to_copilot
397410
from headroom.proxy.cost import _summarize_transforms
398411
from headroom.proxy.models import RequestLog
@@ -417,7 +430,7 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None:
417430
_pending_proactive = consume_pending_proactive_retrieval()
418431

419432
# Consume (read + clear) the dropped CCR continuation rounds' usage for this
420-
# request. Folded into the billed totals only in the success section below —
433+
# request. Folded into the cost view only in the success section below —
421434
# a >=500 short-circuit must never book continuation tokens that, by the
422435
# account-after-success rule in handle_response, were never billed.
423436
_pending_continuation = consume_pending_ccr_continuation_usage()
@@ -440,24 +453,28 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None:
440453
if _proactive_rec is not None:
441454
_proactive_rec(*_pending_proactive)
442455

443-
# Fold the dropped continuation rounds' real billed usage into the cost-
444-
# relevant token totals. handle_response returns only the final round, so
445-
# without this the intermediate rounds' usage never reaches cost_with_headroom
446-
# (it sat in the /stats ccr_overhead side-channel but was deliberately
447-
# excluded from the total). Each bucket lands in the field cost_with_headroom
448-
# prices: uncached input at list, cache_read at the discounted rate,
449-
# cache_write at the premium rate, and output into the budget/metrics total
450-
# (cost_with_headroom itself is input-only). Client-facing response.usage is
451-
# unchanged; only the internal billed totals move.
452-
if _pending_continuation is not None:
453-
_ct_uin, _ct_cr, _ct_cw, _ct_out = _pending_continuation
454-
outcome = dataclasses.replace(
455-
outcome,
456-
output_tokens=outcome.output_tokens + _ct_out,
457-
uncached_input_tokens=outcome.uncached_input_tokens + _ct_uin,
458-
cache_read_tokens=outcome.cache_read_tokens + _ct_cr,
459-
cache_write_tokens=outcome.cache_write_tokens + _ct_cw,
460-
)
456+
# The dropped continuation rounds were really billed, so they belong in the
457+
# cost view — and ONLY there, which is why they are added at the
458+
# ``cost_tracker.record_tokens`` call below instead of onto ``outcome``:
459+
#
460+
# * ``metrics.record_request`` forwards ``cache_read_tokens`` to
461+
# SavingsTracker, which credits it as cache *savings*
462+
# (``_estimate_cache_savings_usd`` -> ``lifetime.cache_savings_usd`` ->
463+
# ``SavingsSnapshot.total_savings``). Folding there would let CCR retrieval
464+
# overhead RAISE the reported savings.
465+
# * the output-shaper estimator below reads ``outcome.output_tokens`` as the
466+
# observed completion length of *this* turn; continuation output would bias
467+
# the per-arm counterfactual.
468+
# * the RequestLog row and the PERF line describe the one round the client
469+
# received, so they stay on the final round's numbers.
470+
#
471+
# ``cost_tracker.record_tokens`` is where the billed input breakdown lands
472+
# (``_api_{uncached,cache_read,cache_write}_by_model`` -> the per-category
473+
# pricing behind ``cost_with_headroom_usd``) and where ``_costs`` is appended
474+
# for budget enforcement, so the fold reaches both of the surfaces that are
475+
# meant to reflect real spend. ``/stats`` ``cost.ccr_overhead`` keeps the
476+
# token-level attribution view.
477+
_ct_uin, _ct_cr, _ct_cw, _ct_out = _pending_continuation or (0, 0, 0, 0)
461478

462479
# Output-shaping savings ledger (counterfactual estimator). The shaper
463480
# tags each request's (arm, stratum) onto ``transforms_applied``; feed the
@@ -513,19 +530,23 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None:
513530
tool_search_saved=tool_search_saved,
514531
)
515532

516-
# 2. Cost tracker (optional).
533+
# 2. Cost tracker (optional). The dropped CCR continuation rounds are folded
534+
# in here — see the comment above the ``_ct_*`` unpack. The 5m/1h split is
535+
# left alone: the provider does not report a TTL for a continuation round,
536+
# so only the cache-write total moves and the TTL attribution view stays
537+
# honest about what it actually observed.
517538
cost_tracker = getattr(handler, "cost_tracker", None)
518539
if cost_tracker is not None:
519540
cost_tracker.record_tokens(
520541
outcome.model,
521542
outcome.tokens_saved,
522543
outcome.optimized_tokens,
523-
cache_read_tokens=outcome.cache_read_tokens,
524-
cache_write_tokens=outcome.cache_write_tokens,
544+
cache_read_tokens=outcome.cache_read_tokens + _ct_cr,
545+
cache_write_tokens=outcome.cache_write_tokens + _ct_cw,
525546
cache_write_5m_tokens=outcome.cache_write_5m_tokens,
526547
cache_write_1h_tokens=outcome.cache_write_1h_tokens,
527-
uncached_tokens=outcome.uncached_input_tokens,
528-
output_tokens=outcome.output_tokens,
548+
uncached_tokens=outcome.uncached_input_tokens + _ct_uin,
549+
output_tokens=outcome.output_tokens + _ct_out,
529550
)
530551

531552
# 3. Per-request log (optional). The ``client`` outcome field is

headroom/proxy/server.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1940,7 +1940,10 @@ async def _record_request_outcome(self, outcome: RequestOutcome) -> None:
19401940
See ``docs/superpowers/specs/P0-proxy-pipeline-audit.md`` for the
19411941
divergence catalog this funnel collapses.
19421942
"""
1943-
from headroom.proxy.outcome import emit_request_outcome
1943+
from headroom.proxy.outcome import (
1944+
clear_pending_outcome_side_channels,
1945+
emit_request_outcome,
1946+
)
19441947

19451948
# Shielded because four call sites are `finally:` blocks inside streaming
19461949
# async generators (streaming.py:1611, :1859, :2069, openai.py:8614). A
@@ -1953,7 +1956,14 @@ async def _record_request_outcome(self, outcome: RequestOutcome) -> None:
19531956
# The shield does not swallow the cancellation — the await below still
19541957
# raises CancelledError, so generator teardown propagates exactly as
19551958
# before. It only keeps the bookkeeping from being torn in half.
1956-
await asyncio.shield(emit_request_outcome(self, outcome))
1959+
try:
1960+
await asyncio.shield(emit_request_outcome(self, outcome))
1961+
finally:
1962+
# The shielded funnel runs in a child task, which holds a COPY of this
1963+
# context: its consume_* cleared the copy, not ours. Clear here as well,
1964+
# or a second outcome emitted from this same context would re-book the
1965+
# same CCR continuation usage / proactive drawback.
1966+
clear_pending_outcome_side_channels()
19571967

19581968
async def _next_request_id(self) -> str:
19591969
"""Generate unique request ID."""

0 commit comments

Comments
 (0)