Skip to content

Commit 78136e4

Browse files
committed
fix(ccr): fold dropped continuation rounds' usage into cost_with_headroom
handle_response returns only the final continuation round, so the dropped rounds' usage was captured into /stats ccr_overhead but excluded from cost_with_headroom. The cost.py comment claiming it was 'already inside cost_with_headroom' did not hold: handle_response returns only current_response, the handler adopts it as resp_json, and the outcome funnel reads usage from that single final-round response. The intermediate rounds go through a raw api_call_fn and skip the funnel. Fold the dropped rounds' usage into the billed totals, cache-split per provider, reusing the proactive-expansion ContextVar pattern already in this PR. Cache-split matters: Anthropic input_tokens is the uncached portion, but OpenAI prompt_tokens is gross (includes cached_tokens), so collapsing to one input number would price cached continuation tokens at full list instead of the discounted rate. - response_handler.handle_response: sum each dropped round's usage into locals as (uncached_input, cache_read, cache_write, output) via the new _extract_round_cost_usage (provider-aware; mirrors the per-handler parsing), and publish via set_pending_ccr_continuation_usage before returning. Locals, not the shared instance counters, so concurrent requests on one handler do not mix. The instance accumulators stay for the /stats attribution view. Signature and all call sites unchanged. - outcome.emit_request_outcome: consume it and add each bucket to the field cost_with_headroom prices (uncached_input_tokens at list, cache_read_tokens discounted, cache_write_tokens premium; output_tokens to the budget/metrics total, since cost_with_headroom itself is input-only). Client-facing response.usage is untouched. The >=500 short-circuit still books nothing. - cost.py: fix the comment. OpenAI cache_write inference (the _infer_openai_cache_write_tokens heuristic) is not replicated in _extract_round_cost_usage; only explicitly reported cache_creation is credited. The batch path (multiple handle_response calls, one emit) is a known follow-up. Verified: 148 passed across CCR / proactive-expansion / stats / outcome, 0 regressions.
1 parent fcdebb5 commit 78136e4

4 files changed

Lines changed: 532 additions & 3 deletions

File tree

