Skip to content

feat(litellm): preserve reasoning usage details - #31

Merged
VickyXAI merged 2 commits into
mainfrom
feat/reasoning-token-usage
Aug 8, 2026
Merged

feat(litellm): preserve reasoning usage details#31
VickyXAI merged 2 commits into
mainfrom
feat/reasoning-token-usage

Conversation

@KillerQueen-Z

Copy link
Copy Markdown
Collaborator

What changed

  • Preserve prompt/completion token detail blocks on streamed LiteLLM usage frames.
  • Carry cache read/create and completion_tokens_details.reasoning_tokens through stream aggregation.
  • Preserve token detail blocks when bridging Chat Completions to the Responses API, for both non-streamed and streamed responses.
  • Add regression coverage for non-stream, stream, and Responses usage conversion.

Why

The gateway returned the correct reasoning breakdown, but LiteLLM's final streamed usage object retained only prompt/completion/total counts.

Validation

  • 14 tests passed; 1 documented compatibility test skipped.
  • Python bytecode compilation passed.
  • git diff --check passed.

@VickyXAI

VickyXAI commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Pre-landing review

Good instinct on this one — the cache-read forwarding is a real cost-accuracy win (see the "verified safe" note at the bottom). But there's a blocker, and the patch is only half-applied.


🔴 Blocking — provider.py:253,255,293,295 crash whenever the details blocks are absent

if chunk.usage.prompt_tokens_details is not None:      # provider.py:253
if chunk.usage.completion_tokens_details is not None:  # provider.py:255

ChatUsage (blockrun-llm types.py:79-93) declares only prompt_tokens, completion_tokens, total_tokens, num_sources_used, cache_read_input_tokens, cache_creation_input_tokens, with extra = "allow". prompt_tokens_details and completion_tokens_details are not declared fields — they're pydantic extras. Attribute access on an absent extra raises; it does not return None:

>>> ChatUsage(prompt_tokens=1, completion_tokens=2, total_tokens=3).prompt_tokens_details
AttributeError: 'ChatUsage' object has no attribute 'prompt_tokens_details'

The two cache_* accesses are fine — those are declared.

End-to-end, this drops 2 of the 3 usage shapes the gateway emits. I drove realistic streams through the full path (registered provider → CustomStreamWrapperstream_chunk_builder), varying only the final choices: [] include_usage frame:

########## PR CODE AS SUBMITTED (9d348fe) ##########
  [CRASH] anthropic (cache fields, NO *_tokens_details)
          APIConnectionError: 'ChatUsage' object has no attribute 'prompt_tokens_details'
  [OK]    openai (both details blocks present)
          cache_read=None cached_tokens=40 reasoning=12
  [CRASH] bare (three counts only)
          APIConnectionError: 'ChatUsage' object has no attribute 'prompt_tokens_details'
  1/3 gateway usage shapes survive the full stream path

Note the symptom the caller actually sees: LiteLLM wraps the AttributeError into litellm.APIConnectionError, which reads as a transient network fault. Any client with retry-on-connection-error will retry a stream that already completed and already settled — paying twice for one completion, with the retry guaranteed to fail the same way. Only the OpenAI-family shape survives, because OpenAI always emits prompt_tokens_details.

The branch does not pass its tests. main: 10 passed. This branch: 9 failed, 302 passed, 1 skipped, every failure tracing to provider.py:253 through _native_extras:

blockrun_litellm/provider.py:253: in _native_extras
    if chunk.usage.prompt_tokens_details is not None:
E   AttributeError: 'ChatUsage' object has no attribute 'prompt_tokens_details'

Ironically the new fixture is what exposes it: GATEWAY_USAGE (test_stream_usage.py:43) adds completion_tokens_details but no prompt_tokens_details. The PR description's "14 tests passed" looks like a filtered subset — please re-run the full suite.

_to_generic_chunk is called unguarded at provider.py:408 and :435, so this propagates out of the stream generator and kills the stream on the final usage frame, after the gateway has settled. Per the optimistic-settle behaviour on Solana the caller is already charged at that point. OpenAI models survive (OpenAI always emits prompt_tokens_details); anything that omits it does not.

Fix. In _native_extras the two blocks are also redundant — extras already land in model_extra, which line 252 copies wholesale:

>>> ChatUsage(..., prompt_tokens_details={'cached_tokens':4}, completion_tokens_details={'reasoning_tokens':5}).model_extra
{'prompt_tokens_details': {'cached_tokens': 4}, 'completion_tokens_details': {'reasoning_tokens': 5}}

