Cost attribution and margin
Record what each model call costs with trace() and track(), read the margin per run, and pick an integration pattern.
Prices and guardrails need costs to compare against. This guide shows how cost reaches Runledger, how reversals change the margin you report, and which integration pattern fits where your costs are known.
Every amount in the examples is illustrative, and so are the providers (Vidlab, Textco) and the catalog entries shown: none is a real provider price.
Before you start
- You know the parts of pricing from cost and who controls each variable.
- Your code already calls
check()andtrack(), and you have a cost model for each model you call.
Cost attribution with trace() and track()
trace() takes usage_lines for units that aren’t tokens. Each line has a unit, a quantity and the dimension values that set its rate:
When an active cost model matches the call’s provider and model, Runledger prices every line from its rate card, applies the cost model’s overrides, and records which one priced it, so a cost can always be traced back to the rates behind it. Here the line was priced by the catalog price version unchanged (rate_source: "catalog"). A line priced by an override says "rate_source": "override" and names it in override, and a custom cost model’s lines say "custom":
Token usage goes in usage, as usual, and a call can send both for different units. A prompt of 1,840 input tokens and 512 output tokens on textco-large at the list prices in the override example costs 0.552 + 0.768 = 1.32. priced_by can also be catalog. Without a matching cost model, a call on a model the catalog has is priced at the catalog’s list price, with priced_by: price_table and the warning catalog_model_available naming the catalog model, so you or an agent can create the cost model. For a model the catalog doesn’t have, no cost is worked out, and a cost you send yourself is kept as you sent it (priced_by: request).
On the revenue side, track() carries what the customer chose in properties, and a price rule reads it. The response says how the event was priced:
The same run_id on check(), track() and trace() joins revenue to cost, and a run has a margin once it has a confirmed charge and a traced cost. POST /v1/events/preview returns the same pricing block without recording anything, so an agent can test a rule before any integration code changes.
Preview a price
By default a preview prices the event with the rule its plan rate uses. To price it with a rule that no plan rate uses yet, name the rule with price_rule, and optionally a version with price_rule_version:
The response carries the same pricing block as above, and price, in the customer’s balance unit. For eight seconds of 1080p on the rule above, pricing.cost is "96" and pricing.price is "134.4". Without price_rule_version, the version in force at the event’s timestamp is used. The rule’s currency must match the customer’s plan. An unknown price_rule returns 404.
The same call gives a workflow an upfront estimate. Before an AI job runs, preview the event it would send and show “this will use about 12 credits”, with price in the customer’s balance unit. track() prices the event it receives, so the estimate is a preview and not a promise. The preview doesn’t say whether the customer’s balance covers it. To ask that, send a check(): its affordability block gives the balance, pools, allowances and spend limits before and after, and the check places no hold unless you send hold: true.
Currencies
A cost is recorded in the currency of the rate card that priced it, and a price rule works in one currency. The rule’s currency must match its rate cards and the plans that use it, and Runledger doesn’t convert between currencies. Revenue and cost in different currencies are never summed: the margin report and a simulation each cover one currency, so a product that sells and spends in two currencies reads two reports.
Read the margin of runs
GET /v1/runs/{run_id} returns one run’s revenue, cost and margin, and GET /v1/runs lists runs, newest first.
A run has a margin only with a confirmed charge and a traced cost. has_margin says which. Pending and reversed charges don’t count as confirmed: a run whose charges are all pending has no margin yet, and when a reversal removes the only confirmed charge, the run drops back to no margin. The cost stays recorded. Without a margin, margin and margin_percent are null, and the run still shows its revenue_pending, revenue_reversed and cost.
The same run while its outcome charge is still pending has no margin. margin and margin_percent are null, and revenue and cost still show what is known:
The same holds on GET /v1/runs and GET /v1/runs/{run_id}: whenever has_margin is false, margin and margin_percent are null, whichever of the confirmed charge or the traced cost is missing.
GET /v1/runs filters by customer_id, outcome_definition, from, to and has_margin. has_margin=true returns only runs with a margin, and has_margin=false only runs without one.
Totals across runs
GET /v1/runs/summary takes the same filters and totals all the runs that match, not a page of them:
How runs without a margin count. The money totals add up only the runs that have a margin, so margin is always revenue_confirmed minus cost. A run with no confirmed charge, or with no traced cost, adds nothing to revenue_confirmed, cost or margin, whichever of the two it lacks. That includes the cost of a run whose revenue is still pending or was reversed. Those runs are counted in runs and runs_without_margin, which is how you see how many were left out. With has_margin=false, every matching run is without a margin, so the totals are "0" and margin_percent is null. For margin that includes cost with no revenue, use the margin report, which groups by customer, model or outcome definition.
A summary is in one currency, and amounts are never converted or added across currencies. Send currency, or leave it out when every matching run is in one. If the matching runs are in more than one, the call returns 400 with parameter_invalid on currency.
Add group_by=day, week or customer to get the same totals as rows for a chart: margin over time, or margin by customer. A row with no run that has a margin has has_margin: false and a null margin. Reports and charts shows the response.
Margin after reversals
The margin report shows margin as confirmed revenue minus cost, and shows pending and reversed revenue separately, because pending revenue may still reverse. A reversal removes the revenue of an outcome charge. It never changes a cost already recorded, because the model call happened either way. So for an outcome that sells at a fixed price while the cost of each run varies, a reversed outcome shows up in the margin report as cost with no revenue, which a customer or an outcome definition includes. On a single run, a reversal that removes the only confirmed charge leaves the run without a margin (has_margin: false), with its cost still recorded.
Nothing on this page changes that arithmetic. Expected margin at check() time doesn’t try to predict reversals, and a simulation keeps recorded reversals as they were. A realized guardrail and the margin report are where reversals show.
Integration patterns
Attribute cost from your own code
Call trace() after each model call with the provider, the model, the usage and the run_id. Use a stable idempotency_key built from your own data, such as the provider’s request ID, so a retry lands on the same cost. check() and track() carry the same run_id, and the customer’s choices in properties.
Ingest usage from a gateway or a provider export
If your model calls already pass through a gateway or proxy that logs usage, or you can export usage from a provider, a small forwarder can turn each record into an item of POST /v1/costs/batch, up to 100 at a time. Each item succeeds or fails on its own, and the idempotency_key deduplicates for 35 days, so a replayed log doesn’t double-count.
The forwarder is yours: Runledger doesn’t provide adapters for specific gateways. What it needs from the gateway is a request ID, the provider and model names as the cost models spell them, the usage, and a way to carry your run_id and customer.
Use the catalog’s prices
Create a cost model with catalog_model for each provider model you call, and trace() the usage. There are no rates to enter and none to keep current: Runledger prices every line from the catalog price version in force when the call happened. This is the quickest setup, and the right start if you pay list price.
Bring your own rates
If you pay less than list, add overrides to the catalog cost model: a discount or a replacement rate, with the dates and the contract it comes from. If you run your own GPUs, or a provider isn’t in the catalog, create a custom cost model with a rate card of your own. Both kinds of cost model work together in one run, so a request that calls a catalog model and a self-hosted one is priced from each.
Report a cost you already know
If your system already computes what a call cost, send it as cost and skip the cost models. Runledger records it as you sent it (priced_by: request), and margin reporting works the same way. Cost models are for when you want Runledger to work the cost out, version the rates, and price from them.
Set it up with an agent
A coding agent can set this up in the sandbox the same way it sets up everything else, and propose the live change for a person to approve. The MCP server has three tools for this, search_provider_catalog, get_cost_catalog and simulate_pricing, and validate_config, apply_config, propose_live_config and preview_event cover the new objects. The order:
get_cost_catalogandget_configshow what exists.- The agent finds the providers, models and units your code calls, and uses
search_provider_catalogto match each one to a catalog model. It drafts a cost model withcatalog_modelfor every match, so it doesn’t enter or guess any prices. For a model the catalog doesn’t have, it drafts a custom cost model and asks you for the rates and where they came from. - The agent asks whether you pay list price. You supply any negotiated, committed or private rates and the contract behind them, and the agent adds them as overrides. It doesn’t assume a discount.
- The agent asks you to confirm the markup or target margin, the floor, and whether a guardrail may block. It suggests
warn. validate_configreturns the changes and warnings. The agent shows you every warning.apply_configwrites the sandbox.preview_eventshows thepricingan event would get, and takesprice_ruleandprice_rule_versionto price it with a rule no plan rate uses yet.simulate_pricingreplays recent usage against the setup.- For live,
propose_live_configsends the same document with a reason, and you approve the diff in the dashboard.
Handle a provider price change
For a catalog cost model, listen for catalog.price_changed (or read GET /v1/catalog/prices?changed_since=...), read the scheduled rate card’s changes and affected_price_rules, run a simulation with the new catalog_price, and decide whether the price rules hold or follow. For a custom cost model, add the rate card yourself. Provider price changes and simulation walks through both.
API reference
Trace a model call, list runs, summarize runs, the margin report and the cost report.
Next steps
- Price rules and margin guardrails use the margin this guide records.
- Provider price changes and simulation shows what a catalog update does to your costs and prices.
- Going live lists what to check before real billing starts.