What this guide covers

This is for teams shipping an AI feature to more than one paying customer, where the model calls happen inside your own application rather than being billed directly to the customer's provider account. It assumes you have agents — multi-step runs with tool calls — rather than a single request-response, because that is where naive accounting falls apart fastest.

The guide covers the schema, the instrumentation using the OpenTelemetry GenAI semantic conventions, the propagation problems that cause silent gaps, the maths of turning tokens into money correctly, and the four reports that justify the whole exercise. It is deliberately implementation-level; the strategic case for unit economics is covered separately in our guide to cost per task and margin.

Why agents make this harder than it sounds

With a simple chat feature, one user message produces one model call, and attribution is close to trivial. With an agent, one user action fans out. A single "summarise this account and draft a follow-up" might produce a planning call, four tool calls each with their own model invocation, a retrieval step, two retries after a malformed response, a summarisation pass over the accumulated context and a final generation. Nine or more calls, several models, wildly different token volumes.

Three consequences follow, and each breaks a common assumption.

  • Cost per tenant is heavily skewed. Tenant spend distributions in agent products are consistently long-tailed: a small fraction of customers drives a large fraction of consumption. Averages are actively misleading here. If your pricing is built on a mean cost per customer, it is built on a number that describes almost none of them.
  • The expensive part is rarely the part you would guess. Retries, failed tool calls and context re-sends frequently outweigh the "real" generation. A team that optimises the final answer model while an unbounded retry loop burns four times as much has optimised the wrong thing, and without call-level data they cannot tell.
  • Aggregating too early destroys the answer. If you record one row per user turn with total input and output tokens, you can never afterwards ask which step was expensive, which model was involved, or whether the cache was hit. That question always arrives later, and re-instrumenting is far more work than recording properly the first time.
Watch out

Cheaper per-token pricing does not protect you. Across 2026 blended token prices fell substantially, and a widely-reported finding is that around 73 per cent of enterprises still exceeded their AI cost projections — because agentic workflows consume multiples of what a chat-era spreadsheet assumed. Falling unit prices make attribution more important, not less, because they encourage exactly the volume growth that hides the problem.

The four token buckets in an agent turn

Collapsing everything into "input" and "output" is the most common modelling error, because it makes the four genuinely different kinds of consumption indistinguishable. Record them separately.

Bucket What it is Typical share What you do about it
System and instruction Static prompt, tool schemas, style blocks — sent on every call Large and constant Cache it; this is where caching pays most
Context and memory Retrieved documents, conversation history, agent scratchpad Grows through the run Compact and prune; cap the working set
Tool traffic Tool definitions plus tool results fed back in Often underestimated Trim results before they re-enter context
Generation What the model actually produces, including reasoning tokens Smallest by volume, priciest per token Right-size the model and the effort level

Reasoning tokens deserve a specific note. They are billed as output on most providers, they are frequently invisible to the caller, and on a reasoning-heavy task they can dominate the bill. The OpenTelemetry GenAI conventions define gen_ai.usage.reasoning_tokens for precisely this reason. If your accounting does not have a column for them, you have an unexplained variance you will spend a week chasing.

The schema

One row per model call. Not per turn, not per session. Storage is cheap; lost dimensions are not.

