Docs / AI Infrastructure / Usage & credits
Usage & credits
Every AI reply costs credits. The chat route reserves the cost before it ever calls the model, then records the usage (or refunds the reservation) once the generation is done. This page covers how the call site spends credits. For the schema, top-ups, and the balance itself, see Credit system.
Reserve, then generate
app/api/teams/[teamId]/ai/chat/route.ts calls reserveAiCredits() from lib/ai/credits.ts before touching the model:
const reserved = await reserveAiCredits({ teamId, cost: AI_CREDIT_COST_PER_MESSAGE });
if (!reserved) {
return Response.json({ error: "Out of credits." }, { status: 402 });
}
This is the real ledger guard, not just a UX check: it's an atomic conditional decrement (UPDATE ... SET balance = balance - cost WHERE balance >= cost), so a request that can't pay never reaches streamText. Two concurrent requests can't both reserve the same last unit of balance, one wins the update, the other gets false and a 402 before any provider cost is incurred.
Why reserve instead of charge on completion
An earlier version of this route only charged in onFinish, after the reply had already streamed. That let unlimited concurrent requests all pass a read-only pre-flight check and each get a full paid generation before the one atomic charge at the end landed, every generation past the first free one still cost real provider money whether or not it got billed. Reserving up front closes that: the expensive part never runs unless the balance was actually moved first.
Recording usage, and refunding on failure
Once a reservation succeeds, the balance has already moved, onFinish just writes the audit trail now that real token counts are known:
onFinish: async ({ model, totalUsage }) => {
await recordAiCreditUsage({
teamId, userId, cost: AI_CREDIT_COST_PER_MESSAGE,
model: model.modelId,
inputTokens: totalUsage.inputTokens,
outputTokens: totalUsage.outputTokens,
});
},
If the generation fails instead, onError and onAbort both call refundAiCredits() to give the reservation back:
onError: async ({ error }) => {
await refundAiCredits({ teamId, cost: AI_CREDIT_COST_PER_MESSAGE });
},
onAbort: async () => {
await refundAiCredits({ teamId, cost: AI_CREDIT_COST_PER_MESSAGE });
},
Nothing is charged on failure
A generation that errors or gets stopped mid-stream is refunded, not billed. The client mirrors this: an aborted or errored bubble tells the user no credits were charged.
Changing the price
The per-message cost is a single constant in lib/ai/constants.ts:
export const AI_CREDIT_COST_PER_MESSAGE = 15;
Both the server route and the client UI import it, so changing this one number updates the reservation, the 402 threshold, and the "15 credits / message" label in one place. Flat per-message pricing keeps the reference build simple, per-token billing is the obvious next step, since recordAiCreditUsage already records input and output token counts on every usage_events row.