Per-customer AI budgets with the Vercel AI SDK and OpenRouter
The code was tested on October 11, 2026 against [email protected] and [email protected] using the AI SDK's mock models and a fake OpenRouter client, not live providers. Tested versions are in the repo's examples/. Prices are examples; check your provider's pricing page.
Most AI apps I see are built one of two ways: the Vercel AI SDK (generateText / streamText), or an OpenAI-compatible client pointed at a router like OpenRouter. Both make it easy to call a model. Neither knows that customer cus_123 is on a $10 plan and has already cost you $9.40 this month.
This post shows how to add a monthly dollar budget per customer to both setups:
- near the budget (say 80%), switch to a cheaper model
- at the budget, refuse the call before it costs anything
- record the real cost of every call from the token usage the provider returns
I'll use llmbrake, a small MIT library I wrote for this (disclosure). The pattern is simple enough to build yourself, and I'll point out where it can go wrong.
The three steps
Every budgeted call has the same shape:
- Check: look up the customer's spend for the current month and their budget (usually by plan). If spend ≥ budget, stop. If spend ≥ 80% of budget, pick the cheaper model.
- Call the model you picked.
- Charge: convert the returned token usage to dollars and add it to the customer's monthly total.
Dollars, not tokens, because tokens aren't comparable across models: 1,000 output tokens cost $0.03 on one model and $0.0045 on another.
Setup (shared)
npm install llmbrake
// lib/ai-budget.ts
import { createGuard, MemoryStore, DEFAULT_PRICES, computeCostUsd } from "llmbrake";
// MemoryStore is per-process. On serverless or multiple instances use Redis:
// see the UsageStore example in the llmbrake README.
export const store = new MemoryStore();
export const prices = DEFAULT_PRICES; // read 2026-10-09; verify against your provider's pricing page
export const guard = createGuard({
store,
prices,
budgetUsd: (ctx) => (ctx.plan === "pro" ? 20 : 2), // USD per customer per calendar month (UTC)
downgradeAt: 0.8,
downgradeMap: { "gpt-5.5": "gpt-5.4-mini" },
});
Part 1: Vercel AI SDK
The AI SDK doesn't call chat.completions.create directly, so instead of wrapping a client we use the guard's two building blocks: guard.check() before the call and computeCostUsd() after it.
Charge from result.usage
In AI SDK v7, result.usage is a LanguageModelUsage with inputTokens (total input, including cached), inputTokenDetails.cacheReadTokens and outputTokens. Map it to the OpenAI-style shape computeCostUsd expects:
// lib/ai-budget.ts (continued)
import type { LanguageModelUsage } from "ai";
import type { CallContext } from "llmbrake";
type Decision = Awaited<ReturnType<typeof guard.check>>;
export async function charge(ctx: CallContext, d: Decision, usage: LanguageModelUsage) {
const cost = computeCostUsd(d.model, {
prompt_tokens: usage.inputTokens ?? 0,
completion_tokens: usage.outputTokens ?? 0,
prompt_tokens_details: { cached_tokens: usage.inputTokenDetails?.cacheReadTokens ?? 0 },
}, prices);
if (cost) await store.addSpend(ctx.customerId, d.period, cost);
return cost;
}
Cached input tokens matter. On the price table above, cached gpt-5.5 input is $0.50/1M versus $5/1M uncached. If you charge cached tokens at the full rate, you'll block customers early.
generateText
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { isBudgetExceededError } from "llmbrake";
import { guard, charge } from "@/lib/ai-budget";
export async function summarize(user: { id: string; plan: string }, text: string) {
const ctx = { customerId: user.id, plan: user.plan, feature: "summarize" };
try {
const d = await guard.check(ctx, "gpt-5.5"); // throws at the cap
const result = await generateText({ model: openai(d.model), prompt: `Summarize:\n${text}` });
await charge(ctx, d, result.usage);
return result.text;
} catch (e) {
if (isBudgetExceededError(e)) return null; // show your upgrade prompt
throw e;
}
}
d.model is "gpt-5.5" normally and "gpt-5.4-mini" once the customer passes 80% of their budget. d.downgraded tells you which, if you want to show a small "using a faster model" note.
streamText in a route handler
For streaming, charge in onFinish, which runs once the stream has completed and usage is known:
// app/api/chat/route.ts
import { streamText, convertToModelMessages } from "ai";
import { openai } from "@ai-sdk/openai";
import { isBudgetExceededError } from "llmbrake";
import { guard, charge } from "@/lib/ai-budget";
export async function POST(req: Request) {
const user = await getUser(req); // your auth
const { messages } = await req.json();
const ctx = { customerId: user.id, plan: user.plan, feature: "chat" };
let d;
try {
d = await guard.check(ctx, "gpt-5.5");
} catch (e) {
if (isBudgetExceededError(e)) {
return Response.json({ error: "budget_exceeded", spentUsd: e.spentUsd, budgetUsd: e.budgetUsd }, { status: 402 });
}
throw e;
}
const result = streamText({
model: openai(d.model),
messages: await convertToModelMessages(messages),
onFinish: async ({ usage }) => { await charge(ctx, d, usage); },
});
return result.toUIMessageStreamResponse();
}
Two caveats with streaming:
- Aborted streams. If the client disconnects,
onFinishmay not run, and some providers never send usage for a stream that was cut short. Then that call costs you money but isn't charged. If that matters, also charge fromonAbortwith an estimate, or accept a small leak. - Multi-step tool calls. With tools and multiple steps, use the usage for the whole call (in v7
result.usageand theonFinishusage cover all steps). Don't charge per step and then again at the end.
I checked this flow end to end with the AI SDK's MockLanguageModelV4 for both generateText and streamText: a customer with a $0.05 budget was charged $0.0285 per call (2,000 uncached + 1,000 cached input tokens, 600 output on gpt-5.5) and blocked on the third call.
Part 2: OpenRouter
OpenRouter is OpenAI-compatible, so you can wrap the client's create directly and get check, downgrade, call and charge in one go, streaming included:
import OpenAI from "openai";
import { createGuard, DEFAULT_PRICES, isBudgetExceededError } from "llmbrake";
const openrouter = new OpenAI({ baseURL: "https://openrouter.ai/api/v1", apiKey: process.env.OPENROUTER_API_KEY });
const guard = createGuard({
budgetUsd: 5,
downgradeMap: { "openai/gpt-5.5": "openai/gpt-5.4-mini" },
prices: {
...DEFAULT_PRICES,
// OpenRouter model IDs. Copy current prices from https://openrouter.ai/models before relying on them.
"openai/gpt-5.5": { inputPerMTok: 5, cachedInputPerMTok: 0.5, outputPerMTok: 30, source: "openrouter.ai/models (verify)" },
"openai/gpt-5.4-mini": { inputPerMTok: 0.75, cachedInputPerMTok: 0.075, outputPerMTok: 4.5, source: "openrouter.ai/models (verify)" },
},
});
const chat = openrouter.chat.completions;
const create = guard.wrap(chat.create.bind(chat));
const res = await create(
{ model: "openai/gpt-5.5", messages: [{ role: "user", content: "Hello" }] },
{ customerId: user.id, feature: "chat" },
);
With a $0.05 budget and a fake client returning 1,500 input / 500 output tokens per call, the run looked like this: two calls on openai/gpt-5.5 ($0.0225 each), then two on openai/gpt-5.4-mini once spend passed $0.04 ($0.003375 each), then LLMBRAKE_BUDGET_EXCEEDED. Note that total spend ended at $0.0518, slightly over $0.05: the check happens before the call, so the last call that gets through can overshoot by its own cost.
OpenRouter already tells you the cost
OpenRouter returns usage.cost on every response (and on the final chunk of a stream): the amount actually charged to your account. That's better than any price table I maintain. llmbrake 0.1.0 prices from tokens and your table. Using provider-reported cost when present is next on the roadmap. If you'd use that, tell me in the issues.
What can go wrong (with any implementation)
- Concurrent requests overshoot. Five parallel requests can all pass the check before any of them is charged. Keep a margin, or add a per-customer concurrency limit.
- Prices drift. A hard-coded table goes stale. Keep a
sourceand date on every entry, or use provider-reported cost where it exists. - Per-instance memory.
MemoryStoreresets on deploy and isn't shared between serverless instances. Use Redis (INCRBYFLOATis atomic) or your database. - Store outages. Decide whether a store failure should let calls through (
failOpen: true, the default) or block them.
Try it
- Tested, copy-paste versions of everything above (plus a LangChain.js version): https://github.com/llmbrake/llmbrake/tree/main/examples
- Live demo, in your browser with a simulated provider: https://llmbrake.com/demo.html
- Code: https://github.com/llmbrake/llmbrake (MIT),
npm install llmbrake
It's v0.1.0. If something here doesn't match how you run the AI SDK or OpenRouter in production, open an issue. That's the feedback I'm looking for.
Try the live demo (a browser simulation, no API keys needed), read the README, or see how LLMBrake compares with LiteLLM, Helicone and Langfuse.