@convex-dev/ai-budget 0.0.2-alpha.16 → 0.0.2-alpha.18

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
@@ -390,6 +390,13 @@ reserve-then-settle admission check runs per bucket. Uncapped buckets never seri
390
390
  adding tags you don't cap is free at admission; their totals still accrue for
391
391
  reporting.
392
392
 
393
+ > **Choosing what to cap.** A cap makes its bucket's admissions atomic by
394
+ > reserving on that bucket's single row, so capping a dimension that *all* traffic
395
+ > shares (one `env`/`action` every request carries) serializes those admissions on
396
+ > one document under load. Prefer capping **naturally-sharded** dimensions — per
397
+ > `user`, per `customer` — and use the [global cap](#global-cap) for a
398
+ > deployment-wide ceiling. Uncapped high-traffic tags are always free.
399
+
393
400
  ### Tags — budgeting by any dimension
394
401
 
395
402
  ```ts
@@ -488,9 +495,12 @@ ai.global.status(ctx) // { limits, spentTodayNanos, spentTotalNanos, … }
488
495
  ai.global.bump(ctx, { dailyNanos?, lifetimeNanos? })
489
496
  ```
490
497
 
491
- A killswitch across everything. Backed by a sharded counter for throughput, so
492
- it's enforced **approximately** (bounded concurrency overshoot under burst).
493
- Per-bucket admission is atomic against each request's estimate.
498
+ A best-effort killswitch across everything. Backed by a sharded counter for
499
+ throughput, so it's enforced **approximately** under burst it can overshoot the
500
+ cap by a bounded amount, and it excludes in-flight (not-yet-settled) spend. It is
501
+ **not** a to-the-dollar ceiling: for an exact limit use a per-bucket cap (those
502
+ reserve atomically); reach for the global cap when you want a deployment-wide
503
+ "stop everything" switch.
494
504
 
495
505
  ### Model policy
496
506
 
@@ -552,6 +562,12 @@ ai.global.setRetention(ctx, { retentionMs }) // default 1h; 0 disables
552
562
  Full request rows (prompts + responses) are swept after the window to bound the
553
563
  audit table. **Spend history survives** — it lives in separate durable rollups.
554
564
 
565
+ > **Prompt/response content is stored** on the request row until the window sweeps
566
+ > it (default 1h), so it's visible in the request log and to admin reads until
567
+ > then. If your prompts carry PII you don't want retained, shorten `retentionMs`
568
+ > (or set it low enough that content lives only as long as you need replay). Spend
569
+ > rollups never contain content, so trimming retention doesn't cost you history.
570
+
555
571
  ---
556
572
 
557
573
  ## Admin dashboard
@@ -568,19 +584,29 @@ import { AIBudget } from "@convex-dev/ai-budget";
568
584
  const ai = new AIBudget(components.aiBudget);
569
585
  const http = httpRouter();
570
586
 
571
- ai.registerRoutes(http, {
572
- // Gate it — the endpoint is public. Recommended: check the caller is an admin.
573
- authorize: async (ctx) => (await ctx.auth.getUserIdentity())?.role === "admin",
574
- });
587
+ // Recommended: token mode. Set AI_BUDGET_DASHBOARD_TOKEN in the deployment env;
588
+ // open the dashboard with ?token=<it> once and the page keeps using it.
589
+ ai.registerRoutes(http);
575
590
 
576
591
  export default http;
577
592
  ```
578
593
 
579
594
  It lives at `https://<deployment>.convex.site/aibudget` (override with `path`).
580
- **It is a public internet endpoint, so you must gate it**: pass `authorize`
581
- (return `true` to allow) or set `AI_BUDGET_DASHBOARD_TOKEN` (sent as
582
- `Authorization: Bearer …`). With neither, every route returns 401. Everything the
583
- page shows is backed by the component's own functions nothing else to wire up.
595
+ **It is a public internet endpoint, so you must gate it**, one of two ways:
596
+
597
+ - **Token (recommended, works end-to-end):** set the `AI_BUDGET_DASHBOARD_TOKEN`
598
+ env var. Open `…/aibudget?token=<token>` once; the page strips it from the URL
599
+ and sends it as a bearer on every API call. `?token=` is accepted only on the
600
+ page navigation, and the compare is constant-time.
601
+ - **`authorize(ctx, request)` callback:** must authenticate from something the
602
+ **browser sends on a top-level navigation** — a cookie or a header *you*
603
+ control — because a page load carries no `Authorization` bearer. `ctx.auth`
604
+ (deployment JWT) is `null` for the HTML page, so `authorize: (ctx) => (await
605
+ ctx.auth.getUserIdentity())?.role === "admin"` will 401 the page. Use it only
606
+ when you have your own session cookie to check.
607
+
608
+ With neither configured, every route returns 401. Everything the page shows is
609
+ backed by the component's own functions — nothing else to wire up.
584
610
 
585
611
  ---
586
612
 
@@ -625,6 +651,19 @@ more tokens or costs more than estimated, settlement records the real amount and
625
651
  the final total can exceed a hard cap by that request's estimation delta. The next
626
652
  admission sees the settled total and blocks until there is headroom again.
627
653
 
654
+ **Throughput characteristics (know these before you turn on a global cap).**
655
+ Reconciliation runs as small, independently-rescheduling phases, so folding,
656
+ expiry, and retention can't stall each other and each drains its own backlog.
657
+ Two shared-write hot spots remain, by design:
658
+ - The **global cap** reads a sharded counter *inside* admission, which contends
659
+ with every fold that writes it — so a configured global cap adds contention on
660
+ the hot path. It's a deployment-wide killswitch, not a high-throughput per-call
661
+ limit; prefer per-bucket caps for the common case.
662
+ - **Settlement writes every attributed bucket**, including the per-request
663
+ `action` bucket that every call shares. That single row is written by every
664
+ settle, so an extremely high single-action settle rate can lag totals. Spread
665
+ load across naturally-sharded dimensions (per user/customer) where you can.
666
+
628
667
  The `error.md` file documents the adversarial audits this design survived, with
629
668
  live repros.
630
669
 
@@ -97,8 +97,18 @@ async function renderBuckets() {
97
97
  const dims = ["user", "action"];
98
98
  const state = { dimension: window.__dim ?? "" };
99
99
  const rows = await get("/buckets", state.dimension ? { dimension: state.dimension } : {});
100
- const grand = rows.reduce((s, b) => s + b.totalSpendNanos, 0);
101
- document.getElementById("total").textContent = rows.length + " buckets · " + usd(grand) + " total";
100
+ // Within ONE dimension each request bills exactly one bucket, so summing is a
101
+ // true total. Across dimensions a request bills several buckets (user + action
102
+ // + tags), so summing multi-counts — use the deployment-wide spend instead.
103
+ let totalText;
104
+ if (state.dimension) {
105
+ const grand = rows.reduce((s, b) => s + b.totalSpendNanos, 0);
106
+ totalText = rows.length + " buckets · " + usd(grand) + " total (" + state.dimension + ")";
107
+ } else {
108
+ const g = await get("/global", {});
109
+ totalText = rows.length + " buckets · " + usd(g.spentTotalNanos ?? 0) + " spent (deployment)";
110
+ }
111
+ document.getElementById("total").textContent = totalText;
102
112
  const dimSet = [...new Set(rows.map((b) => b.dimension).concat(dims))];
103
113
  const sel = el("select", { value: state.dimension, style: "width:140px",
104
114
  onchange: (e) => { window.__dim = e.target.value; render(); } },
@@ -39,8 +39,17 @@ function toTokenCount(x) {
39
39
  }
40
40
  function extractUsage(usage) {
41
41
  return {
42
- promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens),
43
- completionTokens: toTokenCount(usage?.outputTokens ?? usage?.completionTokens),
42
+ // Cover AI SDK camelCase (v5/v7), raw OpenAI-compatible snake_case, AND raw
43
+ // Anthropic (`input_tokens`/`output_tokens`), so a `meter` caller passing any
44
+ // provider's raw `usage` object still gets counts.
45
+ promptTokens: toTokenCount(usage?.inputTokens ??
46
+ usage?.promptTokens ??
47
+ usage?.prompt_tokens ??
48
+ usage?.input_tokens),
49
+ completionTokens: toTokenCount(usage?.outputTokens ??
50
+ usage?.completionTokens ??
51
+ usage?.completion_tokens ??
52
+ usage?.output_tokens),
44
53
  // cached prompt tokens. The Convex gateway reports these at
45
54
  // `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
46
55
  // (`cachedInputTokens`) and raw OpenAI-compatible shapes.
@@ -48,7 +57,8 @@ function extractUsage(usage) {
48
57
  usage?.cachedInputTokens ??
49
58
  usage?.promptTokensDetails?.cachedTokens ??
50
59
  usage?.prompt_tokens_details?.cached_tokens ??
51
- usage?.cached_tokens),
60
+ usage?.cached_tokens ??
61
+ usage?.cache_read_input_tokens),
52
62
  };
53
63
  }
54
64
  // The AI Gateway reports the authoritative dollar cost of each request.
@@ -70,6 +80,10 @@ function extractGatewayCostNanos(result) {
70
80
  return undefined;
71
81
  }
72
82
  const NANOS_PER_DOLLAR = 1e9;
83
+ // Cap stored prompt/response content so one row can't approach Convex's 1 MiB
84
+ // document limit (which would fail the call) or bloat the reconciler's scans.
85
+ const MAX_STORED_CONTENT = 32 * 1024;
86
+ const capContent = (s) => s.length > MAX_STORED_CONTENT ? s.slice(0, MAX_STORED_CONTENT) + "…[truncated]" : s;
73
87
  // Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
74
88
  function simplifyPrompt(prompt) {
75
89
  if (!Array.isArray(prompt))
@@ -81,13 +95,22 @@ function simplifyPrompt(prompt) {
81
95
  }
82
96
  else if (Array.isArray(m.content)) {
83
97
  content = m.content
84
- .map((part) => part?.type === "text" ? part.text : JSON.stringify(part))
98
+ .map((part) => {
99
+ if (part?.type === "text")
100
+ return part.text ?? "";
101
+ // NEVER inline a base64 image/file part — a data URL or Uint8Array here
102
+ // becomes megabytes, pushing the stored row past the 1 MiB doc limit
103
+ // (failing the call) and turning a 200 KB image into a ~50k-token
104
+ // estimate. Store a compact placeholder.
105
+ const bytes = part?.data?.length ?? part?.image?.length ?? part?.data?.byteLength;
106
+ return `[${part?.type ?? "part"}${typeof bytes === "number" ? ` ${bytes}b` : ""}]`;
107
+ })
85
108
  .join("");
86
109
  }
87
110
  else {
88
111
  content = JSON.stringify(m.content);
89
112
  }
90
- return { role: String(m.role), content };
113
+ return { role: String(m.role), content: capContent(content) };
91
114
  });
