fix(usage): capture Anthropic cache tokens + emit total_input_tokens (#431) - #432
Conversation
…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
left a comment
There was a problem hiding this comment.
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:
- Provider parse (
anthropic.go) — parses both cache fields, folds all four intoTotalTokens(input+cache_read+cache_creation+output, matching OpenAI's inclusiveprompt_tokens). OpenAI / ollama untouched. - Types —
UsageInfo+LLMUsageboth gain the fields and aTotalInputTokens()helper. - Emit —
total_input_tokensis ALWAYS set (keeps security-next#36'stotal_input_tokens ?? input_tokensfallback on its fast path); cache fields are nil-pointer-omitempty;tokens_unavailablenow keys offtotalIn==0 && out==0, so a cache-read-only turn is not misflagged as free. - Accumulate — sums cache + true input; snapshot exposes them; the unavailable latch keys off the total.
invocation_complete— all THREE emission sites (runner.go1731 / 2025 / 2323) settotal_input_tokens_total+ the conditional cache breakdown. N-of-N; no site missed.- Header —
X-Forge-Tokens-In = max(TotalInputTokens, InputTokens), a defensive guard mirroring the?? input_tokensfallback.
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
- Streaming still undercounts. The PR explicitly scopes out
readAnthropicStream(emits no input tokens today). The dominant A2A path is non-streaming (theAfterLLMCallhook readsparseAnthropicResponse), so production billing is covered — but a cache-heavy STREAMING invocation stays uncounted until that gap closes. Worth a tracked follow-up. - The
TotalTokenssemantic change also makes theregisterLoggingHooksfields["tokens"]value cache-inclusive — intentional and harmless (more accurate; no arithmetic consumer doesTotalTokens - 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 |
There was a problem hiding this comment.
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
|
Filed the streaming follow-up the review flagged: #433 — the Anthropic |
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
Fixes #431.
Problem
Forge sets
cache_control: ephemeralbreakpoints on the Anthropic tools+system prefix, so prompt caching is active by default. Under caching, Anthropic'susage.input_tokensis only the uncached delta — the bulk of the prompt is billed ascache_read_input_tokens(hit) andcache_creation_input_tokens(write). The response parser read onlyinput_tokens/output_tokens, sollm_callevents 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
cache_read_input_tokens/cache_creation_input_tokensinanthropicResponse.Usage; fold them intoUsageInfo.TotalTokens(nowinput + cache_read + cache_creation + output, matching OpenAI's inclusiveprompt_tokens).llm.UsageInfoand runtimeLLMUsage, each with aTotalInputTokens()helper.llm_call:cache_read_input_tokens+cache_creation_input_tokens(omitempty) and a summedtotal_input_tokens(always present). Same field names as initializ-sdk#10, so security-next#36'stotal_input_tokens ?? input_tokensfallback picks it up unchanged.total_input_tokensinusage_accumulator;invocation_completenow carriestotal_input_tokens_total(+ cache breakdown totals when caching was active).tokens_unavailablenow keys off total input, so a fully-cached turn (input_tokens0,cache_read> 0) is no longer misflagged as a free call.OpenAI unaffected — its
prompt_tokensalready includes cached input; the cache fields stay absent.Orchestrator header
The
X-Forge-Tokens-Inresponse header now bills fromTotalInputTokens(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. Amax(total, input)guard keeps it from ever reporting below the uncached delta if a snapshot lacks the total (mirrors thetotal_input_tokens ?? input_tokensfallback). 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/TotalInputTokensmath.runtime/audit: breakdown + summed total emitted with exact wire names;total_input_tokenspresent + 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-lintclean (0 issues), all affected suites pass.Acceptance
llm_calltotal_input_tokensmatches real input (incl. cache read/creation).llm_callcarriescache_read_input_tokens+cache_creation_input_tokens+total_input_tokens.https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