CREATE TABLE llm_call_ledger (
  id              BIGSERIAL PRIMARY KEY,
  occurred_at     TIMESTAMPTZ  NOT NULL,

  -- WHO (attribution keys — all mandatory, never nullable)
  tenant_id       TEXT         NOT NULL,
  user_id         TEXT,                     -- null for system-initiated work
  plan_code       TEXT         NOT NULL,    -- snapshot; plans change

  -- WHAT (product dimensions)
  feature         TEXT         NOT NULL,    -- 'inbox_triage', 'report_draft'
  task_type       TEXT         NOT NULL,    -- 'plan' | 'tool' | 'generate' | 'retry'
  agent_run_id    TEXT         NOT NULL,    -- groups calls in one user action
  trace_id        TEXT,                     -- links to your OTel traces

  -- HOW (provider dimensions)
  provider        TEXT         NOT NULL,
  model           TEXT         NOT NULL,
  is_batch        BOOLEAN      NOT NULL DEFAULT FALSE,

  -- TOKENS (never collapse these)
  input_tokens            INTEGER NOT NULL,
  cached_input_tokens     INTEGER NOT NULL DEFAULT 0,
  cache_write_tokens      INTEGER NOT NULL DEFAULT 0,
  output_tokens           INTEGER NOT NULL,
  reasoning_tokens        INTEGER NOT NULL DEFAULT 0,

  -- MONEY (computed at write time, in micro-units to avoid float drift)
  cost_micros     BIGINT       NOT NULL,
  price_version   TEXT         NOT NULL,    -- which rate card was applied

  -- OUTCOME (this is what makes retries visible)
  finish_reason   TEXT,                     -- 'stop' | 'length' | 'tool_calls' | 'error'
  succeeded       BOOLEAN      NOT NULL
);

CREATE INDEX ON llm_call_ledger (tenant_id, occurred_at DESC);
CREATE INDEX ON llm_call_ledger (agent_run_id);
CREATE INDEX ON llm_call_ledger (feature, occurred_at DESC);

Four fields in there are the ones teams leave out and regret. plan_code is snapshotted because plans change and you need to know what the customer was on at the time. price_version records which rate card produced the cost, so a provider price change does not silently rewrite your history. succeeded and finish_reason are what let you separate useful spend from waste. And agent_run_id is what lets you compute cost per completed user action rather than cost per call, which is the number that actually matters.

Pro tip

Store money in integer micro-units of your accounting currency, not floats. At $0.75 per million tokens you are routinely computing values in the seventh decimal place, and floating-point accumulation over millions of rows produces reconciliation differences against your provider invoice that are genuinely painful to explain to a finance team.

Instrumenting with the OpenTelemetry GenAI conventions

Do not invent your own attribute names. The OpenTelemetry GenAI semantic conventions define a standard gen_ai.* namespace, they are supported by Datadog, Honeycomb and New Relic among others, and frameworks including LangChain, CrewAI and AutoGen emit conforming spans natively or through instrumentation packages. Using the standard means your data is portable between observability vendors, which is worth a great deal the first time you change one.

The attributes that matter for cost work are gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.usage.reasoning_tokens and gen_ai.response.finish_reasons. Your tenant dimensions are not part of the standard, so add them under your own namespace.

from opentelemetry import trace, context
from opentelemetry.baggage import get_baggage, set_baggage

tracer = trace.get_tracer("agent.llm")

def record_call(*, feature, task_type, agent_run_id,
                provider, model, response):
    tenant_id = get_baggage("tenant.id")
    if not tenant_id:
        # Fail loudly. Silent unattributed spend is the whole problem.
        raise RuntimeError("no tenant in baggage at LLM call site")

    u = response.usage
    with tracer.start_as_current_span("gen_ai.chat") as span:
        # Standard GenAI attributes
        span.set_attribute("gen_ai.system", provider)
        span.set_attribute("gen_ai.request.model", model)
        span.set_attribute("gen_ai.usage.input_tokens", u.input_tokens)
        span.set_attribute("gen_ai.usage.output_tokens", u.output_tokens)
        span.set_attribute("gen_ai.usage.reasoning_tokens",
                           getattr(u, "reasoning_tokens", 0))
        span.set_attribute("gen_ai.response.finish_reasons",
                           [response.finish_reason])

        # Your attribution dimensions
        span.set_attribute("app.tenant.id", tenant_id)
        span.set_attribute("app.plan.code", get_baggage("plan.code"))
        span.set_attribute("app.feature", feature)
        span.set_attribute("app.task_type", task_type)
        span.set_attribute("app.agent_run_id", agent_run_id)

        cost = price(model, u, is_batch=False)
        span.set_attribute("app.cost_micros", cost.micros)
        ledger.insert(tenant_id=tenant_id, feature=feature, ...)