92
115
  }
93
116
  function extractText(result) {
@@ -240,35 +263,42 @@ export class AIBudget {
240
263
  }
241
264
  const { requestId, warnings, notices } = started;
242
265
  const start = Date.now();
266
+ // Run the provider call. ONLY a failure of the call itself settles as an
267
+ // error (no charge expected).
268
+ let out;
243
269
  try {
244
- const out = await run();
245
- const { costNanos } = await this.settle(ctx, {
246
- requestId,
247
- responseText: out.text,
248
- usage: out.usage,
249
- promptTokens: out.promptTokens,
250
- completionTokens: out.completionTokens,
251
- cachedTokens: out.cachedTokens,
252
- serverToolUses: out.serverToolUses,
253
- costNanos: out.costNanos,
254
- latencyMs: Date.now() - start,
255
- });
256
- // Re-derive the recorded usage for the return value.
257
- const usage = out.promptTokens !== undefined ||
258
- out.completionTokens !== undefined ||
259
- out.cachedTokens !== undefined
260
- ? {
261
- promptTokens: out.promptTokens ?? 0,
262
- completionTokens: out.completionTokens ?? 0,
263
- cachedTokens: out.cachedTokens ?? 0,
264
- }
265
- : extractUsage(out.usage);
266
- return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
270
+ out = await run();
267
271
  }
268
272
  catch (e) {
269
273
  await this.settle(ctx, { requestId, error: String(e), latencyMs: Date.now() - start });
270
274
  throw e;
271
275
  }
276
+ // The call SUCCEEDED (the provider may have charged). Settle the real usage.
277
+ // If settlement itself fails here, do NOT fall into an error-settle that
278
+ // records zero — that would erase a real charge. Rethrow and leave the
279
+ // reservation for the reconciler; billing stays "unknown", never a false zero.
280
+ const { costNanos } = await this.settle(ctx, {
281
+ requestId,
282
+ responseText: out.text,
283
+ usage: out.usage,
284
+ promptTokens: out.promptTokens,
285
+ completionTokens: out.completionTokens,
286
+ cachedTokens: out.cachedTokens,
287
+ serverToolUses: out.serverToolUses,
288
+ costNanos: out.costNanos,
289
+ latencyMs: Date.now() - start,
290
+ });
291
+ // Re-derive the recorded usage for the return value.
292
+ const usage = out.promptTokens !== undefined ||
293
+ out.completionTokens !== undefined ||
294
+ out.cachedTokens !== undefined
295
+ ? {
296
+ promptTokens: out.promptTokens ?? 0,
297
+ completionTokens: out.completionTokens ?? 0,
298
+ cachedTokens: out.cachedTokens ?? 0,
299
+ }
300
+ : extractUsage(out.usage);
301
+ return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
272
302
  }
