Skip to content

budget: surface cached and reasoning token counts from the live usage payload #307

Closed

Description

@amirkhan-ak-sf

What this is

Surface the cached and reasoning token counts the proxy already returns,
on both Budget and the OTel span — so a developer can see cache effectiveness
and reasoning overhead without parsing the response body themselves.

Why it is needed

The FinOps roadmap lists "Support for Cached and Reasoning Tokens" (accurate cost
tracking for cached input and reasoning output). Unusually for a roadmap item,
the data is already live — no verification row needed.

python/tests/fixtures/anypoint/llm_proxy/responses.success.body.json, captured
against the real proxy, carries:

"usage": {
  "input_tokens": 17,
  "input_tokens_details": { "cache_write_tokens": 0, "cached_tokens": 0 },
  "output_tokens": 51,
  "output_tokens_details": { "reasoning_tokens": 0 },
  "total_tokens": 68
}

Meanwhile core/budget.py models only the three x-token-* headers —
limit, remaining, reset. It never looks at the response body, so these
fields are invisible to the SDK today.

This matters beyond curiosity. A reasoning model can spend the majority of its
output tokens on reasoning the developer never sees, and cached input tokens are
typically billed at a fraction of the normal rate. A developer reading only
total_tokens draws the wrong conclusion about both cost and latency, and has no
signal at all about whether their prompt-caching strategy is working.

Scope

  • Parse usage.input_tokens_details.cached_tokens,
    usage.input_tokens_details.cache_write_tokens and
    usage.output_tokens_details.reasoning_tokens from the response body.
  • Expose them as last-call fields alongside the existing budget window. They are
    per-call, not a window — do not fold them into remaining, which is a
    gateway-owned quantity.
  • Emit them as span attributes so they land in whatever backend consumes telemetry: OTel GenAI spans at a pinned semconv version, dual-emitted with a stable donkey.* namespace #192.
  • Absent fields stay None, never 0 — the same honesty rule Budget.observe()
    already applies to an unobserved window.

Sketch

from donkey_kit import Donkey

donkey = Donkey.from_env()
model = donkey.langgraph.chat_model("gpt-5.1")          # deep adapter
await model.ainvoke("...")

u = donkey.budget.last_usage
u.input_tokens        # 17
u.cached_tokens       # 0   -- billed at the cached rate
u.reasoning_tokens    # 0   -- output tokens you never see
u.total_tokens        # 68

donkey.budget.remaining   # unchanged: still the gateway's window

Raw client — the parse lives in the shared transport's _on_response hook, so
the no-framework path gets it identically:

client = donkey.llm.client()
await client.responses.create(model="gpt-5.1", input="...")
donkey.budget.last_usage.reasoning_tokens

TypeScript parity (planned, Phase 5):

const usage = donkey.budget.lastUsage;
usage.cachedTokens;
usage.reasoningTokens;

Acceptance

  • The three fields parse from the captured live fixture, with the fixture as the test input.
  • Absent fields report None, not 0.
  • A response with no usage object is a defined no-op, exactly as a response
    with no x-token-* headers is today.
  • Span attributes emitted under the pinned semconv names where they exist.
  • Both the async and sync transports carry the behaviour.

Cross-surface impact

  • core: budget.py (a per-call usage record beside the window),
    transport.py (_on_response already fires once on the final response — the
    right seam), telemetry.py (the new attributes).
  • llm: streaming responses deliver usage differently; confirm the streaming path.
  • registry / tools / integrations: not affected.
  • TypeScript parity (planned): flag.
  • Provisioning CLI: not affected.
  • Nextra docs site: the budget and telemetry pages.
  • docs/verified-apis.md: the usage shape is already live-captured; add a row
    pointing at the fixture so nobody re-opens this as a verification question.

Related

Activity

  1. added
    enhancementNew feature or request
    priority/p1Must-have: blocks a real adapter or CI gate
    size/s~half day, one file
    area:budgetBudget object, pacing, rate-limit awareness (1.3)
    area:telemetryOTel GenAI spans, correlation IDs, cost tags (1.6, 1.7)
    on Sep 9, 2026
  2. amirkhan-ak-sf commented on Sep 11, 2026

    @amirkhan-ak-sf
    CollaboratorAuthor

    donkey.budget.last_usage does not exist yet, and #309 independently sketches donkey.last_routing — a different name on a different parent, describing the same HTTP response. Filed #362 to define the last-call record once so these per-call usage fields have a settled home; the cached/reasoning parsing itself stays scoped here.

  3. self-assigned this
    on Sep 15, 2026
  4. tommasobolis commented on Sep 15, 2026

    @tommasobolis
    Collaborator

    Delivered on develop via #377 (squash commit 7155dc2). Auto-close didn't fire because the PR merged into develop, not the default branch; closing manually. Ships with the Phase 1 (0.1.0) promotion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:budgetBudget object, pacing, rate-limit awareness (1.3)area:telemetryOTel GenAI spans, correlation IDs, cost tags (1.6, 1.7)enhancementNew feature or requestpriority/p1Must-have: blocks a real adapter or CI gatesize/s~half day, one file

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions