@convex-dev/ai-budget 0.0.2-alpha.15 → 0.0.2-alpha.17

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
@@ -23,7 +23,7 @@ full audit log you can replay later.
23
23
  - [Setup](#setup)
24
24
  - [Quickstart](#quickstart)
25
25
  - [Concepts](#concepts) — dimensions, nanodollars, reserve→settle
26
- - [Generating text](#generating-text) — `chat`, `languageModel`, replay
26
+ - [Generating text](#generating-text) — `chat`, `languageModel`, `meter`, `decisions`, replay
27
27
  - [Budgets & limits](#budgets--limits) — set caps, bumps, credits, alerts
28
28
  - [Monitoring](#monitoring) — totals, spend history, the request log
29
29
  - [Deployment-wide controls](#deployment-wide-controls) — global cap, model policy, pricing, retention
@@ -251,6 +251,35 @@ await ai.meter(ctx,
251
251
  });
252
252
  ```
253
253
 
254
+ ### `ai.decisions` — structured decisions (Jev)
255
+
256
+ Budget the gateway's Decisions endpoint ([Jev](https://docs.typesafe.ai)) — typed
257
+ `choice` / `score` / `boolean` questions evaluated against a `state` — with the
258
+ same limits, audit log, cost tracking, and tags as `ai.chat`:
259
+
260
+ ```ts
261
+ const { answers, costNanos } = await ai.decisions(ctx, {
262
+ state: { ticket: "Customer cannot sign in" },
263
+ questions: {
264
+ priority: {
265
+ type: "choice",
266
+ instructions: "Choose the response priority",
267
+ criteria: { urgent: "Respond now", normal: "Respond today" },
268
+ },
269
+ needsReview: { type: "boolean", instructions: "Does a human need to review this?" },
270
+ },
271
+ tags: [{ dimension: "team", value: "support" }],
272
+ });
273
+ answers.priority.choice; // "urgent" | "normal"
274
+ answers.needsReview.probability;
275
+ ```
276
+
277
+ Sugar over `ai.meter`, so cost is the gateway's authoritative amount. Model
278
+ defaults to `defaultEvalModel` (`"typesafe/jev-1.13"`). Requires
279
+ `@convex-dev/ai-sdk-provider` ≥ 0.2.1 and an `ai` version with
280
+ `experimental_evaluate` — both imported lazily, so callers who don't use
281
+ `decisions` are unaffected.
282
+
254
283
  ### `ai.begin` / `ai.settle` — long async jobs (video)
255
284
 
256
285
  A video job is submit → wait minutes → poll/webhook → done, spanning multiple
@@ -361,6 +390,13 @@ reserve-then-settle admission check runs per bucket. Uncapped buckets never seri
361
390
  adding tags you don't cap is free at admission; their totals still accrue for
362
391
  reporting.
363
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
+
364
400
  ### Tags — budgeting by any dimension
365
401
 
366
402
  ```ts
@@ -459,9 +495,12 @@ ai.global.status(ctx) // { limits, spentTodayNanos, spentTotalNanos, … }
459
495
  ai.global.bump(ctx, { dailyNanos?, lifetimeNanos? })
460
496
  ```
461
497
 
462
- A killswitch across everything. Backed by a sharded counter for throughput, so
463
- it's enforced **approximately** (bounded concurrency overshoot under burst).
464
- 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.
465
504
 
466
505
  ### Model policy
467
506
 
@@ -523,6 +562,12 @@ ai.global.setRetention(ctx, { retentionMs }) // default 1h; 0 disables
523
562
  Full request rows (prompts + responses) are swept after the window to bound the
524
563
  audit table. **Spend history survives** — it lives in separate durable rollups.
525
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
+
526
571
  ---
527
572
 
528
573
  ## Admin dashboard
@@ -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,6 +39,8 @@ export type SoftLimitInfo = BudgetEventInfo & {
39
39
  };
40
40
  export type AIBudgetOptions = {
41
41
  defaultModel?: string;
42
+ /** Default model for `decisions()` (the Decisions/"Jev" endpoint). */
43
+ defaultEvalModel?: string;
42
44
  /**
43
45
  * A *soft* limit was exceeded (request still allowed). Lets you surface budget
44
46
  * warnings even on the languageModel/Agent path where they can't be returned.
@@ -82,6 +84,16 @@ export type ChatResult = {
82
84
  /** Approaching-cap notices (empty unless a warnAtPct threshold was crossed). */
83
85
  notices: string[];
84
86
  };
87
+ /** The tracked result of a `decisions()` call: budgeting metadata plus the
88
+ * structured answers from the Decisions ("Jev") endpoint. */
89
+ export type DecisionResult = Omit<ChatResult, "text"> & {
90
+ /** Structured answers keyed by your question names (shape depends on each
91
+ * question type: `choice`, `score`, or `boolean`). */
92
+ answers: Record<string, any>;
93
+ /** The raw gateway response, including provider-specific fields (e.g.
94
+ * `confidence`) under `response.body`. */
95
+ response?: any;
96
+ };
85
97
  /** Limits/controls settable on any budget bucket (user, action, or tag). */
86
98
  export type BucketLimits = {
87
99
  /** Token-bucket refill per minute and burst capacity; 0 blocks all requests. */
@@ -107,6 +119,7 @@ export type BumpArgs = {
107
119
  export declare class AIBudget {
108
120
  component: AIBudgetApi;
109
121
  defaultModel: string;
122
+ defaultEvalModel: string;
110
123
  private onSoftLimit?;
111
124
  private onThreshold?;
112
125
  private onLimitReached?;
@@ -208,6 +221,46 @@ export declare class AIBudget {
208
221
  /** Extra attribution dimensions to bill/limit (team, customer, env, …). */
209
222
  tags?: Tag[];
210
223
  }): Promise<ChatResult>;
224
+ /**
225
+ * Budget a structured decision through the AI Gateway's Decisions ("Jev")
226
+ * endpoint — sugar over `meter`. Evaluates typed `questions` (choice / score /
227
+ * boolean) about the `state` you provide, with the same reserve→settle
228
+ * limits, audit log, cost tracking, and per-tag attribution as `chat`. Call
229
+ * from an action. `userId` defaults to the authenticated caller.
230
+ *
231
+ * Requires `@convex-dev/ai-sdk-provider` >= 0.2.1 and an `ai` version that
232
+ * exposes `experimental_evaluate` (AI SDK 7's evaluation interface); both are
233
+ * imported lazily, so consumers who never call `decisions()` are unaffected.
234
+ *
235
+ * const { answers } = await ai.decisions(ctx, {
236
+ * state: { ticket: "Customer cannot sign in" },
237
+ * questions: {
238
+ * priority: { type: "choice", instructions: "...", criteria: { urgent: "...", normal: "..." } },
239
+ * needsReview: { type: "boolean", instructions: "..." },
240
+ * },
241
+ * });
242
+ * answers.priority.choice; // "urgent" | "normal"
243
+ */
244
+ decisions(ctx: RunMutationCtx, args: {
245
+ /** The evaluation model. Defaults to `defaultEvalModel` ("typesafe/jev-1.13"). */
246
+ model?: string;
247
+ /** Context the questions are evaluated against (a string or an object). */
248
+ state: unknown;
249
+ /** Typed questions (choice / score / boolean) keyed by name. */
250
+ questions: Record<string, unknown>;
251
+ /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
252
+ userId?: string;
253
+ /** Attribute spend to this action name. Defaults to the calling action. */
254
+ action?: string;
255
+ /** Extra attribution dimensions to bill/limit (team, customer, env, …). */
256
+ tags?: Tag[];
257
+ /** Reserve this exact amount (nanodollars) up front — the decision cost
258
+ * isn't known before the call, so a hard cap is only exact with this. */
259
+ estimatedCostNanos?: number;
260
+ rerunOf?: string;
261
+ /** Cancel the underlying request. */
262
+ abortSignal?: AbortSignal;
263
+ }): Promise<DecisionResult>;
211
264
  /**
212
265
  * An AI SDK LanguageModel that enforces limits and records usage/cost for
213
266
  * `userId` on every call. Drop it into `generateText`, `streamText`, or the
@@ -39,8 +39,10 @@ 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) AND raw OpenAI-compatible snake_case, so a
43
+ // `meter` caller passing a raw provider `usage` object still gets counts.
44
+ promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens ?? usage?.prompt_tokens),
45
+ completionTokens: toTokenCount(usage?.outputTokens ?? usage?.completionTokens ?? usage?.completion_tokens),
44
46
  // cached prompt tokens. The Convex gateway reports these at
45
47
  // `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
46
48
  // (`cachedInputTokens`) and raw OpenAI-compatible shapes.
@@ -115,12 +117,14 @@ function timingSafeEqual(a, b) {
115
117
  export class AIBudget {
116
118
  component;
117
119
  defaultModel;
120
+ defaultEvalModel;
118
121
  onSoftLimit;
119
122
  onThreshold;
120
123
  onLimitReached;
121
124
  constructor(component, options) {
122
125
  this.component = component;
123
126
  this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
127
+ this.defaultEvalModel = options?.defaultEvalModel ?? "typesafe/jev-1.13";
124
128
  this.onSoftLimit = options?.onSoftLimit;
125
129
  this.onThreshold = options?.onThreshold;
126
130
  this.onLimitReached = options?.onLimitReached;
@@ -238,35 +242,42 @@ export class AIBudget {
238
242
  }
239
243
  const { requestId, warnings, notices } = started;
240
244
  const start = Date.now();
245
+ // Run the provider call. ONLY a failure of the call itself settles as an
246
+ // error (no charge expected).
247
+ let out;
241
248
  try {
242
- const out = await run();
243
- const { costNanos } = await this.settle(ctx, {
244
- requestId,
245
- responseText: out.text,
246
- usage: out.usage,
247
- promptTokens: out.promptTokens,
248
- completionTokens: out.completionTokens,
249
- cachedTokens: out.cachedTokens,
250
- serverToolUses: out.serverToolUses,
251
- costNanos: out.costNanos,
252
- latencyMs: Date.now() - start,
253
- });
254
- // Re-derive the recorded usage for the return value.
255
- const usage = out.promptTokens !== undefined ||
256
- out.completionTokens !== undefined ||
257
- out.cachedTokens !== undefined
258
- ? {
259
- promptTokens: out.promptTokens ?? 0,
260
- completionTokens: out.completionTokens ?? 0,
261
- cachedTokens: out.cachedTokens ?? 0,
262
- }
263
- : extractUsage(out.usage);
264
- return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
249
+ out = await run();
265
250
  }
266
251
  catch (e) {
267
252
  await this.settle(ctx, { requestId, error: String(e), latencyMs: Date.now() - start });
268
253
  throw e;
269
254
  }
255
+ // The call SUCCEEDED (the provider may have charged). Settle the real usage.
256
+ // If settlement itself fails here, do NOT fall into an error-settle that
257
+ // records zero — that would erase a real charge. Rethrow and leave the
258
+ // reservation for the reconciler; billing stays "unknown", never a false zero.
259
+ const { costNanos } = await this.settle(ctx, {
260
+ requestId,
261
+ responseText: out.text,
262
+ usage: out.usage,
263
+ promptTokens: out.promptTokens,
264
+ completionTokens: out.completionTokens,
265
+ cachedTokens: out.cachedTokens,
266
+ serverToolUses: out.serverToolUses,
267
+ costNanos: out.costNanos,
268
+ latencyMs: Date.now() - start,
269
+ });
270
+ // Re-derive the recorded usage for the return value.
271
+ const usage = out.promptTokens !== undefined ||
272
+ out.completionTokens !== undefined ||
273
+ out.cachedTokens !== undefined
274
+ ? {
275
+ promptTokens: out.promptTokens ?? 0,
276
+ completionTokens: out.completionTokens ?? 0,
277
+ cachedTokens: out.cachedTokens ?? 0,
278
+ }
279
+ : extractUsage(out.usage);
280
+ return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
270
281
  }
271
282
  /**
272
283
  * One-shot chat through the AI Gateway with tracking + limits — sugar over
@@ -302,6 +313,72 @@ export class AIBudget {
302
313
  };
303
314
  });
304
315
  }
316
+ /**
317
+ * Budget a structured decision through the AI Gateway's Decisions ("Jev")
318
+ * endpoint — sugar over `meter`. Evaluates typed `questions` (choice / score /
319
+ * boolean) about the `state` you provide, with the same reserve→settle
320
+ * limits, audit log, cost tracking, and per-tag attribution as `chat`. Call
321
+ * from an action. `userId` defaults to the authenticated caller.
322
+ *
323
+ * Requires `@convex-dev/ai-sdk-provider` >= 0.2.1 and an `ai` version that
324
+ * exposes `experimental_evaluate` (AI SDK 7's evaluation interface); both are
325
+ * imported lazily, so consumers who never call `decisions()` are unaffected.
326
+ *
327
+ * const { answers } = await ai.decisions(ctx, {
328
+ * state: { ticket: "Customer cannot sign in" },
329
+ * questions: {
330
+ * priority: { type: "choice", instructions: "...", criteria: { urgent: "...", normal: "..." } },
331
+ * needsReview: { type: "boolean", instructions: "..." },
332
+ * },
333
+ * });
334
+ * answers.priority.choice; // "urgent" | "normal"
335
+ */
336
+ async decisions(ctx, args) {
337
+ const model = args.model ?? this.defaultEvalModel;
338
+ // `evaluate` is an experimental, version-gated export; import it lazily and
339
+ // untyped so consumers on an older `ai` (who never call this) aren't broken.
340
+ const evaluate = (await import("ai")).experimental_evaluate;
341
+ if (typeof evaluate !== "function") {
342
+ throw new Error("ai-budget: decisions() needs `experimental_evaluate` from the `ai` " +
343
+ "package (AI SDK 7's evaluation interface). Upgrade `ai` to a " +
344
+ "version that exports it.");
345
+ }
346
+ // Likewise, `evaluationModel` exists on @convex-dev/ai-sdk-provider >= 0.2.1.
347
+ const evaluationModel = convexGateway.evaluationModel;
348
+ if (typeof evaluationModel !== "function") {
349
+ throw new Error("ai-budget: decisions() needs `convexGateway.evaluationModel` from " +
350
+ "@convex-dev/ai-sdk-provider >= 0.2.1. Upgrade the provider.");
351
+ }
352
+ let decision;
353
+ const result = await this.meter(ctx, {
354
+ model,
355
+ // Store the structured request for audit/replay.
356
+ messages: [
357
+ {
358
+ role: "user",
359
+ content: JSON.stringify({ state: args.state, questions: args.questions }),
360
+ },
361
+ ],
362
+ userId: args.userId,
363
+ action: args.action,
364
+ tags: args.tags,
365
+ estimatedCostNanos: args.estimatedCostNanos,
366
+ rerunOf: args.rerunOf,
367
+ }, async () => {
368
+ decision = await evaluate({
369
+ model: evaluationModel(model),
370
+ state: args.state,
371
+ questions: args.questions,
372
+ ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
373
+ });
374
+ return {
375
+ usage: decision?.usage,
376
+ costNanos: extractGatewayCostNanos(decision),
377
+ };
378
+ });
379
+ const { text: _text, ...tracking } = result;
380
+ return { ...tracking, answers: decision?.answers ?? {}, response: decision?.response };
381
+ }
305
382
  /**
306
383
  * An AI SDK LanguageModel that enforces limits and records usage/cost for
307
384
  * `userId` on every call. Drop it into `generateText`, `streamText`, or the
@@ -347,15 +424,10 @@ export class AIBudget {
347
424
  wrapGenerate: async ({ doGenerate, params }) => {
348
425
  const requestId = await begin(params);
349
426
  const start = Date.now();
427
+ // Only a failure of the generation itself settles as an error.
428
+ let result;
350
429
  try {
351
- const result = await doGenerate();
352
- await finish(requestId, {
353
- responseText: extractText(result),
354
- ...extractUsage(result.usage),
355
- costNanos: extractGatewayCostNanos(result),
356
- latencyMs: Date.now() - start,
357
- });
358
- return result;
430
+ result = await doGenerate();
359
431
  }
360
432
  catch (e) {
361
433
  await finish(requestId, {
@@ -364,6 +436,15 @@ export class AIBudget {
364
436
  });
365
437
  throw e;
366
438
  }
439
+ // Generation succeeded (provider may have charged). Settle the real
440
+ // usage; a failure here rethrows rather than recording a false zero.
441
+ await finish(requestId, {
442
+ responseText: extractText(result),
443
+ ...extractUsage(result.usage),
444
+ costNanos: extractGatewayCostNanos(result),
445
+ latencyMs: Date.now() - start,
446
+ });
447
+ return result;
367
448
  },
368
449
  wrapStream: async ({ doStream, params }) => {
369
450
  const requestId = await begin(params);
@@ -378,21 +459,22 @@ export class AIBudget {
378
459
  // chunk, or a cancel — is safe: the first wins, the rest no-op.
379
460
  // Without this an errored or abandoned stream would never settle and
380
461
  // its real usage would be lost (recorded as free by the reconciler).
381
- let settled = false;
382
- const settle = (error) => {
383
- if (settled)
384
- return;
385
- settled = true;
386
- return finish(requestId, {
387
- responseText: text,
388
- error,
389
- ...extractUsage(usage),
390
- costNanos: extractGatewayCostNanos({ providerMetadata }),
391
- latencyMs: Date.now() - start,
392
- });
393
- };
462
+ // Settle at most once, memoizing the PROMISE so the finish chunk, an
463
+ // error chunk, and flush all await the same settlement instead of
464
+ // racing, dropping it (the old `void settle()`), or flipping a
465
+ // "settled" flag before the mutation actually committed. A stream
466
+ // that is cancelled/never fully consumed won't deliver finish or
467
+ // flush; the reconciler's reservation expiry is the backstop there.
468
+ let settlement;
469
+ const settle = (error) => (settlement ??= finish(requestId, {
470
+ responseText: text,
471
+ error,
472
+ ...extractUsage(usage),
473
+ costNanos: extractGatewayCostNanos({ providerMetadata }),
474
+ latencyMs: Date.now() - start,
475
+ }));
394
476
  const tapped = result.stream.pipeThrough(new TransformStream({
395
- transform(chunk, controller) {
477
+ async transform(chunk, controller) {
396
478
  if (chunk?.type === "text-delta") {
397
479
  text += chunk.delta ?? chunk.textDelta ?? "";
398
480
  }
@@ -400,9 +482,11 @@ export class AIBudget {
400
482
  usage = chunk.usage;
401
483
  providerMetadata = chunk.providerMetadata ?? providerMetadata;
402
484
  }
403
- if (chunk?.type === "error")
404
- void settle(String(chunk.error));
405
485
  controller.enqueue(chunk);
486
+ // Settle after forwarding the terminal error chunk, and AWAIT
487
+ // it so a failed settle surfaces instead of being dropped.
488
+ if (chunk?.type === "error")
489
+ await settle(String(chunk.error));
406
490
  },
407
491
  async flush() {
408
492
  await settle();
@@ -590,16 +674,26 @@ export class AIBudget {
590
674
  return { ok: false, token: "" };
591
675
  const url = new URL(request.url);
592
676
  const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
593
- // `?token=` is accepted only for the initial page navigation (a browser
594
- // GET can't set headers); the page strips it from the URL on load and the
595
- // JSON API is called with the bearer header. Compared in constant time.
596
- const provided = bearer || url.searchParams.get("token") || "";
677
+ // `?token=` is accepted ONLY for the initial page navigation (a browser GET
678
+ // can't set headers); the page strips it from the URL on load and calls the
679
+ // JSON API with the bearer header. Restrict it to that GET page route so a
680
+ // token can't be smuggled in a query string on API/mutation calls (where it
681
+ // would also land in access logs). Compared in constant time.
682
+ const sub = url.pathname.slice(prefix.length) || "/";
683
+ const isPageNav = request.method === "GET" && !sub.startsWith("/api");
684
+ const provided = bearer || (isPageNav ? url.searchParams.get("token") ?? "" : "");
597
685
  return { ok: timingSafeEqual(provided, token), token };
598
686
  };
599
687
  const json = (data, status = 200) => new Response(JSON.stringify(data ?? null), {
600
688
  status,
601
- headers: { "content-type": "application/json" },
689
+ // Never let a shared cache/proxy retain budget data or the token-bearing
690
+ // page — these responses are per-viewer and sensitive.
691
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
602
692
  });
693
+ // JSON.stringify does NOT escape `<`, so a value containing `</script>`
694
+ // would close the inline <script> and break out. Escape `<` (and the JS line
695
+ // separators) before embedding in HTML.
696
+ const jsonForScript = (x) => JSON.stringify(x).replace(/[<\u2028\u2029]/g, (ch) => "\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0"));
603
697
  const handle = async (ctx, request) => {
604
698
  const url = new URL(request.url);
605
699
  const sub = url.pathname.slice(prefix.length) || "/";
@@ -656,12 +750,16 @@ export class AIBudget {
656
750
  return json({ error: "not found" }, 404);
657
751
  }
658
752
  }
659
- // Inject as JSON literals (function replacers so `$` in the value isn't
660
- // treated as a replacement pattern). This keeps a token/prefix containing
661
- // quotes, backslashes, or `</script>` from breaking out of the JS string.
662
- const html = DASHBOARD_HTML.replace(/__API_BASE__/g, () => JSON.stringify(`${prefix}/api`)).replace(/__TOKEN__/g, () => JSON.stringify(token));
753
+ // Inject as script-safe JSON literals (function replacers so `$` in the
754
+ // value isn't treated as a replacement pattern; `jsonForScript` escapes
755
+ // `<` so a token containing `</script>` can't break out of the inline JS).
756
+ const html = DASHBOARD_HTML.replace(/__API_BASE__/g, () => jsonForScript(`${prefix}/api`)).replace(/__TOKEN__/g, () => jsonForScript(token));
757
+ // The page embeds the bearer token — never let a shared cache retain it.
663
758
  return new Response(html, {
664
- headers: { "content-type": "text/html; charset=utf-8" },
759
+ headers: {
760
+ "content-type": "text/html; charset=utf-8",
761
+ "cache-control": "no-store",
762
+ },
665
763
  });
666
764
  };
667
765
  const handler = httpActionGeneric(handle);
@@ -377,9 +377,9 @@ export declare const getGlobalStatus: import("convex/server").RegisteredQuery<"p
377
377
  defaultWarnAtPct: number | null;
378
378
  }>>;
379
379
  export declare const setGlobalLimits: import("convex/server").RegisteredMutation<"public", {
380
- dailySpendLimitNanos?: number | undefined;
381
- lifetimeSpendLimitNanos?: number | undefined;
382
- enforcement?: "hard" | "soft" | undefined;
380
+ dailySpendLimitNanos?: number | null | undefined;
381
+ lifetimeSpendLimitNanos?: number | null | undefined;
382
+ enforcement?: "hard" | "soft" | null | undefined;
383
383
  }, Promise<null>>;
384
384
  export declare const bumpGlobal: import("convex/server").RegisteredMutation<"public", {
385
385
  dailyNanos?: number | undefined;