@jeffreycao/copilot-api 2.6.18 → 2.6.19

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
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Copilot API Usage Dashboard</title>
6
+ <title>Copilot API Dashboard</title>
7
7
 
8
8
  <script src="https://cdn.tailwindcss.com"></script>
9
9
 
@@ -108,6 +108,128 @@
108
108
  align-items: end;
109
109
  }
110
110
 
111
+ [hidden] {
112
+ display: none !important;
113
+ }
114
+
115
+ .page-tabs {
116
+ display: inline-flex;
117
+ gap: 4px;
118
+ margin-bottom: 14px;
119
+ padding: 4px;
120
+ border: 1px solid var(--color-bg-light-2);
121
+ border-radius: 10px;
122
+ background: rgba(255, 255, 255, 0.88);
123
+ }
124
+
125
+ .page-tab {
126
+ display: inline-flex;
127
+ align-items: center;
128
+ gap: 8px;
129
+ padding: 8px 14px;
130
+ border-radius: 7px;
131
+ color: var(--color-gray);
132
+ font-size: 14px;
133
+ font-weight: 700;
134
+ cursor: pointer;
135
+ }
136
+
137
+ .page-tab[aria-selected="true"] {
138
+ background: var(--color-blue);
139
+ color: #ffffff;
140
+ }
141
+
142
+ .page-tab:focus-visible,
143
+ .models-action:focus-visible {
144
+ outline: 2px solid var(--color-blue);
145
+ outline-offset: 3px;
146
+ }
147
+
148
+ .control-surface.models-view {
149
+ grid-template-columns: minmax(320px, 1fr) minmax(220px, 320px) 132px;
150
+ }
151
+
152
+ .models-toolbar {
153
+ flex-wrap: wrap;
154
+ padding: 14px;
155
+ }
156
+
157
+ .models-toolbar .models-search {
158
+ width: min(340px, 100%);
159
+ }
160
+
161
+ .models-table-scroll {
162
+ overflow-x: auto;
163
+ }
164
+
165
+ .models-table {
166
+ width: 100%;
167
+ min-width: 640px;
168
+ border-collapse: collapse;
169
+ table-layout: fixed;
170
+ text-align: left;
171
+ font-size: 13px;
172
+ }
173
+
174
+ .models-table th,
175
+ .models-table td {
176
+ padding: 14px;
177
+ border-bottom: 1px solid var(--color-bg-light-2);
178
+ overflow-wrap: anywhere;
179
+ }
180
+
181
+ .models-table th {
182
+ background: #f8fafc;
183
+ color: var(--color-gray);
184
+ font-size: 12px;
185
+ font-weight: 700;
186
+ }
187
+
188
+ .models-id {
189
+ display: flex;
190
+ align-items: center;
191
+ gap: 10px;
192
+ }
193
+
194
+ .models-id code {
195
+ min-width: 0;
196
+ color: var(--color-fg-lightest);
197
+ font-size: 12px;
198
+ }
199
+
200
+ .models-action {
201
+ display: inline-flex;
202
+ align-items: center;
203
+ gap: 4px;
204
+ flex-shrink: 0;
205
+ color: var(--color-blue);
206
+ font-size: 12px;
207
+ font-weight: 650;
208
+ cursor: pointer;
209
+ }
210
+
211
+ .models-action:hover {
212
+ color: var(--color-blue-accent);
213
+ text-decoration: underline;
214
+ }
215
+
216
+ .models-table .models-json-cell {
217
+ padding: 0;
218
+ }
219
+
220
+ .models-json {
221
+ margin: 0;
222
+ max-height: 480px;
223
+ overflow-y: auto;
224
+ padding: 16px;
225
+ background: #f8fafc;
226
+ color: var(--color-fg-medium);
227
+ font-size: 12px;
228
+ line-height: 1.6;
229
+ white-space: pre-wrap;
230
+ overflow-wrap: anywhere;
231
+ }
232
+
111
233
  .field-label {
112
234
  display: block;
113
235
  margin-bottom: 6px;
@@ -360,6 +482,10 @@
360
482
  .endpoint-field {
361
483
  grid-column: 1 / -1;
362
484
  }
485
+
486
+ .control-surface.models-view {
487
+ grid-template-columns: minmax(0, 1fr) 132px;
488
+ }
363
489
  }
