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

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
141
+
142
+ Three ideas make the rest of the API obvious.
143
+
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.
150
+
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`.
122
154
 
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.
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).
125
160
 
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`.
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.
129
163
 
130
- ### Generating
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,37 @@ 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`.
207
+
208
+ ### `ai.meter` — budget *any* provider call
209
+
210
+ `chat`/`languageModel` go through the gateway. When you need something the
211
+ gateway can't serve (Anthropic web search, computer-use, a different provider,
212
+ a raw `fetch`), `meter` brings that call under the *same* caps, audit log, and
213
+ cost tracking. It reserves before your `run` (throwing over a hard cap), runs
214
+ it, and records the actual usage:
215
+
216
+ ```ts
217
+ await ai.meter(ctx, { userId, model: "anthropic/claude-…", messages }, async () => {
218
+ const res = await anthropic.messages.create({
219
+ messages,
220
+ tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 3 }],
221
+ });
222
+ return {
223
+ text: extractText(res),
224
+ promptTokens: res.usage.input_tokens,
225
+ completionTokens: res.usage.output_tokens,
226
+ cachedTokens: res.usage.cache_read_input_tokens ?? 0,
227
+ serverToolUses: { web_search: res.usage.server_tool_use?.web_search_requests ?? 0 },
228
+ // costNanos? — pass an authoritative total to skip token/tool pricing
229
+ };
230
+ });
231
+ ```
232
+
233
+ `chat` is sugar over `meter`. Return either a raw provider `usage` (auto-
234
+ normalized) or explicit `promptTokens`/`completionTokens`/`cachedTokens`, plus
235
+ optional `serverToolUses` (see [pricing](#pricing--cost)) and `costNanos`.
170
236
 
171
237
  ### Replay
172
238
 
@@ -176,190 +242,322 @@ ai.requests.lineage(ctx, { requestId }): Promise<{ ancestors, reruns }>
176
242
  ```
177
243
 
178
244
  `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.
245
+ to the original and billed to its **original** user. `lineage` walks the re-run
246
+ chain in both directions.
247
+
248
+ ---
249
+
250
+ ## Budgets & limits
251
+
252
+ `ai.users`, `ai.actions`, and `ai.tag(dimension)` are the same namespace over
253
+ different dimensions. Each exposes:
254
+
255
+ | Method | |
256
+ |---|---|
257
+ | `list(ctx)` | every bucket in the dimension, with spend + caps |
258
+ | `get(ctx, { … })` | one bucket (null if it has none yet) |
259
+ | `setLimits(ctx, { …, ...limits })` | set/clear caps & controls |
260
+ | `bump(ctx, { …, dailyNanos?, monthlyNanos?, lifetimeNanos? })` | one-time headroom |
261
+ | `adjust(ctx, { …, deltaNanos, reason? })` | manual credit / debit |
262
+ | `history(ctx, { …, period })` | durable day/month spend history |
263
+ | `adjustments(ctx, { … })` | the manual-adjustment audit log |
264
+ | `delete(ctx, { … })` | remove the bucket (and, for `user`, its request rows) |
265
+
266
+ The identifier field is `userId` for `ai.users`, `name` for `ai.actions`, and
267
+ `value` for `ai.tag(d)`. For example: `ai.tag("customer").setLimits(ctx, { value: "acme", … })`.
180
268
 
181
- ### User limits
269
+ ### Setting caps
182
270
 
183
271
  ```ts
184
272
  ai.users.setLimits(ctx, {
185
- userId,
273
+ userId, // or `name` / `value` for actions / tags
186
274
  requestsPerMinute?,
275
+ maxConcurrent?, // max in-flight requests at once
187
276
  dailySpendLimitNanos?,
277
+ monthlySpendLimitNanos?, // calendar-month budget (UTC)
188
278
  lifetimeSpendLimitNanos?,
189
279
  dailyTokenLimit?,
280
+ monthlyTokenLimit?,
190
281
  lifetimeTokenLimit?,
191
- enforcement?, // "hard" (block, default) | "soft" (warn but allow)
192
- blocked?, // hard block on/off
282
+ warnAtPct?, // e.g. 0.8 alert at 80% of a cap
283
+ enforcement?, // "hard" (block, default) | "soft" (warn but allow)
284
+ blocked?, // hard block on/off
193
285
  })