So just delete 253-256. In _to_generic_chunk the values genuinely need forwarding, so read them from model_extra (or getattr(..., None)):

            _extra = chunk.usage.model_extra or {}
            for _k in ("prompt_tokens_details", "completion_tokens_details"):
                if _extra.get(_k) is not None:
                    usage[_k] = _extra[_k]

With exactly that change, the full suite is 311 passed, 1 skipped, and the same end-to-end run goes green — including the cache detail this PR is trying to preserve:

########## WITH RECOMMENDED FIX ##########
  [OK]    anthropic (cache fields, NO *_tokens_details)
          prompt=2160 cache_read=2048 cached_tokens=2048 reasoning=0
  [OK]    openai (both details blocks present)
          prompt=100  cache_read=None cached_tokens=40   reasoning=12
  [OK]    bare (three counts only)
          prompt=100  cache_read=None cached_tokens=None reasoning=0
  3/3 gateway usage shapes survive the full stream path

The feature works. This is the only thing standing in its way.


🟡 provider.py:337-343 — only one of the two usage paths was patched

    usage = None
    if chunk.usage is not None:
        usage = {
            "prompt_tokens": chunk.usage.prompt_tokens,
            "completion_tokens": chunk.usage.completion_tokens,
            "total_tokens": chunk.usage.total_tokens,
        }

The choices-bearing branch still emits bare counts, while _native_extras (line 277) runs on every chunk and attaches full detail to provider_specific_fields. One frame, two answers to "how many tokens were cached".

This also brushes an unguarded reset in LiteLLM — streaming_chunk_builder_utils.py:369 assigns prompt_tokens_details unconditionally, unlike its is not None-guarded completion_tokens_details neighbour on :365 — so a later bare usage chunk nulls the detail. The gateway emits one canonical choices: [] end-of-stream frame (ai-stream.ts:475-520), which is the branch you did patch, so this isn't live today. Worth closing as symmetry and defense-in-depth, not as an active billing bug.

🟡 proxy.py:1862-1876usage_out is replaced, not merged

Every usage-bearing chunk rebuilds the dict from scratch, so a later bare frame wipes detail captured earlier. Same class as above, but here nothing upstream protects it. Merge into usage_out instead of reassigning.

🟡 proxy.py:1765-1775 — the Responses detail blocks are conditional, but the spec makes them required

Against openai 1.75.0:

ResponseUsage        input_tokens_details required=True, output_tokens_details required=True
InputTokensDetails   cached_tokens        required=True
OutputTokensDetails  reasoning_tokens     required=True

Pre-existing (they were always absent before), so not a regression — this PR half-fixes it. Suggest finishing: always emit both blocks, default cached_tokens/reasoning_tokens to 0, and project only the spec keys rather than splatting the chat-shaped dict, which currently leaks audio_tokens / accepted_prediction_tokens / rejected_prediction_tokens into the response. An isinstance(..., dict) guard is worth adding too — LiteLLM type-guards this (types/utils.py:889-893), the proxy doesn't, so whatever the upstream put in that extra reaches the HTTP body verbatim.

🟡 The Responses bridge drops the Anthropic cache counts entirely

proxy.py:1762-1775 maps only prompt_tokens_details / completion_tokens_details. For Anthropic models the cache counts live only in the top-level cache_read_input_tokens / cache_creation_input_tokens (README "Native fingerprint passthrough" table), so /v1/responses still emits no input_tokens_details for them. The PR's stated cache goal is met in provider.py only.


Test gaps

  • No test for the absent-details case — the one that breaks. Add a _to_generic_chunk case with a bare ChatUsage(prompt_tokens=…, completion_tokens=…, total_tokens=…) asserting it returns the plain three-key usage without raising. That single test makes this class of regression impossible.
  • The Responses SSE branch (proxy.py:1866-1876) ships untested. test_responses_usage.py covers _chat_payload_to_response only; test_responses_streaming_event_sequence (test_responses.py:105) feeds usage with no details and asserts event ordering alone. The description's "regression coverage for non-stream, stream, and Responses" — the "stream" there is the LiteLLM provider stream, not this branch.
  • No test has a choices-bearing chunk carrying usage, which is why the gap above went unnoticed.

Housekeeping

pyproject.toml:7 and __init__.py:43 are both still 0.9.0, already released at the top of CHANGELOG.md. #28 → 0.7.6, #29 → 0.8.0, #30 → 0.9.0 each landed their own bump; this one needs 0.9.1 + an entry unless that's deferred to ship.


✅ Verified safe — don't let a reviewer talk you out of this part

An adversarial pass flagged cache_read_input_tokens entering LiteLLM's cost math as able to drive prompt cost negative, via llm_cost_calc/utils.py:242:

