Migrating Token Usage Alerts and Budget Caps Between Providers
11 min read · updated August 11, 2026
A spend alert is a threshold over a window over a metric. Migrating one fails when the new provider defines any of the three differently, and the usual outcome is not a false alarm — it is an alert that never fires again.
Three signals, only one of which is yours
Before rebuilding anything, be clear about which of three different numbers each existing alert is reading.
- The per-response usage object. Returned inline with every completion. Immediate, authoritative for that one request, and available to you in your own process the moment the request ends.
- The aggregate reporting surface. A usage or cost endpoint, or the dashboard built on it. Bucketed — commonly by day or hour, in UTC — and published with a lag. Convenient, and unsuitable for enforcement.
- The invoice. Authoritative for what you pay, and far too late for anything else. It also includes commitments, credits and discounts that neither of the other two knows about, which is why it never matches them exactly.
Alerts that were built on the second signal because it was the easiest thing to reach are the ones that break at migration, because bucket size and lag are provider-specific. Alerts built on the first survive, because you control the pipeline. The rebuild is therefore mostly a move from the second to the first, and the migration is a good excuse to do it. Why a hard cap in particular cannot be built on the second is derived in the reporting-delay page.
Inventory the alerts you have
Write each existing alert as an explicit triple before you touch code. The exercise is short and it exposes the ones that were never well-defined:
| alert | metric | window | threshold | | daily spend | cost, all models | 1 day, UTC | $400 | | tenant overage | cost, per tenant | 1 day, tenant TZ | $50 | | runaway agent | tokens, per session | session | 500k | | unexpected model | requests, by model | 1 hour | > 0 |
Two questions per row. First: which of the three signals can supply this metric at this window? A per-session token total cannot come from a daily bucket at all, so that alert was already on the inline usage object whether or not anybody said so. Second: is the window aligned to UTC or to something else? A provider’s daily bucket is almost always UTC, and a tenant-facing “daily” budget usually is not. Migrating that alert without noticing means the threshold now covers a different set of hours, and the first anyone hears of it is a tenant disputing a cap.
Normalising the usage record
Every adapter returns the same record, whatever the provider called its fields. Four token counts plus enough identity to attribute the spend:
type UsageRecord = {
requestId: string;
tenantId: string;
provider: string;
model: string; // the exact model string, not a class
inputTokens: number; // uncached input only
cachedInputTokens: number;
outputTokens: number; // includes reasoning tokens where billed as output
reasoningTokens: number; // reported separately, already inside outputTokens
estimated: boolean; // true when reconstructed from a truncated stream
at: string; // ISO, UTC
};The mappings that need care, rather than the obvious ones:
- Cached input is not free input. OpenAI reports it inside
usage.prompt_tokens_details.cached_tokens, and the cached count is a subset ofprompt_tokensrather than an additional field. Anthropic reportscache_read_input_tokensandcache_creation_input_tokensas separate counts alongsideinput_tokens. Adding them where they should have been subtracted, or the reverse, produces a meter that is wrong by whatever your cache hit rate is — a systematic error, not a noisy one. - Reasoning tokens are billed but not visible in the text. OpenAI reports them under
usage.completion_tokens_details.reasoning_tokensand includes them incompletion_tokens. A meter that estimates output cost by counting the characters it received will understate reasoning models badly. The library covers the billing mechanism in reasoning token billing. - Price belongs to you. Keep a price table keyed by provider and exact model string, with an effective date on every row, and never derive cost from anything the response says. Prices change on dates you did not choose, and a dated table lets you recompute history correctly instead of retroactively repricing it.
The streaming flag that zeroes your meter
This is the single step most likely to be missed, and its failure mode is silence.
On Anthropic’s Messages streaming API, usage arrives without asking: input counts in the message_start event and output counts in message_delta near the end of the stream. On OpenAI’s chat completions streaming API, the streamed chunks carry usage as null unless the request sets stream_options: { include_usage: true }, in which case an additional final chunk arrives after the content, with an empty choices array and the usage totals attached.
So a service migrating in that direction, whose streaming path always received usage for free, will start recording zero for every streamed request. Total spend in your dashboards drops sharply on the day of the cutover, which looks like a migration win, and every threshold alert stops firing because the number is under it. Set the flag, and assert on it: a test that streams a request and requires a non-zero token count in the recorded usage row is three lines and prevents the entire class.
estimated: true so the reconciliation step can distinguish them from a genuine discrepancy.Rebuilding the alerts
- Emit one usage record per request from the adapter, in the normalised shape, on the success path and on the cancelled path.
- Price it at write time using the dated table, and store both the token counts and the computed cost. Storing only cost makes it impossible to reprice; storing only tokens makes every query a join.
- Aggregate into the windows your alerts need — per-tenant per-day in the tenant’s own timezone, per-session, per-model — rather than into one general bucket you then try to slice.
- Re-express each threshold against the new aggregation, and keep the old alert running in parallel for a full billing period so you can compare what each one would have fired on.
- Add an alert on the meter itself. If the count of usage records in an hour falls to zero while request volume does not, the meter is broken. This is the alert that catches the streaming-flag failure before month end, and it is the one nobody writes.
- Then remove the old alerts, and record what each threshold was and why, because the numbers were chosen against a different provider’s prices and will need revisiting.
Reconciling against the provider
Your meter will not equal the provider’s reporting exactly, and the useful posture is a tolerance band rather than an expectation of equality. Known sources of legitimate divergence: requests that failed after the provider had generated tokens; cancelled streams recorded as estimates; cache writes and reads priced with multipliers your table may round differently; free-tier or committed-spend discounts applied at invoice time and nowhere else; and rounding, which is per-request on your side and per-bucket on theirs.
Run the comparison daily against the provider’s aggregate endpoint once its lag has passed, alert on the drift exceeding a band rather than on any difference at all, and treat a sudden change in the drift as the signal. A meter that was 2% low for six months and is now 30% low has a mapping bug introduced by a deploy, and that transition is far more informative than the absolute number. The library’s token-counting-versus-bill test covers pinning this in CI rather than only in production.