Skip to content

Audit your metering against the provider

The gateway meters from what the provider reported on the wire, per request, attributed to a virtual key. That is the billing ledger, and nothing here replaces it. See metering for why it is the one to trust.

It cannot notice its own blind spots. A usage field a new model version added, a session the frame decoder gave up on, or traffic that reached the provider on the same credential without passing through the gateway are all invisible from inside the ledger. reconcile builds a second ledger from the provider’s own admin usage API and judges the two against each other.

You need an admin credential per provider, org-scoped, distinct from the data-plane key, sk-ant-admin… for Anthropic, an organization admin key for OpenAI. If you cannot issue one, this page does not apply. Most OpenAI-compatible vendors have no usage API at all.

Reconciliation keeps two replicated ledgers, so it needs a cluster to keep them in. A single node is a cluster of one. In the node file:

cluster:
bootstrap: true
node_id: gw-0
raft_addr: 10.0.0.4:7000
data_dir: /var/lib/pistra
secret: ${PISTRA_CLUSTER_SECRET}

The block itself is deployment configuration, so it goes in the deployment document:

reconcile:
providers:
- provider: openai-prod
type: openai
admin_key: ${OPENAI_ADMIN_KEY}
api_key_ids: [key_abc123]
- provider: anthropic-prod
type: anthropic
admin_key: ${ANTHROPIC_ADMIN_KEY}

type is explicit because a provider’s data-plane dialect does not imply an admin API. A provider absent from this list is not audited at all.

Set api_key_ids if you can. Scoped to the keys the gateway routes through, drift in either direction is a hard signal. Unscoped, the query covers the whole organization, and provider-over-metered drift may just be a colleague’s notebook on the same account. That is a real finding the first time and noise every time after.

The defaults are worth leaving alone to start: a round every hour, a day judged 48 hours after it closes (the usage APIs trail and restate recent buckets), 7 days refetched each round, 90 days of history. All of them are tunable. See reconcile.

In a cluster every node meters and only the raft leader polls, but the result of polling is replicated. You never have to find the leader to read it.

Run this command:

Terminal window
$ curl -s localhost:8485/admin/v1/reconcile | jq

The response looks like this:

{
"leader": false,
"leader_id": "gw-1",
"providers": [
{
"provider": "openai-prod",
"last_fetch": "2026-08-27T09:00:04Z",
"judged_through": "2026-08-24",
"recent": [
{
"day": "2026-08-24",
"breached": false,
"classes": [
{"class": "input", "metered": 48211903, "provider": 48209777, "ratio": 0.0000441, "breach": false},
{"class": "cached_input", "metered": 12004112, "provider": 12004112, "ratio": 0, "breach": false},
{"class": "cache_creation", "metered": 0, "provider": 0, "ratio": 0, "breach": false},
{"class": "output", "metered": 3391204, "provider": 3391204, "ratio": 0, "breach": false}
]
}
]
}
]
}

judged_through is the last day the audit actually decided. Nothing past it is shown. A day the poller has not judged has not been judged, and printing a verdict for it would be the view inventing a decision.

last_error, when present, is the last failed round, a rejected admin key, a rate limit, an outage. It is written down rather than kept in the leader’s memory, so it survives the failover that a broken credential tends to cause.

leader says which node runs the loop and nothing more. It does not qualify the body. Every node answers this identically, because the verdicts are recomputed from replicated ledgers using the same arithmetic the poller judged with. An empty providers list means nothing has been judged yet, not that you reached the wrong node.

Before anything is configured the endpoint answers 501 rather than an empty body, so “not set up” and “set up and quiet” never look alike.

Four token classes are compared per day. A class breaches when it disagrees by both drift_ratio (default 2% of the larger side) and drift_floor (default 10 000 tokens). There are two thresholds, because a ratio alone pages someone over a quiet Sunday and a floor alone never fires on a busy account.

The classes do not mean the same thing on both sides of every provider. That is the usual source of a confusing verdict:

Class OpenAI Anthropic
input full prompt, cache reads included uncached_input_tokens, cache reads excluded
cached_input input_cached_tokens, the cached subset cache_read_input_tokens
cache_creation no such class; zero on both sides cache_creation breakdown
output output tokens output tokens

Both sides of a comparison use that provider’s own convention, so the two ledgers are directly comparable within a provider and not across one. Anthropic’s report carries no request count, so provider-reported requests read zero there, diagnostic only, never judged.

Read the sign first.

Metered above provider. The gateway thinks it sent more than the provider billed. Usually this is double counting: a stream whose usage chunk was folded twice, or a retry that settled after the first attempt had already settled. It is genuinely rare, and worth a bug report.

Provider above metered. The gateway missed spend. In order of likelihood:

  1. Traffic on that provider credential that never traversed the gateway. api_key_ids rules this out. Without it, this is the first thing to check and usually the answer.
  2. A usage field the scanner does not read, a class a model version introduced, on a provider that reports it out of band.
  3. Streams settling against the estimate. If stream_usage is off for this provider, or the upstream ignores include_usage, every streamed request charged a guess. This shows up as a persistent one-directional drift on output in particular.
  4. A WebSocket session whose frame decoder desynced and disabled itself.

A breach is a question, not an alarm. It says two ledgers disagree by more than you said you would tolerate. It does not say which one is wrong.

There are three metrics, and the useful one is not the drift:

  • pistra_reconcile_drift_ratio{provider}, worst per-class disagreement on the most recently judged day. 0 means the ledgers agree.
  • pistra_reconcile_judged_day_seconds{provider}, start of the most recently judged day, as Unix time. Staleness here is the alert. A poller that is stuck reports a reassuring drift of 0 forever, and a drift gauge alone would call that healthy.
  • pistra_reconcile_fetch_errors_total{provider}, failed rounds.

Alert on the second going stale past roughly maturity_lag + interval, and treat the first as a dashboard number you look at when the second is fresh.

It is a daily, per-provider, per-class total. It cannot attribute drift to a virtual key, a model or a route. The provider’s report is account-scoped and the gateway’s is not, and the only axis they share is the day. When a breach points at unmetered traffic, the audit tells you that much. Go to the gateway’s own logs and audit trail next.

It also says nothing about budgets. A budget bucket is enforcement, not accounting. GET /admin/v1/budgets shows what has been spent against each one on that node, including buckets left draining by a budget that has since been removed from the configuration. Alert on pistra_budget_used_ratio for headroom. See metering.

Turning reconciliation off stops the polling and the admin-API credential with it, but metering keeps running. That is deliberate. The audit can only recognise a day it never metered at the start of the ledger, so a block that came and went would leave a hole in the middle that reads as drift forever.