273
303
  /**
274
304
  * One-shot chat through the AI Gateway with tracking + limits — sugar over
@@ -415,15 +445,10 @@ export class AIBudget {
415
445
  wrapGenerate: async ({ doGenerate, params }) => {
416
446
  const requestId = await begin(params);
417
447
  const start = Date.now();
448
+ // Only a failure of the generation itself settles as an error.
449
+ let result;
418
450
  try {
419
- const result = await doGenerate();
420
- await finish(requestId, {
421
- responseText: extractText(result),
422
- ...extractUsage(result.usage),
423
- costNanos: extractGatewayCostNanos(result),
424
- latencyMs: Date.now() - start,
425
- });
426
- return result;
451
+ result = await doGenerate();
427
452
  }
428
453
  catch (e) {
429
454
  await finish(requestId, {
@@ -432,6 +457,15 @@ export class AIBudget {
432
457
  });
433
458
  throw e;
434
459
  }
460
+ // Generation succeeded (provider may have charged). Settle the real
461
+ // usage; a failure here rethrows rather than recording a false zero.
462
+ await finish(requestId, {
463
+ responseText: extractText(result),
464
+ ...extractUsage(result.usage),
465
+ costNanos: extractGatewayCostNanos(result),
466
+ latencyMs: Date.now() - start,
467
+ });
468
+ return result;
435
469
  },
436
470
  wrapStream: async ({ doStream, params }) => {
437
471
  const requestId = await begin(params);
@@ -446,21 +480,22 @@ export class AIBudget {
446
480
  // chunk, or a cancel — is safe: the first wins, the rest no-op.
447
481
  // Without this an errored or abandoned stream would never settle and
448
482
  // its real usage would be lost (recorded as free by the reconciler).
449
- let settled = false;
450
- const settle = (error) => {
451
- if (settled)
452
- return;
453
- settled = true;
454
- return finish(requestId, {
455
- responseText: text,
456
- error,
457
- ...extractUsage(usage),
458
- costNanos: extractGatewayCostNanos({ providerMetadata }),
459
- latencyMs: Date.now() - start,
460
- });
461
- };
483
+ // Settle at most once, memoizing the PROMISE so the finish chunk, an
484
+ // error chunk, and flush all await the same settlement instead of
485
+ // racing, dropping it (the old `void settle()`), or flipping a
486
+ // "settled" flag before the mutation actually committed. A stream
487
+ // that is cancelled/never fully consumed won't deliver finish or
488
+ // flush; the reconciler's reservation expiry is the backstop there.
489
+ let settlement;
490
+ const settle = (error) => (settlement ??= finish(requestId, {
491
+ responseText: text,
492
+ error,
493
+ ...extractUsage(usage),
494
+ costNanos: extractGatewayCostNanos({ providerMetadata }),
495
+ latencyMs: Date.now() - start,
496
+ }));
462
497
  const tapped = result.stream.pipeThrough(new TransformStream({
463
- transform(chunk, controller) {
498
+ async transform(chunk, controller) {
464
499
  if (chunk?.type === "text-delta") {
465
500
  text += chunk.delta ?? chunk.textDelta ?? "";
466
501
  }
@@ -468,13 +503,30 @@ export class AIBudget {
468
503
  usage = chunk.usage;
469
504
  providerMetadata = chunk.providerMetadata ?? providerMetadata;
470
505
  }
471
- if (chunk?.type === "error")
472
- void settle(String(chunk.error));
473
506
  controller.enqueue(chunk);
507
+ // Settle after forwarding the terminal error chunk, and AWAIT
508
+ // it so a failed settle surfaces instead of being dropped.
509
+ if (chunk?.type === "error")
510
+ await settle(String(chunk.error));
474
511
  },
475
512
  async flush() {
476
513
  await settle();
477
514
  },
515
+ // A cancelled/aborted stream (client disconnect, AbortSignal, or
516
+ // breaking out of `for await`) does NOT run `flush`. Without this
517
+ // the request would sit pending until the 30-min reservation
518
+ // expiry and be recorded as $0 though the provider charged for
519
+ // what streamed. Settle here with the text so far, estimating the
520
+ // completion tokens when the provider gave no usage.
521
+ // `cancel` is a newer Streams-spec transformer hook not yet in
522
+ // the TS DOM lib types; cast the literal so it compiles (runtimes
523
+ // without it simply won't call it, and the reconciler backstops).
524
+ async cancel(reason) {
525
+ if (usage === undefined && text) {
526
+ usage = { outputTokens: Math.ceil(text.length / 4) };
527
+ }
528
+ await settle(`cancelled: ${String(reason)}`);
529
+ },
478
530
  }));
479
531
  return { ...result, stream: tapped };
480
532
  }
@@ -658,16 +710,26 @@ export class AIBudget {
658
710
  return { ok: false, token: "" };
659
711
  const url = new URL(request.url);
660
712
  const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
661
- // `?token=` is accepted only for the initial page navigation (a browser
662
- // GET can't set headers); the page strips it from the URL on load and the
663
- // JSON API is called with the bearer header. Compared in constant time.
664
- const provided = bearer || url.searchParams.get("token") || "";
713
+ // `?token=` is accepted ONLY for the initial page navigation (a browser GET
714
+ // can't set headers); the page strips it from the URL on load and calls the
715
+ // JSON API with the bearer header. Restrict it to that GET page route so a
716
+ // token can't be smuggled in a query string on API/mutation calls (where it
717
+ // would also land in access logs). Compared in constant time.
718
+ const sub = url.pathname.slice(prefix.length) || "/";
719
+ const isPageNav = request.method === "GET" && !sub.startsWith("/api");
720
+ const provided = bearer || (isPageNav ? url.searchParams.get("token") ?? "" : "");
665
721
  return { ok: timingSafeEqual(provided, token), token };
666
722
  };
667
723
  const json = (data, status = 200) => new Response(JSON.stringify(data ?? null), {
668
724
  status,
669
- headers: { "content-type": "application/json" },
725
+ // Never let a shared cache/proxy retain budget data or the token-bearing
726
+ // page — these responses are per-viewer and sensitive.
727
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
670
728
  });
729
+ // JSON.stringify does NOT escape `<`, so a value containing `</script>`
730
+ // would close the inline <script> and break out. Escape `<` (and the JS line
731
+ // separators) before embedding in HTML.
732
+ const jsonForScript = (x) => JSON.stringify(x).replace(/[<\u2028\u2029]/g, (ch) => "\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0"));
671
733
  const handle = async (ctx, request) => {
672
734
  const url = new URL(request.url);
673
735
  const sub = url.pathname.slice(prefix.length) || "/";
@@ -724,12 +786,16 @@ export class AIBudget {
724
786
  return json({ error: "not found" }, 404);
725
787
  }
726
788
  }
727
- // Inject as JSON literals (function replacers so `$` in the value isn't
728
- // treated as a replacement pattern). This keeps a token/prefix containing
729
- // quotes, backslashes, or `</script>` from breaking out of the JS string.
730
- const html = DASHBOARD_HTML.replace(/__API_BASE__/g, () => JSON.stringify(`${prefix}/api`)).replace(/__TOKEN__/g, () => JSON.stringify(token));
789
+ // Inject as script-safe JSON literals (function replacers so `$` in the
790
+ // value isn't treated as a replacement pattern; `jsonForScript` escapes
791
+ // `<` so a token containing `</script>` can't break out of the inline JS).
792
+ const html = DASHBOARD_HTML.replace(/__API_BASE__/g, () => jsonForScript(`${prefix}/api`)).replace(/__TOKEN__/g, () => jsonForScript(token));
793
+ // The page embeds the bearer token — never let a shared cache retain it.
731
794
  return new Response(html, {
732
- headers: { "content-type": "text/html; charset=utf-8" },
795
+ headers: {
796
+ "content-type": "text/html; charset=utf-8",
797
+ "cache-control": "no-store",
798
+ },
733
799
  });
734
800
  };
735
801
  const handler = httpActionGeneric(handle);
@@ -117,9 +117,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
117
117
  warnAtPct?: number;
118
118
  }, null, Name>;
119
119
  setGlobalLimits: FunctionReference<"mutation", "internal", {
120
- dailySpendLimitNanos?: number;
121
- enforcement?: "hard" | "soft";
122
- lifetimeSpendLimitNanos?: number;
120
+ dailySpendLimitNanos?: number | null;
121
+ enforcement?: "hard" | "soft" | null;
122
+ lifetimeSpendLimitNanos?: number | null;
123
123
  }, null, Name>;
124
124
  setModelPolicy: FunctionReference<"mutation", "internal", {
125
125
  mode: "open" | "allowlist" | "denylist";
@@ -39,9 +39,14 @@ export declare const finishRequest: import("convex/server").RegisteredMutation<"
39
39
  export declare const foldTotals: import("convex/server").RegisteredMutation<"internal", {
40
40
  requestId: import("convex/values").GenericId<"requests">;
41
41
  }, Promise<null>>;
42
- export declare const reconcile: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
42
+ export declare const reconcile: import("convex/server").RegisteredMutation<"internal", {}, Promise<null>>;
43
+ export declare const foldPhase: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
43
44
  folded: number;
45
+ }>>;
46
+ export declare const expirePhase: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
44
47
  expired: number;
48
+ }>>;
49
+ export declare const retentionPhase: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
45
50
  purged: number;
46
51
  }>>;
47
52
  export declare const setRetention: import("convex/server").RegisteredMutation<"public", {
@@ -377,9 +382,9 @@ export declare const getGlobalStatus: import("convex/server").RegisteredQuery<"p
377
382
  defaultWarnAtPct: number | null;
378
383
  }>>;
379
384
  export declare const setGlobalLimits: import("convex/server").RegisteredMutation<"public", {
380
- dailySpendLimitNanos?: number | undefined;
381
- lifetimeSpendLimitNanos?: number | undefined;
382
- enforcement?: "hard" | "soft" | undefined;
385
+ dailySpendLimitNanos?: number | null | undefined;
386
+ lifetimeSpendLimitNanos?: number | null | undefined;
387
+ enforcement?: "hard" | "soft" | null | undefined;
383
388
  }, Promise<null>>;
384
389
  export declare const bumpGlobal: import("convex/server").RegisteredMutation<"public", {
385
390
  dailyNanos?: number | undefined;