← All posts
Engineering

Multi-Agent Cost Tracking: Attributing Spend Across an Agent Run

One user action becomes fifty model calls across four agent roles. Here is how to attribute that spend by role, by run and by step, reconcile it against the ledger, and put a ceiling under it that application code cannot bypass.

nRouter team · 11 min read
Multi-Agent Cost Tracking: Attributing Spend Across an Agent Run

The answer: attribute at three levels and you can always answer the question being asked. A key per agent role tells you which role is expensive, the user field per run tells you what one job cost, and the per-call x-nr-request-cost header tells you which step drove it. Budgets on the keys are the only ceiling that a buggy agent cannot argue with — org, team and user budgets refuse with 402 budget_exceeded, a per-key budget with 429 key_budget_exceeded.

Knowing what one API call costs is not hard; providers publish rate cards — OpenAI's, Anthropic's and AWS Bedrock's are all per million tokens, in and out, priced separately. The hard question is the one finance actually asks: what does one customer interaction cost us? In a multi-agent system, one interaction is fifty model calls spread across four roles, three models and two providers, with a call count that the agent decides at runtime. "Somewhere between a tenth of a cent and fifty cents, depending" is not an answer you can price a product on.

This post is the attribution machinery for that question: what breaks when you try to reconstruct it after the fact, the three layers that make it a grouping instead of an investigation, the arithmetic on a real-shaped run, the edge cases we had to decide, and what the whole thing looks like from outside.

The invoice line nobody can explain

Month end. The provider invoice says $8,400. Your product manager asks what the "Draft RFP answer" feature costs per use, because pricing depends on it.

You have three sources and none of them answer:

  • The provider console groups by API key and model. You have one key and a handful of models, so it tells you that a lot of tokens happened.
  • Your application logs record which agent ran, but not what it cost, because the cost is not a property your code ever saw.
  • Your own token counter disagrees with the invoice, because it estimated tokens from string length rather than asking the provider (Anthropic exposes a token-counting endpoint precisely because the estimate is not the number you are billed on), missed cached-token pricing, which is rated separately from ordinary input, and never saw the calls that failed after partially generating.

The failure is structural, not lazy: cost attribution has to be attached at the moment the call is made, by something that knows both the identity of the caller and the settled price. Reconstructing it afterwards from two systems that each hold half the fact is a join that never quite closes, and every team that tries builds the same reconciliation spreadsheet.

Why per-call cost does not add up to per-run cost

Even when you do have per-call costs, summing them into a run total goes wrong in four specific ways. All four are worth naming because each has a different fix.

FailureWhat it looks likeWhy it happens
Fan-out invisibleRun total is right, but you cannot say which role spent itEvery agent shares one key, so the calls are indistinguishable
Non-determinismTwo runs of the "same" job differ 10×The tool loop ran 6 times or 60; the agent decided
Silent zeroTotals look great, reality does notAn unpriced call counted as $0.00 in the accumulator
Parallel driftThe in-code total is lower than the ledgerConcurrent tasks raced on a shared float; some increments were lost

The third is the dangerous one. If a call cannot be priced and your accumulator treats "no cost reported" as zero, your run total is understated in the direction that makes you look efficient, and nothing in the code ever raises. This is exactly why the gateway omits x-nr-request-cost rather than sending 0 — an absent header forces you to handle the case; a zero lets you not notice it. The full argument is Cost Honesty: Unpriced Is Never $0 on Your LLM Bill.

The fourth is unglamorous but common: total += cost from twenty concurrent coroutines is not atomic in the way people assume once Decimal conversion and rounding get involved, and the drift is small enough to look like rounding rather than a bug.

The mechanism: three attribution layers, resolved at authentication

Attribution works because identity is resolved from the key before anything is sent upstream, and every spend record carries that identity. Nothing about who is spending comes from the request body — which is what makes the numbers trustworthy, since a caller cannot claim to be a different team by sending a header.

Three layers, each answering a different question:

LayerQuestion it answersCarried byEnforceable?
RoleWhich agent type is expensive?One virtual key per roleYes — budgets, rate limits
RunWhat did this job cost?The user field per requestNo — attribution only
StepWhich call drove the run's cost?x-nr-request-cost per responseNo — reporting only

The distinction in the last column is the one that saves you a design mistake. Keys enforce. user describes. A budget hangs off a key, a team or the organization, because those are authenticated facts. The user field is a caller-supplied string — useful for grouping, useless as a control, and never permitted to move an authorization decision. If you need a per-project ceiling, that project needs its own key. The complete treatment is LLM Cost Attribution: Keys, Teams, and the user Field.