194
- ai.users.delete(ctx, { userId }) // remove a user and all their request rows
195
286
  ```
196
287
 
197
- Pass a field as `undefined` to clear that limit (unlimited).
288
+ Pass a field as `undefined` to clear that limit (unlimited). A request is
289
+ admitted only if its estimate fits **every** bucket it touches — the same atomic
290
+ reserve-then-settle admission check runs per bucket. Uncapped buckets never serialize, so
291
+ adding tags you don't cap is free at admission; their totals still accrue for
292
+ reporting.
198
293
 
199
- ### Per-action budgets
294
+ ### Tags — budgeting by any dimension
200
295
 
201
- Spend is attributed to the calling action automatically. Cap a feature:
296
+ ```ts
297
+ // Bill this call to a user, an action (implicit), AND a customer + env.
298
+ await ai.chat(ctx, {
299
+ prompt,
300
+ tags: [
301
+ { dimension: "customer", value: "acme" },
302
+ { dimension: "env", value: "prod" },
303
+ ],
304
+ });
305
+
306
+ // Cap the customer "acme" to $50/day — independent of any per-user cap.
307
+ await ai.tag("customer").setLimits(ctx, {
308
+ value: "acme",
309
+ dailySpendLimitNanos: 50 * 1_000_000_000,
310
+ });
311
+ ```
312
+
313
+ ### One-time bumps
202
314
 
203
315
  ```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
- })
316
+ ai.users.bump(ctx, { userId, dailyNanos?, monthlyNanos?, lifetimeNanos? })
213
317
  ```
214
318
 
215
- ### Global (deployment-wide) budget
319
+ Adds headroom on top of the standing cap without changing it. Daily/monthly
320
+ bumps apply to the current window; lifetime bumps are permanent.
321
+
322
+ ### Manual credits & debits
216
323
 
217
324
  ```ts
218
- ai.global.setLimits(ctx, { dailySpendLimitNanos?, lifetimeSpendLimitNanos?, enforcement? })
219
- ai.global.status(ctx) // { limits, spentTodayNanos, spentTotalNanos }
325
+ await ai.users.adjust(ctx, { userId, deltaNanos: -5 * 1_000_000_000, reason: "goodwill" });
326
+ await ai.users.adjustments(ctx, { userId }); // the audit log
327
+ ```
328
+
329
+ Negative = credit, positive = extra charge; it adjusts the live day/month/lifetime
330
+ windows, the history, and an audit log.
331
+
332
+ ### Alerts
333
+
334
+ Get a callback *before* a cap is hit, and when a hard cap blocks:
335
+
336
+ ```ts
337
+ await ai.global.setAlertDefaults(ctx, { warnAtPct: 0.8 }); // 80%, all buckets
338
+ await ai.users.setLimits(ctx, { userId, warnAtPct: 0.9 }); // per-bucket override
339
+
340
+ new AIBudget(components.aiBudget, {
341
+ onThreshold: ({ userId, messages }) => notify(userId, messages), // approaching
342
+ onSoftLimit: ({ userId, messages }) => notify(userId, messages), // soft cap exceeded
343
+ onLimitReached: ({ userId, reason }) => notify(userId, reason), // hard cap blocked
344
+ });
345
+ ```
346
+
347
+ `chat()` also returns `notices` (approaching) alongside `warnings` (soft-exceeded).
348
+
349
+ ---
350
+
351
+ ## Monitoring
352
+
353
+ ### Lists & totals
354
+
355
+ ```ts
356
+ ai.users.list(ctx) // per-user spend today / month / total / limits
357
+ ai.actions.list(ctx) // per-action spend & totals
358
+ ai.tag("customer").list(ctx) // spend & caps for any custom dimension
220
359
  ```
221
360
 
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.
361
+ ### Spend history (survives retention)
362
+
363
+ Request rows are retained only briefly (see [retention](#retention)), but
364
+ **durable per-bucket day/month rollups are not** — so charts and "what did we
365
+ spend last month" keep working:
366
+
367
+ ```ts
368
+ await ai.users.history(ctx, { userId, period: "month" }); // [{ stamp, spendNanos, tokens, requests }]
369
+ await ai.tag("customer").history(ctx, { value: "acme", period: "day", limit: 30 });
370
+ ```
225
371
 
226
- ### One-time bumps ("approve another $X")
372
+ ### The request log
227
373
 
228
374
  ```ts
