Per-user daily token cap that prevents cost-amplification abuse. A single authenticated user cannot exhaust the AI quota for all other users by running many expensive requests.
Config
DAILY_TOKEN_CAP in src/lib/server/ai/config.ts
caps input + output tokens combined, per user, per UTC day. The number is not repeated here
— at the value it currently holds, spend lands around $1–2 per user per day on premium
models, and a doc that restates it is a doc that goes stale the first time it is tuned.
Redis Key Shape
ai:budget:{userId}:{YYYY-MM-DD}
TTL: 25 hours (TTL_SECONDS = 25 * 60 * 60). The extra hour past midnight ensures the key is still readable in the final minutes of a UTC day regardless of clock skew between app instances.
The {YYYY-MM-DD} segment is derived from new Date().toISOString().slice(0, 10) — always UTC.
API ($lib/server/ai/budget.ts)
checkUserBudget(userId)
Call at request entry — before any expensive model invocation:
import { checkUserBudget } from '$lib/server/ai/budget';
const budget = await checkUserBudget(userId);
if (!budget.allowed) return decisionResponse(budget);
Returns a Decision. On denial, retryAfterMs is set to milliseconds until UTC midnight so the client can show an accurate reset time.
Redis-unavailable behavior:
| Environment | Redis missing |
|---|---|
| Dev | Passthrough (always allowed) |
| Production | Denied — budget check unavailable is treated as over-limit |
chargeTokens(userId, tokens)
Call once the model is done, while the response is still open (the chatbot's post-text stage runs it alongside the message backfill, before finish):
import { chargeTokens } from '$lib/server/ai/budget';
// After the model's total usage is known, before the stream closes:
await chargeTokens(userId, usage.totalTokens);
One pipelined Redis round trip: INCRBY + EXPIRE (the TTL retires the key with its day). It sits on the answer path between the last token and finish, so it is one hop, not two.
Check-Then-Charge Caveat (v1)
The gate and charge are not atomic. A burst of N parallel requests can each pass checkUserBudget before any has called chargeTokens. The daily total can overshoot by up to N × MAX_TOKENS before the cap engages.
At current rates the worst-case overshoot is $0.20–0.50 — acceptable for v1. Upgrade to atomic pre-charge (reserve tokens before generation, reconcile after) if abuse data warrants it.
BOT_DETECTION_MODE Interaction
The token budget does not read BOT_DETECTION_MODE — it is always enforced when Redis is available. The mode flag is scoped to the captcha layer only.
Where Enforced
- Shared AI entry guard (
src/lib/server/ai/guard.ts,guardAiRequest):checkUserBudgetruns in the auth →aiConfigured→ (rate-limit ∥ budget) preamble shared by the AI routes (/api/ai/chatbot,/api/ai/deskbot,/api/ai/images/[id]/analyze), before the AI work starts. The limiter and the budget are read concurrently; when both deny, the rate-limit response answers. On a budget denial alone, returnsdecisionResponse(budget)(429). - Chat orchestrator (
$lib/server/ai/chat-orchestrator.ts):chargeTokens(userId, inputTokens + outputTokens)in the chatbot's post-text stage (afterText, together with the message backfill and the token-total refresh, all awaited beforefinish) and in the deskbot/fallbackonFinish.
The gate sits in the entry guard because the chatbot grounds every turn (forced tool-provider routing + per-turn retrieval), so the cost floor per request is higher — the budget is checked before any model work begins, and the single guard keeps the rejection identical across every AI route.