@convex-dev/ai-budget 0.0.2-alpha.5 → 0.0.2-alpha.6

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
@@ -39,6 +39,7 @@ full audit log you can replay later.
39
39
  | **Model policy** | Allow/deny lists for models; an unknown/unpriced model **fails closed** (charged a conservative max, never $0). |
40
40
  | **Replay** | Re-run any stored request with edited messages or a different model; re-runs are linked to their original (lineage). |
41
41
  | **Agent-ready** | `ai.languageModel(ctx, { userId })` is a standard AI SDK model — drop it into [`@convex-dev/agent`](https://www.npmjs.com/package/@convex-dev/agent) and every agent generation is budgeted. |
42
+ | **Built-in dashboard** | `ai.registerRoutes(http)` mounts a self-contained admin dashboard (buckets, requests, usage charts, settings) at a URL — one line, no UI to build. |
42
43
 
43
44
  ---
44
45
 
@@ -378,6 +379,36 @@ ai.requests.list(ctx, { userId?, limit? }) // the audit log (blocked i
378
379
  ai.requests.list(ctx, { dimension: "customer", value: "acme" }) // filter the log by any tag
379
380
  ```
380
381
 
382
+ ### Built-in admin dashboard
383
+
384
+ The component ships a self-contained admin dashboard — buckets & limits, the
385
+ request log, spend-over-time charts, and global settings. Mount it on your HTTP
386
+ router with **one call** (no UI to build, no extra queries to write):
387
+
388
+ ```ts
389
+ // convex/http.ts
390
+ import { httpRouter } from "convex/server";
391
+ import { components } from "./_generated/api";
392
+ import { AIBudget } from "@convex-dev/ai-budget";
393
+
394
+ const ai = new AIBudget(components.aiBudget);
395
+ const http = httpRouter();
396
+
397
+ ai.registerRoutes(http, {
398
+ // Gate it — the endpoint is public. Recommended: check the caller is an admin.
399
+ authorize: async (ctx) => (await ctx.auth.getUserIdentity())?.role === "admin",
400
+ });
401
+
402
+ export default http;
403
+ ```
404
+
405
+ It then lives at `https://<deployment>.convex.site/aibudget` (override with
406
+ `path`). **It is a public internet endpoint, so you must gate it**: pass
407
+ `authorize` (return `true` to allow), or set the `AI_BUDGET_DASHBOARD_TOKEN` env
408
+ var (sent as `Authorization: Bearer …` or `?token=`). With neither, every route
409
+ returns 401. Everything the page shows is backed by the component's own
410
+ functions, so there is nothing else to wire up.
411
+
381
412
  ---
382
413
 
383
414
  ## How spend caps stay correct
