Usage Cost Calculation#
Cost computation for observations flows through five sequential stages: usage normalization → model lookup → pricing tier selection → exact-match cost calculation → ClickHouse storage. Two ingestion paths feed into the same enrichment pipeline in IngestionService: the OTel path via OtelIngestionProcessor and the native SDK path.
ClickHouse stores four Map-type columns on the observations table to separate user-supplied from computed values :
| Column | Purpose |
|---|---|
provided_usage_details | Raw user-supplied token counts |
usage_details | Enriched/final counts (auto-tokenized or copied from provided) |
provided_cost_details | User-supplied cost breakdown |
cost_details | Final computed cost breakdown |
Key source files:
| File | Purpose |
|---|---|
worker/src/services/IngestionService/index.ts | getGenerationUsage, getUsageUnits, calculateUsageCosts |
packages/shared/src/server/otel/OtelIngestionProcessor.ts | extractUsageDetails, extractGenericGenAiUsageDetails |
packages/shared/src/server/ingestion/modelMatch.ts | findModel with multi-layer caching |
packages/shared/src/server/pricing-tiers/matcher.ts | matchPricingTier |
worker/src/backgroundMigrations/addGenerationsCostBackfill.ts | Batch cost recomputation migration |
Usage Key Normalization#
Both ingestion paths converge on canonical keys (input, output, total, input_cached_tokens, etc.) before hitting the cost calculator.
OTel Path#
extractUsageDetails() applies sources in this priority order — the first matching source wins :
langfuse.observation.usage_detailsspan attribute (JSON blob) — skips all other sources when present, even if{}- Genkit (
genkit-tracerscope) — readsgenkit:output.usage.*keys - Vercel AI SDK (
aiscope) — readsgen_ai.usage.*and enriches fromai.response.providerMetadata(OpenAI, Anthropic, Bedrock cache details) - Pydantic AI (
pydantic-aiscope) — delegates to generic extraction - Generic
gen_ai.usage.*/llm.token_count.*— fallback for OpenInference, TraceLoop, etc.
The generic extractor extractGenericGenAiUsageDetails() strips OTel prefixes and maps to canonical keys :
| OTel attribute suffix | Canonical key |
|---|---|
prompt_tokens, input_tokens, prompt | input |
completion_tokens, output_tokens, completion | output |
total_tokens, total | total |
cache_read.input_tokens, cache_read_tokens | input_cached_tokens |
cache_creation.input_tokens, cache_write_tokens | input_cache_creation |
| anything else | kept as-is (with details. prefix stripped) |
input is decremented by cache read + cache creation tokens to avoid double-counting .
Native SDK Path#
mapObservationEventsToRecords() populates provided_usage_details from three sources :
- Legacy
usage.input/output/totalfields - Newer
usageDetailsmap (null values filtered at merge) costDetailsmap →provided_cost_details
getUsageUnits() then coerces all values via Number(), silently dropping entries that are NaN or negative . If any user-provided usage key survives this validation, automatic tokenization is skipped entirely.
Model Lookup, Pricing Tier Selection, and Cost Calculation#
Model Lookup#
getGenerationUsage() only calls findModel when provided_model_name is set; without a model, automatic tokenization and pricing are both skipped .
findModel() resolves via a three-layer cache :
- L1 — in-process TTL cache (default 10 s)
- L2 — Redis (caches model + pricing tiers together)
- Postgres — regex
match_patternfield on theModeltable
Unknown models are cached with a NOT_FOUND_TOKEN sentinel to prevent repeated DB hits.
Automatic Tokenization Fallback#
Auto-tokenization only runs when :
- A model was resolved
provided_usage_detailsis empty- Observation
levelis notERROR
tokenCountAsync runs in a non-blocking worker thread pool, with a synchronous tokenCount fallback. Both dispatch on model.tokenizerId (e.g., OpenAI tiktoken, Claude). Failure produces empty usage_details; ingestion is never blocked.
Pricing Tier Selection#
matchPricingTier() evaluates non-default tiers in priority order with AND logic . Each condition:
- Compiles
usageDetailPatterninto a regex - Sums all
usage_detailskeys matching the pattern - Compares the sum to a threshold using an operator (
gt,gte,lt,lte,eq,neq)
The first tier where all conditions pass is used; otherwise the default tier is the fallback. Pricing tiers are stored in the PricingTier table (Prisma) with a Price entry per usageType, each holding a Decimal price value .
Exact-Match Cost Calculation#
calculateUsageCosts() iterates usageUnits key-by-key and looks up the matching price via price.usageType === key — a strict exact string match . Cost for each key = price × units.
User override: if provided_cost_details contains any key, all automatic cost calculation is bypassed and the user values are used as-is .
Background Cost Recomputation#
When model pricing definitions change, historical observations need cost recalculation. AddGenerationsCostBackfill handles this as a Postgres-side background migration .
How it works:
- Processes observations in batches of 1000 (configurable), ordered by
start_time DESCusing the last batch row'sstart_timeas a cursor - Each batch uses a
LATERAL JOINto find the matching model byinternal_modelname,unit, project scope, andstart_date ≤ observation.start_time - Computes
calculated_input_cost = prompt_tokens * input_price,calculated_output_cost = completion_tokens * output_price, andcalculated_total_cost(usingtotal_price * total_tokenswhen available, otherwise summing input + output) - Respects user-provided costs: only fills
calculated_*columns when all three ofinput_cost,output_cost, andtotal_costareNULL - Adds a temporary
tmp_has_calculated_cost BOOLEANcolumn for progress tracking; drops it on completion - Sets
statement_timeout = '19min'and restores the original on exit - Accepts
maxRowsToProcessandmaxDateCLI arguments for staged runs
The migration implements IBackgroundMigration and can also be executed directly as a CLI script .