Note the exception on a missing tenant. That single line is the most valuable defensive measure in the whole system. The alternative — defaulting to "unknown" — produces a bucket that grows quietly for months until it is twelve per cent of your spend and nobody can reconstruct where it came from.

The propagation problem, which is where this usually breaks

Attribution is easy inside a request handler and hard everywhere else. Four places consistently leak.

  1. Background jobs and queues. Request-scoped context does not survive into a worker process. You must serialise the tenant identifier into the job payload at enqueue time and re-establish it at the top of the worker. Nightly summarisation and scheduled report generation are pure agent spend and, in the teams I have seen get this wrong, are usually the largest unattributed block.
  2. Sub-agents and parallel branches. If your framework spawns concurrent work, the child needs the parent's baggage. Most async runtimes handle this; thread pools and process pools generally do not. Test it explicitly rather than assuming.
  3. Retries and error paths. A retry after an exception often runs through a different code path with a fresh context. Retries are frequently the most expensive thing your agent does, so losing them is losing the finding.
  4. Shared and internal work. Some spend genuinely is not attributable to one tenant — embedding a shared knowledge base, a cache warm, an internal evaluation run. Give it an explicit synthetic tenant such as _platform_shared. Naming it is the point; it stops shared cost being invisible and it stops it being wrongly allocated.
Recommended

Add a daily reconciliation job that compares the sum of cost_micros in your ledger against the provider's reported spend for the same period, and alerts when the gap exceeds two per cent. This one check catches every category of propagation bug, plus rate-card drift, plus the calls made through a code path nobody instrumented. It is roughly thirty lines and it is the difference between a ledger you trust and a ledger you argue about.

Turning tokens into money correctly

Pricing is not one number per model, and treating it as one produces figures that never reconcile. A correct rate card needs at least five dimensions.

Dimension Why it matters
Input versus output rate Output is commonly four to five times input; the ratio varies by model
Cache read rate Often a small fraction of the base input rate — ignoring it overstates cost badly
Cache write rate Usually a premium over base input; ignoring it understates cost
Batch discount Asynchronous batch tiers are materially cheaper and must be a separate rate
Effective date Prices change on announced dates; historical rows must keep their original rate

Keep the rate card in version control as data, not in code as constants, and stamp every ledger row with the version that priced it. When a provider changes a price — and several are scheduled to, including introductory rates with published expiry dates — you add a new version rather than mutating the old one, and your historical margin analysis stays intact.

Two adjacent levers change these numbers substantially once you can see them: prompt caching on the static prefix, and the batch API for anything that does not need to be synchronous. Attribution is what tells you which tenants and features they would actually help.

Running the cost engineering on a real AI product? That is a scarce skill.

AI Tech Connect lists AI engineers, founders and researchers across India and the UK — and the people hiring browse it to find them. Adding your profile is free.

Become a Verified Builder →

The four reports that pay for the work

The ledger is a means. These are the ends, and if you build the schema above, all four are single queries.

One: margin by tenant. Monthly cost against monthly revenue, per customer, sorted by margin ascending. The first time you run this you will find at least one customer whose consumption exceeds what they pay. That is not a failure; it is the finding, and it is actionable in three specific ways — route their traffic to a cheaper tier, upsell them to a plan that reflects their usage, or apply a hard cap. Without the number you cannot choose between them, and you will default to doing nothing.

Two: cost per completed action, by feature. Group by agent_run_id, sum cost, join to whether the run succeeded. This gives you cost per successful outcome and, separately, the amount you spend on runs that fail. The failed-run figure is usually far larger than teams expect the first time they see it, and it is the fastest optimisation available to most of them.

Three: waste breakdown. Filter to succeeded = false or finish_reason = 'length' and group by feature and model. Truncated generations and retry storms hide here. A length-based stop in particular is a silent corruption risk as well as a cost one, because the truncated output often flows into a downstream step.

Four: concentration. What share of total spend comes from your top one per cent of tenants, and your top ten? Track this monthly as a single figure. When it rises, either a customer's usage has changed shape or someone has found a way to make your agent loop expensive. Both are worth knowing about within days rather than at the end of the quarter.