headroom/ccr/response_handler.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,19 @@ async def handle_response(
459459
current_response = response
460460
current_messages = list(messages) # Copy to avoid mutation
461461
rounds = 0
462+
# Per-call sum of the dropped rounds' usage, split into the cost buckets
463+
# the outcome funnel prices (uncached input / cache_read / cache_write /
464+
# output). Kept in locals (NOT the shared instance counters at 542-543)
465+
# so concurrent requests on one handler can't mix their overhead; the
466+
# instance counters stay for the /stats ccr_overhead attribution view.
467+
# Cache-split (not just gross input) matters: OpenAI prompt_tokens and
468+
# Anthropic input_tokens have different cache semantics, and pricing
469+
# cached tokens at the discounted/premium rate vs full-list uncached is
470+
# the difference between an accurate fold and an over-count.
471+
call_overhead_uncached_in = 0
472+
call_overhead_cache_read = 0
473+
call_overhead_cache_write = 0
474+
call_overhead_output = 0
462475

463476
while rounds < self.config.max_retrieval_rounds:
464477
# Check for CCR tool calls
@@ -541,6 +554,11 @@ async def handle_response(
541554
self._retrieved_tokens += sum(r.tokens_retrieved for r in results)
542555
self._ccr_overhead_input_tokens += _in_tok
543556
self._ccr_overhead_output_tokens += _out_tok
557+
_uin, _cr, _cw, _uout = self._extract_round_cost_usage(current_response, provider)
558+
call_overhead_uncached_in += _uin
559+
call_overhead_cache_read += _cr
560+
call_overhead_cache_write += _cw
561+
call_overhead_output += _uout
544562

545563
# Durable ledger: record this round's proxy-side retrieval so
546564
# `headroom savings` reflects net (not just gross) for proxy
@@ -555,6 +573,28 @@ async def handle_response(
555573
f"returning response with possible unhandled CCR calls"
556574
)
557575

576+
# Publish this call's dropped-round usage (cache-split) to the outcome
577+
# funnel (per-asyncio-task ContextVar). The funnel folds each bucket
578+
# into the matching cost field so cost_with_headroom prices continuation
579+
# cache at the right rate. Guarded so a no-continuation call (rounds ==
580+
# 0) publishes nothing.
581+
if (
582+
call_overhead_uncached_in
583+
or call_overhead_cache_read
584+
or call_overhead_cache_write
585+
or call_overhead_output
586+
):
587+
from headroom.proxy.outcome import set_pending_ccr_continuation_usage
588+
589+
set_pending_ccr_continuation_usage(
590+
(
591+
call_overhead_uncached_in,
592+
call_overhead_cache_read,
593+
call_overhead_cache_write,
594+
call_overhead_output,
595+
)
596+
)
597+
558598
return current_response
559599

560600
def _record_retrieval_savings(self, results: list[CCRToolResult]) -> None:
@@ -616,6 +656,59 @@ def _extract_usage_tokens(resp: Any) -> tuple[int, int]:
616656
except (TypeError, ValueError):
617657
return 0, 0
618658

659+
@staticmethod
660+
def _extract_round_cost_usage(resp: Any, provider: str) -> tuple[int, int, int, int]:
661+
"""Return (uncached_input, cache_read, cache_write, output) for one
662+
continuation round, split into the cost buckets the outcome funnel
663+
prices.
664+
665+
Provider-aware so cached tokens land in cache_read / cache_write
666+
(priced discounted / premium by cost_with_headroom) instead of being
667+
collapsed into uncached input at full list price. Mirrors the per-handler
668+
usage parsing (handlers/anthropic.py, handlers/openai.py) for the dropped
669+
continuation rounds, which those handlers only parse for the final round.
670+
671+
OpenAI cache_write *inference* (_infer_openai_cache_write_tokens) is
672+
deliberately NOT replicated here: it is a heuristic, and for the cost
673+
fold we only credit cache_write that the provider reported explicitly
674+
(cache_creation_input_tokens). Cached-token reads prefer the
675+
Anthropic/Bedrock top-level keys when present (authoritative), then fall
676+
back to the OpenAI prompt_tokens_details / input_tokens_details shapes.
677+
"""
678+
if not isinstance(resp, dict):
679+
return 0, 0, 0, 0
680+
usage = resp.get("usage") or {}
681+
if not isinstance(usage, dict):
682+
return 0, 0, 0, 0
683+
684+
def _i(value: Any, default: int = 0) -> int:
685+
try:
686+
return max(int(value), 0)
687+
except (TypeError, ValueError):
688+
return default
689+
690+
if provider in ("openai", "openai_responses"):
691+
gross = _i(usage.get("prompt_tokens", usage.get("input_tokens", 0)))
692+
out = _i(usage.get("completion_tokens", usage.get("output_tokens", 0)))
693+
cr = _i(usage.get("cache_read_input_tokens", 0))
694+
cw = _i(usage.get("cache_creation_input_tokens", 0))
695+
if cr == 0:
696+
details = (
697+
usage.get("prompt_tokens_details") or usage.get("input_tokens_details") or {}
698+
)
699+
if isinstance(details, dict):
700+
cr = _i(details.get("cached_tokens", 0))
701+
uncached = max(0, gross - cr - cw)
702+
return uncached, cr, cw, out
703+
704+
# anthropic (and default): input_tokens is already the uncached portion.
705+
return (
706+
_i(usage.get("input_tokens", 0)),
707+
_i(usage.get("cache_read_input_tokens", 0)),
708+
_i(usage.get("cache_creation_input_tokens", 0)),
709+
_i(usage.get("output_tokens", 0)),
710+
)
711+
619712
def get_stats(self) -> dict[str, Any]:
620713
"""Get handler statistics."""
621714
with self._retrieval_count_lock:

headroom/proxy/cost.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -670,9 +670,16 @@ def _entry_number(entry: Any, attr: str) -> int | float:
670670
summary["compression"]["retrievals_total"] = retrievals_total
671671
summary["compression"]["net_tokens_saved"] = net_tokens_saved
672672

673-
# Cost view = ATTRIBUTION ONLY, never debited: the CCR continuation's usage
674-
# is already inside cost_with_headroom (handlers/anthropic.py reads usage
675-
# AFTER the continuation replaced resp_json), so debiting here double-counts.
673+
# 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
680+
# total, that double-counts. (The earlier "already inside cost_with_headroom"
681+
# claim was inaccurate: handle_response returns only the final round, so
682+
# without the funnel fold the dropped rounds were never costed at all.)
676683
summary["cost"]["retrieval_cost_usd"] = round(
677684
estimate_cost_usd(primary_model, tokens_retrieved + overhead_tokens), 6
678685
)

headroom/proxy/outcome.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,35 @@ def consume_pending_proactive_retrieval() -> tuple[int, int] | None:
5656
return value
5757

5858

59+
# CCR reactive-continuation overhead (the dropped rounds' real billed usage),
60+
# bound per-request via a ContextVar for the same reason as the proactive one:
61+
# the outcome funnel can fold it into the billed token totals WITHOUT threading
62+
# a parameter through every handler. handle_response publishes the per-call sum
63+
# (kept in locals, so it is safe across concurrent requests on a shared handler);
64+
# consumed only in emit's success section, so a failed forward books nothing.
65+
# The payload is cache-split (uncached_input, cache_read, cache_write, output) so
66+
# cost_with_headroom prices continuation cache at the right rate rather than
67+
# folding cached tokens into uncached input at full list price.
68+
_pending_ccr_continuation_usage: ContextVar[tuple[int, int, int, int] | None] = ContextVar(
69+
"headroom_ccr_continuation_pending", default=None
70+
)
71+
72+
73+
def set_pending_ccr_continuation_usage(
74+
value: tuple[int, int, int, int] | None,
75+
) -> None:
76+
"""Bind the dropped continuation rounds' (uncached_in, cache_read, cache_write, output) to this request."""
77+
_pending_ccr_continuation_usage.set(value)
78+
79+
80+
def consume_pending_ccr_continuation_usage() -> tuple[int, int, int, int] | None:
81+
"""Read and clear the pending continuation usage for this request."""
82+
value = _pending_ccr_continuation_usage.get()
83+
if value is not None:
84+
_pending_ccr_continuation_usage.set(None)
85+
return value
86+
87+
5988
@dataclass(frozen=True)
6089
class RequestOutcome:
6190
"""Immutable, value-equal snapshot of a completed request.
@@ -362,6 +391,8 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None:
362391
and is awaitable-compatible. We could lift this to a typing.Protocol
363392
if/when another contract surface emerges, but YAGNI.
364393
"""
394+
import dataclasses
395+
365396
from headroom.copilot_auth import consume_request_routed_to_copilot
366397
from headroom.proxy.cost import _summarize_transforms
367398
from headroom.proxy.models import RequestLog
@@ -385,6 +416,12 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None:
385416
# >=500 short-circuit must never book a retrieval that was never billed.
386417
_pending_proactive = consume_pending_proactive_retrieval()
387418

419+
# Consume (read + clear) the dropped CCR continuation rounds' usage for this
420+
# request. Folded into the billed totals only in the success section below —
421+
# a >=500 short-circuit must never book continuation tokens that, by the
422+
# account-after-success rule in handle_response, were never billed.
423+
_pending_continuation = consume_pending_ccr_continuation_usage()
424+
388425
# Upstream failure (>= 500, e.g. a 529 Overloaded surfaced after retry
389426
# exhaustion) must not feed the savings/cost/log success stats; that would
390427
# let a failed request inflate the save-rate. Record it as failed and stop,
@@ -403,6 +440,25 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None:
403440
if _proactive_rec is not None:
404441
_proactive_rec(*_pending_proactive)
405442

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+
)
461+
406462
# Output-shaping savings ledger (counterfactual estimator). The shaper
407463
# tags each request's (arm, stratum) onto ``transforms_applied``; feed the
408464
# observed output tokens to the recorder so it can produce an honest

0 commit comments

Comments
 (0)