@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 +335 -183
- package/dist/client/dashboard.d.ts +1 -0
- package/dist/client/dashboard.js +223 -0
- package/dist/client/index.d.ts +494 -35
- package/dist/client/index.js +276 -33
- package/dist/component/_generated/component.d.ts +56 -24
- package/dist/component/lib.d.ts +132 -40
- package/dist/component/lib.js +590 -305
- package/dist/component/schema.d.ts +103 -53
- package/dist/component/schema.js +81 -38
- package/package.json +5 -5
- package/src/client/dashboard.ts +223 -0
- package/src/client/index.ts +407 -73
- package/src/component/_generated/component.ts +79 -29
- package/src/component/lib.test.ts +208 -15
- package/src/component/lib.ts +688 -333
- package/src/component/schema.ts +84 -38
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
|
|
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
|

|
|
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
|
-
##
|
|
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**
|
|
28
|
-
| **
|
|
29
|
-
| **
|
|
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
|
-
| **
|
|
32
|
-
| **
|
|
33
|
-
| **
|
|
34
|
-
| **
|
|
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
|
|
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
|
-
##
|
|
61
|
+
## Setup
|
|
41
62
|
|
|
42
|
-
Requires `convex@^1.45`, AI SDK
|
|
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"`
|
|
45
|
-
`
|
|
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
|
-
|
|
73
|
-
onSoftLimit: ({ userId, warnings }) => console.warn(userId, warnings),
|
|
95
|
+
onSoftLimit: ({ userId, messages }) => console.warn(userId, messages),
|
|
74
96
|
});
|
|
75
97
|
```
|
|
76
98
|
|
|
77
|
-
|
|
78
|
-
`ai.
|
|
79
|
-
`ai.
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
140
|
+
## Concepts
|
|
122
141
|
|
|
123
|
-
|
|
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
|
-
**
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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?,
|
|
135
|
-
prompt?,
|
|
136
|
-
messages?,
|
|
137
|
-
model?,
|
|
138
|
-
action?,
|
|
139
|
-
|
|
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
|
|
143
|
-
|
|
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
|
-
###
|
|
185
|
+
### `ai.languageModel` — an AI SDK model (Agent, generateText, streamText)
|
|
147
186
|
|
|
148
187
|
```ts
|
|
149
|
-
ai.languageModel(ctx, { userId
|
|
188
|
+
ai.languageModel(ctx, { userId?, model?, action?, tags? }): LanguageModel
|
|
150
189
|
```
|
|
151
190
|
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
###
|
|
265
|
+
### Tags — budgeting by any dimension
|
|
200
266
|
|
|
201
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
219
|
-
ai.
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
318
|
+
`chat()` also returns `notices` (approaching) alongside `warnings` (soft-exceeded).
|
|
319
|
+
|
|
320
|
+
---
|
|
225
321
|
|
|
226
|
-
|
|
322
|
+
## Monitoring
|
|
323
|
+
|
|
324
|
+
### Lists & totals
|
|
227
325
|
|
|
228
326
|
```ts
|
|
229
|
-
ai.users.
|
|
230
|
-
ai.actions.
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
248
|
-
|
|
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 }) //
|
|
389
|
+
ai.prices.set(ctx, { model, inputNanosPerMTok, outputNanosPerMTok, cachedNanosPerMTok? }) // ≥ 0
|
|
253
390
|
ai.prices.list(ctx)
|
|
254
391
|
```
|
|
255
392
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
2. **Settle.** `finishRequest` writes only the request's own (uncontended) row
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
Reservations are only
|
|
309
|
-
traffic never serializes
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
**One
|
|
313
|
-
the *same* admission check
|
|
314
|
-
`committed + reserved + estimate ≤ cap` (bumps included)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
1. **Let `userId` default to the authenticated caller** (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
2. **Gate every admin call** — `setLimits`, `
|
|
339
|
-
`setPrice`, `
|
|
340
|
-
|
|
341
|
-
3. **Scope reads and replay to the owner.** `
|
|
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
|
|
345
|
-
|
|
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
|
|
353
|
-
|
|
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
|

|
|
359
|
-
|
|
360
|
-
Per-action budgets, auto-attributed to the calling Convex function (including
|
|
361
|
-
agent generations via `agentChat`):
|
|
362
|
-
|
|
363
521
|

|
|
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
|
|
377
|
-
|
|
378
|
-
- The real features being metered — `sendMessage`, `summarize`, the agent
|
|
379
|
-
- An **eval playground**
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
559
|
+
**Layout**
|
|
407
560
|
|
|
408
|
-
- `src/component/` — **the component** (published): tables (`
|
|
409
|
-
`
|
|
410
|
-
`finishRequest`), the idempotent `foldTotals`, the
|
|
411
|
-
admin functions. Mounts `@convex-dev/sharded-counter`
|
|
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
|
|
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
|
|
569
|
+
playground, and the UI, all built on the two directories above.
|
|
418
570
|
|
|
419
571
|
## License
|
|
420
572
|
|