@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.cjs CHANGED
@@ -2297,6 +2297,7 @@ var MetricsRegistry = class {
2297
2297
  #upstream = /* @__PURE__ */ new Map();
2298
2298
  #copilotQuota;
2299
2299
  #githubRateLimit = /* @__PURE__ */ new Map();
2300
+ #extraction = { extracted: 0, missing: 0 };
2300
2301
  constructor(options = {}) {
2301
2302
  this.#startedAtMs = (options.now ?? Date.now)();
2302
2303
  }
@@ -2313,6 +2314,19 @@ var MetricsRegistry = class {
2313
2314
  this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
2314
2315
  this.#observeDuration(observation.route, observation.durationMs / 1e3);
2315
2316
  }
2317
+ /**
2318
+ * Record whether one upstream completion reported token usage. `missing`
2319
+ * counts responses that carried no usage object — most often streamed Chat
2320
+ * Completions sent without `stream_options: {"include_usage": true}` — so a
2321
+ * rising miss rate flags clients whose token usage is going unaccounted.
2322
+ */
2323
+ recordTokenExtraction(extracted) {
2324
+ if (extracted) {
2325
+ this.#extraction.extracted += 1;
2326
+ } else {
2327
+ this.#extraction.missing += 1;
2328
+ }
2329
+ }
2316
2330
  /** Accumulate token counts for a model from one upstream completion. */
2317
2331
  recordTokens(model, usage) {
2318
2332
  const name = this.#modelLabel(model);
@@ -2418,13 +2432,45 @@ var MetricsRegistry = class {
2418
2432
  return {
2419
2433
  githubRateLimit,
2420
2434
  inFlight: this.#inFlight,
2435
+ latency: this.#latencySnapshot(),
2421
2436
  requests: { byRoute, byStatus, total: requestsTotal },
2422
2437
  startedAt: new Date(this.#startedAtMs).toISOString(),
2423
- tokens: { byModel, ...tokenTotals },
2438
+ tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
2424
2439
  upstream: { errors: upstreamErrors, total: upstreamTotal },
2425
2440
  uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
2426
2441
  };
2427
2442
  }
2443
+ // Summarize the duration histogram into a JSON latency view: per-route count and
2444
+ // exact average, plus overall average and estimated p50/p95. The percentiles come
2445
+ // from the buckets aggregated across routes, so they share /metrics' resolution.
2446
+ #latencySnapshot() {
2447
+ const byRoute = {};
2448
+ const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
2449
+ let totalCount = 0;
2450
+ let totalSum = 0;
2451
+ for (const [route, entry] of this.#durations) {
2452
+ byRoute[route] = {
2453
+ avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
2454
+ count: entry.count
2455
+ };
2456
+ totalCount += entry.count;
2457
+ totalSum += entry.sum;
2458
+ for (let i = 0; i < aggregateBuckets.length; i += 1) {
2459
+ aggregateBuckets[i] = (aggregateBuckets[i] ?? 0) + (entry.buckets[i] ?? 0);
2460
+ }
2461
+ }
2462
+ return {
2463
+ avgMs: totalCount > 0 ? round2(totalSum / totalCount * 1e3) : 0,
2464
+ byRoute,
2465
+ count: totalCount,
2466
+ p50Ms: round2(
2467
+ quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.5) * 1e3
2468
+ ),
2469
+ p95Ms: round2(
2470
+ quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.95) * 1e3
2471
+ )
2472
+ };
2473
+ }
2428
2474
  /** Render the Prometheus text exposition format (version 0.0.4). */
2429
2475
  renderPrometheus(now = Date.now) {
2430
2476
  const lines = [];
@@ -2470,6 +2516,16 @@ var MetricsRegistry = class {
2470
2516
  for (const [model, totals] of this.#tokens) {
2471
2517
  lines.push(`hoopilot_model_requests_total${labels({ model })} ${totals.requests}`);
2472
2518
  }
2519
+ lines.push(
2520
+ "# HELP hoopilot_token_extraction_total Completions by whether upstream reported token usage."
2521
+ );
2522
+ lines.push("# TYPE hoopilot_token_extraction_total counter");
2523
+ lines.push(
2524
+ `hoopilot_token_extraction_total${labels({ outcome: "extracted" })} ${this.#extraction.extracted}`
2525
+ );
2526
+ lines.push(
2527
+ `hoopilot_token_extraction_total${labels({ outcome: "missing" })} ${this.#extraction.missing}`
2528
+ );
2473
2529
  lines.push("# HELP hoopilot_request_duration_seconds Request duration by route.");
2474
2530
  lines.push("# TYPE hoopilot_request_duration_seconds histogram");
2475
2531
  for (const [route, entry] of this.#durations) {
@@ -2625,23 +2681,25 @@ var MetricsRegistry = class {
2625
2681
  }
2626
2682
  }
2627
2683
  };
2628
- function observeResponseUsage(response, fallbackModel, onUsage, signal) {
2684
+ function observeResponseUsage(response, fallbackModel, onUsage, signal, onOutcome) {
2629
2685
  const body = response.body;
2630
2686
  if (!body) {
2631
2687
  return response;
2632
2688
  }
2633
2689
  const [clientBranch, observerBranch] = body.tee();
2634
2690
  const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
2635
- void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal).catch(() => {
2636
- });
2691
+ void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal, onOutcome).catch(
2692
+ () => {
2693
+ }
2694
+ );
2637
2695
  return new Response(clientBranch, {
2638
2696
  headers: response.headers,
2639
2697
  status: response.status,
2640
2698
  statusText: response.statusText
2641
2699
  });
2642
2700
  }
