@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 +50 -11
- package/dist/client/dashboard.js +12 -2
- package/dist/client/index.js +128 -62
- package/dist/component/_generated/component.d.ts +3 -3
- package/dist/component/lib.d.ts +9 -4
- package/dist/component/lib.js +215 -65
- package/package.json +1 -1
- package/src/client/dashboard.ts +12 -2
- package/src/client/index.ts +133 -60
- package/src/component/_generated/component.ts +3 -3
- package/src/component/lib.test.ts +155 -18
- package/src/component/lib.ts +219 -69
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
|
|
492
|
-
it's enforced **approximately**
|
|
493
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
|
package/dist/client/dashboard.js
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
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(); } },
|
package/dist/client/index.js
CHANGED
|
@@ -39,8 +39,17 @@ function toTokenCount(x) {
|
|
|
39
39
|
}
|
|
40
40
|
function extractUsage(usage) {
|
|
41
41
|
return {
|
|
42
|
-
|
|
43
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
662
|
-
//
|
|
663
|
-
// JSON API
|
|
664
|
-
|
|
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
|
-
|
|
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
|
|
728
|
-
// treated as a replacement pattern
|
|
729
|
-
//
|
|
730
|
-
const html = DASHBOARD_HTML.replace(/__API_BASE__/g, () =>
|
|
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: {
|
|
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";
|
package/dist/component/lib.d.ts
CHANGED
|
@@ -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;
|