Skip to content

fix(observability): count the prompt-cache tiers as input tokens for the CLI providers - #10251

Merged
loopover-orb[bot] merged 1 commit into
mainfrom
fix/claude-code-cache-tokens-10235
Jul 31, 2026
Merged

fix(observability): count the prompt-cache tiers as input tokens for the CLI providers#10251
loopover-orb[bot] merged 1 commit into
mainfrom
fix/claude-code-cache-tokens-10235

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

Every claude-code call recorded exactly ~2 input tokens. Measured on edge-nl-01 over 48h:

provider calls input total input avg output total output avg cost
claude-code 1000 2048 2.0 1,051,346 1051.3 $225.06

An average of 2.0 across a thousand calls is not a measurement, it is a constant. The output side is healthy and the dollar cost is real, so the event was arriving and being parsed — the input figure specifically carried nothing.

Cause

Anthropic — and therefore Claude Code — splits one prompt across three counters. input_tokens carries only the portion that was neither read from nor written to the prompt cache; the rest lands in cache_read_input_tokens and cache_creation_input_tokens. INPUT_TOKEN_KEYS (src/selfhost/ai.ts) read only the first group:

const INPUT_TOKEN_KEYS = ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"] as const;

With caching active — which it is on every review the CLI runs, since the system prompt and tool definitions are stable — that degenerates to the handful of genuinely-new tokens per call. Nothing was malformed and nothing was being dropped defensively: the extractor read a real field that does not mean what its name suggests under caching.

Fix, and the trap inside it

The two cache tiers are counted as input tokens, because they are: the model processed them, and they are billed (cache reads at a reduced rate).

Each tier gets its own alias group, and the groups are SUMMED — not appended to INPUT_TOKEN_KEYS. maxNumber takes the maximum across a key list, which is correct for aliases (input_tokens and prompt_tokens are two names for one number) but wrong here, because the three are additive components of a single prompt. Folding them into one list would take the max of the three and silently under-report again, just less severely — the same failure wearing a smaller number.