364
490
 
365
491
  @media (max-width: 640px) {
@@ -371,10 +497,15 @@
371
497
  display: block;
372
498
  }
373
499
 
374
- .control-surface {
500
+ .control-surface,
501
+ .control-surface.models-view {
375
502
  grid-template-columns: 1fr;
376
503
  }
377
504
 
505
+ .models-toolbar .models-search {
506
+ width: 100%;
507
+ }
508
+
378
509
  .endpoint-field {
379
510
  grid-column: auto;
380
511
  }
@@ -391,14 +522,47 @@
391
522
  <header class="topbar">
392
523
  <div class="topbar-row">
393
524
  <div>
394
- <h1 class="page-title">Token Usage</h1>
395
- <p class="page-subtitle">Copilot quota and token billing dashboard</p>
525
+ <h1 class="page-title">Copilot API Dashboard</h1>
526
+ <p class="page-subtitle">Token usage and available models</p>
396
527
  </div>
397
528
  </div>
398
529
 
530
+ <div
531
+ id="page-tabs"
532
+ class="page-tabs"
533
+ role="tablist"
534
+ aria-label="Dashboard views"
535
+ >
536
+ <button
537
+ id="usage-tab"
538
+ type="button"
539
+ class="page-tab"
540
+ role="tab"
541
+ data-tab="usage"
542
+ aria-selected="true"
543
+ aria-controls="content-area"
544
+ >
545
+ <i data-lucide="bar-chart-3" class="h-4 w-4" aria-hidden="true"></i>
546
+ Token Usage
547
+ </button>
548
+ <button
549
+ id="models-tab"
550
+ type="button"
551
+ class="page-tab"
552
+ role="tab"
553
+ data-tab="models"
554
+ aria-selected="false"
555
+ aria-controls="content-area"
556
+ tabindex="-1"
557
+ >
558
+ <i data-lucide="boxes" class="h-4 w-4" aria-hidden="true"></i>
559
+ Models
560
+ </button>
561
+ </div>
562
+
399
563
  <form id="endpoint-form" class="control-surface">
400
564
  <div class="endpoint-field">
401
- <label for="endpoint-url" class="field-label">Usage Endpoint URL</label>
565
+ <label for="endpoint-url" class="field-label">Endpoint URL</label>
402
566
  <input
403
567
  type="text"
404
568
  id="endpoint-url"
@@ -417,7 +581,7 @@
417
581
  spellcheck="false"
418
582
  />
419
583
  </div>
420
- <div>
584
+ <div id="token-usage-period-field">
421
585
  <label for="token-usage-period" class="field-label">Period</label>
422
586
  <select
423
587
  id="token-usage-period"
@@ -441,7 +605,12 @@
441
605
  </p>
442
606
  </header>
443
607
 
444
- <main id="content-area" class="content-area"></main>
608
+ <main
609
+ id="content-area"
610
+ class="content-area"
611
+ role="tabpanel"
612
+ aria-labelledby="usage-tab"
613
+ ></main>
445
614
  </div>
446
615
  </div>
447
616
 
@@ -454,6 +623,12 @@
454
623
  "token-usage-period"
455
624
  );
456
625
  const contentArea = document.getElementById("content-area");
626
+ const pageTabs = document.getElementById("page-tabs");
627
+ const tabButtons = [
628
+ document.getElementById("usage-tab"),
629
+ document.getElementById("models-tab"),
630
+ ];
631
+ const periodField = document.getElementById("token-usage-period-field");
457
632
  const fetchButton = document.getElementById("fetch-button");
458
633
  const fetchButtonLabel = fetchButton.querySelector("span");
459
634
 
@@ -494,6 +669,13 @@
494
669
 
