ADR-044: Canonical AI token-metering model (one `tokens` meter + `token_type` dimension)
Status: Accepted; superseded in part by ADR-130 (decision 4: token prices are plan prices, most specific wins), built in PR-4 (#1386)
Date: 2026-06-01
Related: ADR-033 (LiteLLM spend adapter), multi-dimensional metering (internal/usage AggregateByPricingRules), pricing recipes (internal/recipe), project_positioning_wedge.md
Superseded in part by ADR-130, decided 2026-10-23 and built in PR-4 (#1386). Where this ADR differs, ADR-130 describes the code. Decision 4 changes. Token prices are plan prices on the tokens meter, matched on {model, token_type}, and the most specific matching price wins, with no priority and no meter_pricing_rules (PR-4). The one tokens meter, the token_type dimension and the mapper rules stay.
Summary
Section titled “Summary”All AI token usage is metered on one tokens meter (aggregation sum), with the token role in a token_type dimension: input, output, cache_read, cache_write_5m, cache_write_1h, and more as needed. model and provider are dimensions too, and prices are pricing rules on that meter that match {model, token_type}, so a new model or role adds rules, never meters. Each ingestion mapper must turn a provider’s counts into separate, non-overlapping roles (input is prompt tokens minus cached tokens), so the rating engine only sums. The LiteLLM mapper emits input, cache_read, cache_write_5m, cache_write_1h and output: LiteLLM 1.83.9 reports the split in prompt_tokens_details.cache_creation_token_details. When a record has cache writes without a usable split, they bill as input and the fallback is counted (hardcount_litellm_cache_write_unsplit_total). Status: active.
Amendment (2026-10-22). An earlier version of this summary said LiteLLM does not report the 5-minute and 1-hour split, so cache writes billed as input (1.0x instead of 1.25x or 2x). That was false for LiteLLM 1.83.9, whose usage object carries the split. The mapper now emits both write roles. A call first stored the old way (its input quantity includes the writes) is stored the old way again when LiteLLM resends it, decided per call from the stored input row, and a held cache-write row of such a call is not billed by a resend or a release. The exception is a release run by the code before this change (a rollback, or the rolling deploy), which does not know the split and can bill those writes twice.
Amendment (2026-10-22, model families). A Claude name maps to a family only as the family’s exact id, or that id plus a date or version ending (-20250929, -v1, -v1:0, @20250805, @default, -v2@20241022, -latest). An unknown version, such as claude-opus-4-9, keeps its raw name on model and is unpriced, loudly; it is never priced as an older family. Provider and cross-region prefixes are stripped repeatedly. A family priced by prompt length (Claude Haiku 5.5) also gets a context_tier dimension (prompt_up_to_100k or prompt_over_100k) on every event of the call, set by the mapper; other families’ events never carry it. The cache-read multiple in the table below is 0.1x for most models; Opus 5.5 and Sonnet 5.5 read at 0.05x, Fable 5.1 and Mythos 5.1 at 0.025x.
Context
Section titled “Context”Hardcount has two incompatible shapes for LLM token usage today, and they don’t connect:
- Pricing recipes (
anthropic_style,openai_style) create onetokensmeter and price via dimensionsoperation=input|output+ booleancached=true|false+model, with priority-matched rating rules (thecachedvariant wins viapriority=200). - The LiteLLM ingestion mapper (
internal/integrations/litellm/mapper.go) emits two meters —tokens_inputandtokens_output— with dimensionsmodel+provider, nooperation/cached.
So LiteLLM-ingested events never rate against the recipe rules: the headline wedge demo (“point your LiteLLM proxy at Hardcount and your AI-native plan bills automatically”) cannot work end-to-end. Picking the canonical model is a re-litigable architecture decision, hence this ADR. We must define one metering model that every ingestion path (LiteLLM today; OpenAI/Bedrock/Vertex/direct tomorrow) and every consumer (recipes, rating rules, cost dashboard) conforms to — so the next provider doesn’t re-open this mismatch.
Verified industry research (2026-06-01)
Section titled “Verified industry research (2026-06-01)”Six sourced agents (metering platforms + the providers’ own billing itemization):
- Metering platforms converge on ONE token meter, role as a DIMENSION, priced by matrix/dimensional (“GROUP BY then price”) pricing:
- Lago (high): “Create a single billable metric to track token usage across different models and input/output types” —
type:[input,output]is a filter value, model another filter; “one metric, many prices, no duplicated metrics.” - OpenMeter (high): one
tokens_totalmeter,groupBy:{provider,model,type}; “We recommend using groups instead of creating multiple meters.”- Correction (2026-10-23): the repo’s sample
tokens_totalmeter groups bymodelandtypeonly, with noprovider. Neitherprovidernor the quoted sentence appears in the OpenMeter repo. Both may come from openmeter.io, which was not fetched again. Checked in OpenMeterf04b561f(2026-10-22):config.example.yaml:157-165.
- Correction (2026-10-23): the repo’s sample
- Orb / Metronome (medium): one event per call carrying each role as a property; model/provider as dimensional-pricing group keys. They lean toward one rate per role at the pricing layer — which in Hardcount is the same thing as one meter with N rules matching on the role dimension.
- Lago (high): “Create a single billable metric to track token usage across different models and input/output types” —
- Provider APIs (OpenAI, Anthropic, high) itemize roles as separate named FIELDS (
prompt_tokens/completion_tokens;uncached_input_tokens/output_tokens/cache_read_input_tokens/cache_creation). This is a per-call reporting itemization, not a metering data model — even theremodelis agroup_bydimension (never baked into the field name) andproviderdoesn’t exist (each API is first-party). A metering platform consumes this itemization at the mapper boundary.
Hardcount’s own storage already is the metering-platform model: one meter, scalar summed quantity, dimensions in usage_events.properties (JSONB), rating via e.properties @> dimension_match. So this ADR ratifies the model Hardcount is structurally built for and deletes the two-meter anomaly.
The binding constraint: Anthropic’s cache roles
Section titled “The binding constraint: Anthropic’s cache roles”The provider itemization sets the hard floor on what the model must express. Anthropic meters and prices five disjoint roles per model:
| Role | Anthropic rate (relative to base input) |
|---|---|
input (uncached) |
1× |
output |
model-specific (≈3–5×) |
cache_read |
0.1× |
cache_write_5m |
1.25× |
cache_write_1h |
2× |
A single boolean cached:true|false (what both recipes use today) provably cannot distinguish cache-read from 5-minute-write from 1-hour-write — three different prices. So the role dimension must be an open string enum, not a boolean.
OpenAI adds a normalization wrinkle: its cached_tokens is a subset of prompt_tokens (already counted), not additive — a mapper that naively emits both double-counts the cached portion.
Decision
Section titled “Decision”Canonical metering model for all AI token usage:
- One meter, key
tokens(unit:tokens, aggregation:sum). No per-role meters. - Token role is a single dimension
token_typeonusage_events.properties— an open string enum, seeded{input, output, cache_read, cache_write_5m, cache_write_1h}and extensible (reasoning,audio,system, …). modelandproviderare dimensions (provideris a Hardcount-only attribution dimension; no provider API prices by provider).- Pricing is matrix/dimensional, unchanged mechanism: N
meter_pricing_ruleson thetokensmeter, eachdimension_matcha JSONB subset over{model, token_type}(e.g.{model:'claude-opus-4', token_type:'cache_write_1h'}), resolved by the existing priority+claime.properties @> dimension_match. Becausetoken_typevalues are disjoint, each(model, role)is exactly one rule at one priority — the recipes no longer need thepriority=200cached-wins trick. - Mapper-normalization invariant: every provider mapper is solely responsible for normalizing provider-specific itemization into additive-disjoint canonical roles —
uncached_input = prompt_tokens − cached_tokens, Anthropiccache_creationTTL split intocache_write_5m/cache_write_1h. The rating engine never does role arithmetic;SUM(quantity)partitioned bytoken_typeis always correct. This keeps OpenAI-style subset semantics out of the engine and out of every future mapper’s contract.
No schema migration: usage_events.properties and meter_pricing_rules.dimension_match are free-form JSONB. Pre-launch, local-only, zero live integrations — clean change, no backfill (per feedback_no_speculative_backfill).
Conformance
Section titled “Conformance”- Ingestion (
internal/integrations/litellm/mapper.go+ every future mapper): emit meter keytokens, one event per present role,token_typeonproperties. ReplaceMeterKeyTokensInput/MeterKeyTokensOutputwithMeterKeyTokens = "tokens";prompt_tokens → {token_type:input},completion_tokens → {token_type:output}; idempotency keyp.ID + ":" + token_type.model/providerdims already correct. Cache roles require extendingpayload.go’sUsagestruct to parseprompt_tokens_details.cached_tokens(and Anthropiccache_creationTTLs when LiteLLM forwards them) — see Open Decisions for v1 scope. - Recipes (
anthropic_style.yaml,openai_style.yaml): rename the role dimensionoperation → token_type; replacecached:truerules withtoken_typerole values (cache_read, and for Anthropiccache_write_5m/cache_write_1h). Meter/rule shape is unchanged (one meter, many rules,dimension_matchsubset). Drop thepriority=200cached-wins comment/trick. - Cost dashboard (
internal/usage/cost_dashboard.go): no structural change — it already lists one row per meter key with a per-ruleDimensionMatchbreakdown. Under this model it shows onetokensmeter grouped by{model, token_type}, matching how OpenAI/Anthropic’s own usage APIs group. Only cosmetic work: finance-readable labels fortoken_typevalues (perfeedback_ui_copy_no_engineering_jargon). - ADR-033 (LiteLLM adapter): the
prompt_tokens→tokens_input / completion_tokens→tokens_outputmapping table (lines ~28–32) is superseded by this ADR — correct it in the same change (doc discipline).
Decisions locked (2026-06-01)
Section titled “Decisions locked (2026-06-01)”- Cache-write TTL granularity → carry
cache_write_5m+cache_write_1hseparately (full Anthropic parity: 1.25× vs 2×). - v1 scope / cadence → adopt the canonical dimension model now, ship input/output end-to-end immediately, define all cache roles in the enum + recipes now; LiteLLM cache-token parsing is a fast-follow (the payload doesn’t parse
prompt_tokens_details.cached_tokens/ Anthropiccache_creationyet). Until that lands, LiteLLM emits onlyinput/output; direct-ingest operators get full cache parity via the recipe rules immediately. - Dimension name →
token_type(matches OpenMeter’stype). provider→ attribution/reporting dimension only; pricing rules key on{model, token_type}. (Revisit if an operator needs to price the same model differently across providers.)token_typevalidation locus → engine storage stays free-form; validatetoken_typeagainst the known enum at the mapper boundary.
v1 token_type enum: {input, output, cache_read, cache_write_5m, cache_write_1h}.
Consequences
Section titled “Consequences”Positive: one metering model across all providers; expresses 5+ roles × N models × cache without a meter explosion (adding a model/role adds rules, never meters); unblocks the wedge demo; deletes the two-meter anomaly and the priority cached-hack; cost dashboard rolls up cleanly under one tokens headline.
Negative: full Anthropic cache parity requires the mapper to parse and normalize provider-specific cache itemization (real work, isolated to the mapper). The token_type enum is convention-enforced (validated at the mapper), not schema-enforced — consistent with the multi-dim design’s free-form-dimension stance.
Neutral: the recipe and mapper both change, but the storage model and rating mechanism are untouched — this ratifies what Hardcount already is.