Absence stays absence: an envelope reporting no input counter at all yields undefined, never a fabricated 0 (#10207's rule), while a tier that is present but zero contributes a real zero.

Blast radius

  • Providers that emit no cache keys — codex and the OpenAI-compatible bindings — are byte-identical to before. That is what makes this safe to apply at the shared extraction point rather than per-provider, and it is pinned by a test.
  • loopover_ai_input_tokens_total is corrected by the same change, since it reads the same usage.inputTokens. The Prometheus/Grafana view was wrong in the same direction.

What is deliberately NOT changed

coerceByokUsage (src/services/ai-review.ts) — the BYOK API path — already considered these exact two keys and declined them:

"intentionally not read here, since callAiProvider never sends cache_control, so Anthropic never populates them on this path."

That reasoning still holds, and reading them there would be worse than dead code: it feeds BYOK_MODEL_PRICING_USD_PER_MTOK, where a cache read bills at a different rate than fresh input, so if cache_control is ever introduced it would mis-price. The two paths naming the same keys but disagreeing on whether to read them is correct, not drift — its comment now says so explicitly, so the next reader sees an intentional divergence rather than an oversight.

Closes #10235

Scope

  • The PR title follows type(scope): short summary Conventional Commit format, for example fix(api): restore profile access checks.
  • This PR is focused and does not mix unrelated backend, UI, MCP, docs, dependency, and deploy changes.
  • This follows CONTRIBUTING.md and does not reintroduce GitHub Pages, VitePress, site/, or CNAME.
  • I linked a currently open issue this PR resolves (e.g. Closes #123) — a linked open issue is required for every contributor PR.

Validation

  • git diff --check
  • npm run actionlint
  • npm run typecheck
  • npm run test:coverage locally; codecov/patch requires ≥99% coverage of the lines AND branches you changed
  • npm run test:workers
  • npm run build:mcp
  • npm run test:mcp-pack
  • npm run ui:openapi:check
  • npm run ui:lint
  • npm run ui:typecheck
  • npm run ui:build
  • npm audit --audit-level=moderate
  • New or changed behavior has unit/integration tests for new branches, fallback paths, and sanitizer boundaries

Detail:

  • Full suite against current main: 26,623 passed, 0 failed.
  • Patch coverage verified line-by-line against lcov, every changed hunk: all changed lines and branches covered, no exceptions.
  • Mutation-tested: reverting totalInputTokens to the old maxNumber(entry, INPUT_TOKEN_KEYS) fails both new tests.
  • The regression test uses the real Claude Code result frame (input_tokens: 2 alongside cache_read_input_tokens: 41820 / cache_creation_input_tokens: 1140), reproducing the exact ~2 observed in production.
  • Boundary cases pinned separately: no cache keys at all (unchanged), a cache tier with no uncached remainder, camelCase aliases, a genuinely-reported 0 in one tier, and no input counter of any kind (stays absent).
  • Drift sweep green: selfhost:env-reference:check, docs:drift-check, coverage-boltons:check, dead-exports:check, manifest:drift-check.
  • Unchecked boxes cover surfaces this diff does not touch. npm audit reports only pre-existing advisories transitive under release-please; this PR changes no dependencies.

Safety

  • No secrets, wallet details, hotkeys, coldkeys, user PATs, private keys, raw trust scores, private rankings, or private maintainer evidence are exposed.
  • Public GitHub text stays sanitized, low-noise, and does not imply compensation guarantees or optimization tactics.
  • Auth, cookie, CORS, GitHub App, Cloudflare, or session changes include negative-path tests.
  • API/OpenAPI/MCP behavior is updated and tested where needed.
  • UI changes use live API data or real empty/error/loading states, not production mock/demo fallbacks.
  • Visible UI changes include a UI Evidence section below with JPG/JPEG or PNG screenshots arranged as organized, captioned, clickable thumbnails.
  • Public docs/changelogs are updated where needed; changelogs are only edited for release-prep PRs.

Token counts only — no prompt, diff, transcript or completion content is read or emitted by this path, and the metadata-only policy on $ai_generation is unchanged.

UI Evidence

Not applicable — no visible UI, frontend, docs, or extension change.

Notes

Historical ai_usage_events rows keep their wrong figure; this corrects the reading going forward rather than backfilling. Worth knowing when comparing across the cutover: claude-code input tokens will jump by roughly three orders of magnitude, and that jump is the fix landing, not a change in usage. Cost is unaffected — $ai_total_cost_usd was always read from the provider's own total_cost_usd and was never derived from these counters.

…the CLI providers

Every claude-code call recorded exactly ~2 input tokens. Measured on edge-nl-01 over
48h: 2048 input tokens across 1000 calls -- an average of 2.0 -- against 1051 output
tokens per call and $225.06 of real spend. An average of 2.0 over a thousand calls is
not a measurement, it is a constant.

Anthropic, and therefore Claude Code, splits one prompt across three counters:
input_tokens carries only the portion neither read from nor written to the prompt
cache, with the rest in cache_read_input_tokens and cache_creation_input_tokens.
INPUT_TOKEN_KEYS read only the first, so with caching active -- which it is on every
review the CLI runs -- the figure degenerated to the handful of genuinely-new tokens.

These are real input tokens: the model processed them and they are billed, cache reads
at a reduced rate. Each tier gets its OWN alias group because the three are additive
components of one prompt, not names for one value; folding them into INPUT_TOKEN_KEYS
would take the maximum of the three and silently under-report again, just less
severely. Absence is still absence -- an envelope reporting no input counter at all
yields undefined rather than a fabricated 0 (#10207), and a tier present but zero
contributes a real zero.

Providers that emit no cache keys -- codex and the OpenAI-compatible bindings -- are
byte-identical to before, which is what makes this safe at the shared extraction
point. loopover_ai_input_tokens_total is corrected by the same change, since it reads
the same usage.inputTokens.

coerceByokUsage (src/services/ai-review.ts) is deliberately NOT changed. It already
documented these two keys and declined them, because callAiProvider never sends
cache_control so the provider never populates them there, and it feeds
BYOK_MODEL_PRICING_USD_PER_MTOK where a cache read bills at a different rate than
fresh input. Its comment now records that the divergence from the CLI path is
intentional rather than an oversight.

Closes #10235
@JSONbored JSONbored self-assigned this Jul 31, 2026
@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@loopover-orb

loopover-orb Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Tip

✅ LoopOver review result - approve/merge recommended

Review updated: 2026-07-31 14:45:07 UTC

3 files · 1 AI reviewer · no blockers · readiness 98/100 · CI green · clean

✅ Suggested Action - Approve/Merge

  • safe to merge

Review summary
This fixes a real bug: `mergeUsage` in src/selfhost/ai.ts previously read only `input_tokens` via `maxNumber(entry, INPUT_TOKEN_KEYS)`, missing the two prompt-cache tiers that Claude Code reports separately, causing a ~3-order-of-magnitude undercount of input tokens for cached calls. The new `totalInputTokens` correctly sums the three additive tiers (uncached + cache-read + cache-creation) rather than max'ing them as aliases, preserves the 'absent means undefined, not fabricated 0' contract by checking `every(tier === undefined)`, and is scoped only to `mergeUsage` (the CLI-subprocess path), deliberately leaving `coerceByokUsage` in ai-review.ts untouched with a documented rationale. Tests cover the real Claude Code envelope shape, camelCase aliases, per-tier absence/zero handling, and no-cache-key providers remaining unaffected.

Nits — 4 non-blocking
  • The magic numbers 2048/1051/3 in the test comment and doc comment (src/selfhost/ai.ts:806-809) are just illustrative measured values, not literals used in logic, so they don't need named constants despite the automated flag.
  • src/selfhost/ai.ts and src/services/ai-review.ts are both large files (per the size-smell note) but this diff only adds small, well-scoped functions/comments to each, so the file-size flag isn't actionable here.
  • Consider a brief top-level comment cross-referencing `totalInputTokens` from `coerceByokUsage`'s doc block (already partially done) so a future reader lands on both halves of the divergence from either file.
  • PR author also opened the linked issue — Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.

Decision drivers

  • ✅ Code review — No blockers (1 reviewer)
  • ✅ Gate result — Passing (No configured blocker found.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ✅ Linked #10235
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (1 linked issue).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 9 registered-repo PR(s), 8 merged, 299 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 9 PR(s), 299 issue(s).
Improvement ✅ Minor risk: clean · value: minor · LLM: significant
Linked issue satisfaction

Partially addressed
The diff correctly adds cache_read/cache_creation keys as summed additive components in mergeUsage and adds regression tests pinning the summed total and the unaffected uncached-only path, addressing the core deliverable and the maxNumber trap called out in the issue; however, it does not verify loopover_ai_input_tokens_total against the same fixture or make an explicit decision on separately repo

Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository is configured for direct PR review.
  • Public profile languages: Python, TypeScript, Ruby, Go, MDX, Shell, Solidity, JavaScript
  • Official Gittensor activity: 9 PR(s), 299 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Start here: Treat this as maintainer-lane context rather than normal contributor-lane activity.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.
🧪 Chat with LoopOver

Ask LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.

  • @loopover ask <question> answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat <question> answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).
  • A plain-language @loopover mention with a real question is routed to the closest matching read-only command automatically — no exact syntax required.

Full command reference: https://loopover.ai/docs/loopover-commands

🧪 Experimental — new and may change.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.

  • Re-run LoopOver review

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.39%. Comparing base (ee1b86e) to head (0aa0f8e).
⚠️ Report is 10 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #10251      +/-   ##
==========================================
- Coverage   92.25%   91.39%   -0.87%     
==========================================
  Files         938      938              
  Lines      114702   114707       +5     
  Branches    27698    27698              
==========================================
- Hits       105821   104832     -989     
- Misses       7575     8764    +1189     
+ Partials     1306     1111     -195     
Flag Coverage Δ
backend 94.14% <100.00%> (-1.55%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/selfhost/ai.ts 98.46% <100.00%> (+0.01%) ⬆️
src/services/ai-review.ts 96.69% <ø> (ø)

... and 3 files with indirect coverage changes

@loopover-orb loopover-orb Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LoopOver approves — the gate is satisfied and CI is green.

@loopover-orb
loopover-orb Bot merged commit fed13d4 into main Jul 31, 2026
10 checks passed
@loopover-orb
loopover-orb Bot deleted the fix/claude-code-cache-tokens-10235 branch July 31, 2026 14:45
JSONbored added a commit that referenced this pull request Jul 31, 2026
…e's own parsers

#10251 fixed this on the ORB side. Both of the engine's deliberately-parallel
copies were left reading only `input_tokens`, so the miner still records a
near-constant handful for every cached claude attempt.

Anthropic, and therefore Claude Code and the Agent SDK, splits one prompt across
three counters: input_tokens carries only the portion neither read from nor
written to the prompt cache, with the rest in cache_read_input_tokens and
cache_creation_input_tokens. With caching active -- which it is for every attempt
the CLI runs -- essentially the whole prompt lands in the latter two.

Sum the three, matching the ORB's totalInputTokens exactly, so the two parsers
cannot report different numbers for the same envelope. Each tier keeps its own
alias group because the three are additive components of one prompt, not names
for one value; folding them into INPUT_TOKEN_KEYS would take the maximum and
under-report again, just less severely.

Absence stays absence: an envelope with no input counter at all yields undefined
rather than a fabricated 0, and a tier present but zero contributes a real zero.
Providers emitting no cache keys -- codex and the OpenAI-compatible bindings --
are byte-identical to before, which is what makes the shared extraction point
safe to change.

Closes #10246
loopover-orb Bot pushed a commit that referenced this pull request Jul 31, 2026
…e's own parsers (#10252)

* fix(miner): count the prompt-cache tiers as input tokens in the engine's own parsers

#10251 fixed this on the ORB side. Both of the engine's deliberately-parallel
copies were left reading only `input_tokens`, so the miner still records a
near-constant handful for every cached claude attempt.

Anthropic, and therefore Claude Code and the Agent SDK, splits one prompt across
three counters: input_tokens carries only the portion neither read from nor
written to the prompt cache, with the rest in cache_read_input_tokens and
cache_creation_input_tokens. With caching active -- which it is for every attempt
the CLI runs -- essentially the whole prompt lands in the latter two.

Sum the three, matching the ORB's totalInputTokens exactly, so the two parsers
cannot report different numbers for the same envelope. Each tier keeps its own
alias group because the three are additive components of one prompt, not names
for one value; folding them into INPUT_TOKEN_KEYS would take the maximum and
under-report again, just less severely.

Absence stays absence: an envelope with no input counter at all yields undefined
rather than a fabricated 0, and a tier present but zero contributes a real zero.
Providers emitting no cache keys -- codex and the OpenAI-compatible bindings --
are byte-identical to before, which is what makes the shared extraction point
safe to change.

Closes #10246

* test(engine): cover the cli driver's input-tier sum in the engine's own suite

codecov/patch failed at 83.78% because totalInputTokens' body and its
mergeCliUsage call site had zero hits in the engine flag's c8 report: the
driver's behavior tests live entirely in the root vitest copy, which the
engine flag never sees. Port the tier-sum scenarios into
packages/loopover-engine/test/, the suite that actually carries this file's
coverage. Verified locally: every changed line in both drivers now has hits
in the engine lcov.

---------

Co-authored-by: JSONbored <aetherealdev@gmail.com>
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.

ai(observability): every claude-code call records exactly 2 input tokens — the cache_read/cache_creation keys are never counted

1 participant