← Research

How spend is attributed

The per-dimension spend cuts on the Attribution surface and in the attribution CSV export: spend by key, project, route, and tag namespace.

What it is

Every request the gateway proxies produces one receipt. Attribution buckets those same receipts into per-period cuts by the key that authenticated the call, the project and tags you declared on two Recovea headers, and the route class we proxied. Each request lands in exactly one bucket per dimension, so every cut sums to the same period total the dashboard shows.

How it is computed

  1. Capture. We read x-recovea-tags (comma-separated, repeatable) and x-recovea-project (one value). Values are trimmed, empty pieces skipped, never validated. Both headers are ours and are stripped before we forward, so they never reach the provider.
  2. Bound. At most 32 tags per request, in declaration order. Each kept tag, and the project value, is truncated to 128 characters.
  3. Price. Cost is integer micro-USD (1 USD = 1,000,000). Provider-reported token counts multiply a frozen price list, pinned on the receipt as refPriceVersion, whose rates are micro-USD per million tokens. Products are summed in i128 and floor-divided by 1,000,000 exactly once, so no per-component rounding accumulates. A model absent from the list prices to null, never a guess.
  4. Bucket. Period is the UTC calendar month of the request start. Route is a canonical endpoint class, never a raw path. A missing project lands in the reserved unassigned bucket; a request with no tag in a namespace lands in that namespace's untagged bucket; where several tags share a namespace, the first in declaration order wins. A null cost adds 0 spend and still counts 1 request. Buckets are upserted in the same transaction as the daily rollup.
  5. Read. Rows return spend-descending. Share is integer permyriad, floor(part * 10000 / total), divided by 10,000 for display. A zero total gives a null share, not a zero.

Worked example: three requests in 2026-08, all gpt-4.1 on price list rpl-2026-08-05 (input 2,000,000, cached input 500,000, output 8,000,000 micro-USD per million tokens).

RequestKeyProjectTagsTokensCost
R1key_acheckoutteam:payments, feature:cart1200 in, 400 cached, 340 out4520
R2key_aabsentteam:payments1000 in, 600 out6800
R3key_bcheckoutnone500 in, 0 out1000

R1 is (1200 - 400) x 2,000,000 + 400 x 500,000 + 340 x 8,000,000 = 4,520,000,000, floored by 1,000,000, so 4520. Period total 12,320. Project cut: checkout 5,520, unassigned 6,800. Feature cut: cart 4,520, untagged 7,800. Each sums to 12,320, and key_a's share is floor(11320 x 10000 / 12320) = 9188, shown as 0.9188.

Key and project are Developer and above; route cuts and the tag namespaces are Growth and above; the CSV export of any cut is Growth and above. A plan below the gate gets a locked response carrying only its real metered request count.

What it does not include

  • Prompts, completions, header values, raw paths. Tag values are opaque strings, never resolved to a person.
  • Provider spend that did not pass through the gateway: direct calls, batch jobs, console usage, fine-tuning.
  • Your Recovea subscription, taxes, credits, negotiated discounts, committed-use pricing. Attribution is list price.
  • Token detail the provider did not report, which contributes zero because it is not on their invoice either. Reasoning tokens sit inside completion tokens and are never charged twice.
  • SKUs outside our price schema: long-context tiers, one-hour cache writes, regional uplifts, fast-mode variants. These price at the base rate, which under-measures rather than over-charges.

Limits and current status

Live today: capture, pricing, bucketing, the reconciliation property, the cuts API, the CSV export, the plan gates. Known gaps:

  • Tag length is a layer drift. Capture truncates to 128 characters, so the rollup's separate over-length drop rule never fires. Two tags sharing their first 128 characters merge into one bucket, and our "drop, never truncate" comment describes the rollup only.
  • Drop counters are not customer-visible. We count tags dropped past the 32-tag guard at the gateway and in worker logs. Neither reaches a receipt, the API, or the export.
  • Reserved buckets are not escaped. A project named unassigned or a tag named untagged merges with the reserved bucket.
  • Budgets. Only workspace-scoped caps reach the gateway. Key- and project-scoped caps can be created and are displayed, but are not enforced in path.
  • Alerts. The sweep runs about once a minute, so a fast burst can reach a cap between checks. Alerts warn on the way up; they are not an ordering guarantee.
  • Counterfactual. Measured, never applied. No lever is activated in any deployed environment, so the realized model always equals the baseline.
  • CPSO. Figures are list price. Invoice reconciliation at period close exists in the worker but runs in no job, and the live / settled label reflects only whether the month has ended, so a past month reads settled with nothing reconciled.
  • Recovea Score. The engine is not shipped. The production store returns no factors, so no workspace has a score today.

Where to check us

  • Re-derive a request from its receipt at GET /platform/v1/requests/{id}: inputTokens, outputTokens, cachedTokens, and refPriceVersion, then step 3. A published price version is frozen and never changes.
  • Re-derive the row hash. The recipe production writes is recovea-chain-v1, and that is the hash on every exported row: SHA-256 over 14 parts joined with U+001F, lowercase hex. The published recovea-receipt-v1 recipe joins 17 parts — the recipe id and its 16 hashed fields in published order — under the same canonical rule, but nothing in the API writes it. Integers are base-10, null is the empty string, arrays are comma-joined.
  • Re-derive a cut. Export the period, group your rows by the step 4 rules, compare with the CSV. Any dimension whose buckets do not sum to your period total is our bug, and the correction ships as a new row, never an edit.