229
- ai.users.bump(ctx, { userId, dailyNanos?, lifetimeNanos? })
230
- ai.actions.bump(ctx, { name, dailyNanos?, lifetimeNanos? })
375
+ ai.requests.list(ctx, { userId?, limit? }) // audit log (blocked included)
376
+ ai.requests.list(ctx, { dimension: "customer", value: "acme" }) // filter by any tag
377
+ ai.requests.get(ctx, { requestId }) // one request (full prompt + response)
378
+ ```
379
+
380
+ ---
381
+
382
+ ## Deployment-wide controls
383
+
384
+ ### Global cap
385
+
386
+ ```ts
387
+ ai.global.setLimits(ctx, { dailySpendLimitNanos?, lifetimeSpendLimitNanos?, enforcement? })
388
+ ai.global.status(ctx) // { limits, spentTodayNanos, spentTotalNanos, … }
231
389
  ai.global.bump(ctx, { dailyNanos?, lifetimeNanos? })
232
390
  ```
233
391
 
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.
392
+ A killswitch across everything. Backed by a sharded counter for throughput, so
393
+ it's enforced **approximately** (bounded concurrency overshoot under burst).
394
+ Per-bucket admission is atomic against each request's estimate.
236
395
 
237
396
  ### Model policy
238
397
 
239
398
  ```ts
240
- ai.models.setPolicy(ctx, { mode, models })
241
- // mode: "open" (default) | "allowlist" (only these) | "denylist" (all but these)
399
+ ai.models.setPolicy(ctx, { mode, models }) // mode: "open" | "allowlist" | "denylist"
242
400
  ai.models.getPolicy(ctx)
243
401
  ```
244
402
 
245
- ### Pricing
403
+ ### Pricing & cost
404
+
405
+ The component records the gateway's **real per-request dollar cost** when the
406
+ provider surfaces it (`@convex-dev/ai-sdk-provider ≥ 0.2.0-alpha.1`, via
407
+ `providerMetadata.convexGateway.cost`) — cached and reasoning tokens included, so
408
+ recorded spend equals the actual bill. Without it, cost is computed from token
409
+ counts, discounting the cached slice (the gateway's real `cacheReadTokens`) at
410
+ `cachedNanosPerMTok` (default 10% of input). Either way, an **unknown model is
411
+ charged a conservative max, never $0** (so a cap can't be dodged by naming an
412
+ unlisted model) and its rows are flagged `unpricedModel: true`.
246
413
 
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:
414
+ Prices are **nanodollars per million tokens**; sensible defaults ship for common
415
+ models. Override or add any model:
250
416
 
251
417
  ```ts
252
- ai.prices.set(ctx, { model, inputNanosPerMTok, outputNanosPerMTok }) // must be ≥ 0
418
+ ai.prices.set(ctx, { model, inputNanosPerMTok, outputNanosPerMTok, cachedNanosPerMTok? }) // ≥ 0
253
419
  ai.prices.list(ctx)
254
420
  ```
255
421
 
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):
422
+ **Server tools.** Provider server-side tools bill a per-call fee on top of
423
+ tokens (e.g. Anthropic web search). Report them from `meter` as
424
+ `serverToolUses: { web_search: 3 }` and they're priced per call (default
425
+ $0.01/`web_search`) unless you pass an authoritative `costNanos`, which already
426
+ includes them. Override the rate:
427
+
428
+ ```ts
429
+ ai.prices.setServerTool(ctx, { tool: "web_search", nanosPerCall: 12_000_000 })
430
+ ai.prices.listServerTools(ctx)
431
+ ```
432
+
433
+ The gateway's `provider/model` ids match OpenRouter's, whose public models
434
+ endpoint returns per-token pricing — so you can keep prices current from your own
435
+ action (see `example/convex/ai.ts` → `syncPrices`):
260
436
 
261
437
  ```ts
262
438
  const { data } = await (await fetch("https://openrouter.ai/api/v1/models")).json();
263
439
  const p = data.find((m) => m.id === "openai/gpt-4o-mini").pricing;
264
440
  await ai.prices.set(ctx, {
265
441
  model: "openai/gpt-4o-mini",
266
- inputNanosPerMTok: Math.round(Number(p.prompt) * 1e15), // $/token → nano/Mtok
442
+ inputNanosPerMTok: Math.round(Number(p.prompt) * 1e15), // $/token → nano/Mtok
267
443
  outputNanosPerMTok: Math.round(Number(p.completion) * 1e15),
268
444
  });