@@ -0,0 +1 @@
1
+ export declare const DASHBOARD_HTML: string;
@@ -0,0 +1,215 @@
1
+ // Self-contained admin dashboard served by the component over HTTP (see
2
+ // AIBudget.registerRoutes). Plain HTML/CSS/JS — no build step, no framework —
3
+ // so it ships inside the published package as a string. `__API_BASE__` and
4
+ // `__TOKEN__` are substituted at serve time; the page talks only to the
5
+ // component's own JSON API under API_BASE.
6
+ export const DASHBOARD_HTML = String.raw `<!doctype html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8" />
10
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11
+ <title>AI Budget</title>
12
+ <style>
13
+ :root {
14
+ --bg: #0f1115; --panel: #171a21; --border: #262b36; --muted: #8b93a7;
15
+ --fg: #e6e9ef; --accent: #6ea8fe; --accent2: #4ade80; --danger: #f87171;
16
+ }
17
+ * { box-sizing: border-box; }
18
+ body { margin: 0; font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(--fg); }
19
+ header { padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
20
+ header h1 { font-size: 16px; margin: 0; }
21
+ header .sub { color: var(--muted); font-size: 12px; }
22
+ nav { display: flex; gap: 8px; padding: 10px 20px; border-bottom: 1px solid var(--border); }
23
+ nav button { background: transparent; color: var(--muted); border: 1px solid transparent; padding: 6px 12px; border-radius: 6px; cursor: pointer; font: inherit; }
24
+ nav button.active { background: var(--panel); color: var(--fg); border-color: var(--border); }
25
+ main { padding: 20px; }
26
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
27
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
28
+ th { color: var(--muted); font-weight: 600; }
29
+ td.mono, .mono { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; }
30
+ input, select { background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 4px 6px; font: inherit; width: 90px; }
31
+ button.act { background: var(--panel); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 3px 8px; cursor: pointer; font-size: 12px; }
32
+ button.act:hover { border-color: var(--accent); }
33
+ .row { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
34
+ .pill { font-size: 11px; padding: 1px 7px; border-radius: 10px; border: 1px solid var(--border); color: var(--muted); }
35
+ .ok { color: var(--accent2); } .bad { color: var(--danger); }
36
+ .bars { display: flex; align-items: flex-end; gap: 3px; height: 120px; padding-top: 10px; }
37
+ .bar { background: var(--accent); border-radius: 2px 2px 0 0; min-width: 8px; flex: 1; position: relative; }
38
+ .bar span { position: absolute; bottom: -18px; left: 0; right: 0; text-align: center; font-size: 9px; color: var(--muted); }
39
+ .muted { color: var(--muted); } h2 { font-size: 14px; margin: 18px 0 8px; }
40
+ .err { color: var(--danger); padding: 10px; }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <header>
45
+ <h1>☂️ AI Budget</h1>
46
+ <span class="sub">component admin dashboard</span>
47
+ <span id="total" class="sub" style="margin-left:auto"></span>
48
+ </header>
49
+ <nav>
50
+ <button data-tab="buckets" class="active">Buckets</button>
51
+ <button data-tab="requests">Requests</button>
52
+ <button data-tab="usage">Usage</button>
53
+ <button data-tab="settings">Settings</button>
54
+ </nav>
55
+ <main id="main"></main>
56
+ <script>
57
+ const API = "__API_BASE__";
58
+ const TOKEN = "__TOKEN__";
59
+ const H = TOKEN ? { Authorization: "Bearer " + TOKEN } : {};
60
+ const NANOS = 1e9;
61
+ const usd = (n) => n == null ? "—" : "$" + (n / NANOS).toFixed(n && n < NANOS/100 ? 6 : 2);
62
+ const q = (o) => Object.entries(o).filter(([,v]) => v != null && v !== "").map(([k,v]) => k+"="+encodeURIComponent(v)).join("&");
63
+ async function get(path, params) { const r = await fetch(API + path + (params ? "?" + q(params) : ""), { headers: H, credentials: "same-origin" }); if (!r.ok) throw new Error(await r.text()); return r.json(); }
64
+ async function post(path, body) { const r = await fetch(API + path, { method: "POST", headers: { "content-type": "application/json", ...H }, credentials: "same-origin", body: JSON.stringify(body) }); if (!r.ok) throw new Error(await r.text()); return r.json(); }
65
+ const el = (t, a = {}, kids = []) => { const e = document.createElement(t); for (const k in a) { if (k === "class") e.className = a[k]; else if (k.startsWith("on")) e.addEventListener(k.slice(2), a[k]); else if (k === "value") e.value = a[k] ?? ""; else e.setAttribute(k, a[k]); } for (const c of [].concat(kids)) e.append(c?.nodeType ? c : document.createTextNode(c ?? "")); return e; };
66
+ const main = document.getElementById("main");
67
+ let tab = "buckets";
68
+
69
+ document.querySelectorAll("nav button").forEach((b) =>
70
+ b.addEventListener("click", () => { tab = b.dataset.tab; document.querySelectorAll("nav button").forEach((x) => x.classList.toggle("active", x === b)); render(); }));
71
+
72
+ // number input that saves on Enter/blur
73
+ function numInput(value, onSave, { money = false, width = 90 } = {}) {
74
+ const shown = value == null ? "" : money ? value / NANOS : value;
75
+ const i = el("input", { value: shown, style: "width:" + width + "px" });
76
+ const save = () => { const t = i.value.trim(); onSave(t === "" ? undefined : money ? Math.round(Number(t) * NANOS) : Number(t)); };
77
+ i.addEventListener("keydown", (e) => { if (e.key === "Enter") i.blur(); });
78
+ i.addEventListener("blur", save);
79
+ return i;
80
+ }
81
+
82
+ async function render() {
83
+ main.innerHTML = "";
84
+ try { await ({ buckets: renderBuckets, requests: renderRequests, usage: renderUsage, settings: renderSettings }[tab])(); }
85
+ catch (e) { main.append(el("div", { class: "err" }, "Error: " + e.message)); }
86
+ }
87
+
88
+ async function renderBuckets() {
89
+ const dims = ["user", "action"];
90
+ const state = { dimension: window.__dim ?? "" };
91
+ const rows = await get("/buckets", state.dimension ? { dimension: state.dimension } : {});
92
+ const grand = rows.reduce((s, b) => s + b.totalSpendNanos, 0);
93
+ document.getElementById("total").textContent = rows.length + " buckets · " + usd(grand) + " total";
94
+ const dimSet = [...new Set(rows.map((b) => b.dimension).concat(dims))];
95
+ const sel = el("select", { value: state.dimension, style: "width:140px",
96
+ onchange: (e) => { window.__dim = e.target.value; render(); } },
97
+ [el("option", { value: "" }, "all dimensions")].concat(dimSet.map((d) => el("option", { value: d }, d))));
98
+ main.append(el("div", { class: "row" }, ["Dimension:", sel]));
99
+
100
+ const head = ["dimension", "value", "today", "month", "total", "daily $", "monthly $", "warn %", "max/min", "blocked", ""];
101
+ const table = el("table", {}, el("thead", {}, el("tr", {}, head.map((h) => el("th", {}, h)))));
102
+ const body = el("tbody");
103
+ for (const b of rows) {
104
+ const set = (patch) => post("/setLimits", { dimension: b.dimension, value: b.value, ...patch }).then(render);
105
+ body.append(el("tr", {}, [
106
+ el("td", { class: "mono muted" }, b.dimension),
107
+ el("td", {}, el("b", {}, b.value)),
108
+ el("td", { class: "mono" }, usd(b.spendTodayNanos)),
109
+ el("td", { class: "mono" }, usd(b.spendThisMonthNanos)),
110
+ el("td", { class: "mono" }, usd(b.totalSpendNanos)),
111
+ el("td", {}, numInput(b.dailySpendLimitNanos, (v) => set({ dailySpendLimitNanos: v }), { money: true })),
112
+ el("td", {}, numInput(b.monthlySpendLimitNanos, (v) => set({ monthlySpendLimitNanos: v }), { money: true })),
113
+ el("td", {}, numInput(b.warnAtPct == null ? undefined : Math.round(b.warnAtPct * 100), (v) => set({ warnAtPct: v == null ? undefined : v / 100 }), { width: 55 })),
114
+ el("td", {}, numInput(b.maxConcurrent, (v) => set({ maxConcurrent: v }), { width: 55 })),
115
+ el("td", {}, el("input", { type: "checkbox", style: "width:auto", ...(b.blocked ? { checked: "" } : {}), onchange: (e) => set({ blocked: e.target.checked }) })),
116
+ el("td", {}, [
117
+ el("button", { class: "act", onclick: () => post("/adjust", { dimension: b.dimension, value: b.value, deltaNanos: -NANOS, reason: "dashboard credit" }).then(render) }, "−$1"),
118
+ " ",
119
+ el("button", { class: "act", onclick: () => post("/bump", { dimension: b.dimension, value: b.value, dailyNanos: NANOS }).then(render) }, "+$1 today"),
120
+ ]),
121
+ ]));
122
+ }
123
+ table.append(body);
124
+ main.append(table);
125
+ }
126
+
127
+ async function renderRequests() {
128
+ const filter = window.__reqFilter ?? {};
129
+ const dimInput = el("input", { value: filter.dimension ?? "", placeholder: "dimension", style: "width:110px" });
130
+ const valInput = el("input", { value: filter.value ?? "", placeholder: "value", style: "width:130px" });
131
+ const go = () => { window.__reqFilter = { dimension: dimInput.value.trim() || undefined, value: valInput.value.trim() || undefined }; render(); };
132
+ main.append(el("div", { class: "row" }, ["Filter by tag:", dimInput, valInput,
133
+ el("button", { class: "act", onclick: go }, "Apply"),
134
+ el("button", { class: "act", onclick: () => { window.__reqFilter = {}; render(); } }, "Clear")]));
135
+
136
+ const rows = await get("/requests", filter);
137
+ const head = ["time", "user", "action", "model", "status", "tokens", "cached", "cost"];
138
+ const table = el("table", {}, el("thead", {}, el("tr", {}, head.map((h) => el("th", {}, h)))));
139
+ const body = el("tbody");
140
+ for (const r of rows) {
141
+ body.append(el("tr", {}, [
142
+ el("td", { class: "muted" }, new Date(r._creationTime).toLocaleTimeString()),
143
+ el("td", { class: "mono" }, r.userId),
144
+ el("td", { class: "mono muted" }, r.actionName ?? "—"),
145
+ el("td", { class: "mono muted" }, r.model),
146
+ el("td", { class: r.status === "success" ? "ok" : r.status === "blocked" || r.status === "error" ? "bad" : "" }, r.status),
147
+ el("td", { class: "mono" }, ((r.promptTokens ?? 0) + (r.completionTokens ?? 0)) || "—"),
148
+ el("td", { class: "mono muted" }, r.cachedTokens || "—"),
149
+ el("td", { class: "mono" }, usd(r.costNanos)),
150
+ ]));
151
+ }
152
+ table.append(body);
153
+ main.append(rows.length ? table : el("div", { class: "muted" }, "No matching requests."));
154
+ }
155
+
156
+ async function renderUsage() {
157
+ const s = window.__usage ?? (window.__usage = { dimension: "user", value: "", period: "day" });
158
+ const buckets = await get("/buckets", { dimension: s.dimension });
159
+ if (!s.value && buckets[0]) s.value = buckets[0].value;
160
+ const dimInput = el("input", { value: s.dimension, style: "width:110px", onchange: (e) => { s.dimension = e.target.value.trim(); s.value = ""; render(); } });
161
+ const valSel = el("select", { value: s.value, style: "width:150px", onchange: (e) => { s.value = e.target.value; render(); } },
162
+ buckets.map((b) => el("option", { value: b.value }, b.value)));
163
+ const perSel = el("select", { value: s.period, style: "width:90px", onchange: (e) => { s.period = e.target.value; render(); } },
164
+ [el("option", { value: "day" }, "day"), el("option", { value: "month" }, "month")]);
165
+ main.append(el("div", { class: "row" }, ["Dimension:", dimInput, "Value:", valSel, "Period:", perSel]));
166
+ if (!s.value) { main.append(el("div", { class: "muted" }, "No buckets in this dimension yet.")); return; }
167
+
168
+ const hist = (await get("/usage", { dimension: s.dimension, value: s.value, period: s.period })).slice().reverse();
169
+ if (!hist.length) { main.append(el("div", { class: "muted" }, "No usage history yet.")); return; }
170
+ const max = Math.max(...hist.map((h) => h.spendNanos), 1);
171
+ main.append(el("h2", {}, "Spend per " + s.period + " — " + s.dimension + " \"" + s.value + "\""));
172
+ main.append(el("div", { class: "bars" }, hist.map((h) =>
173
+ el("div", { class: "bar", title: h.stamp + ": " + usd(h.spendNanos), style: "height:" + Math.max(2, (h.spendNanos / max) * 100) + "%" },
174
+ el("span", {}, h.stamp.slice(5))))));
175
+ const total = hist.reduce((a, h) => a + h.spendNanos, 0);
176
+ main.append(el("div", { class: "muted", style: "margin-top:26px" }, hist.length + " " + s.period + "s · " + usd(total) + " total"));
177
+ }
178
+
179
+ async function renderSettings() {
180
+ const g = await get("/global");
181
+ main.append(el("h2", {}, "Global (deployment-wide) cap"));
182
+ const setG = (patch) => post("/global/setLimits", patch).then(render);
183
+ main.append(el("div", { class: "row" }, [
184
+ "Daily $:", numInput(g.dailySpendLimitNanos, (v) => setG({ dailySpendLimitNanos: v }), { money: true }),
185
+ "Lifetime $:", numInput(g.lifetimeSpendLimitNanos, (v) => setG({ lifetimeSpendLimitNanos: v }), { money: true }),
186
+ el("span", { class: "pill" }, "today " + usd(g.spentTodayNanos) + " · total " + usd(g.spentTotalNanos)),
187
+ ]));
188
+ main.append(el("h2", {}, "Alerts & retention"));
189
+ main.append(el("div", { class: "row" }, [
190
+ "Alert at %:", numInput(g.defaultWarnAtPct == null ? undefined : Math.round(g.defaultWarnAtPct * 100),
191
+ (v) => post("/global/setAlertDefaults", { warnAtPct: v == null ? undefined : v / 100 }).then(render), { width: 55 }),
192
+ "Retention (ms):", numInput(g.retentionMs, (v) => post("/global/setRetention", { retentionMs: v ?? 0 }).then(render), { width: 130 }),
193
+ ]));
194
+
195
+ const prices = await get("/prices");
196
+ main.append(el("h2", {}, "Model prices (nanodollars / Mtok)"));
197
+ const table = el("table", {}, el("thead", {}, el("tr", {}, ["model", "input", "output", "cached", ""].map((h) => el("th", {}, h)))));
198
+ const body = el("tbody");
199
+ for (const [model, p] of Object.entries(prices)) {
200
+ body.append(el("tr", {}, [
201
+ el("td", { class: "mono" }, model),
202
+ el("td", { class: "mono" }, p.input),
203
+ el("td", { class: "mono" }, p.output),
204
+ el("td", { class: "mono muted" }, p.cached ?? "default"),
205
+ el("td", {}, p.overridden ? el("span", { class: "pill" }, "override") : ""),
206
+ ]));
207
+ }
208
+ table.append(body);
209
+ main.append(table);
210
+ }
211
+
212
+ render();
213
+ </script>
214
+ </body>
215
+ </html>`;
@@ -1,4 +1,4 @@
1
- import type { Expand, FunctionReference } from "convex/server";
1
+ import { type Expand, type FunctionReference, type HttpRouter } from "convex/server";
2
2
  import { type GenericId } from "convex/values";
