@convex-dev/ai-budget 0.0.2-alpha.0 → 0.0.2-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  **Add this component and get worry-free AI.** A metered, budget-governed layer
4
4
  over the [Convex AI Gateway](https://docs.convex.dev/ai-gateway/overview). Point
5
5
  your LLM calls through it and every request is tracked, priced, attributed, and
6
- held to a budget — with spend caps that actually hold under concurrent load.
6
+ held to a budget — with atomic admission that accounts for concurrent load.
7
7
 
8
8
  ```ts
9
9
  // userId defaults to the signed-in user — this is the whole integration:
@@ -17,38 +17,61 @@ full audit log you can replay later.
17
17
 
18
18
  ![Chat with a live request log — every call tracked, priced, and attributed](docs/hero.png)
19
19
 
20
+ ## Contents
21
+
22
+ - [Features](#features)
23
+ - [Setup](#setup)
24
+ - [Quickstart](#quickstart)
25
+ - [Concepts](#concepts) — dimensions, nanodollars, reserve→settle
26
+ - [Generating text](#generating-text) — `chat`, `languageModel`, replay
27
+ - [Budgets & limits](#budgets--limits) — set caps, bumps, credits, alerts
28
+ - [Monitoring](#monitoring) — totals, spend history, the request log
29
+ - [Deployment-wide controls](#deployment-wide-controls) — global cap, model policy, pricing, retention
30
+ - [Admin dashboard](#admin-dashboard)
31
+ - [How spend caps stay correct](#how-spend-caps-stay-correct) — the design
32
+ - [Security](#security-before-you-ship)
33
+ - [Example app](#example-app)
34
+ - [Development](#development)
35
+
20
36
  ---
21
37
 
22
- ## What you get
38
+ ## Features
23
39
 
24
40
  | | |
25
41
  |---|---|
26
42
  | **Usage & cost tracking** | Every request stored with messages, response, tokens, latency, and per-request cost. |
27
- | **Attribution** | Each call is attributed to a `userId` **and** to the Convex action that made it — auto-detected via `ctx.meta`, no manual tagging. Running totals per user and per action. |
28
- | **Spend & token limits** | Per-user daily / lifetime **spend** and **token** budgets, plus a requests-per-minute rate limit and a block switch. |
29
- | **Concurrency-safe caps** | A reserve-then-settle design makes admission a true atomic check concurrent in-flight requests can't blow past the cap (a naive implementation overshoots ~40×). |
43
+ | **Attribution** | Each call is attributed to a `userId` **and** the Convex action that made it — auto-detected via `ctx.meta`, no manual tagging. |
44
+ | **Tagged budgets** | `user` and `action` are just built-in *dimensions* — add your own (team, project, customer, env…) via `tags`, and cap any of them. One request can be billed to several buckets at once. |
45
+ | **Spend & token limits** | Per-bucket **daily / monthly / lifetime** spend and token budgets, plus requests-per-minute, a max-concurrent cap, and a block switch. |
46
+ | **Concurrency-safe admission** | A reserve-then-settle design makes admission atomic against estimated usage, so concurrent requests can't all spend the same remaining budget. Final usage can exceed its reservation; the actual amount is recorded at settlement. |
30
47
  | **Hard or soft** | Each limit either **blocks** (`hard`) or **allows-with-a-warning** (`soft`). |
31
- | **Per-feature budgets** | Cap or disable a whole action (e.g. `summarize`) independently of any user. |
32
- | **Global killswitch** | A deployment-wide spend cap across all users and actions (sharded for throughput; enforced approximately). |
33
- | **One-time bumps** | "Approve another $X" at any level (user / action / global) without changing the standing cap daily bumps are today-only, lifetime bumps permanent. |
34
- | **Model policy** | Allow/deny lists for models; an unknown/unpriced model **fails closed** (charged a conservative max, never $0). |
48
+ | **Approaching-limit alerts** | Set `warnAtPct` (e.g. 0.8) and get an `onThreshold` callback before a cap is hit; `onLimitReached` fires when one blocks. |
49
+ | **Spend history** | Durable per-bucket **daily & monthly** rollups that survive request retention real spend-over-time, per user / action / tag. |
50
+ | **Manual credits/debits** | Comp a user or correct an overcharge with a signed adjustment, recorded to the live windows, the history, and an audit log. |
51
+ | **One-time bumps** | "Approve another $X" without changing the standing cap — bucket daily/monthly bumps and global daily bumps reset with their windows; lifetime bumps persist. |
52
+ | **Global killswitch** | A deployment-wide spend cap across everything (sharded for throughput; enforced approximately). |
53
+ | **Authoritative cost** | Records the gateway's **real** per-request dollar cost when available; otherwise cache-aware token pricing. Unknown models **fail closed** (charged a conservative max, never $0). |
54
+ | **Model policy** | Allow/deny lists for models. |
35
55
  | **Replay** | Re-run any stored request with edited messages or a different model; re-runs are linked to their original (lineage). |
36
- | **Agent-ready** | `ai.languageModel(ctx, { userId })` is a standard AI SDK model — drop it into [`@convex-dev/agent`](https://www.npmjs.com/package/@convex-dev/agent) and every agent generation is budgeted. |
56
+ | **Agent-ready** | `ai.languageModel(ctx, { userId })` is a standard AI SDK model — drop it into [`@convex-dev/agent`](https://www.npmjs.com/package/@convex-dev/agent) and every generation is budgeted. |
57
+ | **Built-in dashboard** | `ai.registerRoutes(http)` mounts a self-contained admin dashboard at a URL — one line, no UI to build. |
37
58
 
38
59
  ---
39
60
 
40
- ## Install
61
+ ## Setup
41
62
 
42
- Requires `convex@^1.45`, AI SDK 5+, and a Convex team on a paid plan (the
63
+ Requires `convex@^1.45`, the AI SDK, and a Convex team on a paid plan (the
43
64
  gateway is a paid feature). Runs in Convex's **default runtime** — no
44
- `"use node"` required (the component is pure V8 mutations/queries/crons, and
45
- `ai.chat` / `ai.languageModel` are `fetch`-based).
65
+ `"use node"` (the component is pure V8; `ai.chat` / `ai.languageModel` are
66
+ `fetch`-based).
67
+
68
+ **1. Install**
46
69
 
47
70
  ```sh
48
71
  npm install @convex-dev/ai-budget @convex-dev/ai-sdk-provider ai
49
72
  ```
50
73
 
51
- Register the component:
74
+ **2. Register the component**
52
75
 
53
76
  ```ts
54
77
  // convex/convex.config.ts
@@ -60,7 +83,7 @@ app.use(aiBudget);
60
83
  export default app;
61
84
  ```
62
85
 
63
- Create a client:
86
+ **3. Create a client**
64
87
 
65
88
  ```ts
66
89
  // convex/ai.ts
@@ -69,14 +92,13 @@ import { components } from "./_generated/api";
69
92
 
70
93
  export const ai = new AIBudget(components.aiBudget, {
71
94
  defaultModel: "openai/gpt-4o-mini",
72
- // Optional: surface soft-limit warnings even on the Agent/languageModel path.
73
- onSoftLimit: ({ userId, warnings }) => console.warn(userId, warnings),
95
+ onSoftLimit: ({ userId, messages }) => console.warn(userId, messages),
74
96
  });
75
97
  ```
76
98
 
77
- The admin API is namespaced: `ai.users.*`, `ai.actions.*`, `ai.global.*`,
78
- `ai.models.*`, `ai.prices.*`, and `ai.requests.*`; `ai.chat` and
79
- `ai.languageModel` are top-level.
99
+ `ai.chat` and `ai.languageModel` are top-level; everything else is namespaced —
100
+ `ai.users.*`, `ai.actions.*`, `ai.tag(d).*`, `ai.global.*`, `ai.models.*`,
101
+ `ai.prices.*`, `ai.requests.*`.
80
102
 
81
103
  ---
82
104
 
@@ -98,65 +120,80 @@ export const sendMessage = action({
98
120
  });
99
121
  ```
100
122
 
101
- Pass an explicit `{ userId }` only for service/admin flows or when you manage
102
- identity yourself.
103
-
104
123
  Give someone a budget:
105
124
 
106
125
  ```ts
107
126
  await ai.users.setLimits(ctx, {
108
127
  userId: "alice",
109
- dailySpendLimitNanos: 1_000_000_000, // $1.00 / day (1e9 nano)
128
+ dailySpendLimitNanos: 1_000_000_000, // $1.00 / day
110
129
  dailyTokenLimit: 500_000,
111
130
  requestsPerMinute: 20,
112
131
  });
113
132
  ```
114
133
 
115
134
  When a request would exceed a **hard** cap, `chat` throws a `ConvexError`
116
- carrying `{ kind: "AIBudgetLimit", code, reason }`; the attempt is still
117
- recorded (`status: "blocked"`) so you can see who's hitting limits.
135
+ carrying `{ kind: "AIBudgetLimit", code, reason }`; the attempt is still recorded
136
+ (`status: "blocked"`) so you can see who's hitting limits.
118
137
 
119
138
  ---
120
139
 
121
- ## API reference
140
+ ## Concepts
122
141
 
123
- All methods are called from a Convex **action** (they run the gateway call) or,
124
- for the read-only ones, a query. `ctx` is the Convex context.
142
+ Three ideas make the rest of the API obvious.
125
143
 
126
- **Money is integer nanodollars** (`1 USD = 1e9 nano`) everywhere — costs, limits,
127
- and prices. Integers avoid the rounding drift floating-point cents accumulate and
128
- keep cap comparisons exact (exact to ~$9M per value). `$1 = 1_000_000_000`.
144
+ **Dimensions & buckets.** Spend is attributed along *dimensions*. `user` and
145
+ `action` are built in (from `userId` and the calling Convex action); you can add
146
+ any others `team`, `customer`, `env`, `feature` by passing `tags`. Each
147
+ `(dimension, value)` pair is a **bucket** with its own totals and optional caps.
148
+ `ai.users`, `ai.actions`, and `ai.tag("customer")` are the *same* API over
149
+ different dimensions.
129
150
 
130
- ### Generating
151
+ **Nanodollars.** All money is integer **nanodollars** (`1 USD = 1e9 nano`) —
152
+ costs, limits, prices. Integers avoid the rounding drift floating-point cents
153
+ accumulate and keep cap comparisons exact (to ~$9M per value). `$1 = 1_000_000_000`.
154
+
155
+ **Reserve → settle.** Each request is admitted by an atomic check-and-reserve
156
+ of estimated usage against every capped bucket it touches, then settled to its
157
+ real cost when it finishes. That's what prevents concurrent requests from all
158
+ spending the same remaining budget — see
159
+ [How spend caps stay correct](#how-spend-caps-stay-correct).
160
+
161
+ > All methods are called from a Convex **action** (they run the gateway call),
162
+ > or a **query** for the read-only ones. `ctx` is the Convex context.
163
+
164
+ ---
165
+
166
+ ## Generating text
167
+
168
+ ### `ai.chat` — one-shot completion
131
169
 
132
170
  ```ts
133
171
  ai.chat(ctx, {
134
- userId?, // defaults to the authenticated caller (ctx.auth)
135
- prompt?, // or:
136
- messages?, // [{ role, content }]
137
- model?, // defaults to defaultModel
138
- action?, // attribution name; defaults to the calling Convex action
139
- }): Promise<{ text, requestId, costNanos, promptTokens, completionTokens, warnings }>
172
+ userId?, // defaults to the authenticated caller (ctx.auth)
173
+ prompt?, // or:
174
+ messages?, // [{ role, content }]
175
+ model?, // defaults to defaultModel
176
+ action?, // attribution name; defaults to the calling Convex action
177
+ tags?, // extra dimensions: [{ dimension: "customer", value: "acme" }, …]
178
+ }): Promise<{ text, requestId, costNanos, promptTokens, completionTokens, cachedTokens, warnings, notices }>
140
179
  ```
141
180
 
142
- `warnings` is non-empty only when a **soft** limit was exceeded. If no `userId`
143
- is passed and there is no authenticated user, `chat` throws budgets are never
144
- silently un-attributed.
181
+ `warnings` is non-empty only when a **soft** limit was exceeded; `notices` when a
182
+ [threshold](#alerts) was crossed. If no `userId` is passed and there's no
183
+ authenticated user, `chat` throws — budgets are never silently un-attributed.
145
184
 
146
- ### As an AI SDK model (Agent, generateText, streamText)
185
+ ### `ai.languageModel` an AI SDK model (Agent, generateText, streamText)
147
186
 
148
187
  ```ts
149
- ai.languageModel(ctx, { userId, model?, action? }): LanguageModel
188
+ ai.languageModel(ctx, { userId?, model?, action?, tags? }): LanguageModel
150
189
  ```
151
190
 
152
- Returns a standard AI SDK `LanguageModel` that enforces the user's budget and
153
- records usage/cost on every call including streaming. Use it anywhere an AI
154
- SDK model is expected:
191
+ A standard AI SDK `LanguageModel` that enforces the budget and records usage/cost
192
+ on every call, including streaming. Drop it into the Convex Agent:
155
193
 
156
194
  ```ts
157
195
  import { Agent } from "@convex-dev/agent";
158
196
 
159
- // Construct per-request so the agent is bound to this user.
160
197
  const agent = new Agent(components.agent, {
161
198
  name: "assistant",
162
199
  languageModel: ai.languageModel(ctx, { userId }),
@@ -165,8 +202,8 @@ const { threadId } = await agent.createThread(ctx, { userId });
165
202
  const result = await agent.generateText(ctx, { threadId }, { prompt });
166
203
  ```
167
204
 
168
- Every generation the agent makes is now tracked and budgeted, attributed to
169
- `userId` and to the calling action. See `example/convex/agentDemo.ts`.
205
+ Every generation is now tracked and budgeted, attributed to `userId` and the
206
+ calling action. See `example/convex/agentDemo.ts`.
170
207
 
171
208
  ### Replay
172
209
 
@@ -176,190 +213,311 @@ ai.requests.lineage(ctx, { requestId }): Promise<{ ancestors, reruns }>
176
213
  ```
177
214
 
178
215
  `rerun` re-runs a stored request (optionally with edited messages/model), linked
179
- to the original. `lineage` walks the re-run chain in both directions.
216
+ to the original and billed to its **original** user. `lineage` walks the re-run
217
+ chain in both directions.
218
+
219
+ ---
180
220
 
181
- ### User limits
221
+ ## Budgets & limits
222
+
223
+ `ai.users`, `ai.actions`, and `ai.tag(dimension)` are the same namespace over
224
+ different dimensions. Each exposes:
225
+
226
+ | Method | |
227
+ |---|---|
228
+ | `list(ctx)` | every bucket in the dimension, with spend + caps |
229
+ | `get(ctx, { … })` | one bucket (null if it has none yet) |
230
+ | `setLimits(ctx, { …, ...limits })` | set/clear caps & controls |
231
+ | `bump(ctx, { …, dailyNanos?, monthlyNanos?, lifetimeNanos? })` | one-time headroom |
232
+ | `adjust(ctx, { …, deltaNanos, reason? })` | manual credit / debit |
233
+ | `history(ctx, { …, period })` | durable day/month spend history |
234
+ | `adjustments(ctx, { … })` | the manual-adjustment audit log |
235
+ | `delete(ctx, { … })` | remove the bucket (and, for `user`, its request rows) |
236
+
237
+ The identifier field is `userId` for `ai.users`, `name` for `ai.actions`, and
238
+ `value` for `ai.tag(d)`. For example: `ai.tag("customer").setLimits(ctx, { value: "acme", … })`.
239
+
240
+ ### Setting caps
182
241
 
183
242
  ```ts
184
243
  ai.users.setLimits(ctx, {
185
- userId,
244
+ userId, // or `name` / `value` for actions / tags
186
245
  requestsPerMinute?,
246
+ maxConcurrent?, // max in-flight requests at once
187
247
  dailySpendLimitNanos?,
248
+ monthlySpendLimitNanos?, // calendar-month budget (UTC)
188
249
  lifetimeSpendLimitNanos?,
189
250
  dailyTokenLimit?,
251
+ monthlyTokenLimit?,
190
252
  lifetimeTokenLimit?,
191
- enforcement?, // "hard" (block, default) | "soft" (warn but allow)
192
- blocked?, // hard block on/off
253
+ warnAtPct?, // e.g. 0.8 alert at 80% of a cap
254
+ enforcement?, // "hard" (block, default) | "soft" (warn but allow)
255
+ blocked?, // hard block on/off
193
256
  })
194
- ai.users.delete(ctx, { userId }) // remove a user and all their request rows
195
257
  ```
196
258
 
197
- Pass a field as `undefined` to clear that limit (unlimited).
259
+ Pass a field as `undefined` to clear that limit (unlimited). A request is
260
+ admitted only if its estimate fits **every** bucket it touches — the same atomic
261
+ reserve-then-settle admission check runs per bucket. Uncapped buckets never serialize, so
262
+ adding tags you don't cap is free at admission; their totals still accrue for
263
+ reporting.
198
264
 
199
- ### Per-action budgets
265
+ ### Tags — budgeting by any dimension
200
266
 
201
- Spend is attributed to the calling action automatically. Cap a feature:
267
+ ```ts
268
+ // Bill this call to a user, an action (implicit), AND a customer + env.
269
+ await ai.chat(ctx, {
270
+ prompt,
271
+ tags: [
272
+ { dimension: "customer", value: "acme" },
273
+ { dimension: "env", value: "prod" },
274
+ ],
275
+ });
276
+
277
+ // Cap the customer "acme" to $50/day — independent of any per-user cap.
278
+ await ai.tag("customer").setLimits(ctx, {
279
+ value: "acme",
280
+ dailySpendLimitNanos: 50 * 1_000_000_000,
281
+ });
282
+ ```
283
+
284
+ ### One-time bumps
202
285
 
203
286
  ```ts
204
- ai.actions.setLimits(ctx, {
205
- name, // e.g. "ai:summarize"
206
- dailySpendLimitNanos?,
207
- lifetimeSpendLimitNanos?,
208
- dailyTokenLimit?,
209
- lifetimeTokenLimit?,
210
- enforcement?, // "hard" | "soft"
211
- disabled?, // kill switch for the whole feature
212
- })
287
+ ai.users.bump(ctx, { userId, dailyNanos?, monthlyNanos?, lifetimeNanos? })
213
288
  ```
214
289
 
215
- ### Global (deployment-wide) budget
290
+ Adds headroom on top of the standing cap without changing it. Daily/monthly
291
+ bumps apply to the current window; lifetime bumps are permanent.
292
+
293
+ ### Manual credits & debits
216
294
 
217
295
  ```ts
218
- ai.global.setLimits(ctx, { dailySpendLimitNanos?, lifetimeSpendLimitNanos?, enforcement? })
219
- ai.global.status(ctx) // { limits, spentTodayNanos, spentTotalNanos }
296
+ await ai.users.adjust(ctx, { userId, deltaNanos: -5 * 1_000_000_000, reason: "goodwill" });
297
+ await ai.users.adjustments(ctx, { userId }); // the audit log
298
+ ```
299
+
300
+ Negative = credit, positive = extra charge; it adjusts the live day/month/lifetime
301
+ windows, the history, and an audit log.
302
+
303
+ ### Alerts
304
+
305
+ Get a callback *before* a cap is hit, and when a hard cap blocks:
306
+
307
+ ```ts
308
+ await ai.global.setAlertDefaults(ctx, { warnAtPct: 0.8 }); // 80%, all buckets
309
+ await ai.users.setLimits(ctx, { userId, warnAtPct: 0.9 }); // per-bucket override
310
+
311
+ new AIBudget(components.aiBudget, {
312
+ onThreshold: ({ userId, messages }) => notify(userId, messages), // approaching
313
+ onSoftLimit: ({ userId, messages }) => notify(userId, messages), // soft cap exceeded
314
+ onLimitReached: ({ userId, reason }) => notify(userId, reason), // hard cap blocked
315
+ });
220
316
  ```
221
317
 
222
- A killswitch across all users and actions. Backed by a sharded counter for
223
- throughput, so it's enforced **approximately** (bounded overshoot under burst) —
224
- per-user and per-action caps remain exact.
318
+ `chat()` also returns `notices` (approaching) alongside `warnings` (soft-exceeded).
319
+
320
+ ---
225
321
 
226
- ### One-time bumps ("approve another $X")
322
+ ## Monitoring
323
+
324
+ ### Lists & totals
227
325
 
228
326
  ```ts
229
- ai.users.bump(ctx, { userId, dailyNanos?, lifetimeNanos? })
230
- ai.actions.bump(ctx, { name, dailyNanos?, lifetimeNanos? })
327
+ ai.users.list(ctx) // per-user spend today / month / total / limits
328
+ ai.actions.list(ctx) // per-action spend & totals
329
+ ai.tag("customer").list(ctx) // spend & caps for any custom dimension
330
+ ```
331
+
332
+ ### Spend history (survives retention)
333
+
334
+ Request rows are retained only briefly (see [retention](#retention)), but
335
+ **durable per-bucket day/month rollups are not** — so charts and "what did we
336
+ spend last month" keep working:
337
+
338
+ ```ts
339
+ await ai.users.history(ctx, { userId, period: "month" }); // [{ stamp, spendNanos, tokens, requests }]
340
+ await ai.tag("customer").history(ctx, { value: "acme", period: "day", limit: 30 });
341
+ ```
342
+
343
+ ### The request log
344
+
345
+ ```ts
346
+ ai.requests.list(ctx, { userId?, limit? }) // audit log (blocked included)
347
+ ai.requests.list(ctx, { dimension: "customer", value: "acme" }) // filter by any tag
348
+ ai.requests.get(ctx, { requestId }) // one request (full prompt + response)
349
+ ```
350
+
351
+ ---
352
+
353
+ ## Deployment-wide controls
354
+
355
+ ### Global cap
356
+
357
+ ```ts
358
+ ai.global.setLimits(ctx, { dailySpendLimitNanos?, lifetimeSpendLimitNanos?, enforcement? })
359
+ ai.global.status(ctx) // { limits, spentTodayNanos, spentTotalNanos, … }
231
360
  ai.global.bump(ctx, { dailyNanos?, lifetimeNanos? })
232
361
  ```
233
362
 
234
- Adds headroom on top of the standing cap without changing it. Daily bumps apply
235
- to today only (reset with the day); lifetime bumps are permanent.
363
+ A killswitch across everything. Backed by a sharded counter for throughput, so
364
+ it's enforced **approximately** (bounded concurrency overshoot under burst).
365
+ Per-bucket admission is atomic against each request's estimate.
236
366
 
237
367
  ### Model policy
238
368
 
239
369
  ```ts
240
- ai.models.setPolicy(ctx, { mode, models })
241
- // mode: "open" (default) | "allowlist" (only these) | "denylist" (all but these)
370
+ ai.models.setPolicy(ctx, { mode, models }) // mode: "open" | "allowlist" | "denylist"
242
371
  ai.models.getPolicy(ctx)
243
372
  ```
244
373
 
245
- ### Pricing
374
+ ### Pricing & cost
375
+
376
+ The component records the gateway's **real per-request dollar cost** when the
377
+ provider surfaces it (`@convex-dev/ai-sdk-provider ≥ 0.2.0-alpha.1`, via
378
+ `providerMetadata.convexGateway.cost`) — cached and reasoning tokens included, so
379
+ recorded spend equals the actual bill. Without it, cost is computed from token
380
+ counts, discounting the cached slice (the gateway's real `cacheReadTokens`) at
381
+ `cachedNanosPerMTok` (default 10% of input). Either way, an **unknown model is
382
+ charged a conservative max, never $0** (so a cap can't be dodged by naming an
383
+ unlisted model) and its rows are flagged `unpricedModel: true`.
246
384
 
247
- Prices are in **nanodollars per million tokens**. Sensible defaults ship for
248
- common models (validated against OpenRouter's public pricing); override or add
249
- any model:
385
+ Prices are **nanodollars per million tokens**; sensible defaults ship for common
386
+ models. Override or add any model:
250
387
 
251
388
  ```ts
252
- ai.prices.set(ctx, { model, inputNanosPerMTok, outputNanosPerMTok }) // must be ≥ 0
389
+ ai.prices.set(ctx, { model, inputNanosPerMTok, outputNanosPerMTok, cachedNanosPerMTok? }) // ≥ 0
253
390
  ai.prices.list(ctx)
254
391
  ```
255
392
 
256
- **Real prices from an API.** The gateway's `provider/model` ids match
257
- OpenRouter's, whose public models endpoint returns per-token pricing — so you can
258
- keep prices current from your own action (this is app code; the component just
259
- stores what you give it):
393
+ The gateway's `provider/model` ids match OpenRouter's, whose public models
394
+ endpoint returns per-token pricing — so you can keep prices current from your own
395
+ action (see `example/convex/ai.ts` `syncPrices`):
260
396
 
261
397
  ```ts
262
398
  const { data } = await (await fetch("https://openrouter.ai/api/v1/models")).json();
263
399
  const p = data.find((m) => m.id === "openai/gpt-4o-mini").pricing;
264
400
  await ai.prices.set(ctx, {
265
401
  model: "openai/gpt-4o-mini",
266
- inputNanosPerMTok: Math.round(Number(p.prompt) * 1e15), // $/token → nano/Mtok
402
+ inputNanosPerMTok: Math.round(Number(p.prompt) * 1e15), // $/token → nano/Mtok
267
403
  outputNanosPerMTok: Math.round(Number(p.completion) * 1e15),
268
404
  });
269
405
  ```
270
406
 
271
- See `example/convex/ai.ts` → `syncPrices` for a full sync over several models.
407
+ ### Retention
408
+
409
+ ```ts
410
+ ai.global.setRetention(ctx, { retentionMs }) // default 1h; 0 disables
411
+ ```
412
+
413
+ Full request rows (prompts + responses) are swept after the window to bound the
414
+ audit table. **Spend history survives** — it lives in separate durable rollups.
415
+
416
+ ---
272
417
 
273
- An unpriced model is charged the conservative maximum of the known table (so a
274
- cap can never be bypassed by naming an unlisted model) and its request rows are
275
- flagged `unpricedModel: true` so you know to add a real price.
418
+ ## Admin dashboard
276
419
 
277
- ### Observability
420
+ The component ships a self-contained admin dashboard — buckets & limits, the
421
+ request log, spend-over-time charts, and settings. Mount it with **one call**:
278
422
 
279
423
  ```ts
280
- ai.users.list(ctx) // per-user spend today / total / tokens / limits
281
- ai.actions.list(ctx) // per-action spend & totals
282
- ai.requests.list(ctx, { userId?, limit? }) // the audit log (blocked attempts included)
424
+ // convex/http.ts
425
+ import { httpRouter } from "convex/server";
426
+ import { components } from "./_generated/api";
427
+ import { AIBudget } from "@convex-dev/ai-budget";
428
+
429
+ const ai = new AIBudget(components.aiBudget);
430
+ const http = httpRouter();
431
+
432
+ ai.registerRoutes(http, {
433
+ // Gate it — the endpoint is public. Recommended: check the caller is an admin.
434
+ authorize: async (ctx) => (await ctx.auth.getUserIdentity())?.role === "admin",
435
+ });
436
+
437
+ export default http;
283
438
  ```
284
439
 
440
+ It lives at `https://<deployment>.convex.site/aibudget` (override with `path`).
441
+ **It is a public internet endpoint, so you must gate it**: pass `authorize`
442
+ (return `true` to allow) or set `AI_BUDGET_DASHBOARD_TOKEN` (sent as
443
+ `Authorization: Bearer …`). With neither, every route returns 401. Everything the
444
+ page shows is backed by the component's own functions — nothing else to wire up.
445
+
285
446
  ---
286
447
 
287
448
  ## How spend caps stay correct
288
449
 
289
450
  A naive tracker checks the running total, makes the call, then records the cost.
290
- Under concurrency that leaks badly: dozens of in-flight requests all read the
291
- same pre-spend total and all pass, so spend blows past the cap (measured at
292
- ~40× before this design).
293
-
294
- `ai-budget` instead **reserves then settles**:
451
+ Under concurrency that leaks badly: dozens of in-flight requests all read the same
452
+ pre-spend total and all pass, so spend blows past the cap (measured at ~40× before
453
+ this design). `ai-budget` instead **reserves then settles**:
295
454
 
296
455
  1. **Reserve.** `startRequest` estimates the request's cost/tokens and, *in the
297
- same transaction as the limit check*, reserves them against the capped
298
- entity. Under Convex's serializable isolation this makes admission a true
299
- atomic check-and-reserve — concurrent requests see each other's holds.
300
- 2. **Settle.** `finishRequest` writes only the request's own (uncontended) row
301
- and schedules a fold of the real cost into the totals, releasing the
302
- reservation. A request is therefore never orphaned mid-flight.
456
+ same transaction as the limit check*, reserves them against each capped bucket.
457
+ Under Convex's serializable isolation this is a true atomic check-and-reserve —
458
+ concurrent requests see each other's holds.
459
+ 2. **Settle.** `finishRequest` writes only the request's own (uncontended) row and
460
+ schedules a fold of the real cost into the totals, releasing the reservation —
461
+ so a request is never orphaned mid-flight.
303
462
  3. **Reconcile.** A once-a-minute cron folds any stragglers and releases
304
463
  reservations for requests that died before settling. Settlement is
305
- **exactly-once** (a terminal request is never re-folded), so a slow request
306
- that the reconciler already swept can't double-count when it finally returns.
307
-
308
- Reservations are only taken on entities that actually have a cap, so uncapped
309
- traffic never serializes. The `error.md` file documents the adversarial audits
310
- this design survived, with live repros.
311
-
312
- **One guarantee, all scopes.** Per-user, per-action, and global caps run through
313
- the *same* admission check a request is admitted only when
314
- `committed + reserved + estimate ≤ cap` (bumps included). The only thing that
315
- differs is the holder: per-user and per-action reserve on a single document, an
316
- exact atomic check-and-reserve; the **global** killswitch is backed by a sharded
317
- counter for throughput, so its committed total is read as an eventually-consistent
318
- sum with no cross-request reservation. That makes the global cap **approximate** —
319
- it can overshoot by a bounded amount under a burst — the deliberate
320
- exactness-for-throughput trade for a deployment-wide cap, and the only scope
321
- that isn't exact.
464
+ **exactly-once** (a terminal request is never re-folded), so a slow request the
465
+ reconciler already swept can't double-count when it finally returns.
466
+
467
+ Reservations are taken only on buckets that actually have a cap, so uncapped
468
+ traffic never serializes this is what makes arbitrary `tags` cheap: a request
469
+ reserves on one row per *capped* dimension it carries, and nothing else.
470
+
471
+ **One admission rule, all scopes.** Every per-bucket cap — user, action, or any
472
+ tag — runs through the *same* admission check: a request is admitted only when
473
+ `committed + reserved + estimate ≤ cap` (bumps included) for **every** bucket it
474
+ touches. Each per-bucket cap reserves on a single document, making concurrent
475
+ admission atomic. The **global** killswitch is backed by a sharded counter for
476
+ throughput, read as an eventually-consistent sum with no cross-request
477
+ reservation, so concurrent global admissions can overshoot under a burst.
478
+
479
+ Reservations are estimates, not provider-side maximum charges. If a response uses
480
+ more tokens or costs more than estimated, settlement records the real amount and
481
+ the final total can exceed a hard cap by that request's estimation delta. The next
482
+ admission sees the settled total and blocks until there is headroom again.
483
+
484
+ The `error.md` file documents the adversarial audits this design survived, with
485
+ live repros.
322
486
 
323
487
  ---
324
488
 
325
489
  ## Security: before you ship
326
490
 
327
491
  The component is deliberately **identity-agnostic** — like
328
- `@convex-dev/rate-limiter`, it trusts the `userId` and admin calls your app
329
- hands it. **Your app owns auth.** The `example/` app skips auth on purpose to
330
- keep the demo frictionless (a persona dropdown, public admin functions); do not
331
- copy its endpoints verbatim. In production:
332
-
333
- 1. **Let `userId` default to the authenticated caller** (that's the built-in
334
- behavior — `ai.chat(ctx, { prompt })` uses `ctx.auth.getUserIdentity()`).
335
- Only pass an explicit `userId` from a trusted server context; never forward a
336
- client-supplied id, or a caller can spend under someone else's budget or
337
- dodge their own limits by rotating ids.
338
- 2. **Gate every admin call** — `setLimits`, `setActionLimits`, `setModelPolicy`,
339
- `setPrice`, `deleteUser` — behind an admin check. A limit-management surface
340
- must not be operable by the party being limited.
341
- 3. **Scope reads and replay to the owner.** `listRequests` / `getRequest` /
492
+ `@convex-dev/rate-limiter`, it trusts the `userId` and admin calls your app hands
493
+ it. **Your app owns auth.** The `example/` app skips auth on purpose to keep the
494
+ demo frictionless (a persona dropdown, public admin functions); do not copy its
495
+ endpoints verbatim. In production:
496
+
497
+ 1. **Let `userId` default to the authenticated caller** (the built-in behavior —
498
+ `ai.chat(ctx, { prompt })` uses `ctx.auth.getUserIdentity()`). Only pass an
499
+ explicit `userId` from a trusted server context; never forward a client-supplied
500
+ id, or a caller can spend under someone else's budget or dodge their own limits
501
+ by rotating ids.
502
+ 2. **Gate every admin call** — `setLimits`, `bump`, `adjust`, `setModelPolicy`,
503
+ `setPrice`, `delete` — behind an admin check. A limit-management surface must
504
+ not be operable by the party being limited.
505
+ 3. **Scope reads and replay to the owner.** `requests.list` / `requests.get` /
342
506
  `lineage` return full prompts, responses, and spend, and `rerun` re-runs a
343
507
  request billed to its **original** user. An unchecked client `requestId` is an
344
- IDOR — verify `request.userId === caller`, or treat `rerun` and the
345
- cross-user views as admin-only.
508
+ IDOR — verify `request.userId === caller`, or treat those as admin-only.
509
+ 4. **Gate the dashboard.** `registerRoutes` is a public endpoint; always pass a
510
+ real `authorize` (or a token). See [Admin dashboard](#admin-dashboard).
346
511
 
347
512
  ---
348
513
 
349
514
  ## Example app
350
515
 
351
516
  `example/` is a full working demo: chat as different personas on the left; a live
352
- admin panel on the right with the request audit log (inspect → edit → re-run,
353
- with lineage), a users table (rate / spend / token limits, soft toggle, block,
354
- one-time bump), and per-action budgets.
355
-
356
- Per-user limits — rate, daily spend, daily tokens, hard/soft, block, one-time bump:
517
+ admin panel on the right the request audit log (inspect → edit → re-run, with
518
+ lineage), a users table (limits, soft toggle, block, bump), and per-action budgets.
357
519
 
358
520
  ![Users & Limits admin table](docs/users.png)
359
-
360
- Per-action budgets, auto-attributed to the calling Convex function (including
361
- agent generations via `agentChat`):
362
-
363
521
  ![Actions & Budgets admin table](docs/actions.png)
364
522
 
365
523
  ```sh
@@ -373,48 +531,42 @@ npm run dev # terminal 2 — Vite app
373
531
 
374
532
  The component (`src/`) is **only** the metering/budget primitive — it knows
375
533
  nothing about chat or evaluation. Everything below lives in `example/` as
376
- **application code that uses the component**, not as part of the published API:
377
-
378
- - The real features being metered — `sendMessage`, `summarize`, the agent (`agentDemo.ts`).
379
- - An **eval playground** — a 🧪 Experiment tab with three modes:
380
- - **Matrix** — run one prompt across a system-prompt × model grid; an LLM judge
381
- ranks them by *your* criteria.
382
- - **Backtest** replay a candidate system prompt against a specific action's
383
- real historical requests and judge whether it improved each one.
384
- - **Evolve** — an LLM iteratively improves a prompt toward a goal, scored on
385
- real traffic, **stopping when it hits a spend budget.**
386
- - The public endpoints and UI wiring that drive the admin panel.
387
-
388
- These are built entirely on two component primitives: `ai.chat(...)` (every eval
389
- call is budgeted and tracked) and `ai.requests.list(...)` (the audit log *is* the
390
- eval dataset). They're a **"how to build on it" reference, not the component's
391
- surface** — a real, non-trivial feature (budget-capped prompt backtesting on live
392
- traffic) that falls out of the primitives without the component needing to know
393
- eval exists. If you productize this, it belongs in your app or its own component
394
- that *composes* `@convex-dev/ai-budget` — never folded back into it.
534
+ **application code that uses the component**, not part of the published API:
535
+
536
+ - The real features being metered — `sendMessage`, `summarize`, the agent.
537
+ - An **eval playground** (🧪 Experiment tab): **Matrix** (one prompt across a
538
+ system-prompt × model grid, ranked by an LLM judge on *your* criteria),
539
+ **Backtest** (replay a candidate prompt against an action's real historical
540
+ requests and judge each), and **Evolve** (an LLM iteratively improves a prompt
541
+ toward a goal on real traffic, **stopping when it hits a spend budget**).
542
+
543
+ These are built on two primitives `ai.chat(...)` (every eval call is budgeted)
544
+ and `ai.requests.list(...)` (the audit log *is* the eval dataset) a "how to
545
+ build on it" reference, not the component's surface. If you productize this it
546
+ belongs in your app or a component that *composes* `@convex-dev/ai-budget`, never
547
+ folded back into it.
395
548
 
396
549
  ---
397
550
 
398
551
  ## Development
399
552
 
400
553
  ```sh
401
- npm test # vitest + convex-test: reserve/settle, exactly-once,
402
- # token quotas, soft limits, model policy, pricing
554
+ npm test # vitest + convex-test: reserve/settle, exactly-once, monthly
555
+ # caps, cache pricing, tag budgets, alerts, adjustments,
403
556
  npm run build # emit dist/ (client + component) for publishing
404
557
  ```
405
558
 
406
- ## Layout
559
+ **Layout**
407
560
 
408
- - `src/component/` — **the component** (published): tables (`users`, `actions`,
409
- `requests`, `prices`, `settings`), the reserve/settle mutations (`startRequest` /
410
- `finishRequest`), the idempotent `foldTotals`, the `reconcile` cron, and the
411
- admin functions. Mounts `@convex-dev/sharded-counter` for the global total.
561
+ - `src/component/` — **the component** (published): tables (`buckets`, `requests`,
562
+ `usage`, `requestTags`, `adjustments`, `prices`, `settings`), the reserve/settle
563
+ mutations (`startRequest` / `finishRequest`), the idempotent `foldTotals`, the
564
+ `reconcile` cron, and the admin functions. Mounts `@convex-dev/sharded-counter`
565
+ for the global total.
412
566
  - `src/client/` — **the client** (published): the `AIBudget` class — `chat`,
413
- `languageModel` (AI SDK middleware around `convexGateway`), and the namespaced
414
- admin API (`ai.users.*`, `ai.actions.*`, `ai.global.*`, `ai.models.*`,
415
- `ai.prices.*`, `ai.requests.*`).
567
+ `languageModel`, `registerRoutes`, and the namespaced admin API.
416
568
  - `example/` — **the demo app** (not published): real features, the eval
417
- playground, and the UI, all built on the two files above.
569
+ playground, and the UI, all built on the two directories above.
418
570
 
419
571
  ## License
420
572