@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 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
- | **Spend & token limits** | Per-user daily / lifetime **spend** and **token** budgets, plus a requests-per-minute rate limit and a block switch. |
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 disable a whole action (e.g. `summarize`) independently of any user. |
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
- enforcement?, // "hard" (block, default) | "soft" (warn but allow)
192
- blocked?, // hard block on/off
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
- disabled?, // kill switch for the whole feature
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 }) // must be ≥ 0
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 / total / tokens / limits
373
+ ai.users.list(ctx) // per-user spend today / month / total / limits
281
374
  ai.actions.list(ctx) // per-action spend & totals
282
- ai.requests.list(ctx, { userId?, limit? }) // the audit log (blocked attempts included)
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 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
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