fix(openai): record cached and reasoning tokens on the LLM metrics counters#2758
Merged
benfrank241 merged 1 commit intoJul 17, 2026
Conversation
…unters The OpenAI-compatible provider extracts cached_tokens and thoughts_tokens on both call paths and hands them to TokenUsage, but never passes them to metrics.record_llm_call, which accepts and buckets both. Two separate effects: - Reasoning tokens reach no counter at all. vectorize-io#2378 made output_tokens visible-only by subtracting thoughts_tokens directly above the record_llm_call, so the reasoning half of the billed output was removed from the metrics path rather than moved onto llm_tokens_thoughts. Before vectorize-io#2378 those tokens were still counted inside output_tokens. - cached_input_tokens has read 0 for every OpenAI-compatible provider since the counter was added; only gemini_llm passes it. Pass both kwargs at the two call sites that parse a usage object. The fallback path (no usage) and the Ollama native path (no reasoning or cached fields) are unchanged. Invariant: recorded output_tokens + recorded thoughts_tokens equals the provider's completion_tokens, so every billed token lands on exactly one counter. The new tests assert on the collector itself; the existing ones patch it without asserting, which is why this went unnoticed.
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.
Root cause
OpenAICompatibleLLMextractscached_tokensandthoughts_tokensfrom the provider'susage object on both call paths and passes them to
TokenUsage, but not tometrics.record_llm_call. That collector already accepts both kwargs and keeps abucketed counter for each (
llm_tokens_cached_input,llm_tokens_thoughts), so thevalues are extracted, used for the return contract, and then dropped on the metrics path.
Two distinct effects, which are worth keeping separate:
output_tokensvisible-only by subtracting
thoughts_tokens, and that subtraction sits directly aboverecord_llm_call. So reasoning was removed from the metrics path rather than moved ontollm_tokens_thoughts. Before fix(openai): propagate reasoning_tokens into TokenUsage for OpenAI-compatible providers #2378 those tokens were at least still counted insideoutput_tokens.cached_input_tokenshas always read 0 here. It has never been passed by thisprovider since the counter landed in feat(llm): provider prompt-prefix caching — retain + consolidation + reflect (bank-agnostic, default-on) #1936;
gemini_llmis the only provider thatpasses it. This half is a gap, not a regression.
The rationale for the counter is already written next to it in
metrics.py:That comment introduces them as the Gemini 2.5+ family, which is where they came from, but
the counter name and the
MetricsCollectorBase.record_llm_callsignature are bothprovider-neutral, and #2378 established that OpenAI-compatible reasoning models report the
same counts. An o-series or deepseek-r1 workload is exactly the one that reads as cheap
today.
Fix
Pass
cached_input_tokensandthoughts_tokensat the two call sites that parse a usageobject (
call()andcall_with_tools()). Four added kwargs, no logic or signature change.The other two
record_llm_callsites are deliberately untouched: thetool_use_failedfallback records
input_tokens=0, output_tokens=0(it has no usage object), and the Ollamanative path reads
prompt_eval_count/eval_count, which carry no reasoning or cachedcounts.
Invariant: recorded
output_tokens+ recordedthoughts_tokensequals the provider'scompletion_tokens, so every billed output token lands on exactly one counter, never zeroand never two.
How I verified
Clean
python:3.12-slimcontainer, package installed into real site-packages (not asource-dir import), against current
main(9676fc1).Usage shape:
prompt=2000 (cached=1024), completion=83 (reasoning=64).record_llm_callreceivesmainoutput_tokens=19, nothoughts_tokens, nocached_input_tokensoutput_tokens=19, thoughts_tokens=64, cached_input_tokens=1024On
mainthat is 19 of 83 billed output tokens and 0 of 1024 cached; on the branch,83 of 83 and 1024 of 1024.
For the regression half I installed #2378's parent (701de32) in the same container and ran
the same probe:
record_llm_call(output_tokens=83), so all billed output was visible tothe counters before that change.
Tests: three added to
tests/test_token_usage_cached_thoughts.py, asserting on aMagicMock(spec=MetricsCollector). All three fail onmain(KeyError: 'thoughts_tokens')and pass on the branch; the full file is 14/14 green. The existing provider tests patch
get_metrics_collectorwithout asserting on it, which is why this survived them.ruff checkandruff format --check(0.14.9, perscripts/hooks/lint.sh) are clean onboth changed files, and
ty check hindsight_apireports the same 181 diagnostics aspristine
main.What I did not verify. No live provider call: the usage objects are
SimpleNamespacemocks, and thecompletion=83, reasoning=64pair is quoted from #2378'sown commit message rather than measured by me. My claim is only that HEAD drops these
fields given that usage shape. Verification also stops at the provider-to-collector
boundary: I assert the kwargs the provider passes, and read
record_llm_callto confirmeach lands on a counter. I did not scrape a live Prometheus endpoint.
Scope
Kept to the OpenAI-compatible provider, following #2378's precedent of scoping to one
provider.
anthropic_llm.pyhas the samecached_inputgap (it extractscached_tokensat line 320, passes it to the span recorder, and omits it from
record_llm_callat line324), and
litellm_llm.pyhas the same shape. #2378 explicitly left anthropic as anoptional follow-up. Happy to do those as a separate PR if you want them, but a
multi-provider diff seemed more conflict-prone and harder to review than it is worth.
Also out of scope: the OTel span recorder and the DB trace recorder accept
cached_tokensbut have no
thoughts_tokensparameter, so reasoning is invisible there too. That needs anew parameter and a GenAI attribute, so it is a bigger change than this one.
AI assistance disclosure: this change was written with AI assistance. I ran the
reproduction, the differential against #2378's parent, and the tests myself, and I can
explain every line.