@convex-dev/ai-budget 0.0.2-alpha.4 → 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
@@ -26,7 +26,11 @@ full audit log you can replay later.
26
26
  | **Usage & cost tracking** | Every request stored with messages, response, tokens, latency, and per-request cost. |
27
27
  | **Attribution** | Each call is attributed to a `userId` **and** to the Convex action that made it — auto-detected via `ctx.meta`, no manual tagging. Running totals per user and per action. |
28
28
  | **Tagged budgets** | `user` and `action` are just built-in *dimensions* — add your own (team, project, customer, env, feature…) by passing `tags`, and cap any of them with `ai.tag("customer").setLimits(...)`. One request can be billed to several buckets at once. |
29
- | **Spend & token limits** | Per-user daily / lifetime **spend** and **token** budgets, plus a requests-per-minute rate limit and a block switch. |
29
+ | **Spend & token limits** | Per-bucket **daily / monthly / lifetime** spend and token budgets, plus a requests-per-minute rate limit, a max-concurrent cap, and a block switch. |
30
+ | **Spend history** | Durable per-bucket **daily & monthly** rollups that survive request retention — real spend-over-time, "what did we spend last month," per user / action / tag. |
31
+ | **Approaching-limit alerts** | Set `warnAtPct` (e.g. 0.8) and get an `onThreshold` callback before a cap is hit; `onLimitReached` fires when one blocks. |
32
+ | **Manual credits/debits** | Comp a user or correct an overcharge with a signed adjustment; the change hits the live windows, the history, and an audit log. |
33
+ | **Cache-aware cost** | Cached (prompt-cache-read) tokens are billed at a discount using the gateway's real cached-token count — not the full input rate. |
30
34
  | **Concurrency-safe caps** | A reserve-then-settle design makes admission a true atomic check — concurrent in-flight requests can't blow past the cap (a naive implementation overshoots ~40×). |
31
35
  | **Hard or soft** | Each limit either **blocks** (`hard`) or **allows-with-a-warning** (`soft`). |
32
36
  | **Per-feature budgets** | Cap or block a whole action (e.g. `summarize`) independently of any user. |
@@ -35,6 +39,7 @@ full audit log you can replay later.
35
39
  | **Model policy** | Allow/deny lists for models; an unknown/unpriced model **fails closed** (charged a conservative max, never $0). |
36
40
  | **Replay** | Re-run any stored request with edited messages or a different model; re-runs are linked to their original (lineage). |
37
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. |
38
43
 
39
44
  ---
40
45
 
@@ -186,16 +191,21 @@ to the original. `lineage` walks the re-run chain in both directions.
186
191
  ai.users.setLimits(ctx, {
187
192
  userId,
188
193
  requestsPerMinute?,
194
+ maxConcurrent?, // max in-flight requests at once
189
195
  dailySpendLimitNanos?,
196
+ monthlySpendLimitNanos?, // calendar-month budget (UTC)
190
197
  lifetimeSpendLimitNanos?,
191
198
  dailyTokenLimit?,
199
+ monthlyTokenLimit?,
192
200
  lifetimeTokenLimit?,
193
- enforcement?, // "hard" (block, default) | "soft" (warn but allow)
194
- blocked?, // hard block on/off
201
+ warnAtPct?, // e.g. 0.8 fire onThreshold at 80% of a cap
202
+ enforcement?, // "hard" (block, default) | "soft" (warn but allow)
203
+ blocked?, // hard block on/off
195
204
  })
196
205
  ai.users.delete(ctx, { userId }) // remove a user and all their request rows
197
206
  ```
198
207
 
208
+ The same limit fields apply to `ai.actions.setLimits` and `ai.tag(d).setLimits`.
199
209
  Pass a field as `undefined` to clear that limit (unlimited).
200
210
 
201
211
  ### Per-action budgets
@@ -246,6 +256,48 @@ Uncapped buckets never serialize, so adding tags you don't cap is free at
246
256
  admission; their running totals still accrue for reporting. `ai.users.*` and
247
257
  `ai.actions.*` are simply sugar over `ai.tag("user")` / `ai.tag("action")`.
248
258
 
259
+ Every dimension namespace (`users`, `actions`, `tag(d)`) shares the same methods:
260
+ `list`, `get`, `setLimits`, `bump`, `adjust`, `history`, `adjustments`, `delete`.
261
+
262
+ ### Spend history (survives retention)
263
+
264
+ Request rows are retained only briefly (see retention), but **durable per-bucket
265
+ day/month rollups are not** — so charts and "what did we spend last month" keep
266
+ working:
267
+
268
+ ```ts
269
+ await ai.users.history(ctx, { userId, period: "month" }); // [{ stamp, spendNanos, tokens, requests }]
270
+ await ai.tag("customer").history(ctx, { value: "acme", period: "day", limit: 30 });
271
+ ```
272
+
273
+ ### Approaching-limit alerts
274
+
275
+ Set a threshold (per bucket via `warnAtPct`, or a deployment default) and get a
276
+ callback before a cap is hit — plus one when a hard cap blocks:
277
+
278
+ ```ts
279
+ await ai.global.setAlertDefaults(ctx, { warnAtPct: 0.8 }); // 80%, all buckets
280
+ await ai.users.setLimits(ctx, { userId, warnAtPct: 0.9 }); // override per bucket
281
+
282
+ new AIBudget(components.aiBudget, {
283
+ onThreshold: ({ userId, messages }) => notify(userId, messages), // approaching
284
+ onLimitReached: ({ userId, reason }) => notify(userId, reason), // blocked
285
+ onSoftLimit: ({ userId, messages }) => notify(userId, messages), // soft-cap exceeded
286
+ });
287
+ ```
288
+
289
+ `chat()` also returns `notices` (approaching) alongside `warnings` (soft-exceeded).
290
+
291
+ ### Manual credits & debits
292
+
293
+ Comp a user or correct an overcharge. Negative = credit, positive = extra charge;
294
+ it adjusts the live day/month/lifetime windows, the history, and an audit log:
295
+
296
+ ```ts
297
+ await ai.users.adjust(ctx, { userId, deltaNanos: -5 * 1_000_000_000, reason: "goodwill" });
298
+ await ai.users.adjustments(ctx, { userId }); // the audit log
299
+ ```
300
+
249
301
  ### Global (deployment-wide) budget
250
302
 
251
303
  ```ts
@@ -283,10 +335,18 @@ common models (validated against OpenRouter's public pricing); override or add
283
335
  any model:
284
336
 
285
337
  ```ts
286
- ai.prices.set(ctx, { model, inputNanosPerMTok, outputNanosPerMTok }) // must be ≥ 0
338
+ ai.prices.set(ctx, { model, inputNanosPerMTok, outputNanosPerMTok, cachedNanosPerMTok? }) // ≥ 0
287
339
  ai.prices.list(ctx)
288
340
  ```
289
341
 
342
+ **Cached tokens & actual cost.** The gateway reports a real cached-token count
343
+ (`usage.inputTokenDetails.cacheReadTokens`), so the cached slice of a prompt is
344
+ billed at `cachedNanosPerMTok` (or a default 10%-of-input discount) instead of
345
+ the full input rate. The gateway does **not** currently return a dollar cost, so
346
+ cost is computed from tokens — but `finishRequest` accepts an authoritative
347
+ `costNanos` and prefers it whenever present, so adopting a real gateway cost is a
348
+ one-line change the day it's available.
349
+
290
350
  **Real prices from an API.** The gateway's `provider/model` ids match
291
351
  OpenRouter's, whose public models endpoint returns per-token pricing — so you can
292
352
  keep prices current from your own action (this is app code; the component just
@@ -311,12 +371,44 @@ flagged `unpricedModel: true` so you know to add a real price.
311
371
  ### Observability
312
372
 
313
373
  ```ts
314
- ai.users.list(ctx) // per-user spend today / total / tokens / limits
374
+ ai.users.list(ctx) // per-user spend today / month / total / limits
315
375
  ai.actions.list(ctx) // per-action spend & totals
316
376
  ai.tag("customer").list(ctx) // spend & caps for any custom dimension
317
- ai.requests.list(ctx, { userId?, limit? }) // the audit log (blocked attempts included)
377
+ ai.users.history(ctx, { userId, period: "month" }) // durable spend-over-time
378
+ ai.requests.list(ctx, { userId?, limit? }) // the audit log (blocked included)
379
+ ai.requests.list(ctx, { dimension: "customer", value: "acme" }) // filter the log by any tag
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;
318
403
  ```
319
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
+
320
412
  ---
321
413
 
322
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>`;