In code, all three layers are two lines and a header read:

import os
from openai import OpenAI

# Layer 1 — one client per role, one key per role.
ROLE_KEYS = {
    "orchestrator": os.environ["NROUTER_KEY_ORCHESTRATOR"],
    "researcher":   os.environ["NROUTER_KEY_RESEARCHER"],
    "writer":       os.environ["NROUTER_KEY_WRITER"],
    "critic":       os.environ["NROUTER_KEY_CRITIC"],
}
CLIENTS = {
    role: OpenAI(api_key=key, base_url="https://api.nrouter.ai/v1")
    for role, key in ROLE_KEYS.items()
}

def call(role: str, model: str, messages: list, run_id: str):
    resp = CLIENTS[role].chat.completions.with_raw_response.create(
        model=model,
        messages=messages,
        user=f"run:{run_id}",          # Layer 2 — attribution, not authorization
    )
    h = resp.headers
    raw = h.get("x-nr-request-cost")   # Layer 3 — ABSENT when unpriced
    return resp.parse(), {
        "role": role,
        "step_cost": None if raw is None else float(raw),
        "cost_status": h.get("x-nr-cost-status"),   # "exact" | "unpriced"
        "routed_model": h.get("x-nr-model"),
        "request_id": h.get("x-nr-request-id"),
    }

step_cost is deliberately None, not 0.0, when the header is absent. Everything downstream then has to decide what to do with an unknown, which is the correct amount of friction.

Worked example: fifty calls, five roles, one run

Take one run of the "Draft RFP answer" feature. Figures below are illustrative arithmetic on one pipeline shape, shown so the reasoning is checkable — read your own from the headers and Models.

RoleCallsCost per callSubtotalShare of run
orchestrator3$0.0053$0.015910.0%
researcher31$0.0008$0.024815.7%
writer2$0.0570$0.114072.0%
critic4$0.0007$0.00281.8%
embeddings10$0.00008$0.00080.5%
Total50$0.1583100%

Read the first and third columns against each other and the whole optimization plan writes itself:

researcher    31 of 50 calls  (62%)   →  15.7% of the cost
writer         2 of 50 calls  ( 4%)   →  72.0% of the cost

The role generating almost two-thirds of the traffic is a sixth of the bill. The role generating four percent of the traffic is nearly three-quarters of it. Any effort spent making the research loop cheaper is effort spent on 15.7% of the problem — and without role-level attribution, the researcher is the obvious suspect precisely because it is loud.

Now the failure mode. Suppose a critique loop misfires and the writer redrafts nine times instead of twice:

normal run        writer 2 × $0.0570 = $0.1140   →  run total $0.1583
runaway run       writer 9 × $0.0570 = $0.5130   →  run total $0.5573

                                                     3.5× a normal run

At 4,000 runs a month, the normal shape is $633.20 of provider spend. On Pay as you go the platform fee is charged on top as a flat 4% of the credits — buying $633.20 of credits is a $658.53 charge, so the fee is $25.33. On Pro the fee is 0% for $50/mo, which does not pay for itself until the fee on your monthly spend (4% of spend) passes $50, exactly $1,250/mo of provider spend. At this volume Pay as you go is the cheaper plan, and knowing that requires exactly the per-run number this section computed. Current fees are on Pricing; the reasoning behind the fee-not-a-gate model is Every Feature on Every Plan: We Charge a Fee, Not a Gate.

Reconciling your total against the ledger

An in-code accumulator is a projection. The ledger is the record. They should agree, and checking that they do is a five-minute exercise that catches every class of bug in the table above.

sum of 50 x-nr-request-cost values          $0.1583   ← your accumulator
settled provider spend for run:abc123       $0.1583   ← the ledger
platform fee (Pay as you go, 4% of credits) $0.0063   ← charged ON TOP, at purchase
                                            --------
all-in cost of the run                      $0.1646

Three things make that reconciliation possible at all. The cost is computed once and the header, the analytics view and the ledger all read the same settled value, so nothing can disagree — see One Authoritative Cost Per LLM Request, Across Providers. The fee is on top, never deducted from credit you loaded, so provider spend and platform fee are separable lines rather than a blended rate — Markup-Free LLM Credits: The Fee Is On Top, Never In The Rate. And credits move by reserve-then-settle, so a call that failed after reserving releases rather than lingering as a phantom charge.

