opencode-plugboard 0.17.0 → 0.18.0
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 +458 -39
- package/package.json +36 -9
- package/rpc.ts +3 -0
- package/src/dashboard/app.css +209 -0
- package/src/dashboard/app.js +444 -0
- package/src/dashboard/assets.ts +72 -0
- package/src/dashboard/page.ts +87 -0
- package/src/discovery.ts +8 -2
- package/src/openai/route.ts +11 -2
- package/src/tui-format.ts +217 -0
- package/src/tui.tsx +166 -0
- package/tui.ts +4 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* plugboard usage dashboard (#141). Plain browser script, no build step, served as /dashboard/app.js.
|
|
3
|
+
*
|
|
4
|
+
* Data comes only from GET /v1/usage, with the key the person typed (kept in sessionStorage). Every
|
|
5
|
+
* value that came from the server (app names, model ids) reaches the page through textContent or
|
|
6
|
+
* attributes; nothing here writes markup from a string. The page sends no inline script or style (see page.ts).
|
|
7
|
+
*/
|
|
8
|
+
(function () {
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
var KEY_STORAGE = "plugboard.usage.key";
|
|
12
|
+
var COLORS = ["#2f6fdb", "#e0701b", "#2a9d5b", "#b43fa6", "#c99700", "#d6336c", "#15877f", "#6b5ce7"];
|
|
13
|
+
var CHART_HEIGHT = 220;
|
|
14
|
+
var MODEL_ROWS = 20;
|
|
15
|
+
var dark = window.matchMedia("(prefers-color-scheme: dark)");
|
|
16
|
+
|
|
17
|
+
var state = { days: 7, report: null, sort: { key: "costUsd", dir: -1 }, charts: [] };
|
|
18
|
+
|
|
19
|
+
function $(id) {
|
|
20
|
+
return document.getElementById(id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** An element with static attributes and text children (strings become text nodes). */
|
|
24
|
+
function h(tag, attrs) {
|
|
25
|
+
var node = document.createElement(tag);
|
|
26
|
+
var children = Array.prototype.slice.call(arguments, 2);
|
|
27
|
+
Object.keys(attrs || {}).forEach(function (name) {
|
|
28
|
+
node.setAttribute(name, attrs[name]);
|
|
29
|
+
});
|
|
30
|
+
children.forEach(function (child) {
|
|
31
|
+
if (child !== null && child !== undefined) node.append(child);
|
|
32
|
+
});
|
|
33
|
+
return node;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------- formatting
|
|
37
|
+
|
|
38
|
+
var integer = new Intl.NumberFormat(undefined);
|
|
39
|
+
var compact = new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 });
|
|
40
|
+
var dollars = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 2 });
|
|
41
|
+
|
|
42
|
+
function fmtCount(n) {
|
|
43
|
+
return n >= 10000 ? compact.format(n) : integer.format(n);
|
|
44
|
+
}
|
|
45
|
+
function fmtCost(usd) {
|
|
46
|
+
if (usd > 0 && usd < 0.005) return "<$0.01";
|
|
47
|
+
return dollars.format(usd);
|
|
48
|
+
}
|
|
49
|
+
function fmtMs(ms) {
|
|
50
|
+
if (ms === null || ms === undefined) return "n/a";
|
|
51
|
+
if (ms >= 1000) return (ms / 1000).toFixed(ms >= 10000 ? 0 : 1) + " s";
|
|
52
|
+
return ms + " ms";
|
|
53
|
+
}
|
|
54
|
+
function fmtTime(iso) {
|
|
55
|
+
return iso ? new Date(iso).toLocaleString() : "never";
|
|
56
|
+
}
|
|
57
|
+
function fmtRate(hits, misses) {
|
|
58
|
+
var total = hits + misses;
|
|
59
|
+
return total === 0 ? "n/a" : ((hits / total) * 100).toFixed(1) + "%";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function tokensOf(counts) {
|
|
63
|
+
var t = counts.tokens;
|
|
64
|
+
return t.input + t.cacheRead + t.cacheWrite + t.output + t.reasoning;
|
|
65
|
+
}
|
|
66
|
+
function errorsOf(counts) {
|
|
67
|
+
var e = counts.errors;
|
|
68
|
+
return e.client + e.server + e.timeout + e.disconnected;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The UTC calendar day `offset` days from today, YYYY-MM-DD. */
|
|
72
|
+
function utcDay(offset) {
|
|
73
|
+
var d = new Date();
|
|
74
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
75
|
+
d.setUTCDate(d.getUTCDate() + offset);
|
|
76
|
+
return d.toISOString().slice(0, 10);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------- view state
|
|
80
|
+
|
|
81
|
+
function setStatus(text) {
|
|
82
|
+
$("status").textContent = text;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function showSignIn(message) {
|
|
86
|
+
destroyCharts();
|
|
87
|
+
state.report = null;
|
|
88
|
+
$("panel").hidden = true;
|
|
89
|
+
$("signin").hidden = false;
|
|
90
|
+
$("auth-error").hidden = !message;
|
|
91
|
+
$("auth-error").textContent = message || "";
|
|
92
|
+
$("key").focus();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function showPanel() {
|
|
96
|
+
$("signin").hidden = true;
|
|
97
|
+
$("panel").hidden = false;
|
|
98
|
+
Array.prototype.forEach.call(document.querySelectorAll("[data-days]"), function (button) {
|
|
99
|
+
button.setAttribute("aria-pressed", String(Number(button.getAttribute("data-days")) === state.days));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------- loading
|
|
104
|
+
|
|
105
|
+
async function load() {
|
|
106
|
+
var stored = sessionStorage.getItem(KEY_STORAGE);
|
|
107
|
+
if (stored === null) return showSignIn("");
|
|
108
|
+
showPanel();
|
|
109
|
+
var from = utcDay(-(state.days - 1));
|
|
110
|
+
var to = utcDay(0);
|
|
111
|
+
setStatus("Loading " + from + " to " + to + " (UTC)...");
|
|
112
|
+
var response;
|
|
113
|
+
try {
|
|
114
|
+
response = await fetch("/v1/usage?from=" + from + "&to=" + to, {
|
|
115
|
+
headers: stored === "" ? {} : { authorization: "Bearer " + stored },
|
|
116
|
+
cache: "no-store",
|
|
117
|
+
credentials: "omit",
|
|
118
|
+
referrerPolicy: "no-referrer",
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
setStatus("Could not reach plugboard. Check the connection and refresh.");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (response.status === 401 || response.status === 403) {
|
|
125
|
+
sessionStorage.removeItem(KEY_STORAGE);
|
|
126
|
+
showSignIn(response.status === 401 ? "That key was not accepted." : "This key may not read usage.");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
setStatus("plugboard answered " + response.status + ". Refresh to try again.");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
state.report = await response.json();
|
|
134
|
+
render();
|
|
135
|
+
setStatus("Updated " + new Date().toLocaleTimeString() + ". " + from + " to " + to + " (UTC days).");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function render() {
|
|
139
|
+
if (!state.report) return;
|
|
140
|
+
renderTotals();
|
|
141
|
+
renderApps();
|
|
142
|
+
renderCharts();
|
|
143
|
+
renderModels();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------- totals
|
|
147
|
+
|
|
148
|
+
function stat(label, value, note) {
|
|
149
|
+
return h("div", { class: "card stat" }, h("span", { class: "label" }, label), h("strong", null, value), note ? h("span", { class: "muted" }, note) : null);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderTotals() {
|
|
153
|
+
var t = state.report.totals;
|
|
154
|
+
var e = t.errors;
|
|
155
|
+
var cards = [
|
|
156
|
+
stat("Requests", fmtCount(t.requests), null),
|
|
157
|
+
stat("Tokens", fmtCount(tokensOf(t)), "input " + fmtCount(t.tokens.input) + ", output " + fmtCount(t.tokens.output + t.tokens.reasoning)),
|
|
158
|
+
stat("Estimated cost", fmtCost(t.costUsd), t.unpricedRequests > 0 ? t.unpricedRequests + " call(s) had no price" : null),
|
|
159
|
+
stat("Errors", fmtCount(errorsOf(t)), "client " + e.client + ", server " + e.server + ", timeout " + e.timeout + ", disconnected " + e.disconnected),
|
|
160
|
+
stat("Cache hit rate", fmtRate(t.cacheHits, t.cacheMisses), t.cacheHits + " hits, " + t.cacheMisses + " misses"),
|
|
161
|
+
stat("Fallbacks", fmtCount(t.fallbacks), "retries on another target"),
|
|
162
|
+
stat("Rate limited (429)", fmtCount(t.limited), null),
|
|
163
|
+
stat("p95 latency", t.latency.p95Ms === null ? "n/a" : "\u2264 " + fmtMs(t.latency.p95Ms), "estimate from buckets"),
|
|
164
|
+
];
|
|
165
|
+
$("totals").replaceChildren(...cards);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------- tables
|
|
169
|
+
|
|
170
|
+
function sortable(label, key) {
|
|
171
|
+
var active = state.sort.key === key;
|
|
172
|
+
var arrow = active ? (state.sort.dir < 0 ? " \u2193" : " \u2191") : "";
|
|
173
|
+
var button = h("button", { type: "button", "data-sort": key }, label + arrow);
|
|
174
|
+
return h("th", { class: "num", "aria-sort": active ? (state.sort.dir < 0 ? "descending" : "ascending") : "none" }, button);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
var SORT_VALUE = {
|
|
178
|
+
requests: function (t) {
|
|
179
|
+
return t.requests;
|
|
180
|
+
},
|
|
181
|
+
tokens: tokensOf,
|
|
182
|
+
costUsd: function (t) {
|
|
183
|
+
return t.costUsd;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
function renderApps() {
|
|
188
|
+
var apps = state.report.apps;
|
|
189
|
+
var names = Object.keys(apps).sort();
|
|
190
|
+
var table = $("apps");
|
|
191
|
+
table.replaceChildren();
|
|
192
|
+
if (names.length === 0) {
|
|
193
|
+
table.append(h("tr", null, h("td", null, "No usage in this range.")));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
var value = SORT_VALUE[state.sort.key] || SORT_VALUE.costUsd;
|
|
197
|
+
names.sort(function (a, b) {
|
|
198
|
+
return (value(apps[a].totals) - value(apps[b].totals)) * state.sort.dir || (a < b ? -1 : a > b ? 1 : 0);
|
|
199
|
+
});
|
|
200
|
+
table.append(
|
|
201
|
+
h(
|
|
202
|
+
"thead",
|
|
203
|
+
null,
|
|
204
|
+
h(
|
|
205
|
+
"tr",
|
|
206
|
+
null,
|
|
207
|
+
h("th", null, "App"),
|
|
208
|
+
sortable("Requests", "requests"),
|
|
209
|
+
sortable("Tokens", "tokens"),
|
|
210
|
+
sortable("Cost", "costUsd"),
|
|
211
|
+
h("th", { class: "num" }, "Errors"),
|
|
212
|
+
h("th", { class: "num" }, "p95"),
|
|
213
|
+
h("th", null, "Last request"),
|
|
214
|
+
),
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
var body = h("tbody", null);
|
|
218
|
+
names.forEach(function (name) {
|
|
219
|
+
var t = apps[name].totals;
|
|
220
|
+
body.append(
|
|
221
|
+
h(
|
|
222
|
+
"tr",
|
|
223
|
+
null,
|
|
224
|
+
h("td", null, name),
|
|
225
|
+
h("td", { class: "num" }, integer.format(t.requests)),
|
|
226
|
+
h("td", { class: "num" }, fmtCount(tokensOf(t))),
|
|
227
|
+
h("td", { class: "num" }, fmtCost(t.costUsd)),
|
|
228
|
+
h("td", { class: "num" }, integer.format(errorsOf(t))),
|
|
229
|
+
h("td", { class: "num" }, t.latency.p95Ms === null ? "n/a" : "\u2264 " + fmtMs(t.latency.p95Ms)),
|
|
230
|
+
h("td", null, fmtTime(t.lastRequestAt)),
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
table.append(body);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function renderModels() {
|
|
238
|
+
var totals = {};
|
|
239
|
+
Object.keys(state.report.apps).forEach(function (name) {
|
|
240
|
+
var models = state.report.apps[name].models;
|
|
241
|
+
Object.keys(models).forEach(function (model) {
|
|
242
|
+
var sum = totals[model] || { requests: 0, tokens: 0, costUsd: 0 };
|
|
243
|
+
totals[model] = { requests: sum.requests + models[model].requests, tokens: sum.tokens + models[model].tokens, costUsd: sum.costUsd + models[model].costUsd };
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
var rows = Object.keys(totals)
|
|
247
|
+
.map(function (model) {
|
|
248
|
+
return { model: model, usage: totals[model] };
|
|
249
|
+
})
|
|
250
|
+
.sort(function (a, b) {
|
|
251
|
+
return b.usage.costUsd - a.usage.costUsd || b.usage.requests - a.usage.requests || (a.model < b.model ? -1 : 1);
|
|
252
|
+
});
|
|
253
|
+
var table = $("models");
|
|
254
|
+
table.replaceChildren();
|
|
255
|
+
$("models-note").textContent = rows.length > MODEL_ROWS ? "Top " + MODEL_ROWS + " of " + rows.length + " models, by cost." : rows.length === 0 ? "No model calls in this range." : "";
|
|
256
|
+
if (rows.length === 0) return;
|
|
257
|
+
table.append(h("thead", null, h("tr", null, h("th", null, "Model"), h("th", { class: "num" }, "Requests"), h("th", { class: "num" }, "Tokens"), h("th", { class: "num" }, "Cost"))));
|
|
258
|
+
var body = h("tbody", null);
|
|
259
|
+
rows.slice(0, MODEL_ROWS).forEach(function (row) {
|
|
260
|
+
body.append(
|
|
261
|
+
h(
|
|
262
|
+
"tr",
|
|
263
|
+
null,
|
|
264
|
+
h("td", { class: "wrap" }, row.model),
|
|
265
|
+
h("td", { class: "num" }, integer.format(row.usage.requests)),
|
|
266
|
+
h("td", { class: "num" }, fmtCount(row.usage.tokens)),
|
|
267
|
+
h("td", { class: "num" }, fmtCost(row.usage.costUsd)),
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
table.append(body);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------- charts
|
|
275
|
+
|
|
276
|
+
function destroyCharts() {
|
|
277
|
+
state.charts.forEach(function (chart) {
|
|
278
|
+
chart.destroy();
|
|
279
|
+
});
|
|
280
|
+
state.charts = [];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** One value per UTC day of the range, for every app that has records. Days without records are 0. */
|
|
284
|
+
function dailySeries(metric) {
|
|
285
|
+
var report = state.report;
|
|
286
|
+
var dates = [];
|
|
287
|
+
for (var at = Date.parse(report.from + "T00:00:00Z"); at <= Date.parse(report.to + "T00:00:00Z"); at += 86400000) {
|
|
288
|
+
dates.push(new Date(at).toISOString().slice(0, 10));
|
|
289
|
+
}
|
|
290
|
+
var names = Object.keys(report.apps).sort();
|
|
291
|
+
var series = names.map(function (name) {
|
|
292
|
+
var byDate = {};
|
|
293
|
+
report.apps[name].days.forEach(function (day) {
|
|
294
|
+
byDate[day.date] = metric(day);
|
|
295
|
+
});
|
|
296
|
+
return dates.map(function (date) {
|
|
297
|
+
return byDate[date] || 0;
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
return {
|
|
301
|
+
xs: dates.map(function (date) {
|
|
302
|
+
return Date.parse(date + "T00:00:00Z") / 1000;
|
|
303
|
+
}),
|
|
304
|
+
names: names,
|
|
305
|
+
series: series,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function renderChart(id, metric, fmt) {
|
|
310
|
+
var container = $(id);
|
|
311
|
+
container.replaceChildren();
|
|
312
|
+
var data = dailySeries(metric);
|
|
313
|
+
var hasData = data.series.some(function (values) {
|
|
314
|
+
return values.some(function (v) {
|
|
315
|
+
return v > 0;
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
if (!hasData) {
|
|
319
|
+
container.append(h("p", { class: "muted" }, "No usage in this range."));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (!window.uPlot) {
|
|
323
|
+
container.append(h("p", { class: "muted" }, "Charts need uplot.js, which did not load."));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
var ink = dark.matches ? "#c9d1d9" : "#3d4551";
|
|
327
|
+
var grid = dark.matches ? "#30363d" : "#e3e6ea";
|
|
328
|
+
var options = {
|
|
329
|
+
width: Math.max(260, container.clientWidth),
|
|
330
|
+
height: CHART_HEIGHT,
|
|
331
|
+
legend: { show: true },
|
|
332
|
+
scales: {
|
|
333
|
+
x: { time: true },
|
|
334
|
+
// Counts and costs start at zero, whatever the smallest value is.
|
|
335
|
+
y: {
|
|
336
|
+
range: function (u, min, max) {
|
|
337
|
+
return [0, max > 0 ? max * 1.1 : 1];
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
axes: [
|
|
342
|
+
{ stroke: ink, grid: { stroke: grid }, ticks: { stroke: grid } },
|
|
343
|
+
{
|
|
344
|
+
stroke: ink,
|
|
345
|
+
grid: { stroke: grid },
|
|
346
|
+
ticks: { stroke: grid },
|
|
347
|
+
values: function (u, splits) {
|
|
348
|
+
return splits.map(function (v) {
|
|
349
|
+
return fmt(v);
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
series: [
|
|
355
|
+
{},
|
|
356
|
+
...data.names.map(function (name, i) {
|
|
357
|
+
return {
|
|
358
|
+
label: name,
|
|
359
|
+
stroke: COLORS[i % COLORS.length],
|
|
360
|
+
width: 2,
|
|
361
|
+
points: { show: true, size: 5 },
|
|
362
|
+
value: function (u, v) {
|
|
363
|
+
return v === null || v === undefined ? "" : fmt(v);
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}),
|
|
367
|
+
],
|
|
368
|
+
};
|
|
369
|
+
var plot = new window.uPlot(options, [data.xs].concat(data.series), container);
|
|
370
|
+
state.charts.push(plot);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function renderCharts() {
|
|
374
|
+
destroyCharts();
|
|
375
|
+
renderChart("chart-requests", function (day) {
|
|
376
|
+
return day.requests;
|
|
377
|
+
}, function (v) {
|
|
378
|
+
return fmtCount(v);
|
|
379
|
+
});
|
|
380
|
+
renderChart("chart-cost", function (day) {
|
|
381
|
+
return day.costUsd;
|
|
382
|
+
}, function (v) {
|
|
383
|
+
return v >= 1 ? dollars.format(v) : "$" + v.toFixed(3);
|
|
384
|
+
});
|
|
385
|
+
renderChart("chart-tokens", tokensOf, function (v) {
|
|
386
|
+
return fmtCount(v);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function resizeCharts() {
|
|
391
|
+
state.charts.forEach(function (chart) {
|
|
392
|
+
var container = chart.root.parentNode;
|
|
393
|
+
chart.setSize({ width: Math.max(260, container.clientWidth), height: CHART_HEIGHT });
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------- events
|
|
398
|
+
|
|
399
|
+
$("key-form").addEventListener("submit", function (event) {
|
|
400
|
+
event.preventDefault();
|
|
401
|
+
sessionStorage.setItem(KEY_STORAGE, $("key").value.trim());
|
|
402
|
+
$("key").value = "";
|
|
403
|
+
load();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
Array.prototype.forEach.call(document.querySelectorAll("[data-days]"), function (button) {
|
|
407
|
+
button.addEventListener("click", function () {
|
|
408
|
+
state.days = Number(button.getAttribute("data-days"));
|
|
409
|
+
load();
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
$("refresh").addEventListener("click", function () {
|
|
414
|
+
load();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
$("sign-out").addEventListener("click", function () {
|
|
418
|
+
sessionStorage.removeItem(KEY_STORAGE);
|
|
419
|
+
showSignIn("");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
$("apps").addEventListener("click", function (event) {
|
|
423
|
+
var button = event.target.closest("button[data-sort]");
|
|
424
|
+
if (!button || !state.report) return;
|
|
425
|
+
var key = button.getAttribute("data-sort");
|
|
426
|
+
state.sort = { key: key, dir: state.sort.key === key ? -state.sort.dir : -1 };
|
|
427
|
+
renderApps();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
dark.addEventListener("change", function () {
|
|
431
|
+
if (state.report) {
|
|
432
|
+
renderTotals();
|
|
433
|
+
renderCharts();
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
var resizeTimer = null;
|
|
438
|
+
window.addEventListener("resize", function () {
|
|
439
|
+
clearTimeout(resizeTimer);
|
|
440
|
+
resizeTimer = setTimeout(resizeCharts, 150);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
load();
|
|
444
|
+
})();
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serves the usage dashboard (`src/dashboard/page.ts`): the page, the page's own script and
|
|
3
|
+
* stylesheet, and uPlot's browser build and stylesheet from its npm package. Files are read on the
|
|
4
|
+
* first request and kept in memory. Setup does no filesystem work (I3). The bytes never change
|
|
5
|
+
* while plugboard runs, so the ETag is their hash and a revalidation answers 304.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto"
|
|
8
|
+
import { readFile } from "node:fs/promises"
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
10
|
+
import { createRequire } from "node:module"
|
|
11
|
+
import { dirname, join } from "node:path"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
import { sendText } from "../discovery.js"
|
|
14
|
+
import { DASHBOARD_HEADERS, DASHBOARD_PAGE } from "./page.js"
|
|
15
|
+
|
|
16
|
+
const PAGE_PREFIX = "GET /dashboard/"
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
const resolveFrom = createRequire(import.meta.url)
|
|
19
|
+
|
|
20
|
+
interface AssetFile {
|
|
21
|
+
readonly path: () => string
|
|
22
|
+
readonly contentType: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The asset named by the last segment of `/dashboard/<name>`. */
|
|
26
|
+
const ASSETS: Readonly<Record<string, AssetFile>> = {
|
|
27
|
+
"app.js": { path: () => join(here, "app.js"), contentType: "text/javascript; charset=utf-8" },
|
|
28
|
+
"app.css": { path: () => join(here, "app.css"), contentType: "text/css; charset=utf-8" },
|
|
29
|
+
"uplot.js": { path: () => resolveFrom.resolve("uplot/dist/uPlot.iife.min.js"), contentType: "text/javascript; charset=utf-8" },
|
|
30
|
+
"uplot.css": { path: () => resolveFrom.resolve("uplot/dist/uPlot.min.css"), contentType: "text/css; charset=utf-8" },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Asset {
|
|
34
|
+
readonly body: string
|
|
35
|
+
readonly contentType: string
|
|
36
|
+
readonly etag: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const loaded = new Map<string, Promise<Asset>>()
|
|
40
|
+
|
|
41
|
+
async function load(name: string): Promise<Asset> {
|
|
42
|
+
const file = ASSETS[name]
|
|
43
|
+
if (!file) throw new Error(`no dashboard asset ${name}`)
|
|
44
|
+
const body = await readFile(file.path(), "utf8")
|
|
45
|
+
const digest = createHash("sha256").update(body).digest("hex").slice(0, 32)
|
|
46
|
+
return { body, contentType: file.contentType, etag: `"${digest}"` }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The asset's bytes, read once per process. A failed read is not kept, so the next request tries again. */
|
|
50
|
+
function asset(name: string): Promise<Asset> {
|
|
51
|
+
let pending = loaded.get(name)
|
|
52
|
+
if (!pending) {
|
|
53
|
+
pending = load(name)
|
|
54
|
+
loaded.set(name, pending)
|
|
55
|
+
pending.catch(() => loaded.delete(name))
|
|
56
|
+
}
|
|
57
|
+
return pending
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Answers one dashboard route (`DASHBOARD_ROUTES`, already matched by the caller). */
|
|
61
|
+
export async function serveDashboard(request: IncomingMessage, response: ServerResponse, route: string): Promise<void> {
|
|
62
|
+
if (route === "GET /dashboard") return sendText(response, 200, "text/html; charset=utf-8", DASHBOARD_PAGE, DASHBOARD_HEADERS)
|
|
63
|
+
const found = await asset(route.slice(PAGE_PREFIX.length))
|
|
64
|
+
// Assets revalidate on every use: a plugboard upgrade changes them, and the ETag makes that check free.
|
|
65
|
+
const headers = { ...DASHBOARD_HEADERS, "cache-control": "no-cache", etag: found.etag }
|
|
66
|
+
if (request.headers["if-none-match"] === found.etag) {
|
|
67
|
+
if (!response.headersSent && !response.destroyed) response.writeHead(304, headers)
|
|
68
|
+
response.end()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
return sendText(response, 200, found.contentType, found.body, headers)
|
|
72
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The usage dashboard page (`GET /dashboard`, #141): static HTML, its headers and the routes that
|
|
3
|
+
* serve it. The page holds no data. It asks for a key, then reads `GET /v1/usage` from the same
|
|
4
|
+
* origin with that key (the data never reaches the page's HTML). No inline script or style: the
|
|
5
|
+
* script and stylesheets are plugboard's own files under `/dashboard/`, served by `src/dashboard/assets.ts`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Scripts and styles from this origin only; nothing else may load, frame or submit. */
|
|
9
|
+
export const DASHBOARD_CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
10
|
+
|
|
11
|
+
/** Headers of every dashboard response; the assets get them too. */
|
|
12
|
+
export const DASHBOARD_HEADERS: Readonly<Record<string, string>> = {
|
|
13
|
+
"content-security-policy": DASHBOARD_CSP,
|
|
14
|
+
"x-frame-options": "DENY",
|
|
15
|
+
"referrer-policy": "no-referrer",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Routes of the dashboard: the page and its four files. Served only when option `usage` is on. */
|
|
19
|
+
export const DASHBOARD_ROUTES: ReadonlySet<string> = new Set([
|
|
20
|
+
"GET /dashboard",
|
|
21
|
+
"GET /dashboard/app.js",
|
|
22
|
+
"GET /dashboard/app.css",
|
|
23
|
+
"GET /dashboard/uplot.js",
|
|
24
|
+
"GET /dashboard/uplot.css",
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
export const DASHBOARD_PAGE = `<!doctype html>
|
|
28
|
+
<html lang="en">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8">
|
|
31
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
32
|
+
<meta name="referrer" content="no-referrer">
|
|
33
|
+
<title>plugboard usage</title>
|
|
34
|
+
<link rel="stylesheet" href="/dashboard/uplot.css">
|
|
35
|
+
<link rel="stylesheet" href="/dashboard/app.css">
|
|
36
|
+
<script src="/dashboard/uplot.js" defer></script>
|
|
37
|
+
<script src="/dashboard/app.js" defer></script>
|
|
38
|
+
</head>
|
|
39
|
+
<body>
|
|
40
|
+
<header class="top">
|
|
41
|
+
<h1>plugboard usage</h1>
|
|
42
|
+
<p class="muted">Requests, tokens and estimated cost per app, per UTC day. Cost uses the catalog's list prices.</p>
|
|
43
|
+
</header>
|
|
44
|
+
<main>
|
|
45
|
+
<section id="signin" class="card" hidden>
|
|
46
|
+
<form id="key-form">
|
|
47
|
+
<label for="key">API key</label>
|
|
48
|
+
<div class="row">
|
|
49
|
+
<input id="key" name="key" type="password" autocomplete="off" autocapitalize="off" spellcheck="false">
|
|
50
|
+
<button type="submit" class="primary">Show usage</button>
|
|
51
|
+
</div>
|
|
52
|
+
<p class="muted">The admin key shows every app. An app key shows only its own. The key is kept in this tab's session storage and sent only to this server.</p>
|
|
53
|
+
</form>
|
|
54
|
+
<p id="auth-error" class="error" role="alert" hidden></p>
|
|
55
|
+
</section>
|
|
56
|
+
<section id="panel" hidden>
|
|
57
|
+
<div class="toolbar">
|
|
58
|
+
<div class="ranges" role="group" aria-label="Range">
|
|
59
|
+
<button type="button" data-days="1">Today</button>
|
|
60
|
+
<button type="button" data-days="7">7 days</button>
|
|
61
|
+
<button type="button" data-days="30">30 days</button>
|
|
62
|
+
<button type="button" data-days="90">90 days</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="row">
|
|
65
|
+
<button type="button" id="refresh">Refresh</button>
|
|
66
|
+
<button type="button" id="sign-out">Sign out</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<p id="status" class="muted" aria-live="polite"></p>
|
|
70
|
+
<h2>Totals</h2>
|
|
71
|
+
<div id="totals" class="cards"></div>
|
|
72
|
+
<h2>Apps</h2>
|
|
73
|
+
<div class="scroll"><table id="apps"></table></div>
|
|
74
|
+
<h2>Requests per day</h2>
|
|
75
|
+
<div id="chart-requests" class="chart"></div>
|
|
76
|
+
<h2>Estimated cost per day (USD)</h2>
|
|
77
|
+
<div id="chart-cost" class="chart"></div>
|
|
78
|
+
<h2>Tokens per day</h2>
|
|
79
|
+
<div id="chart-tokens" class="chart"></div>
|
|
80
|
+
<h2>Models</h2>
|
|
81
|
+
<p id="models-note" class="muted"></p>
|
|
82
|
+
<div class="scroll"><table id="models"></table></div>
|
|
83
|
+
</section>
|
|
84
|
+
</main>
|
|
85
|
+
</body>
|
|
86
|
+
</html>
|
|
87
|
+
`
|
package/src/discovery.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface Endpoint {
|
|
|
61
61
|
/**
|
|
62
62
|
* Every endpoint plugboard serves, in the order the document lists them. Dispatch lives in
|
|
63
63
|
* `src/openai/route.ts` and the front-end routes; `test/http/discovery.test.ts` probes each served
|
|
64
|
-
* route and fails when this list omits one (#136). `/v1/usage` and `/metrics` are added only when their options are on.
|
|
64
|
+
* route and fails when this list omits one (#136). `/v1/usage`, `/dashboard` and `/metrics` are added only when their options are on.
|
|
65
65
|
*/
|
|
66
66
|
export const ENDPOINTS: ReadonlyArray<Endpoint> = [
|
|
67
67
|
{ method: "GET", path: "/", auth: false, description: "This page." },
|
|
@@ -85,7 +85,12 @@ export const ENDPOINTS: ReadonlyArray<Endpoint> = [
|
|
|
85
85
|
function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
|
|
86
86
|
return [
|
|
87
87
|
...ENDPOINTS,
|
|
88
|
-
...(options.usage.enabled
|
|
88
|
+
...(options.usage.enabled
|
|
89
|
+
? [
|
|
90
|
+
{ method: "GET", path: "/v1/usage", auth: true, description: "Per-app usage by day: requests, tokens, cost, latency. The admin key reads every app." },
|
|
91
|
+
{ method: "GET", path: "/dashboard", auth: false, description: "Usage dashboard page. Its data needs a key (`/v1/usage`)." },
|
|
92
|
+
]
|
|
93
|
+
: []),
|
|
89
94
|
...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
|
|
90
95
|
]
|
|
91
96
|
}
|
|
@@ -238,6 +243,7 @@ export function renderDiscoveryHtml(doc: Discovery): string {
|
|
|
238
243
|
const more = doc.catalog.moreProviders ? `<p>${escapeHtml(doc.catalog.moreProviders)} more providers not listed.</p>` : ""
|
|
239
244
|
sections.push(`<h2>Catalog</h2><table><tr><th>Provider</th><th>Models</th><th>Examples</th></tr>${rows}</table>${more}`)
|
|
240
245
|
}
|
|
246
|
+
if (doc.endpoints.some((endpoint) => endpoint.path === "/dashboard")) sections.push(`<p><a href="/dashboard">Usage dashboard</a></p>`)
|
|
241
247
|
sections.push(`<h2>Try it</h2>${pre(doc.example.curl)}`)
|
|
242
248
|
sections.push(
|
|
243
249
|
`<h2>Endpoints</h2><table><tr><th>Method</th><th>Path</th><th>Key</th><th></th></tr>${doc.endpoints
|
package/src/openai/route.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* `POST /v1/chat/completions` (`n` > 1 through `./fanout.ts`), and the dispatch of `POST /v1/responses`
|
|
4
4
|
* and `POST /v1/completions` to `src/responses/route.ts` and `src/completions/route.ts`.
|
|
5
5
|
* Observability and first-run routes (`src/discovery.ts`, `src/metrics.ts`): `GET /` and `GET /v1`
|
|
6
|
-
* (keyless discovery), the authenticated `GET /v1/status`,
|
|
6
|
+
* (keyless discovery), the authenticated `GET /v1/status`, `GET /metrics` (option `metrics`), and the
|
|
7
|
+
* usage dashboard `GET /dashboard` with its assets (option `usage`, `src/dashboard/`).
|
|
7
8
|
*
|
|
8
9
|
* The flow is: establish cancellation and the deadline, check origin, authenticate, parse, resolve
|
|
9
10
|
* the model, call the ModelCaller, encode. Parsing, policy and encoding are imported directly;
|
|
@@ -50,6 +51,8 @@ import { serveCompletions } from "../completions/route.js"
|
|
|
50
51
|
import { serveResponses } from "../responses/route.js"
|
|
51
52
|
import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
|
|
52
53
|
import type { UsageRecorder } from "../usage.js"
|
|
54
|
+
import { DASHBOARD_ROUTES } from "../dashboard/page.js"
|
|
55
|
+
import { serveDashboard } from "../dashboard/assets.js"
|
|
53
56
|
import { chatCodec, serveChoices } from "./fanout.js"
|
|
54
57
|
import { parseChatRequest } from "./request.js"
|
|
55
58
|
import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
@@ -120,7 +123,10 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
120
123
|
const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
|
|
121
124
|
/** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
|
|
122
125
|
const known = (route: string) =>
|
|
123
|
-
KNOWN_ROUTES.includes(route) ||
|
|
126
|
+
KNOWN_ROUTES.includes(route) ||
|
|
127
|
+
DISCOVERY_ROUTES.includes(route) ||
|
|
128
|
+
(metrics !== undefined && route === "GET /metrics") ||
|
|
129
|
+
(host.usage !== undefined && (route === "GET /v1/usage" || DASHBOARD_ROUTES.has(route)))
|
|
124
130
|
/** Responses to Gemini routes: errors there use Google's body. */
|
|
125
131
|
const googleErrors = new WeakSet<ServerResponse>()
|
|
126
132
|
const fail = (response: ServerResponse, error: PlugError, close = false) =>
|
|
@@ -176,6 +182,9 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
176
182
|
return sendText(response, 200, "text/html; charset=utf-8", renderDiscoveryHtml(doc), PAGE_HEADERS)
|
|
177
183
|
}
|
|
178
184
|
|
|
185
|
+
// The usage dashboard (#141): a page and its files, keyless like the discovery pages. Its data comes from `GET /v1/usage`, behind a key.
|
|
186
|
+
if (host.usage && DASHBOARD_ROUTES.has(route)) return serveDashboard(request, response, route)
|
|
187
|
+
|
|
179
188
|
// Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
|
|
180
189
|
if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
|
|
181
190
|
|