3
3
  import { type LanguageModel } from "ai";
4
4
  import type { api } from "../component/_generated/api";
@@ -676,6 +676,8 @@ export declare class AIBudget {
676
676
  enforcement: "hard" | "soft";
677
677
  spentTodayNanos: number;
678
678
  spentTotalNanos: number;
679
+ retentionMs: number | null;
680
+ defaultWarnAtPct: number | null;
679
681
  }>;
680
682
  /** A killswitch spend cap across all users/actions (enforced approximately). */
681
683
  setLimits: (ctx: RunMutationCtx, args: {
@@ -726,6 +728,32 @@ export declare class AIBudget {
726
728
  cachedNanosPerMTok?: number;
727
729
  }) => Promise<null>;
728
730
  };
731
+ /**
732
+ * Mount the built-in admin dashboard on your app's HTTP router with one call.
733
+ * Serves a self-contained HTML dashboard (buckets, requests, usage history,
734
+ * settings) plus a small JSON API, all backed by the component — no extra
735
+ * queries to write.
736
+ *
737
+ * // convex/http.ts
738
+ * import { httpRouter } from "convex/server";
739
+ * const http = httpRouter();
740
+ * ai.registerRoutes(http, { authorize: async (ctx) =>
741
+ * (await ctx.auth.getUserIdentity())?.role === "admin" });
742
+ * export default http;
743
+ *
744
+ * It then lives at `https://<deployment>.convex.site/aibudget`.
745
+ *
746
+ * SECURITY: the endpoint is public on the internet. You MUST gate it — either
747
+ * pass `authorize` (recommended: check the caller is a deployment admin) or
748
+ * set the `AI_BUDGET_DASHBOARD_TOKEN` env var (a bearer token / `?token=`).
749
+ * With neither, every route returns 401.
750
+ */
751
+ registerRoutes(http: HttpRouter, opts?: {
752
+ /** Mount path (default "/aibudget"). */
753
+ path?: string;
754
+ /** Return true to allow the request. Runs on the HTML page and every API call. */
755
+ authorize?: (ctx: any, request: Request) => boolean | Promise<boolean>;
756
+ }): void;
729
757
  }
730
758
  /** @deprecated Renamed to `AIBudget`. */
731
759
  export declare const WorryFreeAI: typeof AIBudget;
@@ -1,6 +1,8 @@
1
+ import { httpActionGeneric, } from "convex/server";
1
2
  import { ConvexError } from "convex/values";
2
3
  import { generateText, wrapLanguageModel } from "ai";
3
4
  import { convexGateway } from "@convex-dev/ai-sdk-provider";
5
+ import { DASHBOARD_HTML } from "./dashboard";
4
6
  // The calling Convex action's name (e.g. "ai:sendMessage"), unless overridden.
5
7
  async function resolveActionName(ctx, explicit) {
6
8
  if (explicit !== undefined)
@@ -451,6 +453,111 @@ export class AIBudget {
451
453
  set: (ctx, args) => ctx.runMutation(c.lib.setPrice, args),
452
454
  };
453
455
  }
456
+ /**
457
+ * Mount the built-in admin dashboard on your app's HTTP router with one call.
458
+ * Serves a self-contained HTML dashboard (buckets, requests, usage history,
459
+ * settings) plus a small JSON API, all backed by the component — no extra
460
+ * queries to write.
461
+ *
462
+ * // convex/http.ts
463
+ * import { httpRouter } from "convex/server";
464
+ * const http = httpRouter();
465
+ * ai.registerRoutes(http, { authorize: async (ctx) =>
466
+ * (await ctx.auth.getUserIdentity())?.role === "admin" });
467
+ * export default http;
468
+ *
469
+ * It then lives at `https://<deployment>.convex.site/aibudget`.
470
+ *
471
+ * SECURITY: the endpoint is public on the internet. You MUST gate it — either
472
+ * pass `authorize` (recommended: check the caller is a deployment admin) or
473
+ * set the `AI_BUDGET_DASHBOARD_TOKEN` env var (a bearer token / `?token=`).
474
+ * With neither, every route returns 401.
475
+ */
476
+ registerRoutes(http, opts = {}) {
477
+ const prefix = (opts.path ?? "/aibudget").replace(/\/+$/, "");
478
+ const c = this.component.lib;
479
+ const authorize = opts.authorize;
480
+ const guard = async (ctx, request) => {
481
+ if (authorize)
482
+ return { ok: await authorize(ctx, request), token: "" };
483
+ const token = globalThis.process?.env?.AI_BUDGET_DASHBOARD_TOKEN;
484
+ if (!token)
485
+ return { ok: false, token: "" };
486
+ const url = new URL(request.url);
487
+ const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
488
+ const provided = bearer || url.searchParams.get("token") || "";
489
+ return { ok: provided === token, token };
490
+ };
491
+ const json = (data, status = 200) => new Response(JSON.stringify(data ?? null), {
492
+ status,
493
+ headers: { "content-type": "application/json" },
494
+ });
495
+ const handle = async (ctx, request) => {
496
+ const url = new URL(request.url);
497
+ const sub = url.pathname.slice(prefix.length) || "/";
498
+ const { ok, token } = await guard(ctx, request);
499
+ if (!ok) {
500
+ return new Response("Unauthorized. Pass `authorize` to registerRoutes or set AI_BUDGET_DASHBOARD_TOKEN.", { status: 401 });
501
+ }
502
+ if (sub.startsWith("/api/")) {
503
+ const route = request.method + " " + sub.slice(4); // strip "/api"
504
+ const p = {};
505
+ url.searchParams.forEach((v, k) => {
506
+ p[k] = v;
507
+ });
508
+ const body = request.method === "POST"
509
+ ? await request.json().catch(() => ({}))
510
+ : {};
511
+ switch (route) {
512
+ case "GET /buckets":
513
+ return json(await ctx.runQuery(c.listBuckets, { dimension: p.dimension || undefined }));
514
+ case "GET /requests":
515
+ return json(await ctx.runQuery(c.listRequests, {
516
+ userId: p.userId || undefined,
517
+ dimension: p.dimension || undefined,
518
+ value: p.value || undefined,
519
+ limit: 100,
520
+ }));
521
+ case "GET /usage":
522
+ return json(await ctx.runQuery(c.usageHistory, {
523
+ dimension: p.dimension,
524
+ value: p.value,
525
+ period: p.period === "month" ? "month" : "day",
526
+ }));
527
+ case "GET /global":
528
+ return json(await ctx.runQuery(c.getGlobalStatus, {}));
529
+ case "GET /prices":
530
+ return json(await ctx.runQuery(c.listPrices, {}));
531
+ case "POST /setLimits":
532
+ return json(await ctx.runMutation(c.setBucketLimits, body));
533
+ case "POST /bump":
534
+ return json(await ctx.runMutation(c.bumpBucket, body));
535
+ case "POST /adjust":
536
+ return json(await ctx.runMutation(c.adjustBucket, body));
537
+ case "POST /delete":
538
+ return json(await ctx.runMutation(c.deleteBucket, body));
539
+ case "POST /global/setLimits":
540
+ return json(await ctx.runMutation(c.setGlobalLimits, body));
541
+ case "POST /global/setAlertDefaults":
542
+ return json(await ctx.runMutation(c.setAlertDefaults, body));
543
+ case "POST /global/setRetention":
544
+ return json(await ctx.runMutation(c.setRetention, body));
545
+ case "POST /setPrice":
546
+ return json(await ctx.runMutation(c.setPrice, body));
547
+ default:
548
+ return json({ error: "not found" }, 404);
549
+ }
550
+ }
551
+ const html = DASHBOARD_HTML.replace(/__API_BASE__/g, `${prefix}/api`).replace(/__TOKEN__/g, token);
552
+ return new Response(html, {
553
+ headers: { "content-type": "text/html; charset=utf-8" },
554
+ });
555
+ };
556
+ const handler = httpActionGeneric(handle);
557
+ http.route({ path: prefix, method: "GET", handler });
558
+ http.route({ pathPrefix: `${prefix}/`, method: "GET", handler });
559
+ http.route({ pathPrefix: `${prefix}/`, method: "POST", handler });
560
+ }
454
561
  }
