@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.
@@ -15,19 +15,30 @@ export declare const vTag: import("convex/values").VObject<{
15
15
  declare const _default: import("convex/server").SchemaDefinition<{
16
16
  buckets: import("convex/server").TableDefinition<import("convex/values").VObject<{
17
17
  requestsPerMinute?: number | undefined;
18
+ maxConcurrent?: number | undefined;
18
19
  dailySpendLimitNanos?: number | undefined;
20
+ monthlySpendLimitNanos?: number | undefined;
19
21
  lifetimeSpendLimitNanos?: number | undefined;
20
22
  dailyTokenLimit?: number | undefined;
23
+ monthlyTokenLimit?: number | undefined;
21
24
  lifetimeTokenLimit?: number | undefined;
22
25
  blocked?: boolean | undefined;
26
+ warnAtPct?: number | undefined;
23
27
  enforcement?: "hard" | "soft" | undefined;
24
28
  dailyBumpNanos?: number | undefined;
29
+ monthlyBumpNanos?: number | undefined;
25
30
  lifetimeBumpNanos?: number | undefined;
26
31
  bumpDayStamp?: string | undefined;
32
+ bumpMonthStamp?: string | undefined;
27
33
  tokensToday?: number | undefined;
34
+ monthStamp?: string | undefined;
35
+ spendThisMonthNanos?: number | undefined;
36
+ tokensThisMonth?: number | undefined;
28
37
  reservedTodayNanos?: number | undefined;
38
+ reservedMonthNanos?: number | undefined;
29
39
  reservedTotalNanos?: number | undefined;
30
40
  reservedTodayTokens?: number | undefined;
41
+ reservedMonthTokens?: number | undefined;
31
42
  reservedTotalTokens?: number | undefined;
32
43
  pendingCount?: number | undefined;
33
44
  dimension: string;
@@ -41,30 +52,88 @@ declare const _default: import("convex/server").SchemaDefinition<{
41
52
  dimension: import("convex/values").VString<string, "required">;
42
53
  value: import("convex/values").VString<string, "required">;
43
54
  requestsPerMinute: import("convex/values").VFloat64<number | undefined, "optional">;
55
+ maxConcurrent: import("convex/values").VFloat64<number | undefined, "optional">;
44
56
  dailySpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
57
+ monthlySpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
45
58
  lifetimeSpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
46
59
  dailyTokenLimit: import("convex/values").VFloat64<number | undefined, "optional">;
60
+ monthlyTokenLimit: import("convex/values").VFloat64<number | undefined, "optional">;
47
61
  lifetimeTokenLimit: import("convex/values").VFloat64<number | undefined, "optional">;
48
62
  blocked: import("convex/values").VBoolean<boolean | undefined, "optional">;
63
+ warnAtPct: import("convex/values").VFloat64<number | undefined, "optional">;
49
64
  enforcement: import("convex/values").VUnion<"hard" | "soft" | undefined, [import("convex/values").VLiteral<"hard", "required">, import("convex/values").VLiteral<"soft", "required">], "optional", never>;
50
65
  dailyBumpNanos: import("convex/values").VFloat64<number | undefined, "optional">;
66
+ monthlyBumpNanos: import("convex/values").VFloat64<number | undefined, "optional">;
51
67
  lifetimeBumpNanos: import("convex/values").VFloat64<number | undefined, "optional">;
52
68
  bumpDayStamp: import("convex/values").VString<string | undefined, "optional">;
69
+ bumpMonthStamp: import("convex/values").VString<string | undefined, "optional">;
53
70
  totalSpendNanos: import("convex/values").VFloat64<number, "required">;
54
71
  totalRequests: import("convex/values").VFloat64<number, "required">;
55
72
  totalTokens: import("convex/values").VFloat64<number, "required">;
56
73
  dayStamp: import("convex/values").VString<string, "required">;
57
74
  spendTodayNanos: import("convex/values").VFloat64<number, "required">;
58
75
  tokensToday: import("convex/values").VFloat64<number | undefined, "optional">;
76
+ monthStamp: import("convex/values").VString<string | undefined, "optional">;
77
+ spendThisMonthNanos: import("convex/values").VFloat64<number | undefined, "optional">;
78
+ tokensThisMonth: import("convex/values").VFloat64<number | undefined, "optional">;
59
79
  reservedTodayNanos: import("convex/values").VFloat64<number | undefined, "optional">;
80
+ reservedMonthNanos: import("convex/values").VFloat64<number | undefined, "optional">;
60
81
  reservedTotalNanos: import("convex/values").VFloat64<number | undefined, "optional">;
61
82
  reservedTodayTokens: import("convex/values").VFloat64<number | undefined, "optional">;
83
+ reservedMonthTokens: import("convex/values").VFloat64<number | undefined, "optional">;
62
84
  reservedTotalTokens: import("convex/values").VFloat64<number | undefined, "optional">;
63
85
  pendingCount: import("convex/values").VFloat64<number | undefined, "optional">;
64
- }, "required", "dimension" | "value" | "requestsPerMinute" | "dailySpendLimitNanos" | "lifetimeSpendLimitNanos" | "dailyTokenLimit" | "lifetimeTokenLimit" | "blocked" | "enforcement" | "dailyBumpNanos" | "lifetimeBumpNanos" | "bumpDayStamp" | "totalSpendNanos" | "totalRequests" | "totalTokens" | "dayStamp" | "spendTodayNanos" | "tokensToday" | "reservedTodayNanos" | "reservedTotalNanos" | "reservedTodayTokens" | "reservedTotalTokens" | "pendingCount">, {
86
+ }, "required", "dimension" | "value" | "requestsPerMinute" | "maxConcurrent" | "dailySpendLimitNanos" | "monthlySpendLimitNanos" | "lifetimeSpendLimitNanos" | "dailyTokenLimit" | "monthlyTokenLimit" | "lifetimeTokenLimit" | "blocked" | "warnAtPct" | "enforcement" | "dailyBumpNanos" | "monthlyBumpNanos" | "lifetimeBumpNanos" | "bumpDayStamp" | "bumpMonthStamp" | "totalSpendNanos" | "totalRequests" | "totalTokens" | "dayStamp" | "spendTodayNanos" | "tokensToday" | "monthStamp" | "spendThisMonthNanos" | "tokensThisMonth" | "reservedTodayNanos" | "reservedMonthNanos" | "reservedTotalNanos" | "reservedTodayTokens" | "reservedMonthTokens" | "reservedTotalTokens" | "pendingCount">, {
65
87
  dim_value: ["dimension", "value", "_creationTime"];
66
88
  dimension: ["dimension", "_creationTime"];
67
89
  }, {}, {}>;
90
+ usage: import("convex/server").TableDefinition<import("convex/values").VObject<{
91
+ dimension: string;
92
+ value: string;
93
+ period: "day" | "month";
94
+ stamp: string;
95
+ spendNanos: number;
96
+ tokens: number;
97
+ requests: number;
98
+ }, {
99
+ dimension: import("convex/values").VString<string, "required">;
100
+ value: import("convex/values").VString<string, "required">;
101
+ period: import("convex/values").VUnion<"day" | "month", [import("convex/values").VLiteral<"day", "required">, import("convex/values").VLiteral<"month", "required">], "required", never>;
102
+ stamp: import("convex/values").VString<string, "required">;
103
+ spendNanos: import("convex/values").VFloat64<number, "required">;
104
+ tokens: import("convex/values").VFloat64<number, "required">;
105
+ requests: import("convex/values").VFloat64<number, "required">;
106
+ }, "required", "dimension" | "value" | "period" | "stamp" | "spendNanos" | "tokens" | "requests">, {
107
+ bucket_period_stamp: ["dimension", "value", "period", "stamp", "_creationTime"];
108
+ period_stamp: ["period", "stamp", "_creationTime"];
109
+ }, {}, {}>;
110
+ requestTags: import("convex/server").TableDefinition<import("convex/values").VObject<{
111
+ dimension: string;
112
+ value: string;
113
+ requestId: import("convex/values").GenericId<"requests">;
114
+ }, {
115
+ dimension: import("convex/values").VString<string, "required">;
116
+ value: import("convex/values").VString<string, "required">;
117
+ requestId: import("convex/values").VId<import("convex/values").GenericId<"requests">, "required">;
118
+ }, "required", "dimension" | "value" | "requestId">, {
119
+ dim_value: ["dimension", "value", "_creationTime"];
120
+ requestId: ["requestId", "_creationTime"];
121
+ }, {}, {}>;
122
+ adjustments: import("convex/server").TableDefinition<import("convex/values").VObject<{
123
+ tokens?: number | undefined;
124
+ reason?: string | undefined;
125
+ dimension: string;
126
+ value: string;
127
+ deltaNanos: number;
128
+ }, {
129
+ dimension: import("convex/values").VString<string, "required">;
130
+ value: import("convex/values").VString<string, "required">;
131
+ deltaNanos: import("convex/values").VFloat64<number, "required">;
132
+ tokens: import("convex/values").VFloat64<number | undefined, "optional">;
133
+ reason: import("convex/values").VString<string | undefined, "optional">;
134
+ }, "required", "dimension" | "value" | "tokens" | "deltaNanos" | "reason">, {
135
+ dim_value: ["dimension", "value", "_creationTime"];
136
+ }, {}, {}>;
68
137
  requests: import("convex/server").TableDefinition<import("convex/values").VObject<{
69
138
  actionName?: string | undefined;
70
139
  tags?: {
@@ -137,6 +206,7 @@ declare const _default: import("convex/server").SchemaDefinition<{
137
206
  settled: ["settled", "_creationTime"];
138
207
  }, {}, {}>;
139
208
  prices: import("convex/server").TableDefinition<import("convex/values").VObject<{
209
+ cachedNanosPerMTok?: number | undefined;
140
210
  model: string;
141
211
  inputNanosPerMTok: number;
142
212
  outputNanosPerMTok: number;
@@ -144,7 +214,8 @@ declare const _default: import("convex/server").SchemaDefinition<{
144
214
  model: import("convex/values").VString<string, "required">;
145
215
  inputNanosPerMTok: import("convex/values").VFloat64<number, "required">;
146
216
  outputNanosPerMTok: import("convex/values").VFloat64<number, "required">;
147
- }, "required", "model" | "inputNanosPerMTok" | "outputNanosPerMTok">, {
217
+ cachedNanosPerMTok: import("convex/values").VFloat64<number | undefined, "optional">;
218
+ }, "required", "model" | "inputNanosPerMTok" | "outputNanosPerMTok" | "cachedNanosPerMTok">, {
148
219
  model: ["model", "_creationTime"];
149
220
  }, {}, {}>;
150
221
  settings: import("convex/server").TableDefinition<import("convex/values").VObject<{
@@ -157,6 +228,7 @@ declare const _default: import("convex/server").SchemaDefinition<{
157
228
  globalLifetimeBumpNanos?: number | undefined;
158
229
  globalBumpDayStamp?: string | undefined;
159
230
  retentionMs?: number | undefined;
231
+ defaultWarnAtPct?: number | undefined;
160
232
  key: string;
161
233
  }, {
162
234
  key: import("convex/values").VString<string, "required">;
@@ -169,7 +241,8 @@ declare const _default: import("convex/server").SchemaDefinition<{
169
241
  globalLifetimeBumpNanos: import("convex/values").VFloat64<number | undefined, "optional">;
170
242
  globalBumpDayStamp: import("convex/values").VString<string | undefined, "optional">;
171
243
  retentionMs: import("convex/values").VFloat64<number | undefined, "optional">;
172
- }, "required", "key" | "modelMode" | "models" | "globalDailySpendLimitNanos" | "globalLifetimeSpendLimitNanos" | "globalEnforcement" | "globalDailyBumpNanos" | "globalLifetimeBumpNanos" | "globalBumpDayStamp" | "retentionMs">, {
244
+ defaultWarnAtPct: import("convex/values").VFloat64<number | undefined, "optional">;
245
+ }, "required", "key" | "modelMode" | "models" | "globalDailySpendLimitNanos" | "globalLifetimeSpendLimitNanos" | "globalEnforcement" | "globalDailyBumpNanos" | "globalLifetimeBumpNanos" | "globalBumpDayStamp" | "retentionMs" | "defaultWarnAtPct">, {
173
246
  key: ["key", "_creationTime"];
174
247
  }, {}, {}>;
175
248
  }, true>;
@@ -17,18 +17,26 @@ export default defineSchema({
17
17
  value: v.string(),
18
18
  // limits (all optional — unlimited by default)
19
19
  requestsPerMinute: v.optional(v.number()), // enforced on the "user" dimension
20
+ maxConcurrent: v.optional(v.number()), // max in-flight (pending) requests
20
21
  dailySpendLimitNanos: v.optional(v.number()),
22
+ monthlySpendLimitNanos: v.optional(v.number()),
21
23
  lifetimeSpendLimitNanos: v.optional(v.number()),
22
24
  dailyTokenLimit: v.optional(v.number()),
25
+ monthlyTokenLimit: v.optional(v.number()),
23
26
  lifetimeTokenLimit: v.optional(v.number()),
24
27
  blocked: v.optional(v.boolean()), // hard block (was `blocked`/`disabled`)
28
+ // Fire an approaching-limit alert once usage crosses this fraction of a cap
29
+ // (e.g. 0.8 = warn at 80%). Falls back to the deployment default.
30
+ warnAtPct: v.optional(v.number()),
25
31
  // "hard" (default): exceeding a budget blocks. "soft": warn but allow.
26
32
  enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
27
- // one-time bumps ("approve another $X"). Daily bump is scoped to bumpDayStamp
28
- // (resets with the day); lifetime bump is permanent.
33
+ // one-time bumps ("approve another $X"). Daily/monthly bumps are scoped to
34
+ // their stamp (reset with the window); lifetime bump is permanent.
29
35
  dailyBumpNanos: v.optional(v.number()),
36
+ monthlyBumpNanos: v.optional(v.number()),
30
37
  lifetimeBumpNanos: v.optional(v.number()),
31
38
  bumpDayStamp: v.optional(v.string()),
39
+ bumpMonthStamp: v.optional(v.string()),
32
40
  // settled totals (from finished requests)
33
41
  totalSpendNanos: v.number(),
34
42
  totalRequests: v.number(),
@@ -37,15 +45,55 @@ export default defineSchema({
37
45
  dayStamp: v.string(),
38
46
  spendTodayNanos: v.number(),
39
47
  tokensToday: v.optional(v.number()),
48
+ // monthly window (UTC calendar month, e.g. "2026-09")
49
+ monthStamp: v.optional(v.string()),
50
+ spendThisMonthNanos: v.optional(v.number()),
51
+ tokensThisMonth: v.optional(v.number()),
40
52
  // in-flight reservations (pessimistic holds; released on settle/expiry)
41
53
  reservedTodayNanos: v.optional(v.number()),
54
+ reservedMonthNanos: v.optional(v.number()),
42
55
  reservedTotalNanos: v.optional(v.number()),
43
56
  reservedTodayTokens: v.optional(v.number()),
57
+ reservedMonthTokens: v.optional(v.number()),
44
58
  reservedTotalTokens: v.optional(v.number()),
45
59
  pendingCount: v.optional(v.number()),
46
60
  })
47
61
  .index("dim_value", ["dimension", "value"])
48
62
  .index("dimension", ["dimension"]),
63
+ // Durable per-(bucket, period) spend history. Written from settled requests
64
+ // and manual adjustments; NEVER swept by request retention, so spend charts
65
+ // and "what did we spend last month" survive long after the raw request rows
66
+ // are purged. period is "day" ("2026-09-04") or "month" ("2026-09").
67
+ usage: defineTable({
68
+ dimension: v.string(),
69
+ value: v.string(),
70
+ period: v.union(v.literal("day"), v.literal("month")),
71
+ stamp: v.string(),
72
+ spendNanos: v.number(),
73
+ tokens: v.number(),
74
+ requests: v.number(),
75
+ })
76
+ .index("bucket_period_stamp", ["dimension", "value", "period", "stamp"])
77
+ .index("period_stamp", ["period", "stamp"]),
78
+ // Reverse index for filtering the request log by an arbitrary tag dimension
79
+ // (user/action are already indexed on `requests`). One row per extra tag per
80
+ // request; cleaned up with the request on retention/deletion.
81
+ requestTags: defineTable({
82
+ dimension: v.string(),
83
+ value: v.string(),
84
+ requestId: v.id("requests"),
85
+ })
86
+ .index("dim_value", ["dimension", "value"])
87
+ .index("requestId", ["requestId"]),
88
+ // Manual credits/debits applied to a bucket (comp a user, correct an
89
+ // overcharge). Negative delta = credit/refund, positive = extra charge.
90
+ adjustments: defineTable({
91
+ dimension: v.string(),
92
+ value: v.string(),
93
+ deltaNanos: v.number(),
94
+ tokens: v.optional(v.number()),
95
+ reason: v.optional(v.string()),
96
+ }).index("dim_value", ["dimension", "value"]),
49
97
  requests: defineTable({
50
98
  // `user` and `action` stay first-class + indexed (the hot-path filters and
51
99
  // rate limiting); the full attribution incl. extra tags lives in `tags`.
@@ -86,6 +134,9 @@ export default defineSchema({
86
134
  model: v.string(),
87
135
  inputNanosPerMTok: v.number(),
88
136
  outputNanosPerMTok: v.number(),
137
+ // price for cached (prompt-cache-read) input tokens. Providers bill these
138
+ // at a fraction of the input rate; if unset, a default discount is applied.
139
+ cachedNanosPerMTok: v.optional(v.number()),
89
140
  }).index("model", ["model"]),
90
141
  // singleton component config (key === "singleton")
91
142
  settings: defineTable({
@@ -108,5 +159,8 @@ export default defineSchema({
108
159
  globalBumpDayStamp: v.optional(v.string()),
109
160
  // request-row retention window in ms (default 1h); 0 disables sweeping.
110
161
  retentionMs: v.optional(v.number()),
162
+ // default approaching-limit alert threshold (fraction of a cap) for buckets
163
+ // that don't set their own warnAtPct. 0/unset disables threshold alerts.
164
+ defaultWarnAtPct: v.optional(v.number()),
111
165
  }).index("key", ["key"]),
112
166
  });
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/ai-budget/issues"
9
9
  },
10
- "version": "0.0.2-alpha.4",
10
+ "version": "0.0.2-alpha.6",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [
@@ -0,0 +1,215 @@
1
+ // Self-contained admin dashboard served by the component over HTTP (see
2
+ // AIBudget.registerRoutes). Plain HTML/CSS/JS — no build step, no framework —
3
+ // so it ships inside the published package as a string. `__API_BASE__` and
4
+ // `__TOKEN__` are substituted at serve time; the page talks only to the
5
+ // component's own JSON API under API_BASE.
6
+ export const DASHBOARD_HTML = String.raw`<!doctype html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8" />
10
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11
+ <title>AI Budget</title>
12
+ <style>
13
+ :root {
14
+ --bg: #0f1115; --panel: #171a21; --border: #262b36; --muted: #8b93a7;
15
+ --fg: #e6e9ef; --accent: #6ea8fe; --accent2: #4ade80; --danger: #f87171;
16
+ }
17
+ * { box-sizing: border-box; }
18
+ body { margin: 0; font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(--fg); }
19
+ header { padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
20
+ header h1 { font-size: 16px; margin: 0; }
21
+ header .sub { color: var(--muted); font-size: 12px; }
22
+ nav { display: flex; gap: 8px; padding: 10px 20px; border-bottom: 1px solid var(--border); }
23
+ nav button { background: transparent; color: var(--muted); border: 1px solid transparent; padding: 6px 12px; border-radius: 6px; cursor: pointer; font: inherit; }
24
+ nav button.active { background: var(--panel); color: var(--fg); border-color: var(--border); }
25
+ main { padding: 20px; }
26
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
27
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
28
+ th { color: var(--muted); font-weight: 600; }
29
+ td.mono, .mono { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; }
30
+ input, select { background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 4px 6px; font: inherit; width: 90px; }
31
+ button.act { background: var(--panel); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 3px 8px; cursor: pointer; font-size: 12px; }
32
+ button.act:hover { border-color: var(--accent); }
33
+ .row { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
34
+ .pill { font-size: 11px; padding: 1px 7px; border-radius: 10px; border: 1px solid var(--border); color: var(--muted); }
35
+ .ok { color: var(--accent2); } .bad { color: var(--danger); }
36
+ .bars { display: flex; align-items: flex-end; gap: 3px; height: 120px; padding-top: 10px; }
37
+ .bar { background: var(--accent); border-radius: 2px 2px 0 0; min-width: 8px; flex: 1; position: relative; }
38
+ .bar span { position: absolute; bottom: -18px; left: 0; right: 0; text-align: center; font-size: 9px; color: var(--muted); }
39
+ .muted { color: var(--muted); } h2 { font-size: 14px; margin: 18px 0 8px; }
40
+ .err { color: var(--danger); padding: 10px; }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <header>
45
+ <h1>☂️ AI Budget</h1>
46
+ <span class="sub">component admin dashboard</span>
47
+ <span id="total" class="sub" style="margin-left:auto"></span>
48
+ </header>
49
+ <nav>
50
+ <button data-tab="buckets" class="active">Buckets</button>
51
+ <button data-tab="requests">Requests</button>
52
+ <button data-tab="usage">Usage</button>
53
+ <button data-tab="settings">Settings</button>
54
+ </nav>
55
+ <main id="main"></main>
56
+ <script>
57
+ const API = "__API_BASE__";
58
+ const TOKEN = "__TOKEN__";
59
+ const H = TOKEN ? { Authorization: "Bearer " + TOKEN } : {};
60
+ const NANOS = 1e9;
61
+ const usd = (n) => n == null ? "—" : "$" + (n / NANOS).toFixed(n && n < NANOS/100 ? 6 : 2);
62
+ const q = (o) => Object.entries(o).filter(([,v]) => v != null && v !== "").map(([k,v]) => k+"="+encodeURIComponent(v)).join("&");
63
+ async function get(path, params) { const r = await fetch(API + path + (params ? "?" + q(params) : ""), { headers: H, credentials: "same-origin" }); if (!r.ok) throw new Error(await r.text()); return r.json(); }
64
+ async function post(path, body) { const r = await fetch(API + path, { method: "POST", headers: { "content-type": "application/json", ...H }, credentials: "same-origin", body: JSON.stringify(body) }); if (!r.ok) throw new Error(await r.text()); return r.json(); }
65
+ const el = (t, a = {}, kids = []) => { const e = document.createElement(t); for (const k in a) { if (k === "class") e.className = a[k]; else if (k.startsWith("on")) e.addEventListener(k.slice(2), a[k]); else if (k === "value") e.value = a[k] ?? ""; else e.setAttribute(k, a[k]); } for (const c of [].concat(kids)) e.append(c?.nodeType ? c : document.createTextNode(c ?? "")); return e; };
66
+ const main = document.getElementById("main");
67
+ let tab = "buckets";
68
+
69
+ document.querySelectorAll("nav button").forEach((b) =>
70
+ b.addEventListener("click", () => { tab = b.dataset.tab; document.querySelectorAll("nav button").forEach((x) => x.classList.toggle("active", x === b)); render(); }));
71
+
72
+ // number input that saves on Enter/blur
73
+ function numInput(value, onSave, { money = false, width = 90 } = {}) {
74
+ const shown = value == null ? "" : money ? value / NANOS : value;
75
+ const i = el("input", { value: shown, style: "width:" + width + "px" });
76
+ const save = () => { const t = i.value.trim(); onSave(t === "" ? undefined : money ? Math.round(Number(t) * NANOS) : Number(t)); };
77
+ i.addEventListener("keydown", (e) => { if (e.key === "Enter") i.blur(); });
78
+ i.addEventListener("blur", save);
79
+ return i;
80
+ }
81
+
82
+ async function render() {
83
+ main.innerHTML = "";
84
+ try { await ({ buckets: renderBuckets, requests: renderRequests, usage: renderUsage, settings: renderSettings }[tab])(); }
85
+ catch (e) { main.append(el("div", { class: "err" }, "Error: " + e.message)); }
86
+ }
87
+
88
+ async function renderBuckets() {
89
+ const dims = ["user", "action"];
90
+ const state = { dimension: window.__dim ?? "" };
91
+ const rows = await get("/buckets", state.dimension ? { dimension: state.dimension } : {});
92
+ const grand = rows.reduce((s, b) => s + b.totalSpendNanos, 0);
93
+ document.getElementById("total").textContent = rows.length + " buckets · " + usd(grand) + " total";
94
+ const dimSet = [...new Set(rows.map((b) => b.dimension).concat(dims))];
95
+ const sel = el("select", { value: state.dimension, style: "width:140px",
96
+ onchange: (e) => { window.__dim = e.target.value; render(); } },
97
+ [el("option", { value: "" }, "all dimensions")].concat(dimSet.map((d) => el("option", { value: d }, d))));
98
+ main.append(el("div", { class: "row" }, ["Dimension:", sel]));
99
+
100
+ const head = ["dimension", "value", "today", "month", "total", "daily $", "monthly $", "warn %", "max/min", "blocked", ""];
101
+ const table = el("table", {}, el("thead", {}, el("tr", {}, head.map((h) => el("th", {}, h)))));
102
+ const body = el("tbody");
103
+ for (const b of rows) {
104
+ const set = (patch) => post("/setLimits", { dimension: b.dimension, value: b.value, ...patch }).then(render);
105
+ body.append(el("tr", {}, [
106
+ el("td", { class: "mono muted" }, b.dimension),
107
+ el("td", {}, el("b", {}, b.value)),
108
+ el("td", { class: "mono" }, usd(b.spendTodayNanos)),
109
+ el("td", { class: "mono" }, usd(b.spendThisMonthNanos)),
110
+ el("td", { class: "mono" }, usd(b.totalSpendNanos)),
111
+ el("td", {}, numInput(b.dailySpendLimitNanos, (v) => set({ dailySpendLimitNanos: v }), { money: true })),
112
+ el("td", {}, numInput(b.monthlySpendLimitNanos, (v) => set({ monthlySpendLimitNanos: v }), { money: true })),
113
+ el("td", {}, numInput(b.warnAtPct == null ? undefined : Math.round(b.warnAtPct * 100), (v) => set({ warnAtPct: v == null ? undefined : v / 100 }), { width: 55 })),
114
+ el("td", {}, numInput(b.maxConcurrent, (v) => set({ maxConcurrent: v }), { width: 55 })),
115
+ el("td", {}, el("input", { type: "checkbox", style: "width:auto", ...(b.blocked ? { checked: "" } : {}), onchange: (e) => set({ blocked: e.target.checked }) })),
116
+ el("td", {}, [
117
+ el("button", { class: "act", onclick: () => post("/adjust", { dimension: b.dimension, value: b.value, deltaNanos: -NANOS, reason: "dashboard credit" }).then(render) }, "−$1"),
118
+ " ",
119
+ el("button", { class: "act", onclick: () => post("/bump", { dimension: b.dimension, value: b.value, dailyNanos: NANOS }).then(render) }, "+$1 today"),
120
+ ]),
121
+ ]));
122
+ }
123
+ table.append(body);
124
+ main.append(table);
125
+ }
126
+
127
+ async function renderRequests() {
128
+ const filter = window.__reqFilter ?? {};
129
+ const dimInput = el("input", { value: filter.dimension ?? "", placeholder: "dimension", style: "width:110px" });
130
+ const valInput = el("input", { value: filter.value ?? "", placeholder: "value", style: "width:130px" });
131
+ const go = () => { window.__reqFilter = { dimension: dimInput.value.trim() || undefined, value: valInput.value.trim() || undefined }; render(); };
132
+ main.append(el("div", { class: "row" }, ["Filter by tag:", dimInput, valInput,
133
+ el("button", { class: "act", onclick: go }, "Apply"),
134
+ el("button", { class: "act", onclick: () => { window.__reqFilter = {}; render(); } }, "Clear")]));
135
+
136
+ const rows = await get("/requests", filter);
137
+ const head = ["time", "user", "action", "model", "status", "tokens", "cached", "cost"];
138
+ const table = el("table", {}, el("thead", {}, el("tr", {}, head.map((h) => el("th", {}, h)))));
139
+ const body = el("tbody");
140
+ for (const r of rows) {
141
+ body.append(el("tr", {}, [
142
+ el("td", { class: "muted" }, new Date(r._creationTime).toLocaleTimeString()),
143
+ el("td", { class: "mono" }, r.userId),
144
+ el("td", { class: "mono muted" }, r.actionName ?? "—"),
145
+ el("td", { class: "mono muted" }, r.model),
146
+ el("td", { class: r.status === "success" ? "ok" : r.status === "blocked" || r.status === "error" ? "bad" : "" }, r.status),
147
+ el("td", { class: "mono" }, ((r.promptTokens ?? 0) + (r.completionTokens ?? 0)) || "—"),
148
+ el("td", { class: "mono muted" }, r.cachedTokens || "—"),
149
+ el("td", { class: "mono" }, usd(r.costNanos)),
150
+ ]));
151
+ }
152
+ table.append(body);
153
+ main.append(rows.length ? table : el("div", { class: "muted" }, "No matching requests."));
154
+ }
155
+
156
+ async function renderUsage() {
157
+ const s = window.__usage ?? (window.__usage = { dimension: "user", value: "", period: "day" });
158
+ const buckets = await get("/buckets", { dimension: s.dimension });
159
+ if (!s.value && buckets[0]) s.value = buckets[0].value;
160
+ const dimInput = el("input", { value: s.dimension, style: "width:110px", onchange: (e) => { s.dimension = e.target.value.trim(); s.value = ""; render(); } });
161
+ const valSel = el("select", { value: s.value, style: "width:150px", onchange: (e) => { s.value = e.target.value; render(); } },
162
+ buckets.map((b) => el("option", { value: b.value }, b.value)));
163
+ const perSel = el("select", { value: s.period, style: "width:90px", onchange: (e) => { s.period = e.target.value; render(); } },
164
+ [el("option", { value: "day" }, "day"), el("option", { value: "month" }, "month")]);
165
+ main.append(el("div", { class: "row" }, ["Dimension:", dimInput, "Value:", valSel, "Period:", perSel]));
166
+ if (!s.value) { main.append(el("div", { class: "muted" }, "No buckets in this dimension yet.")); return; }
167
+
168
+ const hist = (await get("/usage", { dimension: s.dimension, value: s.value, period: s.period })).slice().reverse();
169
+ if (!hist.length) { main.append(el("div", { class: "muted" }, "No usage history yet.")); return; }
170
+ const max = Math.max(...hist.map((h) => h.spendNanos), 1);
171
+ main.append(el("h2", {}, "Spend per " + s.period + " — " + s.dimension + " \"" + s.value + "\""));
172
+ main.append(el("div", { class: "bars" }, hist.map((h) =>
173
+ el("div", { class: "bar", title: h.stamp + ": " + usd(h.spendNanos), style: "height:" + Math.max(2, (h.spendNanos / max) * 100) + "%" },
174
+ el("span", {}, h.stamp.slice(5))))));
175
+ const total = hist.reduce((a, h) => a + h.spendNanos, 0);
176
+ main.append(el("div", { class: "muted", style: "margin-top:26px" }, hist.length + " " + s.period + "s · " + usd(total) + " total"));
177
+ }
178
+
179
+ async function renderSettings() {
180
+ const g = await get("/global");
181
+ main.append(el("h2", {}, "Global (deployment-wide) cap"));
182
+ const setG = (patch) => post("/global/setLimits", patch).then(render);
183
+ main.append(el("div", { class: "row" }, [
184
+ "Daily $:", numInput(g.dailySpendLimitNanos, (v) => setG({ dailySpendLimitNanos: v }), { money: true }),
185
+ "Lifetime $:", numInput(g.lifetimeSpendLimitNanos, (v) => setG({ lifetimeSpendLimitNanos: v }), { money: true }),
186
+ el("span", { class: "pill" }, "today " + usd(g.spentTodayNanos) + " · total " + usd(g.spentTotalNanos)),
187
+ ]));
188
+ main.append(el("h2", {}, "Alerts & retention"));
189
+ main.append(el("div", { class: "row" }, [
190
+ "Alert at %:", numInput(g.defaultWarnAtPct == null ? undefined : Math.round(g.defaultWarnAtPct * 100),
191
+ (v) => post("/global/setAlertDefaults", { warnAtPct: v == null ? undefined : v / 100 }).then(render), { width: 55 }),
192
+ "Retention (ms):", numInput(g.retentionMs, (v) => post("/global/setRetention", { retentionMs: v ?? 0 }).then(render), { width: 130 }),
193
+ ]));
194
+
195
+ const prices = await get("/prices");
196
+ main.append(el("h2", {}, "Model prices (nanodollars / Mtok)"));
197
+ const table = el("table", {}, el("thead", {}, el("tr", {}, ["model", "input", "output", "cached", ""].map((h) => el("th", {}, h)))));
198
+ const body = el("tbody");
199
+ for (const [model, p] of Object.entries(prices)) {
200
+ body.append(el("tr", {}, [
201
+ el("td", { class: "mono" }, model),
202
+ el("td", { class: "mono" }, p.input),
203
+ el("td", { class: "mono" }, p.output),
204
+ el("td", { class: "mono muted" }, p.cached ?? "default"),
205
+ el("td", {}, p.overridden ? el("span", { class: "pill" }, "override") : ""),
206
+ ]));
207
+ }
208
+ table.append(body);
209
+ main.append(table);
210
+ }
211
+
212
+ render();
213
+ </script>
214
+ </body>
215
+ </html>`;