495
670
  // --- State Management ---
496
671
  const state = {
672
+ activeTab: "usage",
673
+ models: null,
674
+ modelsError: null,
675
+ isModelsLoading: false,
676
+ modelsConnectionKey: null,
677
+ modelsRequestId: 0,
678
+ modelsSearch: "",
497
679
  isLoading: false,
498
680
  isTokenUsageLoading: false,
499
681
  isEventsLoading: false,
@@ -825,12 +1007,28 @@
825
1007
  }
826
1008
 
827
1009
  function updateControls() {
828
- fetchButton.disabled = state.isLoading;
829
- fetchButtonLabel.textContent = state.isLoading
1010
+ const isLoading =
1011
+ state.activeTab === "models"
1012
+ ? state.isModelsLoading
1013
+ : state.isLoading;
1014
+ fetchButton.disabled = isLoading;
1015
+ fetchButtonLabel.textContent = isLoading
830
1016
  ? "Refreshing..."
831
1017
  : "Refresh";
832
1018
  tokenUsagePeriodSelect.disabled =
833
1019
  state.isLoading || state.isTokenUsageLoading;
1020
+ periodField.hidden = state.activeTab === "models";
1021
+ endpointForm.classList.toggle(
1022
+ "models-view",
1023
+ state.activeTab === "models"
1024
+ );
1025
+ contentArea.setAttribute("aria-labelledby", `${state.activeTab}-tab`);
1026
+ contentArea.setAttribute("aria-busy", String(isLoading));
1027
+ for (const button of tabButtons) {
1028
+ const selected = button.dataset.tab === state.activeTab;
1029
+ button.setAttribute("aria-selected", String(selected));
1030
+ button.tabIndex = selected ? 0 : -1;
1031
+ }
834
1032
  }
835
1033
 