455
562
  /** @deprecated Renamed to `AIBudget`. */
456
563
  export const WorryFreeAI = AIBudget;
@@ -64,8 +64,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
64
64
  }, any, Name>;
65
65
  getGlobalStatus: FunctionReference<"query", "internal", {}, {
66
66
  dailySpendLimitNanos: number | null;
67
+ defaultWarnAtPct: number | null;
67
68
  enforcement: "hard" | "soft";
68
69
  lifetimeSpendLimitNanos: number | null;
70
+ retentionMs: number | null;
69
71
  spentTodayNanos: number;
70
72
  spentTotalNanos: number;
71
73
  }, Name>;
@@ -330,6 +330,8 @@ export declare const getGlobalStatus: import("convex/server").RegisteredQuery<"p
330
330
  enforcement: "hard" | "soft";
331
331
  spentTodayNanos: number;
332
332
  spentTotalNanos: number;
333
+ retentionMs: number | null;
334
+ defaultWarnAtPct: number | null;
333
335
  }>>;
334
336
  export declare const setGlobalLimits: import("convex/server").RegisteredMutation<"public", {
335
337
  dailySpendLimitNanos?: number | undefined;
@@ -983,6 +983,9 @@ export const getGlobalStatus = query({
983
983
  enforcement: v.union(v.literal("hard"), v.literal("soft")),
984
984
  spentTodayNanos: v.number(),
985
985
  spentTotalNanos: v.number(),
986
+ // deployment-wide config (surfaced for the admin dashboard)
987
+ retentionMs: v.union(v.number(), v.null()),
988
+ defaultWarnAtPct: v.union(v.number(), v.null()),
986
989
  }),
987
990
  handler: async (ctx) => {
988
991
  const s = await ctx.db
@@ -995,6 +998,8 @@ export const getGlobalStatus = query({
995
998
  enforcement: s?.globalEnforcement ?? "hard",
996
999
  spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
997
1000
  spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
1001
+ retentionMs: s?.retentionMs ?? null,
1002
+ defaultWarnAtPct: s?.defaultWarnAtPct ?? null,
998
1003
  };
999
1004
  },
1000
1005
  });
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/ai-budget/issues"
9
9
  },