From a verified Builder

"Teams add tenant tagging expecting to find one heavy customer. What they usually find first is failed runs — agent attempts that errored or truncated and were never successfully retried, being paid for every day and invisible in the aggregate. That is the finding that makes the attribution work pay for itself, and it has nothing to do with the customer you were originally worried about."

— PremKumar, Verified Builder · Chennai, India

Turning the numbers into policy without punishing customers

Finding an unprofitable tenant is the easy part. Deciding what to do about it without damaging the relationship is where most teams stall, and the stalling is usually because the options were never written down in advance.

Write the policy before you need it, at the plan level rather than the customer level. A rule that applies to everyone on a plan is defensible; a rule invented for one customer who became expensive is a negotiation you will lose. A workable ladder has four rungs, applied in order.

  1. Route, do not restrict. The first response to an expensive tenant should be invisible to them: move their traffic to a cheaper model tier for the task families where quality holds. Your attribution data tells you which families those are. Most of the time this closes the gap entirely and the customer notices nothing.
  2. Soft-limit with visibility. Expose a usage counter in the product and warn at eighty per cent of the plan allowance. Customers who can see their consumption almost always moderate it themselves, and the ones who do not have just qualified themselves as an upsell.
  3. Upsell on evidence. "You are running four times the usage of a typical account on this plan, here is the data, here is the tier that fits" is a straightforward commercial conversation when you have the numbers. It is an accusation when you do not.
  4. Hard cap last. A cap that stops work mid-run is the worst customer experience in the ladder and should be the final resort, reserved for abuse rather than enthusiasm. If you do implement one, cap at the agent-run boundary rather than mid-run, so the failure is a clear refusal rather than a half-finished task.

One caution on plan-level policy: snapshot the plan code on every ledger row, as in the schema above, and evaluate policy against the plan the customer was actually on at the time. Applying today's rules to last quarter's usage produces conclusions that are both wrong and unfair, and it is a mistake that is very easy to make once the reporting is a few joins deep.

Buy or build

There is a healthy tooling ecosystem here — Helicone, Langfuse, Traceloop, OpenMeter and Portkey on the observability and metering side, with FinOps platforms handling the roll-up into business dimensions. Any of them will get you further, faster, than a from-scratch build.

The part you cannot outsource is the tenant dimension. No external tool knows which of your customers made a call; only your code does. So the realistic split is: use a platform for collection, storage, tracing and dashboards, and own the propagation of tenant_id, feature and plan_code to every call site. That is a day of work and it is the part that determines whether the tool tells you anything useful.

Avoid

Do not issue a separate provider API key per tenant to get attribution from the provider's dashboard. It appears elegant and fails badly: it fragments your rate-limit headroom, breaks connection pooling, complicates key rotation, and stops scaling somewhere around a few dozen tenants. Attribute in your own code, where tenant identity and token counts already exist in the same scope.

A sequence that works

  1. Day one: add tenant_id, feature and model to every model call, with a hard failure when the tenant is missing. Log token counts and a computed cost. This alone answers the concentration question.
  2. Week one: add agent_run_id and succeeded. You can now compute cost per completed action and see waste.
  3. Week two: split the token buckets, including cached and reasoning tokens, and version the rate card. Your numbers now reconcile against the provider invoice.
  4. Week three: add the daily reconciliation alert and the four reports. Put the concentration figure somewhere the whole team sees it.
  5. Ongoing: review margin by tenant monthly alongside the pricing conversation, not separately from it.

The general principle is worth stating plainly, because it applies well beyond inference cost. Cost data without dimensions is reporting: it tells you what happened and gives you nothing to do about it. Cost data with dimensions is leverage: it tells you which customer, which feature and which failure mode, and every one of those is a decision you can make on Monday.

Reference: the OpenTelemetry GenAI observability conventions. Related reading on isolation in shared serving is in our guide to multi-tenant RAG and LLM serving, and the tracing foundations are covered in agent observability with OpenTelemetry.