@jeffreycao/copilot-api 2.7.39 → 2.7.41

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
@@ -18,6 +18,9 @@
18
18
 
19
19
  <style>
20
20
  :root {
21
+ --page-zoom: 0.9;
22
+ zoom: var(--page-zoom);
23
+
21
24
  --color-red: #dc2626;
22
25
  --color-green: #16a34a;
23
26
  --color-yellow: #d97706;
@@ -62,7 +65,7 @@
62
65
  }
63
66
 
64
67
  .app-shell {
65
- min-height: 100vh;
68
+ min-height: calc(100vh / var(--page-zoom));
66
69
  background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
67
70
  }
68
71
 
@@ -114,6 +117,7 @@
114
117
 
115
118
  .page-tabs {
116
119
  display: inline-flex;
120
+ max-width: 100%;
117
121
  gap: 4px;
118
122
  margin-bottom: 14px;
119
123
  padding: 4px;
@@ -390,6 +394,10 @@
390
394
  grid-template-columns: repeat(6, minmax(0, 1fr));
391
395
  }
392
396
 
397
+ .subscription-grid {
398
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
399
+ }
400
+
393
401
  .metric-grid-tight .metric-label {
394
402
  white-space: nowrap;
395
403
  }
@@ -531,6 +539,8 @@
531
539
  }
532
540
 
533
541
  @media (max-width: 639px) {
542
+ .page-tabs { display: flex; width: 100%; gap: 2px; }
543
+ .page-tab { flex: 1 1 auto; min-width: 0; justify-content: center; gap: 5px; padding: 8px; font-size: 12px; white-space: nowrap; }
534
544
  .main-shell {
535
545
  padding: 12px 14px 18px;
536
546
  }
@@ -556,6 +566,82 @@
556
566
  grid-template-columns: repeat(2, minmax(0, 1fr));
557
567
  }
558
568
  }
