@jeffreycao/copilot-api 1.9.3 → 1.9.4
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/package.json +1 -1
- package/pages/index.html +871 -82
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@jeffreycao/copilot-api",
|
|
4
|
-
"version": "1.9.
|
|
4
|
+
"version": "1.9.4",
|
|
5
5
|
"description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code Or Codex Or Opencode!",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"proxy",
|
package/pages/index.html
CHANGED
|
@@ -92,6 +92,12 @@
|
|
|
92
92
|
border-color: var(--color-blue);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
button:disabled,
|
|
96
|
+
select:disabled {
|
|
97
|
+
cursor: not-allowed;
|
|
98
|
+
opacity: 0.55;
|
|
99
|
+
}
|
|
100
|
+
|
|
95
101
|
|
|
96
102
|
</style>
|
|
97
103
|
</head>
|
|
@@ -140,29 +146,73 @@
|
|
|
140
146
|
>
|
|
141
147
|
<form
|
|
142
148
|
id="endpoint-form"
|
|
143
|
-
class="
|
|
149
|
+
class="grid grid-cols-1 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_180px_auto] gap-3 items-end"
|
|
144
150
|
>
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
151
|
+
<div class="w-full">
|
|
152
|
+
<label
|
|
153
|
+
for="endpoint-url"
|
|
154
|
+
class="mb-1 block font-semibold whitespace-nowrap text-sm"
|
|
155
|
+
style="color: var(--color-fg-lightest)"
|
|
156
|
+
>Usage Endpoint URL</label
|
|
157
|
+
>
|
|
158
|
+
<input
|
|
159
|
+
type="text"
|
|
160
|
+
id="endpoint-url"
|
|
161
|
+
class="w-full px-3 py-2 border focus:ring-1 transition input-focus text-sm"
|
|
162
|
+
style="
|
|
163
|
+
background-color: var(--color-bg-darkest);
|
|
164
|
+
border-color: var(--color-bg-light-3);
|
|
165
|
+
color: var(--color-fg-medium);
|
|
166
|
+
"
|
|
167
|
+
placeholder="http://localhost:4141/usage"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="w-full">
|
|
171
|
+
<label
|
|
172
|
+
for="x-api-key"
|
|
173
|
+
class="mb-1 block font-semibold whitespace-nowrap text-sm"
|
|
174
|
+
style="color: var(--color-fg-lightest)"
|
|
175
|
+
>x-api-key</label
|
|
176
|
+
>
|
|
177
|
+
<input
|
|
178
|
+
type="password"
|
|
179
|
+
id="x-api-key"
|
|
180
|
+
class="w-full px-3 py-2 border focus:ring-1 transition input-focus text-sm"
|
|
181
|
+
style="
|
|
182
|
+
background-color: var(--color-bg-darkest);
|
|
183
|
+
border-color: var(--color-bg-light-3);
|
|
184
|
+
color: var(--color-fg-medium);
|
|
185
|
+
"
|
|
186
|
+
placeholder="Optional when auth is enabled"
|
|
187
|
+
autocomplete="off"
|
|
188
|
+
spellcheck="false"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
<div class="w-full">
|
|
192
|
+
<label
|
|
193
|
+
for="token-usage-period"
|
|
194
|
+
class="mb-1 block font-semibold whitespace-nowrap text-sm"
|
|
195
|
+
style="color: var(--color-fg-lightest)"
|
|
196
|
+
>Token Usage Period</label
|
|
197
|
+
>
|
|
198
|
+
<select
|
|
199
|
+
id="token-usage-period"
|
|
200
|
+
class="w-full px-3 py-2 border focus:ring-1 transition input-focus text-sm"
|
|
201
|
+
style="
|
|
202
|
+
background-color: var(--color-bg-darkest);
|
|
203
|
+
border-color: var(--color-bg-light-3);
|
|
204
|
+
color: var(--color-fg-medium);
|
|
205
|
+
"
|
|
206
|
+
>
|
|
207
|
+
<option value="day">Day</option>
|
|
208
|
+
<option value="week">Week</option>
|
|
209
|
+
<option value="month">Month</option>
|
|
210
|
+
</select>
|
|
211
|
+
</div>
|
|
162
212
|
<button
|
|
163
213
|
id="fetch-button"
|
|
164
214
|
type="submit"
|
|
165
|
-
class="w-full
|
|
215
|
+
class="w-full xl:w-auto font-bold py-2 px-5 transition-colors flex items-center justify-center gap-2 text-sm"
|
|
166
216
|
style="
|
|
167
217
|
background-color: var(--color-blue);
|
|
168
218
|
color: var(--color-bg-darkest);
|
|
@@ -188,6 +238,11 @@
|
|
|
188
238
|
<span>Fetch</span>
|
|
189
239
|
</button>
|
|
190
240
|
</form>
|
|
241
|
+
<p class="mt-3 text-xs" style="color: var(--color-gray-accent)">
|
|
242
|
+
The page derives <code>/token-usage</code> and
|
|
243
|
+
<code>/token-usage/events</code> from the usage endpoint and sends
|
|
244
|
+
<code>x-api-key</code> to all requests when provided.
|
|
245
|
+
</p>
|
|
191
246
|
</div>
|
|
192
247
|
|
|
193
248
|
<!-- Content Area for dynamic data -->
|
|
@@ -199,8 +254,13 @@
|
|
|
199
254
|
document.addEventListener("DOMContentLoaded", () => {
|
|
200
255
|
const endpointForm = document.getElementById("endpoint-form");
|
|
201
256
|
const endpointUrlInput = document.getElementById("endpoint-url");
|
|
257
|
+
const apiKeyInput = document.getElementById("x-api-key");
|
|
258
|
+
const tokenUsagePeriodSelect = document.getElementById(
|
|
259
|
+
"token-usage-period"
|
|
260
|
+
);
|
|
202
261
|
const contentArea = document.getElementById("content-area");
|
|
203
262
|
const fetchButton = document.getElementById("fetch-button");
|
|
263
|
+
const fetchButtonLabel = fetchButton.querySelector("span");
|
|
204
264
|
|
|
205
265
|
// Apply hover effect for fetch button via JS
|
|
206
266
|
fetchButton.addEventListener("mouseenter", () => {
|
|
@@ -211,12 +271,31 @@
|
|
|
211
271
|
});
|
|
212
272
|
|
|
213
273
|
const DEFAULT_ENDPOINT = "http://localhost:4141/usage";
|
|
274
|
+
const DEFAULT_TOKEN_USAGE_PERIOD = "day";
|
|
275
|
+
const DEFAULT_TOKEN_USAGE_EVENTS_PAGE_SIZE = 20;
|
|
276
|
+
const API_KEY_STORAGE_KEY = "copilot-api.usage-viewer.x-api-key";
|
|
277
|
+
const VALID_PERIODS = new Set(["day", "week", "month"]);
|
|
278
|
+
const EMPTY_TOKEN_USAGE_TOTALS = {
|
|
279
|
+
cache_creation_input_tokens: 0,
|
|
280
|
+
cache_read_input_tokens: 0,
|
|
281
|
+
input_tokens: 0,
|
|
282
|
+
output_tokens: 0,
|
|
283
|
+
request_count: 0,
|
|
284
|
+
total_tokens: 0,
|
|
285
|
+
};
|
|
214
286
|
|
|
215
287
|
// --- State Management ---
|
|
216
288
|
const state = {
|
|
217
289
|
isLoading: false,
|
|
290
|
+
isTokenUsageLoading: false,
|
|
291
|
+
isEventsLoading: false,
|
|
218
292
|
error: null,
|
|
219
293
|
data: null,
|
|
294
|
+
tokenUsageSummary: null,
|
|
295
|
+
tokenUsageEventsPage: null,
|
|
296
|
+
tokenUsageSummaryError: null,
|
|
297
|
+
tokenUsageEventsError: null,
|
|
298
|
+
tokenUsagePeriod: DEFAULT_TOKEN_USAGE_PERIOD,
|
|
220
299
|
};
|
|
221
300
|
|
|
222
301
|
// --- Rendering Logic ---
|
|
@@ -230,24 +309,253 @@
|
|
|
230
309
|
}
|
|
231
310
|
}
|
|
232
311
|
|
|
312
|
+
function escapeHtml(value) {
|
|
313
|
+
return String(value)
|
|
314
|
+
.replaceAll("&", "&")
|
|
315
|
+
.replaceAll("<", "<")
|
|
316
|
+
.replaceAll(">", ">")
|
|
317
|
+
.replaceAll('"', """)
|
|
318
|
+
.replaceAll("'", "'");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function getErrorMessage(error) {
|
|
322
|
+
if (error instanceof Error) {
|
|
323
|
+
return error.message;
|
|
324
|
+
}
|
|
325
|
+
return typeof error === "string" ? error : "Unknown error.";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isMissingTokenUsageEndpointError(error) {
|
|
329
|
+
return Boolean(
|
|
330
|
+
error
|
|
331
|
+
&& typeof error === "object"
|
|
332
|
+
&& "status" in error
|
|
333
|
+
&& error.status === 404
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function normalizePeriod(value) {
|
|
338
|
+
return VALID_PERIODS.has(value)
|
|
339
|
+
? value
|
|
340
|
+
: DEFAULT_TOKEN_USAGE_PERIOD;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function getSelectedPeriod() {
|
|
344
|
+
const normalized = normalizePeriod(tokenUsagePeriodSelect.value);
|
|
345
|
+
tokenUsagePeriodSelect.value = normalized;
|
|
346
|
+
state.tokenUsagePeriod = normalized;
|
|
347
|
+
return normalized;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function loadStoredApiKey() {
|
|
351
|
+
try {
|
|
352
|
+
return localStorage.getItem(API_KEY_STORAGE_KEY) || "";
|
|
353
|
+
} catch (error) {
|
|
354
|
+
console.warn("Could not read stored API key:", getErrorMessage(error));
|
|
355
|
+
return "";
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function storeApiKey(value) {
|
|
360
|
+
try {
|
|
361
|
+
const trimmed = value.trim();
|
|
362
|
+
if (trimmed) {
|
|
363
|
+
localStorage.setItem(API_KEY_STORAGE_KEY, trimmed);
|
|
364
|
+
} else {
|
|
365
|
+
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
|
366
|
+
}
|
|
367
|
+
} catch (error) {
|
|
368
|
+
console.warn("Could not store API key:", getErrorMessage(error));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function buildRequestHeaders() {
|
|
373
|
+
const headers = {};
|
|
374
|
+
const apiKey = apiKeyInput.value.trim();
|
|
375
|
+
if (apiKey) {
|
|
376
|
+
headers["x-api-key"] = apiKey;
|
|
377
|
+
}
|
|
378
|
+
return headers;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function resolveUsageUrl(endpoint) {
|
|
382
|
+
return new URL(endpoint, window.location.href).toString();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function deriveEndpointUrl(usageEndpoint, targetPath) {
|
|
386
|
+
const url = new URL(usageEndpoint, window.location.href);
|
|
387
|
+
const normalizedTargetPath = targetPath.replace(/^\/+/, "");
|
|
388
|
+
const normalizedPath = url.pathname.replace(/\/+$/, "");
|
|
389
|
+
const nextPath = normalizedPath.endsWith("/usage")
|
|
390
|
+
? normalizedPath.replace(/\/usage$/, `/${normalizedTargetPath}`)
|
|
391
|
+
: `${normalizedPath || ""}/${normalizedTargetPath}`;
|
|
392
|
+
|
|
393
|
+
url.pathname = nextPath.startsWith("/") ? nextPath : `/${nextPath}`;
|
|
394
|
+
url.search = "";
|
|
395
|
+
url.hash = "";
|
|
396
|
+
return url.toString();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function buildTokenUsageSummaryUrl(usageEndpoint, period) {
|
|
400
|
+
const url = new URL(deriveEndpointUrl(usageEndpoint, "token-usage"));
|
|
401
|
+
url.searchParams.set("period", period);
|
|
402
|
+
return url.toString();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function buildTokenUsageEventsUrl(usageEndpoint, period, page) {
|
|
406
|
+
const url = new URL(
|
|
407
|
+
deriveEndpointUrl(usageEndpoint, "token-usage/events")
|
|
408
|
+
);
|
|
409
|
+
url.searchParams.set("period", period);
|
|
410
|
+
url.searchParams.set("page", String(page));
|
|
411
|
+
url.searchParams.set(
|
|
412
|
+
"page_size",
|
|
413
|
+
String(DEFAULT_TOKEN_USAGE_EVENTS_PAGE_SIZE)
|
|
414
|
+
);
|
|
415
|
+
return url.toString();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function fetchJson(url) {
|
|
419
|
+
const response = await fetch(url, {
|
|
420
|
+
headers: buildRequestHeaders(),
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
let message = response.statusText || "Request failed";
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const contentType = response.headers.get("content-type") || "";
|
|
428
|
+
if (contentType.includes("application/json")) {
|
|
429
|
+
const payload = await response.json();
|
|
430
|
+
const apiMessage = payload?.error?.message || payload?.message;
|
|
431
|
+
if (typeof apiMessage === "string" && apiMessage.trim()) {
|
|
432
|
+
message = apiMessage.trim();
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
const text = await response.text();
|
|
436
|
+
if (text.trim()) {
|
|
437
|
+
message = text.trim();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
} catch (error) {
|
|
441
|
+
console.warn(
|
|
442
|
+
"Could not parse error response:",
|
|
443
|
+
getErrorMessage(error)
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const error = new Error(
|
|
448
|
+
`Request failed with status ${response.status}: ${message}`
|
|
449
|
+
);
|
|
450
|
+
error.status = response.status;
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return response.json();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function syncUrlState() {
|
|
458
|
+
try {
|
|
459
|
+
const currentUrl = new URL(window.location.href);
|
|
460
|
+
const endpoint = endpointUrlInput.value.trim();
|
|
461
|
+
const period = getSelectedPeriod();
|
|
462
|
+
|
|
463
|
+
if (endpoint) {
|
|
464
|
+
currentUrl.searchParams.set("endpoint", endpoint);
|
|
465
|
+
} else {
|
|
466
|
+
currentUrl.searchParams.delete("endpoint");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
currentUrl.searchParams.set("period", period);
|
|
470
|
+
window.history.pushState({}, "", currentUrl);
|
|
471
|
+
} catch (error) {
|
|
472
|
+
console.warn("Could not update URL:", getErrorMessage(error));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function updateControls() {
|
|
477
|
+
fetchButton.disabled = state.isLoading;
|
|
478
|
+
fetchButtonLabel.textContent = state.isLoading ? "Fetching..." : "Fetch";
|
|
479
|
+
tokenUsagePeriodSelect.disabled =
|
|
480
|
+
state.isLoading || state.isTokenUsageLoading;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function formatNumber(value) {
|
|
484
|
+
return Number.isFinite(value) ? Number(value).toLocaleString() : "0";
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function formatDateTime(value) {
|
|
488
|
+
if (!Number.isFinite(value)) {
|
|
489
|
+
return "N/A";
|
|
490
|
+
}
|
|
491
|
+
return new Date(value).toLocaleString();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function formatCellText(value) {
|
|
495
|
+
if (typeof value !== "string") {
|
|
496
|
+
return "—";
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const trimmed = value.trim();
|
|
500
|
+
return trimmed || "—";
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function renderTokenUsageRangeText(period, range) {
|
|
504
|
+
const labels = {
|
|
505
|
+
day: "Day window",
|
|
506
|
+
week: "Week window",
|
|
507
|
+
month: "Month window",
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
if (!range) {
|
|
511
|
+
return labels[period] || labels.day;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return `${labels[period] || labels.day}: ${formatDateTime(
|
|
515
|
+
range.start_ms
|
|
516
|
+
)} - ${formatDateTime(range.end_ms)}`;
|
|
517
|
+
}
|
|
518
|
+
|
|
233
519
|
/**
|
|
234
520
|
* Renders the entire UI based on the current state.
|
|
235
521
|
*/
|
|
236
522
|
function render() {
|
|
523
|
+
updateControls();
|
|
524
|
+
|
|
237
525
|
if (state.isLoading) {
|
|
238
526
|
contentArea.innerHTML = renderSpinner();
|
|
527
|
+
createIcons();
|
|
239
528
|
return;
|
|
240
529
|
}
|
|
241
|
-
|
|
242
|
-
|
|
530
|
+
|
|
531
|
+
const hasContent = Boolean(
|
|
532
|
+
state.error
|
|
533
|
+
|| state.data
|
|
534
|
+
|| state.isTokenUsageLoading
|
|
535
|
+
|| state.isEventsLoading
|
|
536
|
+
|| state.tokenUsageSummary
|
|
537
|
+
|| state.tokenUsageEventsPage
|
|
538
|
+
|| state.tokenUsageSummaryError
|
|
539
|
+
|| state.tokenUsageEventsError
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
if (!hasContent) {
|
|
543
|
+
contentArea.innerHTML = renderWelcomeMessage();
|
|
544
|
+
} else if (state.error && !state.data) {
|
|
545
|
+
contentArea.innerHTML = `
|
|
546
|
+
${renderError(state.error, "Usage request failed")}
|
|
547
|
+
${renderTokenUsageSection()}
|
|
548
|
+
`;
|
|
243
549
|
} else if (state.data) {
|
|
244
550
|
contentArea.innerHTML = `
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
551
|
+
${renderUsageQuotas(state.data.quota_snapshots)}
|
|
552
|
+
${renderTokenUsageSection()}
|
|
553
|
+
${renderDetailedData(state.data)}
|
|
554
|
+
`;
|
|
248
555
|
} else {
|
|
249
|
-
contentArea.innerHTML =
|
|
556
|
+
contentArea.innerHTML = renderTokenUsageSection();
|
|
250
557
|
}
|
|
558
|
+
|
|
251
559
|
// Replace placeholder icons with actual Lucide icons
|
|
252
560
|
createIcons();
|
|
253
561
|
}
|
|
@@ -301,7 +609,7 @@
|
|
|
301
609
|
return `
|
|
302
610
|
<div class="p-4 border" style="background-color: var(--color-bg); border-color: var(--color-bg-light-2);">
|
|
303
611
|
<div class="flex justify-between items-center mb-2">
|
|
304
|
-
<h3 class="text-md font-semibold capitalize" style="color: var(--color-fg-lightest);">${title.replace(/_/g, " ")}</h3>
|
|
612
|
+
<h3 class="text-md font-semibold capitalize" style="color: var(--color-fg-lightest);">${escapeHtml(title.replace(/_/g, " "))}</h3>
|
|
305
613
|
${
|
|
306
614
|
unlimited
|
|
307
615
|
? `<span class="px-2 py-0.5 text-xs font-medium" style="color: var(--color-blue-accent); background-color: var(--color-bg-light-1);">Unlimited</span>`
|
|
@@ -328,14 +636,14 @@
|
|
|
328
636
|
*/
|
|
329
637
|
function formatObject(obj) {
|
|
330
638
|
if (obj === null || typeof obj !== "object") {
|
|
331
|
-
return `<span style="color: var(--color-green-accent);">${JSON.stringify(obj)}</span>`;
|
|
639
|
+
return `<span style="color: var(--color-green-accent);">${escapeHtml(JSON.stringify(obj))}</span>`;
|
|
332
640
|
}
|
|
333
641
|
|
|
334
642
|
return (
|
|
335
643
|
'<div class="pl-4">' +
|
|
336
644
|
Object.entries(obj)
|
|
337
645
|
.map(([key, value]) => {
|
|
338
|
-
const formattedKey = key.replace(/_/g, " ");
|
|
646
|
+
const formattedKey = escapeHtml(key.replace(/_/g, " "));
|
|
339
647
|
let displayValue;
|
|
340
648
|
|
|
341
649
|
if (Array.isArray(value)) {
|
|
@@ -346,9 +654,9 @@
|
|
|
346
654
|
} else if (typeof value === "object" && value !== null) {
|
|
347
655
|
displayValue = formatObject(value);
|
|
348
656
|
} else if (typeof value === "boolean") {
|
|
349
|
-
displayValue = `<span class="font-semibold" style="color: ${value ? "var(--color-green-accent)" : "var(--color-red-accent)"}">${value}</span>`;
|
|
657
|
+
displayValue = `<span class="font-semibold" style="color: ${value ? "var(--color-green-accent)" : "var(--color-red-accent)"}">${escapeHtml(String(value))}</span>`;
|
|
350
658
|
} else {
|
|
351
|
-
displayValue = `<span style="color: var(--color-blue-accent);">${JSON.stringify(value)}</span>`;
|
|
659
|
+
displayValue = `<span style="color: var(--color-blue-accent);">${escapeHtml(JSON.stringify(value))}</span>`;
|
|
352
660
|
}
|
|
353
661
|
|
|
354
662
|
return `<div class="mt-1">
|
|
@@ -371,15 +679,329 @@
|
|
|
371
679
|
return `
|
|
372
680
|
<section id="detailed-data">
|
|
373
681
|
<h2 class="text-xl font-bold mb-3 flex items-center gap-2" style="color: var(--color-fg-lightest);">
|
|
374
|
-
<i data-lucide="file-text"></i>
|
|
682
|
+
<i data-lucide="file-text"></i> Usage API Response
|
|
375
683
|
</h2>
|
|
376
|
-
<div class="border p-4 relative font-mono text-xs" style="background-color: var(--color-bg-darkest); border-color: var(--color-bg-light-2);">
|
|
684
|
+
<div class="border p-4 relative font-mono text-xs code-block overflow-auto" style="background-color: var(--color-bg-darkest); border-color: var(--color-bg-light-2);">
|
|
377
685
|
${formattedDetails}
|
|
378
686
|
</div>
|
|
379
687
|
</section>
|
|
380
688
|
`;
|
|
381
689
|
}
|
|
382
690
|
|
|
691
|
+
function renderTokenUsageSection() {
|
|
692
|
+
const hasTokenUsageContent = Boolean(
|
|
693
|
+
state.isTokenUsageLoading
|
|
694
|
+
|| state.isEventsLoading
|
|
695
|
+
|| state.tokenUsageSummary
|
|
696
|
+
|| state.tokenUsageEventsPage
|
|
697
|
+
|| state.tokenUsageSummaryError
|
|
698
|
+
|| state.tokenUsageEventsError
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
if (!hasTokenUsageContent) {
|
|
702
|
+
return "";
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const summary = state.tokenUsageSummary;
|
|
706
|
+
const eventsPage = state.tokenUsageEventsPage;
|
|
707
|
+
const totals = summary?.totals || EMPTY_TOKEN_USAGE_TOTALS;
|
|
708
|
+
const activeRange = summary?.range || eventsPage?.range || null;
|
|
709
|
+
const totalPages = eventsPage ? Math.max(eventsPage.total_pages, 1) : 1;
|
|
710
|
+
const eventsMeta = eventsPage
|
|
711
|
+
? `Page ${eventsPage.page} / ${totalPages} · ${formatNumber(
|
|
712
|
+
eventsPage.total
|
|
713
|
+
)} events`
|
|
714
|
+
: "No detail page loaded yet.";
|
|
715
|
+
|
|
716
|
+
return `
|
|
717
|
+
<section id="token-usage" class="mb-6">
|
|
718
|
+
<div class="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
719
|
+
<div>
|
|
720
|
+
<h2 class="text-xl font-bold flex items-center gap-2" style="color: var(--color-fg-lightest);">
|
|
721
|
+
<i data-lucide="database"></i> Token Usage
|
|
722
|
+
</h2>
|
|
723
|
+
<p class="mt-1 text-xs" style="color: var(--color-gray);">${escapeHtml(
|
|
724
|
+
renderTokenUsageRangeText(state.tokenUsagePeriod, activeRange)
|
|
725
|
+
)}</p>
|
|
726
|
+
</div>
|
|
727
|
+
<div class="flex flex-wrap items-center gap-2 text-xs" style="color: var(--color-gray-accent);">
|
|
728
|
+
${
|
|
729
|
+
state.isTokenUsageLoading
|
|
730
|
+
? "<span>Refreshing summary...</span>"
|
|
731
|
+
: ""
|
|
732
|
+
}
|
|
733
|
+
${
|
|
734
|
+
state.isEventsLoading
|
|
735
|
+
? "<span>Refreshing details...</span>"
|
|
736
|
+
: ""
|
|
737
|
+
}
|
|
738
|
+
</div>
|
|
739
|
+
</div>
|
|
740
|
+
|
|
741
|
+
${
|
|
742
|
+
state.tokenUsageSummaryError
|
|
743
|
+
? renderError(
|
|
744
|
+
state.tokenUsageSummaryError,
|
|
745
|
+
"Token usage summary failed"
|
|
746
|
+
)
|
|
747
|
+
: ""
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
<div class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3 ${
|
|
751
|
+
state.isTokenUsageLoading ? "opacity-60" : ""
|
|
752
|
+
}">
|
|
753
|
+
${renderTokenUsageMetric(
|
|
754
|
+
"Total",
|
|
755
|
+
totals.total_tokens,
|
|
756
|
+
"var(--color-fg-lightest)"
|
|
757
|
+
)}
|
|
758
|
+
${renderTokenUsageMetric(
|
|
759
|
+
"Input",
|
|
760
|
+
totals.input_tokens,
|
|
761
|
+
"var(--color-blue-accent)"
|
|
762
|
+
)}
|
|
763
|
+
${renderTokenUsageMetric(
|
|
764
|
+
"Output",
|
|
765
|
+
totals.output_tokens,
|
|
766
|
+
"var(--color-green-accent)"
|
|
767
|
+
)}
|
|
768
|
+
${renderTokenUsageMetric(
|
|
769
|
+
"Cache Read",
|
|
770
|
+
totals.cache_read_input_tokens,
|
|
771
|
+
"var(--color-aqua-accent)"
|
|
772
|
+
)}
|
|
773
|
+
${renderTokenUsageMetric(
|
|
774
|
+
"Cache Write",
|
|
775
|
+
totals.cache_creation_input_tokens,
|
|
776
|
+
"var(--color-yellow-accent)"
|
|
777
|
+
)}
|
|
778
|
+
${renderTokenUsageMetric(
|
|
779
|
+
"Requests",
|
|
780
|
+
totals.request_count,
|
|
781
|
+
"var(--color-purple-accent)"
|
|
782
|
+
)}
|
|
783
|
+
</div>
|
|
784
|
+
|
|
785
|
+
<div class="mt-4 border" style="background-color: var(--color-bg); border-color: var(--color-bg-light-2);">
|
|
786
|
+
<div class="px-4 py-3 border-b flex items-center justify-between gap-3" style="background-color: var(--color-bg-soft); border-color: var(--color-bg-light-2);">
|
|
787
|
+
<div>
|
|
788
|
+
<p class="text-sm font-semibold" style="color: var(--color-fg-lightest);">By Model</p>
|
|
789
|
+
<p class="mt-1 text-xs" style="color: var(--color-gray-accent);">${summary ? `${formatNumber(summary.byModel.length)} models` : "No summary rows loaded."}</p>
|
|
790
|
+
</div>
|
|
791
|
+
</div>
|
|
792
|
+
${renderTokenUsageModelBreakdown(summary)}
|
|
793
|
+
</div>
|
|
794
|
+
|
|
795
|
+
<div class="mt-4 border" style="background-color: var(--color-bg); border-color: var(--color-bg-light-2);">
|
|
796
|
+
<div class="px-4 py-3 border-b flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between" style="background-color: var(--color-bg-soft); border-color: var(--color-bg-light-2);">
|
|
797
|
+
<div>
|
|
798
|
+
<p class="text-sm font-semibold" style="color: var(--color-fg-lightest);">Event Details</p>
|
|
799
|
+
<p class="mt-1 text-xs" style="color: var(--color-gray-accent);">${escapeHtml(
|
|
800
|
+
eventsMeta
|
|
801
|
+
)}</p>
|
|
802
|
+
</div>
|
|
803
|
+
<div class="flex items-center gap-2">
|
|
804
|
+
${renderPaginationButton(
|
|
805
|
+
"previous",
|
|
806
|
+
"Previous",
|
|
807
|
+
!eventsPage || state.isEventsLoading || eventsPage.page <= 1
|
|
808
|
+
)}
|
|
809
|
+
${renderPaginationButton(
|
|
810
|
+
"next",
|
|
811
|
+
"Next",
|
|
812
|
+
!eventsPage
|
|
813
|
+
|| state.isEventsLoading
|
|
814
|
+
|| eventsPage.page >= totalPages
|
|
815
|
+
)}
|
|
816
|
+
</div>
|
|
817
|
+
</div>
|
|
818
|
+
${
|
|
819
|
+
state.tokenUsageEventsError
|
|
820
|
+
? `<div class="p-4">${renderError(
|
|
821
|
+
state.tokenUsageEventsError,
|
|
822
|
+
"Token usage details failed"
|
|
823
|
+
)}</div>`
|
|
824
|
+
: ""
|
|
825
|
+
}
|
|
826
|
+
${renderTokenUsageEventsTable(eventsPage)}
|
|
827
|
+
</div>
|
|
828
|
+
</section>
|
|
829
|
+
`;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function renderTokenUsageMetric(label, value, accentColor) {
|
|
833
|
+
return `
|
|
834
|
+
<div class="p-4 border" style="background-color: var(--color-bg); border-color: var(--color-bg-light-2);">
|
|
835
|
+
<div class="text-lg font-bold" style="color: ${accentColor};">${formatNumber(
|
|
836
|
+
value
|
|
837
|
+
)}</div>
|
|
838
|
+
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-gray-accent);">${escapeHtml(
|
|
839
|
+
label
|
|
840
|
+
)}</div>
|
|
841
|
+
</div>
|
|
842
|
+
`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function renderTokenUsageModelBreakdown(summary) {
|
|
846
|
+
if (!summary || summary.byModel.length === 0) {
|
|
847
|
+
return renderEmptyState(
|
|
848
|
+
"No token usage recorded for the selected period."
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const rows = summary.byModel
|
|
853
|
+
.map((model) => {
|
|
854
|
+
return `
|
|
855
|
+
<tr class="border-b last:border-b-0" style="border-color: var(--color-bg-light-1);">
|
|
856
|
+
<td class="px-4 py-2 max-w-[280px] truncate" style="color: var(--color-fg-lightest);" title="${escapeHtml(
|
|
857
|
+
model.model
|
|
858
|
+
)}">${escapeHtml(model.model)}</td>
|
|
859
|
+
<td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
|
|
860
|
+
model.request_count
|
|
861
|
+
)}</td>
|
|
862
|
+
<td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
|
|
863
|
+
model.input_tokens
|
|
864
|
+
)}</td>
|
|
865
|
+
<td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
|
|
866
|
+
model.output_tokens
|
|
867
|
+
)}</td>
|
|
868
|
+
<td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
|
|
869
|
+
model.cache_read_input_tokens
|
|
870
|
+
)}</td>
|
|
871
|
+
<td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
|
|
872
|
+
model.cache_creation_input_tokens
|
|
873
|
+
)}</td>
|
|
874
|
+
<td class="px-4 py-2 text-right font-semibold" style="color: var(--color-yellow-accent);">${formatNumber(
|
|
875
|
+
model.total_tokens
|
|
876
|
+
)}</td>
|
|
877
|
+
</tr>
|
|
878
|
+
`;
|
|
879
|
+
})
|
|
880
|
+
.join("");
|
|
881
|
+
|
|
882
|
+
return `
|
|
883
|
+
<div class="overflow-auto ${state.isTokenUsageLoading ? "opacity-60" : ""}">
|
|
884
|
+
<table class="w-full min-w-[760px] text-left text-xs sm:text-sm">
|
|
885
|
+
<thead style="background-color: var(--color-bg-light-1); color: var(--color-fg-medium);">
|
|
886
|
+
<tr>
|
|
887
|
+
<th class="px-4 py-2 font-semibold">Model</th>
|
|
888
|
+
<th class="px-4 py-2 text-right font-semibold">Requests</th>
|
|
889
|
+
<th class="px-4 py-2 text-right font-semibold">Input</th>
|
|
890
|
+
<th class="px-4 py-2 text-right font-semibold">Output</th>
|
|
891
|
+
<th class="px-4 py-2 text-right font-semibold">Cache Read</th>
|
|
892
|
+
<th class="px-4 py-2 text-right font-semibold">Cache Write</th>
|
|
893
|
+
<th class="px-4 py-2 text-right font-semibold">Total</th>
|
|
894
|
+
</tr>
|
|
895
|
+
</thead>
|
|
896
|
+
<tbody>
|
|
897
|
+
${rows}
|
|
898
|
+
</tbody>
|
|
899
|
+
</table>
|
|
900
|
+
</div>
|
|
901
|
+
`;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function renderTokenUsageEventsTable(eventsPage) {
|
|
905
|
+
if (!eventsPage || eventsPage.items.length === 0) {
|
|
906
|
+
return renderEmptyState(
|
|
907
|
+
"No token usage detail rows for the selected period."
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const rows = eventsPage.items
|
|
912
|
+
.map((event) => {
|
|
913
|
+
const userId = formatCellText(event.user_id);
|
|
914
|
+
const sessionId = formatCellText(event.session_id);
|
|
915
|
+
const traceId = formatCellText(event.trace_id);
|
|
916
|
+
|
|
917
|
+
return `
|
|
918
|
+
<tr class="border-b last:border-b-0" style="border-color: var(--color-bg-light-1);">
|
|
919
|
+
<td class="px-4 py-2 whitespace-nowrap" style="color: var(--color-fg-dark);" title="${escapeHtml(
|
|
920
|
+
event.created_at_utc
|
|
921
|
+
)}">${escapeHtml(formatDateTime(event.created_at_ms))}</td>
|
|
922
|
+
<td class="px-4 py-2 max-w-[160px] truncate" style="color: var(--color-fg-lightest);" title="${escapeHtml(
|
|
923
|
+
userId
|
|
924
|
+
)}">${escapeHtml(userId)}</td>
|
|
925
|
+
<td class="px-4 py-2 whitespace-nowrap" style="color: var(--color-fg-dark);">${escapeHtml(
|
|
926
|
+
event.endpoint.replace(/_/g, " ")
|
|
927
|
+
)}</td>
|
|
928
|
+
<td class="px-4 py-2 max-w-[220px] truncate" style="color: var(--color-fg-lightest);" title="${escapeHtml(
|
|
929
|
+
event.model
|
|
930
|
+
)}">${escapeHtml(event.model)}</td>
|
|
931
|
+
<td class="px-4 py-2 max-w-[180px] truncate font-mono" style="color: var(--color-fg-dark);" title="${escapeHtml(
|
|
932
|
+
sessionId
|
|
933
|
+
)}">${escapeHtml(sessionId)}</td>
|
|
934
|
+
<td class="px-4 py-2 max-w-[200px] truncate font-mono" style="color: var(--color-fg-dark);" title="${escapeHtml(
|
|
935
|
+
traceId
|
|
936
|
+
)}">${escapeHtml(traceId)}</td>
|
|
937
|
+
<td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
|
|
938
|
+
event.input_tokens
|
|
939
|
+
)}</td>
|
|
940
|
+
<td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
|
|
941
|
+
event.output_tokens
|
|
942
|
+
)}</td>
|
|
943
|
+
<td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
|
|
944
|
+
event.cache_read_input_tokens
|
|
945
|
+
)}</td>
|
|
946
|
+
<td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
|
|
947
|
+
event.cache_creation_input_tokens
|
|
948
|
+
)}</td>
|
|
949
|
+
<td class="px-4 py-2 text-right font-semibold" style="color: var(--color-yellow-accent);">${formatNumber(
|
|
950
|
+
event.total_tokens
|
|
951
|
+
)}</td>
|
|
952
|
+
</tr>
|
|
953
|
+
`;
|
|
954
|
+
})
|
|
955
|
+
.join("");
|
|
956
|
+
|
|
957
|
+
return `
|
|
958
|
+
<div class="overflow-auto ${state.isEventsLoading ? "opacity-60" : ""}">
|
|
959
|
+
<table class="w-full min-w-[1180px] text-left text-xs sm:text-sm">
|
|
960
|
+
<thead style="background-color: var(--color-bg-light-1); color: var(--color-fg-medium);">
|
|
961
|
+
<tr>
|
|
962
|
+
<th class="px-4 py-2 font-semibold">Time</th>
|
|
963
|
+
<th class="px-4 py-2 font-semibold">User</th>
|
|
964
|
+
<th class="px-4 py-2 font-semibold">Endpoint</th>
|
|
965
|
+
<th class="px-4 py-2 font-semibold">Model</th>
|
|
966
|
+
<th class="px-4 py-2 font-semibold">Session</th>
|
|
967
|
+
<th class="px-4 py-2 font-semibold">Trace</th>
|
|
968
|
+
<th class="px-4 py-2 text-right font-semibold">Input</th>
|
|
969
|
+
<th class="px-4 py-2 text-right font-semibold">Output</th>
|
|
970
|
+
<th class="px-4 py-2 text-right font-semibold">Cache Read</th>
|
|
971
|
+
<th class="px-4 py-2 text-right font-semibold">Cache Write</th>
|
|
972
|
+
<th class="px-4 py-2 text-right font-semibold">Total</th>
|
|
973
|
+
</tr>
|
|
974
|
+
</thead>
|
|
975
|
+
<tbody>
|
|
976
|
+
${rows}
|
|
977
|
+
</tbody>
|
|
978
|
+
</table>
|
|
979
|
+
</div>
|
|
980
|
+
`;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function renderPaginationButton(action, label, disabled) {
|
|
984
|
+
return `
|
|
985
|
+
<button
|
|
986
|
+
type="button"
|
|
987
|
+
data-page-action="${action}"
|
|
988
|
+
class="px-3 py-1.5 border text-xs font-medium"
|
|
989
|
+
style="background-color: var(--color-bg-darkest); border-color: var(--color-bg-light-2); color: var(--color-fg-light);"
|
|
990
|
+
${disabled ? "disabled" : ""}
|
|
991
|
+
>
|
|
992
|
+
${label}
|
|
993
|
+
</button>
|
|
994
|
+
`;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function renderEmptyState(message) {
|
|
998
|
+
return `
|
|
999
|
+
<div class="px-4 py-6 text-sm" style="color: var(--color-gray);">
|
|
1000
|
+
${escapeHtml(message)}
|
|
1001
|
+
</div>
|
|
1002
|
+
`;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
383
1005
|
/**
|
|
384
1006
|
* Renders a loading spinner.
|
|
385
1007
|
* @returns {string} HTML string for the spinner.
|
|
@@ -396,32 +1018,22 @@
|
|
|
396
1018
|
* @param {string} message - The error message to display.
|
|
397
1019
|
* @returns {string} HTML string for the error message.
|
|
398
1020
|
*/
|
|
399
|
-
function renderError(message) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
<div
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
<p class="font-bold text-sm">An Error Occurred</p>
|
|
412
|
-
<p class="text-xs">${message}</p>
|
|
413
|
-
</div>
|
|
1021
|
+
function renderError(message, title = "An Error Occurred") {
|
|
1022
|
+
return `
|
|
1023
|
+
<div
|
|
1024
|
+
class="p-3 border"
|
|
1025
|
+
style="background-color: rgba(204, 36, 29, 0.2); border-color: var(--color-red); color: var(--color-red-accent);"
|
|
1026
|
+
role="alert"
|
|
1027
|
+
>
|
|
1028
|
+
<div class="flex items-start">
|
|
1029
|
+
<i data-lucide="alert-triangle" class="h-5 w-5 mr-3 mt-0.5"></i>
|
|
1030
|
+
<div>
|
|
1031
|
+
<p class="font-bold text-sm">${escapeHtml(title)}</p>
|
|
1032
|
+
<p class="text-xs">${escapeHtml(message)}</p>
|
|
414
1033
|
</div>
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
() =>
|
|
419
|
-
lucide.createIcons({
|
|
420
|
-
nodes: [container.querySelector("[data-lucide]")],
|
|
421
|
-
}),
|
|
422
|
-
0
|
|
423
|
-
);
|
|
424
|
-
return container.outerHTML;
|
|
1034
|
+
</div>
|
|
1035
|
+
</div>
|
|
1036
|
+
`;
|
|
425
1037
|
}
|
|
426
1038
|
|
|
427
1039
|
/**
|
|
@@ -433,7 +1045,7 @@
|
|
|
433
1045
|
<div class="text-center py-16 px-4 border" style="background-color: var(--color-bg-soft); border-color: var(--color-bg-light-2);">
|
|
434
1046
|
<i data-lucide="info" class="mx-auto h-10 w-10" style="color: var(--color-gray-accent);"></i>
|
|
435
1047
|
<h3 class="mt-2 text-lg font-semibold" style="color: var(--color-fg-lightest);">Welcome!</h3>
|
|
436
|
-
<p class="mt-1 text-sm" style="color: var(--color-gray);">Enter
|
|
1048
|
+
<p class="mt-1 text-sm" style="color: var(--color-gray);">Enter a usage endpoint, optionally provide an x-api-key, and click "Fetch" to load usage and token usage data.</p>
|
|
437
1049
|
</div>
|
|
438
1050
|
`;
|
|
439
1051
|
}
|
|
@@ -441,40 +1053,184 @@
|
|
|
441
1053
|
// --- Data Fetching ---
|
|
442
1054
|
|
|
443
1055
|
/**
|
|
444
|
-
* Fetches data from the specified API endpoint.
|
|
1056
|
+
* Fetches usage data and token usage data from the specified API endpoint.
|
|
445
1057
|
*/
|
|
446
|
-
async function fetchData() {
|
|
447
|
-
const
|
|
448
|
-
if (!
|
|
1058
|
+
async function fetchData(page = 1) {
|
|
1059
|
+
const endpoint = endpointUrlInput.value.trim();
|
|
1060
|
+
if (!endpoint) {
|
|
1061
|
+
state.data = null;
|
|
1062
|
+
state.tokenUsageSummary = null;
|
|
1063
|
+
state.tokenUsageEventsPage = null;
|
|
449
1064
|
state.error = "Endpoint URL cannot be empty.";
|
|
450
|
-
state.
|
|
1065
|
+
state.tokenUsageSummaryError =
|
|
1066
|
+
"Token usage requires a valid endpoint URL.";
|
|
1067
|
+
state.tokenUsageEventsError = null;
|
|
451
1068
|
render();
|
|
452
1069
|
return;
|
|
453
1070
|
}
|
|
454
1071
|
|
|
1072
|
+
const period = getSelectedPeriod();
|
|
455
1073
|
state.isLoading = true;
|
|
456
1074
|
state.error = null;
|
|
1075
|
+
state.tokenUsageSummaryError = null;
|
|
1076
|
+
state.tokenUsageEventsError = null;
|
|
457
1077
|
render();
|
|
458
1078
|
|
|
459
1079
|
try {
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
1080
|
+
const usageUrl = resolveUsageUrl(endpoint);
|
|
1081
|
+
const [usageResult, summaryResult, eventsResult] =
|
|
1082
|
+
await Promise.allSettled([
|
|
1083
|
+
fetchJson(usageUrl),
|
|
1084
|
+
fetchJson(buildTokenUsageSummaryUrl(usageUrl, period)),
|
|
1085
|
+
fetchJson(buildTokenUsageEventsUrl(usageUrl, period, page)),
|
|
1086
|
+
]);
|
|
1087
|
+
|
|
1088
|
+
if (usageResult.status === "fulfilled") {
|
|
1089
|
+
state.data = usageResult.value;
|
|
1090
|
+
} else {
|
|
1091
|
+
state.data = null;
|
|
1092
|
+
state.error = getErrorMessage(usageResult.reason);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (summaryResult.status === "fulfilled") {
|
|
1096
|
+
state.tokenUsageSummary = summaryResult.value;
|
|
1097
|
+
} else if (
|
|
1098
|
+
isMissingTokenUsageEndpointError(summaryResult.reason)
|
|
1099
|
+
) {
|
|
1100
|
+
state.tokenUsageSummary = null;
|
|
1101
|
+
state.tokenUsageSummaryError = null;
|
|
1102
|
+
} else {
|
|
1103
|
+
state.tokenUsageSummary = null;
|
|
1104
|
+
state.tokenUsageSummaryError = getErrorMessage(
|
|
1105
|
+
summaryResult.reason
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (eventsResult.status === "fulfilled") {
|
|
1110
|
+
state.tokenUsageEventsPage = eventsResult.value;
|
|
1111
|
+
} else if (
|
|
1112
|
+
isMissingTokenUsageEndpointError(eventsResult.reason)
|
|
1113
|
+
) {
|
|
1114
|
+
state.tokenUsageEventsPage = null;
|
|
1115
|
+
state.tokenUsageEventsError = null;
|
|
1116
|
+
} else {
|
|
1117
|
+
state.tokenUsageEventsPage = null;
|
|
1118
|
+
state.tokenUsageEventsError = getErrorMessage(
|
|
1119
|
+
eventsResult.reason
|
|
464
1120
|
);
|
|
465
1121
|
}
|
|
466
|
-
const jsonData = await response.json();
|
|
467
|
-
state.data = jsonData;
|
|
468
1122
|
} catch (error) {
|
|
469
1123
|
console.error("Fetch error:", error);
|
|
470
1124
|
state.data = null;
|
|
471
|
-
state.
|
|
1125
|
+
state.tokenUsageSummary = null;
|
|
1126
|
+
state.tokenUsageEventsPage = null;
|
|
1127
|
+
state.error = getErrorMessage(error);
|
|
1128
|
+
state.tokenUsageSummaryError = getErrorMessage(error);
|
|
1129
|
+
state.tokenUsageEventsError = getErrorMessage(error);
|
|
472
1130
|
} finally {
|
|
473
1131
|
state.isLoading = false;
|
|
474
1132
|
render();
|
|
475
1133
|
}
|
|
476
1134
|
}
|
|
477
1135
|
|
|
1136
|
+
async function fetchTokenUsageSummaryAndEvents(page = 1) {
|
|
1137
|
+
const endpoint = endpointUrlInput.value.trim();
|
|
1138
|
+
if (!endpoint) {
|
|
1139
|
+
state.tokenUsageSummary = null;
|
|
1140
|
+
state.tokenUsageEventsPage = null;
|
|
1141
|
+
state.tokenUsageSummaryError =
|
|
1142
|
+
"Token usage requires a valid endpoint URL.";
|
|
1143
|
+
state.tokenUsageEventsError = null;
|
|
1144
|
+
render();
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const period = getSelectedPeriod();
|
|
1149
|
+
state.isTokenUsageLoading = true;
|
|
1150
|
+
state.tokenUsageSummary = null;
|
|
1151
|
+
state.tokenUsageEventsPage = null;
|
|
1152
|
+
state.tokenUsageSummaryError = null;
|
|
1153
|
+
state.tokenUsageEventsError = null;
|
|
1154
|
+
render();
|
|
1155
|
+
|
|
1156
|
+
try {
|
|
1157
|
+
const usageUrl = resolveUsageUrl(endpoint);
|
|
1158
|
+
const [summaryResult, eventsResult] = await Promise.allSettled([
|
|
1159
|
+
fetchJson(buildTokenUsageSummaryUrl(usageUrl, period)),
|
|
1160
|
+
fetchJson(buildTokenUsageEventsUrl(usageUrl, period, page)),
|
|
1161
|
+
]);
|
|
1162
|
+
|
|
1163
|
+
if (summaryResult.status === "fulfilled") {
|
|
1164
|
+
state.tokenUsageSummary = summaryResult.value;
|
|
1165
|
+
} else if (
|
|
1166
|
+
isMissingTokenUsageEndpointError(summaryResult.reason)
|
|
1167
|
+
) {
|
|
1168
|
+
state.tokenUsageSummary = null;
|
|
1169
|
+
state.tokenUsageSummaryError = null;
|
|
1170
|
+
} else {
|
|
1171
|
+
state.tokenUsageSummary = null;
|
|
1172
|
+
state.tokenUsageSummaryError = getErrorMessage(
|
|
1173
|
+
summaryResult.reason
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (eventsResult.status === "fulfilled") {
|
|
1178
|
+
state.tokenUsageEventsPage = eventsResult.value;
|
|
1179
|
+
} else if (
|
|
1180
|
+
isMissingTokenUsageEndpointError(eventsResult.reason)
|
|
1181
|
+
) {
|
|
1182
|
+
state.tokenUsageEventsPage = null;
|
|
1183
|
+
state.tokenUsageEventsError = null;
|
|
1184
|
+
} else {
|
|
1185
|
+
state.tokenUsageEventsPage = null;
|
|
1186
|
+
state.tokenUsageEventsError = getErrorMessage(
|
|
1187
|
+
eventsResult.reason
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
console.error("Token usage fetch error:", error);
|
|
1192
|
+
state.tokenUsageSummary = null;
|
|
1193
|
+
state.tokenUsageEventsPage = null;
|
|
1194
|
+
state.tokenUsageSummaryError = getErrorMessage(error);
|
|
1195
|
+
state.tokenUsageEventsError = getErrorMessage(error);
|
|
1196
|
+
} finally {
|
|
1197
|
+
state.isTokenUsageLoading = false;
|
|
1198
|
+
render();
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function fetchTokenUsageEventsPage(page) {
|
|
1203
|
+
const endpoint = endpointUrlInput.value.trim();
|
|
1204
|
+
if (!endpoint) {
|
|
1205
|
+
state.tokenUsageEventsError =
|
|
1206
|
+
"Token usage requires a valid endpoint URL.";
|
|
1207
|
+
render();
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
state.isEventsLoading = true;
|
|
1212
|
+
state.tokenUsageEventsError = null;
|
|
1213
|
+
render();
|
|
1214
|
+
|
|
1215
|
+
try {
|
|
1216
|
+
const usageUrl = resolveUsageUrl(endpoint);
|
|
1217
|
+
state.tokenUsageEventsPage = await fetchJson(
|
|
1218
|
+
buildTokenUsageEventsUrl(usageUrl, getSelectedPeriod(), page)
|
|
1219
|
+
);
|
|
1220
|
+
} catch (error) {
|
|
1221
|
+
if (isMissingTokenUsageEndpointError(error)) {
|
|
1222
|
+
state.tokenUsageEventsPage = null;
|
|
1223
|
+
state.tokenUsageEventsError = null;
|
|
1224
|
+
} else {
|
|
1225
|
+
console.error("Token usage events fetch error:", error);
|
|
1226
|
+
state.tokenUsageEventsError = getErrorMessage(error);
|
|
1227
|
+
}
|
|
1228
|
+
} finally {
|
|
1229
|
+
state.isEventsLoading = false;
|
|
1230
|
+
render();
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
478
1234
|
// --- Event Handlers & Initialization ---
|
|
479
1235
|
|
|
480
1236
|
/**
|
|
@@ -483,18 +1239,41 @@
|
|
|
483
1239
|
*/
|
|
484
1240
|
function handleFormSubmit(event) {
|
|
485
1241
|
event.preventDefault();
|
|
486
|
-
|
|
1242
|
+
storeApiKey(apiKeyInput.value);
|
|
1243
|
+
syncUrlState();
|
|
1244
|
+
void fetchData();
|
|
1245
|
+
}
|
|
487
1246
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
1247
|
+
function handlePeriodChange() {
|
|
1248
|
+
syncUrlState();
|
|
1249
|
+
void fetchTokenUsageSummaryAndEvents(1);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function handleContentAreaClick(event) {
|
|
1253
|
+
if (!(event.target instanceof Element)) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const actionButton = event.target.closest("[data-page-action]");
|
|
1258
|
+
if (!actionButton || !state.tokenUsageEventsPage || state.isEventsLoading) {
|
|
1259
|
+
return;
|
|
495
1260
|
}
|
|
496
1261
|
|
|
497
|
-
|
|
1262
|
+
const action = actionButton.getAttribute("data-page-action");
|
|
1263
|
+
if (
|
|
1264
|
+
action === "previous"
|
|
1265
|
+
&& state.tokenUsageEventsPage.page > 1
|
|
1266
|
+
) {
|
|
1267
|
+
void fetchTokenUsageEventsPage(state.tokenUsageEventsPage.page - 1);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (
|
|
1271
|
+
action === "next"
|
|
1272
|
+
&& state.tokenUsageEventsPage.page
|
|
1273
|
+
< state.tokenUsageEventsPage.total_pages
|
|
1274
|
+
) {
|
|
1275
|
+
void fetchTokenUsageEventsPage(state.tokenUsageEventsPage.page + 1);
|
|
1276
|
+
}
|
|
498
1277
|
}
|
|
499
1278
|
|
|
500
1279
|
/**
|
|
@@ -502,17 +1281,27 @@
|
|
|
502
1281
|
*/
|
|
503
1282
|
function init() {
|
|
504
1283
|
endpointForm.addEventListener("submit", handleFormSubmit);
|
|
1284
|
+
tokenUsagePeriodSelect.addEventListener("change", handlePeriodChange);
|
|
1285
|
+
apiKeyInput.addEventListener("input", () => {
|
|
1286
|
+
storeApiKey(apiKeyInput.value);
|
|
1287
|
+
});
|
|
1288
|
+
contentArea.addEventListener("click", handleContentAreaClick);
|
|
1289
|
+
|
|
1290
|
+
apiKeyInput.value = loadStoredApiKey();
|
|
505
1291
|
|
|
506
|
-
// Get endpoint from URL param on load
|
|
507
1292
|
const urlParams = new URLSearchParams(window.location.search);
|
|
1293
|
+
const periodFromUrl = normalizePeriod(urlParams.get("period"));
|
|
508
1294
|
const endpointFromUrl = urlParams.get("endpoint");
|
|
509
1295
|
|
|
1296
|
+
state.tokenUsagePeriod = periodFromUrl;
|
|
1297
|
+
tokenUsagePeriodSelect.value = periodFromUrl;
|
|
1298
|
+
|
|
510
1299
|
if (endpointFromUrl) {
|
|
511
1300
|
endpointUrlInput.value = endpointFromUrl;
|
|
512
|
-
fetchData();
|
|
1301
|
+
void fetchData();
|
|
513
1302
|
} else {
|
|
514
1303
|
endpointUrlInput.value = DEFAULT_ENDPOINT;
|
|
515
|
-
render();
|
|
1304
|
+
render();
|
|
516
1305
|
}
|
|
517
1306
|
}
|
|
518
1307
|
|