269
445
  ```
270
446
 
271
- See `example/convex/ai.ts` → `syncPrices` for a full sync over several models.
447
+ ### Retention
448
+
449
+ ```ts
450
+ ai.global.setRetention(ctx, { retentionMs }) // default 1h; 0 disables
451
+ ```
452
+
453
+ Full request rows (prompts + responses) are swept after the window to bound the
454
+ audit table. **Spend history survives** — it lives in separate durable rollups.
272
455
 
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.
456
+ ---
276
457
 
277
- ### Observability
458
+ ## Admin dashboard
459
+
460
+ The component ships a self-contained admin dashboard — buckets & limits, the
461
+ request log, spend-over-time charts, and settings. Mount it with **one call**:
278
462
 
279
463
  ```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)
464
+ // convex/http.ts
465
+ import { httpRouter } from "convex/server";
466
+ import { components } from "./_generated/api";
467
+ import { AIBudget } from "@convex-dev/ai-budget";
468
+
469
+ const ai = new AIBudget(components.aiBudget);
470
+ const http = httpRouter();
471
+
472
+ ai.registerRoutes(http, {
473
+ // Gate it — the endpoint is public. Recommended: check the caller is an admin.
474
+ authorize: async (ctx) => (await ctx.auth.getUserIdentity())?.role === "admin",
475
+ });
476
+
477
+ export default http;
283
478
  ```
284
479
 
480
+ It lives at `https://<deployment>.convex.site/aibudget` (override with `path`).
481
+ **It is a public internet endpoint, so you must gate it**: pass `authorize`
482
+ (return `true` to allow) or set `AI_BUDGET_DASHBOARD_TOKEN` (sent as
483
+ `Authorization: Bearer …`). With neither, every route returns 401. Everything the
484
+ page shows is backed by the component's own functions — nothing else to wire up.
485
+
285
486
  ---
286
487
 
287
488
  ## How spend caps stay correct
288
489
 
289
490
  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**:
491
+ Under concurrency that leaks badly: dozens of in-flight requests all read the same
492
+ pre-spend total and all pass, so spend blows past the cap (measured at ~40× before
493
+ this design). `ai-budget` instead **reserves then settles**:
295
494
 
296
495
  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.
496
+ same transaction as the limit check*, reserves them against each capped bucket.
497
+ Under Convex's serializable isolation this is a true atomic check-and-reserve —
498
+ concurrent requests see each other's holds.
499
+ 2. **Settle.** `finishRequest` writes only the request's own (uncontended) row and
500
+ schedules a fold of the real cost into the totals, releasing the reservation —
501
+ so a request is never orphaned mid-flight.
303
502
  3. **Reconcile.** A once-a-minute cron folds any stragglers and releases
304
503
  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.
504
+ **exactly-once** (a terminal request is never re-folded), so a slow request the
505
+ reconciler already swept can't double-count when it finally returns.
506
+
507
+ Reservations are taken only on buckets that actually have a cap, so uncapped
508
+ traffic never serializes this is what makes arbitrary `tags` cheap: a request
509
+ reserves on one row per *capped* dimension it carries, and nothing else.
510
+
511
+ **One admission rule, all scopes.** Every per-bucket cap — user, action, or any
512
+ tag — runs through the *same* admission check: a request is admitted only when
513
+ `committed + reserved + estimate ≤ cap` (bumps included) for **every** bucket it
514
+ touches. Each per-bucket cap reserves on a single document, making concurrent
515
+ admission atomic. The **global** killswitch is backed by a sharded counter for
516
+ throughput, read as an eventually-consistent sum with no cross-request
517
+ reservation, so concurrent global admissions can overshoot under a burst.
518
+
519
+ Reservations are estimates, not provider-side maximum charges. If a response uses
520
+ more tokens or costs more than estimated, settlement records the real amount and
521
+ the final total can exceed a hard cap by that request's estimation delta. The next
522
+ admission sees the settled total and blocks until there is headroom again.
523
+
524
+ The `error.md` file documents the adversarial audits this design survived, with
525
+ live repros.
322
526
 
323
527
  ---
324
528
 
325
529
  ## Security: before you ship
326
530
 