836
1034
  function formatNumber(value) {
@@ -952,9 +1150,18 @@
952
1150
  /**
953
1151
  * Renders the entire UI based on the current state.
954
1152
  */
955
- function render() {
1153
+ function render(view = state.activeTab) {
1154
+ if (view !== state.activeTab) {
1155
+ return;
1156
+ }
956
1157
  updateControls();
957
1158
 
1159
+ if (state.activeTab === "models") {
1160
+ contentArea.innerHTML = renderModelsSection();
1161
+ createIcons();
1162
+ return;
1163
+ }
1164
+
958
1165
  if (state.isLoading) {
959
1166
  contentArea.innerHTML = renderSpinner();
960
1167
  createIcons();
@@ -995,6 +1202,223 @@
995
1202
  createIcons();
996
1203
  }
997
1204
 
1205
+ function getModelField(model, fields) {
1206
+ for (const field of fields) {
1207
+ if (typeof model?.[field] === "string" && model[field].trim()) {
1208
+ return model[field];
1209
+ }
1210
+ }
1211
+ return "";
1212
+ }
1213
+
1214
+ function getFilteredModels() {
1215
+ const query = state.modelsSearch.trim().toLowerCase();
1216
+ return (state.models || []).filter((model) => [
1217
+ getModelField(model, ["id"]),
1218
+ getModelField(model, ["display_name", "name"]),
1219
+ getModelField(model, ["owned_by", "vendor"]),
1220
+ ].some((value) => value.toLowerCase().includes(query)));
1221
+ }
1222
+
1223
+ function getModelsCountText() {
1224
+ const total = state.models?.length || 0;
1225
+ return state.modelsSearch.trim()
1226
+ ? `${getFilteredModels().length} of ${total} models`
1227
+ : `${total} models`;
1228
+ }
1229
+
1230
+ function renderModelsSection() {
1231
+ return `
1232
+ <section class="panel" aria-label="Available models">
1233
+ <div class="panel-header models-toolbar">
1234
+ <div>
1235
+ <h2 class="panel-title">Models</h2>
1236
+ <p id="models-count" class="panel-meta">${getModelsCountText()}</p>
1237
+ </div>
1238
+ <input
1239
+ type="search"
1240
+ class="input-control input-focus models-search"
1241
+ data-model-search
1242
+ aria-label="Search models by ID, name or vendor"
1243
+ placeholder="Search by ID, name or vendor"
1244
+ value="${escapeHtml(state.modelsSearch)}"
1245
+ ${state.isModelsLoading || !state.models ? "disabled" : ""}
1246
+ />
1247
+ </div>
1248
+ <div id="models-results">${renderModelsResults()}</div>
1249
+ <p id="models-feedback" class="px-4 text-xs" role="status" aria-live="polite"></p>
1250
+ </section>
1251
+ `;
1252
+ }
1253
+
1254
+ function renderModelsResults() {
1255
+ if (state.isModelsLoading) {
1256
+ return renderSpinner();
1257
+ }
1258
+ if (state.modelsError) {
1259
+ return `<div class="p-4">${renderError(state.modelsError, "Could not load models")}</div>`;
1260
+ }
1261
+ if (!state.models) {
1262
+ return renderEmptyState("Refresh to load models for this endpoint.");
1263
+ }
1264
+ if (state.models.length === 0) {
1265
+ return renderEmptyState("This endpoint returned no models.");
1266
+ }
1267
+ const models = getFilteredModels();
1268
+ if (models.length === 0) {
1269
+ return renderEmptyState("No models match your search.");
1270
+ }
1271
+ return `
1272
+ <div class="models-table-scroll">
1273
+ <table class="models-table" aria-label="Model list">
1274
+ <thead>
1275
+ <tr>
1276
+ <th scope="col" style="width: 40%;">Model ID</th>
1277
+ <th scope="col" style="width: 28%;">Name</th>
1278
+ <th scope="col" style="width: 18%;">Vendor</th>
1279
+ <th scope="col" style="width: 14%;">Details</th>
1280
+ </tr>
1281
+ </thead>
1282
+ <tbody>
1283
+ ${models.map((model, index) => {
1284
+ const id = getModelField(model, ["id"]);
1285
+ const name = getModelField(model, ["display_name", "name"]);
1286
+ const vendor = getModelField(model, ["owned_by", "vendor"]);
1287
+ return `
1288
+ <tr>
1289
+ <td>
1290
+ <div class="models-id">
1291
+ <code>${escapeHtml(id || "—")}</code>
1292
+ ${id ? `<button type="button" class="models-action" data-model-copy="${escapeHtml(id)}" aria-label="Copy model ID ${escapeHtml(id)}"><i data-lucide="copy" class="h-3 w-3" aria-hidden="true"></i>Copy</button>` : ""}
1293
+ </div>
1294
+ </td>
1295
+ <td>${escapeHtml(name || "—")}</td>
1296
+ <td>${escapeHtml(vendor || "—")}</td>
1297
+ <td><button type="button" class="models-action" data-model-details="model-details-${index}" aria-controls="model-details-${index}" aria-expanded="false">View JSON</button></td>
1298
+ </tr>
1299
+ <tr id="model-details-${index}" hidden>
1300
+ <td colspan="4" class="models-json-cell"><pre class="models-json">${escapeHtml(JSON.stringify(model, null, 2))}</pre></td>
1301
+ </tr>
1302
+ `;
1303
+ }).join("")}
1304
+ </tbody>
1305
+ </table>
1306
+ </div>
1307
+ `;
1308
+ }
1309
+
1310
+ function getModelsConnectionKey() {
1311
+ return JSON.stringify([
1312
+ endpointUrlInput.value.trim(),
1313
+ apiKeyInput.value.trim(),
1314
+ ]);
1315
+ }
1316
+
1317
+ function syncModelsConnection() {
1318
+ const key = getModelsConnectionKey();
1319
+ if (state.modelsConnectionKey !== key) {
1320
+ state.modelsConnectionKey = key;
1321
+ state.modelsRequestId += 1;
1322
+ state.models = null;
1323
+ state.modelsError = null;
1324
+ state.isModelsLoading = false;
1325
+ }
1326
+ }
1327
+
1328
+ function handleModelsConnectionChange() {
1329
+ syncModelsConnection();
1330
+ if (state.activeTab === "models") {
1331
+ render();
1332
+ }
1333
+ }
1334
+
1335
+ async function fetchModels() {
1336
+ syncEndpointCredential();
1337
+ syncModelsConnection();
1338
+ const connectionKey = state.modelsConnectionKey;
1339
+ const requestId = ++state.modelsRequestId;
1340
+ const isCurrentRequest = () =>
1341
+ requestId === state.modelsRequestId
1342
+ && connectionKey === getModelsConnectionKey();
1343
+ state.models = null;
1344
+ state.modelsError = null;
1345
+ state.isModelsLoading = true;
1346
+ render("models");
1347
+ try {
1348
+ const endpoint = endpointUrlInput.value.trim();
1349
+ if (!endpoint) {
1350
+ throw new Error("Endpoint URL cannot be empty.");
1351
+ }
1352
+ const payload = await fetchJson(deriveEndpointUrl(endpoint, "models"));
1353
+ if (!Array.isArray(payload?.data)) {
1354
+ throw new Error("Model response must contain a data array.");
1355
+ }
1356
+ if (isCurrentRequest()) {
1357
+ state.models = payload.data;
1358
+ }
1359
+ } catch (error) {
1360
+ if (isCurrentRequest()) {
1361
+ state.modelsError = getErrorMessage(error);
1362
+ }
1363
+ } finally {
1364
+ if (isCurrentRequest()) {
1365
+ state.isModelsLoading = false;
1366
+ render("models");
1367
+ }
1368
+ }
1369
+ }
1370
+
1371
+ function activateTab(tab) {
1372
+ if (tab !== "usage" && tab !== "models") {
1373
+ return;
1374
+ }
1375
+ syncEndpointCredential();
1376
+ syncModelsConnection();
1377
+ state.activeTab = tab;
1378
+ render();
1379
+ if (tab === "models" && !state.models && !state.isModelsLoading) {
1380
+ void fetchModels();
1381
+ }
1382
+ }
1383
+
1384
+ function handleTabsKeydown(event) {
1385
+ const keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
1386
+ if (!keys.includes(event.key)) {
1387
+ return;
1388
+ }
1389
+ event.preventDefault();
1390
+ const index = state.activeTab === "usage" ? 0 : 1;
1391
+ const nextIndex = event.key === "Home" ? 0
1392
+ : event.key === "End" ? 1
1393
+ : 1 - index;
1394
+ activateTab(tabButtons[nextIndex].dataset.tab);
1395
+ tabButtons[nextIndex].focus();
1396
+ }
1397
+
1398
+ function handleModelsSearch(event) {
1399
+ if (!event.target.matches("[data-model-search]")) {
1400
+ return;
1401
+ }
1402
+ state.modelsSearch = event.target.value;
1403
+ document.getElementById("models-results").innerHTML =
1404
+ renderModelsResults();
1405
+ document.getElementById("models-count").textContent = getModelsCountText();
1406
+ document.getElementById("models-feedback").textContent = "";
1407
+ createIcons();
1408
+ }
1409
+
1410
+ async function copyModelId(id) {
1411
+ const feedback = document.getElementById("models-feedback");
1412
+ try {
1413
+ await navigator.clipboard.writeText(id);
1414
+ feedback.textContent = "Model ID copied.";
1415
+ } catch {
1416
+ feedback.textContent =
1417
+ "Could not copy. Select the model ID and copy it manually.";
1418
+ }
1419
+ feedback.classList.add("py-3");
1420
+ }
1421
+
998
1422
  /**
999
1423
  * Renders the "Usage Quotas" section with progress bars.
1000
1424
  * @param {object} snapshots - The quota_snapshots object from the API response.
@@ -2072,7 +2496,7 @@
2072
2496
  state.tokenUsageEventsError = getErrorMessage(error);
2073
2497
  } finally {
2074
2498
  state.isLoading = false;
2075
- render();
2499
+ render("usage");
2076
2500
  }
2077
2501
  }
2078
2502
 
@@ -2161,7 +2585,7 @@
2161
2585
  state.tokenUsageEventsError = getErrorMessage(error);
2162
2586
  } finally {
2163
2587
  state.isTokenUsageLoading = false;
2164
- render();
2588
+ render("usage");
2165
2589
  }
2166
2590
  }
2167
2591
 
@@ -2193,7 +2617,7 @@
2193
2617
  }
2194
2618
  } finally {
2195
2619
  state.isEventsLoading = false;
2196
- render();
2620
+ render("usage");
2197
2621
  }
2198
2622
  }
2199
2623
 
@@ -2208,7 +2632,11 @@
2208
2632
  syncEndpointCredential();
2209
2633
  storeApiKey(apiKeyInput.value, endpointUrlInput.value.trim());
2210
2634
  syncUrlState();
2211
- void fetchData();
2635
+ if (state.activeTab === "models") {
2636
+ void fetchModels();
2637
+ } else {
2638
+ void fetchData();
2639
+ }
2212
2640
  }
2213
2641
 
2214
2642
  function handlePeriodChange() {
@@ -2220,7 +2648,21 @@
2220
2648
  if (!(event.target instanceof Element)) {
2221
2649
  return;
2222
2650
  }
2223
-
2651
+ const copyButton = event.target.closest("[data-model-copy]");
2652
+ if (copyButton) {
2653
+ void copyModelId(copyButton.dataset.modelCopy);
2654
+ return;
2655
+ }
2656
+ const detailsButton = event.target.closest("[data-model-details]");
2657
+ if (detailsButton) {
2658
+ const details = document.getElementById(
2659
+ detailsButton.dataset.modelDetails
2660
+ );
2661
+ details.hidden = !details.hidden;
2662
+ detailsButton.setAttribute("aria-expanded", String(!details.hidden));
2663
+ detailsButton.textContent = details.hidden ? "View JSON" : "Hide JSON";
2664
+ return;
2665
+ }
2224
2666
  const trendMetric = event.target.closest("[data-trend-metric]");
2225
2667
  if (trendMetric) {
2226
2668
  state.tokenUsageTrendMetric =
@@ -2293,16 +2735,29 @@
2293
2735
  */
2294
2736
  function init() {
2295
2737
  endpointForm.addEventListener("submit", handleFormSubmit);
2738
+ pageTabs.addEventListener("click", (event) => {
2739
+ const button = event.target.closest("[data-tab]");
2740
+ if (button) {
2741
+ activateTab(button.dataset.tab);
2742
+ }
2743
+ });
2744
+ pageTabs.addEventListener("keydown", handleTabsKeydown);
2296
2745
  tokenUsagePeriodSelect.addEventListener("change", handlePeriodChange);
2297
2746
  apiKeyInput.addEventListener("input", () => {
2298
2747
  storeApiKey(
2299
2748
  apiKeyInput.value,
2300
2749
  endpointUrlInput.value.trim()
2301
2750
  );
2751
+ handleModelsConnectionChange();
2752
+ });
2753
+ endpointUrlInput.addEventListener("input", handleModelsConnectionChange);
2754
+ endpointUrlInput.addEventListener("change", () => {
2755
+ syncEndpointCredential();
2756
+ handleModelsConnectionChange();
2302
2757
  });
2303
- endpointUrlInput.addEventListener("change", syncEndpointCredential);
2304
2758
  contentArea.addEventListener("click", handleContentAreaClick);
2305
2759
  contentArea.addEventListener("change", handleContentAreaChange);
2760
+ contentArea.addEventListener("input", handleModelsSearch);
2306
2761
 
2307
2762
  const urlParams = new URLSearchParams(window.location.search);
2308
2763
  const periodFromUrl = normalizePeriod(urlParams.get("period"));
@@ -1,2 +0,0 @@
1
- import { t as auth } from "./auth-B1ZRVwUG.js";
2
- export { auth };