# Attributing AI cost to customers, features and teams

URL: https://infro.io/blog/llm-cost-attribution
Author: INFRO Editorial (https://infro.io/blog/editorial)
Publisher: INFRO
Published: 2026-09-20
Last modified: 2026-10-13
Last reviewed: 2026-10-13 (next review due 2026-11-12)
Topic: Cost and FinOps
Format: Guide

> Provider cost APIs report daily totals grouped by workspace, project or key, and carry no customer-supplied identifier. To attribute AI spend to a customer, a feature or a team you have to record the dimension at the moment the request is made. Put low-cardinality dimensions you will group by into structure — projects, keys, environments — and high-cardinality ones you will filter by into per-request annotations, then track the share of spend you could not attribute at all.

Key takeaways:
- Provider cost APIs report at daily granularity grouped by workspace, project, line item or key — never by an identifier you attached to a request.
- The end-user identifier providers ask you to send is for abuse detection, not for billing, and is not a grouping in their cost reporting.
- Decide before instrumenting which dimensions you will group by and which you will filter by; the first belong in account structure, the second in per-request metadata.
- One key per customer does not scale as an attribution strategy, and providers say so themselves.
- Requests refused before their body is read, unpriced models and shared prompts are the three places attribution quietly stops being complete.
- Report the share of spend you could not attribute. It is the number that says whether the rest of the report is worth reading.

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 only[1] | Daily buckets only[2] |
| Grouping | Line item, project[1] | Workspace, or description[2] |
| Credential | Admin key, which cannot make ordinary API calls[3] | Admin key; workspace keys are refused[2] |
| Customer-supplied identifier | None | None |
| Known exclusions | — | Priority Tier costs are not in the cost endpoint[2] |

Table: Provider cost reporting as documented on 2026-10-13. Both also publish finer usage endpoints that report tokens rather than money.

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, speed[2]. 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_id`[2]. 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 organization[4]. It is documented for that purpose, and it is not a dimension of cost reporting: OpenAI's costs group by line item and project[1].

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 alternative[2].
- **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.

> **Note: Cardinality is a cost, not just a design taste**
> Grouping by a dimension means the store holds one row per combination per bucket. Five features across three environments and two models is thirty rows an hour. Add a customer id with ten thousand values and it is three hundred thousand. That is why customer id belongs on the request rather than in the group-by — you filter to the one customer who is being discussed, and you never materialize the cross-product.

## 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.

```json
{
  "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.

```bash
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"
```

```bash
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.

```bash
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.

> **Example: A monthly allocation, with illustrative figures**
> A support product runs three features across two teams. Structure carries the team, annotations carry the feature and the customer.
> 
> | Feature | Team | Estimated provider cost | Share |
> | --- | --- | --- | --- |
> | Ticket summary | Support platform | $4,120 | 47% |
> | Reply drafting | Support platform | $2,980 | 34% |
> | Knowledge search | Search | $1,190 | 14% |
> | Not attributed | — | $440 | 5% |
> 
> The last row is the important one. It is what ran without a feature tag: a backfill script, a developer's one-off, an evaluation run before a release. Reporting it is what makes the other four rows credible.

## 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 oversight[5].

> **Warning: Check which rate the number was computed at**
> Attribution divides an estimate; it does not improve it. If a negotiated rate has not been entered, every row in the report is proportionally wrong, and the chargeback is confidently incorrect rather than approximately right. Each INFRO request record, and the `usage` object of each response, carries a `price_source` saying whether the rate came from your own rate for that provider, your rate for any provider, or the vendor's published list rate — or `provider` when the provider reported the request's cost in its own response. Before a request, `POST /v1/estimates` answers with the same field; [estimating cost before the request](https://infro.io/blog/estimate-llm-cost-before-request) covers what that figure can and cannot know.

[Diagram: Where the rate behind an attributed cost comes from. A report is only as good as the rung it was computed on.]

## 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 allocation[5], 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 made[6], 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](https://infro.io/blog/ai-spend-report-for-leadership) 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 `metadata` object 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/usage` and `GET /v1/requests` read 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` (or `X-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 with `402` and code `end_user_budget`, carrying `resets_at` for your app to show its user; a rule can refuse requests with no id with `400 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](https://infro.io/docs/observability/analytics), [traces](https://infro.io/docs/observability/traces), [spend controls](https://infro.io/docs/org/spend-controls), [end-customer caps](https://infro.io/docs/org/end-customer-caps) and the [Summary](https://infro.io/docs/observability/summary) for the request shapes and the record fields.

## Sources

1. How to use the Usage API and Cost API to monitor your OpenAI usage — OpenAI. https://developers.openai.com/cookbook/examples/completions_usage_api (accessed 2026-10-13)
2. Usage and Cost API — Anthropic. https://platform.claude.com/docs/en/manage-claude/usage-cost-api (accessed 2026-10-13)
3. Admin API keys — OpenAI. https://developers.openai.com/api/docs/guides/admin-apis (accessed 2026-10-13)
4. Safety best practices — OpenAI. https://developers.openai.com/api/docs/guides/safety-best-practices (accessed 2026-10-13)
5. Allocation — FinOps Framework capability — FinOps Foundation. https://www.finops.org/framework/capabilities/allocation/ (accessed 2026-10-13)
6. FinOps for AI Overview — FinOps Foundation. https://www.finops.org/wg/finops-for-ai-overview/ (accessed 2026-10-13)

## Revision history

- 2026-10-13: Corrected OpenAI's cost grouping to line item and project, and the claim that refused requests lose their metadata: they keep it once the gateway has read the body. Added where the end-customer id also becomes a spend cap, how project access follows the same structure, the summary and monthly report's attribution check, and that chat responses now carry price_source.
