core: deduct cache reads on every channel whose provider counts them in input - #52
Conversation
…in input OpenAI, Google, DeepSeek and xAI all report cached tokens inside the input token count they return, so a price row on those channels has to deduct them before applying the input rate. The rule was left to each row to state, and the bundled catalog states it on openai-api, deepseek-api and xai-api but not on the thirteen google-api and google-vertex-ai rows, every one of which carries a cache-read rate. Gemini cached input was therefore billed at the input rate and again at the cache-read rate: 0.33 instead of 0.03 for a million cached tokens on 3.5 Flash Lite, an 11x overcharge that grows with cache hit rate. The channel list now sits beside direct_channel_for_provider, the other provider fact this package owns, and resolve_price_rules supplies the rule at load. That covers the bundled catalog and any operator catalog loaded through MG_PRICES_PATH, neither of which can be expected to know which providers do this. A row that states the rule either way is left alone, so a gateway row serving one of these providers keeps its own answer. Anthropic and Bedrock report cache reads outside the input count and are absent by design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
This subsumes #51 (same defect, Google rows only), so I am closing that one in favour of this. The channel-level default is the better shape: a new row cannot forget the rule. One row it gets wrong, though, and it is the reason #51 changed rows rather than channels.
At 1M input and 1M cache reads on that row: $5.50 + $0.55 = $6.05 correct, versus $0.55 after the deduction. That is the mirror of the bug this PR fixes, on the one channel that is not single-publisher. Two ways to close it:
I would take the first: the second relies on every future row remembering, which is the failure mode this PR exists to remove. Also worth knowing: this bumps to 0.2.24, and open PR #50 bumps to 0.2.24 too. Whichever lands second needs 0.2.25. #50 is a separate defect (the long-context tier is judged on the count passed for billing, so a cached prompt over the threshold misses the premium), and the two do not otherwise overlap. |
resolve_price_rules synthesized a rules mapping at load so that one `rules.get` in _price_tokens would read it, which left a loaded Price carrying a rule its YAML row never stated. There is one reader, so the channel answers there directly: counts_cache_read_in_input takes the row's stated rule when it has one and the channel's otherwise, and the loader goes back to reading the file as written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Simplified per review: no rules abstraction.
def counts_cache_read_in_input(channel, rules) -> bool:
stated = rules.get("input_includes_cache_read")
if stated is not None:
return bool(stated)
return channel in _INPUT_INCLUDES_CACHE_READ_CHANNELS
Same pricing outcome, one less moving part, and |
A channel does not decide how a call reports cached tokens; whoever serves the model does. google-vertex-ai serves Anthropic's models beside Google's, and anthropic/claude-opus-4.6 has a Vertex row with a 0.55 cache-read rate and no rules of its own. Defaulting on the channel deducted cache reads from a usage shape that already excludes them: at 1M input including 1M cache reads that row priced at 0.55 instead of 6.05, the mirror of the Gemini defect this change exists to remove, on the one mixed-publisher channel in the catalog. The default is now a set of (publisher, channel) pairs, Price carries the model's publisher, and the loader fills it from the entry the aliases already read it from. An unknown publisher deducts nothing, so a row we cannot place keeps the arithmetic it had. EXPECTED_VERSION in the package artifact check moves to 0.2.24 with core/pyproject.toml; it was left behind and failed the release gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while triaging Finding 5 of the v0.2.13 trial, which was about an operator's own price row. The same gap turned out to be in our bundled catalog.
The defect
OpenAI (
prompt_tokens), Google (promptTokenCount), DeepSeek and xAI all report cached tokens inside the input token count they return. A price row on those channels has to carryinput_includes_cache_read, or_price_tokensbills the full input count at the input rate and then adds the cache-read rate on top.The rule was left to each row to state. The bundled catalog states it on
openai-api,deepseek-apiandxai-api— and on none of the thirteengoogle-api/google-vertex-airows, every one of which carries a cache-read rate:An 11x overcharge that grows with cache hit rate, on every path that prices through core: the pipeline's analysis reports and the OSS server. Ingest, which prices from the internal DB catalog, gets the same Gemini models right — so today the same call costs two different amounts depending on which pricer looked at it.
One existing test asserted the wrong behaviour in its own docstring ("Google direct bills cache reads on top of full input"), and its sibling noticed the Vercel gateway needed the rule without asking why the direct channel did not.
Change
_INPUT_INCLUDES_CACHE_READ_CHANNELSsits beside_DIRECT_CHANNEL_BY_PROVIDER— the other provider fact this package already owns — andresolve_price_rules(channel, rules)supplies the rule in the loader.Applying it at load rather than fixing thirteen YAML rows is deliberate: it also covers any operator catalog loaded through
MG_PRICES_PATH, which cannot be expected to know which providers do this. A row that states the rule either way is left alone, so a gateway row serving one of these providers keeps its own answer. Anthropic and Bedrock report cache reads outside the input count and are absent by design.resolve_price_rulesis exported so metergraph-internal can apply the same list to its Postgres catalog instead of keeping a second copy.Version
Bumped to 0.2.24. The pipeline and internal pin bumps follow once this is published.
Tests
false, andanthropic-api/aws-bedrock/vercel-ai-gateway, are left alone.pytest core/tests: 238 passed, 1 failed —test_core_package_artifacts_are_publishable, which dies inensurepipbuilding a temp venv. It fails the same way on a cleanmaincheckout here, so it is environmental; it needs a CI run to confirm.🤖 Generated with Claude Code