Skip to content

core: deduct cache reads on every channel whose provider counts them in input - #52

Merged
VasiliyRad merged 3 commits into
mainfrom
codex/cache-read-channels
Sep 18, 2026
Merged

VasiliyRad merged 3 commits into
mainfrom
codex/cache-read-channels

Conversation

@VasiliyRad

Copy link
Copy Markdown
Collaborator

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 carry input_includes_cache_read, or _price_tokens bills 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-api and xai-api — and on none of the thirteen google-api / google-vertex-ai rows, every one of which carries a cache-read rate:

gemini-3.5-flash-lite, 1M input, all cached
  before:  $0.33     (1M x $0.30 input  +  1M x $0.03 cache read)
  after:   $0.03

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_CHANNELS sits beside _DIRECT_CHANNEL_BY_PROVIDER — the other provider fact this package already owns — and resolve_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_rules is 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

  • Five channels get the rule from a row that omits it; an explicit false, and anthropic-api / aws-bedrock / vercel-ai-gateway, are left alone.
  • The Gemini public-pricing expectations now assert the corrected amounts, with the docstrings that stated the old belief rewritten.

pytest core/tests: 238 passed, 1 failed — test_core_package_artifacts_are_publishable, which dies in ensurepip building a temp venv. It fails the same way on a clean main checkout here, so it is environmental; it needs a CI run to confirm.

🤖 Generated with Claude Code

…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>
@sam-bretz

Copy link
Copy Markdown
Collaborator

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.

google-vertex-ai carries models Google did not publish. anthropic/claude-opus-4.6 has a google-vertex-ai price row (core/src/metergraph_core/data/prices.yaml:534) with cache_read_per_mtok: 0.55 and no stated rules. Claude on Vertex answers with Anthropic's Messages shape, where usage.input_tokens already excludes cache_read_input_tokens. resolve_price_rules("google-vertex-ai", None) now returns input_includes_cache_read: True for it, so those cache reads are deducted from an input count that never contained them.

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:

  • key the default on the model's publisher as well as the channel, so google-vertex-ai only defaults for Google-published models (what core: count Google cache reads inside the reported input #51 did, row by row); or
  • state input_includes_cache_read: false explicitly on the Anthropic-on-Vertex rows, which resolve_price_rules already honours, and add a test so a future Claude-on-Vertex row cannot inherit the default silently.

I would take the first: the second relies on every future row remembering, which is the failure mode this PR exists to remove. aws-bedrock is absent from the set, so Bedrock is already safe.

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>
@VasiliyRad

Copy link
Copy Markdown
Collaborator Author

Simplified per review: no rules abstraction.

resolve_price_rules synthesized a rules mapping at load so that one rules.get further down in _price_tokens would read it — which also left a loaded Price carrying a rule its YAML row never stated. There is exactly one reader in this package (and none in the pipeline), so the channel answers at that reader:

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

_price_tokens already has the Price, so it passes price.pricing_channel. The loader change is reverted: the catalog is read exactly as written.

Same pricing outcome, one less moving part, and rules keeps meaning "what the row says". Core suite: 240 passed (release gate still needs CI).

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>
@VasiliyRad
VasiliyRad merged commit 6ee5c76 into main Sep 18, 2026
4 checks passed
@VasiliyRad
VasiliyRad deleted the codex/cache-read-channels branch September 18, 2026 03:35
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