Skip to content

fix(usage): capture Anthropic cache tokens + emit total_input_tokens (#431) - #432

Merged
initializ-mk merged 3 commits into
mainfrom
fix/anthropic-cache-token-undercount
Sep 1, 2026
Merged

fix(usage): capture Anthropic cache tokens + emit total_input_tokens (#431)#432
initializ-mk merged 3 commits into
mainfrom
fix/anthropic-cache-token-undercount

Conversation

@initializ-mk

@initializ-mk initializ-mk commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #431.

Problem

Forge sets cache_control: ephemeral breakpoints on the Anthropic tools+system prefix, so prompt caching is active by default. Under caching, Anthropic's usage.input_tokens is only the uncached delta — the bulk of the prompt is billed as cache_read_input_tokens (hit) and cache_creation_input_tokens (write). The response parser read only input_tokens/output_tokens, so llm_call events and the run aggregate undercounted real input by orders of magnitude on cache-heavy runs.

Parallel to the SDK fix in initializ-sdk#10.

Fix

  1. Parse cache_read_input_tokens / cache_creation_input_tokens in anthropicResponse.Usage; fold them into UsageInfo.TotalTokens (now input + cache_read + cache_creation + output, matching OpenAI's inclusive prompt_tokens).
  2. Thread the cache counts through llm.UsageInfo and runtime LLMUsage, each with a TotalInputTokens() helper.
  3. Emit on llm_call: cache_read_input_tokens + cache_creation_input_tokens (omitempty) and a summed total_input_tokens (always present). Same field names as initializ-sdk#10, so security-next#36's total_input_tokens ?? input_tokens fallback picks it up unchanged.
  4. Aggregate total_input_tokens in usage_accumulator; invocation_complete now carries total_input_tokens_total (+ cache breakdown totals when caching was active).
  5. tokens_unavailable now keys off total input, so a fully-cached turn (input_tokens 0, cache_read > 0) is no longer misflagged as a free call.

OpenAI unaffected — its prompt_tokens already includes cached input; the cache fields stay absent.

Orchestrator header

The X-Forge-Tokens-In response header now bills from TotalInputTokens (delta + cache read + cache creation), not the uncached delta — so a cost ceiling-check against the header can't be fooled by a cache-heavy stage. A max(total, input) guard keeps it from ever reporting below the uncached delta if a snapshot lacks the total (mirrors the total_input_tokens ?? input_tokens fallback). Added in a follow-up commit on this branch.

Scope boundary

Streaming (readAnthropicStream) does not emit input tokens at all today; that pre-existing gap is out of scope for this fix.

Tests

  • providers: cache extraction + TotalTokens/TotalInputTokens math.
  • runtime/audit: breakdown + summed total emitted with exact wire names; total_input_tokens present + cache fields omitted when no caching; cache-read-only turn not flagged unavailable.
  • runtime/usage_accumulator: true-input aggregation across a cold+warm pair; cache-read-only run not latched unavailable.

gofmt + golangci-lint clean (0 issues), all affected suites pass.

Acceptance

  • A cache-heavy run's summed per-llm_call total_input_tokens matches real input (incl. cache read/creation).
  • llm_call carries cache_read_input_tokens + cache_creation_input_tokens + total_input_tokens.
  • OpenAI behavior unchanged.

https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ

…431)

Under prompt caching (Forge sets cache_control breakpoints on the
tools+system prefix), Anthropic's usage.input_tokens is only the
UNCACHED delta — the cached prefix is billed as cache_read_input_tokens
and cache_creation_input_tokens. The response parser dropped both, so
llm_call events (and the run aggregate) undercounted real input by
orders of magnitude on cache-heavy runs.

- Parse cache_read/creation in anthropicResponse.Usage; fold them into
  UsageInfo.TotalTokens (now input+cache_read+cache_creation+output,
  matching OpenAI's inclusive prompt_tokens semantics).
- Thread cache counts through llm.UsageInfo and runtime LLMUsage; add
  TotalInputTokens() helper on both.
- llm_call now carries cache_read_input_tokens + cache_creation_input_tokens
  (omitempty) and a summed total_input_tokens (always present) — same
  field names as initializ-sdk#10, so security-next#36's
  `total_input_tokens ?? input_tokens` fallback consumes it unchanged.
- usage_accumulator sums total_input_tokens (+ cache breakdown) for the
  run aggregate; invocation_complete emits total_input_tokens_total.
- tokens_unavailable now keys off total input, so a fully-cached turn
  (input_tokens 0, cache_read > 0) is no longer misflagged as free.

OpenAI unaffected — its prompt_tokens already includes cached input.

Tests: provider cache extraction, audit emission (breakdown + summed
total + cache-read-only not-unavailable + omitempty when zero),
accumulator aggregation. Docs: audit-logging.md token-usage section.

Claude-Session: https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ
The X-Forge-Tokens-In response header summed only the uncached
input_tokens delta, so an orchestrator ceiling-checking cost against it
would wildly under-count on cache-heavy runs and let a stage sail past a
cap. Stamp it from the accumulator's TotalInputTokens (delta + cache
read + cache creation) instead, with a max(total, input) guard so the
header never reports below the uncached delta when a snapshot lacks the
total (mirrors security-next#36's `total_input_tokens ?? input_tokens`).

Tests: cache-heavy snapshot bills the true total; unpopulated-total
snapshot falls back to the delta. Docs: header table updated.

Claude-Session: https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Approve-grade — correct root-cause fix, complete end-to-end wiring, thorough tests. Traced the full chain against the branch source.

The fix is end-to-end complete

Under Forge's default cache_control breakpoints, Anthropic's input_tokens is only the uncached delta; the cached prefix bills as cache_read / cache_creation. The parser dropped both. Verified each link:

  1. Provider parse (anthropic.go) — parses both cache fields, folds all four into TotalTokens (input+cache_read+cache_creation+output, matching OpenAI's inclusive prompt_tokens). OpenAI / ollama untouched.
  2. TypesUsageInfo + LLMUsage both gain the fields and a TotalInputTokens() helper.
  3. Emittotal_input_tokens is ALWAYS set (keeps security-next#36's total_input_tokens ?? input_tokens fallback on its fast path); cache fields are nil-pointer-omitempty; tokens_unavailable now keys off totalIn==0 && out==0, so a cache-read-only turn is not misflagged as free.
  4. Accumulate — sums cache + true input; snapshot exposes them; the unavailable latch keys off the total.
  5. invocation_complete — all THREE emission sites (runner.go 1731 / 2025 / 2323) set total_input_tokens_total + the conditional cache breakdown. N-of-N; no site missed.
  6. HeaderX-Forge-Tokens-In = max(TotalInputTokens, InputTokens), a defensive guard mirroring the ?? input_tokens fallback.

The critical wiring is clean

The one real risk here is a divergent LLMUsage construction feeding the accumulator WITHOUT the cache fields (which would silently undercount the header + invocation_complete while the audit event looked correct). It is clean: the single AfterLLMCall hook builds usage once with the cache counts and hands the SAME struct to both EmitLLMCall and acc.AddLLMCall. One source, two consumers, no undercount path.

Non-blocking notes

  1. Streaming still undercounts. The PR explicitly scopes out readAnthropicStream (emits no input tokens today). The dominant A2A path is non-streaming (the AfterLLMCall hook reads parseAnthropicResponse), so production billing is covered — but a cache-heavy STREAMING invocation stays uncounted until that gap closes. Worth a tracked follow-up.
  2. The TotalTokens semantic change also makes the registerLoggingHooks fields["tokens"] value cache-inclusive — intentional and harmless (more accurate; no arithmetic consumer does TotalTokens - InputTokens).

All 10 CI checks green. Field names align with initializ-sdk#10; docs updated in lockstep. Nicely done.

// event emits cache_read/creation + a summed total_input_tokens
// instead of undercounting to the uncached delta (issue #431).
usage.CacheReadInputTokens = hctx.Response.Usage.CacheReadInputTokens
usage.CacheCreationInputTokens = hctx.Response.Usage.CacheCreationInputTokens

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the wiring that makes the whole fix hold together, and I verified it is clean. usage is built ONCE here with the cache counts, then the same struct is handed to both EmitLLMCall (so the llm_call event carries total_input_tokens + the cache breakdown) AND acc.AddLLMCall a few lines down (so the accumulator — and thus total_input_tokens_total on invocation_complete + X-Forge-Tokens-In — sums the true input). There is no second LLMUsage construction path that could feed the accumulator the uncached delta while the audit event looked correct, which is exactly the failure mode a change like this invites. One source, two consumers. Nice.

…knowledge skill (#431)

sync-docs pass for the #431 provider/audit/accumulator/header changes:

- hooks.md: AfterLLMCall audit-field table gains cache_read/creation +
  total_input_tokens, with the uncached-delta caveat and a link to the
  audit-logging token-usage section.
- forge.md (knowledge skill): X-Forge-Tokens-In now bills the true total;
  the call-site token-capture paragraph explains the cache breakdown;
  AuditLLMCall + AuditInvocationComplete event rows list the new fields.

audit-logging.md was already synced in the code commits on this branch.

Claude-Session: https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Filed the streaming follow-up the review flagged: #433 — the Anthropic readAnthropicStream path has no message_start case, so it drops input + cache tokens (StreamDelta.Usage carries only output_tokens). Confirmed latent, not live: the A2A executor's ExecuteStream wraps non-streaming ExecuteChat (the #431-fixed parseAnthropicResponse), and the only production ChatStream caller is forge-cli/cmd/ui.go (workspace UI chat), which doesn't feed the audit/billing accumulator. Tracked so the gap closes before any billing path adopts streaming.

@initializ-mk
initializ-mk merged commit 4a6ccbe into main Sep 1, 2026
10 checks passed
initializ-mk added a commit that referenced this pull request Sep 5, 2026
The llm.completion span carried only gen_ai.usage.input_tokens /
output_tokens, so the prompt-cache counts forge already parses
(cache_read/creation, since #431/#432) were invisible in traces — and
the span diverged from the llm_call audit event, which does carry them.

- Add gen_ai.usage.cache_read_input_tokens / cache_creation_input_tokens
  / total_input_tokens attribute constants (observability/attrs.go),
  following forge's existing gen_ai.usage.* prefix (OTel GenAI semconv
  has no standard cache-token attributes yet).
- Stamp them on the LLM span (loop.go), only when non-zero so
  non-caching / non-Anthropic calls keep their span shape; total is
  emitted whenever caching contributed, matching the audit event's
  always-present bill-from field.

This restores trace<->audit consistency: an llm_call row's span_id now
resolves to a span carrying matching cache tokens.

Tests: cache-heavy call stamps read/creation/total on the span; a
non-caching call omits the cache attributes entirely. Docs:
observability-tracing.md span table + audit-logging.md trace-link note.

Scope: flat cache counts only. The nested cache_creation.{5m,1h} TTL
split is tracked separately in #442.

Claude-Session: https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ
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.

Anthropic llm_call undercounts tokens: capture cache_read/creation + emit total_input_tokens (parity with initializ-sdk#10)

1 participant