10
- "version": "0.0.2-alpha.5",
10
+ "version": "0.0.2-alpha.6",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [
@@ -0,0 +1,215 @@
1
+ // Self-contained admin dashboard served by the component over HTTP (see
2
+ // AIBudget.registerRoutes). Plain HTML/CSS/JS — no build step, no framework —
3
+ // so it ships inside the published package as a string. `__API_BASE__` and
4
+ // `__TOKEN__` are substituted at serve time; the page talks only to the
5
+ // component's own JSON API under API_BASE.
6
+ export const DASHBOARD_HTML = String.raw`<!doctype html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8" />
10
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11
+ <title>AI Budget</title>
12
+ <style>
13
+ :root {
14
+ --bg: #0f1115; --panel: #171a21; --border: #262b36; --muted: #8b93a7;
15
+ --fg: #e6e9ef; --accent: #6ea8fe; --accent2: #4ade80; --danger: #f87171;
16
+ }
17
+ * { box-sizing: border-box; }
18
+ body { margin: 0; font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(--fg); }
19
+ header { padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
20
+ header h1 { font-size: 16px; margin: 0; }
21
+ header .sub { color: var(--muted); font-size: 12px; }
22
+ nav { display: flex; gap: 8px; padding: 10px 20px; border-bottom: 1px solid var(--border); }
23
+ nav button { background: transparent; color: var(--muted); border: 1px solid transparent; padding: 6px 12px; border-radius: 6px; cursor: pointer; font: inherit; }
24
+ nav button.active { background: var(--panel); color: var(--fg); border-color: var(--border); }
25
+ main { padding: 20px; }
26
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
27
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
28
+ th { color: var(--muted); font-weight: 600; }
29
+ td.mono, .mono { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; }
30
+ input, select { background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 4px 6px; font: inherit; width: 90px; }
31
+ button.act { background: var(--panel); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 3px 8px; cursor: pointer; font-size: 12px; }
32
+ button.act:hover { border-color: var(--accent); }
33
+ .row { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
34
+ .pill { font-size: 11px; padding: 1px 7px; border-radius: 10px; border: 1px solid var(--border); color: var(--muted); }
35
+ .ok { color: var(--accent2); } .bad { color: var(--danger); }
36
+ .bars { display: flex; align-items: flex-end; gap: 3px; height: 120px; padding-top: 10px; }
37
+ .bar { background: var(--accent); border-radius: 2px 2px 0 0; min-width: 8px; flex: 1; position: relative; }
38
+ .bar span { position: absolute; bottom: -18px; left: 0; right: 0; text-align: center; font-size: 9px; color: var(--muted); }
39
+ .muted { color: var(--muted); } h2 { font-size: 14px; margin: 18px 0 8px; }
40
+ .err { color: var(--danger); padding: 10px; }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <header>
45
+ <h1>☂️ AI Budget</h1>
46
+ <span class="sub">component admin dashboard</span>
47
+ <span id="total" class="sub" style="margin-left:auto"></span>
48
+ </header>
49
+ <nav>
50
+ <button data-tab="buckets" class="active">Buckets</button>
51
+ <button data-tab="requests">Requests</button>
52
+ <button data-tab="usage">Usage</button>
53
+ <button data-tab="settings">Settings</button>
54
+ </nav>
55
+ <main id="main"></main>
56
+ <script>
57
+ const API = "__API_BASE__";
58
+ const TOKEN = "__TOKEN__";
59
+ const H = TOKEN ? { Authorization: "Bearer " + TOKEN } : {};
60
+ const NANOS = 1e9;
61
+ const usd = (n) => n == null ? "—" : "$" + (n / NANOS).toFixed(n && n < NANOS/100 ? 6 : 2);
62
+ const q = (o) => Object.entries(o).filter(([,v]) => v != null && v !== "").map(([k,v]) => k+"="+encodeURIComponent(v)).join("&");
63
+ async function get(path, params) { const r = await fetch(API + path + (params ? "?" + q(params) : ""), { headers: H, credentials: "same-origin" }); if (!r.ok) throw new Error(await r.text()); return r.json(); }
64
+ async function post(path, body) { const r = await fetch(API + path, { method: "POST", headers: { "content-type": "application/json", ...H }, credentials: "same-origin", body: JSON.stringify(body) }); if (!r.ok) throw new Error(await r.text()); return r.json(); }
65
+ const el = (t, a = {}, kids = []) => { const e = document.createElement(t); for (const k in a) { if (k === "class") e.className = a[k]; else if (k.startsWith("on")) e.addEventListener(k.slice(2), a[k]); else if (k === "value") e.value = a[k] ?? ""; else e.setAttribute(k, a[k]); } for (const c of [].concat(kids)) e.append(c?.nodeType ? c : document.createTextNode(c ?? "")); return e; };
66
+ const main = document.getElementById("main");
67
+ let tab = "buckets";
68
+
69
+ document.querySelectorAll("nav button").forEach((b) =>
70
+ b.addEventListener("click", () => { tab = b.dataset.tab; document.querySelectorAll("nav button").forEach((x) => x.classList.toggle("active", x === b)); render(); }));
71
+
72
+ // number input that saves on Enter/blur
73
+ function numInput(value, onSave, { money = false, width = 90 } = {}) {
74
+ const shown = value == null ? "" : money ? value / NANOS : value;
75
+ const i = el("input", { value: shown, style: "width:" + width + "px" });
76
+ const save = () => { const t = i.value.trim(); onSave(t === "" ? undefined : money ? Math.round(Number(t) * NANOS) : Number(t)); };
77
+ i.addEventListener("keydown", (e) => { if (e.key === "Enter") i.blur(); });
78
+ i.addEventListener("blur", save);
79
+ return i;
80
+ }
81
+
82
+ async function render() {
83
+ main.innerHTML = "";
84
+ try { await ({ buckets: renderBuckets, requests: renderRequests, usage: renderUsage, settings: renderSettings }[tab])(); }
85
+ catch (e) { main.append(el("div", { class: "err" }, "Error: " + e.message)); }
86
+ }
87
+
88
+ async function renderBuckets() {
89
+ const dims = ["user", "action"];
90
+ const state = { dimension: window.__dim ?? "" };
91
+ const rows = await get("/buckets", state.dimension ? { dimension: state.dimension } : {});
92
+ const grand = rows.reduce((s, b) => s + b.totalSpendNanos, 0);
93
+ document.getElementById("total").textContent = rows.length + " buckets · " + usd(grand) + " total";
94
+ const dimSet = [...new Set(rows.map((b) => b.dimension).concat(dims))];
95
+ const sel = el("select", { value: state.dimension, style: "width:140px",
96
+ onchange: (e) => { window.__dim = e.target.value; render(); } },
97
+ [el("option", { value: "" }, "all dimensions")].concat(dimSet.map((d) => el("option", { value: d }, d))));
98
+ main.append(el("div", { class: "row" }, ["Dimension:", sel]));
99
+
100
+ const head = ["dimension", "value", "today", "month", "total", "daily $", "monthly $", "warn %", "max/min", "blocked", ""];
101
+ const table = el("table", {}, el("thead", {}, el("tr", {}, head.map((h) => el("th", {}, h)))));
102
+ const body = el("tbody");
103
+ for (const b of rows) {
104
+ const set = (patch) => post("/setLimits", { dimension: b.dimension, value: b.value, ...patch }).then(render);
105
+ body.append(el("tr", {}, [
106
+ el("td", { class: "mono muted" }, b.dimension),
107
+ el("td", {}, el("b", {}, b.value)),
108
+ el("td", { class: "mono" }, usd(b.spendTodayNanos)),
109
+ el("td", { class: "mono" }, usd(b.spendThisMonthNanos)),
110
+ el("td", { class: "mono" }, usd(b.totalSpendNanos)),
111
+ el("td", {}, numInput(b.dailySpendLimitNanos, (v) => set({ dailySpendLimitNanos: v }), { money: true })),
112
+ el("td", {}, numInput(b.monthlySpendLimitNanos, (v) => set({ monthlySpendLimitNanos: v }), { money: true })),
113
+ el("td", {}, numInput(b.warnAtPct == null ? undefined : Math.round(b.warnAtPct * 100), (v) => set({ warnAtPct: v == null ? undefined : v / 100 }), { width: 55 })),
114
+ el("td", {}, numInput(b.maxConcurrent, (v) => set({ maxConcurrent: v }), { width: 55 })),
115
+ el("td", {}, el("input", { type: "checkbox", style: "width:auto", ...(b.blocked ? { checked: "" } : {}), onchange: (e) => set({ blocked: e.target.checked }) })),
116
+ el("td", {}, [
117
+ el("button", { class: "act", onclick: () => post("/adjust", { dimension: b.dimension, value: b.value, deltaNanos: -NANOS, reason: "dashboard credit" }).then(render) }, "−$1"),
118
+ " ",
119
+ el("button", { class: "act", onclick: () => post("/bump", { dimension: b.dimension, value: b.value, dailyNanos: NANOS }).then(render) }, "+$1 today"),
120
+ ]),
121
+ ]));
122
+ }
123
+ table.append(body);
124
+ main.append(table);
125
+ }
126
+
127
+ async function renderRequests() {
128
+ const filter = window.__reqFilter ?? {};
129
+ const dimInput = el("input", { value: filter.dimension ?? "", placeholder: "dimension", style: "width:110px" });
130
+ const valInput = el("input", { value: filter.value ?? "", placeholder: "value", style: "width:130px" });
131
+ const go = () => { window.__reqFilter = { dimension: dimInput.value.trim() || undefined, value: valInput.value.trim() || undefined }; render(); };
132
+ main.append(el("div", { class: "row" }, ["Filter by tag:", dimInput, valInput,
133
+ el("button", { class: "act", onclick: go }, "Apply"),
134
+ el("button", { class: "act", onclick: () => { window.__reqFilter = {}; render(); } }, "Clear")]));
135
+
136
+ const rows = await get("/requests", filter);
137
+ const head = ["time", "user", "action", "model", "status", "tokens", "cached", "cost"];
138
+ const table = el("table", {}, el("thead", {}, el("tr", {}, head.map((h) => el("th", {}, h)))));
139
+ const body = el("tbody");
140
+ for (const r of rows) {
141
+ body.append(el("tr", {}, [
142
+ el("td", { class: "muted" }, new Date(r._creationTime).toLocaleTimeString()),
143
+ el("td", { class: "mono" }, r.userId),
144
+ el("td", { class: "mono muted" }, r.actionName ?? "—"),
145
+ el("td", { class: "mono muted" }, r.model),
146
+ el("td", { class: r.status === "success" ? "ok" : r.status === "blocked" || r.status === "error" ? "bad" : "" }, r.status),
147
+ el("td", { class: "mono" }, ((r.promptTokens ?? 0) + (r.completionTokens ?? 0)) || "—"),
148
+ el("td", { class: "mono muted" }, r.cachedTokens || "—"),
149
+ el("td", { class: "mono" }, usd(r.costNanos)),
150
+ ]));
151
+ }
152
+ table.append(body);
153
+ main.append(rows.length ? table : el("div", { class: "muted" }, "No matching requests."));
154
+ }
155
+
156
+ async function renderUsage() {
157
+ const s = window.__usage ?? (window.__usage = { dimension: "user", value: "", period: "day" });
158
+ const buckets = await get("/buckets", { dimension: s.dimension });
159
+ if (!s.value && buckets[0]) s.value = buckets[0].value;
160
+ const dimInput = el("input", { value: s.dimension, style: "width:110px", onchange: (e) => { s.dimension = e.target.value.trim(); s.value = ""; render(); } });
161
+ const valSel = el("select", { value: s.value, style: "width:150px", onchange: (e) => { s.value = e.target.value; render(); } },
162
+ buckets.map((b) => el("option", { value: b.value }, b.value)));
163
+ const perSel = el("select", { value: s.period, style: "width:90px", onchange: (e) => { s.period = e.target.value; render(); } },
164
+ [el("option", { value: "day" }, "day"), el("option", { value: "month" }, "month")]);
165
+ main.append(el("div", { class: "row" }, ["Dimension:", dimInput, "Value:", valSel, "Period:", perSel]));
166
+ if (!s.value) { main.append(el("div", { class: "muted" }, "No buckets in this dimension yet.")); return; }
167
+
168
+ const hist = (await get("/usage", { dimension: s.dimension, value: s.value, period: s.period })).slice().reverse();
169
+ if (!hist.length) { main.append(el("div", { class: "muted" }, "No usage history yet.")); return; }
170
+ const max = Math.max(...hist.map((h) => h.spendNanos), 1);
171
+ main.append(el("h2", {}, "Spend per " + s.period + " — " + s.dimension + " \"" + s.value + "\""));
172
+ main.append(el("div", { class: "bars" }, hist.map((h) =>
173
+ el("div", { class: "bar", title: h.stamp + ": " + usd(h.spendNanos), style: "height:" + Math.max(2, (h.spendNanos / max) * 100) + "%" },
174
+ el("span", {}, h.stamp.slice(5))))));
175
+ const total = hist.reduce((a, h) => a + h.spendNanos, 0);
176
+ main.append(el("div", { class: "muted", style: "margin-top:26px" }, hist.length + " " + s.period + "s · " + usd(total) + " total"));
177
+ }
178
+
179
+ async function renderSettings() {
180
+ const g = await get("/global");
181
+ main.append(el("h2", {}, "Global (deployment-wide) cap"));
182
+ const setG = (patch) => post("/global/setLimits", patch).then(render);
183
+ main.append(el("div", { class: "row" }, [
184
+ "Daily $:", numInput(g.dailySpendLimitNanos, (v) => setG({ dailySpendLimitNanos: v }), { money: true }),
185
+ "Lifetime $:", numInput(g.lifetimeSpendLimitNanos, (v) => setG({ lifetimeSpendLimitNanos: v }), { money: true }),
186
+ el("span", { class: "pill" }, "today " + usd(g.spentTodayNanos) + " · total " + usd(g.spentTotalNanos)),
187
+ ]));
188
+ main.append(el("h2", {}, "Alerts & retention"));
189
+ main.append(el("div", { class: "row" }, [
190
+ "Alert at %:", numInput(g.defaultWarnAtPct == null ? undefined : Math.round(g.defaultWarnAtPct * 100),
191
+ (v) => post("/global/setAlertDefaults", { warnAtPct: v == null ? undefined : v / 100 }).then(render), { width: 55 }),
192
+ "Retention (ms):", numInput(g.retentionMs, (v) => post("/global/setRetention", { retentionMs: v ?? 0 }).then(render), { width: 130 }),
193
+ ]));
194
+
195
+ const prices = await get("/prices");
196
+ main.append(el("h2", {}, "Model prices (nanodollars / Mtok)"));
197
+ const table = el("table", {}, el("thead", {}, el("tr", {}, ["model", "input", "output", "cached", ""].map((h) => el("th", {}, h)))));
198
+ const body = el("tbody");
199
+ for (const [model, p] of Object.entries(prices)) {
200
+ body.append(el("tr", {}, [
201
+ el("td", { class: "mono" }, model),
202
+ el("td", { class: "mono" }, p.input),
203
+ el("td", { class: "mono" }, p.output),
204
+ el("td", { class: "mono muted" }, p.cached ?? "default"),
205
+ el("td", {}, p.overridden ? el("span", { class: "pill" }, "override") : ""),
206
+ ]));
207
+ }
208
+ table.append(body);
209
+ main.append(table);
210
+ }
211
+
212
+ render();
213
+ </script>
214
+ </body>
215
+ </html>`;
@@ -1,8 +1,14 @@
1
- import type { Expand, FunctionReference } from "convex/server";
1
+ import {
2
+ httpActionGeneric,
3
+ type Expand,
4
+ type FunctionReference,
5
+ type HttpRouter,
6
+ } from "convex/server";
2
7
  import { ConvexError, type GenericId } from "convex/values";
3
8
  import { generateText, wrapLanguageModel, type LanguageModel } from "ai";
4
9
  import { convexGateway } from "@convex-dev/ai-sdk-provider";
5
10
  import type { api } from "../component/_generated/api";
11
+ import { DASHBOARD_HTML } from "./dashboard";
6
12
 
7
13
  // ---------- types ----------
8
14
 
@@ -707,6 +713,134 @@ export class AIBudget {
707
713
  ) => ctx.runMutation(c.lib.setPrice, args),
708
714
  };
