fix(token-spy): stop billing OpenAI cached tokens twice - #2519
Open
Hoang130203 wants to merge 1 commit into
Open
fix(token-spy): stop billing OpenAI cached tokens twice#2519Hoang130203 wants to merge 1 commit into
Hoang130203 wants to merge 1 commit into
Conversation
calculate_cost() bills every *_tokens field independently:
input * input_rate + output * output_rate
+ cache_read * cache_read_rate + cache_write * cache_write_rate
so the fields a provider reports have to be disjoint. Anthropic's API
returns them that way — input_tokens excludes cache_read_input_tokens —
and the Anthropic provider passes them straight through.
OpenAI's prompt_tokens is a total that INCLUDES
prompt_tokens_details.cached_tokens, and the provider reported it
verbatim. Every cached token was therefore charged twice: once at the
full input rate, once again at the cache-read rate.
On gpt-4o with 1M prompt tokens at a 90% cache hit rate:
reported $3.6250
correct $1.3750 (100k uncached @ 2.50, 900k cached @ 1.25)
overstated 2.64x
Prompt caching is most valuable exactly when the hit rate is high, so the
error grows with how well caching is working — a user who tunes their
prompts for cache reuse sees their reported spend go up.
Subtract the cached count from the total in both the response and stream
paths so the two fields no longer overlap. The count is clamped to
0..total, so a malformed or impossible cached value cannot produce a
negative input count or a negative bill.
Adds tests/test_cached_token_accounting.py (13 assertions): the split, a
hand-computed bill, the streaming path, the no-cache and null-details
paths unchanged, impossible counts, and that both providers now report
the same disjoint numbers for the same conversation. Runs in the
token-spy CI job.
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.
Summary
LLMProvider.calculate_cost()bills every*_tokensfield independently:so the fields a provider reports must be disjoint. Anthropic's API returns
them that way —
input_tokensexcludescache_read_input_tokens— and theAnthropic provider passes them straight through.
OpenAI's
prompt_tokensis a total that includesprompt_tokens_details.cached_tokens, and the provider reported it verbatim:Every cached token was therefore charged twice — once at the full input rate,
once again at the cache-read rate.
Measured on
gpt-4o, 1M prompt tokens at a 90% cache hit rate:The same codebase getting it right for one provider and wrong for the other is
what convinced me this is a bug rather than a convention I had misread.
The error grows with how well caching is working. Prompt caching pays off
precisely when the hit rate is high, so a user who tunes their prompts for cache
reuse watches their reported spend increase. For a service whose whole purpose
is telling you what your agents cost, that is the wrong direction to be wrong
in — and it is wrong on the cheap path, not the expensive one.
Fix
A small
_split_cached()helper subtracts the cached count from the total, inboth the response and stream paths, so the two fields stop overlapping. The
count is clamped to
0..total, so an impossible or non-numericcached_tokenscannot produce a negative input count or a negative bill.
Test
Adds
tests/test_cached_token_accounting.py— 13 assertions: the split, ahand-computed bill, the streaming path, the no-cache and
null-details pathsasserted unchanged, impossible cached counts (
1500of1000,-5,"many",None), a missingusageblock, and a cross-provider check thatOpenAI and Anthropic now report the same disjoint numbers for the same
conversation. Wired into the token-spy CI job.
AI Assistance
AI assisted with the clamping helper, the test matrix, and wording this
description. I found it by reading
calculate_costand asking what eachprovider's
input_tokensactually contains, then computing the bill both waysagainst the shipped code.
Release Lane
release/2.6.xmainStable hotfix reason:
Changed Surface
(
token-spyis a bundled extension service. One provider module and its newtests; CI config is also touched.)
Risk And Validation
git diff --checkrelease/2.6.xCommands/results:
The four that pass either way are the no-cache paths, which is the point — this
must not disturb a response without cache details.
Operational Change Check
extract_usage_from_response/extract_usage_from_streamrun inside thetoken-spycontainer when recording a completion. The change alters therecorded
input_tokensfor OpenAI-compatible responses that carry cachedetails, and therefore the computed cost — downward, to the correct figure.
Responses without
prompt_tokens_detailsare byte-identical to before.Two things worth knowing:
Rows already stored keep their old numbers. This fixes new events; it is
not a backfill. Historical OpenAI-with-cache costs remain overstated.
Anyone reconstructing a prompt total from stored rows must now add
input_tokens + cache_read_tokens. That was already true for Anthropicrows, so this makes the two consistent rather than introducing a new rule —
but if a dashboard query assumes
input_tokensis the total, it will nowunder-report for OpenAI. I grepped the token-spy dashboard and report code and
did not find such a query; worth a second look from someone who knows those
views better than I do.
This is not an operational change.
This is an operational change and validation is recorded above.
This is an operational change and validation is intentionally deferred for:
Notes For Reviewers
cache_writestays 0 for OpenAI — the API does not expose a cache-writecount, and the table prices it at 0.0 for every OpenAI row, so nothing changes
there.
DeepSeek and Moonshot go through this same provider and both report
OpenAI-shaped usage with cache fields. DeepSeek's
prompt_cache_hit_tokens/prompt_cache_miss_tokensare a different pair of keys that this code doesnot read at all, so DeepSeek caching is currently invisible rather than
double-counted. That is a separate gap and I did not fix it here — say the word
and I will send it, since the pricing rows for
deepseek-chatanddeepseek-reasoneralready carry cache_read rates that nothing can currentlyreach.
Related: #2511 fixes the Claude 3.x rows in the Anthropic pricing table.
Different file, no overlap.