569
+
570
+ /* Subscription allowance workspace. Provider SVGs are bundled from models.dev. */
571
+ .subscriptions-page {
572
+ --quota-text: var(--color-ink, var(--color-fg-lightest));
573
+ --quota-muted: var(--color-ink-soft, var(--color-gray));
574
+ --quota-faint: var(--color-ink-faint, var(--color-fg-darker));
575
+ --quota-line: var(--color-line, var(--color-bg-light-2));
576
+ --quota-soft: var(--color-sunken, var(--color-bg-light-1));
577
+ --quota-surface: var(--color-surface, var(--color-bg));
578
+ padding: 0;
579
+ color: var(--quota-text);
580
+ }
581
+ .subscriptions-page-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 26px; }
582
+ .subscriptions-eyebrow { margin: 0 0 7px; font-size: 10px; font-weight: 650; letter-spacing: .16em; color: var(--quota-faint); }
583
+ .subscriptions-page-heading h2 { margin: 0; font-size: 25px; font-weight: 650; letter-spacing: -.04em; line-height: 1.25; }
584
+ .subscriptions-description { margin: 9px 0 0; font-size: 13px; line-height: 1.6; color: var(--quota-muted); }
585
+ .subscriptions-count { display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0; padding: 7px 11px; border: 1px solid var(--quota-line); border-radius: 99px; font-size: 11px; color: var(--quota-muted); background: var(--quota-surface); }
586
+ .subscriptions-count > span { width: 5px; height: 5px; border-radius: 50%; background: #22a06b; }
587
+ .subscriptions-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; align-items: start; }
588
+ .quota-provider-card { --quota-brand: #7c5ac7; position: relative; min-width: 0; padding: 22px 20px 14px; overflow: hidden; border: 1px solid var(--quota-line); border-radius: 18px; background: var(--quota-surface); box-shadow: 0 3px 12px rgb(0 0 0 / 2%); }
589
+ .quota-provider-card[data-provider="codex"] { --quota-brand: #179876; }
590
+ .quota-provider-card[data-provider="xai"] { --quota-brand: #386fce; }
591
+ .dark .quota-provider-card { --quota-brand: #b095ef; }
592
+ .dark .quota-provider-card[data-provider="codex"] { --quota-brand: #50bd9b; }
593
+ .dark .quota-provider-card[data-provider="xai"] { --quota-brand: #7aa8f1; }
594
+ .quota-card-header { display: flex; align-items: center; gap: 11px; }
595
+ .quota-provider-logo { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; border: 1px solid var(--quota-line); border-radius: 13px; background: var(--quota-soft); }
596
+ .quota-provider-logo img { width: 32px; height: 32px; object-fit: contain; }
597
+ .quota-card-header h3 { margin: 0; font-size: 16px; font-weight: 650; letter-spacing: -.025em; line-height: 1.3; }
598
+ .quota-provider-product { margin: 3px 0 0; font-size: 11px; color: var(--quota-faint); }
599
+ .quota-refresh { display: grid; place-items: center; flex-shrink: 0; width: 30px; height: 30px; padding: 0; margin-left: auto; border: 1px solid var(--quota-line); border-radius: 9px; color: var(--quota-muted); background: var(--quota-surface); cursor: pointer; }
600
+ .quota-refresh:hover { background: var(--quota-soft); color: var(--quota-text); }
601
+ .quota-refresh:disabled { opacity: .45; cursor: default; }
602
+ .quota-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 17px 0 0; }
603
+ .quota-plan { min-width: 0; max-width: 65%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 8px; border-radius: 6px; background: var(--quota-soft); color: var(--quota-muted); font-size: 11px; font-weight: 500; }
604
+ .quota-status { display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0; font-size: 10px; color: var(--quota-faint); }
605
+ .quota-status > span { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
606
+ .quota-status[data-state="ready"] { color: var(--quota-muted); }
607
+ .quota-status[data-state="warning"] { color: #d58a25; }
608
+ .quota-summary { margin: 18px 0 22px; padding: 18px; border-radius: 13px; background: color-mix(in srgb, var(--quota-brand) 4%, var(--quota-soft)); }
609
+ .quota-summary-label { margin: 0 0 9px; font-size: 11px; font-weight: 500; color: var(--quota-muted); }
610
+ .quota-summary-value { font-size: 40px; line-height: 1.05; font-weight: 600; letter-spacing: -.055em; font-variant-numeric: tabular-nums; }
611
+ .quota-summary-context { margin: 9px 0 0; font-size: 11px; color: var(--quota-faint); }
612
+ .quota-details { font-size: 12px; }
613
+ .quota-window + .quota-window { margin-top: 18px; }
614
+ .quota-window-heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 7px; font-size: 12px; }
615
+ .quota-window-heading > :first-child { color: var(--quota-muted); }
616
+ .quota-window-heading > :last-child { font-weight: 550; color: var(--quota-text); }
617
+ .quota-progress { height: 6px; overflow: hidden; border-radius: 99px; background: var(--quota-soft); }
618
+ .quota-progress-fill { height: 100%; border-radius: inherit; background: var(--quota-brand); transition: width .25s ease; }
619
+ .quota-window[data-warning="true"] .quota-progress-fill { background: #d58a25; }
620
+ .quota-window-note { margin: 6px 0 0; font-size: 11px; color: var(--quota-faint); line-height: 1.5; }
621
+ .quota-balance { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--quota-line); font-size: 12px; color: var(--quota-muted); }
622
+ .quota-balance strong { color: var(--quota-text); font-weight: 600; }
623
+ .quota-notice { margin: 0 0 15px; padding: 10px 12px; border-radius: 9px; background: rgb(213 138 37 / 8%); color: #b67822; font-size: 12px; line-height: 1.6; }
624
+ .quota-card-footer { margin-top: 22px; padding-top: 12px; border-top: 1px solid var(--quota-line); color: var(--quota-faint); font-size: 10px; }
625
+ .quota-card-message { display: flex; align-items: center; min-height: 180px; margin: 0; font-size: 12px; line-height: 1.8; color: var(--quota-muted); }
626
+ .quota-card-message[role="alert"] { color: #b67822; }
627
+ .quota-skeleton { display: flex; flex-direction: column; justify-content: center; gap: 16px; min-height: 230px; animation: quota-pulse 1.5s ease-in-out infinite; }
628
+ .quota-skeleton > div { height: 9px; border-radius: 6px; background: var(--quota-soft); }
629
+ .quota-skeleton > div:first-child { width: 55%; height: 42px; margin-bottom: 22px; }
630
+ .quota-skeleton > div:last-child { width: 75%; }
631
+ @keyframes quota-pulse { 50% { opacity: .45; } }
632
+ .subscriptions-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 260px; padding: 30px; text-align: center; border: 1px dashed var(--quota-line); border-radius: 18px; color: var(--quota-muted); font-size: 13px; }
633
+ .subscriptions-empty h3 { margin: 0; font-size: 17px; font-weight: 600; color: var(--quota-text); }
634
+ .subscriptions-empty p { max-width: 360px; margin: 0; line-height: 1.7; }
635
+ .subscriptions-action { margin-top: 5px; border: 0; border-radius: 9px; padding: 8px 14px; background: var(--quota-text); color: var(--quota-surface); cursor: pointer; font-size: 12px; }
636
+ @media (max-width: 899px) {
637
+ .subscriptions-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
638
+ }
639
+ @media (max-width: 639px) {
640
+ .subscriptions-page-heading { align-items: flex-start; gap: 12px; margin-bottom: 20px; flex-direction: column; }
641
+ .subscriptions-page-heading h2 { font-size: 23px; }
642
+ .subscriptions-grid { grid-template-columns: minmax(0, 1fr); }
643
+ .quota-provider-card { padding: 20px 18px 14px; }
644
+ }
559
645
  </style>
560
646
  </head>
561
647
  <body class="antialiased">
@@ -587,6 +673,10 @@
587
673
  <i data-lucide="bar-chart-3" class="h-4 w-4" aria-hidden="true"></i>
588
674
  Token Usage
589
675
  </button>
676
+ <button id="subscriptions-tab" type="button" class="page-tab" role="tab" data-tab="subscriptions" aria-selected="false" aria-controls="content-area" tabindex="-1">
677
+ <i data-lucide="gauge" class="h-4 w-4" aria-hidden="true"></i>
678
+ Subscriptions
679
+ </button>
590
680
  <button
591
681
  id="models-tab"
592
682
  type="button"
@@ -668,6 +758,7 @@
668
758
  const pageTabs = document.getElementById("page-tabs");
669
759
  const tabButtons = [
670
760
  document.getElementById("usage-tab"),
761
+ document.getElementById("subscriptions-tab"),
671
762
  document.getElementById("models-tab"),
672
763
  ];
673
764
  const periodField = document.getElementById("token-usage-period-field");
@@ -682,6 +773,29 @@
682
773
  fetchButton.style.backgroundColor = "var(--color-blue)";
683
774
  });
684
775
 
776
+ // models.dev provider SVGs are bundled to work without external requests.
777
+ const SUBSCRIPTION_PROVIDERS = {
778
+ "github-copilot": {
779
+ "name": "GitHub Copilot",
780
+ "product": "GitHub",
781
+ "logo": "data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2040%2040%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M35.3993%2026.4544C34.2871%2028.3855%2027.8314%2032.9425%2020%2032.9425C12.1686%2032.9425%205.71288%2028.3855%204.60075%2026.4544C4.5427%2026.3532%204.50826%2026.2401%204.5%2026.1237V22.418C4.51199%2022.3209%204.53496%2022.2256%204.56846%2022.1338C5.04896%2020.9261%206.30833%2019.1733%207.93325%2018.7031C8.14896%2018.149%208.468%2017.3404%208.76508%2016.7437C8.71727%2016.2776%208.69485%2015.8094%208.69792%2015.3409C8.69792%2013.6217%209.06217%2012.113%2010.1601%2010.9906C10.6729%2010.4662%2011.3097%2010.0644%2012.064%209.76091C13.871%208.29357%2016.4453%207.05745%2019.9716%207.05745C23.4978%207.05745%2026.129%208.29357%2027.936%209.76091C28.6903%2010.0644%2029.3271%2010.4662%2029.8399%2010.9906C30.9378%2012.113%2031.3021%2013.6217%2031.3021%2015.3409C31.3021%2015.8162%2031.284%2016.2877%2031.2349%2016.7437C31.532%2017.3404%2031.851%2018.149%2032.0667%2018.7031C33.6917%2019.1733%2034.951%2020.9261%2035.4315%2022.1338C35.4677%2022.2248%2035.4908%2022.3205%2035.5%2022.418V26.1237C35.4917%2026.2401%2035.4573%2026.3532%2035.3993%2026.4544ZM20.2222%2018.7147H19.7778C19.6422%2018.945%2019.4889%2019.1644%2019.3193%2019.3709C18.3247%2020.5941%2016.8419%2021.2981%2014.7881%2021.2981C12.56%2021.2981%2010.9273%2020.8344%209.90304%2019.6719C9.86495%2019.6283%209.82833%2019.5835%209.79325%2019.5375L9.66667%2019.6719V28.1775C11.5202%2029.1837%2015.4972%2030.992%2020%2030.992C24.5028%2030.992%2028.4798%2029.1837%2030.3333%2028.1775V19.6719L30.2067%2019.5375C30.2067%2019.5375%2030.1641%2019.5957%2030.097%2019.6719C29.0727%2020.8344%2027.44%2021.2981%2025.2119%2021.2981C23.1581%2021.2981%2021.6753%2020.5941%2020.6807%2019.3709C20.5111%2019.1644%2020.3578%2018.945%2020.2222%2018.7147ZM23.2292%2022.9127C23.9383%2022.9127%2024.5208%2023.4952%2024.5208%2024.2043V26.7877C24.5208%2027.4968%2023.9383%2028.0793%2023.2292%2028.0793C22.52%2028.0793%2021.9375%2027.4968%2021.9375%2026.7877V24.2043C21.9375%2023.4952%2022.52%2022.9127%2023.2292%2022.9127ZM16.7708%2022.9127C17.48%2022.9127%2018.0625%2023.4952%2018.0625%2024.2043V26.7877C18.0625%2027.4968%2017.48%2028.0793%2016.7708%2028.0793C16.0617%2028.0793%2015.4792%2027.4968%2015.4792%2026.7877V24.2043C15.4792%2023.4952%2016.0617%2022.9127%2016.7708%2022.9127ZM21.0501%2014.9237C21.2258%2016.289%2021.5707%2017.3947%2022.1842%2018.149C22.7551%2018.8517%2023.649%2019.3606%2025.2119%2019.3606C27.2437%2019.3606%2028.1724%2018.9253%2028.6438%2018.3905C29.1398%2017.8287%2029.3646%2016.9051%2029.3646%2015.3409C29.3646%2013.8684%2029.0507%2012.9552%2028.454%2012.3455C27.8378%2011.7152%2026.7502%2011.2321%2024.8063%2011.0216C22.8856%2010.8136%2021.975%2011.1998%2021.5345%2011.7049C21.187%2012.1014%2020.97%2012.7485%2020.9688%2013.7431V13.7702C20.9688%2014.1121%2020.9959%2014.4966%2021.0501%2014.9237ZM18.9499%2014.9237C19.0041%2014.4966%2019.0312%2014.1117%2019.0312%2013.7689V13.7431C19.03%2012.7485%2018.813%2012.1014%2018.4655%2011.7049C18.025%2011.1998%2017.1144%2010.8136%2015.1937%2011.0216C13.2498%2011.2321%2012.1622%2011.7152%2011.546%2012.3455C10.9493%2012.9552%2010.6354%2013.8684%2010.6354%2015.3409C10.6354%2016.9051%2010.8615%2017.8287%2011.3562%2018.3905C11.8276%2018.9253%2012.7563%2019.3606%2014.7881%2019.3606C16.351%2019.3606%2017.2449%2018.8517%2017.8158%2018.149C18.4293%2017.3947%2018.7742%2016.289%2018.9499%2014.9237Z%22%20fill%3D%22currentColor%22%2F%3E%0A%3C%2Fsvg%3E%0A"
782
+ },
783
+ "codex": {
784
+ "name": "Codex",
785
+ "product": "OpenAI",
786
+ "logo": "data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2040%2040%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M32.8377%2017.282C33.2127%2016.25%2033.3072%2015.218%2033.2127%2014.1875C33.1197%2013.1571%2032.7447%2012.1251%2032.2752%2011.1876C31.4322%209.78209%2030.2127%208.6571%2028.8072%208.0001C27.3072%207.34461%2025.7127%207.15711%2024.1197%207.53211C23.3698%206.78212%2022.5253%206.12512%2021.5878%205.65713C20.6503%205.18913%2019.5253%205.00013%2018.4948%205.00013C16.8851%204.99074%2015.3125%205.48246%2013.9948%206.40712C12.6824%207.34311%2011.7449%208.6571%2011.2754%2010.1571C10.1504%2010.4376%209.21289%2010.9071%208.27539%2011.4696C7.4324%2012.1251%206.77541%2012.9696%206.21291%2013.8126C5.36992%2015.2195%205.08792%2016.8125%205.27542%2018.407C5.46399%2019.9968%206.11605%2021.496%207.1504%2022.718C6.79608%2023.7086%206.66795%2024.7659%206.77541%2025.8124C6.86991%2026.8444%207.2449%2027.8749%207.7129%2028.8124C8.55739%2030.2194%209.77538%2031.3444%2011.1824%2031.9999C12.6824%2032.6569%2014.2753%2032.8444%2015.8698%2032.4694C16.6198%2033.2194%2017.4628%2033.8749%2018.4003%2034.3444C19.3378%2034.8139%2020.4628%2034.9999%2021.4948%2034.9999C23.1043%2035.0097%2024.6769%2034.5185%2025.9947%2033.5944C27.3072%2032.6569%2028.2447%2031.3444%2028.7127%2029.8444C29.7719%2029.6432%2030.7682%2029.1934%2031.6197%2028.5319C32.4627%2027.8749%2033.2127%2027.1249%2033.6822%2026.1874C34.5251%2024.7819%2034.8071%2023.1875%2034.6196%2021.5945C34.4322%2020%2033.8697%2018.5015%2032.8377%2017.282ZM21.5878%2033.0304C20.0878%2033.0304%2018.9628%2032.5609%2017.9323%2031.7179C17.9323%2031.7179%2018.0253%2031.6234%2018.1198%2031.6234L24.1197%2028.1554C24.2862%2028.0803%2024.4196%2027.9469%2024.4947%2027.7804C24.5698%2027.636%2024.6021%2027.4731%2024.5877%2027.3109V18.875L27.1197%2020.375V27.3124C27.1455%2028.0547%2027.0215%2028.7945%2026.755%2029.4878C26.4885%2030.181%2026.085%2030.8134%2025.5687%2031.3473C25.0523%2031.8811%2024.4337%2032.3054%2023.7497%2032.5949C23.0658%2032.8843%2022.3305%2033.0314%2021.5878%2033.0304ZM9.49488%2027.8749C8.83789%2026.7499%208.55739%2025.4374%208.83789%2024.125C8.83789%2024.125%208.93239%2024.2195%209.02539%2024.2195L15.0253%2027.6874C15.1693%2027.7638%2015.3325%2027.7966%2015.4948%2027.7819C15.6823%2027.7819%2015.8698%2027.7819%2015.9628%2027.6874L23.2753%2023.4695V26.3749L17.1823%2029.9374C16.5506%2030.3042%2015.8527%2030.5427%2015.1287%2030.6393C14.4046%2030.7358%2013.6686%2030.6884%2012.9629%2030.4999C11.4629%2030.1249%2010.2449%2029.1874%209.49488%2027.8749ZM7.9004%2014.8445C8.56239%2013.7234%209.58826%2012.8627%2010.8074%2012.4056V19.532C10.8074%2019.718%2010.8074%2019.907%2010.9004%2020C10.9755%2020.1665%2011.1089%2020.2998%2011.2754%2020.375L18.5878%2024.5944L16.0573%2026.0944L10.0574%2022.625C9.41842%2022.2639%208.85742%2021.7797%208.40684%2021.2004C7.95627%2020.6211%207.62506%2019.9582%207.4324%2019.25C7.05741%2017.8445%207.1504%2016.157%207.9004%2014.8445ZM28.6197%2019.625L21.3073%2015.407L23.8377%2013.9071L29.8377%2017.375C30.7752%2017.9375%2031.5252%2018.6875%2031.9947%2019.625C32.4642%2020.5625%2032.7447%2021.5945%2032.6502%2022.7195C32.5603%2023.7755%2032.1699%2024.7837%2031.5252%2025.6249C30.8697%2026.4694%2030.0252%2027.1249%2028.9947%2027.4999V20.375C28.9947%2020.1875%2028.9947%2020%2028.9002%2019.907C28.9002%2019.907%2028.8072%2019.718%2028.6197%2019.625ZM31.1502%2015.875C31.1502%2015.875%2031.0572%2015.782%2030.9627%2015.782L24.9627%2012.3126C24.7752%2012.2196%2024.6822%2012.2196%2024.4947%2012.2196C24.3072%2012.2196%2024.1197%2012.2196%2024.0252%2012.3126L16.7128%2016.532V13.6251L22.8073%2010.0626C23.7448%209.50009%2024.7752%209.31259%2025.9002%209.31259C26.9322%209.31259%2027.9627%209.68759%2028.9002%2010.3446C29.7447%2011.0001%2030.4947%2011.8446%2030.8697%2012.7821C31.2447%2013.7196%2031.3377%2014.8445%2031.1502%2015.875ZM15.4003%2021.125L12.8699%2019.625V12.5946C12.8699%2011.5626%2013.1503%2010.4376%2013.7128%209.59459C14.2753%208.6571%2015.1198%208.0001%2016.0573%207.53211C17.0127%207.05249%2018.0956%206.88812%2019.1503%207.06261C20.1823%207.15711%2021.2128%207.62511%2022.0573%208.2821C22.0573%208.2821%2021.9628%208.3751%2021.8698%208.3751L15.8698%2011.8446C15.7033%2011.9197%2015.57%2012.0531%2015.4948%2012.2196C15.4003%2012.4071%2015.4003%2012.5001%2015.4003%2012.6876V21.125ZM16.7128%2018.125L19.9948%2016.25L23.2753%2018.125V21.875L19.9948%2023.75L16.7128%2021.875V18.125Z%22%20fill%3D%22currentColor%22%2F%3E%0A%3C%2Fsvg%3E%0A"
787
+ },
788
+ "xai": {
789
+ "name": "xAI",
790
+ "product": "Grok Build",
791
+ "logo": "data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2040%2040%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M12.4579%2015.6036L26.1529%2035H20.0656L6.37059%2015.6036H12.4579ZM12.4524%2026.3764L15.4974%2030.6909L12.4551%2035H6.36377L12.4524%2026.3764ZM33.6365%207.15727V35H28.647V14.2236L33.6365%207.15727ZM33.6365%205L20.0656%2024.2205L17.0206%2019.9073L27.5451%205H33.6365Z%22%20fill%3D%22currentColor%22%2F%3E%0A%3C%2Fsvg%3E%0A"
792
+ },
793
+ "opencode-go": {
794
+ "name": "OpenCode Go",
795
+ "product": "OpenCode",
796
+ "logo": "data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M15.6%2017.4H8.4V10.2H15.6V17.4Z%22%20fill%3D%22currentColor%22%20fill-opacity%3D%220.4%22%2F%3E%0A%3Cpath%20d%3D%22M15.6%206.6H8.4V17.4H15.6V6.6ZM19.2%2021H4.8V3H19.2V21Z%22%20fill%3D%22currentColor%22%2F%3E%0A%3C%2Fsvg%3E%0A"
797
+ }
798
+ };
685
799
  const DEFAULT_ENDPOINT = "http://localhost:4141/usage";
686
800
  const DEFAULT_TOKEN_USAGE_PERIOD = "today";
687
801
  const DEFAULT_TOKEN_USAGE_EVENTS_PAGE_SIZE = 20;
@@ -722,8 +836,15 @@
722
836
  isLoading: false,
723
837
  isTokenUsageLoading: false,
724
838
  isEventsLoading: false,
725
- error: null,
726
- data: null,
839
+ subscriptionUsage: {},
840
+ subscriptionUsageErrors: {},
841
+ isSubscriptionsLoading: false,
842
+ subscriptionRequestId: 0,
843
+ subscriptionRequests: {},
844
+ subscriptionLoadingProviders: {},
845
+ subscriptionsLoaded: false,
846
+ subscriptionsConnectionKey: null,
847
+ subscriptionPageError: null,
727
848
  tokenUsageDailySummary: null,
728
849
  tokenUsageSummary: null,
729
850
  tokenUsageSessionsPage: null,
@@ -1049,6 +1170,8 @@
1049
1170
  const currentUrl = new URL(window.location.href);
1050
1171
  const endpoint = endpointUrlInput.value.trim();
1051
1172
  const period = getSelectedPeriod();
1173
+ if (state.activeTab === "usage") currentUrl.searchParams.delete("tab");
1174
+ else currentUrl.searchParams.set("tab", state.activeTab);
1052
1175
 
1053
1176
  if (endpoint) {
1054
1177
  currentUrl.searchParams.set("endpoint", endpoint);
@@ -1067,17 +1190,17 @@
1067
1190
  const isLoading =
1068
1191
  state.activeTab === "models"
1069
1192
  ? state.isModelsLoading
1070
- : state.isLoading;
1193
+ : state.activeTab === "subscriptions" ? state.isSubscriptionsLoading : state.isLoading;
1071
1194
  fetchButton.disabled = isLoading;
1072
1195
  fetchButtonLabel.textContent = isLoading
1073
1196
  ? "Refreshing..."
1074
- : "Refresh";
1197
+ : state.activeTab === "subscriptions" ? "Refresh all" : "Refresh";
1075
1198
  tokenUsagePeriodSelect.disabled =
1076
1199
  state.isLoading || state.isTokenUsageLoading;
1077
- periodField.hidden = state.activeTab === "models";
1200
+ periodField.hidden = state.activeTab !== "usage";
1078
1201
  endpointForm.classList.toggle(
1079
1202
  "models-view",
1080
- state.activeTab === "models"
1203
+ state.activeTab !== "usage"
1081
1204
  );
1082
1205
  contentArea.setAttribute("aria-labelledby", `${state.activeTab}-tab`);
1083
1206
  contentArea.setAttribute("aria-busy", String(isLoading));
@@ -1239,41 +1362,22 @@
1239
1362
  return;
1240
1363
  }
1241
1364
 
1365
+ if (state.activeTab === "subscriptions") {
1366
+ contentArea.innerHTML = renderSubscriptionUsageSection();
1367
+ createIcons();
1368
+ return;
1369
+ }
1370
+
1242
1371
  if (state.isLoading) {
1243
1372
  contentArea.innerHTML = renderSpinner();
1244
1373
  createIcons();
1245
1374
  return;
1246
1375
  }
1247
1376
 
1248
- const hasContent = Boolean(
1249
- state.error
1250
- || state.data
1251
- || state.isTokenUsageLoading
1252
- || state.isEventsLoading
1253
- || state.tokenUsageDailySummary
1254
- || state.tokenUsageSummary
1255
- || state.tokenUsageEventsPage
1256
- || state.tokenUsageDailyError
1257
- || state.tokenUsageSummaryError
1258
- || state.tokenUsageEventsError
1259
- );
1260
-
1261
- if (!hasContent) {
1262
- contentArea.innerHTML = renderWelcomeMessage();
1263
- } else if (state.error && !state.data) {
1264
- contentArea.innerHTML = `
1265
- ${renderTokenUsageSection()}
1266
- ${renderUsageErrorSection()}
1267
- `;
1268
- } else if (state.data) {
1269
- contentArea.innerHTML = `
1270
- ${renderTokenUsageSection()}
1271
- ${renderUsageQuotas(state.data.quota_snapshots)}
1272
- ${renderDetailedData(state.data)}
1273
- `;
1274
- } else {
1275
- contentArea.innerHTML = renderTokenUsageSection();
1276
- }
1377
+ const hasContent = Boolean(state.isTokenUsageLoading || state.isEventsLoading
1378
+ || state.tokenUsageSummary || state.tokenUsageDailySummary || state.tokenUsageEventsPage
1379
+ || state.tokenUsageSummaryError || state.tokenUsageDailyError || state.tokenUsageEventsError);
1380
+ contentArea.innerHTML = hasContent ? renderTokenUsageSection() : renderWelcomeMessage();
1277
1381
 
1278
1382
  // Replace placeholder icons with actual Lucide icons
1279
1383
  createIcons();
@@ -1287,8 +1391,6 @@
1287
1391
  "modelBreakdown",
1288
1392
  "sessionBreakdown",
1289
1393
  "requestEvents",
1290
- "copilotQuotas",
1291
- "usageApiResponse",
1292
1394
  ]);
1293
1395
 
1294
1396
  function readCollapsedSections() {
@@ -1488,7 +1590,7 @@
1488
1590
  `;
1489
1591
  }
1490
1592
 
1491
- function getModelsConnectionKey() {
1593
+ function getConnectionKey() {
1492
1594
  return JSON.stringify([
1493
1595
  endpointUrlInput.value.trim(),
1494
1596
  apiKeyInput.value.trim(),
@@ -1496,7 +1598,7 @@
1496
1598
  }
1497
1599
 
1498
1600
  function syncModelsConnection() {
1499
- const key = getModelsConnectionKey();
1601
+ const key = getConnectionKey();
1500
1602
  if (state.modelsConnectionKey !== key) {
1501
1603
  state.modelsConnectionKey = key;
1502
1604
  state.modelsRequestId += 1;
@@ -1506,9 +1608,24 @@
1506
1608
  }
1507
1609
  }
1508
1610
 
1611
+ function syncSubscriptionsConnection() {
1612
+ const key = getConnectionKey();
1613
+ if (state.subscriptionsConnectionKey !== key) {
1614
+ state.subscriptionsConnectionKey = key;
1615
+ state.subscriptionRequestId += 1;
1616
+ state.subscriptionUsage = {};
1617
+ state.subscriptionUsageErrors = {};
1618
+ state.subscriptionLoadingProviders = {};
1619
+ state.subscriptionsLoaded = false;
1620
+ state.isSubscriptionsLoading = false;
1621
+ state.subscriptionPageError = null;
1622
+ }
1623
+ }
1624
+
1509
1625
  function handleModelsConnectionChange() {
1510
1626
  syncModelsConnection();
1511
- if (state.activeTab === "models") {
1627
+ syncSubscriptionsConnection();
1628
+ if (state.activeTab === "models" || state.activeTab === "subscriptions") {
1512
1629
  render();
1513
1630
  }
1514
1631
  }
@@ -1520,7 +1637,7 @@
1520
1637
  const requestId = ++state.modelsRequestId;
1521
1638
  const isCurrentRequest = () =>
1522
1639
  requestId === state.modelsRequestId
1523
- && connectionKey === getModelsConnectionKey();
1640
+ && connectionKey === getConnectionKey();
1524
1641
  state.models = null;
1525
1642
  state.modelsError = null;
1526
1643
  state.isModelsLoading = true;
@@ -1550,16 +1667,21 @@
1550
1667
  }
1551
1668
 
1552
1669
  function activateTab(tab) {
1553
- if (tab !== "usage" && tab !== "models") {
1670
+ if (!["usage", "subscriptions", "models"].includes(tab)) {
1554
1671
  return;
1555
1672
  }
1556
1673
  syncEndpointCredential();
1557
1674
  syncModelsConnection();
1675
+ syncSubscriptionsConnection();
1558
1676
  state.activeTab = tab;
1677
+ syncUrlState();
1559
1678
  render();
1560
1679
  if (tab === "models" && !state.models && !state.isModelsLoading) {
1561
1680
  void fetchModels();
1562
1681
  }
1682
+ if (tab === "subscriptions" && !state.subscriptionsLoaded && !state.isSubscriptionsLoading) {
1683
+ void refreshSubscriptionQuotas();
1684
+ }
1563
1685
  }
1564
1686
 
1565
1687
  function handleTabsKeydown(event) {
@@ -1568,10 +1690,10 @@
1568
1690
  return;
1569
1691
  }
1570
1692
  event.preventDefault();
1571
- const index = state.activeTab === "usage" ? 0 : 1;
1693
+ const index = tabButtons.findIndex((button) => button.dataset.tab === state.activeTab);
1572
1694
  const nextIndex = event.key === "Home" ? 0
1573
- : event.key === "End" ? 1
1574
- : 1 - index;
1695
+ : event.key === "End" ? tabButtons.length - 1
1696
+ : (index + (event.key === "ArrowRight" ? 1 : -1) + tabButtons.length) % tabButtons.length;
1575
1697
  activateTab(tabButtons[nextIndex].dataset.tab);
1576
1698
  tabButtons[nextIndex].focus();
1577
1699
  }
@@ -1600,197 +1722,85 @@
1600
1722
  feedback.classList.add("py-3");
1601
1723
  }
1602
1724
 
1603
- /**
1604
- * Renders the "Usage Quotas" section with progress bars.
1605
- * @param {object} snapshots - The quota_snapshots object from the API response.
1606
- * @returns {string} HTML string for the usage quotas section.
1607
- */
1608
- function renderUsageQuotas(snapshots) {
1609
- if (!snapshots) return "";
1610
-
1611
- const quotaCards = Object.entries(snapshots)
1612
- .map(([key, value]) => {
1613
- return renderQuotaCard(key, value);
1614
- })
1615
- .join("");
1616
-
1725
+ // Subscription quotas use the same normalized server response as desktop.
1726
+ function subscriptionQuotaLabel(window) {
1727
+ let label = "Included allowance";
1728
+ if (window.id.startsWith("copilot:")) return window.name || "Premium";
1729
+ if (window.kind === "spend") return "Monthly credit limit";
1730
+ if (window.kind === "on-demand") return "Pay as you go · Monthly";
1731
+ const seconds = window.windowSeconds;
1732
+ if (window.period === "weekly" || seconds === 604800) label = "Weekly limit";
1733
+ else if (window.period === "monthly") label = "Monthly limit";
1734
+ else if (Number.isFinite(seconds) && seconds >= 86400) label = `${seconds / 86400}-day limit`;
1735
+ else if (Number.isFinite(seconds) && seconds >= 3600) label = `${seconds / 3600}-hour limit`;
1736
+ else if (Number.isFinite(seconds) && seconds > 0) label = `${seconds / 60}-minute limit`;
1737
+ return window.name ? `${window.name} · ${label}` : label;
1738
+ }
1739
+
1740
+ function formatSubscriptionAmount(value, unit) {
1741
+ if (!Number.isFinite(value)) return "—";
1742
+ return unit === "USD"
1743
+ ? value.toLocaleString(undefined, { style: "currency", currency: "USD" })
1744
+ : value.toLocaleString(undefined, { maximumFractionDigits: 2 });
1745
+ }
1746
+
1747
+ function renderSubscriptionQuotaWindow(window) {
1748
+ const percent = window.usedPercent;
1749
+ const hasPercent = Number.isFinite(percent) && !window.unlimited;
1750
+ const width = hasPercent ? Math.min(100, Math.max(0, percent)) : 0;
1751
+ const label = escapeHtml(subscriptionQuotaLabel(window));
1752
+ const reset = Number.isFinite(window.resetAt) ? new Date(window.resetAt * 1000) : null;
1753
+ const resetDate = window.resetDate || (reset && Number.isFinite(reset.getTime()) ? reset.toLocaleString() : null);
1617
1754
  return `
1618
- <section id="usage-quotas" class="dashboard-section">
1619
- <h2 class="section-heading">
1620
- ${renderCollapseToggle(
1621
- "copilotQuotas",
1622
- `<i data-lucide="gauge" class="h-5 w-5" style="color: var(--color-blue);"></i><span>Copilot Quotas</span>`
1623
- )}
1624
- </h2>
1625
- ${renderCollapseBody("copilotQuotas", quotaCards, "metric-grid")}
1626
- </section>
1627
- `;
1628
- }
1629
-
1630
- function renderUsageErrorSection() {
1631
- if (!state.error || state.data) {
1632
- return "";
1633
- }
1634
-
1635
- return `
1636
- <section id="usage-quotas" class="dashboard-section">
1637
- <h2 class="section-heading">
1638
- ${renderCollapseToggle(
1639
- "copilotQuotas",
1640
- `<i data-lucide="gauge" class="h-5 w-5" style="color: var(--color-blue);"></i><span>Copilot Quotas</span>`
1641
- )}
1642
- </h2>
1643
- ${renderCollapseBody(
1644
- "copilotQuotas",
1645
- renderError(state.error, "Usage request failed")
1646
- )}
1647
- </section>
1648
- `;
1649
- }
1650
-
1651
- /**
1652
- * Renders a single quota card.
1653
- * @param {string} title - The name of the quota (e.g., 'chat').
1654
- * @param {object} details - The details object for the quota.
1655
- * @returns {string} HTML string for a single card.
1656
- */
1657
- function getCopilotQuotaRemaining(details) {
1658
- if (Number.isFinite(details.remaining)) return details.remaining;
1659
- if (Number.isFinite(details.quota_remaining)) {
1660
- return details.quota_remaining;
1661
- }
1662
- return null;
1663
- }
1664
-
1665
- function getCopilotQuotaPercentRemaining(details) {
1666
- if (details.unlimited) return 100;
1667
- const percent = Number.isFinite(details.percent_remaining)
1668
- ? details.percent_remaining
1669
- : Number.isFinite(details.entitlement) && details.entitlement > 0
1670
- ? ((getCopilotQuotaRemaining(details) ?? details.entitlement) /
1671
- details.entitlement) * 100
1672
- : 100;
1673
- return Math.max(0, Math.min(100, percent));
1674
- }
1675
-
1676
- function renderQuotaCard(title, details) {
1677
- const { entitlement, unlimited } = details;
1678
- const remaining = getCopilotQuotaRemaining(details) ?? 0;
1679
-
1680
- const percentUsed = 100 - getCopilotQuotaPercentRemaining(details);
1681
- const used = unlimited
1682
- ? "N/A"
1683
- : (entitlement - remaining).toLocaleString();
1684
-
1685
- let progressBarColor = "var(--color-green)";
1686
- if (percentUsed > 75) progressBarColor = "var(--color-yellow)";
1687
- if (percentUsed > 90) progressBarColor = "var(--color-red)";
1688
- if (unlimited) progressBarColor = "var(--color-blue)";
1689
-
1690
- return `
1691
- <div class="metric-card">
1692
- <div class="flex items-center justify-between gap-3">
1693
- <div class="metric-label capitalize">
1694
- <span class="metric-dot" style="background-color: ${progressBarColor};"></span>
1695
- <span>${escapeHtml(title.replace(/_/g, " "))}</span>
1696
- </div>
1697
- ${
1698
- unlimited
1699
- ? `<span class="rounded-md px-2 py-1 text-xs font-semibold" style="background-color: rgba(37, 99, 235, 0.1); color: var(--color-blue);">Unlimited</span>`
1700
- : `<span class="text-xs font-semibold" style="color: var(--color-gray);">${percentUsed.toFixed(1)}% used</span>`
1701
- }
1702
- </div>
1703
- <div class="mt-5">
1704
- <div class="w-full progress-bar-bg h-2">
1705
- <div class="progress-bar-fg h-2" style="width: ${unlimited ? 100 : percentUsed}%; background-color: ${progressBarColor};"></div>
1706
- </div>
1707
- </div>
1708
- <div class="mt-2 flex items-center justify-between gap-4 text-xs font-medium" style="color: var(--color-gray);">
1709
- <span>${used} / ${unlimited ? "∞" : entitlement.toLocaleString()}</span>
1710
- <span>${unlimited ? "∞" : remaining.toLocaleString()} remaining</span>
1711
- </div>
1755
+ <div class="quota-window" data-warning="${Boolean(window.limitReached || percent >= 90)}">
1756
+ <div class="quota-window-heading"><span>${label}</span><span>${window.unlimited ? "Unlimited" : hasPercent ? `${Math.round(percent * 10) / 10}% used` : "—"}</span></div>
1757
+ ${hasPercent ? `<div role="progressbar" aria-label="${label}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${width}" class="quota-progress"><div class="quota-progress-fill" style="width: ${width}%;"></div></div>` : ""}
1758
+ ${!window.unlimited && (window.used !== undefined || window.limit !== undefined) ? `<p class="quota-window-note">${formatSubscriptionAmount(window.used, window.unit)} / ${formatSubscriptionAmount(window.limit, window.unit)}${window.unit === "credits" ? " Credits" : window.unit === "requests" ? " requests" : ""}</p>` : ""}
1759
+ ${resetDate ? `<p class="quota-window-note">Resets ${escapeHtml(resetDate)}</p>` : ""}
1712
1760
  </div>
1713
1761
  `;
1714
1762
  }
1715
1763
 
1716
- /**
1717
- * Recursively builds a formatted HTML list from a JSON object.
1718
- * @param {object} obj - The object to format.
1719
- * @returns {string} HTML string for the formatted list.
1720
- */
1721
- function formatObject(obj) {
1722
- if (Array.isArray(obj)) {
1723
- if (obj.length === 0) {
1724
- return `<span style="color: var(--color-gray);">[]</span>`;
1725
- }
1726
-
1727
- return (
1728
- '<div class="pl-4">' +
1729
- obj
1730
- .map(
1731
- (value, index) =>
1732
- `<div class="mt-1"><span class="capitalize font-semibold" style="color: var(--color-fg-lightest);">[${index}]:</span> ${formatObject(value)}</div>`,
1733
- )
1734
- .join("") +
1735
- "</div>"
1736
- );
1737
- }
1738
-
1739
- if (obj === null || typeof obj !== "object") {
1740
- if (typeof obj === "boolean") {
1741
- return `<span class="font-semibold" style="color: ${obj ? "var(--color-green)" : "var(--color-red)"}">${escapeHtml(String(obj))}</span>`;
1742
- }
1743
-
1744
- return `<span style="color: var(--color-blue);">${escapeHtml(JSON.stringify(obj))}</span>`;
1745
- }
1746
-
1747
- const entries = Object.entries(obj);
1748
- if (entries.length === 0) {
1749
- return `<span style="color: var(--color-gray);">{}</span>`;
1750
- }
1751
-
1752
- return (
1753
- '<div class="pl-4">' +
1754
- entries
1755
- .map(([key, value]) => {
1756
- const formattedKey = escapeHtml(key.replace(/_/g, " "));
1757
- return `<div class="mt-1">
1758
- <span class="capitalize font-semibold" style="color: var(--color-fg-lightest);">${formattedKey}:</span>
1759
- ${formatObject(value)}
1760
- </div>`;
1761
- })
1762
- .join("") +
1763
- "</div>"
1764
- );
1765
- }
1766
-
1767
- /**
1768
- * Renders the section with the full, formatted API response.
1769
- * @param {object} data - The full API response data.
1770
- * @returns {string} HTML string for the full data section.
1771
- */
1772
- function renderDetailedData(data) {
1773
- if (data === null || typeof data === "undefined") {
1774
- return "";
1775
- }
1776
-
1777
- const formattedDetails = formatObject(data);
1778
- return `
1779
- <section id="detailed-data" class="dashboard-section">
1780
- <h2 class="section-heading">
1781
- ${renderCollapseToggle(
1782
- "usageApiResponse",
1783
- `<i data-lucide="file-text" class="h-5 w-5" style="color: var(--color-blue);"></i><span>Copilot Usage API Response</span>`
1784
- )}
1785
- </h2>
1786
- ${renderCollapseBody(
1787
- "usageApiResponse",
1788
- `<div class="panel p-4 relative font-mono text-xs code-block overflow-auto" style="max-height: 420px;">
1789
- ${formattedDetails}
1790
- </div>`
1791
- )}
1792
- </section>
1793
- `;
1764
+ function renderSubscriptionSummary(usage) {
1765
+ const main = usage.windows.find((window) => window.kind === "usage") || usage.windows[0];
1766
+ const credits = usage.credits;
1767
+ const showCredits = credits && (credits.unlimited || (credits.hasCredits && credits.balance !== undefined) || !main);
1768
+ const value = showCredits ? credits.unlimited ? "∞"
1769
+ : formatSubscriptionAmount(credits.balance, credits.unit)
1770
+ : main?.unlimited ? "∞"
1771
+ : Number.isFinite(main?.usedPercent) ? `${formatSubscriptionAmount(Math.max(0, Math.min(100, 100 - main.usedPercent)))}%` : "—";
1772
+ const label = showCredits ? credits.unit === "USD" ? "Prepaid balance" : "Credits" : "Remaining allowance";
1773
+ return `<div class="quota-summary"><p class="quota-summary-label">${label}</p><div class="quota-summary-value">${value}</div><p class="quota-summary-context">${showCredits ? credits.unlimited ? "Unlimited" : "Available balance" : main ? escapeHtml(subscriptionQuotaLabel(main)) : "Included allowance"}</p></div>`;
1774
+ }
1775
+
1776
+ function renderSubscriptionUsageSection() {
1777
+ const ids = Object.keys(SUBSCRIPTION_PROVIDERS).filter((provider) => state.subscriptionUsage?.[provider] || state.subscriptionUsageErrors?.[provider] || state.subscriptionLoadingProviders?.[provider]);
1778
+ const cards = ids.map((provider) => {
1779
+ const meta = SUBSCRIPTION_PROVIDERS[provider];
1780
+ const usage = state.subscriptionUsage?.[provider];
1781
+ const failed = state.subscriptionUsageErrors?.[provider];
1782
+ const loading = state.subscriptionLoadingProviders?.[provider];
1783
+ const credits = usage?.credits;
1784
+ let balance = "—";
1785
+ if (credits?.unlimited) balance = "Unlimited";
1786
+ else if (Number.isFinite(credits?.balance)) balance = formatSubscriptionAmount(credits.balance, credits.unit);
1787
+ else if (credits?.hasCredits) balance = "Available";
1788
+ let limitMessage = "Usage limit reached";
1789
+ if (usage?.limitReason === "workspace_owner_credits_depleted" || usage?.limitReason === "workspace_member_credits_depleted") limitMessage = "Workspace credits are depleted";
1790
+ if (usage?.limitReason === "workspace_owner_usage_limit_reached" || usage?.limitReason === "workspace_member_usage_limit_reached") limitMessage = "Workspace spending limit reached";
1791
+ const stateName = loading ? "loading" : failed || usage?.limitReached ? "warning" : "ready";
1792
+ const status = loading ? "Loading…" : failed ? "Could not load" : usage?.limitReached ? "Limit reached" : "Available";
1793
+ return `
1794
+ <section class="quota-provider-card" data-provider="${provider}" aria-label="${meta.name} subscription quota">
1795
+ <div class="quota-card-header"><span class="quota-provider-logo"><img src="${meta.logo}" alt="" /></span><div style="min-width: 0; flex: 1;"><h3>${meta.name}</h3><p class="quota-provider-product">${meta.product}</p></div><button class="quota-refresh" type="button" data-quota-refresh="${provider}" aria-label="Refresh ${meta.name} quota" title="Refresh" ${loading ? "disabled" : ""}><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5" /><path d="M6.1 7a7 7 0 0 1 11.6-2L20 8M4 16l2.3 3A7 7 0 0 0 18 17" /></svg></button></div>
1796
+ <div class="quota-card-meta"><span class="quota-plan">${escapeHtml(usage?.planType || meta.product)}</span><span class="quota-status" data-state="${stateName}"><span></span>${status}</span></div>
1797
+ ${loading ? `<div class="quota-skeleton" aria-label="Loading"><div></div><div></div><div></div></div>` : failed ? `<p role="alert" class="quota-card-message">Could not load quota. Refresh to retry.</p>` : `${renderSubscriptionSummary(usage)}<div class="quota-details">${usage.limitReached ? `<p class="quota-notice">${limitMessage}</p>` : ""}${usage.windows.map(renderSubscriptionQuotaWindow).join("")}${credits && usage.windows.length ? `<div class="quota-balance"><span>${credits.unit === "USD" ? "Prepaid balance" : "Credits"}</span><strong>${balance}</strong></div>` : ""}${!usage.windows.length && !credits && !usage.limitReached ? `<p class="quota-window-note">No quota details reported.</p>` : ""}</div>`}
1798
+ <div class="quota-card-footer">Subscription allowance · Refresh anytime</div>
1799
+ </section>
1800
+ `;
1801
+ }).join("");
1802
+ const empty = state.subscriptionPageError || (state.subscriptionsLoaded ? "No subscriptions available. Enable a subscription provider and refresh." : "Refresh to load your subscription allowances.");
1803
+ return `<section id="subscription-quotas" class="subscriptions-page"><div class="subscriptions-page-heading"><div><p class="subscriptions-eyebrow">ACCOUNT USAGE</p><h2>Subscriptions</h2><p class="subscriptions-description">Your subscriptions, balances and reset times in one place.</p></div>${ids.length ? `<span class="subscriptions-count"><span></span>${ids.length} providers</span>` : ""}</div>${cards ? `<div class="subscriptions-grid">${cards}</div>` : `<div class="subscriptions-empty"><h3>Your subscriptions will appear here</h3><p>${escapeHtml(empty)}</p></div>`}</section>`;
1794
1804
  }
1795
1805
 
1796
1806
  function renderTokenUsageSection() {
@@ -2685,111 +2695,72 @@
2685
2695
  <div class="panel text-center py-16 px-4">
2686
2696
  <i data-lucide="info" class="mx-auto h-10 w-10" style="color: var(--color-blue);"></i>
2687
2697
  <h3 class="mt-3 text-lg font-bold" style="color: var(--color-fg-lightest);">Usage data is ready to load</h3>
2688
- <p class="mt-2 text-sm" style="color: var(--color-gray);">Refresh the configured endpoint to view Copilot quotas, token totals, trends, and request events.</p>
2698
+ <p class="mt-2 text-sm" style="color: var(--color-gray);">Refresh the configured endpoint to view token totals, trends, and request events. Open Subscriptions for account allowances.</p>
2689
2699
  </div>
2690
2700
  `;
2691
2701
  }
2692
2702
 
2693
2703
  // --- Data Fetching ---
2694
2704
 
2705
+ async function refreshSubscriptionQuotas(provider) {
2706
+ syncSubscriptionsConnection();
2707
+ try {
2708
+ if (!endpointUrlInput.value.trim()) throw new Error("Endpoint URL cannot be empty.");
2709
+ await fetchSubscriptionQuotas(resolveUsageUrl(endpointUrlInput.value.trim()), provider);
2710
+ } catch (error) {
2711
+ state.subscriptionPageError = getErrorMessage(error);
2712
+ render("subscriptions");
2713
+ }
2714
+ }
2715
+
2716
+ async function fetchSubscriptionQuotas(usageUrl, selectedProvider) {
2717
+ if (selectedProvider && !Object.hasOwn(SUBSCRIPTION_PROVIDERS, selectedProvider)) return;
2718
+ if (!selectedProvider) {
2719
+ state.subscriptionRequestId += 1;
2720
+ state.subscriptionUsage = {};
2721
+ state.subscriptionUsageErrors = {};
2722
+ state.subscriptionLoadingProviders = {};
2723
+ }
2724
+ const requestId = state.subscriptionRequestId;
2725
+ const connectionKey = getConnectionKey();
2726
+ state.subscriptionPageError = null;
2727
+ const providers = selectedProvider ? [selectedProvider] : Object.keys(SUBSCRIPTION_PROVIDERS);
2728
+ await Promise.all(providers.map(async (provider) => {
2729
+ const revision = (state.subscriptionRequests[provider] || 0) + 1;
2730
+ state.subscriptionRequests[provider] = revision;
2731
+ delete state.subscriptionUsage[provider];
2732
+ delete state.subscriptionUsageErrors[provider];
2733
+ state.subscriptionLoadingProviders[provider] = true;
2734
+ state.isSubscriptionsLoading = true;
2735
+ render("subscriptions");
2736
+ const isCurrent = () => requestId === state.subscriptionRequestId
2737
+ && connectionKey === getConnectionKey() && revision === state.subscriptionRequests[provider];
2738
+ try {
2739
+ const usage = await fetchJson(deriveEndpointUrl(usageUrl, `usage/${provider}`));
2740
+ if (!isCurrent()) return;
2741
+ if (usage?.provider !== provider || !Array.isArray(usage.windows)) throw new Error("Invalid subscription quota response");
2742
+ state.subscriptionUsage[provider] = usage;
2743
+ } catch (error) {
2744
+ if (!isCurrent()) return;
2745
+ if (error?.status !== 404) state.subscriptionUsageErrors[provider] = true;
2746
+ } finally {
2747
+ if (isCurrent()) {
2748
+ delete state.subscriptionLoadingProviders[provider];
2749
+ state.isSubscriptionsLoading = Object.keys(state.subscriptionLoadingProviders).length > 0;
2750
+ state.subscriptionsLoaded = true;
2751
+ render("subscriptions");
2752
+ }
2753
+ }
2754
+ }));
2755
+ }
2756
+
2695
2757
  /**
2696
2758
  * Fetches usage data and token usage data from the specified API endpoint.
2697
2759
  */
2698
2760
  async function fetchData(page = 1) {
2699
- invalidateSessionsRequest();
2700
- const endpoint = endpointUrlInput.value.trim();
2701
- if (!endpoint) {
2702
- state.data = null;
2703
- state.tokenUsageDailySummary = null;
2704
- state.tokenUsageSummary = null;
2705
- state.tokenUsageEventsPage = null;
2706
- state.error = "Endpoint URL cannot be empty.";
2707
- state.tokenUsageDailyError =
2708
- "Token usage requires a valid endpoint URL.";
2709
- state.tokenUsageSummaryError =
2710
- "Token usage requires a valid endpoint URL.";
2711
- state.tokenUsageEventsError = null;
2712
- render();
2713
- return;
2714
- }
2715
-
2716
- const period = getSelectedPeriod();
2717
2761
  state.isLoading = true;
2718
- state.error = null;
2719
- state.tokenUsageDailyError = null;
2720
- state.tokenUsageSummaryError = null;
2721
- state.tokenUsageEventsError = null;
2722
- render();
2723
-
2724
2762
  try {
2725
- const usageUrl = resolveUsageUrl(endpoint);
2726
- const [usageResult, summaryResult, dailyResult, eventsResult] =
2727
- await Promise.allSettled([
2728
- fetchJson(usageUrl),
2729
- fetchJson(buildTokenUsageSummaryUrl(usageUrl, period)),
2730
- fetchJson(buildTokenUsageDailyUrl(usageUrl, period)),
2731
- fetchJson(buildTokenUsageEventsUrl(usageUrl, period, page)),
2732
- ]);
2733
-
2734
- if (usageResult.status === "fulfilled") {
2735
- state.data = usageResult.value;
2736
- } else {
2737
- state.data = null;
2738
- state.error = getErrorMessage(usageResult.reason);
2739
- }
2740
-
2741
- if (summaryResult.status === "fulfilled") {
2742
- state.tokenUsageSummary = summaryResult.value;
2743
- void fetchTokenUsageSessionsPage(1);
2744
- } else if (
2745
- isMissingTokenUsageEndpointError(summaryResult.reason)
2746
- ) {
2747
- state.tokenUsageSummary = null;
2748
- state.tokenUsageSummaryError = null;
2749
- } else {
2750
- state.tokenUsageSummary = null;
2751
- state.tokenUsageSummaryError = getErrorMessage(
2752
- summaryResult.reason
2753
- );
2754
- }
2755
-
2756
- if (dailyResult.status === "fulfilled") {
2757
- state.tokenUsageDailySummary = dailyResult.value;
2758
- } else if (
2759
- isMissingTokenUsageEndpointError(dailyResult.reason)
2760
- ) {
2761
- state.tokenUsageDailySummary = null;
2762
- state.tokenUsageDailyError = null;
2763
- } else {
2764
- state.tokenUsageDailySummary = null;
2765
- state.tokenUsageDailyError = getErrorMessage(
2766
- dailyResult.reason
2767
- );
2768
- }
2769
-
2770
- if (eventsResult.status === "fulfilled") {
2771
- state.tokenUsageEventsPage = eventsResult.value;
2772
- } else if (
2773
- isMissingTokenUsageEndpointError(eventsResult.reason)
2774
- ) {
2775
- state.tokenUsageEventsPage = null;
2776
- state.tokenUsageEventsError = null;
2777
- } else {
2778
- state.tokenUsageEventsPage = null;
2779
- state.tokenUsageEventsError = getErrorMessage(
2780
- eventsResult.reason
2781
- );
2782
- }
2783
- } catch (error) {
2784
- console.error("Fetch error:", error);
2785
- state.data = null;
2786
- state.tokenUsageDailySummary = null;
2787
- state.tokenUsageSummary = null;
2788
- state.tokenUsageEventsPage = null;
2789
- state.error = getErrorMessage(error);
2790
- state.tokenUsageDailyError = getErrorMessage(error);
2791
- state.tokenUsageSummaryError = getErrorMessage(error);
2792
- state.tokenUsageEventsError = getErrorMessage(error);
2763
+ await fetchTokenUsageSummaryAndEvents(page);
2793
2764
  } finally {
2794
2765
  state.isLoading = false;
2795
2766
  render("usage");
@@ -2982,6 +2953,8 @@
2982
2953
  syncUrlState();
2983
2954
  if (state.activeTab === "models") {
2984
2955
  void fetchModels();
2956
+ } else if (state.activeTab === "subscriptions") {
2957
+ void refreshSubscriptionQuotas();
2985
2958
  } else {
2986
2959
  void fetchData();
2987
2960
  }
@@ -2996,6 +2969,11 @@
2996
2969
  if (!(event.target instanceof Element)) {
2997
2970
  return;
2998
2971
  }
2972
+ const quotaRefresh = event.target.closest("[data-quota-refresh]");
2973
+ if (quotaRefresh) {
2974
+ void refreshSubscriptionQuotas(quotaRefresh.dataset.quotaRefresh);
2975
+ return;
2976
+ }
2999
2977
  const copyButton = event.target.closest("[data-model-copy]");
3000
2978
  if (copyButton) {
3001
2979
  void copyModelId(copyButton.dataset.modelCopy);
@@ -3129,6 +3107,8 @@
3129
3107
  const endpointFromUrl = urlParams.get("endpoint");
3130
3108
  const initialEndpoint = endpointFromUrl || DEFAULT_ENDPOINT;
3131
3109
 
3110
+ const initialTab = urlParams.get("tab");
3111
+ if (["usage", "subscriptions", "models"].includes(initialTab)) state.activeTab = initialTab;
3132
3112
  state.tokenUsagePeriod = periodFromUrl;
3133
3113
  tokenUsagePeriodSelect.value = periodFromUrl;
3134
3114
  endpointUrlInput.value = initialEndpoint;
@@ -3137,7 +3117,9 @@
3137
3117
  state.collapsedSections = readCollapsedSections();
3138
3118
 
3139
3119
  if (endpointFromUrl) {
3140
- void fetchData();
3120
+ if (state.activeTab === "subscriptions") void refreshSubscriptionQuotas();
3121
+ else if (state.activeTab === "models") void fetchModels();
3122
+ else void fetchData();
3141
3123
  } else {
3142
3124
  render();
3143
3125
  }