709
715
  }
716
+
717
+ /**
718
+ * Mount the built-in admin dashboard on your app's HTTP router with one call.
719
+ * Serves a self-contained HTML dashboard (buckets, requests, usage history,
720
+ * settings) plus a small JSON API, all backed by the component — no extra
721
+ * queries to write.
722
+ *
723
+ * // convex/http.ts
724
+ * import { httpRouter } from "convex/server";
725
+ * const http = httpRouter();
726
+ * ai.registerRoutes(http, { authorize: async (ctx) =>
727
+ * (await ctx.auth.getUserIdentity())?.role === "admin" });
728
+ * export default http;
729
+ *
730
+ * It then lives at `https://<deployment>.convex.site/aibudget`.
731
+ *
732
+ * SECURITY: the endpoint is public on the internet. You MUST gate it — either
733
+ * pass `authorize` (recommended: check the caller is a deployment admin) or
734
+ * set the `AI_BUDGET_DASHBOARD_TOKEN` env var (a bearer token / `?token=`).
735
+ * With neither, every route returns 401.
736
+ */
737
+ registerRoutes(
738
+ http: HttpRouter,
739
+ opts: {
740
+ /** Mount path (default "/aibudget"). */
741
+ path?: string;
742
+ /** Return true to allow the request. Runs on the HTML page and every API call. */
743
+ authorize?: (ctx: any, request: Request) => boolean | Promise<boolean>;
744
+ } = {}
745
+ ) {
746
+ const prefix = (opts.path ?? "/aibudget").replace(/\/+$/, "");
747
+ const c = this.component.lib;
748
+ const authorize = opts.authorize;
749
+
750
+ const guard = async (
751
+ ctx: any,
752
+ request: Request
753
+ ): Promise<{ ok: boolean; token: string }> => {
754
+ if (authorize) return { ok: await authorize(ctx, request), token: "" };
755
+ const token = (globalThis as any).process?.env?.AI_BUDGET_DASHBOARD_TOKEN;
756
+ if (!token) return { ok: false, token: "" };
757
+ const url = new URL(request.url);
758
+ const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
759
+ const provided = bearer || url.searchParams.get("token") || "";
760
+ return { ok: provided === token, token };
761
+ };
762
+ const json = (data: unknown, status = 200) =>
763
+ new Response(JSON.stringify(data ?? null), {
764
+ status,
765
+ headers: { "content-type": "application/json" },
766
+ });
767
+
768
+ const handle = async (ctx: any, request: Request): Promise<Response> => {
769
+ const url = new URL(request.url);
770
+ const sub = url.pathname.slice(prefix.length) || "/";
771
+ const { ok, token } = await guard(ctx, request);
772
+ if (!ok) {
773
+ return new Response(
774
+ "Unauthorized. Pass `authorize` to registerRoutes or set AI_BUDGET_DASHBOARD_TOKEN.",
775
+ { status: 401 }
776
+ );
777
+ }
778
+
779
+ if (sub.startsWith("/api/")) {
780
+ const route = request.method + " " + sub.slice(4); // strip "/api"
781
+ const p: Record<string, string> = {};
782
+ url.searchParams.forEach((v, k) => {
783
+ p[k] = v;
784
+ });
785
+ const body =
786
+ request.method === "POST"
787
+ ? await request.json().catch(() => ({}))
788
+ : {};
789
+ switch (route) {
790
+ case "GET /buckets":
791
+ return json(await ctx.runQuery(c.listBuckets, { dimension: p.dimension || undefined }));
792
+ case "GET /requests":
793
+ return json(await ctx.runQuery(c.listRequests, {
794
+ userId: p.userId || undefined,
795
+ dimension: p.dimension || undefined,
796
+ value: p.value || undefined,
797
+ limit: 100,
798
+ }));
799
+ case "GET /usage":
800
+ return json(await ctx.runQuery(c.usageHistory, {
801
+ dimension: p.dimension,
802
+ value: p.value,
803
+ period: p.period === "month" ? "month" : "day",
804
+ }));
805
+ case "GET /global":
806
+ return json(await ctx.runQuery(c.getGlobalStatus, {}));
807
+ case "GET /prices":
808
+ return json(await ctx.runQuery(c.listPrices, {}));
809
+ case "POST /setLimits":
810
+ return json(await ctx.runMutation(c.setBucketLimits, body));
811
+ case "POST /bump":
812
+ return json(await ctx.runMutation(c.bumpBucket, body));
813
+ case "POST /adjust":
814
+ return json(await ctx.runMutation(c.adjustBucket, body));
815
+ case "POST /delete":
816
+ return json(await ctx.runMutation(c.deleteBucket, body));
817
+ case "POST /global/setLimits":
818
+ return json(await ctx.runMutation(c.setGlobalLimits, body));
819
+ case "POST /global/setAlertDefaults":
820
+ return json(await ctx.runMutation(c.setAlertDefaults, body));
821
+ case "POST /global/setRetention":
822
+ return json(await ctx.runMutation(c.setRetention, body));
823
+ case "POST /setPrice":
824
+ return json(await ctx.runMutation(c.setPrice, body));
825
+ default:
826
+ return json({ error: "not found" }, 404);
827
+ }
828
+ }
829
+
830
+ const html = DASHBOARD_HTML.replace(/__API_BASE__/g, `${prefix}/api`).replace(
831
+ /__TOKEN__/g,
832
+ token
833
+ );
834
+ return new Response(html, {
835
+ headers: { "content-type": "text/html; charset=utf-8" },
836
+ });
837
+ };
838
+
839
+ const handler = httpActionGeneric(handle);
840
+ http.route({ path: prefix, method: "GET", handler });
841
+ http.route({ pathPrefix: `${prefix}/`, method: "GET", handler });
842
+ http.route({ pathPrefix: `${prefix}/`, method: "POST", handler });
843
+ }
710
844
  }
711
845
 
712
846
  /** @deprecated Renamed to `AIBudget`. */
@@ -93,8 +93,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
93
93
  {},
94
94
  {
95
95
  dailySpendLimitNanos: number | null;
96
+ defaultWarnAtPct: number | null;
96
97
  enforcement: "hard" | "soft";
97
98
  lifetimeSpendLimitNanos: number | null;
99
+ retentionMs: number | null;
98
100
  spentTodayNanos: number;
99
101
  spentTotalNanos: number;
100
102
  },
@@ -1179,6 +1179,9 @@ export const getGlobalStatus = query({
1179
1179
  enforcement: v.union(v.literal("hard"), v.literal("soft")),
1180
1180
  spentTodayNanos: v.number(),
1181
1181
  spentTotalNanos: v.number(),
1182
+ // deployment-wide config (surfaced for the admin dashboard)
1183
+ retentionMs: v.union(v.number(), v.null()),
1184
+ defaultWarnAtPct: v.union(v.number(), v.null()),
1182
1185
  }),
1183
1186
  handler: async (ctx) => {
1184
1187
  const s = await ctx.db
@@ -1191,6 +1194,8 @@ export const getGlobalStatus = query({
1191
1194
  enforcement: s?.globalEnforcement ?? "hard",
1192
1195
  spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
1193
1196
  spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
1197
+ retentionMs: s?.retentionMs ?? null,
1198
+ defaultWarnAtPct: s?.defaultWarnAtPct ?? null,
1194
1199
  };
1195
1200
  },
1196
1201
  });