A finance partner asks which customer accounted for last month's model spend. The provider console shows a monthly total, a chart by model, and a breakdown by workspace. None of those is a customer. Somebody exports a CSV, joins it to a guess about which key belongs to which team, and produces a number with a caveat attached. The caveat is the honest part, and it is the reason the exercise gets repeated every month instead of answered once.
This is not a reporting failure. It is a consequence of where the cost is recorded. A provider bills the account that authenticated the request, and the account structure is the finest dimension it has. Everything your business actually runs on — a customer, a feature, a team, an agent run, an environment — exists only in the application that made the call, and only at the moment it makes it.
What a provider can and cannot tell you
Both major providers publish organization-level cost reporting, and both are more capable than their consoles suggest. It is worth knowing exactly where they stop.
| OpenAI Costs API | Anthropic Cost API | |
|---|---|---|
| Time granularity | Daily buckets only1 | Daily buckets only2 |
| Grouping | Line item, project1 | Workspace, or description2 |
| Credential | Admin key, which cannot make ordinary API calls3 | Admin key; workspace keys are refused2 |
| Customer-supplied identifier | None | None |
| Known exclusions | — | Priority Tier costs are not in the cost endpoint2 |
The usage endpoints go further than the cost endpoints. Anthropic's reports tokens in one-minute, one-hour or daily buckets and can filter and group by API key, workspace, model, service tier, context window, data residency and, in beta, speed2. That is a rich set of dimensions — and every one of them describes the request as the provider saw it, not the work it was doing.
Two details are worth carrying into any design that leans on provider-side reporting. Anthropic notes that usage from the console playground is not associated with an API key, so api_key_id is null when grouping by it, and that costs attributed to the default workspace carry a null workspace_id2. Both are small, and both put a bucket labelled "unknown" in a report somebody will ask about.
The identifier you can send is not the one you get back
Providers do ask you to send an end-user identifier. OpenAI's safety_identifier exists so that abuse can be traced to an individual end user and one user's misuse does not disrupt access for the whole organization4. It is documented for that purpose, and it is not a dimension of cost reporting: OpenAI's costs group by line item and project1.
This trips people up because the field looks exactly like the one they want. Sending it is good practice for the reason OpenAI gives. It is not a cost dimension, and building a chargeback report on the assumption that it will become one is a plan with no source behind it.
Why "one key per customer" stops working
Since keys and workspaces are the dimensions providers do report, the obvious move is to mint one per thing you want to measure. It works at ten customers and fails somewhere before a thousand, for reasons that are structural rather than a matter of effort:
- Cardinality outruns the reporting. Anthropic's own guidance, answering how to get per-user cost breakdowns, points away from splitting cost across many API keys and suggests tracking token consumption as a cost proxy instead, citing the performance limits of the alternative2.
- You only get one hierarchy. Customer, feature, team and environment are independent. A key can encode one of them cleanly, two by convention, and the third by parsing its name.
- Keys carry operational weight. Rate limits, rotation, revocation and blast radius all attach to a key. Choosing how many you have for accounting reasons means making a security decision for a reporting one.
- The dimension can change after the request. A customer moves to another plan; a feature is renamed; a team is reorganized. A key issued last quarter still says what was true then.
Keys and projects remain the right home for some dimensions. The mistake is treating them as the home for all of them.
Two kinds of dimension, and the question that sorts them
Decide this before instrumenting anything, because it is expensive to change afterwards.
Structural dimensions are low-cardinality, set when a credential or a project is created, and present on every record — including requests that were refused before they reached a provider. Project, environment, provider account and route are structural. You will group by these: they make up the rows of a monthly report.
Annotations are set per request by the application. Customer id, feature, workflow, agent step and experiment arm are annotations. They can be high-cardinality, they change without reissuing anything, and they are only present when the call that carried them was actually made. You will filter by these: they answer a question somebody asked about one thing.
The sorting question is simply: will I want the top ten of this, or will I want the total for one of them? A report that lists every row belongs in structure. A number about one customer belongs in an annotation.
What to attach to a request
The annotation should travel with the call, be recorded next to the cost, and never reach the provider. In INFRO, that is a top-level metadata object on the request body, which is stripped before anything is forwarded upstream because it describes your systems and an upstream has no business receiving it.
{
"model": "anthropic/claude-sonnet-5",
"messages": [{ "role": "user", "content": "Summarise this ticket." }],
"metadata": {
"user_id": "cus_8123",
"session_id": "sess_2f91ab",
"feature": "ticket-summary",
"tags": {
"team": "support-platform",
"env": "production"
}
}
}feature is a reserved key with its own indexed column, as are workflow, step and trace_id; requests that cannot carry a JSON body can send them as X-INFRO-Feature, X-INFRO-Workflow, X-INFRO-Step and X-INFRO-Trace-Id headers, and the end customer as X-INFRO-User-Id. A malformed reserved value is refused with 400 invalid_attribution naming the key rather than silently dropped. Anything else goes under tags.
Three conventions matter more than the exact names:
- Store stable ids, not display names. A customer's name changes and a report written against it silently splits in two. Keep the id; resolve it to a name when the report is rendered.
- Keep tag keys bounded and agreed. A small, reviewed set of keys is queryable. A set that grows per team produces a metadata document nobody can write a query against.
- Do not put personal data in an annotation. An opaque customer or account id identifies the right row. An email address turns a request log into a system that needs a different retention policy and a different review.
One practical limit: the multipart transcription endpoint takes its fields as form strings, so a structured metadata object cannot travel with it. Attribute those calls with the X-INFRO-* headers, or by key and project.
Reading it back
Structural dimensions come back as a grouped aggregate. In INFRO, GET /v1/usage groups estimated cost, request counts, token counts and latency percentiles by model, modality, project, key, endpoint, provider kind, connection, route or environment. It also groups by the reserved annotations feature, workflow, step and user, and by up to five tags you promote. Because you choose how many values those have, they return the hundred largest groups by estimated cost plus one row for the rest, and requests that carried no value come back as their own row. The console's Analytics screen offers the same groupings and shows what share of spend carries the chosen field.
curl -s "$INFRO_BASE/v1/usage?group_by=project&granularity=day\
&from=2026-09-01T00:00:00Z&to=2026-09-30T00:00:00Z" \
-H "Authorization: Bearer $INFRO_API_KEY"curl -s "$INFRO_BASE/v1/usage?group_by=feature&granularity=month\
&from=2026-09-01T00:00:00Z&to=2026-09-30T00:00:00Z" \
-H "Authorization: Bearer $INFRO_API_KEY"The request log filters on the metadata you attached — user_id, session_id, feature, workflow, step, and any tag.<key>=<value> pair, all ANDed together.
curl -s "$INFRO_BASE/v1/requests?user_id=cus_8123&feature=ticket-summary&limit=100" \
-H "Authorization: Bearer $INFRO_API_KEY"The two together are the weekly review: the aggregate says which rows moved, and the filter says what was inside the row that moved.
Where attribution stops being complete
An attribution system that does not publish its own edges will be believed further than it deserves. There are three edges, and the third is the one that surprises people.
Refusals are recorded, except the earliest ones. When a request is refused before it runs — a budget ceiling, a model policy, no connected provider for the vendor — INFRO still records it, with its key, project, connection and route, the metadata the caller sent, and a cost of zero because nothing ran, so a burst of budget refusals shows up against the feature that caused it. What leaves no record at all is a request refused before the gateway has read which model it asked for: a rate-limit 429, a body that is not JSON, a schema failure. Those show up in your own client logs and nowhere in an attribution report.
An unpriced model is not a free one. When no rate is known for a model, the cost of a served request is recorded as unknown rather than zero, and a bucket reports how many of its requests were unpriced alongside the total. A total over a bucket where some rows are unpriced is not a total, and a report that adds them in as zeros understates exactly the models nobody has priced yet.
Shared cost does not decompose cleanly. A cached system prompt serving five features, a retrieval index built once and queried by everything, an evaluation run before a release: these are real costs with no single owner. The FinOps Framework's allocation capability names this directly — organizations need a shared cost strategy, and many practitioners deliberately keep shared items in a central budget rather than spreading them, an "informed ignore" that is a decision rather than an oversight5.
- 1
custom_providerYour negotiated rate for this model on this provider kind. The only rung that can be exactly right. - 2
customYour rate for this model on any provider. - 3
listThe model vendor's published list rate. Right on the vendor's own API, approximate on a cloud reseller. - 4
unpricedNo rate on file. Recorded as unknown (null), never guessed and never shown as free.
Per request: the estimate
Per day: the provider's own figure
Report what you could not attribute
The FinOps Framework lists the ability to surface the percentage of cost that cannot be categorized and allocated as a measure of success for allocation5, because unallocated cost is where accountability disappears. AI spend makes this sharper than cloud spend does. Billing is tied to units of consumption such as tokens processed or API requests made6, so a prompt release can change the bill the day it ships, with no infrastructure change to point at.
Put the unattributed share on the report, every week, as a number:
- Estimated spend, total, with the number of unpriced requests stated beside it.
- The top rows by structural dimension: project, key, provider account, route.
- The top rows by annotation: feature, team, and the customers above a threshold you agreed.
- Spend that carried no feature tag, as a percentage — with a named owner and a date if it is rising.
- Shared costs, listed separately and by explicit decision, not spread silently.
- Variance against what the providers' own cost APIs reported for the same period.
A report whose unattributed share falls quarter on quarter is a report people start making decisions from. One that never mentions it gets quietly discounted the first time somebody checks a row. The same share belongs on the page a finance lead or a CTO reads, stated as plainly as the total; the AI spend report leadership actually reads sets out how.
When the customer id becomes a limit
An annotation recorded for a report can also be enforced. If your product passes its own customers' usage through to your provider accounts, the customer id on the request is the only thing that can stop one customer's loop from spending everyone's month — and it can only do that if it is present when the request is admitted, not added afterwards in a warehouse.
That changes the requirements on the id. A report tolerates a missing value as one more "not attributed" row; a limit has to decide what to do with a request that carries none. Decide it explicitly: serve such requests uncounted, or refuse them so a missing id is found on the first request of an integration rather than at the end of a month. Keep internal traffic with no end customer — batch jobs, evaluations — somewhere the strict rule does not apply.
How INFRO records attribution
Requests run on the provider accounts you connect, those providers invoice you directly, and INFRO charges a subscription with no share of model spend. What the control plane adds is that every request passes through one place that can record the dimension while the call is being made.
- Structural dimensions on every record. Project, key, environment, provider kind, connection and route are recorded whether the request succeeded, failed upstream or was refused before it ran, once it has named a model.
- Your annotations, kept out of the provider's hands. A top-level
metadataobject is recorded on the request and echoed back on its record, and stripped before anything is forwarded upstream. - Grouped aggregates and filtered logs from the same rows.
GET /v1/usageandGET /v1/requestsread the same records, so a chart and a reconciliation cannot disagree. Both are available on any gateway key. - The rate that produced the number, stored with it. Each record keeps the estimate as it was priced when the request ran, with its source, rather than being repriced later against a catalog that has since changed.
- Request history is a plan window. Seven days on Developer, fourteen on Startup, thirty on Team and ninety on Business, enforced on the server. Attribution you need to keep longer belongs in your own warehouse, which is what the usage and request APIs are for.
- The end-customer id is also a cap. The id in
metadata.user_id(orX-INFRO-User-Id) drives a per-customer cost view on every plan and, from the Team plan, end-customer spend caps: one rule per organization and optionally per project, with overrides your backend sets through the management API. A request that would pass its customer's cap is refused before the provider call with402and codeend_user_budget, carryingresets_atfor your app to show its user; a rule can refuse requests with no id with400 end_user_id_required, or serve them uncounted. - Project is a boundary as well as a dimension. An owner or admin can limit a developer or analyst to some projects; they then read those projects' requests, analytics, jobs and findings, and nothing organization-wide. Putting a team's work in its own project is what makes both its report and its access line up.
- The unattributed share is on the Summary. The console's Summary page shows attribution as one of four status lights, green when at least 80% of spend is assigned to a project and at least 99% of requests are priced. Owners and finance members receive a weekly summary on Mondays and a monthly report on the 2nd by default; a member limited to some projects never receives them, because they describe the whole organization.
See analytics, traces, spend controls, end-customer caps and the Summary for the request shapes and the record fields.
