Token metering
Reserve a token estimate before the call, true it to actual usage after: a runaway prompt loop 402s on the next call instead of running your bill up.
What it is
ai-meter is the metered-inference money path: estimate a call's cost, reserve integer credits against it before the provider answers, then reconcile to the provider's actual reported usage. A per-tenant spend window and circuit breaker sit on top, so a crossed hard cap blocks the next reservation before a provider call ever fires; the block is checked against real usage, never assumed.
What ships in the module
Browser-safe entry point
Import @caisson/ai-meter/browser inside a client bundle for the pure half: BUNDLED_PRICE_BOOK with computeCost and creditsForMicroUsd, the estimateTokens/estimateUsage estimator, and the spend vocabulary including SpendCapError. The main entry keeps the full surface, every browser-entry export is also on it, and nothing that moves a credit or takes a database handle is reachable from it.
Pre-call estimate
estimateCost sizes the reservation before the provider responds: a chars/4 heuristic (estimateTokens) against the message array, deliberately rounded up (a full output budget assumed, no cache) so reconcile() trues a shortfall down rather than an under-reservation slipping past a cap.
reserve() then reconcile()
reserve() debits the estimate from the tenant's credit wallet before the call; reconcile() computes the real cost from provider-reported usage and trues the delta: a feature_grant refund on over-reservation, a feature_debit shortfall charge on under-reservation, nothing at all when the delta is zero. The usage_event (account_id, call_id) UNIQUE constraint makes a retried reconcile settle exactly once.
Versioned, fail-closed price book
BUNDLED_PRICE_BOOK maps provider/model to integer micro-USD rates per million tokens; resolvePriceEntry throws on an unrecognized provider/model instead of metering at zero. Every leg rounds up per computeCost, so a partial-cache mix never under-bills, and forge.config can swap in an operator-supplied book validated by parsePriceBook.
Circuit breaker
assertBreakerClosed runs before every reserve(): an open breaker throws SpendCapError (402) with no provider call made. A crossed hard_limit trips it (tripBreaker); it stays open until an operator calls resetBreaker, so a runaway loop can't spend past the cap on the next retry.
Dedup-before-meter gate
checkDedupGate runs a dependency-free MinHash/LSH similarity check (normalizePrompt → shingle → computeMinHashSignature → lshBands) against recent calls in the same account+scope, ahead of the price-book estimate. It only detects a likely-redundant prompt above a 0.92 Jaccard threshold and returns duplicate-of; it never auto-skips the call or moves a credit itself.
/**
* Atomic running-spend mutation, returning the new total. A non-negative `amount` upserts (the window
* row may not exist yet — the reservation creates it). A negative `amount` (a reconcile refund) is a
* plain UPDATE on the row the reservation already created: `ON CONFLICT` only arbitrates UNIQUE
* violations, so a negative VALUES tuple would trip the `spent >= 0` CHECK during the insert attempt
* BEFORE the conflict resolves — the UPDATE instead evaluates the CHECK on the resulting (>= 0) row.
*/
async function bumpSpend(
tx: TenantExecutor,
accountId: string,
scope: string,
key: string,
amount: number,
): Promise<number> {
if (amount >= 0) {
const r = await tx.query<{ spent: number }>(
`INSERT INTO ${TENANT_SPEND_WINDOW_TABLE} (account_id, scope, unit, window_key, spent)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (account_id, scope, unit, window_key)
DO UPDATE SET spent = ${TENANT_SPEND_WINDOW_TABLE}.spent + EXCLUDED.spent,
updated_at = now()
RETURNING spent`,
[accountId, scope, SPEND_UNIT, key, amount],
);
return r.rows[0]?.spent ?? amount;- A non-negative amount runs as an upsert (ON CONFLICT ... DO UPDATE), the spend-window row may not exist yet when the very first reservation for that key lands.
- The doc comment explains the ordering trick: a negative refund evaluates the spent >= 0 CHECK on the UPDATE path, never the INSERT path, so a refund can't trip the constraint before the conflict resolves.