Attribution & tagging
Entities answer who to bill. Tags answer what the call was for.
An invocation with no attribution costs the same as one with it. You just can't tell who it was for. Attribution is the thing that turns a single inference bill into per-customer numbers, and it's one field on each line of a batch.
Entities and tags do different jobs
Entities are the things you bill or budget: a user, a team, a customer, a workspace. They are the axis usage and limits work on.
Tags are free-form labels describing the call itself: which feature, which environment, which prompt version. They're for slicing and searching, never for budgets.
The practical test: if you'd ever want to cap it or invoice it, it's an entity. If you'd only want to group a report by it, it's a tag.
Entity format
An entity is type:id. The type is whatever you decide. Lararouter doesn't hold a fixed enum, so
user, team, customer, workspace, and tenant are all equally valid.
{ "metadata": { "entity": "user:usr_8421, team:team_acme" } }Attach up to five entities to one invocation. Every one of them independently accumulates the cost of that call, which is what makes overlapping hierarchies work: the same $0.004 counts toward the user's total and the team's total, without double-charging you.
Use stable identifiers
Attribute to a primary key, not an email address or a name. Usage is grouped by the exact string you send, so a user who changes their email splits into two entities and the earlier spend becomes unreachable.
Tag format
Tags are key=value, comma-separated. Keys are limited to 64 characters and values to 256.
{ "metadata": { "tags": "feature=support-summary, env=production, prompt=v3" } }Tagging prompt=v3 is the cheapest way to answer "did the new prompt cost more?" later. Group usage
by tag and compare.
Three places to set it
Attribution can be set at three levels, and the most specific one wins.
| Level | Where | Applies to |
|---|---|---|
| Per line | metadata on a JSONL line | That one invocation |
| Per batch, body | metadata on POST /v1/batches | Every line without its own |
| Per batch, header | X-Lararouter-* on POST /v1/batches | Every line without its own, and beats the body |
Per-line metadata is the useful one, because a single job usually carries work for thousands of different customers.
{"custom_id":"ticket_4821","method":"POST","url":"/v1/chat/completions","body":{"model":"llama-3.3-70b","messages":[{"role":"user","content":"Summarize."}]},"metadata":{"entity":"user:usr_8421, team:team_acme","tags":"feature=support-summary, env=production"}}The batch-level default covers the case where the whole job belongs to one thing: a nightly re-index, an internal backfill:
curl https://REGION.api.lararouter.com/v1/batches \
-H "Authorization: Bearer $LARAROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input_file_id": "file_9Hs4TnQ2",
"endpoint": "/v1/chat/completions",
"metadata": {
"entity": "team:team_acme",
"tags": "job=nightly-summaries, env=production"
}
}'Header beating body at the batch level is deliberate: it lets middleware stamp env= or a tenant over
whatever application code supplied. Per-line metadata beats both, because it's the only one that can
say something different for each invocation.
Attribute centrally, not at call sites
The failure mode with attribution isn't getting the format wrong, it's forgetting it on one code path and discovering three months later that a chunk of spend is unattributed. Pass the API key and the default attribution headers on every request, and set the per-line entity in the same place you build the line.
use Illuminate\Support\Facades\Http;
$user = auth()->user();
Http::withToken(config('services.lararouter.key'))
->withHeaders(array_filter([
'X-Lararouter-Entity' => $user ? "user:{$user->id}, team:{$user->team_id}" : null,
'X-Lararouter-Tags' => 'env='.app()->environment(),
]))
->post('https://REGION.api.lararouter.com/v1/batches', $payload);Batches are almost always built inside queued jobs, where there is no auth()->user(). Read the
entity off the record you're processing ($ticket->user_id, not the current session) and write it
into the line as you build the file.
Finding what you missed
Group usage by entity type and look for the gap. Anything with no attribution is reported under the
unattributed key:
curl -G https://REGION.api.lararouter.com/v1/usage/entities \
-H "Authorization: Bearer $LARAROUTER_API_KEY" \
-d "start=2026-07-01" \
-d "group_by=entity_type"{
"object": "usage",
"totals": { "cost_usd": 4182.55, "invocations": 918442 },
"groups": [
{ "key": "user", "cost_usd": 3902.11, "invocations": 861200 },
{ "key": "team", "cost_usd": 3902.11, "invocations": 861200 },
{ "key": "unattributed", "cost_usd": 280.44, "invocations": 57242 }
]
}user and team showing the same total isn't a bug. Every invocation carried both, and each entity
accumulates the full cost of the calls attributed to it. Adding groups together across entity types
will overcount; compare within a type instead.