327
531
  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` /
532
+ `@convex-dev/rate-limiter`, it trusts the `userId` and admin calls your app hands
533
+ it. **Your app owns auth.** The `example/` app skips auth on purpose to keep the
534
+ demo frictionless (a persona dropdown, public admin functions); do not copy its
535
+ endpoints verbatim. In production:
536
+
537
+ 1. **Let `userId` default to the authenticated caller** (the built-in behavior —
538
+ `ai.chat(ctx, { prompt })` uses `ctx.auth.getUserIdentity()`). Only pass an
539
+ explicit `userId` from a trusted server context; never forward a client-supplied
540
+ id, or a caller can spend under someone else's budget or dodge their own limits
541
+ by rotating ids.
542
+ 2. **Gate every admin call** — `setLimits`, `bump`, `adjust`, `setModelPolicy`,
543
+ `setPrice`, `delete` — behind an admin check. A limit-management surface must
544
+ not be operable by the party being limited.
545
+ 3. **Scope reads and replay to the owner.** `requests.list` / `requests.get` /
342
546
  `lineage` return full prompts, responses, and spend, and `rerun` re-runs a
343
547
  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.
548
+ IDOR — verify `request.userId === caller`, or treat those as admin-only.
549
+ 4. **Gate the dashboard.** `registerRoutes` is a public endpoint; always pass a
550
+ real `authorize` (or a token). See [Admin dashboard](#admin-dashboard).
346
551
 
347
552
  ---
348
553
 
349
554
  ## Example app
350
555
 
351
556
  `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:
557
+ admin panel on the right the request audit log (inspect → edit → re-run, with
558
+ lineage), a users table (limits, soft toggle, block, bump), and per-action budgets.
357
559
 
358
560
  ![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
561
  ![Actions & Budgets admin table](docs/actions.png)
364
562
 
365
563
  ```sh
@@ -371,50 +569,55 @@ npm run dev # terminal 2 — Vite app
371
569
 
372
570
  ### What's in the component vs. the demo
373
571
 
374
- The component (`src/`) is **only** the metering/budget primitive — it knows
375
- 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.
572
+ The published AI Budget component (`src/`) is **only** the metering/budget
573
+ primitive — it knows nothing about agents or evaluation. The example composes
574
+ three isolated pieces:
575
+
576
+ - The **Agent component** owns agent threads, messages, tools, and generation.
577
+ - The local **Evaluation component** (`example/convex/evaluations/`) owns immutable
578
+ case snapshots, run lifecycle, and results. It does not call Agent or AI Budget.
579
+ - App-level adapters in `example/convex/ai.ts` compose the siblings: they obtain
580
+ source traffic, run candidate/judge model calls through AI Budget, attach an
581
+ `evalRun` budget tag, and persist outcomes into Evaluation.
582
+ - The **eval playground** (🧪 Experiment tab) exposes **Matrix** (one prompt across a
583
+ system-prompt × model grid, ranked by an LLM judge on *your* criteria),
584
+ **Backtest** (replay a candidate prompt against an action's real historical
585
+ requests and judge each), and **Evolve** (an LLM iteratively improves a prompt
586
+ toward a goal on real traffic, **stopping when it hits a spend budget**).
587
+
588
+ The demo currently snapshots AI Budget audit traffic as its corpus. An Agent-based
589
+ product should instead make the app adapter snapshot cases through Agent's public
590
+ API; Evaluation must never inspect Agent's private tables. The same Evaluation
591
+ component works with either source because its inputs are explicit snapshots.
592
+
593
+ This local component is a proof of the reusable boundary, not part of the
594
+ `@convex-dev/ai-budget` package. If productized, it should ship independently
595
+ (for example, `@convex-dev/evals`) and accept application adapters/function
596
+ handles rather than taking a dependency on either sibling component.
395
597
 
396
598
  ---
397
599
 
398
600
  ## Development
399
601
 
400
602
  ```sh
401
- npm test # vitest + convex-test: reserve/settle, exactly-once,
402
- # token quotas, soft limits, model policy, pricing
603
+ npm test # vitest + convex-test: reserve/settle, exactly-once, monthly
604
+ # caps, cache pricing, tag budgets, alerts, adjustments,
403
605
  npm run build # emit dist/ (client + component) for publishing
404
606
  ```
405
607
 
406
- ## Layout
608
+ **Layout**
407
609
 
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.
610
+ - `src/component/` — **the component** (published): tables (`buckets`, `requests`,
611
+ `usage`, `requestTags`, `adjustments`, `prices`, `settings`), the reserve/settle
612
+ mutations (`startRequest` / `finishRequest`), the idempotent `foldTotals`, the
613
+ `reconcile` cron, and the admin functions. Mounts `@convex-dev/sharded-counter`
614
+ for the global total.
412
615
  - `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.*`).
616
+ `languageModel`, `registerRoutes`, and the namespaced admin API.
416
617
  - `example/` — **the demo app** (not published): real features, the eval
417
- playground, and the UI, all built on the two files above.
618
+ playground, and the UI. `example/convex/evaluations/` is a separate local
619
+ component for datasets, runs, and results; `example/convex/ai.ts` is the
620
+ composition layer.
418
621
 
419
622
  ## License
420
623