@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 +388 -185
- package/dist/client/dashboard.d.ts +1 -0
- package/dist/client/dashboard.js +223 -0
- package/dist/client/index.d.ts +545 -38
- package/dist/client/index.js +343 -59
- package/dist/component/_generated/component.d.ts +62 -24
- package/dist/component/lib.d.ts +144 -40
- package/dist/component/lib.js +648 -305
- package/dist/component/schema.d.ts +107 -53
- package/dist/component/schema.js +87 -38
- package/package.json +5 -5
- package/src/client/dashboard.ts +223 -0
- package/src/client/index.ts +513 -109
- package/src/component/_generated/component.ts +94 -29
- package/src/component/lib.test.ts +253 -15
- package/src/component/lib.ts +746 -333
- package/src/component/schema.ts +90 -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
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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,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
|
|
169
|
-
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
###
|
|
294
|
+
### Tags — budgeting by any dimension
|
|
200
295
|
|
|
201
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
219
|
-
ai.
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
###
|
|
372
|
+
### The request log
|
|
227
373
|
|
|
228
374
|
```ts
|
|
229
|
-
ai.
|
|
230
|
-
ai.
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
248
|
-
|
|
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 }) //
|
|
418
|
+
ai.prices.set(ctx, { model, inputNanosPerMTok, outputNanosPerMTok, cachedNanosPerMTok? }) // ≥ 0
|
|
253
419
|
ai.prices.list(ctx)
|
|
254
420
|
```
|
|
255
421
|
|
|
256
|
-
**
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
2. **Settle.** `finishRequest` writes only the request's own (uncontended) row
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.** `
|
|
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
|
|
345
|
-
|
|
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
|
|
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:
|
|
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
|

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

|
|
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
|
|
375
|
-
nothing about
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
- The
|
|
379
|
-
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
608
|
+
**Layout**
|
|
407
609
|
|
|
408
|
-
- `src/component/` — **the component** (published): tables (`
|
|
409
|
-
`
|
|
410
|
-
`finishRequest`), the idempotent `foldTotals`, the
|
|
411
|
-
admin functions. Mounts `@convex-dev/sharded-counter`
|
|
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
|
|
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
|
|
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
|
|