@convex-dev/ai-budget 0.0.2-alpha.3 → 0.0.2-alpha.5
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 +116 -17
- package/dist/client/index.d.ts +433 -34
- package/dist/client/index.js +141 -33
- package/dist/component/_generated/component.d.ts +54 -24
- package/dist/component/lib.d.ts +130 -40
- package/dist/component/lib.js +550 -290
- package/dist/component/schema.d.ts +103 -53
- package/dist/component/schema.js +81 -38
- package/package.json +1 -1
- package/src/client/index.ts +241 -71
- package/src/component/_generated/component.ts +77 -29
- package/src/component/lib.test.ts +167 -15
- package/src/component/lib.ts +640 -313
- package/src/component/schema.ts +84 -38
package/README.md
CHANGED
|
@@ -25,10 +25,15 @@ full audit log you can replay later.
|
|
|
25
25
|
|---|---|
|
|
26
26
|
| **Usage & cost tracking** | Every request stored with messages, response, tokens, latency, and per-request cost. |
|
|
27
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
|
-
| **
|
|
28
|
+
| **Tagged budgets** | `user` and `action` are just built-in *dimensions* — add your own (team, project, customer, env, feature…) by passing `tags`, and cap any of them with `ai.tag("customer").setLimits(...)`. One request can be billed to several buckets at once. |
|
|
29
|
+
| **Spend & token limits** | Per-bucket **daily / monthly / lifetime** spend and token budgets, plus a requests-per-minute rate limit, a max-concurrent cap, and a block switch. |
|
|
30
|
+
| **Spend history** | Durable per-bucket **daily & monthly** rollups that survive request retention — real spend-over-time, "what did we spend last month," per user / action / tag. |
|
|
31
|
+
| **Approaching-limit alerts** | Set `warnAtPct` (e.g. 0.8) and get an `onThreshold` callback before a cap is hit; `onLimitReached` fires when one blocks. |
|
|
32
|
+
| **Manual credits/debits** | Comp a user or correct an overcharge with a signed adjustment; the change hits the live windows, the history, and an audit log. |
|
|
33
|
+
| **Cache-aware cost** | Cached (prompt-cache-read) tokens are billed at a discount using the gateway's real cached-token count — not the full input rate. |
|
|
29
34
|
| **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×). |
|
|
30
35
|
| **Hard or soft** | Each limit either **blocks** (`hard`) or **allows-with-a-warning** (`soft`). |
|
|
31
|
-
| **Per-feature budgets** | Cap or
|
|
36
|
+
| **Per-feature budgets** | Cap or block a whole action (e.g. `summarize`) independently of any user. |
|
|
32
37
|
| **Global killswitch** | A deployment-wide spend cap across all users and actions (sharded for throughput; enforced approximately). |
|
|
33
38
|
| **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
39
|
| **Model policy** | Allow/deny lists for models; an unknown/unpriced model **fails closed** (charged a conservative max, never $0). |
|
|
@@ -136,6 +141,7 @@ ai.chat(ctx, {
|
|
|
136
141
|
messages?, // [{ role, content }]
|
|
137
142
|
model?, // defaults to defaultModel
|
|
138
143
|
action?, // attribution name; defaults to the calling Convex action
|
|
144
|
+
tags?, // extra dimensions: [{ dimension: "customer", value: "acme" }, …]
|
|
139
145
|
}): Promise<{ text, requestId, costNanos, promptTokens, completionTokens, warnings }>
|
|
140
146
|
```
|
|
141
147
|
|
|
@@ -184,16 +190,21 @@ to the original. `lineage` walks the re-run chain in both directions.
|
|
|
184
190
|
ai.users.setLimits(ctx, {
|
|
185
191
|
userId,
|
|
186
192
|
requestsPerMinute?,
|
|
193
|
+
maxConcurrent?, // max in-flight requests at once
|
|
187
194
|
dailySpendLimitNanos?,
|
|
195
|
+
monthlySpendLimitNanos?, // calendar-month budget (UTC)
|
|
188
196
|
lifetimeSpendLimitNanos?,
|
|
189
197
|
dailyTokenLimit?,
|
|
198
|
+
monthlyTokenLimit?,
|
|
190
199
|
lifetimeTokenLimit?,
|
|
191
|
-
|
|
192
|
-
|
|
200
|
+
warnAtPct?, // e.g. 0.8 → fire onThreshold at 80% of a cap
|
|
201
|
+
enforcement?, // "hard" (block, default) | "soft" (warn but allow)
|
|
202
|
+
blocked?, // hard block on/off
|
|
193
203
|
})
|
|
194
204
|
ai.users.delete(ctx, { userId }) // remove a user and all their request rows
|
|
195
205
|
```
|
|
196
206
|
|
|
207
|
+
The same limit fields apply to `ai.actions.setLimits` and `ai.tag(d).setLimits`.
|
|
197
208
|
Pass a field as `undefined` to clear that limit (unlimited).
|
|
198
209
|
|
|
199
210
|
### Per-action budgets
|
|
@@ -208,10 +219,84 @@ ai.actions.setLimits(ctx, {
|
|
|
208
219
|
dailyTokenLimit?,
|
|
209
220
|
lifetimeTokenLimit?,
|
|
210
221
|
enforcement?, // "hard" | "soft"
|
|
211
|
-
|
|
222
|
+
blocked?, // kill switch for the whole feature
|
|
212
223
|
})
|
|
213
224
|
```
|
|
214
225
|
|
|
226
|
+
### Tagged budgets (custom dimensions)
|
|
227
|
+
|
|
228
|
+
`user` and `action` are the two built-in *dimensions*. To classify or budget
|
|
229
|
+
along any other axis — team, project, tenant, customer, environment, feature —
|
|
230
|
+
attach `tags` to a call and cap a value with `ai.tag(dimension)`:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
// Bill this call to a user, an action (implicit), AND a customer + env.
|
|
234
|
+
await ai.chat(ctx, {
|
|
235
|
+
prompt,
|
|
236
|
+
tags: [
|
|
237
|
+
{ dimension: "customer", value: "acme" },
|
|
238
|
+
{ dimension: "env", value: "prod" },
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Cap the customer "acme" to $50/day — independent of any per-user cap.
|
|
243
|
+
await ai.tag("customer").setLimits(ctx, {
|
|
244
|
+
value: "acme",
|
|
245
|
+
dailySpendLimitNanos: 50 * 1_000_000_000,
|
|
246
|
+
});
|
|
247
|
+
await ai.tag("customer").bump(ctx, { value: "acme", dailyNanos: 10 * 1_000_000_000 });
|
|
248
|
+
await ai.tag("customer").list(ctx); // every customer's spend & caps
|
|
249
|
+
await ai.tag("customer").get(ctx, { value: "acme" }); // one bucket
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
A single request is admitted only if it fits **every** bucket it touches (user,
|
|
253
|
+
action, and each tag) — the same exact reserve-then-settle check runs per bucket.
|
|
254
|
+
Uncapped buckets never serialize, so adding tags you don't cap is free at
|
|
255
|
+
admission; their running totals still accrue for reporting. `ai.users.*` and
|
|
256
|
+
`ai.actions.*` are simply sugar over `ai.tag("user")` / `ai.tag("action")`.
|
|
257
|
+
|
|
258
|
+
Every dimension namespace (`users`, `actions`, `tag(d)`) shares the same methods:
|
|
259
|
+
`list`, `get`, `setLimits`, `bump`, `adjust`, `history`, `adjustments`, `delete`.
|
|
260
|
+
|
|
261
|
+
### Spend history (survives retention)
|
|
262
|
+
|
|
263
|
+
Request rows are retained only briefly (see retention), but **durable per-bucket
|
|
264
|
+
day/month rollups are not** — so charts and "what did we spend last month" keep
|
|
265
|
+
working:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
await ai.users.history(ctx, { userId, period: "month" }); // [{ stamp, spendNanos, tokens, requests }]
|
|
269
|
+
await ai.tag("customer").history(ctx, { value: "acme", period: "day", limit: 30 });
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Approaching-limit alerts
|
|
273
|
+
|
|
274
|
+
Set a threshold (per bucket via `warnAtPct`, or a deployment default) and get a
|
|
275
|
+
callback before a cap is hit — plus one when a hard cap blocks:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
await ai.global.setAlertDefaults(ctx, { warnAtPct: 0.8 }); // 80%, all buckets
|
|
279
|
+
await ai.users.setLimits(ctx, { userId, warnAtPct: 0.9 }); // override per bucket
|
|
280
|
+
|
|
281
|
+
new AIBudget(components.aiBudget, {
|
|
282
|
+
onThreshold: ({ userId, messages }) => notify(userId, messages), // approaching
|
|
283
|
+
onLimitReached: ({ userId, reason }) => notify(userId, reason), // blocked
|
|
284
|
+
onSoftLimit: ({ userId, messages }) => notify(userId, messages), // soft-cap exceeded
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`chat()` also returns `notices` (approaching) alongside `warnings` (soft-exceeded).
|
|
289
|
+
|
|
290
|
+
### Manual credits & debits
|
|
291
|
+
|
|
292
|
+
Comp a user or correct an overcharge. Negative = credit, positive = extra charge;
|
|
293
|
+
it adjusts the live day/month/lifetime windows, the history, and an audit log:
|
|
294
|
+
|
|
295
|
+
```ts
|
|
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
|
+
|
|
215
300
|
### Global (deployment-wide) budget
|
|
216
301
|
|
|
217
302
|
```ts
|
|
@@ -249,10 +334,18 @@ common models (validated against OpenRouter's public pricing); override or add
|
|
|
249
334
|
any model:
|
|
250
335
|
|
|
251
336
|
```ts
|
|
252
|
-
ai.prices.set(ctx, { model, inputNanosPerMTok, outputNanosPerMTok }) //
|
|
337
|
+
ai.prices.set(ctx, { model, inputNanosPerMTok, outputNanosPerMTok, cachedNanosPerMTok? }) // ≥ 0
|
|
253
338
|
ai.prices.list(ctx)
|
|
254
339
|
```
|
|
255
340
|
|
|
341
|
+
**Cached tokens & actual cost.** The gateway reports a real cached-token count
|
|
342
|
+
(`usage.inputTokenDetails.cacheReadTokens`), so the cached slice of a prompt is
|
|
343
|
+
billed at `cachedNanosPerMTok` (or a default 10%-of-input discount) instead of
|
|
344
|
+
the full input rate. The gateway does **not** currently return a dollar cost, so
|
|
345
|
+
cost is computed from tokens — but `finishRequest` accepts an authoritative
|
|
346
|
+
`costNanos` and prefers it whenever present, so adopting a real gateway cost is a
|
|
347
|
+
one-line change the day it's available.
|
|
348
|
+
|
|
256
349
|
**Real prices from an API.** The gateway's `provider/model` ids match
|
|
257
350
|
OpenRouter's, whose public models endpoint returns per-token pricing — so you can
|
|
258
351
|
keep prices current from your own action (this is app code; the component just
|
|
@@ -277,9 +370,12 @@ flagged `unpricedModel: true` so you know to add a real price.
|
|
|
277
370
|
### Observability
|
|
278
371
|
|
|
279
372
|
```ts
|
|
280
|
-
ai.users.list(ctx) // per-user spend today /
|
|
373
|
+
ai.users.list(ctx) // per-user spend today / month / total / limits
|
|
281
374
|
ai.actions.list(ctx) // per-action spend & totals
|
|
282
|
-
ai.
|
|
375
|
+
ai.tag("customer").list(ctx) // spend & caps for any custom dimension
|
|
376
|
+
ai.users.history(ctx, { userId, period: "month" }) // durable spend-over-time
|
|
377
|
+
ai.requests.list(ctx, { userId?, limit? }) // the audit log (blocked included)
|
|
378
|
+
ai.requests.list(ctx, { dimension: "customer", value: "acme" }) // filter the log by any tag
|
|
283
379
|
```
|
|
284
380
|
|
|
285
381
|
---
|
|
@@ -305,15 +401,18 @@ same pre-spend total and all pass, so spend blows past the cap (measured at
|
|
|
305
401
|
**exactly-once** (a terminal request is never re-folded), so a slow request
|
|
306
402
|
that the reconciler already swept can't double-count when it finally returns.
|
|
307
403
|
|
|
308
|
-
Reservations are only taken on
|
|
309
|
-
traffic never serializes
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
404
|
+
Reservations are only taken on buckets that actually have a cap, so uncapped
|
|
405
|
+
traffic never serializes — this is what makes arbitrary `tags` cheap: a request
|
|
406
|
+
reserves on one row per *capped* dimension it carries, and nothing else. The
|
|
407
|
+
`error.md` file documents the adversarial audits this design survived, with live
|
|
408
|
+
repros.
|
|
409
|
+
|
|
410
|
+
**One guarantee, all scopes.** Every per-bucket cap — user, action, or any
|
|
411
|
+
custom tag dimension — and the global cap run through the *same* admission check:
|
|
412
|
+
a request is admitted only when `committed + reserved + estimate ≤ cap` (bumps
|
|
413
|
+
included) for **every** bucket it touches. The only thing that differs is the
|
|
414
|
+
holder: each per-bucket cap reserves on a single document, an exact atomic
|
|
415
|
+
check-and-reserve; the **global** killswitch is backed by a sharded
|
|
317
416
|
counter for throughput, so its committed total is read as an eventually-consistent
|
|
318
417
|
sum with no cross-request reservation. That makes the global cap **approximate** —
|
|
319
418
|
it can overshoot by a bounded amount under a burst — the deliberate
|