2643
- function recordResponseTextUsage(text, isSse, fallbackModel, onUsage) {
2644
- const accumulator = createUsageAccumulator(fallbackModel, onUsage);
2701
+ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome) {
2702
+ const accumulator = createUsageAccumulator(fallbackModel, onUsage, onOutcome);
2645
2703
  if (isSse) {
2646
2704
  for (const line of text.split(/\r?\n/)) {
2647
2705
  considerSseLine(line, accumulator.consider);
@@ -2654,7 +2712,7 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage) {
2654
2712
  }
2655
2713
  accumulator.finish();
2656
2714
  }
2657
- async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal) {
2715
+ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
2658
2716
  const reader = stream.getReader();
2659
2717
  const onAbort = () => {
2660
2718
  reader.cancel().catch(() => {
@@ -2667,7 +2725,12 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal) {
2667
2725
  signal?.addEventListener("abort", onAbort, { once: true });
2668
2726
  }
2669
2727
  const decoder = new TextDecoder();
2670
- const accumulator = createUsageAccumulator(fallbackModel, onUsage);
2728
+ const guardedOutcome = onOutcome ? (extracted) => {
2729
+ if (!signal?.aborted) {
2730
+ onOutcome(extracted);
2731
+ }
2732
+ } : void 0;
2733
+ const accumulator = createUsageAccumulator(fallbackModel, onUsage, guardedOutcome);
2671
2734
  let buffer = "";
2672
2735
  let bufferedBytes = 0;
2673
2736
  let overflowed = false;
@@ -2715,7 +2778,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal) {
2715
2778
  }
2716
2779
  accumulator.finish();
2717
2780
  }
2718
- function createUsageAccumulator(fallbackModel, onUsage) {
2781
+ function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
2719
2782
  let model = fallbackModel;
2720
2783
  let usage;
2721
2784
  return {
@@ -2734,6 +2797,7 @@ function createUsageAccumulator(fallbackModel, onUsage) {
2734
2797
  if (usage) {
2735
2798
  onUsage(model, usage);
2736
2799
  }
2800
+ onOutcome?.(usage !== void 0);
2737
2801
  }
2738
2802
  };
2739
2803
  }
@@ -2764,6 +2828,26 @@ function modelText(value) {
2764
2828
  function nonNegative(value) {
2765
2829
  return Number.isFinite(value) && value > 0 ? value : 0;
2766
2830
  }
2831
+ function round2(value) {
2832
+ return Math.round(value * 100) / 100;
2833
+ }
2834
+ function quantileFromBuckets(bucketCounts, bounds, count, q) {
2835
+ if (count <= 0) {
2836
+ return 0;
2837
+ }
2838
+ const rank = q * count;
2839
+ let cumulative = 0;
2840
+ for (let i = 0; i < bounds.length; i += 1) {
2841
+ const inBucket = bucketCounts[i] ?? 0;
2842
+ if (inBucket > 0 && cumulative + inBucket >= rank) {
2843
+ const lower = i === 0 ? 0 : bounds[i - 1] ?? 0;
2844
+ const upper = bounds[i] ?? lower;
2845
+ return lower + (upper - lower) * ((rank - cumulative) / inBucket);
2846
+ }
2847
+ cumulative += inBucket;
2848
+ }
2849
+ return bounds[bounds.length - 1] ?? 0;
2850
+ }
2767
2851
  function cleanLabel(value) {
2768
2852
  let result = "";
2769
2853
  for (const char of value) {
@@ -2813,9 +2897,835 @@ function formatNumber(value) {
2813
2897
  return Number.isInteger(value) ? value.toString() : String(value);
2814
2898
  }
2815
2899
 
2900
+ // src/dashboard.ts
2901
+ var DASHBOARD_HTML = `<!doctype html>
2902
+ <html lang="en">
2903
+ <head>
2904
+ <meta charset="utf-8" />
2905
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2906
+ <meta name="color-scheme" content="dark light" />
2907
+ <title>hoopilot &middot; dashboard</title>
2908
+ <style>
2909
+ :root {
2910
+ --bg-0:#0b0e14; --bg-1:#11151c; --bg-2:#171c25; --bg-3:#1f2630;
2911
+ --border:#262d38; --border-strong:#37404d;
2912
+ --text-0:#e6edf3; --text-1:#9aa7b4; --text-2:#5e6b78; --text-dim:#3a434e; --text-inv:#0b0e14;
2913
+ --accent:#4ea1ff; --accent-2:#56d4dd; --accent-soft:rgba(78,161,255,.14);
2914
+ --amber:#f5b042;
2915
+ --ok:#3fb950; --warn:#d8a13a; --danger:#f0556a; --info:#a371f7; --cache:#7c8cff;
2916
+ --spark:#4ea1ff; --spark-fill:color-mix(in srgb, var(--accent) 14%, transparent);
2917
+ --grid-line:rgba(255,255,255,.05);
2918
+ --flash:color-mix(in srgb, var(--accent) 22%, transparent);
2919
+ --flash-up:color-mix(in srgb, var(--ok) 22%, transparent);
2920
+ --flash-down:color-mix(in srgb, var(--danger) 22%, transparent);
2921
+ --c1:#4ea1ff; --c2:#3fb950; --c3:#d8a13a; --c4:#a371f7; --c5:#56d4dd; --c6:#f0556a;
2922
+ --mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
2923
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, system-ui, sans-serif;
2924
+ }
2925
+ @media (prefers-color-scheme: light) {
2926
+ :root:not([data-theme="dark"]) {
2927
+ --bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
2928
+ --border:#d0d7de; --border-strong:#b6bec8;
2929
+ --text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
2930
+ --accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
2931
+ --amber:#b5730a;
2932
+ --ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
2933
+ --spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
2934
+ --grid-line:rgba(0,0,0,.06);
2935
+ --flash:color-mix(in srgb, var(--accent) 16%, transparent);
2936
+ --flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
2937
+ --flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
2938
+ --c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
2939
+ }
2940
+ }
2941
+ [data-theme="light"] {
2942
+ --bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
2943
+ --border:#d0d7de; --border-strong:#b6bec8;
2944
+ --text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
2945
+ --accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
2946
+ --amber:#b5730a;
2947
+ --ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
2948
+ --spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
2949
+ --grid-line:rgba(0,0,0,.06);
2950
+ --flash:color-mix(in srgb, var(--accent) 16%, transparent);
2951
+ --flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
2952
+ --flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
2953
+ --c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
2954
+ }
2955
+ * { box-sizing: border-box; }
2956
+ html, body { margin:0; padding:0; }
2957
+ body {
2958
+ background: var(--bg-0); color: var(--text-0); font-family: var(--sans);
2959
+ font-size: 13px; line-height: 1.4; -webkit-font-smoothing: antialiased;
2960
+ }
2961
+ .mono { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
2962
+ .num { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
2963
+ .shell { max-width: 1280px; margin: 0 auto; padding: 0 24px 28px; }
2964
+ @media (min-width: 1080px) { .shell { border-left:1px solid var(--border); border-right:1px solid var(--border); } }
2965
+ @media (max-width: 680px) { .shell { padding: 0 12px 24px; } }
2966
+
2967
+ /* header */
2968
+ header.bar {
2969
+ position: sticky; top: 0; z-index: 20; background: var(--bg-1);
2970
+ border-bottom: 1px solid var(--border); height: 48px;
2971
+ }
2972
+ .bar-in { max-width:1280px; margin:0 auto; height:48px; padding:0 24px; display:flex; align-items:center; gap:12px; }
2973
+ @media (max-width:680px){ .bar-in{ padding:0 12px; gap:8px; } }
2974
+ .wordmark { font-family: var(--mono); font-weight:700; font-size:14px; color:var(--text-0); letter-spacing:-.01em; }
2975
+ .caret { display:inline-block; width:7px; height:15px; background:var(--amber); margin-left:3px; vertical-align:-2px; animation: blink 1.1s steps(1) infinite; }
2976
+ .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; }
2977
+ .chip.plan-pro { background:var(--accent-soft); color:var(--accent); }
2978
+ .chip.plan-business { background:color-mix(in srgb, var(--info) 16%, transparent); color:var(--info); }
2979
+ .chip.plan-free, .chip.plan-offline { background:var(--bg-3); color:var(--text-2); }
2980
+ .spacer { flex:1; }
2981
+ .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); }
2982
+ .dot { width:7px; height:7px; border-radius:50%; background:var(--text-2); flex:none; }
2983
+ .pill.live .dot { background:var(--ok); }
2984
+ .pill.paused .dot { background:var(--text-2); }
2985
+ .pill.reconnect { color:var(--warn); } .pill.reconnect .dot { background:var(--warn); }
2986
+ .pill.authkey { color:var(--warn); } .pill.authkey .dot { background:var(--warn); }
2987
+ .heartbeat { animation: hb .5s ease-out; }
2988
+ .updated { font-family:var(--mono); font-size:11px; color:var(--text-2); white-space:nowrap; }
2989
+ .updated.warn { color:var(--warn); } .updated.danger { color:var(--danger); }
2990
+ .seg { display:inline-flex; border:1px solid var(--border); border-radius:6px; overflow:hidden; }
2991
+ .seg button { background:transparent; color:var(--text-1); border:0; font-family:var(--mono); font-size:11px; padding:3px 8px; cursor:pointer; }
2992
+ .seg button + button { border-left:1px solid var(--border); }
2993
+ .seg button.active { background:var(--accent); color:var(--text-inv); }
2994
+ .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; }
2995
+ .iconbtn:hover { background:var(--bg-3); }
2996
+ button:focus-visible, input:focus-visible, .seg button:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
2997
+ #scanbar { position:absolute; left:0; bottom:-1px; height:1px; width:100%; overflow:hidden; }
2998
+ #scanbar::after { content:""; position:absolute; left:0; top:0; height:1px; width:40%;
2999
+ background:linear-gradient(90deg, transparent, var(--accent), transparent);
3000
+ animation: scan var(--scan-ms, 4000ms) linear infinite; }
3001
+ header.bar.paused #scanbar::after, header.bar.frozen #scanbar::after { animation-play-state:paused; opacity:.35; }
3002
+
3003
+ /* disconnect banner */
3004
+ #banner { display:none; margin-top:10px; padding:7px 12px; border-radius:5px; font-family:var(--mono); font-size:12px;
3005
+ background:color-mix(in srgb, var(--danger) 16%, transparent); color:var(--danger); border:1px solid color-mix(in srgb, var(--danger) 40%, transparent); }
3006
+ #banner.ok { background:color-mix(in srgb, var(--ok) 16%, transparent); color:var(--ok); border-color:color-mix(in srgb, var(--ok) 40%, transparent); }
3007
+ #banner.show { display:block; }
3008
+
3009
+ /* hero strip */
3010
+ .hero { display:grid; grid-template-columns:repeat(4,1fr); margin:18px 0 16px; }
3011
+ .vital { padding:6px 18px; }
3012
+ .vital + .vital { border-left:1px solid var(--border); }
3013
+ .vital .eyebrow { font-size:10px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-1); }
3014
+ .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); }
3015
+ .vital .vsub { font-size:11px; color:var(--text-2); min-height:14px; }
3016
+ .vital .vspark { display:block; width:100%; height:24px; margin-top:4px; }
3017
+ .vital.active { }
3018
+ .vital.active .eyebrow { color:var(--accent); }
3019
+ @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; } }
3020
+ @media (max-width:600px){ .hero{ grid-template-columns:1fr; } .vital + .vital{ border-left:0; border-top:1px solid var(--border); } }
3021
+
3022
+ /* grid + panels */
3023
+ .grid { display:grid; grid-template-columns:repeat(12,1fr); gap:12px; }
3024
+ .panel { position:relative; background:var(--bg-1); border:1px solid var(--border); border-radius:4px; padding:16px 12px 12px; min-width:0; }
3025
+ .panel > .ptitle { position:absolute; top:-8px; left:10px; padding:0 6px; background:var(--bg-1);
3026
+ font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
3027
+ .span5{ grid-column:span 5; } .span3{ grid-column:span 3; } .span4{ grid-column:span 4; }
3028
+ .span7{ grid-column:span 7; } .span8{ grid-column:span 8; }
3029
+ @media (max-width:1079px){ .grid{ grid-template-columns:repeat(6,1fr); }
3030
+ .span5,.span7,.span8{ grid-column:span 6; } .span3{ grid-column:span 3; } .span4{ grid-column:span 6; } }
3031
+ @media (max-width:680px){ .grid{ grid-template-columns:1fr; }
3032
+ .span3,.span4,.span5,.span7,.span8{ grid-column:span 1; } }
3033
+
3034
+ .headline { font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-weight:600; font-size:22px; line-height:1.1; }
3035
+ .cap { font-size:11px; color:var(--text-2); }
3036
+ .stack-bar { display:flex; height:8px; border-radius:4px; overflow:hidden; background:var(--bg-3); margin:8px 0; }
3037
+ .stack-bar i { display:block; height:100%; }
3038
+ .stack-bar.empty { outline:1px dashed var(--border); background:transparent; }
3039
+
3040
+ table.tbl { width:100%; border-collapse:collapse; font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-size:12px; }
3041
+ .scrollx { overflow-x:auto; }
3042
+ 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; }
3043
+ table.tbl th.l { text-align:left; }
3044
+ table.tbl td { padding:3px 6px; text-align:right; white-space:nowrap; border-bottom:1px solid color-mix(in srgb, var(--border) 55%, transparent); }
3045
+ table.tbl td.l { text-align:left; max-width:160px; overflow:hidden; text-overflow:ellipsis; }
3046
+ table.tbl tr:hover td { background:var(--bg-2); }
3047
+ table.tbl tr.total td { border-top:1px solid var(--border-strong); border-bottom:0; font-weight:600; color:var(--text-0); }
3048
+ .minibar { display:inline-block; height:6px; border-radius:3px; background:var(--accent); vertical-align:middle; min-width:1px; }
3049
+ .ghost td { color:var(--text-2); text-align:center; }
3050
+ .reasoning { color:var(--info); } .cached { color:var(--cache); }
3051
+
3052
+ .legend { display:flex; flex-wrap:wrap; gap:4px 14px; margin-top:8px; }
3053
+ .legend .li { display:flex; align-items:center; gap:6px; font-family:var(--mono); font-size:11px; color:var(--text-1); }
3054
+ .legend .sw { width:8px; height:8px; border-radius:2px; flex:none; }
3055
+
3056
+ .lat-trio { display:flex; gap:18px; align-items:baseline; }
3057
+ .lat-trio .b { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
3058
+ .lat-trio .b small { display:block; font-size:10px; font-weight:600; text-transform:uppercase; color:var(--text-2); letter-spacing:.05em; }
3059
+ .lat-p95 { color:var(--info); }
3060
+ .lat-track { position:relative; height:22px; margin-top:10px; }
3061
+ .lat-track .line { position:absolute; top:11px; left:0; right:0; height:1px; background:var(--border); }
3062
+ .lat-track .tick { position:absolute; top:5px; width:2px; height:12px; border-radius:1px; }
3063
+ .lat-track .tick.p50 { background:var(--accent); } .lat-track .tick.p95 { background:var(--info); }
3064
+ .lat-track .tlab { position:absolute; top:-2px; font-family:var(--mono); font-size:9px; color:var(--text-2); transform:translateX(-50%); }
3065
+ details.routes { margin-top:10px; } details.routes summary { cursor:pointer; font-size:11px; color:var(--text-2); font-family:var(--mono); }
3066
+
3067
+ .qrow { margin:10px 0; } .qrow .qhead { display:flex; justify-content:space-between; align-items:baseline; font-size:12px; }
3068
+ .qrow .qname { color:var(--text-1); } .qrow .qval { font-family:var(--mono); font-variant-numeric:tabular-nums; color:var(--text-0); }
3069
+ .qbar { position:relative; height:8px; border-radius:4px; background:var(--bg-3); margin-top:5px; overflow:hidden; }
3070
+ .qbar i { position:absolute; left:0; top:0; height:100%; border-radius:4px; }
3071
+ .qbar.over i.ext { background:repeating-linear-gradient(45deg, var(--danger), var(--danger) 3px, transparent 3px, transparent 6px); }
3072
+ .inf { font-family:var(--mono); font-size:12px; color:var(--ok); }
3073
+ .emptybox { border:1px solid var(--border); border-radius:5px; padding:14px; text-align:center; color:var(--text-2); }
3074
+ .emptybox .keyglyph { font-size:20px; color:var(--text-1); }
3075
+ .emptybox h4 { margin:8px 0 4px; font-family:var(--sans); font-size:13px; color:var(--text-1); font-weight:600; }
3076
+ .emptybox .errline { font-family:var(--mono); font-size:11px; color:var(--text-2); word-break:break-word; margin:4px 0; }
3077
+ .prompt { font-family:var(--mono); font-size:12px; color:var(--text-1); }
3078
+
3079
+ .upblocks { display:flex; gap:18px; }
3080
+ .upblk { } .upblk .v { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
3081
+ .upblk .k { font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-2); }
3082
+ .upblk.err.hot { color:var(--danger); }
3083
+ .rate { font-family:var(--mono); font-size:12px; } .rate.warn{ color:var(--warn);} .rate.danger{ color:var(--danger);} .rate.ok{ color:var(--ok); }
3084
+ #up-spark, #thru-svg { display:block; width:100%; }
3085
+ #up-spark { height:30px; margin-top:8px; }
3086
+ #thru-svg { height:88px; margin-top:6px; }
3087
+ .flag { font-family:var(--mono); font-size:10px; color:var(--text-2); }
3088
+
3089
+ footer.foot { margin-top:14px; padding-top:10px; border-top:1px solid var(--border); display:flex; flex-wrap:wrap; gap:4px 14px;
3090
+ font-family:var(--mono); font-size:11px; color:var(--text-2); }
3091
+ footer.foot .end { margin-left:auto; }
3092
+ @media (max-width:680px){ footer.foot .end{ margin-left:0; } }
3093
+
3094
+ .skel { color:var(--text-dim); }
3095
+ .flash { animation: flash .6s ease-out; } .flash-up { animation: flashup .6s ease-out; } .flash-down { animation: flashdown .6s ease-out; }
3096
+
3097
+ /* auth takeover */
3098
+ #auth { display:none; }
3099
+ #auth.show { display:flex; justify-content:center; padding:64px 16px; }
3100
+ .authcard { width:100%; max-width:420px; background:var(--bg-1); border:1px solid var(--border); border-radius:6px; padding:22px 18px; position:relative; }
3101
+ .authcard h3 { margin:0 0 10px; font-family:var(--mono); font-size:12px; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
3102
+ .authcard p { font-size:12px; color:var(--text-2); margin:0 0 14px; }
3103
+ .authcard .row { display:flex; gap:8px; }
3104
+ .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; }
3105
+ .authcard input.bad { border-color:var(--danger); }
3106
+ .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; }
3107
+ .authcard .err { color:var(--danger); font-family:var(--mono); font-size:11px; min-height:14px; margin-top:8px; }
3108
+ .authcard .clear { position:absolute; top:14px; right:16px; font-size:11px; color:var(--text-2); cursor:pointer; }
3109
+ .dim { opacity:.45; filter:grayscale(.4); transition:opacity .2s, filter .2s; }
3110
+
3111
+ @keyframes blink { 50% { opacity:0; } }
3112
+ @keyframes scan { 0%{ transform:translateX(-100%);} 100%{ transform:translateX(350%);} }
3113
+ @keyframes hb { 0%{ transform:scale(1);} 35%{ transform:scale(1.7);} 100%{ transform:scale(1);} }
3114
+ @keyframes flash { from{ background:var(--flash);} to{ background:transparent;} }
3115
+ @keyframes flashup { from{ background:var(--flash-up);} to{ background:transparent;} }
3116
+ @keyframes flashdown { from{ background:var(--flash-down);} to{ background:transparent;} }
3117
+ @media (prefers-reduced-motion: reduce) {
3118
+ .caret { animation:none; } #scanbar::after { animation:none; opacity:.3; }
3119
+ .heartbeat { animation:none; }
3120
+ .flash, .flash-up, .flash-down { animation:none; box-shadow: inset 2px 0 0 var(--accent); }
3121
+ }
3122
+ </style>
3123
+ </head>
3124
+ <body>
3125
+ <header class="bar" id="bar">
3126
+ <div class="bar-in">
3127
+ <span class="wordmark">hoopilot<span class="caret" aria-hidden="true"></span></span>
3128
+ <span class="chip" id="version-chip">v&middot;&middot;&middot;</span>
3129
+ <span class="chip plan-offline" id="plan-chip">&mdash; offline</span>
3130
+ <span class="spacer"></span>
3131
+ <span class="pill" id="conn-pill" aria-live="polite"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting</span></span>
3132
+ <span class="updated" id="updated"></span>
3133
+ <span class="seg" id="seg" role="group" aria-label="Refresh interval">
3134
+ <button data-ms="2000">2s</button><button data-ms="4000" class="active">4s</button><button data-ms="10000">10s</button>
3135
+ </span>
3136
+ <button class="iconbtn" id="btn-pause" title="Pause / resume" aria-label="Pause or resume">&#10074;&#10074;</button>
3137
+ <button class="iconbtn" id="btn-theme" title="Theme: auto / dark / light" aria-label="Cycle theme">A</button>
3138
+ </div>
3139
+ <div id="scanbar" aria-hidden="true"></div>
3140
+ </header>
3141
+
3142
+ <div class="shell">
3143
+ <div id="banner" role="status" aria-live="polite"></div>
3144
+
3145
+ <section id="content">
3146
+ <section class="hero" aria-label="Vitals">
3147
+ <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>
3148
+ <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>
3149
+ <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>
3150
+ <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>
3151
+ </section>
3152
+
3153
+ <section class="grid">
3154
+ <div class="panel span5"><span class="ptitle">&#9508; Proxy &middot; requests &#9504;</span>
3155
+ <div class="headline"><span id="req-total" class="skel">&middot;&middot;&middot;</span> <span class="cap">requests</span></div>
3156
+ <div class="stack-bar empty" id="route-sharebar"></div>
3157
+ <div class="stack-bar empty" id="status-healthbar"></div>
3158
+ <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>
3159
+ </div>
3160
+
3161
+ <div class="panel span3"><span class="ptitle">&#9508; Status &#9504;</span>
3162
+ <div class="headline"><span id="error-rate" class="skel">&middot;&middot;&middot;</span> <span class="cap">err rate</span></div>
3163
+ <div class="stack-bar empty" id="status-bar"></div>
3164
+ <div class="legend" id="status-legend"></div>
3165
+ </div>
3166
+
3167
+ <div class="panel span4"><span class="ptitle">&#9508; Latency &middot; ms &#9504;</span>
3168
+ <div class="lat-trio">
3169
+ <div class="b"><small>p50</small><span id="lat-p50" class="skel">&middot;</span></div>
3170
+ <div class="b lat-p95"><small>p95</small><span id="lat-p95" class="skel">&middot;</span></div>
3171
+ <div class="b"><small>avg</small><span id="lat-avg" class="skel">&middot;</span></div>
3172
+ <div class="b"><small>obs</small><span id="lat-count" class="skel">&middot;</span></div>
3173
+ </div>
3174
+ <div class="lat-track" id="lat-track"><div class="line"></div></div>
3175
+ <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>
3176
+ </div>
3177
+
3178
+ <div class="panel span7"><span class="ptitle">&#9508; Tokens &middot; by model &#9504;</span>
3179
+ <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>
3180
+ <div class="stack-bar empty" id="tok-mixbar"></div>
3181
+ <div class="legend" id="tok-legend"></div>
3182
+ <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>
3183
+ </div>
3184
+
3185
+ <div class="panel span5"><span class="ptitle">&#9508; Copilot &middot; quota &#9504;</span>
3186
+ <div id="copilot-body"><div class="emptybox skel">loading&hellip;</div></div>
3187
+ </div>
3188
+
3189
+ <div class="panel span4"><span class="ptitle">&#9508; Upstream &middot; copilot edge &#9504;</span>
3190
+ <div class="upblocks">
3191
+ <div class="upblk"><div class="v" id="up-total">&middot;</div><div class="k">calls</div></div>
3192
+ <div class="upblk err" id="up-errblk"><div class="v" id="up-errors">&middot;</div><div class="k">errors</div></div>
3193
+ <div class="upblk"><div class="v rate" id="up-rate">&middot;</div><div class="k">err rate</div></div>
3194
+ </div>
3195
+ <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>
3196
+ <div class="flag" id="up-flag"></div>
3197
+ </div>
3198
+
3199
+ <div class="panel span8"><span class="ptitle">&#9508; Throughput &#9504;</span>
3200
+ <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>
3201
+ <svg id="thru-svg" viewBox="0 0 320 88" preserveAspectRatio="none" aria-hidden="true">
3202
+ <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>
3203
+ <line class="grid" x1="0" y1="22" x2="320" y2="22" stroke="var(--grid-line)"/>
3204
+ <line class="grid" x1="0" y1="44" x2="320" y2="44" stroke="var(--grid-line)"/>
3205
+ <line class="grid" x1="0" y1="66" x2="320" y2="66" stroke="var(--grid-line)"/>
3206
+ <path id="thru-tok-area" fill="url(#thrugrad)" stroke="none"/>
3207
+ <path id="thru-tok-line" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>
3208
+ <path id="thru-req-line" fill="none" stroke="var(--accent-2)" stroke-width="1.2" vector-effect="non-scaling-stroke" opacity="0.9"/>
3209
+ </svg>
3210
+ </div>
3211
+ </section>
3212
+ </section>
3213
+
3214
+ <section id="auth" aria-live="polite">
3215
+ <div class="authcard">
3216
+ <span class="clear" id="auth-clear" style="display:none">clear key</span>
3217
+ <h3>&#9508; Auth required &#9504;</h3>
3218
+ <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>
3219
+ <div class="row"><input id="auth-input" type="password" placeholder="x-api-key" autocomplete="off" spellcheck="false" /><button id="auth-connect">connect</button></div>
3220
+ <div class="err" id="auth-err"></div>
3221
+ </div>
3222
+ </section>
3223
+
3224
+ <footer class="foot">
3225
+ <span id="foot-started">started &middot;</span>
3226
+ <span id="foot-uptime">uptime &middot;</span>
3227
+ <span id="foot-total">&middot; req</span>
3228
+ <span id="foot-tokens">&middot; tokens</span>
3229
+ <span id="foot-upstream">upstream &middot;</span>
3230
+ <span class="end" id="foot-cadence"></span>
3231
+ </footer>
3232
+ </div>
3233
+
3234
+ <script>
3235
+ (function(){
3236
+ "use strict";
3237
+ var byId = function(id){ return document.getElementById(id); };
3238
+ var CAP = 60;
3239
+
3240
+ // ---- persistent state ----
3241
+ var LS = window.localStorage;
3242
+ var apiKey = "";
3243
+ try { apiKey = LS.getItem("hoopilot.apiKey") || ""; } catch (e) { apiKey = ""; }
3244
+ var theme = "auto";
3245
+ try { theme = LS.getItem("hoopilot.theme") || "auto"; } catch (e) { theme = "auto"; }
3246
+ var intervalMs = 4000;
3247
+ try { var sv = parseInt(LS.getItem("hoopilot.intervalMs") || "", 10); if (sv === 2000 || sv === 4000 || sv === 10000) intervalMs = sv; } catch (e) {}
3248
+
3249
+ // ---- runtime state ----
3250
+ var paused = false;
3251
+ var timer = null;
3252
+ var inflightFetch = null;
3253
+ var lastSuccessAt = 0;
3254
+ var prevSample = null; // { t, reqTotal, tokTotal, upTotal, startedAt }
3255
+ var lastRender = {}; // for change-flash
3256
+ var backoffMs = 0;
3257
+ var lastUptime = null; // seconds; ticked locally between polls
3258
+ var hist = { req:[], tok:[], inflight:[], up:[] };
3259
+
3260
+ // ---- formatting helpers ----
3261
+ function humanInt(n){
3262
+ if (n === null || n === undefined || !isFinite(n)) return "0";
3263
+ var a = Math.abs(n);
3264
+ if (a >= 1000000) return (n/1000000).toFixed(a >= 10000000 ? 0 : 1) + "M";
3265
+ if (a >= 1000) return (n/1000).toFixed(a >= 10000 ? 0 : 1) + "k";
3266
+ return String(Math.round(n));
3267
+ }
3268
+ function rate(n){
3269
+ if (n === null || n === undefined || !isFinite(n)) return "0";
3270
+ if (n >= 100) return String(Math.round(n));
3271
+ if (n >= 10) return n.toFixed(1);
3272
+ return n.toFixed(2);
3273
+ }
3274
+ function pct(n){ if (!isFinite(n)) return "0%"; return (n >= 10 ? Math.round(n) : Math.round(n*10)/10) + "%"; }
3275
+ 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 + ""; }
3276
+ function pad2(n){ return (n < 10 ? "0" : "") + n; }
3277
+ function fmtUptime(sec){
3278
+ sec = Math.max(0, Math.floor(sec));
3279
+ var d = Math.floor(sec/86400); sec -= d*86400;
3280
+ var h = Math.floor(sec/3600); sec -= h*3600;
3281
+ var m = Math.floor(sec/60); var s = sec - m*60;
3282
+ if (d > 0) return d + "d " + pad2(h) + ":" + pad2(m);
3283
+ if (h > 0) return h + ":" + pad2(m) + ":" + pad2(s);
3284
+ return m + ":" + pad2(s);
3285
+ }
3286
+ function titleize(key){
3287
+ var map = { premium_interactions:"Premium requests", chat:"Chat", completions:"Completions", code_review:"Code review" };
3288
+ if (map[key]) return map[key];
3289
+ return key.split("_").map(function(w){ return w ? w.charAt(0).toUpperCase() + w.slice(1) : w; }).join(" ");
3290
+ }
3291
+ function relTime(iso){
3292
+ var t = Date.parse(iso); if (!isFinite(t)) return iso || "";
3293
+ var s = Math.max(0, Math.round((Date.now() - t)/1000));
3294
+ return fmtUptime(s) + " ago";
3295
+ }
3296
+ function clearEl(el){ while (el && el.firstChild) el.removeChild(el.firstChild); }
3297
+ 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; }
3298
+
3299
+ // Set numeric text and flash on discrete change.
3300
+ function setNum(id, value, kind){
3301
+ var el = byId(id); if (!el) return;
3302
+ el.classList.remove("skel");
3303
+ var s = String(value);
3304
+ if (el.textContent !== s){
3305
+ el.textContent = s;
3306
+ var prev = lastRender[id];
3307
+ if (prev !== undefined){
3308
+ var cls = "flash";
3309
+ if (kind === "delta" && typeof value === "number" && typeof prev === "number"){
3310
+ cls = value > prev ? "flash-up" : (value < prev ? "flash-down" : null);
3311
+ }
3312
+ if (cls){ el.classList.remove("flash","flash-up","flash-down"); void el.offsetWidth; el.classList.add(cls); }
3313
+ }
3314
+ lastRender[id] = value;
3315
+ }
3316
+ }
3317
+ function setText(id, s){ var el = byId(id); if (el){ el.classList.remove("skel"); el.textContent = s; } }
3318
+
3319
+ // ---- sparkline rendering ----
3320
+ function pushHist(arr, v){ arr.push(v); if (arr.length > CAP) arr.shift(); }
3321
+ function buildSpark(values, w, h){
3322
+ var pts = []; for (var i=0;i<values.length;i++){ if (isFinite(values[i])) pts.push({ i:i, v:values[i] }); }
3323
+ if (pts.length < 2) return null;
3324
+ var min = Infinity, max = -Infinity;
3325
+ 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; } }
3326
+ var flat = (max - min) <= 0;
3327
+ var pad = flat ? 1 : (max - min) * 0.05; var lo = min - pad, hi = max + pad; var span = hi - lo; if (span <= 0) span = 1;
3328
+ var n = values.length;
3329
+ var line = "", lastX = 0, lastY = 0, started = false;
3330
+ for (var k=0;k<n;k++){
3331
+ var val = values[k]; if (!isFinite(val)) continue;
3332
+ var x = (n === 1) ? w : (k * (w/(n-1)));
3333
+ var norm = flat ? 0.5 : (val - lo)/span;
3334
+ var y = h - norm*(h-2) - 1;
3335
+ line += (started ? " L" : "M") + x.toFixed(2) + "," + y.toFixed(2);
3336
+ lastX = x; lastY = y; started = true;
3337
+ }
3338
+ var area = line + " L" + lastX.toFixed(2) + "," + h + " L0," + h + " Z";
3339
+ return { line:line, area:area, lastX:lastX, lastY:lastY };
3340
+ }
3341
+ function drawSpark(svgId, values){
3342
+ var svg = byId(svgId); if (!svg) return;
3343
+ var vb = svg.viewBox.baseVal; var w = vb.width || 200, h = vb.height || 24;
3344
+ var sp = buildSpark(values, w, h);
3345
+ var line = svg.querySelector(".line"), area = svg.querySelector(".area"), dot = svg.querySelector("circle");
3346
+ if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); if (dot) dot.style.display = "none"; return; }
3347
+ if (line) line.setAttribute("d", sp.line);
3348
+ if (area) area.setAttribute("d", sp.area);
3349
+ if (dot){ dot.setAttribute("cx", sp.lastX.toFixed(2)); dot.setAttribute("cy", sp.lastY.toFixed(2)); dot.style.display = ""; }
3350
+ }
3351
+
3352
+ // ---- theme ----
3353
+ function applyTheme(){
3354
+ var root = document.documentElement;
3355
+ if (theme === "dark") root.setAttribute("data-theme","dark");
3356
+ else if (theme === "light") root.setAttribute("data-theme","light");
3357
+ else root.removeAttribute("data-theme");
3358
+ byId("btn-theme").textContent = theme === "dark" ? "D" : (theme === "light" ? "L" : "A");
3359
+ }
3360
+ byId("btn-theme").addEventListener("click", function(){
3361
+ theme = theme === "auto" ? "dark" : (theme === "dark" ? "light" : "auto");
3362
+ try { LS.setItem("hoopilot.theme", theme); } catch (e) {}
3363
+ applyTheme();
3364
+ });
3365
+
3366
+ // ---- interval + pause ----
3367
+ function setActiveSeg(){
3368
+ var btns = byId("seg").querySelectorAll("button");
3369
+ for (var i=0;i<btns.length;i++){ btns[i].classList.toggle("active", parseInt(btns[i].getAttribute("data-ms"),10) === intervalMs); }
3370
+ document.documentElement.style.setProperty("--scan-ms", intervalMs + "ms");
3371
+ }
3372
+ byId("seg").addEventListener("click", function(ev){
3373
+ var b = ev.target.closest ? ev.target.closest("button") : null; if (!b) return;
3374
+ intervalMs = parseInt(b.getAttribute("data-ms"),10) || 4000;
3375
+ try { LS.setItem("hoopilot.intervalMs", String(intervalMs)); } catch (e) {}
3376
+ setActiveSeg();
3377
+ if (!paused){ schedule(0); }
3378
+ });
3379
+ byId("btn-pause").addEventListener("click", function(){
3380
+ paused = !paused;
3381
+ byId("btn-pause").innerHTML = paused ? "&#9654;" : "&#10074;&#10074;";
3382
+ byId("bar").classList.toggle("paused", paused);
3383
+ if (paused){ if (timer){ clearTimeout(timer); timer = null; } setPill("paused","PAUSED",false); }
3384
+ else { setPill("live","LIVE",false); schedule(0); }
3385
+ });
3386
+
3387
+ // ---- connection pill / banner ----
3388
+ function setPill(kind, text, beat){
3389
+ var pill = byId("conn-pill"); var dot = byId("conn-dot");
3390
+ pill.className = "pill " + kind;
3391
+ byId("conn-text").textContent = text;
3392
+ if (beat && dot){ dot.classList.remove("heartbeat"); void dot.offsetWidth; dot.classList.add("heartbeat"); }
3393
+ }
3394
+ function showBanner(text, ok){
3395
+ var b = byId("banner"); b.textContent = text; b.className = "banner show" + (ok ? " ok" : ""); b.classList.add("show");
3396
+ if (ok){ setTimeout(function(){ b.classList.remove("show"); }, 2000); }
3397
+ }
3398
+ function hideBanner(){ byId("banner").classList.remove("show"); }
3399
+ function setDimmed(on){ byId("content").classList.toggle("dim", on); }
3400
+
3401
+ // ---- auth takeover ----
3402
+ function showAuth(rejected){
3403
+ byId("content").style.display = "none";
3404
+ byId("auth").classList.add("show");
3405
+ setPill("authkey","API KEY",false);
3406
+ byId("auth-err").textContent = rejected ? "key rejected" : "";
3407
+ byId("auth-input").classList.toggle("bad", !!rejected);
3408
+ byId("auth-clear").style.display = apiKey ? "" : "none";
3409
+ byId("auth-input").focus();
3410
+ }
3411
+ function hideAuth(){ byId("auth").classList.remove("show"); byId("content").style.display = ""; }
3412
+ byId("auth-connect").addEventListener("click", function(){
3413
+ var v = byId("auth-input").value.trim(); if (!v) return;
3414
+ apiKey = v; try { LS.setItem("hoopilot.apiKey", apiKey); } catch (e) {}
3415
+ hideAuth(); schedule(0);
3416
+ });
3417
+ byId("auth-input").addEventListener("keydown", function(ev){ if (ev.key === "Enter") byId("auth-connect").click(); });
3418
+ byId("auth-clear").addEventListener("click", function(){
3419
+ apiKey = ""; try { LS.removeItem("hoopilot.apiKey"); } catch (e) {}
3420
+ byId("auth-input").value = ""; byId("auth-clear").style.display = "none"; byId("auth-input").focus();
3421
+ });
3422
+
3423
+ // ---- the poll loop (setTimeout-chained, never setInterval) ----
3424
+ var pollGen = 0;
3425
+ function schedule(delay){
3426
+ if (timer){ clearTimeout(timer); }
3427
+ if (paused) return;
3428
+ timer = setTimeout(poll, delay === undefined ? intervalMs : delay);
3429
+ }
3430
+ function poll(){
3431
+ if (paused) return;
3432
+ // A new poll supersedes any in-flight one. Bump the generation so the old
3433
+ // request's settled handlers (including its abort rejection) become no-ops
3434
+ // and never flash a false "disconnected".
3435
+ pollGen += 1; var myGen = pollGen;
3436
+ if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
3437
+ var ctrl = new AbortController(); inflightFetch = ctrl;
3438
+ var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, 3000);
3439
+ var headers = { "accept":"application/json" };
3440
+ if (apiKey) headers["x-api-key"] = apiKey;
3441
+ fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
3442
+ clearTimeout(to);
3443
+ if (myGen !== pollGen) return null;
3444
+ if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
3445
+ if (!res.ok) throw new Error("HTTP " + res.status);
3446
+ return res.json();
3447
+ }).then(function(data){
3448
+ if (myGen !== pollGen || data === null || paused) return;
3449
+ inflightFetch = null;
3450
+ onData(data);
3451
+ backoffMs = 0; lastSuccessAt = Date.now();
3452
+ hideAuth(); setDimmed(false); hideBanner();
3453
+ setPill("live","LIVE",true);
3454
+ byId("bar").classList.remove("frozen");
3455
+ schedule(intervalMs);
3456
+ }).catch(function(err){
3457
+ clearTimeout(to);
3458
+ if (myGen !== pollGen || paused) return;
3459
+ inflightFetch = null;
3460
+ onDisconnect(err);
3461
+ });
3462
+ }
3463
+ function onDisconnect(err){
3464
+ setPill("reconnect","RECONNECTING",false);
3465
+ setDimmed(true);
3466
+ byId("bar").classList.add("frozen");
3467
+ backoffMs = backoffMs ? Math.min(Math.round(backoffMs * 1.5), 30000) : intervalMs;
3468
+ showBanner("Disconnected (" + (err && err.message ? err.message : "no response") + ") \\u2014 retrying in " + Math.round(backoffMs/1000) + "s", false);
3469
+ schedule(backoffMs);
3470
+ }
3471
+
3472
+ // ---- main render ----
3473
+ function onData(usage){
3474
+ var proxy = usage.proxy || {};
3475
+ var now = Date.now();
3476
+
3477
+ setText("version-chip", "v" + (usage.version || "?"));
3478
+
3479
+ // rates
3480
+ var reqTotal = (proxy.requests && proxy.requests.total) || 0;
3481
+ var tokTotal = (proxy.tokens && proxy.tokens.total) || 0;
3482
+ var upTotal = (proxy.upstream && proxy.upstream.total) || 0;
3483
+ var startedAt = proxy.startedAt || "";
3484
+ var reqPerSec = NaN, tokPerSec = NaN, upDelta = 0, restarted = false;
3485
+ if (prevSample){
3486
+ var dt = (now - prevSample.t)/1000;
3487
+ if (prevSample.startedAt && startedAt && prevSample.startedAt !== startedAt) restarted = true;
3488
+ if (reqTotal < prevSample.reqTotal || tokTotal < prevSample.tokTotal) restarted = true;
3489
+ if (restarted){ reqPerSec = 0; tokPerSec = 0; upDelta = 0; }
3490
+ else if (dt > 0 && isFinite(dt)){
3491
+ reqPerSec = Math.max(0, (reqTotal - prevSample.reqTotal)/dt);
3492
+ tokPerSec = Math.max(0, (tokTotal - prevSample.tokTotal)/dt);
3493
+ upDelta = Math.max(0, upTotal - prevSample.upTotal);
3494
+ }
3495
+ }
3496
+ prevSample = { t:now, reqTotal:reqTotal, tokTotal:tokTotal, upTotal:upTotal, startedAt:startedAt };
3497
+
3498
+ // hero vitals
3499
+ if (isFinite(reqPerSec)){ pushHist(hist.req, reqPerSec); setNum("req-num", rate(reqPerSec)); } else setText("req-num","\\u2014");
3500
+ if (isFinite(tokPerSec)){ pushHist(hist.tok, tokPerSec); setNum("tok-num", humanInt(tokPerSec)); } else setText("tok-num","\\u2014");
3501
+ var inflight = proxy.inFlight || 0;
3502
+ pushHist(hist.inflight, inflight); setNum("inflight-num", String(inflight), "delta");
3503
+ byId("v-inflight").classList.toggle("active", inflight > 0);
3504
+ setText("uptime-num", fmtUptime(proxy.uptimeSeconds || 0));
3505
+
3506
+ setText("req-sub", hist.req.length ? ("avg " + rate(avg(hist.req)) + "/s") : "warming up");
3507
+ setText("tok-sub", hist.tok.length ? ("peak " + humanInt(Math.max.apply(null, hist.tok)) + "/s") : "warming up");
3508
+ setText("inflight-sub", inflight + " now");
3509
+ setText("uptime-sub", startedAt ? ("since " + relTime(startedAt)) : "");
3510
+
3511
+ drawSpark("req-spark", hist.req);
3512
+ drawSpark("tok-spark", hist.tok);
3513
+ drawSpark("inflight-spark", hist.inflight);
3514
+
3515
+ renderRequests(proxy);
3516
+ renderStatus(proxy);
3517
+ renderLatency(proxy.latency || {});
3518
+ renderTokens(proxy.tokens || {});
3519
+ renderCopilot(usage);
3520
+ renderUpstream(proxy.upstream || {}, upDelta, restarted);
3521
+ renderThroughput();
3522
+ renderFooter(usage, proxy);
3523
+
3524
+ setNum("req-total", humanInt(reqTotal));
3525
+ setNum("tok-total", humanInt(tokTotal));
3526
+ lastUptime = proxy.uptimeSeconds || 0;
3527
+ }
3528
+
3529
+ 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; }
3530
+
3531
+ var ROUTE_COLORS = ["var(--c1)","var(--c2)","var(--c3)","var(--c4)","var(--c5)","var(--c6)"];
3532
+ function renderRequests(proxy){
3533
+ var byRoute = (proxy.requests && proxy.requests.byRoute) || {};
3534
+ var total = (proxy.requests && proxy.requests.total) || 0;
3535
+ var rows = Object.keys(byRoute).map(function(k){ return { k:k, v:byRoute[k] }; }).sort(function(a,b){ return b.v - a.v; });
3536
+ var share = byId("route-sharebar"); clearEl(share); share.className = "stack-bar" + (total ? "" : " empty");
3537
+ var body = byId("routes-body"); clearEl(body);
3538
+ 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; }
3539
+ rows.forEach(function(r, idx){
3540
+ var p = total ? (r.v/total*100) : 0;
3541
+ 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);
3542
+ var tr = mk("tr");
3543
+ var name = mk("td","l", r.k); name.title = r.k; tr.appendChild(name);
3544
+ tr.appendChild(mk("td",null, humanInt(r.v)));
3545
+ tr.appendChild(mk("td",null, pct(p)));
3546
+ 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);
3547
+ body.appendChild(tr);
3548
+ });
3549
+ 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);
3550
+ }
3551
+
3552
+ 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"; }
3553
+ function statusColor(cls){ return cls === "ok" ? "var(--ok)" : cls === "info" ? "var(--info)" : cls === "warn" ? "var(--warn)" : cls === "danger" ? "var(--danger)" : "var(--text-2)"; }
3554
+ function renderStatus(proxy){
3555
+ var byStatus = (proxy.requests && proxy.requests.byStatus) || {};
3556
+ var total = 0, errs = 0; var groups = { ok:0, info:0, warn:0, danger:0, muted:0 };
3557
+ var codes = Object.keys(byStatus).map(function(k){ return { k:k, v:byStatus[k] }; }).sort(function(a,b){ return b.v - a.v; });
3558
+ codes.forEach(function(c){ total += c.v; var cls = statusClass(c.k); groups[cls] += c.v; if (cls === "warn" || cls === "danger") errs += c.v; });
3559
+ var bar = byId("status-bar"); clearEl(bar); bar.className = "stack-bar" + (total ? "" : " empty");
3560
+ ["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); } });
3561
+ var leg = byId("status-legend"); clearEl(leg);
3562
+ if (!codes.length){ leg.appendChild(mk("span","li","no requests yet")); }
3563
+ 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); });
3564
+ var er = total ? (errs/total*100) : 0;
3565
+ setNum("error-rate", pct(er));
3566
+ var el = byId("error-rate"); el.style.color = er > 5 ? "var(--danger)" : er > 1 ? "var(--warn)" : "var(--ok)";
3567
+ }
3568
+
3569
+ function renderLatency(lat){
3570
+ setText("lat-p50", fmtMs(lat.p50Ms)); setText("lat-avg", fmtMs(lat.avgMs)); setText("lat-count", humanInt(lat.count || 0));
3571
+ var p95 = byId("lat-p95"); p95.classList.remove("skel"); p95.textContent = fmtMs(lat.p95Ms);
3572
+ p95.style.color = (lat.p50Ms > 0 && lat.p95Ms > 2*lat.p50Ms) ? "var(--warn)" : "var(--info)";
3573
+ // track: position p50 and p95 across 0..(p95*1.15)
3574
+ var track = byId("lat-track"); var old = track.querySelectorAll(".tick,.tlab"); for (var i=0;i<old.length;i++) old[i].remove();
3575
+ var maxv = Math.max(lat.p95Ms || 0, lat.avgMs || 0, 1) * 1.15;
3576
+ 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); }
3577
+ place(lat.p50Ms, "p50"); place(lat.p95Ms, "p95");
3578
+ var lr = byId("lat-routes"); clearEl(lr);
3579
+ 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); });
3580
+ 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); });
3581
+ }
3582
+
3583
+ function renderTokens(tok){
3584
+ var prompt = tok.prompt||0, completion = tok.completion||0, reasoning = tok.reasoning||0, cached = tok.cached||0;
3585
+ var sum = prompt + completion + reasoning;
3586
+ var bar = byId("tok-mixbar"); clearEl(bar); bar.className = "stack-bar" + (sum ? "" : " empty");
3587
+ var parts = [ ["prompt", prompt, "var(--text-1)"], ["completion", completion, "var(--accent)"], ["reasoning", reasoning, "var(--info)"] ];
3588
+ 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); } });
3589
+ var leg = byId("tok-legend"); clearEl(leg);
3590
+ var legParts = parts.concat([["cached", cached, "var(--cache)"]]);
3591
+ 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); });
3592
+ var cacheRate = prompt ? (cached/prompt*100) : 0; setText("tok-cache", "cache " + pct(cacheRate));
3593
+ var body = byId("tok-body"); clearEl(body);
3594
+ 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); });
3595
+ 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; }
3596
+ 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);
3597
+ tr.appendChild(mk("td",null, humanInt(m.prompt||0))); tr.appendChild(mk("td",null, humanInt(m.completion||0)));
3598
+ tr.appendChild(mk("td","reasoning", humanInt(m.reasoning||0))); tr.appendChild(mk("td","cached", humanInt(m.cached||0)));
3599
+ tr.appendChild(mk("td",null, humanInt(m.total||0))); tr.appendChild(mk("td",null, humanInt(m.requests||0))); body.appendChild(tr); });
3600
+ }
3601
+
3602
+ 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"; }
3603
+ function renderCopilot(usage){
3604
+ var box = byId("copilot-body"); clearEl(box);
3605
+ var cp = usage.copilot; var planChip = byId("plan-chip");
3606
+ if (!cp){
3607
+ planChip.className = "chip plan-offline"; planChip.textContent = "\\u2014 offline";
3608
+ var eb = mk("div","emptybox"); eb.appendChild(mk("div","keyglyph","\\u26bf"));
3609
+ eb.appendChild(mk("h4",null,"Copilot not connected"));
3610
+ if (usage.copilot_error) eb.appendChild(mk("div","errline", usage.copilot_error));
3611
+ eb.appendChild(mk("div","prompt","$ hoopilot login"));
3612
+ box.appendChild(eb); return;
3613
+ }
3614
+ planChip.className = "chip " + planClass(cp.plan); planChip.textContent = cp.plan || "copilot";
3615
+ var head = mk("div","cap");
3616
+ var bits = [];
3617
+ if (cp.accessTypeSku) bits.push(cp.accessTypeSku);
3618
+ if (cp.chatEnabled !== undefined) bits.push(cp.chatEnabled ? "chat on" : "chat off");
3619
+ if (cp.quotaResetDate) bits.push("resets " + cp.quotaResetDate);
3620
+ head.textContent = bits.join(" \\u00b7 "); box.appendChild(head);
3621
+ var quotas = cp.quotas || {}; var keys = Object.keys(quotas);
3622
+ if (!keys.length){ box.appendChild(mk("div","cap","No metered quotas reported.")); return; }
3623
+ var order = { premium_interactions:0, chat:1, completions:2 };
3624
+ 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); });
3625
+ keys.forEach(function(k){
3626
+ var q = quotas[k]; var row = mk("div","qrow");
3627
+ var hd = mk("div","qhead"); hd.appendChild(mk("span","qname", titleize(k)));
3628
+ if (q.unlimited){ hd.appendChild(mk("span","inf","\\u221e unlimited")); row.appendChild(hd); box.appendChild(row); return; }
3629
+ var ent = q.entitlement, rem = q.remaining, used = q.used;
3630
+ var usedPct = (q.percentRemaining !== undefined) ? (100 - q.percentRemaining) : ((ent && used !== undefined) ? (used/ent*100) : 0);
3631
+ usedPct = Math.max(0, Math.min(100, usedPct));
3632
+ var valTxt = (used !== undefined && ent !== undefined) ? (humanInt(used) + " / " + humanInt(ent)) : (rem !== undefined ? (humanInt(rem) + " left") : pct(100-usedPct) + " left");
3633
+ hd.appendChild(mk("span","qval", valTxt)); row.appendChild(hd);
3634
+ var bar = mk("div","qbar"); var fill = mk("i"); fill.style.width = usedPct + "%";
3635
+ fill.style.background = usedPct > 85 ? "var(--danger)" : usedPct > 60 ? "var(--warn)" : "var(--ok)"; bar.appendChild(fill);
3636
+ 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); }
3637
+ row.appendChild(bar);
3638
+ if (q.overageCount){ var ov = mk("div","flag", humanInt(q.overageCount) + " overage" + (q.tokenBasedBilling ? " \\u00b7 token billing" : "")); row.appendChild(ov); }
3639
+ box.appendChild(row);
3640
+ });
3641
+ }
3642
+
3643
+ function renderUpstream(up, delta, restarted){
3644
+ setNum("up-total", humanInt(up.total||0));
3645
+ setNum("up-errors", humanInt(up.errors||0), "delta");
3646
+ var er = up.total ? (up.errors/up.total*100) : 0;
3647
+ var rt = byId("up-rate"); rt.textContent = pct(er); rt.className = "v rate " + (er > 5 ? "danger" : er > 1 ? "warn" : "ok");
3648
+ byId("up-errblk").classList.toggle("hot", (up.errors||0) > 0);
3649
+ pushHist(hist.up, delta||0); drawSpark("up-spark", hist.up);
3650
+ byId("up-flag").textContent = restarted ? "\\u21bb restarted" : "";
3651
+ }
3652
+
3653
+ function renderThroughput(){
3654
+ drawDual("thru-tok-line","thru-tok-area", hist.tok, true);
3655
+ drawDual("thru-req-line", null, hist.req, false);
3656
+ setText("thru-tok", hist.tok.length ? rate(hist.tok[hist.tok.length-1]) : "\\u2014");
3657
+ setText("thru-req", hist.req.length ? rate(hist.req[hist.req.length-1]) : "\\u2014");
3658
+ var peakTok = hist.tok.length ? Math.max.apply(null, hist.tok) : 0;
3659
+ setText("thru-peak", "peak " + humanInt(peakTok) + " tok/s");
3660
+ }
3661
+ function drawDual(lineId, areaId, values, withArea){
3662
+ var svg = byId("thru-svg"); var vb = svg.viewBox.baseVal; var w = vb.width, h = vb.height;
3663
+ var sp = buildSpark(values, w, h);
3664
+ var line = byId(lineId); var area = areaId ? byId(areaId) : null;
3665
+ if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); return; }
3666
+ if (line) line.setAttribute("d", sp.line);
3667
+ if (area && withArea) area.setAttribute("d", sp.area);
3668
+ }
3669
+
3670
+ function renderFooter(usage, proxy){
3671
+ setText("foot-started", proxy.startedAt ? ("started " + new Date(proxy.startedAt).toLocaleString()) : "started \\u2014");
3672
+ setText("foot-uptime", "uptime " + fmtUptime(proxy.uptimeSeconds||0));
3673
+ setText("foot-total", humanInt((proxy.requests && proxy.requests.total)||0) + " req");
3674
+ setText("foot-tokens", humanInt((proxy.tokens && proxy.tokens.total)||0) + " tokens");
3675
+ var up = proxy.upstream || {}; setText("foot-upstream", "upstream " + humanInt(up.total||0) + " / " + humanInt(up.errors||0) + " err");
3676
+ setText("foot-cadence", "polling /v1/usage every " + Math.round(intervalMs/1000) + "s \\u00b7 GET /dashboard");
3677
+ }
3678
+
3679
+ // ---- 1s freshness + uptime ticker (independent of the poll loop) ----
3680
+ setInterval(function(){
3681
+ if (lastSuccessAt){
3682
+ var ago = Math.round((Date.now() - lastSuccessAt)/1000);
3683
+ var u = byId("updated"); u.textContent = "updated " + ago + "s ago";
3684
+ // Staleness only matters while polling; a deliberate pause is not "stale".
3685
+ u.className = "updated" + (paused ? "" : ago > intervalMs/1000*4 ? " danger" : ago > intervalMs/1000*2 ? " warn" : "");
3686
+ }
3687
+ // Tick uptime locally between polls so the seconds advance smoothly; each
3688
+ // successful poll re-seeds lastUptime from the authoritative server value.
3689
+ if (!paused && lastUptime !== null){
3690
+ lastUptime += 1;
3691
+ byId("uptime-num").textContent = fmtUptime(lastUptime);
3692
+ var fu = byId("foot-uptime"); if (fu) fu.textContent = "uptime " + fmtUptime(lastUptime);
3693
+ }
3694
+ }, 1000);
3695
+
3696
+ // ---- boot ----
3697
+ applyTheme(); setActiveSeg();
3698
+ setPill("","CONNECTING",false);
3699
+ poll();
3700
+ })();
3701
+ </script>
3702
+ </body>
3703
+ </html>
3704
+ `;
3705
+
2816
3706
  // src/version.ts
3707
+ var import_meta = {};
2817
3708
  var BAKED_VERSION = typeof HOOPILOT_VERSION !== "undefined" ? HOOPILOT_VERSION : void 0;
2818
3709
  var IS_STANDALONE_BINARY = BAKED_VERSION !== void 0;
3710
+ var cachedVersion;
3711
+ async function getVersion() {
3712
+ if (cachedVersion !== void 0) {
3713
+ return cachedVersion;
3714
+ }
3715
+ let resolved;
3716
+ if (BAKED_VERSION) {
3717
+ resolved = BAKED_VERSION;
3718
+ } else {
3719
+ try {
3720
+ const manifest = await Bun.file(new URL("../package.json", import_meta.url)).json();
3721
+ resolved = typeof manifest.version === "string" ? manifest.version : "0.0.0";
3722
+ } catch {
3723
+ resolved = "0.0.0";
3724
+ }
3725
+ }
3726
+ cachedVersion = resolved;
3727
+ return resolved;
3728
+ }
2819
3729
 
2820
3730
  // src/server.ts
2821
3731
  var DEFAULT_HOST = "127.0.0.1";
@@ -2842,6 +3752,7 @@ function createHoopilotHandler(options = {}) {
2842
3752
  const metrics = options.metrics ?? new MetricsRegistry();
2843
3753
  const readUsage = createUsageReader(client, metrics);
2844
3754
  const recordTokens = (model, usage) => metrics.recordTokens(model, usage);
3755
+ const recordExtraction = (extracted) => metrics.recordTokenExtraction(extracted);
2845
3756
  const streamingProxyMode = resolveStreamingProxyMode(options);
2846
3757
  const bufferProxyBodies = shouldBufferProxyBodies(streamingProxyMode);
2847
3758
  return async (request) => {
@@ -2881,6 +3792,9 @@ function createHoopilotHandler(options = {}) {
2881
3792
  if (request.method === "OPTIONS") {
2882
3793
  return finish(new Response(null, { headers: corsHeaders() }));
2883
3794
  }
3795
+ if (request.method === "GET" && apiPath === "/dashboard") {
3796
+ return finish(dashboardResponse());
3797
+ }
2884
3798
  if (!isAuthorized(request, apiKey)) {
2885
3799
  requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
2886
3800
  return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
@@ -2907,6 +3821,7 @@ function createHoopilotHandler(options = {}) {
2907
3821
  client,
2908
3822
  metrics,
2909
3823
  recordTokens,
3824
+ recordExtraction,
2910
3825
  request,
2911
3826
  requestLogger,
2912
3827
  bufferProxyBodies
@@ -2922,6 +3837,7 @@ function createHoopilotHandler(options = {}) {
2922
3837
  client,
2923
3838
  metrics,
2924
3839
  recordTokens,
3840
+ recordExtraction,
2925
3841
  request,
2926
3842
  requestLogger,
2927
3843
  bufferProxyBodies
@@ -2934,6 +3850,7 @@ function createHoopilotHandler(options = {}) {
2934
3850
  client,
2935
3851
  metrics,
2936
3852
  recordTokens,
3853
+ recordExtraction,
2937
3854
  request,
2938
3855
  requestLogger,
2939
3856
  bufferProxyBodies
@@ -2942,7 +3859,14 @@ function createHoopilotHandler(options = {}) {
2942
3859
  }
2943
3860
  if (request.method === "POST" && apiPath === "/v1/responses/compact") {
2944
3861
  return finish(
2945
- await handleResponsesCompact(client, metrics, recordTokens, request, requestLogger)
3862
+ await handleResponsesCompact(
3863
+ client,
3864
+ metrics,
3865
+ recordTokens,
3866
+ recordExtraction,
3867
+ request,
3868
+ requestLogger
3869
+ )
2946
3870
  );
2947
3871
  }
2948
3872
  if (request.method === "POST" && apiPath === "/v1/responses") {
@@ -2951,6 +3875,7 @@ function createHoopilotHandler(options = {}) {
2951
3875
  client,
2952
3876
  metrics,
2953
3877
  recordTokens,
3878
+ recordExtraction,
2954
3879
  request,
2955
3880
  requestLogger,
2956
3881
  bufferProxyBodies
@@ -3027,7 +3952,7 @@ function startHoopilotServer(options = {}) {
3027
3952
  url: `http://${urlHost(host)}:${server.port}`
3028
3953
  };
3029
3954
  }
3030
- async function handleAnthropicMessages(client, metrics, recordTokens, request, logger, bufferProxyBodies) {
3955
+ async function handleAnthropicMessages(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
3031
3956
  const anthropicRequest = await readJson(request);
3032
3957
  const responsesRequest = anthropicMessagesToResponsesRequest(anthropicRequest);
3033
3958
  const upstream = await client.responses(JSON.stringify(responsesRequest), request.signal);
@@ -3040,12 +3965,18 @@ async function handleAnthropicMessages(client, metrics, recordTokens, request, l
3040
3965
  if (isStreamingResponse(upstream) && upstream.body) {
3041
3966
  if (bufferProxyBodies) {
3042
3967
  const text = await upstream.text();
3043
- recordResponseTextUsage(text, true, model, recordTokens);
3968
+ recordResponseTextUsage(text, true, model, recordTokens, recordExtraction);
3044
3969
  return proxyResponse(
3045
3970
  responseFromText(upstream, responsesSseTextToAnthropicSseText(text, { model }))
3046
3971
  );
3047
3972
  }
3048
- const observed = observeResponseUsage(upstream, model, recordTokens, request.signal);
3973
+ const observed = observeResponseUsage(
3974
+ upstream,
3975
+ model,
3976
+ recordTokens,
3977
+ request.signal,
3978
+ recordExtraction
3979
+ );
3049
3980
  if (!observed.body) {
3050
3981
  return proxyResponse(observed);
3051
3982
  }
@@ -3063,6 +3994,7 @@ async function handleAnthropicMessages(client, metrics, recordTokens, request, l
3063
3994
  const responseModel = typeof body.model === "string" ? body.model.trim() : "";
3064
3995
  recordTokens(responseModel || model, usage);
3065
3996
  }
3997
+ recordExtraction(usage !== void 0);
3066
3998
  return jsonResponse(responsesResponseToAnthropicMessage(body, model));
3067
3999
  }
3068
4000
  function handleAnthropicCountTokens(body) {
@@ -3088,7 +4020,7 @@ async function handleModels(client, metrics, signal, logger) {
3088
4020
  logUpstreamSuccess(logger, "/models", upstream.status);
3089
4021
  return jsonResponse(normalizeModelsResponse(await upstream.json()));
3090
4022
  }
3091
- async function handleChatCompletions(client, metrics, recordTokens, request, logger, bufferProxyBodies) {
4023
+ async function handleChatCompletions(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
3092
4024
  const chatRequest = normalizeChatCompletionRequest(await readJson(request));
3093
4025
  const upstream = await client.chatCompletions(chatRequest, request.signal);
3094
4026
  metrics.recordUpstream("/chat/completions", upstream.ok);
@@ -3103,11 +4035,12 @@ async function handleChatCompletions(client, metrics, recordTokens, request, log
3103
4035
  model,
3104
4036
  recordTokens,
3105
4037
  request.signal,
3106
- bufferProxyBodies
4038
+ bufferProxyBodies,
4039
+ recordExtraction
3107
4040
  )
3108
4041
  );
3109
4042
  }
3110
- async function handleCompletions(client, metrics, recordTokens, request, logger, bufferProxyBodies) {
4043
+ async function handleCompletions(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
3111
4044
  const body = await readJson(request);
3112
4045
  const upstream = await client.chatCompletions(
3113
4046
  completionsRequestToChatCompletion(body),
@@ -3122,7 +4055,7 @@ async function handleCompletions(client, metrics, recordTokens, request, logger,
3122
4055
  if (isStreamingResponse(upstream) && upstream.body) {
3123
4056
  if (bufferProxyBodies) {
3124
4057
  const upstreamText = await upstream.text();
3125
- recordResponseTextUsage(upstreamText, true, model, recordTokens);
4058
+ recordResponseTextUsage(upstreamText, true, model, recordTokens, recordExtraction);
3126
4059
  const text = completionSseTextFromChatSseText(upstreamText);
3127
4060
  return proxyResponse(responseFromText(upstream, text));
3128
4061
  }
@@ -3135,7 +4068,8 @@ async function handleCompletions(client, metrics, recordTokens, request, logger,
3135
4068
  }),
3136
4069
  model,
3137
4070
  recordTokens,
3138
- request.signal
4071
+ request.signal,
4072
+ recordExtraction
3139
4073
  )
3140
4074
  );
3141
4075
  }
@@ -3145,9 +4079,10 @@ async function handleCompletions(client, metrics, recordTokens, request, logger,
3145
4079
  const responseModel = typeof completion.model === "string" ? completion.model.trim() : "";
3146
4080
  recordTokens(responseModel || model, usage);
3147
4081
  }
4082
+ recordExtraction(usage !== void 0);
3148
4083
  return jsonResponse(chatCompletionToCompletion(completion));
3149
4084
  }
3150
- async function handleResponses(client, metrics, recordTokens, request, logger, bufferProxyBodies) {
4085
+ async function handleResponses(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
3151
4086
  const body = await readJsonText(request);
3152
4087
  const upstream = await client.responses(body, request.signal);
3153
4088
  metrics.recordUpstream("/responses", upstream.ok);
@@ -3162,11 +4097,12 @@ async function handleResponses(client, metrics, recordTokens, request, logger, b
3162
4097
  model,
3163
4098
  recordTokens,
3164
4099
  request.signal,
3165
- bufferProxyBodies
4100
+ bufferProxyBodies,
4101
+ recordExtraction
3166
4102
  )
3167
4103
  );
3168
4104
  }
3169
- async function handleResponsesCompact(client, metrics, recordTokens, request, logger) {
4105
+ async function handleResponsesCompact(client, metrics, recordTokens, recordExtraction, request, logger) {
3170
4106
  const body = await readJson(request);
3171
4107
  const upstream = await client.responses(
3172
4108
  JSON.stringify({ ...body, stream: false }),
@@ -3179,17 +4115,23 @@ async function handleResponsesCompact(client, metrics, recordTokens, request, lo
3179
4115
  logUpstreamSuccess(logger, "/responses", upstream.status);
3180
4116
  const isSse = isStreamingResponse(upstream);
3181
4117
  const text = await upstream.text();
3182
- recordResponseTextUsage(text, isSse, normalizeRequestedModel(body.model), recordTokens);
4118
+ recordResponseTextUsage(
4119
+ text,
4120
+ isSse,
4121
+ normalizeRequestedModel(body.model),
4122
+ recordTokens,
4123
+ recordExtraction
4124
+ );
3183
4125
  return jsonResponse(responsesCompactionResult(text, isSse));
3184
4126
  }
3185
- async function responseWithObservedUsage(response, fallbackModel, recordTokens, signal, bufferBody) {
4127
+ async function responseWithObservedUsage(response, fallbackModel, recordTokens, signal, bufferBody, recordExtraction) {
3186
4128
  const isSse = isStreamingResponse(response);
3187
4129
  if (bufferBody && response.body) {
3188
4130
  const text = await response.text();
3189
- recordResponseTextUsage(text, isSse, fallbackModel, recordTokens);
4131
+ recordResponseTextUsage(text, isSse, fallbackModel, recordTokens, recordExtraction);
3190
4132
  return responseFromText(response, text);
3191
4133
  }
3192
- return observeResponseUsage(response, fallbackModel, recordTokens, signal);
4134
+ return observeResponseUsage(response, fallbackModel, recordTokens, signal, recordExtraction);
3193
4135
  }
3194
4136
  function responseFromText(source, text) {
3195
4137
  return new Response(text, {
@@ -3545,6 +4487,9 @@ function routeFor(method, path) {
3545
4487
  if (method === "GET" && (path === "/" || path === "/healthz")) {
3546
4488
  return "health";
3547
4489
  }
4490
+ if (method === "GET" && path === "/dashboard") {
4491
+ return "dashboard";
4492
+ }
3548
4493
  if (method === "GET" && path === "/metrics") {
3549
4494
  return "metrics";
3550
4495
  }
@@ -3599,10 +4544,28 @@ function metricsResponse(metrics) {
3599
4544
  status: 200
3600
4545
  });
3601
4546
  }
4547
+ function dashboardResponse() {
4548
+ return new Response(DASHBOARD_HTML, {
4549
+ headers: {
4550
+ ...corsHeaders(),
4551
+ "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'",
4552
+ "content-type": "text/html; charset=utf-8",
4553
+ "referrer-policy": "no-referrer",
4554
+ "x-content-type-options": "nosniff",
4555
+ "x-frame-options": "DENY"
4556
+ },
4557
+ status: 200
4558
+ });
4559
+ }
3602
4560
  async function handleUsage(metrics, readUsage, signal) {
3603
4561
  const { copilot, error } = await readUsage(signal);
3604
4562
  const proxy = metrics.snapshot();
3605
- const body = { copilot: copilot ?? null, object: "usage", proxy };
4563
+ const body = {
4564
+ copilot: copilot ?? null,
4565
+ object: "usage",
4566
+ proxy,
4567
+ version: await getVersion()
4568
+ };
3606
4569
  if (error) {
3607
4570
  body.copilot_error = error;
3608
4571
  }