@openhoo/hoopilot 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2220,6 +2220,7 @@ var MetricsRegistry = class {
2220
2220
  #upstream = /* @__PURE__ */ new Map();
2221
2221
  #copilotQuota;
2222
2222
  #githubRateLimit = /* @__PURE__ */ new Map();
2223
+ #extraction = { extracted: 0, missing: 0 };
2223
2224
  constructor(options = {}) {
2224
2225
  this.#startedAtMs = (options.now ?? Date.now)();
2225
2226
  }
@@ -2236,6 +2237,19 @@ var MetricsRegistry = class {
2236
2237
  this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
2237
2238
  this.#observeDuration(observation.route, observation.durationMs / 1e3);
2238
2239
  }
2240
+ /**
2241
+ * Record whether one upstream completion reported token usage. `missing`
2242
+ * counts responses that carried no usage object — most often streamed Chat
2243
+ * Completions sent without `stream_options: {"include_usage": true}` — so a
2244
+ * rising miss rate flags clients whose token usage is going unaccounted.
2245
+ */
2246
+ recordTokenExtraction(extracted) {
2247
+ if (extracted) {
2248
+ this.#extraction.extracted += 1;
2249
+ } else {
2250
+ this.#extraction.missing += 1;
2251
+ }
2252
+ }
2239
2253
  /** Accumulate token counts for a model from one upstream completion. */
2240
2254
  recordTokens(model, usage) {
2241
2255
  const name = this.#modelLabel(model);
@@ -2341,13 +2355,45 @@ var MetricsRegistry = class {
2341
2355
  return {
2342
2356
  githubRateLimit,
2343
2357
  inFlight: this.#inFlight,
2358
+ latency: this.#latencySnapshot(),
2344
2359
  requests: { byRoute, byStatus, total: requestsTotal },
2345
2360
  startedAt: new Date(this.#startedAtMs).toISOString(),
2346
- tokens: { byModel, ...tokenTotals },
2361
+ tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
2347
2362
  upstream: { errors: upstreamErrors, total: upstreamTotal },
2348
2363
  uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
2349
2364
  };
2350
2365
  }
2366
+ // Summarize the duration histogram into a JSON latency view: per-route count and
2367
+ // exact average, plus overall average and estimated p50/p95. The percentiles come
2368
+ // from the buckets aggregated across routes, so they share /metrics' resolution.
2369
+ #latencySnapshot() {
2370
+ const byRoute = {};
2371
+ const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
2372
+ let totalCount = 0;
2373
+ let totalSum = 0;
2374
+ for (const [route, entry] of this.#durations) {
2375
+ byRoute[route] = {
2376
+ avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
2377
+ count: entry.count
2378
+ };
2379
+ totalCount += entry.count;
2380
+ totalSum += entry.sum;
2381
+ for (let i = 0; i < aggregateBuckets.length; i += 1) {
2382
+ aggregateBuckets[i] = (aggregateBuckets[i] ?? 0) + (entry.buckets[i] ?? 0);
2383
+ }
2384
+ }
2385
+ return {
2386
+ avgMs: totalCount > 0 ? round2(totalSum / totalCount * 1e3) : 0,
2387
+ byRoute,
2388
+ count: totalCount,
2389
+ p50Ms: round2(
2390
+ quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.5) * 1e3
2391
+ ),
2392
+ p95Ms: round2(
2393
+ quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.95) * 1e3
2394
+ )
2395
+ };
2396
+ }
2351
2397
  /** Render the Prometheus text exposition format (version 0.0.4). */
2352
2398
  renderPrometheus(now = Date.now) {
2353
2399
  const lines = [];
@@ -2393,6 +2439,16 @@ var MetricsRegistry = class {
2393
2439
  for (const [model, totals] of this.#tokens) {
2394
2440
  lines.push(`hoopilot_model_requests_total${labels({ model })} ${totals.requests}`);
2395
2441
  }
2442
+ lines.push(
2443
+ "# HELP hoopilot_token_extraction_total Completions by whether upstream reported token usage."
2444
+ );
2445
+ lines.push("# TYPE hoopilot_token_extraction_total counter");
2446
+ lines.push(
2447
+ `hoopilot_token_extraction_total${labels({ outcome: "extracted" })} ${this.#extraction.extracted}`
2448
+ );
2449
+ lines.push(
2450
+ `hoopilot_token_extraction_total${labels({ outcome: "missing" })} ${this.#extraction.missing}`
2451
+ );
2396
2452
  lines.push("# HELP hoopilot_request_duration_seconds Request duration by route.");
2397
2453
  lines.push("# TYPE hoopilot_request_duration_seconds histogram");
2398
2454
  for (const [route, entry] of this.#durations) {
@@ -2548,23 +2604,25 @@ var MetricsRegistry = class {
2548
2604
  }
2549
2605
  }
2550
2606
  };
2551
- function observeResponseUsage(response, fallbackModel, onUsage, signal) {
2607
+ function observeResponseUsage(response, fallbackModel, onUsage, signal, onOutcome) {
2552
2608
  const body = response.body;
2553
2609
  if (!body) {
2554
2610
  return response;
2555
2611
  }
2556
2612
  const [clientBranch, observerBranch] = body.tee();
2557
2613
  const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
2558
- void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal).catch(() => {
2559
- });
2614
+ void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal, onOutcome).catch(
2615
+ () => {
2616
+ }
2617
+ );
2560
2618
  return new Response(clientBranch, {
2561
2619
  headers: response.headers,
2562
2620
  status: response.status,
2563
2621
  statusText: response.statusText
2564
2622
  });
2565
2623
  }
