Skip to content

Account for additive cache counts in the total_tokens guard - #23

Open
anassg-lago wants to merge 1 commit into
mainfrom
fix/total-tokens-additive-cache
Open

Account for additive cache counts in the total_tokens guard#23
anassg-lago wants to merge 1 commit into
mainfrom
fix/total-tokens-additive-cache

Conversation

@anassg-lago

@anassg-lago anassg-lago commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

What this fixes

An OpenAI-compatible wire says nothing about the token convention behind it. The total_tokens consistency guard assumed it did, and folds any positive total_tokens - (input + output + reasoning) remainder into output.

That premise held for all three surfaces this adapter served when it was written — native OpenAI (zero deltas across every capture), Databricks (112/112 rows with total == input + output), Cloudflare (cache outside input, but outside total_tokens too, so it never inflated the delta).

Snowflake Cortex breaks it. Cortex answers on an OpenAI wire — /api/v2/cortex/v1/chat/completions, byte-for-byte the OpenAI payload shape — with Anthropic's additive convention. Measured live against the real endpoint, 2026-08-25:

prompt_tokens                        7
prompt_tokens_details.cached_tokens  4805
completion_tokens                    6
total_tokens                         4818   = 7 + 4805 + 6

The cached block sits outside prompt_tokens and inside total_tokens, so those 4,805 tokens looked unaccounted and were folded into output. Run through the real adapter:

reported before actual
input 7 7
output 4811 6
cache_read 4805 4805

The same 4,805 tokens billed twice, the second time at the output rate. On the first capture that was 17,503 tokens for 8,758 consumed — 2.0x on the call, ~800x on the output line. This is live on main today for anyone pointing the OpenAI client at Cortex, and it is not Snowflake-specific: any OpenAI-compatible proxy with additive caching hits it.

What changed

extract_openai_native now subtracts cache_read and the raw prompt_tokens_details.cache_write_tokens alongside reasoning.

  • cache_write_tokens is accounted for without being mapped. It is deliberately absent from CanonicalUsage.cache_write — for OpenAI it sits inside prompt_tokens and billing it separately over-charges 2.24x — so it has no canonical route into the arithmetic and is read straight off the payload. Without that, an additive cache write inflates output the same way the read did.
  • It cannot suppress a genuine fold. On a subtractive surface the cache counts are already inside input, so removing them again only drives the delta further negative, where the > 0 guard already no-ops. Re-verified across every captured OpenAI, Databricks and Cloudflare fixture — all still 0.
  • Deliberately not gated on the details objects being empty, which was the other candidate fix: that suppresses genuine folds on any proxy reporting a details block alongside unreported tokens, reintroducing the silent under-bill the guard exists for.

Tests

Three new assertions, each verified to fail on revert:

  1. the Cortex cache fixture asserting output == 6, not 4811
  2. the additive cache_write payload from the review thread below, asserting output == 4
  3. a payload with both an additive cache block and hidden thinking tokens — the two corrections must not cancel, so the 75 genuine thinking tokens still fold

Fixtures are captured from the live endpoint by a new capture_snowflake_cortex.py, byte-identical to the JS port's copies (sha256 verified). They sit under openai_native/ because that is the adapter Cortex goes through. The script documents the two Cortex quirks that make them un-hand-writable: caching needs an explicit cache_control part, and max_tokens is rejected for max_completion_tokens.

Green locally: ruff check, ruff format --check, mypy src (strict), 606 unit tests, coverage 91.9% against the 80% floor.

Provenance

@ancorcruz raised this class of hazard on #14 (2026-08-17), with the payload shape almost exactly right — he named cache_write_tokens; Cortex does it with cached_tokens. It was answered with a three-surface census returning unaccounted = 0 everywhere and closed as unreachable. That census was correct — it expired when a surface with additive caching arrived. His suggested gate would have caught this too; I went the other way for the false-negative reason above. The census is now pinned by a live payload instead of an argument.

Rebase notes

Shared symbols touched, for the connector branches rebasing after this:

  • src/lago_agent_sdk/adapters/openai_native.py — the declared_total reconciliation block only. No signature change; extract_openai_native(response, model_id=…, provider_hint=…) is untouched.
  • No change to CanonicalUsage, _MAPPED_DETAIL_FIELDS, the drift contract, pricing.py, or any wrapper.
  • New files only under tests/unit/adapters/fixtures/ — no renumbering of existing fixtures.

Twin PR: getlago/lago-agent-sdk-js#38

An OpenAI-compatible wire says nothing about the token CONVENTION behind it, and
the consistency guard assumed it did.

The guard folds any positive `total_tokens - (input + output + reasoning)`
remainder into `output`, on the premise that cache counts always sit INSIDE
`prompt_tokens` and so can never appear in that remainder. That held for all
three surfaces this adapter served when it was written: native OpenAI (zero
deltas across every capture), Databricks (112 of 112 rows with
total == input + output), and Cloudflare (cache outside `input`, but outside
`total_tokens` too, so it never inflated the delta).

Snowflake Cortex breaks it. Cortex answers on an OpenAI wire —
/api/v2/cortex/v1/chat/completions, byte-for-byte the OpenAI payload shape —
with Anthropic's ADDITIVE convention. Measured live 2026-08-25:

    prompt_tokens                       7
    prompt_tokens_details.cached_tokens 4805
    completion_tokens                   6
    total_tokens                        4818   = 7 + 4805 + 6

The cached block sits outside `prompt_tokens` and inside `total_tokens`, so
4,805 tokens looked unaccounted and were added to `output`: 4,811 reported for a
call that generated 6, while the same 4,805 also shipped as cache_read. On the
first capture that was 17,503 tokens billed for 8,758 consumed (2.0x), with the
output line inflated ~800x. Not Snowflake-specific — any OpenAI-compatible proxy
with additive caching hits it.

The reconciliation now subtracts cache_read and the raw
prompt_tokens_details.cache_write_tokens as well.

cache_write_tokens is read straight off the payload rather than through a
canonical field because it is deliberately NOT mapped to
CanonicalUsage.cache_write — for OpenAI it sits inside prompt_tokens and billing
it separately over-charges 2.24x — so it has no other route into the arithmetic,
and an additive cache WRITE would inflate `output` exactly the way the read did.

Subtracting cannot suppress a genuine fold: on a subtractive surface those counts
are already inside `input`, so removing them again only drives the delta further
negative, where the `> 0` guard already no-ops. Re-verified across every captured
OpenAI, Databricks and Cloudflare fixture — all still 0 — and a payload carrying
both an additive cache block and hidden thinking tokens still folds the thinking
remainder alone.

Deliberately NOT gated on the details objects being empty, which was the other
candidate fix: that suppresses genuine folds on any proxy that reports a details
block alongside unreported tokens, reintroducing the silent under-bill the guard
exists for.

This hazard was raised in review on #14 (2026-08-17) with the exact payload
shape, and answered with a three-surface census returning unaccounted = 0
everywhere. That census was correct; it expired when a surface with additive
caching arrived. It is now pinned by live fixtures rather than by an argument:
11_snowflake_cortex_plain_chat.json and 12_snowflake_cortex_cache_chat.json,
captured by capture_snowflake_cortex.py and byte-identical to the JS port's
copies. Reverting the subtraction fails three tests in each repo.
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.

1 participant