Type: Decision Status: Draft Systems: Gateway, Plugins Author: Brendan / Claude Date: 2026-06-23 Related: LLP 0016 (ai-gateway), LLP 0026 (claude-native-granularity), LLP 0030 (session-id-partition-key)
attributes.usage on ai_gateway_messages carries token counts from multiple
providers. Two cross-cutting rules keep the column analyzable without the
analyst (human or model) having to special-case the provider:
input_tokensis net of cache, everywhere. It counts only uncached prompt tokens. Cached prompt reads ridecache_read_tokens, and (Claude only) cache writes ridecache_write_tokens. So for every providerinput_tokens + cache_read_tokens [+ cache_write_tokens] = total prompt, and a naiveSUM(input_tokens)orSUM(input_tokens + cache_read_tokens)means the same thing across rows.- Per-message usage is per-response (a delta), never cumulative. A row's usage describes the one model response that produced it; summing rows over a conversation reconstructs the conversation total.
total_tokens, when a provider supplies it, is stored raw (the provider's
own total, which is gross-input + output). Because input is stored net, the
identity input_tokens + cache_read_tokens + output_tokens == total_tokens
holds: a cheap reconciliation check.
attributes.usage was first shaped by the Claude adapter, whose transcript
usage block is already per-response and net: Anthropic reports
input_tokens (uncached), cache_read_input_tokens, and
cache_creation_input_tokens as three additive, non-overlapping fields
(anthropic.js#anthropicMessageAttributes maps the latter two to
cache_read_tokens / cache_write_tokens).
OpenAI and ChatGPT Codex report usage differently:
input_tokens(Responses) /prompt_tokens(Chat) is gross: it includes the cached reads. The cached subset isinput_tokens_details.cached_tokens(live) orcached_input_tokens(the Codex rollouttoken_countevent).- The Codex rollout emits a
token_countevent after each turn carrying bothtotal_token_usage(cumulative session running total) andlast_token_usage(this turn).
If those raw shapes were stored as-is, usage.input_tokens would mean
"uncached input" for Claude and "input incl. cache" for Codex: the same
column, two meanings. Any cross-provider SUM/comparison would silently
mismix net and gross, and input_tokens + cache_read_tokens would
double-count cache for Codex. That is a confidently-wrong-numbers trap for an
LLM querying the data, which is HypAware's primary consumer.
-
Net input. Both the live Codex exchange projector (
exchange-projector.js#openAiUsageAttributes) and the Codex backfill (backfill.js#codexUsageAttributes) computeinput_tokens = grossInput − cachedInput(floored at 0) and put the cached count oncache_read_tokens. The Claude adapter already produces net input and is unchanged: Claude/net is the anchor convention. -
Per-turn, not cumulative. Codex backfill reads the
token_countevent'slast_token_usage, nevertotal_token_usage. The event is consumed as a turn-boundary marker (it never projects a row); its usage is stamped per the one-carrier rule below. -
One carrier per response, on the last assistant row. A billed response fans into several rows: Claude splits one API message into one row per content block (LLP 0026); Codex fans a response into separate messages and a turn into reasoning/text/tool rows. Response-level
usageis stamped onto exactly one of those rows: the last assistant row of the response (the terminal output item, atool_useon tool-calling turns, else the finaltext). This holds for all four paths:- Claude live (
projector.js#projectAssistantMessage) and backfill (backfill.js, last block permessageId): usage rides the same last-block row asstop_reason, instead of being duplicated onto every block. - Codex live (
exchange-projector.js#stampUsageOnLastAssistant) and backfill (backfill.js#stampUsageOnTurn, last eligible): switched from first to last so the carrier row matches Claude. Both apply the same eligibility predicate (hasTextOrToolUse: the last assistant row carrying text or a tool_use, skipping reasoning-only rows), so the two paths select the same carrier by rule rather than by the coincidence that a live Responses reply never ends in a reasoning-only assistant message. A turn with no eligible assistant (e.g. windowed out) drops its usage rather than mis-attributing it to an earlier row.
Two payoffs: a plain
SUM(attributes.usage.*)over rows is correct with no dedupe, and a human scanning the table sees one identical shape for both providers (a run of assistant rows, usage on the final one). Live and backfill pick the same last row, so they dedupe onto it (the dedup hash excludesattributes, so placement is safe). The within-message carrier rule is enforced structurally too: when a single usage-bearing message has multiple content blocks,expandMessagePartsstampsusageon only its last part (message_projector.js#stripUsage), so a multi-block carrier no longer replicates (over-counts) its usage across every block. This edge was assumed not to occur ("carrier messages are single-block"), but Claude backfill does emit multi-block carrier messages,reasoning + text,reasoning + tool_use, and parallel-tool-call turns (reasoning + reasoning + tool_use + tool_use), where the transcript records several blocks under onemessageId. Those were the only rows where a plainSUMover-counted before this rule was made unconditional. - Claude live (
-
Raw total.
total_tokensis passed through unmodified; net input keeps the reconciliation identity intact.
No information is lost: the provider's gross input is recoverable as
input_tokens + cache_read_tokens.
Token accounting reads attributes.usage (a JSON column), never
raw_frame. attributes.usage is the only path populated for every provider
and capture mode (Claude live + backfill, Codex live + backfill). The
provider-raw frame is unreliable: raw_frame is null for Claude live and all
Codex; only Claude backfill stashes the transcript line. And the id, when
present, is the flat raw_frame.message_id, not the nested
raw_frame.message.id / raw_frame.message.usage some older notes cite (both
null in the data).
With the one-carrier rule above, each response contributes usage to exactly one row, so a plain sum is correct:
SELECT
COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.input_tokens') AS BIGINT)), 0) AS input,
COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.output_tokens') AS BIGINT)), 0) AS output,
COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.cache_read_tokens') AS BIGINT)), 0) AS cache_read,
COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.cache_write_tokens')AS BIGINT)), 0) AS cache_write,
COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.reasoning_tokens') AS BIGINT)), 0) AS reasoning
FROM ai_gateway_messages
WHERE role = 'assistant' AND JSON_EXTRACT(attributes, '$.usage') IS NOT NULLA defensive max()-per-COALESCE(raw_frame.message_id, message_id) rollup also
remains correct (the non-carrier blocks are null and ignored), so it's safe to
keep in queries written before this decision.
The field union is a null trap, and the nulls are
silent. Codex carries reasoning_tokens and no cache_write_tokens; Claude
is the reverse; input_tokens is net for both (#net-input). A field the
provider never emits reads NULL, and NULL propagates rather than zeroing, in
two distinct places:
- Inside a row's arithmetic.
CAST(...cache_read...) + CAST(...cache_write...)is NULL on every OpenAI row, soSUMskips the row entirely and that provider's whole cache-read total collapses to 0. Measured on a real install: 25,581,312 cache-read tokens became 0, with no error. - At the aggregate.
SUMover all-NULL returns NULL, not 0, so an OpenAI-scoped slice yieldscache_write: nulland anyinput + cache_read + cache_writetotal built from it is NULL.
So: COALESCE(..., 0) every token sum, and every term of every token
addition. The rule is the same shape as the net/gross normalization this
document exists for - a cross-provider query must not silently mean different
things per provider - one layer down, in null handling.
- Cross-provider input-token analysis is apples-to-apples; cache is never double-counted in a prompt-token sum.
- Codex
input_tokensno longer equals the provider's raw number. Anyone comparing a HypAware row against an OpenAI dashboard must addcache_read_tokensback. This is the deliberate cost of one consistent column. - The live Codex usage extraction was uncommitted when this decision landed, so no shipped Codex rows used the gross form.
- Claude field values are unchanged (already net), but Claude usage
placement changed: it now rides one row (the last block) instead of every
block, see #one-carrier and the LLP 0026 consequence revision. No in-app
consumer reads
attributes.usage(verified: context graph, enrichment, sinks, datasets, and vector search all ignore it), so only ad-hoc/skill SQL is affected, and themax()-per-message-id form still works.
- Gross everywhere (fold Claude's cache into
input_tokens): rewrites the meaning of already-shipped Claude rows and touches more adapters. Rejected: larger blast radius, and it discards the clean additive cache breakdown. - Leave provider-native, document the asymmetry: zero code, but the footgun stays in the data forever and every consumer must re-learn it. Rejected: pushes the cost onto every future query.