Lararouter
Concepts

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.

LevelWhereApplies to
Per linemetadata on a JSONL lineThat one invocation
Per batch, bodymetadata on POST /v1/batchesEvery line without its own
Per batch, headerX-Lararouter-* on POST /v1/batchesEvery 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.

requests.jsonl
{"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.

On this page