If the two numbers differ, the difference tells you which bug you have. Accumulator lower than the ledger: you swallowed unpriced calls as zero, or a parallel increment was lost. Accumulator higher: you counted a call that was refused before it reached a provider. Neither is ambiguous once you look. How to Read Your LLM Credit Ledger is the field guide to the ledger side.

Budgets are the only ceiling that holds

Every run-level cost guard you write in application code has the same flaw: it is application code, and the bug that made the agent loop is also in application code. A ceiling that the runaway process enforces on itself is not a ceiling.

Budgets attach at four scopes, and the status codes differ in a way that matters more than it should:

ScopeWhat it capsRefusal
OrganizationTotal spend across the account402 · budget_exceeded
TeamEvery key belonging to a team402 · budget_exceeded
UserOne member's spend inside the org402 · budget_exceeded
API keyOne key — here, one agent role429 · key_budget_exceeded

A per-key budget block arrives as 429, not 402. That is the single most important line in this post for anyone writing agent retry logic, because the default behaviour of every retry wrapper ever written is to back off and retry a 429. An exhausted agent key therefore produces a polite infinite loop that burns wall-clock and produces nothing, and the logs look like a rate-limit incident rather than a budget one. Branch on the code in the response body, never on the status alone:

STOP = {"budget_exceeded", "key_budget_exceeded", "guardrail_blocked"}

def should_retry(status: int, code: str | None) -> bool:
    if code in STOP:
        return False                      # a ceiling; retrying cannot succeed
    return status == 429 or 500 <= status < 600

The full taxonomy is 429 vs 402 on an LLM Gateway: Which to Retry, Which to Stop, and the ordering of every pre-flight gate a request passes is Credits, Budgets, Rate Limits, Guardrails: Four Pre-Flight Gates. Which control to reach for in the first place — a spend cap or a throughput cap — is Budgets vs Rate Limits: Pick the Control, Then Set Both.

Application-level run guards are still worth having. They are just the second line: they fail fast and produce a good error message, while the budget is what remains true when they do not run.

Parallel agents and the accumulator that drifts

Multi-agent systems fan out. Five researchers running concurrently is the normal case, and it breaks naive accumulation in two ways that look like rounding until you check.

import asyncio

class RunCost:
    """Accumulate per-step cost across concurrent agents, honestly."""

    def __init__(self) -> None:
        self._lock = asyncio.Lock()
        self.priced_usd = 0.0
        self.priced_calls = 0
        self.unpriced_calls = 0      # NEVER folded into priced_usd

    async def record(self, step_cost: float | None) -> None:
        async with self._lock:
            if step_cost is None:
                self.unpriced_calls += 1
            else:
                self.priced_usd += step_cost
                self.priced_calls += 1

    @property
    def is_complete(self) -> bool:
        """False means the total is a floor, not a total."""
        return self.unpriced_calls == 0

Two design choices carry the weight. The lock removes the lost-update race. And unpriced_calls is tracked separately rather than folded in, so priced_usd is never quietly wrong — when is_complete is false, the number you have is a floor, and anything that displays it should say so. A run total that silently absorbs unknowns is worse than no total, because people plan with it.

The other parallel-specific trap: a fan-out shares the run id, so user: "run:abc123" groups all five researchers together, which is what you want for the run view and not what you want for the role view. That is why both layers exist. The role split comes from the keys, the run grouping comes from user, and neither is trying to do the other's job.

Edge cases we had to decide

Each is the case, the behaviour, and the reason.

  1. When a call cannot be priced, the cost header is absent rather than 0. An absent header is a fact your code must handle; a zero is a claim that the work was free. Every downstream total is more honest for the friction, and a run that contains unpriced calls reports a floor rather than a fiction.

  2. When a step falls back to another provider, the settled cost is the serving route's. Not the requested model's. Quoting the price of a model that did not run would mis-price every run during an outage, and x-nr-model on the response is what makes the difference visible instead of mysterious. The failover mechanics are Provider Fallback Chains: Surviving an OpenAI Outage.

  3. A retry inside a fallback is one billable request, not two. Credit is reserved once per customer request and settled against whichever route served it. The alternative — reserving per attempt — would make an outage cost customers money, which is exactly backwards.

  4. The user field never authorizes anything. It is caller-supplied, so treating it as an enforcement input would let a compromised agent spend under another tenant's identity by changing a string. It groups spend; it does not grant it. Also: it lands in analytics, so put an opaque id there and never an email address or anything else you would not want retained — a caution that follows straight from the definition of personal data in GDPR Article 4.

  5. A post-response guardrail block still settles the cost. The provider generated those tokens and will bill for them. Releasing the reservation would turn a blocked response into a free request, and free requests are a hole somebody eventually drives a truck through. You are charged for work performed and do not receive content that violates your own policy.

  6. Budget refusals happen before egress, so a refused call costs nothing. There is no partial state where a request is refused and forwarded anyway. That is why a run that hits a ceiling shows the ceiling in its error and no extra spend in its ledger.

