@jeffreycao/copilot-api 1.9.2 → 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/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="flex flex-col sm:flex-row items-center gap-3"
149
+ class="grid grid-cols-1 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_180px_auto] gap-3 items-end"
144
150
  >
145
- <label
146
- for="endpoint-url"
147
- class="font-semibold whitespace-nowrap text-sm"
148
- style="color: var(--color-fg-lightest)"
149
- >API Endpoint URL</label
150
- >
151
- <input
152
- type="text"
153
- id="endpoint-url"
154
- class="w-full px-3 py-1.5 border focus:ring-1 transition input-focus text-sm"
155
- style="
156
- background-color: var(--color-bg-darkest);
157
- border-color: var(--color-bg-light-3);
158
- color: var(--color-fg-medium);
159
- "
160
- placeholder="http://localhost:4141/usage"
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 sm:w-auto font-bold py-1.5 px-5 transition-colors flex items-center justify-center gap-2 text-sm"
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("&", "&amp;")
315
+ .replaceAll("<", "&lt;")
316
+ .replaceAll(">", "&gt;")
317
+ .replaceAll('"', "&quot;")
318
+ .replaceAll("'", "&#39;");
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
- if (state.error) {
242
- contentArea.innerHTML = renderError(state.error);
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
- ${renderUsageQuotas(state.data.quota_snapshots)}
246
- ${renderDetailedData(state.data)}
247
- `;
551
+ ${renderUsageQuotas(state.data.quota_snapshots)}
552
+ ${renderTokenUsageSection()}
553
+ ${renderDetailedData(state.data)}
554
+ `;
248
555
  } else {
249
- contentArea.innerHTML = renderWelcomeMessage();
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> Detailed Information
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
- const container = document.createElement("div");
401
- container.className = "p-3 border";
402
- container.style.backgroundColor = "rgba(204, 36, 29, 0.2)";
403
- container.style.borderColor = "var(--color-red)";
404
- container.style.color = "var(--color-red-accent)";
405
- container.setAttribute("role", "alert");
406
-
407
- container.innerHTML = `
408
- <div class="flex items-center">
409
- <i data-lucide="alert-triangle" class="h-5 w-5 mr-3"></i>
410
- <div>
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
- // Must create icons *after* innerHTML is set
417
- setTimeout(
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 an API endpoint URL and click "Fetch" to see usage data.</p>
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 url = endpointUrlInput.value.trim();
448
- if (!url) {
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.isLoading = false;
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 response = await fetch(url);
461
- if (!response.ok) {
462
- throw new Error(
463
- `Request failed with status ${response.status}: ${response.statusText}`
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.error = error.message;
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
- const url = endpointUrlInput.value.trim();
1242
+ storeApiKey(apiKeyInput.value);
1243
+ syncUrlState();
1244
+ void fetchData();
1245
+ }
487
1246
 
488
- // Update URL query parameter, catching potential security errors in sandboxed environments
489
- try {
490
- const currentUrl = new URL(window.location);
491
- currentUrl.searchParams.set("endpoint", url);
492
- window.history.pushState({}, "", currentUrl);
493
- } catch (e) {
494
- console.warn("Could not update URL: ", e.message);
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
- fetchData();
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(); // Render initial welcome message
1304
+ render();
516
1305
  }
517
1306
  }
518
1307