2566
- function recordResponseTextUsage(text, isSse, fallbackModel, onUsage) {
2567
- const accumulator = createUsageAccumulator(fallbackModel, onUsage);
2624
+ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome) {
2625
+ const accumulator = createUsageAccumulator(fallbackModel, onUsage, onOutcome);
2568
2626
  if (isSse) {
2569
2627
  for (const line of text.split(/\r?\n/)) {
2570
2628
  considerSseLine(line, accumulator.consider);
@@ -2577,7 +2635,7 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage) {
2577
2635
  }
2578
2636
  accumulator.finish();
2579
2637
  }
2580
- async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal) {
2638
+ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
2581
2639
  const reader = stream.getReader();
2582
2640
  const onAbort = () => {
2583
2641
  reader.cancel().catch(() => {
@@ -2590,7 +2648,12 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal) {
2590
2648
  signal?.addEventListener("abort", onAbort, { once: true });
2591
2649
  }
2592
2650
  const decoder = new TextDecoder();
2593
- const accumulator = createUsageAccumulator(fallbackModel, onUsage);
2651
+ const guardedOutcome = onOutcome ? (extracted) => {
2652
+ if (!signal?.aborted) {
2653
+ onOutcome(extracted);
2654
+ }
2655
+ } : void 0;
2656
+ const accumulator = createUsageAccumulator(fallbackModel, onUsage, guardedOutcome);
2594
2657
  let buffer = "";
2595
2658
  let bufferedBytes = 0;
2596
2659
  let overflowed = false;
@@ -2638,7 +2701,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal) {
2638
2701
  }
2639
2702
  accumulator.finish();
2640
2703
  }
2641
- function createUsageAccumulator(fallbackModel, onUsage) {
2704
+ function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
2642
2705
  let model = fallbackModel;
2643
2706
  let usage;
2644
2707
  return {
@@ -2657,6 +2720,7 @@ function createUsageAccumulator(fallbackModel, onUsage) {
2657
2720
  if (usage) {
2658
2721
  onUsage(model, usage);
2659
2722
  }
2723
+ onOutcome?.(usage !== void 0);
2660
2724
  }
2661
2725
  };
2662
2726
  }
@@ -2687,6 +2751,26 @@ function modelText(value) {
2687
2751
  function nonNegative(value) {
2688
2752
  return Number.isFinite(value) && value > 0 ? value : 0;
2689
2753
  }
2754
+ function round2(value) {
2755
+ return Math.round(value * 100) / 100;
2756
+ }
2757
+ function quantileFromBuckets(bucketCounts, bounds, count, q) {
2758
+ if (count <= 0) {
2759
+ return 0;
2760
+ }
2761
+ const rank = q * count;
2762
+ let cumulative = 0;
2763
+ for (let i = 0; i < bounds.length; i += 1) {
2764
+ const inBucket = bucketCounts[i] ?? 0;
2765
+ if (inBucket > 0 && cumulative + inBucket >= rank) {
2766
+ const lower = i === 0 ? 0 : bounds[i - 1] ?? 0;
2767
+ const upper = bounds[i] ?? lower;
2768
+ return lower + (upper - lower) * ((rank - cumulative) / inBucket);
2769
+ }
2770
+ cumulative += inBucket;
2771
+ }
2772
+ return bounds[bounds.length - 1] ?? 0;
2773
+ }
2690
2774
  function cleanLabel(value) {
2691
2775
  let result = "";
2692
2776
  for (const char of value) {
@@ -2736,9 +2820,834 @@ function formatNumber(value) {
2736
2820
  return Number.isInteger(value) ? value.toString() : String(value);
2737
2821
  }
2738
2822
 
2823
+ // src/dashboard.ts
2824
+ var DASHBOARD_HTML = `<!doctype html>
2825
+ <html lang="en">
2826
+ <head>
2827
+ <meta charset="utf-8" />
2828
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2829
+ <meta name="color-scheme" content="dark light" />
2830
+ <title>hoopilot &middot; dashboard</title>
2831
+ <style>
2832
+ :root {
2833
+ --bg-0:#0b0e14; --bg-1:#11151c; --bg-2:#171c25; --bg-3:#1f2630;
2834
+ --border:#262d38; --border-strong:#37404d;
2835
+ --text-0:#e6edf3; --text-1:#9aa7b4; --text-2:#5e6b78; --text-dim:#3a434e; --text-inv:#0b0e14;
2836
+ --accent:#4ea1ff; --accent-2:#56d4dd; --accent-soft:rgba(78,161,255,.14);
2837
+ --amber:#f5b042;
2838
+ --ok:#3fb950; --warn:#d8a13a; --danger:#f0556a; --info:#a371f7; --cache:#7c8cff;
2839
+ --spark:#4ea1ff; --spark-fill:color-mix(in srgb, var(--accent) 14%, transparent);
2840
+ --grid-line:rgba(255,255,255,.05);
2841
+ --flash:color-mix(in srgb, var(--accent) 22%, transparent);
2842
+ --flash-up:color-mix(in srgb, var(--ok) 22%, transparent);
2843
+ --flash-down:color-mix(in srgb, var(--danger) 22%, transparent);
2844
+ --c1:#4ea1ff; --c2:#3fb950; --c3:#d8a13a; --c4:#a371f7; --c5:#56d4dd; --c6:#f0556a;
2845
+ --mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
2846
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, system-ui, sans-serif;
2847
+ }
2848
+ @media (prefers-color-scheme: light) {
2849
+ :root:not([data-theme="dark"]) {
2850
+ --bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
2851
+ --border:#d0d7de; --border-strong:#b6bec8;
2852
+ --text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
2853
+ --accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
2854
+ --amber:#b5730a;
2855
+ --ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
2856
+ --spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
2857
+ --grid-line:rgba(0,0,0,.06);
2858
+ --flash:color-mix(in srgb, var(--accent) 16%, transparent);
2859
+ --flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
2860
+ --flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
2861
+ --c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
2862
+ }
2863
+ }
2864
+ [data-theme="light"] {
2865
+ --bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
2866
+ --border:#d0d7de; --border-strong:#b6bec8;
2867
+ --text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
2868
+ --accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
2869
+ --amber:#b5730a;
2870
+ --ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
2871
+ --spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
2872
+ --grid-line:rgba(0,0,0,.06);
2873
+ --flash:color-mix(in srgb, var(--accent) 16%, transparent);
2874
+ --flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
2875
+ --flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
2876
+ --c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
2877
+ }
2878
+ * { box-sizing: border-box; }
2879
+ html, body { margin:0; padding:0; }
2880
+ body {
2881
+ background: var(--bg-0); color: var(--text-0); font-family: var(--sans);
2882
+ font-size: 13px; line-height: 1.4; -webkit-font-smoothing: antialiased;
2883
+ }
2884
+ .mono { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
2885
+ .num { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
2886
+ .shell { max-width: 1280px; margin: 0 auto; padding: 0 24px 28px; }
2887
+ @media (min-width: 1080px) { .shell { border-left:1px solid var(--border); border-right:1px solid var(--border); } }
2888
+ @media (max-width: 680px) { .shell { padding: 0 12px 24px; } }
2889
+
2890
+ /* header */
2891
+ header.bar {
2892
+ position: sticky; top: 0; z-index: 20; background: var(--bg-1);
2893
+ border-bottom: 1px solid var(--border); height: 48px;
2894
+ }
2895
+ .bar-in { max-width:1280px; margin:0 auto; height:48px; padding:0 24px; display:flex; align-items:center; gap:12px; }
2896
+ @media (max-width:680px){ .bar-in{ padding:0 12px; gap:8px; } }
2897
+ .wordmark { font-family: var(--mono); font-weight:700; font-size:14px; color:var(--text-0); letter-spacing:-.01em; }
2898
+ .caret { display:inline-block; width:7px; height:15px; background:var(--amber); margin-left:3px; vertical-align:-2px; animation: blink 1.1s steps(1) infinite; }
2899
+ .chip { font-family: var(--mono); font-size:11px; padding:2px 7px; border-radius:10px; background:var(--bg-3); color:var(--text-1); white-space:nowrap; }
2900
+ .chip.plan-pro { background:var(--accent-soft); color:var(--accent); }
2901
+ .chip.plan-business { background:color-mix(in srgb, var(--info) 16%, transparent); color:var(--info); }
2902
+ .chip.plan-free, .chip.plan-offline { background:var(--bg-3); color:var(--text-2); }
2903
+ .spacer { flex:1; }
2904
+ .pill { display:inline-flex; align-items:center; gap:6px; font-size:11px; font-family:var(--mono); padding:3px 9px; border-radius:11px; background:var(--bg-3); color:var(--text-1); }
2905
+ .dot { width:7px; height:7px; border-radius:50%; background:var(--text-2); flex:none; }
2906
+ .pill.live .dot { background:var(--ok); }
2907
+ .pill.paused .dot { background:var(--text-2); }
2908
+ .pill.reconnect { color:var(--warn); } .pill.reconnect .dot { background:var(--warn); }
2909
+ .pill.authkey { color:var(--warn); } .pill.authkey .dot { background:var(--warn); }
2910
+ .heartbeat { animation: hb .5s ease-out; }
2911
+ .updated { font-family:var(--mono); font-size:11px; color:var(--text-2); white-space:nowrap; }
2912
+ .updated.warn { color:var(--warn); } .updated.danger { color:var(--danger); }
2913
+ .seg { display:inline-flex; border:1px solid var(--border); border-radius:6px; overflow:hidden; }
2914
+ .seg button { background:transparent; color:var(--text-1); border:0; font-family:var(--mono); font-size:11px; padding:3px 8px; cursor:pointer; }
2915
+ .seg button + button { border-left:1px solid var(--border); }
2916
+ .seg button.active { background:var(--accent); color:var(--text-inv); }
2917
+ .iconbtn { background:transparent; border:1px solid var(--border); border-radius:6px; color:var(--text-1); cursor:pointer; font-size:13px; line-height:1; padding:4px 7px; min-width:30px; }
2918
+ .iconbtn:hover { background:var(--bg-3); }
2919
+ button:focus-visible, input:focus-visible, .seg button:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
2920
+ #scanbar { position:absolute; left:0; bottom:-1px; height:1px; width:100%; overflow:hidden; }
2921
+ #scanbar::after { content:""; position:absolute; left:0; top:0; height:1px; width:40%;
2922
+ background:linear-gradient(90deg, transparent, var(--accent), transparent);
2923
+ animation: scan var(--scan-ms, 4000ms) linear infinite; }
2924
+ header.bar.paused #scanbar::after, header.bar.frozen #scanbar::after { animation-play-state:paused; opacity:.35; }
2925
+
2926
+ /* disconnect banner */
2927
+ #banner { display:none; margin-top:10px; padding:7px 12px; border-radius:5px; font-family:var(--mono); font-size:12px;
2928
+ background:color-mix(in srgb, var(--danger) 16%, transparent); color:var(--danger); border:1px solid color-mix(in srgb, var(--danger) 40%, transparent); }
2929
+ #banner.ok { background:color-mix(in srgb, var(--ok) 16%, transparent); color:var(--ok); border-color:color-mix(in srgb, var(--ok) 40%, transparent); }
2930
+ #banner.show { display:block; }
2931
+
2932
+ /* hero strip */
2933
+ .hero { display:grid; grid-template-columns:repeat(4,1fr); margin:18px 0 16px; }
2934
+ .vital { padding:6px 18px; }
2935
+ .vital + .vital { border-left:1px solid var(--border); }
2936
+ .vital .eyebrow { font-size:10px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-1); }
2937
+ .vital .vnum { font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-weight:600; font-size:clamp(2rem,5vw,3.25rem); line-height:1.02; letter-spacing:-.02em; color:var(--text-0); }
2938
+ .vital .vsub { font-size:11px; color:var(--text-2); min-height:14px; }
2939
+ .vital .vspark { display:block; width:100%; height:24px; margin-top:4px; }
2940
+ .vital.active { }
2941
+ .vital.active .eyebrow { color:var(--accent); }
2942
+ @media (max-width:1079px){ .hero{ grid-template-columns:repeat(2,1fr); } .vital:nth-child(3){ border-left:0; } .vital:nth-child(n+3){ border-top:1px solid var(--border); padding-top:12px; } }
2943
+ @media (max-width:600px){ .hero{ grid-template-columns:1fr; } .vital + .vital{ border-left:0; border-top:1px solid var(--border); } }
2944
+
2945
+ /* grid + panels */
2946
+ .grid { display:grid; grid-template-columns:repeat(12,1fr); gap:12px; }
2947
+ .panel { position:relative; background:var(--bg-1); border:1px solid var(--border); border-radius:4px; padding:16px 12px 12px; min-width:0; }
2948
+ .panel > .ptitle { position:absolute; top:-8px; left:10px; padding:0 6px; background:var(--bg-1);
2949
+ font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
2950
+ .span5{ grid-column:span 5; } .span3{ grid-column:span 3; } .span4{ grid-column:span 4; }
2951
+ .span7{ grid-column:span 7; } .span8{ grid-column:span 8; }
2952
+ @media (max-width:1079px){ .grid{ grid-template-columns:repeat(6,1fr); }
2953
+ .span5,.span7,.span8{ grid-column:span 6; } .span3{ grid-column:span 3; } .span4{ grid-column:span 6; } }
2954
+ @media (max-width:680px){ .grid{ grid-template-columns:1fr; }
2955
+ .span3,.span4,.span5,.span7,.span8{ grid-column:span 1; } }
2956
+
2957
+ .headline { font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-weight:600; font-size:22px; line-height:1.1; }
2958
+ .cap { font-size:11px; color:var(--text-2); }
2959
+ .stack-bar { display:flex; height:8px; border-radius:4px; overflow:hidden; background:var(--bg-3); margin:8px 0; }
2960
+ .stack-bar i { display:block; height:100%; }
2961
+ .stack-bar.empty { outline:1px dashed var(--border); background:transparent; }
2962
+
2963
+ table.tbl { width:100%; border-collapse:collapse; font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-size:12px; }
2964
+ .scrollx { overflow-x:auto; }
2965
+ table.tbl th { font-size:10px; font-weight:600; text-transform:uppercase; color:var(--text-2); text-align:right; padding:4px 6px; border-bottom:1px solid var(--border); white-space:nowrap; }
2966
+ table.tbl th.l { text-align:left; }
2967
+ table.tbl td { padding:3px 6px; text-align:right; white-space:nowrap; border-bottom:1px solid color-mix(in srgb, var(--border) 55%, transparent); }
2968
+ table.tbl td.l { text-align:left; max-width:160px; overflow:hidden; text-overflow:ellipsis; }
2969
+ table.tbl tr:hover td { background:var(--bg-2); }
2970
+ table.tbl tr.total td { border-top:1px solid var(--border-strong); border-bottom:0; font-weight:600; color:var(--text-0); }
2971
+ .minibar { display:inline-block; height:6px; border-radius:3px; background:var(--accent); vertical-align:middle; min-width:1px; }
2972
+ .ghost td { color:var(--text-2); text-align:center; }
2973
+ .reasoning { color:var(--info); } .cached { color:var(--cache); }
2974
+
2975
+ .legend { display:flex; flex-wrap:wrap; gap:4px 14px; margin-top:8px; }
2976
+ .legend .li { display:flex; align-items:center; gap:6px; font-family:var(--mono); font-size:11px; color:var(--text-1); }
2977
+ .legend .sw { width:8px; height:8px; border-radius:2px; flex:none; }
2978
+
2979
+ .lat-trio { display:flex; gap:18px; align-items:baseline; }
2980
+ .lat-trio .b { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
2981
+ .lat-trio .b small { display:block; font-size:10px; font-weight:600; text-transform:uppercase; color:var(--text-2); letter-spacing:.05em; }
2982
+ .lat-p95 { color:var(--info); }
2983
+ .lat-track { position:relative; height:22px; margin-top:10px; }
2984
+ .lat-track .line { position:absolute; top:11px; left:0; right:0; height:1px; background:var(--border); }
2985
+ .lat-track .tick { position:absolute; top:5px; width:2px; height:12px; border-radius:1px; }
2986
+ .lat-track .tick.p50 { background:var(--accent); } .lat-track .tick.p95 { background:var(--info); }
2987
+ .lat-track .tlab { position:absolute; top:-2px; font-family:var(--mono); font-size:9px; color:var(--text-2); transform:translateX(-50%); }
2988
+ details.routes { margin-top:10px; } details.routes summary { cursor:pointer; font-size:11px; color:var(--text-2); font-family:var(--mono); }
2989
+
2990
+ .qrow { margin:10px 0; } .qrow .qhead { display:flex; justify-content:space-between; align-items:baseline; font-size:12px; }
2991
+ .qrow .qname { color:var(--text-1); } .qrow .qval { font-family:var(--mono); font-variant-numeric:tabular-nums; color:var(--text-0); }
2992
+ .qbar { position:relative; height:8px; border-radius:4px; background:var(--bg-3); margin-top:5px; overflow:hidden; }
2993
+ .qbar i { position:absolute; left:0; top:0; height:100%; border-radius:4px; }
2994
+ .qbar.over i.ext { background:repeating-linear-gradient(45deg, var(--danger), var(--danger) 3px, transparent 3px, transparent 6px); }
2995
+ .inf { font-family:var(--mono); font-size:12px; color:var(--ok); }
2996
+ .emptybox { border:1px solid var(--border); border-radius:5px; padding:14px; text-align:center; color:var(--text-2); }
2997
+ .emptybox .keyglyph { font-size:20px; color:var(--text-1); }
2998
+ .emptybox h4 { margin:8px 0 4px; font-family:var(--sans); font-size:13px; color:var(--text-1); font-weight:600; }
2999
+ .emptybox .errline { font-family:var(--mono); font-size:11px; color:var(--text-2); word-break:break-word; margin:4px 0; }
3000
+ .prompt { font-family:var(--mono); font-size:12px; color:var(--text-1); }
3001
+
3002
+ .upblocks { display:flex; gap:18px; }
3003
+ .upblk { } .upblk .v { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
3004
+ .upblk .k { font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-2); }
3005
+ .upblk.err.hot { color:var(--danger); }
3006
+ .rate { font-family:var(--mono); font-size:12px; } .rate.warn{ color:var(--warn);} .rate.danger{ color:var(--danger);} .rate.ok{ color:var(--ok); }
3007
+ #up-spark, #thru-svg { display:block; width:100%; }
3008
+ #up-spark { height:30px; margin-top:8px; }
3009
+ #thru-svg { height:88px; margin-top:6px; }
3010
+ .flag { font-family:var(--mono); font-size:10px; color:var(--text-2); }
3011
+
3012
+ footer.foot { margin-top:14px; padding-top:10px; border-top:1px solid var(--border); display:flex; flex-wrap:wrap; gap:4px 14px;
3013
+ font-family:var(--mono); font-size:11px; color:var(--text-2); }
3014
+ footer.foot .end { margin-left:auto; }
3015
+ @media (max-width:680px){ footer.foot .end{ margin-left:0; } }
3016
+
3017
+ .skel { color:var(--text-dim); }
3018
+ .flash { animation: flash .6s ease-out; } .flash-up { animation: flashup .6s ease-out; } .flash-down { animation: flashdown .6s ease-out; }
3019
+
3020
+ /* auth takeover */
3021
+ #auth { display:none; }
3022
+ #auth.show { display:flex; justify-content:center; padding:64px 16px; }
3023
+ .authcard { width:100%; max-width:420px; background:var(--bg-1); border:1px solid var(--border); border-radius:6px; padding:22px 18px; position:relative; }
3024
+ .authcard h3 { margin:0 0 10px; font-family:var(--mono); font-size:12px; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
3025
+ .authcard p { font-size:12px; color:var(--text-2); margin:0 0 14px; }
3026
+ .authcard .row { display:flex; gap:8px; }
3027
+ .authcard input { flex:1; background:var(--bg-0); border:1px solid var(--border); border-radius:5px; color:var(--text-0); font-family:var(--mono); font-size:13px; padding:8px 10px; }
3028
+ .authcard input.bad { border-color:var(--danger); }
3029
+ .authcard button { background:var(--accent); color:var(--text-inv); border:0; border-radius:5px; font-family:var(--mono); font-size:12px; padding:0 14px; cursor:pointer; }
3030
+ .authcard .err { color:var(--danger); font-family:var(--mono); font-size:11px; min-height:14px; margin-top:8px; }
3031
+ .authcard .clear { position:absolute; top:14px; right:16px; font-size:11px; color:var(--text-2); cursor:pointer; }
3032
+ .dim { opacity:.45; filter:grayscale(.4); transition:opacity .2s, filter .2s; }
3033
+
3034
+ @keyframes blink { 50% { opacity:0; } }
3035
+ @keyframes scan { 0%{ transform:translateX(-100%);} 100%{ transform:translateX(350%);} }
3036
+ @keyframes hb { 0%{ transform:scale(1);} 35%{ transform:scale(1.7);} 100%{ transform:scale(1);} }
3037
+ @keyframes flash { from{ background:var(--flash);} to{ background:transparent;} }
3038
+ @keyframes flashup { from{ background:var(--flash-up);} to{ background:transparent;} }
3039
+ @keyframes flashdown { from{ background:var(--flash-down);} to{ background:transparent;} }
3040
+ @media (prefers-reduced-motion: reduce) {
3041
+ .caret { animation:none; } #scanbar::after { animation:none; opacity:.3; }
3042
+ .heartbeat { animation:none; }
3043
+ .flash, .flash-up, .flash-down { animation:none; box-shadow: inset 2px 0 0 var(--accent); }
3044
+ }
3045
+ </style>
3046
+ </head>
3047
+ <body>
3048
+ <header class="bar" id="bar">
3049
+ <div class="bar-in">
3050
+ <span class="wordmark">hoopilot<span class="caret" aria-hidden="true"></span></span>
3051
+ <span class="chip" id="version-chip">v&middot;&middot;&middot;</span>
3052
+ <span class="chip plan-offline" id="plan-chip">&mdash; offline</span>
3053
+ <span class="spacer"></span>
3054
+ <span class="pill" id="conn-pill" aria-live="polite"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting</span></span>
3055
+ <span class="updated" id="updated"></span>
3056
+ <span class="seg" id="seg" role="group" aria-label="Refresh interval">
3057
+ <button data-ms="2000">2s</button><button data-ms="4000" class="active">4s</button><button data-ms="10000">10s</button>
3058
+ </span>
3059
+ <button class="iconbtn" id="btn-pause" title="Pause / resume" aria-label="Pause or resume">&#10074;&#10074;</button>
3060
+ <button class="iconbtn" id="btn-theme" title="Theme: auto / dark / light" aria-label="Cycle theme">A</button>
3061
+ </div>
3062
+ <div id="scanbar" aria-hidden="true"></div>
3063
+ </header>
3064
+
3065
+ <div class="shell">
3066
+ <div id="banner" role="status" aria-live="polite"></div>
3067
+
3068
+ <section id="content">
3069
+ <section class="hero" aria-label="Vitals">
3070
+ <div class="vital" id="v-req"><div class="eyebrow">Req / s</div><div class="vnum skel" id="req-num">&middot;&middot;&middot;</div><div class="vsub" id="req-sub"></div><svg class="vspark" id="req-spark" viewBox="0 0 200 24" preserveAspectRatio="none" aria-hidden="true"><path class="area" fill="var(--spark-fill)" stroke="none"/><path class="line" fill="none" stroke="var(--ok)" stroke-width="1.5" vector-effect="non-scaling-stroke"/><circle r="1.6" fill="var(--ok)" style="display:none"/></svg></div>
3071
+ <div class="vital" id="v-tok"><div class="eyebrow">Tokens / s</div><div class="vnum skel" id="tok-num">&middot;&middot;&middot;</div><div class="vsub" id="tok-sub"></div><svg class="vspark" id="tok-spark" viewBox="0 0 200 24" preserveAspectRatio="none" aria-hidden="true"><path class="area" fill="var(--spark-fill)" stroke="none"/><path class="line" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/><circle r="1.6" fill="var(--accent)" style="display:none"/></svg></div>
3072
+ <div class="vital" id="v-inflight"><div class="eyebrow">In&#8209;flight</div><div class="vnum skel" id="inflight-num">&middot;&middot;&middot;</div><div class="vsub" id="inflight-sub"></div><svg class="vspark" id="inflight-spark" viewBox="0 0 200 24" preserveAspectRatio="none" aria-hidden="true"><path class="area" fill="var(--spark-fill)" stroke="none"/><path class="line" fill="none" stroke="var(--accent-2)" stroke-width="1.5" vector-effect="non-scaling-stroke"/><circle r="1.6" fill="var(--accent-2)" style="display:none"/></svg></div>
3073
+ <div class="vital" id="v-uptime"><div class="eyebrow">Uptime</div><div class="vnum skel" id="uptime-num">&middot;&middot;&middot;</div><div class="vsub" id="uptime-sub"></div></div>
3074
+ </section>
3075
+
3076
+ <section class="grid">
3077
+ <div class="panel span5"><span class="ptitle">&#9508; Proxy &middot; requests &#9504;</span>
3078
+ <div class="headline"><span id="req-total" class="skel">&middot;&middot;&middot;</span> <span class="cap">requests</span></div>
3079
+ <div class="stack-bar empty" id="route-sharebar"></div>
3080
+ <div class="stack-bar empty" id="status-healthbar"></div>
3081
+ <div class="scrollx"><table class="tbl"><thead><tr><th class="l">Route</th><th>Count</th><th>%</th><th style="width:60px">&nbsp;</th></tr></thead><tbody id="routes-body"><tr class="ghost"><td colspan="4">loading&hellip;</td></tr></tbody></table></div>
3082
+ </div>
3083
+
3084
+ <div class="panel span3"><span class="ptitle">&#9508; Status &#9504;</span>
3085
+ <div class="headline"><span id="error-rate" class="skel">&middot;&middot;&middot;</span> <span class="cap">err rate</span></div>
3086
+ <div class="stack-bar empty" id="status-bar"></div>
3087
+ <div class="legend" id="status-legend"></div>
3088
+ </div>
3089
+
3090
+ <div class="panel span4"><span class="ptitle">&#9508; Latency &middot; ms &#9504;</span>
3091
+ <div class="lat-trio">
3092
+ <div class="b"><small>p50</small><span id="lat-p50" class="skel">&middot;</span></div>
3093
+ <div class="b lat-p95"><small>p95</small><span id="lat-p95" class="skel">&middot;</span></div>
3094
+ <div class="b"><small>avg</small><span id="lat-avg" class="skel">&middot;</span></div>
3095
+ <div class="b"><small>obs</small><span id="lat-count" class="skel">&middot;</span></div>
3096
+ </div>
3097
+ <div class="lat-track" id="lat-track"><div class="line"></div></div>
3098
+ <details class="routes"><summary>by route</summary><div class="scrollx"><table class="tbl"><thead><tr><th class="l">Route</th><th>avg ms</th><th>count</th></tr></thead><tbody id="lat-routes"></tbody></table></div></details>
3099
+ </div>
3100
+
3101
+ <div class="panel span7"><span class="ptitle">&#9508; Tokens &middot; by model &#9504;</span>
3102
+ <div class="headline"><span id="tok-total" class="skel">&middot;&middot;&middot;</span> <span class="cap">tokens &middot; <span id="tok-cache">cache &middot;%</span></span></div>
3103
+ <div class="stack-bar empty" id="tok-mixbar"></div>
3104
+ <div class="legend" id="tok-legend"></div>
3105
+ <div class="scrollx" style="margin-top:8px"><table class="tbl"><thead><tr><th class="l">Model</th><th>prompt</th><th>compl</th><th>reason</th><th>cached</th><th>total</th><th>reqs</th></tr></thead><tbody id="tok-body"><tr class="ghost"><td colspan="7">no token usage yet</td></tr></tbody></table></div>
3106
+ </div>
3107
+
3108
+ <div class="panel span5"><span class="ptitle">&#9508; Copilot &middot; quota &#9504;</span>
3109
+ <div id="copilot-body"><div class="emptybox skel">loading&hellip;</div></div>
3110
+ </div>
3111
+
3112
+ <div class="panel span4"><span class="ptitle">&#9508; Upstream &middot; copilot edge &#9504;</span>
3113
+ <div class="upblocks">
3114
+ <div class="upblk"><div class="v" id="up-total">&middot;</div><div class="k">calls</div></div>
3115
+ <div class="upblk err" id="up-errblk"><div class="v" id="up-errors">&middot;</div><div class="k">errors</div></div>
3116
+ <div class="upblk"><div class="v rate" id="up-rate">&middot;</div><div class="k">err rate</div></div>
3117
+ </div>
3118
+ <svg id="up-spark" viewBox="0 0 320 30" preserveAspectRatio="none" aria-hidden="true"><path class="area" fill="var(--spark-fill)" stroke="none"/><path class="line" fill="none" stroke="var(--danger)" stroke-width="1.5" vector-effect="non-scaling-stroke"/></svg>
3119
+ <div class="flag" id="up-flag"></div>
3120
+ </div>
3121
+
3122
+ <div class="panel span8"><span class="ptitle">&#9508; Throughput &#9504;</span>
3123
+ <div class="cap"><span style="color:var(--accent)">&#9632;</span> tokens/s <span id="thru-tok" class="num"></span> &nbsp; <span style="color:var(--accent-2)">&#9632;</span> req/s <span id="thru-req" class="num"></span> <span class="end" id="thru-peak" style="float:right"></span></div>
3124
+ <svg id="thru-svg" viewBox="0 0 320 88" preserveAspectRatio="none" aria-hidden="true">
3125
+ <defs><linearGradient id="thrugrad" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="var(--accent)" stop-opacity="0.28"/><stop offset="100%" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs>
3126
+ <line class="grid" x1="0" y1="22" x2="320" y2="22" stroke="var(--grid-line)"/>
3127
+ <line class="grid" x1="0" y1="44" x2="320" y2="44" stroke="var(--grid-line)"/>
3128
+ <line class="grid" x1="0" y1="66" x2="320" y2="66" stroke="var(--grid-line)"/>
3129
+ <path id="thru-tok-area" fill="url(#thrugrad)" stroke="none"/>
3130
+ <path id="thru-tok-line" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>
3131
+ <path id="thru-req-line" fill="none" stroke="var(--accent-2)" stroke-width="1.2" vector-effect="non-scaling-stroke" opacity="0.9"/>
3132
+ </svg>
3133
+ </div>
3134
+ </section>
3135
+ </section>
3136
+
3137
+ <section id="auth" aria-live="polite">
3138
+ <div class="authcard">
3139
+ <span class="clear" id="auth-clear" style="display:none">clear key</span>
3140
+ <h3>&#9508; Auth required &#9504;</h3>
3141
+ <p>This hoopilot proxy requires an API key. It is stored locally in your browser and sent as <span class="mono">x-api-key</span>.</p>
3142
+ <div class="row"><input id="auth-input" type="password" placeholder="x-api-key" autocomplete="off" spellcheck="false" /><button id="auth-connect">connect</button></div>
3143
+ <div class="err" id="auth-err"></div>
3144
+ </div>
3145
+ </section>
3146
+
3147
+ <footer class="foot">
3148
+ <span id="foot-started">started &middot;</span>
3149
+ <span id="foot-uptime">uptime &middot;</span>
3150
+ <span id="foot-total">&middot; req</span>
3151
+ <span id="foot-tokens">&middot; tokens</span>
3152
+ <span id="foot-upstream">upstream &middot;</span>
3153
+ <span class="end" id="foot-cadence"></span>
3154
+ </footer>
3155
+ </div>
3156
+
3157
+ <script>
3158
+ (function(){
3159
+ "use strict";
3160
+ var byId = function(id){ return document.getElementById(id); };
3161
+ var CAP = 60;
3162
+
3163
+ // ---- persistent state ----
3164
+ var LS = window.localStorage;
3165
+ var apiKey = "";
3166
+ try { apiKey = LS.getItem("hoopilot.apiKey") || ""; } catch (e) { apiKey = ""; }
3167
+ var theme = "auto";
3168
+ try { theme = LS.getItem("hoopilot.theme") || "auto"; } catch (e) { theme = "auto"; }
3169
+ var intervalMs = 4000;
3170
+ try { var sv = parseInt(LS.getItem("hoopilot.intervalMs") || "", 10); if (sv === 2000 || sv === 4000 || sv === 10000) intervalMs = sv; } catch (e) {}
3171
+
3172
+ // ---- runtime state ----
3173
+ var paused = false;
3174
+ var timer = null;
3175
+ var inflightFetch = null;
3176
+ var lastSuccessAt = 0;
3177
+ var prevSample = null; // { t, reqTotal, tokTotal, upTotal, startedAt }
3178
+ var lastRender = {}; // for change-flash
3179
+ var backoffMs = 0;
3180
+ var lastUptime = null; // seconds; ticked locally between polls
3181
+ var hist = { req:[], tok:[], inflight:[], up:[] };
3182
+
3183
+ // ---- formatting helpers ----
3184
+ function humanInt(n){
3185
+ if (n === null || n === undefined || !isFinite(n)) return "0";
3186
+ var a = Math.abs(n);
3187
+ if (a >= 1000000) return (n/1000000).toFixed(a >= 10000000 ? 0 : 1) + "M";
3188
+ if (a >= 1000) return (n/1000).toFixed(a >= 10000 ? 0 : 1) + "k";
3189
+ return String(Math.round(n));
3190
+ }
3191
+ function rate(n){
3192
+ if (n === null || n === undefined || !isFinite(n)) return "0";
3193
+ if (n >= 100) return String(Math.round(n));
3194
+ if (n >= 10) return n.toFixed(1);
3195
+ return n.toFixed(2);
3196
+ }
3197
+ function pct(n){ if (!isFinite(n)) return "0%"; return (n >= 10 ? Math.round(n) : Math.round(n*10)/10) + "%"; }
3198
+ function fmtMs(n){ if (n === null || n === undefined || !isFinite(n) || n <= 0) return "0"; if (n >= 1000) return (n/1000).toFixed(2) + "s"; if (n >= 100) return String(Math.round(n)); return Math.round(n*10)/10 + ""; }
3199
+ function pad2(n){ return (n < 10 ? "0" : "") + n; }
3200
+ function fmtUptime(sec){
3201
+ sec = Math.max(0, Math.floor(sec));
3202
+ var d = Math.floor(sec/86400); sec -= d*86400;
3203
+ var h = Math.floor(sec/3600); sec -= h*3600;
3204
+ var m = Math.floor(sec/60); var s = sec - m*60;
3205
+ if (d > 0) return d + "d " + pad2(h) + ":" + pad2(m);
3206
+ if (h > 0) return h + ":" + pad2(m) + ":" + pad2(s);
3207
+ return m + ":" + pad2(s);
3208
+ }
3209
+ function titleize(key){
3210
+ var map = { premium_interactions:"Premium requests", chat:"Chat", completions:"Completions", code_review:"Code review" };
3211
+ if (map[key]) return map[key];
3212
+ return key.split("_").map(function(w){ return w ? w.charAt(0).toUpperCase() + w.slice(1) : w; }).join(" ");
3213
+ }
3214
+ function relTime(iso){
3215
+ var t = Date.parse(iso); if (!isFinite(t)) return iso || "";
3216
+ var s = Math.max(0, Math.round((Date.now() - t)/1000));
3217
+ return fmtUptime(s) + " ago";
3218
+ }
3219
+ function clearEl(el){ while (el && el.firstChild) el.removeChild(el.firstChild); }
3220
+ function mk(tag, cls, txt){ var e = document.createElement(tag); if (cls) e.className = cls; if (txt !== undefined && txt !== null) e.textContent = txt; return e; }
3221
+
3222
+ // Set numeric text and flash on discrete change.
3223
+ function setNum(id, value, kind){
3224
+ var el = byId(id); if (!el) return;
3225
+ el.classList.remove("skel");
3226
+ var s = String(value);
3227
+ if (el.textContent !== s){
3228
+ el.textContent = s;
3229
+ var prev = lastRender[id];
3230
+ if (prev !== undefined){
3231
+ var cls = "flash";
3232
+ if (kind === "delta" && typeof value === "number" && typeof prev === "number"){
3233
+ cls = value > prev ? "flash-up" : (value < prev ? "flash-down" : null);
3234
+ }
3235
+ if (cls){ el.classList.remove("flash","flash-up","flash-down"); void el.offsetWidth; el.classList.add(cls); }
3236
+ }
3237
+ lastRender[id] = value;
3238
+ }
3239
+ }
3240
+ function setText(id, s){ var el = byId(id); if (el){ el.classList.remove("skel"); el.textContent = s; } }
3241
+
3242
+ // ---- sparkline rendering ----
3243
+ function pushHist(arr, v){ arr.push(v); if (arr.length > CAP) arr.shift(); }
3244
+ function buildSpark(values, w, h){
3245
+ var pts = []; for (var i=0;i<values.length;i++){ if (isFinite(values[i])) pts.push({ i:i, v:values[i] }); }
3246
+ if (pts.length < 2) return null;
3247
+ var min = Infinity, max = -Infinity;
3248
+ for (var j=0;j<values.length;j++){ var v = values[j]; if (isFinite(v)){ if (v<min) min=v; if (v>max) max=v; } }
3249
+ var flat = (max - min) <= 0;
3250
+ var pad = flat ? 1 : (max - min) * 0.05; var lo = min - pad, hi = max + pad; var span = hi - lo; if (span <= 0) span = 1;
3251
+ var n = values.length;
3252
+ var line = "", lastX = 0, lastY = 0, started = false;
3253
+ for (var k=0;k<n;k++){
3254
+ var val = values[k]; if (!isFinite(val)) continue;
3255
+ var x = (n === 1) ? w : (k * (w/(n-1)));
3256
+ var norm = flat ? 0.5 : (val - lo)/span;
3257
+ var y = h - norm*(h-2) - 1;
3258
+ line += (started ? " L" : "M") + x.toFixed(2) + "," + y.toFixed(2);
3259
+ lastX = x; lastY = y; started = true;
3260
+ }
3261
+ var area = line + " L" + lastX.toFixed(2) + "," + h + " L0," + h + " Z";
3262
+ return { line:line, area:area, lastX:lastX, lastY:lastY };
3263
+ }
3264
+ function drawSpark(svgId, values){
3265
+ var svg = byId(svgId); if (!svg) return;
3266
+ var vb = svg.viewBox.baseVal; var w = vb.width || 200, h = vb.height || 24;
3267
+ var sp = buildSpark(values, w, h);
3268
+ var line = svg.querySelector(".line"), area = svg.querySelector(".area"), dot = svg.querySelector("circle");
3269
+ if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); if (dot) dot.style.display = "none"; return; }
3270
+ if (line) line.setAttribute("d", sp.line);
3271
+ if (area) area.setAttribute("d", sp.area);
3272
+ if (dot){ dot.setAttribute("cx", sp.lastX.toFixed(2)); dot.setAttribute("cy", sp.lastY.toFixed(2)); dot.style.display = ""; }
3273
+ }
3274
+
3275
+ // ---- theme ----
3276
+ function applyTheme(){
3277
+ var root = document.documentElement;
3278
+ if (theme === "dark") root.setAttribute("data-theme","dark");
3279
+ else if (theme === "light") root.setAttribute("data-theme","light");
3280
+ else root.removeAttribute("data-theme");
3281
+ byId("btn-theme").textContent = theme === "dark" ? "D" : (theme === "light" ? "L" : "A");
3282
+ }
3283
+ byId("btn-theme").addEventListener("click", function(){
3284
+ theme = theme === "auto" ? "dark" : (theme === "dark" ? "light" : "auto");
3285
+ try { LS.setItem("hoopilot.theme", theme); } catch (e) {}
3286
+ applyTheme();
3287
+ });
3288
+
3289
+ // ---- interval + pause ----
3290
+ function setActiveSeg(){
3291
+ var btns = byId("seg").querySelectorAll("button");
3292
+ for (var i=0;i<btns.length;i++){ btns[i].classList.toggle("active", parseInt(btns[i].getAttribute("data-ms"),10) === intervalMs); }
3293
+ document.documentElement.style.setProperty("--scan-ms", intervalMs + "ms");
3294
+ }
3295
+ byId("seg").addEventListener("click", function(ev){
3296
+ var b = ev.target.closest ? ev.target.closest("button") : null; if (!b) return;
3297
+ intervalMs = parseInt(b.getAttribute("data-ms"),10) || 4000;
3298
+ try { LS.setItem("hoopilot.intervalMs", String(intervalMs)); } catch (e) {}
3299
+ setActiveSeg();
3300
+ if (!paused){ schedule(0); }
3301
+ });
3302
+ byId("btn-pause").addEventListener("click", function(){
3303
+ paused = !paused;
3304
+ byId("btn-pause").innerHTML = paused ? "&#9654;" : "&#10074;&#10074;";
3305
+ byId("bar").classList.toggle("paused", paused);
3306
+ if (paused){ if (timer){ clearTimeout(timer); timer = null; } setPill("paused","PAUSED",false); }
3307
+ else { setPill("live","LIVE",false); schedule(0); }
3308
+ });
3309
+
3310
+ // ---- connection pill / banner ----
3311
+ function setPill(kind, text, beat){
3312
+ var pill = byId("conn-pill"); var dot = byId("conn-dot");
3313
+ pill.className = "pill " + kind;
3314
+ byId("conn-text").textContent = text;
3315
+ if (beat && dot){ dot.classList.remove("heartbeat"); void dot.offsetWidth; dot.classList.add("heartbeat"); }
3316
+ }
3317
+ function showBanner(text, ok){
3318
+ var b = byId("banner"); b.textContent = text; b.className = "banner show" + (ok ? " ok" : ""); b.classList.add("show");
3319
+ if (ok){ setTimeout(function(){ b.classList.remove("show"); }, 2000); }
3320
+ }
3321
+ function hideBanner(){ byId("banner").classList.remove("show"); }
3322
+ function setDimmed(on){ byId("content").classList.toggle("dim", on); }
3323
+
3324
+ // ---- auth takeover ----
3325
+ function showAuth(rejected){
3326
+ byId("content").style.display = "none";
3327
+ byId("auth").classList.add("show");
3328
+ setPill("authkey","API KEY",false);
3329
+ byId("auth-err").textContent = rejected ? "key rejected" : "";
3330
+ byId("auth-input").classList.toggle("bad", !!rejected);
3331
+ byId("auth-clear").style.display = apiKey ? "" : "none";
3332
+ byId("auth-input").focus();
3333
+ }
3334
+ function hideAuth(){ byId("auth").classList.remove("show"); byId("content").style.display = ""; }
3335
+ byId("auth-connect").addEventListener("click", function(){
3336
+ var v = byId("auth-input").value.trim(); if (!v) return;
3337
+ apiKey = v; try { LS.setItem("hoopilot.apiKey", apiKey); } catch (e) {}
3338
+ hideAuth(); schedule(0);
3339
+ });
3340
+ byId("auth-input").addEventListener("keydown", function(ev){ if (ev.key === "Enter") byId("auth-connect").click(); });
3341
+ byId("auth-clear").addEventListener("click", function(){
3342
+ apiKey = ""; try { LS.removeItem("hoopilot.apiKey"); } catch (e) {}
3343
+ byId("auth-input").value = ""; byId("auth-clear").style.display = "none"; byId("auth-input").focus();
3344
+ });
3345
+
3346
+ // ---- the poll loop (setTimeout-chained, never setInterval) ----
3347
+ var pollGen = 0;
3348
+ function schedule(delay){
3349
+ if (timer){ clearTimeout(timer); }
3350
+ if (paused) return;
3351
+ timer = setTimeout(poll, delay === undefined ? intervalMs : delay);
3352
+ }
3353
+ function poll(){
3354
+ if (paused) return;
3355
+ // A new poll supersedes any in-flight one. Bump the generation so the old
3356
+ // request's settled handlers (including its abort rejection) become no-ops
3357
+ // and never flash a false "disconnected".
3358
+ pollGen += 1; var myGen = pollGen;
3359
+ if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
3360
+ var ctrl = new AbortController(); inflightFetch = ctrl;
3361
+ var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, 3000);
3362
+ var headers = { "accept":"application/json" };
3363
+ if (apiKey) headers["x-api-key"] = apiKey;
3364
+ fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
3365
+ clearTimeout(to);
3366
+ if (myGen !== pollGen) return null;
3367
+ if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
3368
+ if (!res.ok) throw new Error("HTTP " + res.status);
3369
+ return res.json();
3370
+ }).then(function(data){
3371
+ if (myGen !== pollGen || data === null || paused) return;
3372
+ inflightFetch = null;
3373
+ onData(data);
3374
+ backoffMs = 0; lastSuccessAt = Date.now();
3375
+ hideAuth(); setDimmed(false); hideBanner();
3376
+ setPill("live","LIVE",true);
3377
+ byId("bar").classList.remove("frozen");
3378
+ schedule(intervalMs);
3379
+ }).catch(function(err){
3380
+ clearTimeout(to);
3381
+ if (myGen !== pollGen || paused) return;
3382
+ inflightFetch = null;
3383
+ onDisconnect(err);
3384
+ });
3385
+ }
3386
+ function onDisconnect(err){
3387
+ setPill("reconnect","RECONNECTING",false);
3388
+ setDimmed(true);
3389
+ byId("bar").classList.add("frozen");
3390
+ backoffMs = backoffMs ? Math.min(Math.round(backoffMs * 1.5), 30000) : intervalMs;
3391
+ showBanner("Disconnected (" + (err && err.message ? err.message : "no response") + ") \\u2014 retrying in " + Math.round(backoffMs/1000) + "s", false);
3392
+ schedule(backoffMs);
3393
+ }
3394
+
3395
+ // ---- main render ----
3396
+ function onData(usage){
3397
+ var proxy = usage.proxy || {};
3398
+ var now = Date.now();
3399
+
3400
+ setText("version-chip", "v" + (usage.version || "?"));
3401
+
3402
+ // rates
3403
+ var reqTotal = (proxy.requests && proxy.requests.total) || 0;
3404
+ var tokTotal = (proxy.tokens && proxy.tokens.total) || 0;
3405
+ var upTotal = (proxy.upstream && proxy.upstream.total) || 0;
3406
+ var startedAt = proxy.startedAt || "";
3407
+ var reqPerSec = NaN, tokPerSec = NaN, upDelta = 0, restarted = false;
3408
+ if (prevSample){
3409
+ var dt = (now - prevSample.t)/1000;
3410
+ if (prevSample.startedAt && startedAt && prevSample.startedAt !== startedAt) restarted = true;
3411
+ if (reqTotal < prevSample.reqTotal || tokTotal < prevSample.tokTotal) restarted = true;
3412
+ if (restarted){ reqPerSec = 0; tokPerSec = 0; upDelta = 0; }
3413
+ else if (dt > 0 && isFinite(dt)){
3414
+ reqPerSec = Math.max(0, (reqTotal - prevSample.reqTotal)/dt);
3415
+ tokPerSec = Math.max(0, (tokTotal - prevSample.tokTotal)/dt);
3416
+ upDelta = Math.max(0, upTotal - prevSample.upTotal);
3417
+ }
3418
+ }
3419
+ prevSample = { t:now, reqTotal:reqTotal, tokTotal:tokTotal, upTotal:upTotal, startedAt:startedAt };
3420
+
3421
+ // hero vitals
3422
+ if (isFinite(reqPerSec)){ pushHist(hist.req, reqPerSec); setNum("req-num", rate(reqPerSec)); } else setText("req-num","\\u2014");
3423
+ if (isFinite(tokPerSec)){ pushHist(hist.tok, tokPerSec); setNum("tok-num", humanInt(tokPerSec)); } else setText("tok-num","\\u2014");
3424
+ var inflight = proxy.inFlight || 0;
3425
+ pushHist(hist.inflight, inflight); setNum("inflight-num", String(inflight), "delta");
3426
+ byId("v-inflight").classList.toggle("active", inflight > 0);
3427
+ setText("uptime-num", fmtUptime(proxy.uptimeSeconds || 0));
3428
+
3429
+ setText("req-sub", hist.req.length ? ("avg " + rate(avg(hist.req)) + "/s") : "warming up");
3430
+ setText("tok-sub", hist.tok.length ? ("peak " + humanInt(Math.max.apply(null, hist.tok)) + "/s") : "warming up");
3431
+ setText("inflight-sub", inflight + " now");
3432
+ setText("uptime-sub", startedAt ? ("since " + relTime(startedAt)) : "");
3433
+
3434
+ drawSpark("req-spark", hist.req);
3435
+ drawSpark("tok-spark", hist.tok);
3436
+ drawSpark("inflight-spark", hist.inflight);
3437
+
3438
+ renderRequests(proxy);
3439
+ renderStatus(proxy);
3440
+ renderLatency(proxy.latency || {});
3441
+ renderTokens(proxy.tokens || {});
3442
+ renderCopilot(usage);
3443
+ renderUpstream(proxy.upstream || {}, upDelta, restarted);
3444
+ renderThroughput();
3445
+ renderFooter(usage, proxy);
3446
+
3447
+ setNum("req-total", humanInt(reqTotal));
3448
+ setNum("tok-total", humanInt(tokTotal));
3449
+ lastUptime = proxy.uptimeSeconds || 0;
3450
+ }
3451
+
3452
+ function avg(arr){ if (!arr.length) return 0; var s = 0; for (var i=0;i<arr.length;i++) s += arr[i]; return s/arr.length; }
3453
+
3454
+ var ROUTE_COLORS = ["var(--c1)","var(--c2)","var(--c3)","var(--c4)","var(--c5)","var(--c6)"];
3455
+ function renderRequests(proxy){
3456
+ var byRoute = (proxy.requests && proxy.requests.byRoute) || {};
3457
+ var total = (proxy.requests && proxy.requests.total) || 0;
3458
+ var rows = Object.keys(byRoute).map(function(k){ return { k:k, v:byRoute[k] }; }).sort(function(a,b){ return b.v - a.v; });
3459
+ var share = byId("route-sharebar"); clearEl(share); share.className = "stack-bar" + (total ? "" : " empty");
3460
+ var body = byId("routes-body"); clearEl(body);
3461
+ if (!rows.length){ var tr = mk("tr","ghost"); var td = mk("td",null,"no requests yet"); td.colSpan = 4; tr.appendChild(td); body.appendChild(tr); return; }
3462
+ rows.forEach(function(r, idx){
3463
+ var p = total ? (r.v/total*100) : 0;
3464
+ var seg = mk("i"); seg.style.width = p + "%"; seg.style.background = ROUTE_COLORS[idx % ROUTE_COLORS.length]; seg.title = r.k + " " + pct(p); share.appendChild(seg);
3465
+ var tr = mk("tr");
3466
+ var name = mk("td","l", r.k); name.title = r.k; tr.appendChild(name);
3467
+ tr.appendChild(mk("td",null, humanInt(r.v)));
3468
+ tr.appendChild(mk("td",null, pct(p)));
3469
+ var btd = mk("td"); var bar = mk("span","minibar"); bar.style.width = Math.max(2, p) + "%"; bar.style.background = ROUTE_COLORS[idx % ROUTE_COLORS.length]; btd.appendChild(bar); tr.appendChild(btd);
3470
+ body.appendChild(tr);
3471
+ });
3472
+ var tot = mk("tr","total"); tot.appendChild(mk("td","l","total")); tot.appendChild(mk("td",null, humanInt(total))); tot.appendChild(mk("td",null,"100%")); tot.appendChild(mk("td")); body.appendChild(tot);
3473
+ }
3474
+
3475
+ function statusClass(code){ var c = String(code).charAt(0); if (c === "2") return "ok"; if (c === "3") return "info"; if (c === "4") return "warn"; if (c === "5") return "danger"; return "muted"; }
3476
+ function statusColor(cls){ return cls === "ok" ? "var(--ok)" : cls === "info" ? "var(--info)" : cls === "warn" ? "var(--warn)" : cls === "danger" ? "var(--danger)" : "var(--text-2)"; }
3477
+ function renderStatus(proxy){
3478
+ var byStatus = (proxy.requests && proxy.requests.byStatus) || {};
3479
+ var total = 0, errs = 0; var groups = { ok:0, info:0, warn:0, danger:0, muted:0 };
3480
+ var codes = Object.keys(byStatus).map(function(k){ return { k:k, v:byStatus[k] }; }).sort(function(a,b){ return b.v - a.v; });
3481
+ codes.forEach(function(c){ total += c.v; var cls = statusClass(c.k); groups[cls] += c.v; if (cls === "warn" || cls === "danger") errs += c.v; });
3482
+ var bar = byId("status-bar"); clearEl(bar); bar.className = "stack-bar" + (total ? "" : " empty");
3483
+ ["ok","info","warn","danger","muted"].forEach(function(cls){ if (groups[cls] > 0){ var seg = mk("i"); seg.style.width = (groups[cls]/total*100) + "%"; seg.style.background = statusColor(cls); bar.appendChild(seg); } });
3484
+ var leg = byId("status-legend"); clearEl(leg);
3485
+ if (!codes.length){ leg.appendChild(mk("span","li","no requests yet")); }
3486
+ codes.forEach(function(c){ var li = mk("span","li"); var sw = mk("span","sw"); sw.style.background = statusColor(statusClass(c.k)); li.appendChild(sw); li.appendChild(mk("span",null, c.k + " " + humanInt(c.v))); leg.appendChild(li); });
3487
+ var er = total ? (errs/total*100) : 0;
3488
+ setNum("error-rate", pct(er));
3489
+ var el = byId("error-rate"); el.style.color = er > 5 ? "var(--danger)" : er > 1 ? "var(--warn)" : "var(--ok)";
3490
+ }
3491
+
3492
+ function renderLatency(lat){
3493
+ setText("lat-p50", fmtMs(lat.p50Ms)); setText("lat-avg", fmtMs(lat.avgMs)); setText("lat-count", humanInt(lat.count || 0));
3494
+ var p95 = byId("lat-p95"); p95.classList.remove("skel"); p95.textContent = fmtMs(lat.p95Ms);
3495
+ p95.style.color = (lat.p50Ms > 0 && lat.p95Ms > 2*lat.p50Ms) ? "var(--warn)" : "var(--info)";
3496
+ // track: position p50 and p95 across 0..(p95*1.15)
3497
+ var track = byId("lat-track"); var old = track.querySelectorAll(".tick,.tlab"); for (var i=0;i<old.length;i++) old[i].remove();
3498
+ var maxv = Math.max(lat.p95Ms || 0, lat.avgMs || 0, 1) * 1.15;
3499
+ function place(v, cls){ if (!isFinite(v) || v <= 0) return; var x = Math.min(100, v/maxv*100); var t = mk("div","tick " + cls); t.style.left = x + "%"; track.appendChild(t); var lab = mk("div","tlab", fmtMs(v)); lab.style.left = x + "%"; track.appendChild(lab); }
3500
+ place(lat.p50Ms, "p50"); place(lat.p95Ms, "p95");
3501
+ var lr = byId("lat-routes"); clearEl(lr);
3502
+ var byRoute = lat.byRoute || {}; var rows = Object.keys(byRoute).map(function(k){ return { k:k, v:byRoute[k] }; }).sort(function(a,b){ return (b.v.avgMs||0) - (a.v.avgMs||0); });
3503
+ rows.forEach(function(r){ var tr = mk("tr"); var n = mk("td","l", r.k); n.title = r.k; tr.appendChild(n); tr.appendChild(mk("td",null, fmtMs(r.v.avgMs))); tr.appendChild(mk("td",null, humanInt(r.v.count||0))); lr.appendChild(tr); });
3504
+ }
3505
+
3506
+ function renderTokens(tok){
3507
+ var prompt = tok.prompt||0, completion = tok.completion||0, reasoning = tok.reasoning||0, cached = tok.cached||0;
3508
+ var sum = prompt + completion + reasoning;
3509
+ var bar = byId("tok-mixbar"); clearEl(bar); bar.className = "stack-bar" + (sum ? "" : " empty");
3510
+ var parts = [ ["prompt", prompt, "var(--text-1)"], ["completion", completion, "var(--accent)"], ["reasoning", reasoning, "var(--info)"] ];
3511
+ parts.forEach(function(p){ if (sum && p[1] > 0){ var seg = mk("i"); seg.style.width = (p[1]/sum*100) + "%"; seg.style.background = p[2]; seg.title = p[0]; bar.appendChild(seg); } });
3512
+ var leg = byId("tok-legend"); clearEl(leg);
3513
+ var legParts = parts.concat([["cached", cached, "var(--cache)"]]);
3514
+ legParts.forEach(function(p){ var li = mk("span","li"); var sw = mk("span","sw"); sw.style.background = p[2]; li.appendChild(sw); var den = (p[0] === "cached") ? prompt : sum; var sh = den ? " " + pct(p[1]/den*100) : ""; li.appendChild(mk("span",null, p[0] + " " + humanInt(p[1]) + sh)); leg.appendChild(li); });
3515
+ var cacheRate = prompt ? (cached/prompt*100) : 0; setText("tok-cache", "cache " + pct(cacheRate));
3516
+ var body = byId("tok-body"); clearEl(body);
3517
+ var byModel = tok.byModel || {}; var rows = Object.keys(byModel).map(function(k){ return { k:k, v:byModel[k] }; }).sort(function(a,b){ return (b.v.total||0) - (a.v.total||0); });
3518
+ if (!rows.length){ var tr = mk("tr","ghost"); var td = mk("td",null,"no token usage yet"); td.colSpan = 7; tr.appendChild(td); body.appendChild(tr); return; }
3519
+ rows.forEach(function(r){ var m = r.v; var tr = mk("tr"); var n = mk("td","l", r.k); n.title = r.k; tr.appendChild(n);
3520
+ tr.appendChild(mk("td",null, humanInt(m.prompt||0))); tr.appendChild(mk("td",null, humanInt(m.completion||0)));
3521
+ tr.appendChild(mk("td","reasoning", humanInt(m.reasoning||0))); tr.appendChild(mk("td","cached", humanInt(m.cached||0)));
3522
+ tr.appendChild(mk("td",null, humanInt(m.total||0))); tr.appendChild(mk("td",null, humanInt(m.requests||0))); body.appendChild(tr); });
3523
+ }
3524
+
3525
+ function planClass(plan){ if (!plan) return "plan-offline"; if (plan.indexOf("pro") >= 0) return "plan-pro"; if (plan.indexOf("business") >= 0 || plan.indexOf("enterprise") >= 0) return "plan-business"; return "plan-free"; }
3526
+ function renderCopilot(usage){
3527
+ var box = byId("copilot-body"); clearEl(box);
3528
+ var cp = usage.copilot; var planChip = byId("plan-chip");
3529
+ if (!cp){
3530
+ planChip.className = "chip plan-offline"; planChip.textContent = "\\u2014 offline";
3531
+ var eb = mk("div","emptybox"); eb.appendChild(mk("div","keyglyph","\\u26bf"));
3532
+ eb.appendChild(mk("h4",null,"Copilot not connected"));
3533
+ if (usage.copilot_error) eb.appendChild(mk("div","errline", usage.copilot_error));
3534
+ eb.appendChild(mk("div","prompt","$ hoopilot login"));
3535
+ box.appendChild(eb); return;
3536
+ }
3537
+ planChip.className = "chip " + planClass(cp.plan); planChip.textContent = cp.plan || "copilot";
3538
+ var head = mk("div","cap");
3539
+ var bits = [];
3540
+ if (cp.accessTypeSku) bits.push(cp.accessTypeSku);
3541
+ if (cp.chatEnabled !== undefined) bits.push(cp.chatEnabled ? "chat on" : "chat off");
3542
+ if (cp.quotaResetDate) bits.push("resets " + cp.quotaResetDate);
3543
+ head.textContent = bits.join(" \\u00b7 "); box.appendChild(head);
3544
+ var quotas = cp.quotas || {}; var keys = Object.keys(quotas);
3545
+ if (!keys.length){ box.appendChild(mk("div","cap","No metered quotas reported.")); return; }
3546
+ var order = { premium_interactions:0, chat:1, completions:2 };
3547
+ keys.sort(function(a,b){ var ra = order[a]===undefined?9:order[a], rb = order[b]===undefined?9:order[b]; return ra-rb || a.localeCompare(b); });
3548
+ keys.forEach(function(k){
3549
+ var q = quotas[k]; var row = mk("div","qrow");
3550
+ var hd = mk("div","qhead"); hd.appendChild(mk("span","qname", titleize(k)));
3551
+ if (q.unlimited){ hd.appendChild(mk("span","inf","\\u221e unlimited")); row.appendChild(hd); box.appendChild(row); return; }
3552
+ var ent = q.entitlement, rem = q.remaining, used = q.used;
3553
+ var usedPct = (q.percentRemaining !== undefined) ? (100 - q.percentRemaining) : ((ent && used !== undefined) ? (used/ent*100) : 0);
3554
+ usedPct = Math.max(0, Math.min(100, usedPct));
3555
+ var valTxt = (used !== undefined && ent !== undefined) ? (humanInt(used) + " / " + humanInt(ent)) : (rem !== undefined ? (humanInt(rem) + " left") : pct(100-usedPct) + " left");
3556
+ hd.appendChild(mk("span","qval", valTxt)); row.appendChild(hd);
3557
+ var bar = mk("div","qbar"); var fill = mk("i"); fill.style.width = usedPct + "%";
3558
+ fill.style.background = usedPct > 85 ? "var(--danger)" : usedPct > 60 ? "var(--warn)" : "var(--ok)"; bar.appendChild(fill);
3559
+ if (q.overageCount && q.overagePermitted){ bar.classList.add("over"); var ext = mk("i","ext"); ext.style.left = "100%"; ext.style.width = "8%"; bar.appendChild(ext); }
3560
+ row.appendChild(bar);
3561
+ if (q.overageCount){ var ov = mk("div","flag", humanInt(q.overageCount) + " overage" + (q.tokenBasedBilling ? " \\u00b7 token billing" : "")); row.appendChild(ov); }
3562
+ box.appendChild(row);
3563
+ });
3564
+ }
3565
+
3566
+ function renderUpstream(up, delta, restarted){
3567
+ setNum("up-total", humanInt(up.total||0));
3568
+ setNum("up-errors", humanInt(up.errors||0), "delta");
3569
+ var er = up.total ? (up.errors/up.total*100) : 0;
3570
+ var rt = byId("up-rate"); rt.textContent = pct(er); rt.className = "v rate " + (er > 5 ? "danger" : er > 1 ? "warn" : "ok");
3571
+ byId("up-errblk").classList.toggle("hot", (up.errors||0) > 0);
3572
+ pushHist(hist.up, delta||0); drawSpark("up-spark", hist.up);
3573
+ byId("up-flag").textContent = restarted ? "\\u21bb restarted" : "";
3574
+ }
3575
+
3576
+ function renderThroughput(){
3577
+ drawDual("thru-tok-line","thru-tok-area", hist.tok, true);
3578
+ drawDual("thru-req-line", null, hist.req, false);
3579
+ setText("thru-tok", hist.tok.length ? rate(hist.tok[hist.tok.length-1]) : "\\u2014");
3580
+ setText("thru-req", hist.req.length ? rate(hist.req[hist.req.length-1]) : "\\u2014");
3581
+ var peakTok = hist.tok.length ? Math.max.apply(null, hist.tok) : 0;
3582
+ setText("thru-peak", "peak " + humanInt(peakTok) + " tok/s");
3583
+ }
3584
+ function drawDual(lineId, areaId, values, withArea){
3585
+ var svg = byId("thru-svg"); var vb = svg.viewBox.baseVal; var w = vb.width, h = vb.height;
3586
+ var sp = buildSpark(values, w, h);
3587
+ var line = byId(lineId); var area = areaId ? byId(areaId) : null;
3588
+ if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); return; }
3589
+ if (line) line.setAttribute("d", sp.line);
3590
+ if (area && withArea) area.setAttribute("d", sp.area);
3591
+ }
3592
+
3593
+ function renderFooter(usage, proxy){
3594
+ setText("foot-started", proxy.startedAt ? ("started " + new Date(proxy.startedAt).toLocaleString()) : "started \\u2014");
3595
+ setText("foot-uptime", "uptime " + fmtUptime(proxy.uptimeSeconds||0));
3596
+ setText("foot-total", humanInt((proxy.requests && proxy.requests.total)||0) + " req");
3597
+ setText("foot-tokens", humanInt((proxy.tokens && proxy.tokens.total)||0) + " tokens");
3598
+ var up = proxy.upstream || {}; setText("foot-upstream", "upstream " + humanInt(up.total||0) + " / " + humanInt(up.errors||0) + " err");
3599
+ setText("foot-cadence", "polling /v1/usage every " + Math.round(intervalMs/1000) + "s \\u00b7 GET /dashboard");
3600
+ }
3601
+
3602
+ // ---- 1s freshness + uptime ticker (independent of the poll loop) ----
3603
+ setInterval(function(){
3604
+ if (lastSuccessAt){
3605
+ var ago = Math.round((Date.now() - lastSuccessAt)/1000);
3606
+ var u = byId("updated"); u.textContent = "updated " + ago + "s ago";
3607
+ // Staleness only matters while polling; a deliberate pause is not "stale".
3608
+ u.className = "updated" + (paused ? "" : ago > intervalMs/1000*4 ? " danger" : ago > intervalMs/1000*2 ? " warn" : "");
3609
+ }
3610
+ // Tick uptime locally between polls so the seconds advance smoothly; each
3611
+ // successful poll re-seeds lastUptime from the authoritative server value.
3612
+ if (!paused && lastUptime !== null){
3613
+ lastUptime += 1;
3614
+ byId("uptime-num").textContent = fmtUptime(lastUptime);
3615
+ var fu = byId("foot-uptime"); if (fu) fu.textContent = "uptime " + fmtUptime(lastUptime);
3616
+ }
3617
+ }, 1000);
3618
+
3619
+ // ---- boot ----
3620
+ applyTheme(); setActiveSeg();
3621
+ setPill("","CONNECTING",false);
3622
+ poll();
3623
+ })();
3624
+ </script>
3625
+ </body>
3626
+ </html>
3627
+ `;
3628
+
2739
3629
  // src/version.ts
2740
3630
  var BAKED_VERSION = typeof HOOPILOT_VERSION !== "undefined" ? HOOPILOT_VERSION : void 0;
2741
3631
  var IS_STANDALONE_BINARY = BAKED_VERSION !== void 0;
3632
+ var cachedVersion;
3633
+ async function getVersion() {
3634
+ if (cachedVersion !== void 0) {
3635
+ return cachedVersion;
3636
+ }
3637
+ let resolved;
3638
+ if (BAKED_VERSION) {
3639
+ resolved = BAKED_VERSION;
3640
+ } else {
3641
+ try {
3642
+ const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json();
3643
+ resolved = typeof manifest.version === "string" ? manifest.version : "0.0.0";
3644
+ } catch {
3645
+ resolved = "0.0.0";
3646
+ }
3647
+ }
3648
+ cachedVersion = resolved;
3649
+ return resolved;
3650
+ }
2742
3651
 
2743
3652
  // src/server.ts
2744
3653
  var DEFAULT_HOST = "127.0.0.1";
@@ -2765,6 +3674,7 @@ function createHoopilotHandler(options = {}) {
2765
3674
  const metrics = options.metrics ?? new MetricsRegistry();
2766
3675
  const readUsage = createUsageReader(client, metrics);
2767
3676
  const recordTokens = (model, usage) => metrics.recordTokens(model, usage);
3677
+ const recordExtraction = (extracted) => metrics.recordTokenExtraction(extracted);
2768
3678
  const streamingProxyMode = resolveStreamingProxyMode(options);
2769
3679
  const bufferProxyBodies = shouldBufferProxyBodies(streamingProxyMode);
2770
3680
  return async (request) => {
@@ -2804,6 +3714,9 @@ function createHoopilotHandler(options = {}) {
2804
3714
  if (request.method === "OPTIONS") {
2805
3715
  return finish(new Response(null, { headers: corsHeaders() }));
2806
3716
  }
3717
+ if (request.method === "GET" && apiPath === "/dashboard") {
3718
+ return finish(dashboardResponse());
3719
+ }
2807
3720
  if (!isAuthorized(request, apiKey)) {
2808
3721
  requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
2809
3722
  return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
@@ -2830,6 +3743,7 @@ function createHoopilotHandler(options = {}) {
2830
3743
  client,
2831
3744
  metrics,
2832
3745
  recordTokens,
3746
+ recordExtraction,
2833
3747
  request,
2834
3748
  requestLogger,
2835
3749
  bufferProxyBodies
@@ -2845,6 +3759,7 @@ function createHoopilotHandler(options = {}) {
2845
3759
  client,
2846
3760
  metrics,
2847
3761
  recordTokens,
3762
+ recordExtraction,
2848
3763
  request,
2849
3764
  requestLogger,
2850
3765
  bufferProxyBodies
@@ -2857,6 +3772,7 @@ function createHoopilotHandler(options = {}) {
2857
3772
  client,
2858
3773
  metrics,
2859
3774
  recordTokens,
3775
+ recordExtraction,
2860
3776
  request,
2861
3777
  requestLogger,
2862
3778
  bufferProxyBodies
@@ -2865,7 +3781,14 @@ function createHoopilotHandler(options = {}) {
2865
3781
  }
2866
3782
  if (request.method === "POST" && apiPath === "/v1/responses/compact") {
2867
3783
  return finish(
2868
- await handleResponsesCompact(client, metrics, recordTokens, request, requestLogger)
3784
+ await handleResponsesCompact(
3785
+ client,
3786
+ metrics,
3787
+ recordTokens,
3788
+ recordExtraction,
3789
+ request,
3790
+ requestLogger
3791
+ )
2869
3792
  );
2870
3793
  }
2871
3794
  if (request.method === "POST" && apiPath === "/v1/responses") {
@@ -2874,6 +3797,7 @@ function createHoopilotHandler(options = {}) {
2874
3797
  client,
2875
3798
  metrics,
2876
3799
  recordTokens,
3800
+ recordExtraction,
2877
3801
  request,
2878
3802
  requestLogger,
2879
3803
  bufferProxyBodies
@@ -2950,7 +3874,7 @@ function startHoopilotServer(options = {}) {
2950
3874
  url: `http://${urlHost(host)}:${server.port}`
2951
3875
  };
2952
3876
  }
2953
- async function handleAnthropicMessages(client, metrics, recordTokens, request, logger, bufferProxyBodies) {
3877
+ async function handleAnthropicMessages(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
2954
3878
  const anthropicRequest = await readJson(request);
2955
3879
  const responsesRequest = anthropicMessagesToResponsesRequest(anthropicRequest);
2956
3880
  const upstream = await client.responses(JSON.stringify(responsesRequest), request.signal);
@@ -2963,12 +3887,18 @@ async function handleAnthropicMessages(client, metrics, recordTokens, request, l
2963
3887
  if (isStreamingResponse(upstream) && upstream.body) {
2964
3888
  if (bufferProxyBodies) {
2965
3889
  const text = await upstream.text();
2966
- recordResponseTextUsage(text, true, model, recordTokens);
3890
+ recordResponseTextUsage(text, true, model, recordTokens, recordExtraction);
2967
3891
  return proxyResponse(
2968
3892
  responseFromText(upstream, responsesSseTextToAnthropicSseText(text, { model }))
2969
3893
  );
2970
3894
  }
2971
- const observed = observeResponseUsage(upstream, model, recordTokens, request.signal);
3895
+ const observed = observeResponseUsage(
3896
+ upstream,
3897
+ model,
3898
+ recordTokens,
3899
+ request.signal,
3900
+ recordExtraction
3901
+ );
2972
3902
  if (!observed.body) {
2973
3903
  return proxyResponse(observed);
2974
3904
  }
@@ -2986,6 +3916,7 @@ async function handleAnthropicMessages(client, metrics, recordTokens, request, l
2986
3916
  const responseModel = typeof body.model === "string" ? body.model.trim() : "";
2987
3917
  recordTokens(responseModel || model, usage);
2988
3918
  }
3919
+ recordExtraction(usage !== void 0);
2989
3920
  return jsonResponse(responsesResponseToAnthropicMessage(body, model));
2990
3921
  }
2991
3922
  function handleAnthropicCountTokens(body) {
@@ -3011,7 +3942,7 @@ async function handleModels(client, metrics, signal, logger) {
3011
3942
  logUpstreamSuccess(logger, "/models", upstream.status);
3012
3943
  return jsonResponse(normalizeModelsResponse(await upstream.json()));
3013
3944
  }
3014
- async function handleChatCompletions(client, metrics, recordTokens, request, logger, bufferProxyBodies) {
3945
+ async function handleChatCompletions(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
3015
3946
  const chatRequest = normalizeChatCompletionRequest(await readJson(request));
3016
3947
  const upstream = await client.chatCompletions(chatRequest, request.signal);
3017
3948
  metrics.recordUpstream("/chat/completions", upstream.ok);
@@ -3026,11 +3957,12 @@ async function handleChatCompletions(client, metrics, recordTokens, request, log
3026
3957
  model,
3027
3958
  recordTokens,
3028
3959
  request.signal,
3029
- bufferProxyBodies
3960
+ bufferProxyBodies,
3961
+ recordExtraction
3030
3962
  )
3031
3963
  );
3032
3964
  }
3033
- async function handleCompletions(client, metrics, recordTokens, request, logger, bufferProxyBodies) {
3965
+ async function handleCompletions(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
3034
3966
  const body = await readJson(request);
3035
3967
  const upstream = await client.chatCompletions(
3036
3968
  completionsRequestToChatCompletion(body),
@@ -3045,7 +3977,7 @@ async function handleCompletions(client, metrics, recordTokens, request, logger,
3045
3977
  if (isStreamingResponse(upstream) && upstream.body) {
3046
3978
  if (bufferProxyBodies) {
3047
3979
  const upstreamText = await upstream.text();
3048
- recordResponseTextUsage(upstreamText, true, model, recordTokens);
3980
+ recordResponseTextUsage(upstreamText, true, model, recordTokens, recordExtraction);
3049
3981
  const text = completionSseTextFromChatSseText(upstreamText);
3050
3982
  return proxyResponse(responseFromText(upstream, text));
3051
3983
  }
@@ -3058,7 +3990,8 @@ async function handleCompletions(client, metrics, recordTokens, request, logger,
3058
3990
  }),
3059
3991
  model,
3060
3992
  recordTokens,
3061
- request.signal
3993
+ request.signal,
3994
+ recordExtraction
3062
3995
  )
3063
3996
  );
3064
3997
  }
@@ -3068,9 +4001,10 @@ async function handleCompletions(client, metrics, recordTokens, request, logger,
3068
4001
  const responseModel = typeof completion.model === "string" ? completion.model.trim() : "";
3069
4002
  recordTokens(responseModel || model, usage);
3070
4003
  }
4004
+ recordExtraction(usage !== void 0);
3071
4005
  return jsonResponse(chatCompletionToCompletion(completion));
3072
4006
  }
3073
- async function handleResponses(client, metrics, recordTokens, request, logger, bufferProxyBodies) {
4007
+ async function handleResponses(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
3074
4008
  const body = await readJsonText(request);
3075
4009
  const upstream = await client.responses(body, request.signal);
3076
4010
  metrics.recordUpstream("/responses", upstream.ok);
@@ -3085,11 +4019,12 @@ async function handleResponses(client, metrics, recordTokens, request, logger, b
3085
4019
  model,
3086
4020
  recordTokens,
3087
4021
  request.signal,
3088
- bufferProxyBodies
4022
+ bufferProxyBodies,
4023
+ recordExtraction
3089
4024
  )
3090
4025
  );
3091
4026
  }
3092
- async function handleResponsesCompact(client, metrics, recordTokens, request, logger) {
4027
+ async function handleResponsesCompact(client, metrics, recordTokens, recordExtraction, request, logger) {
3093
4028
  const body = await readJson(request);
3094
4029
  const upstream = await client.responses(
3095
4030
  JSON.stringify({ ...body, stream: false }),
@@ -3102,17 +4037,23 @@ async function handleResponsesCompact(client, metrics, recordTokens, request, lo
3102
4037
  logUpstreamSuccess(logger, "/responses", upstream.status);
3103
4038
  const isSse = isStreamingResponse(upstream);
3104
4039
  const text = await upstream.text();
3105
- recordResponseTextUsage(text, isSse, normalizeRequestedModel(body.model), recordTokens);
4040
+ recordResponseTextUsage(
4041
+ text,
4042
+ isSse,
4043
+ normalizeRequestedModel(body.model),
4044
+ recordTokens,
4045
+ recordExtraction
4046
+ );
3106
4047
  return jsonResponse(responsesCompactionResult(text, isSse));
3107
4048
  }
3108
- async function responseWithObservedUsage(response, fallbackModel, recordTokens, signal, bufferBody) {
4049
+ async function responseWithObservedUsage(response, fallbackModel, recordTokens, signal, bufferBody, recordExtraction) {
3109
4050
  const isSse = isStreamingResponse(response);
3110
4051
  if (bufferBody && response.body) {
3111
4052
  const text = await response.text();
3112
- recordResponseTextUsage(text, isSse, fallbackModel, recordTokens);
4053
+ recordResponseTextUsage(text, isSse, fallbackModel, recordTokens, recordExtraction);
3113
4054
  return responseFromText(response, text);
3114
4055
  }
3115
- return observeResponseUsage(response, fallbackModel, recordTokens, signal);
4056
+ return observeResponseUsage(response, fallbackModel, recordTokens, signal, recordExtraction);
3116
4057
  }
3117
4058
  function responseFromText(source, text) {
3118
4059
  return new Response(text, {
@@ -3468,6 +4409,9 @@ function routeFor(method, path) {
3468
4409
  if (method === "GET" && (path === "/" || path === "/healthz")) {
3469
4410
  return "health";
3470
4411
  }
4412
+ if (method === "GET" && path === "/dashboard") {
4413
+ return "dashboard";
4414
+ }
3471
4415
  if (method === "GET" && path === "/metrics") {
3472
4416
  return "metrics";
3473
4417
  }
@@ -3522,10 +4466,28 @@ function metricsResponse(metrics) {
3522
4466
  status: 200
3523
4467
  });
3524
4468
  }
4469
+ function dashboardResponse() {
4470
+ return new Response(DASHBOARD_HTML, {
4471
+ headers: {
4472
+ ...corsHeaders(),
4473
+ "content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
4474
+ "content-type": "text/html; charset=utf-8",
4475
+ "referrer-policy": "no-referrer",
4476
+ "x-content-type-options": "nosniff",
4477
+ "x-frame-options": "DENY"
4478
+ },
4479
+ status: 200
4480
+ });
4481
+ }
3525
4482
  async function handleUsage(metrics, readUsage, signal) {
3526
4483
  const { copilot, error } = await readUsage(signal);
3527
4484
  const proxy = metrics.snapshot();
3528
- const body = { copilot: copilot ?? null, object: "usage", proxy };
4485
+ const body = {
4486
+ copilot: copilot ?? null,
4487
+ object: "usage",
4488
+ proxy,
4489
+ version: await getVersion()
4490
+ };
3529
4491
  if (error) {
3530
4492
  body.copilot_error = error;
3531
4493
  }