@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 +31 -0
- package/dist/client/dashboard.d.ts +1 -0
- package/dist/client/dashboard.js +215 -0
- package/dist/client/index.d.ts +29 -1
- package/dist/client/index.js +107 -0
- package/dist/component/_generated/component.d.ts +2 -0
- package/dist/component/lib.d.ts +2 -0
- package/dist/component/lib.js +5 -0
- package/package.json +1 -1
- package/src/client/dashboard.ts +215 -0
- package/src/client/index.ts +135 -1
- package/src/component/_generated/component.ts +2 -0
- package/src/component/lib.ts +5 -0
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>`;
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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;
|
package/dist/client/index.js
CHANGED
|
@@ -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>;
|
package/dist/component/lib.d.ts
CHANGED
|
@@ -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;
|
package/dist/component/lib.js
CHANGED
|
@@ -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
|
@@ -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>`;
|
package/src/client/index.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import
|
|
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
|
},
|
package/src/component/lib.ts
CHANGED
|
@@ -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
|
});
|