What you see from the outside

Everything above is visible through the customer surface. Nothing here requires instrumenting the gateway.

Per call, on the response:

HeaderWhat it tells you
x-nr-request-idThe id to quote when a step needs investigating
x-nr-request-costSettled USD cost — absent when unpriced
x-nr-cost-statusexact or unpriced, so absence is never ambiguous
x-nr-modelWhich model actually served this step

In the dashboard: spend by key (your role split), by model, and by user value (your run split), over a window you choose — the three questions from the top of this post, as three groupings. Analytics covers the views and API Consumers covers the user-value surface.

In the ledger: settled credit movements, the platform fee as its own line, and every reservation resolved. That is the number that reconciles to money.

What is not there: request and response content is never written to the log, so the dashboard will tell you that a step cost $0.0570 and cannot show you the prompt that made it expensive. If you need that for debugging, log it in your own store, deliberately, with the retention decisions in What an LLM Request Log Should Contain — and What to Leave Out. Forwarding logs onward to your own observability stack is available in Beta — Set Up LLM Log Callbacks: Datadog, Langfuse, S3, Slack.

Limits

Attribution is not prediction. Knowing a run cost $0.1583 does not tell you what the next one costs, because the next one may take a different path. Track a distribution, watch p95 rather than the mean, and price the product off the tail you are willing to serve.

Roles are only as clean as your key hygiene. If two agents share a key because it was faster, the role view merges them and no amount of dashboard work separates them afterwards. The key boundary is decided at design time, and the blast-radius reasoning behind that is Virtual Keys vs Master Key: Scoping a Key Per Job.

user gives you granularity, not independence. Two projects distinguished only by their user value share every ceiling. If they need separate budgets, they need separate keys — or separate teams, per Org, Team, Member: Scoping Keys, Budgets, Guardrails.

A budget stops spend; it does not degrade gracefully. When the ceiling is reached, calls are refused. If your product needs a cheaper fallback tier rather than a hard stop, that behaviour lives in your agent — the gateway will not silently downgrade a model on your behalf, because a silent downgrade is a quality change nobody consented to.

Per-customer billing is a further step. Attribution gives you the cost of serving a customer. Turning that into an invoice line, with margin and rounding decisions, is Per-Customer LLM Billing for AI Apps.

Try it

Instrument one pipeline in an afternoon. Issue a key per agent role, add one user= argument carrying a run id, and log three header values per call.

curl -i https://api.nrouter.ai/v1/chat/completions \
  -H "Authorization: Bearer $NROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-5.4-mini",
    "messages": [{"role":"user","content":"attribution smoke test"}],
    "user": "run:smoke-001"
  }'

Read x-nr-request-cost, x-nr-cost-status and x-nr-request-id off the response. Then run one real job, sum the priced calls, count the unpriced ones separately, and compare your total against the ledger for the same window. If they match, your attribution is sound and every later question is a filter. Then set a budget on the noisiest role with budget controls and prove it bites by driving one key past its cap — you should see 429 with key_budget_exceeded, not a retry loop.

No account yet? Sign up — a card is required and a $5 minimum charge is taken, with the platform fee on top. Questions belong in the community.

See also

Sources

Verified 2026-08-23. Call counts, per-call costs and run totals in the worked example are illustrative arithmetic for one pipeline shape, shown so the reasoning is checkable rather than quoted as a rate card; the platform-fee percentages and the Pro price are from Pricing and current model rates are on Models. Corrections to hello@nrouter.ai and we will update.

OpenAI, Anthropic, AWS and the OpenTelemetry project are trademarks or projects of their respective owners. nRouter is not affiliated with or endorsed by them.

Share
Written by nRouter teamEngineering, product, and company posts from the nRouter team — code-first, cost-honest, no vendor-marketing fluff.