text_tokens = usage.prompt_tokens - cache_hit_tokens - audio_tokens

It's a false positive, and the end-to-end run above settles it: the anthropic shape assembles to prompt=2160, cache_read=2048, cached_tokens=2048, i.e. text_tokens = 2160 - 2048 = 112, positive and correct. The gateway folds cache reads into prompt_tokensblockrun/src/lib/ai-providers.test.ts:185-191 proves prompt_tokens = 12 + 2048 + 100 = 2160 with cache_read_input_tokens=2048 surfaced as a subset, which matches LiteLLM's contract exactly (llms/anthropic/chat/transformation.py:700 does prompt_tokens += cache_read_input_tokens). The subtraction can't go negative. Forwarding cache_read_input_tokens is the PR earning its keep: the cache-read discount now applies on the estimate path where main silently paid full price.

Also checked and clean: input_tokens_details/output_tokens_details naming matches the Responses spec; extras stay plain dicts end to end, so nothing non-serializable reaches an SSE frame; the added keys survive LiteLLM's stream aggregation once the crash is gone; the post-finish provider_specific_fields contract is untouched and its canary still passes.


Verdict: request changes on the AttributeError alone. Everything else is a same-sitting follow-up while you're in the file.

…e (0.9.1)

Review fixes for the reasoning-usage passthrough:

- provider.py: prompt_tokens_details / completion_tokens_details are pydantic
  EXTRAS on ChatUsage (extra=allow), not declared fields — attribute access on
  an absent extra raises AttributeError. Any streamed call whose usage frame
  omitted them (Anthropic-shaped or bare) crashed at the final frame, surfaced
  as litellm.APIConnectionError, and retry-on-connection clients re-paid for an
  already-settled completion. Read from model_extra instead; drop the redundant
  re-adds in _native_extras (extras were already copied wholesale).

- provider.py: factor one shared _usage_dict used by BOTH _to_generic_chunk
  branches — the choice-less include_usage frame and a choices-bearing chunk
  carrying usage inline — so the two paths can never disagree about which
  detail fields survive. LiteLLM's chunk builder takes prompt_tokens_details
  unconditionally from the LAST usage-bearing chunk, so an asymmetric bare
  frame could null out detail delivered earlier.

- proxy.py: _usage_to_responses builds spec-correct ResponseUsage — both
  detail blocks REQUIRED by openai.types.responses (typed clients crash on
  their absence), zero-defaulted, spec keys only (no leaked audio_tokens /
  prediction fields), non-dict garbage coerced to 0, Anthropic top-level
  cache_read_input_tokens mapped into cached_tokens. SSE loop merges usage
  frames instead of wholesale replacing (a later bare frame no longer wipes
  detail).

- tests: absent-details regression (the crashing case), choices-bearing usage
  symmetry, Responses spec projection incl. garbage coercion, SSE detail
  passthrough + merge. 320 passed, 1 skipped; all three gateway usage shapes
  (anthropic / openai / bare) verified end-to-end through CustomStreamWrapper +
  stream_chunk_builder.

- release 0.9.1 (pyproject + __init__) with CHANGELOG entry.
@VickyXAI

VickyXAI commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Pushed b6f39a4 addressing everything in the review above:

  • P0 crash: details now read from model_extra (they're pydantic extras, not declared fields). All three gateway usage shapes (anthropic / openai / bare) verified end-to-end through CustomStreamWrapper + stream_chunk_builder — 3/3 pass, was 1/3.
  • Both _to_generic_chunk usage branches now share one _usage_dict builder — choices-bearing chunks carry the same detail as the include_usage frame.
  • Responses usage is spec-correct: input_tokens_details / output_tokens_details always emitted (REQUIRED by openai.types.responses.ResponseUsage), zero-defaulted, spec keys only, garbage-coerced; Anthropic cache_read_input_tokenscached_tokens. Strict-validated against openai 1.75.0 types, 5/5 shapes.
  • SSE loop merges usage frames instead of replacing — a later bare frame no longer wipes detail.
  • Tests: absent-details regression, choices-bearing symmetry, spec projection, SSE passthrough + merge. 320 passed, 1 skipped (was 9 failed / 302 passed).
  • 0.9.1 bump + CHANGELOG.

The cache-read forwarding from the original PR is untouched — that part was verified correct and is the cost-accuracy win here.

@VickyXAI
VickyXAI merged commit 8242fd6 into main Aug 8, 2026
@VickyXAI
VickyXAI deleted the feat/reasoning-token-usage branch August 8, 2026 01:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants