@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 +98 -6
- package/dist/client/dashboard.d.ts +1 -0
- package/dist/client/dashboard.js +215 -0
- package/dist/client/index.d.ts +347 -44
- package/dist/client/index.js +222 -68
- package/dist/component/_generated/component.d.ts +34 -0
- package/dist/component/lib.d.ts +75 -0
- package/dist/component/lib.js +309 -32
- package/dist/component/schema.d.ts +76 -3
- package/dist/component/schema.js +56 -2
- package/package.json +1 -1
- package/src/client/dashboard.ts +215 -0
- package/src/client/index.ts +334 -132
- package/src/component/_generated/component.ts +56 -3
- package/src/component/lib.test.ts +118 -0
- package/src/component/lib.ts +380 -48
- package/src/component/schema.ts +59 -2
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-
|
|
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
|
-
|
|
194
|
-
|
|
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 }) //
|
|
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 /
|
|
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.
|
|
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>`;
|