Reserve and settle LLM calls
An LLM call's size is only known when it's done, and calls run at the same time. check then allow can't handle
that: ten calls can each pass a check that together they blow through. Wrap every model call in
reserve, call, settle:
import { Limitr } from '@formata/limitr';
const limitr = await Limitr.new(`
policy: {
credits: {
ai_token: { label: 'AI token', price: { amount: 0.00002 }, overhead_cost: 0.000008 }
}
plans: {
free: {
default: true
entitlements: {
chat: {
// A call's size depends on its input tokens
estimate_basis: 'input'
// Each model learns its own estimate
estimate_segment: 'model'
limit: {
credit: 'ai_token', value: 10_000
resets: true, reset_sch: 'monthly:1'
}
}
}
}
}
}`);
await limitr.ensureCustomer('ada');
// Stands in for your model provider: answers are about 400 tokens.
async function callModel(model: string, input: number) {
await new Promise((resolve) => setTimeout(resolve, 20));
return { model, input, output: 380 + (input % 50) };
}
// Every model call goes through here.
async function chat(userId: string, model: string, input: number): Promise<string> {
const context = { model, input };
const hold = await limitr.reserve(userId, 'chat', { context });
if (!hold) return 'denied';
try {
const usage = await callModel(model, input);
await limitr.settle(userId, 'chat', hold, usage.input + usage.output, usage);
return `used ${usage.input + usage.output}`;
} catch (error) {
// The call failed: nothing to record
await limitr.release(userId, 'chat', hold);
throw error;
}
}
// Teach the estimate with a few calls, one at a time.
for (const input of [200, 900, 400, 1200, 600]) {
await chat('ada', 'large', input);
}
console.log('used so far:', await limitr.value('ada', 'chat'));
const next = await limitr.estimate('ada', 'chat', { context: { model: 'large', input: 800 } });
console.log('an 800-token prompt should use about', Math.round(next?.value ?? 0));
// Then eight calls at once. Holds count each other, so they can't overspend together.
const inputs = [700, 800, 900, 700, 800, 900, 700, 800];
const results = await Promise.all(inputs.map((input) => chat('ada', 'large', input)));
console.log(results.join(', '));
console.log('used:', await limitr.value('ada', 'chat'), 'of 10000');
It prints:
used so far: 5200
an 800-token prompt should use about 1180
used 1080, used 1180, used 1280, used 1080, denied, denied, denied, denied
used: 9820 of 10000
The pattern
reservebefore the call. It decides likecheck(limits, caps, grants, rules), counting every other open hold, and holds room for this call. With novalue, it holds what it predicts the call will use. It returns a hold ID, ornullwhen the call shouldn't run: return a 402 or 429, or offer an upgrade.- Call the model.
settlewith what the provider says the call used. It records the real usage (even past a hard limit, since it already happened), lets go of the hold, and teaches the estimate. Pass the same event data, with the output tokens, so a cost function prices the call.releaseinstead if the call failed. Nothing is recorded.
Holds expire after 10 minutes (the policy's hold_ttl), so a crashed request can't hold room forever.
What the hold should be
- Let the engine predict it.
estimate_basis: 'input'tells it a call's size depends on its input tokens, andestimate_segment: 'model'keeps one estimate per model. It learns a fixed amount per call plus an amount per input token, and holds at the 90th percentile, so most calls come in under their hold. - Before it has learned anything, it holds the limit's standard
increment. With only a few calls, holds are cautious. After 5 calls with different input sizes, they track real usage closely. - Hold a fixed amount instead with
{ value: 4000 }, Ex. yourmax_tokens. - Each customer's own estimate takes over after 5 of their calls; before that, every customer's calls together.
Things to get right
- Always settle or release. Use
try/catch(orfinally) so a thrown error releases the hold. - Pass the basis every time. On an entitlement with
estimate_basis, everyreserveandsettleneeds that field in its event data. Without it, the call is metered but not learned from, and the engine logs it. - Keep segments few: a model name, never a user or request ID.
- Bill from events: settle sends the usual meter events,
meter-overageincluded. See Bill from events. - Pass the provider's cost to
settleasoverheadwhen it reports one.
Streaming
For a streamed response, reserve before the stream starts and settle when it ends, with the final usage (most providers report it in the last chunk). If the client disconnects, settle what was generated so far, or release the hold when nothing was.
Next
- Predict the answer's length on its own, to set
max_tokensor quote a price: Predict output tokens. - Every option: Reservations and estimates.