@openhoo/hoopilot 1.2.0 → 2.0.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/cli.js CHANGED
@@ -2,18 +2,27 @@
2
2
  import {
3
3
  asRecord,
4
4
  envValue,
5
+ errorMessage,
6
+ firstNumber,
7
+ isLoopbackHostname,
5
8
  isTrustedTokenBaseUrl,
6
9
  main,
10
+ modelIdsFromResponse,
11
+ parseJsonObject,
12
+ parseStreamingProxyMode,
13
+ randomId,
14
+ removeUndefined,
15
+ safeJsonParse,
7
16
  trimTrailingSlash,
8
17
  truncatedResponseText
9
- } from "./chunk-JU6F5L34.js";
18
+ } from "./chunk-6ALEIJJM.js";
10
19
 
11
20
  // src/cli.ts
12
21
  import { spawn } from "child_process";
13
22
  import { readFileSync as readFileSync2 } from "fs";
14
23
 
15
24
  // src/auth-store.ts
16
- import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
25
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
17
26
  import { dirname, join } from "path";
18
27
  var StoredCopilotAuthError = class extends Error {
19
28
  constructor(message) {
@@ -64,7 +73,7 @@ function readStoredCopilotAuth(path = authStorePath()) {
64
73
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
65
74
  throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
66
75
  }
67
- const record = parsed;
76
+ const record = asRecord(parsed);
68
77
  const token = typeof record.token === "string" ? record.token.trim() : "";
69
78
  if (!token) {
70
79
  throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
@@ -90,7 +99,15 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
90
99
  `;
91
100
  const tmpPath = `${path}.${process.pid}.tmp`;
92
101
  writeFileSync(tmpPath, data, { mode: 384 });
93
- renameSync(tmpPath, path);
102
+ try {
103
+ renameSync(tmpPath, path);
104
+ } catch (error) {
105
+ try {
106
+ rmSync(tmpPath, { force: true });
107
+ } catch {
108
+ }
109
+ throw error;
110
+ }
94
111
  try {
95
112
  chmodSync(path, 384);
96
113
  } catch {
@@ -135,23 +152,20 @@ var CopilotAuth = class {
135
152
  throw error;
136
153
  }
137
154
  if (stored) {
138
- return this.#cacheAccess({
155
+ this.#cachedAccess = {
139
156
  apiBaseUrl: trimTrailingSlash(
140
157
  this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
141
158
  ),
142
159
  expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
143
160
  source: "github-copilot-oauth",
144
161
  token: stored.token
145
- });
162
+ };
163
+ return this.#cachedAccess;
146
164
  }
147
165
  throw new CopilotAuthError(
148
166
  "No GitHub Copilot OAuth credential found. Run `hoopilot login` to sign in through your browser."
149
167
  );
150
168
  }
151
- #cacheAccess(access) {
152
- this.#cachedAccess = access;
153
- return access;
154
- }
155
169
  };
156
170
 
157
171
  // src/copilot.ts
@@ -159,23 +173,26 @@ var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
159
173
  var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
160
174
  var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
161
175
  var COPILOT_USAGE_API_VERSION = "2025-04-01";
176
+ var EDITOR_PLUGIN_VERSION = "hoopilot/0.1.0";
177
+ var EDITOR_VERSION = "Hoopilot/0.1.0";
178
+ var HOOPILOT_USER_AGENT = "hoopilot/0.1.0";
162
179
  function applyCopilotHeaders(headers, token) {
163
180
  headers.set("accept", headers.get("accept") ?? "application/json");
164
181
  headers.set("authorization", `Bearer ${token}`);
165
182
  headers.set("copilot-integration-id", "vscode-chat");
166
- headers.set("editor-plugin-version", "hoopilot/0.1.0");
167
- headers.set("editor-version", "Hoopilot/0.1.0");
183
+ headers.set("editor-plugin-version", EDITOR_PLUGIN_VERSION);
184
+ headers.set("editor-version", EDITOR_VERSION);
168
185
  headers.set("openai-intent", "conversation-panel");
169
- headers.set("user-agent", "hoopilot/0.1.0");
186
+ headers.set("user-agent", HOOPILOT_USER_AGENT);
170
187
  headers.set("x-github-api-version", "2026-06-01");
171
188
  return headers;
172
189
  }
173
190
  function applyGithubApiHeaders(headers, token) {
174
191
  headers.set("accept", headers.get("accept") ?? "application/json");
175
192
  headers.set("authorization", `token ${token}`);
176
- headers.set("editor-plugin-version", "hoopilot/0.1.0");
177
- headers.set("editor-version", "Hoopilot/0.1.0");
178
- headers.set("user-agent", "hoopilot/0.1.0");
193
+ headers.set("editor-plugin-version", EDITOR_PLUGIN_VERSION);
194
+ headers.set("editor-version", EDITOR_VERSION);
195
+ headers.set("user-agent", HOOPILOT_USER_AGENT);
179
196
  headers.set("x-github-api-version", COPILOT_USAGE_API_VERSION);
180
197
  return headers;
181
198
  }
@@ -188,7 +205,7 @@ function parseRateLimitHeaders(headers, nowMs = Date.now()) {
188
205
  if (limit === void 0 && remaining === void 0 && used === void 0 && resetEpochSeconds === void 0 && retryAfterSeconds === void 0) {
189
206
  return void 0;
190
207
  }
191
- return removeUndefinedRateLimit({
208
+ return removeUndefined({
192
209
  limit,
193
210
  observedAtMs: nowMs,
194
211
  remaining,
@@ -206,11 +223,6 @@ function headerInt(headers, name) {
206
223
  const value = Number.parseInt(raw.trim(), 10);
207
224
  return Number.isFinite(value) && value >= 0 ? value : void 0;
208
225
  }
209
- function removeUndefinedRateLimit(rateLimit) {
210
- return Object.fromEntries(
211
- Object.entries(rateLimit).filter(([, value]) => value !== void 0)
212
- );
213
- }
214
226
  var CopilotClient = class {
215
227
  #auth;
216
228
  #allowUnsafeUpstream;
@@ -307,7 +319,7 @@ function normalizeCopilotUsage(body) {
307
319
  for (const category of /* @__PURE__ */ new Set([...Object.keys(remaining), ...Object.keys(monthly)])) {
308
320
  const entitlement = numberOrUndefined(monthly[category]);
309
321
  const left = numberOrUndefined(remaining[category]);
310
- quotas[category] = removeUndefinedQuota({
322
+ quotas[category] = removeUndefined({
311
323
  entitlement,
312
324
  percentRemaining: entitlement !== void 0 && entitlement > 0 && left !== void 0 ? left / entitlement * 100 : void 0,
313
325
  remaining: left,
@@ -315,7 +327,7 @@ function normalizeCopilotUsage(body) {
315
327
  });
316
328
  }
317
329
  }
318
- return removeUndefinedUsage({
330
+ return removeUndefined({
319
331
  accessTypeSku: stringOrUndefined(record.access_type_sku),
320
332
  chatEnabled: typeof record.chat_enabled === "boolean" ? record.chat_enabled : void 0,
321
333
  plan: stringOrUndefined(record.copilot_plan),
@@ -327,7 +339,7 @@ function normalizeQuotaDetail(detail) {
327
339
  const entitlement = numberOrUndefined(detail.entitlement);
328
340
  const overageCount = numberOrUndefined(detail.overage_count);
329
341
  const remaining = numberOrUndefined(detail.remaining) ?? numberOrUndefined(detail.quota_remaining);
330
- return removeUndefinedQuota({
342
+ return removeUndefined({
331
343
  entitlement,
332
344
  hasQuota: typeof detail.has_quota === "boolean" ? detail.has_quota : void 0,
333
345
  overageCount,
@@ -351,21 +363,10 @@ function usedFrom(entitlement, remaining, overageCount) {
351
363
  const overage = remaining === 0 ? overageCount ?? 0 : 0;
352
364
  return Math.max(0, base + overage);
353
365
  }
354
- function numberOrUndefined(value) {
355
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
356
- }
366
+ var numberOrUndefined = firstNumber;
357
367
  function stringOrUndefined(value) {
358
368
  return typeof value === "string" && value.length > 0 ? value : void 0;
359
369
  }
360
- function removeUndefinedQuota(quota) {
361
- return Object.fromEntries(
362
- Object.entries(quota).filter(([, value]) => value !== void 0)
363
- );
364
- }
365
- function removeUndefinedUsage(usage) {
366
- const entries = Object.entries(usage).filter(([, value]) => value !== void 0);
367
- return Object.fromEntries(entries);
368
- }
369
370
 
370
371
  // src/github-device.ts
371
372
  import { setTimeout as sleep } from "timers/promises";
@@ -497,11 +498,16 @@ function positiveSeconds(value, fallback) {
497
498
  }
498
499
  async function parseJsonResponse(response, context) {
499
500
  const text = await response.text();
501
+ let value;
500
502
  try {
501
- return JSON.parse(text);
503
+ value = JSON.parse(text);
502
504
  } catch {
503
505
  throw new Error(`${context}: ${text.slice(0, 500)}`);
504
506
  }
507
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
508
+ throw new Error(`${context}: ${text.slice(0, 500)}`);
509
+ }
510
+ return value;
505
511
  }
506
512
 
507
513
  // src/logger.ts
@@ -565,21 +571,29 @@ function createHoopilotLogger(options = {}) {
565
571
  timestamp: pino.stdTimeFunctions.isoTime
566
572
  };
567
573
  if (format === "pretty") {
568
- return pino(
569
- pinoOptions,
570
- pretty({
571
- colorize: options.colorize ?? process.stderr.isTTY,
572
- destination: options.stream ?? 1,
573
- ignore: "pid,hostname",
574
- singleLine: true,
575
- translateTime: "SYS:standard"
576
- })
574
+ return asHoopilotLogger(
575
+ pino(
576
+ pinoOptions,
577
+ pretty({
578
+ // Probe the same sink we write to (stdout / fd 1), so colors are not
579
+ // emitted into a redirected file when only stderr is a TTY. A custom
580
+ // stream's TTY-ness is unknown, so default to no color there.
581
+ colorize: options.colorize ?? (options.stream ? false : process.stdout.isTTY),
582
+ destination: options.stream ?? 1,
583
+ ignore: "pid,hostname",
584
+ singleLine: true,
585
+ translateTime: "SYS:standard"
586
+ })
587
+ )
577
588
  );
578
589
  }
579
590
  if (options.stream) {
580
- return pino(pinoOptions, options.stream);
591
+ return asHoopilotLogger(pino(pinoOptions, options.stream));
581
592
  }
582
- return pino(pinoOptions);
593
+ return asHoopilotLogger(pino(pinoOptions));
594
+ }
595
+ function asHoopilotLogger(logger) {
596
+ return logger;
583
597
  }
584
598
  function parseLogFormat(value) {
585
599
  if (!value) {
@@ -621,6 +635,10 @@ function isLogLevel(value) {
621
635
  return LOG_LEVELS.includes(value);
622
636
  }
623
637
 
638
+ // src/server.ts
639
+ import { createHash, timingSafeEqual } from "crypto";
640
+ import { Elysia } from "elysia";
641
+
624
642
  // src/openai.ts
625
643
  var DEFAULT_MODEL = "gpt-4.1";
626
644
  var OpenAICompatibilityError = class extends Error {
@@ -693,13 +711,6 @@ function compactionOutputFromResponsesSse(text) {
693
711
  }
694
712
  return deltas ? [messageOutputItem(deltas)] : [];
695
713
  }
696
- function safeJsonParse(text) {
697
- try {
698
- return JSON.parse(text);
699
- } catch {
700
- return void 0;
701
- }
702
- }
703
714
  function chatCompletionToCompletion(completion) {
704
715
  return removeUndefined({
705
716
  choices: completionChoices(completion).map((choice, index) => {
@@ -895,21 +906,18 @@ function extractTokenUsage(usage) {
895
906
  asRecord(record.prompt_tokens_details).cached_tokens,
896
907
  asRecord(record.input_tokens_details).cached_tokens
897
908
  );
898
- return removeUndefined({
899
- cachedTokens: cached,
909
+ const result = {
900
910
  completionTokens,
901
911
  promptTokens,
902
- reasoningTokens: reasoning,
903
912
  totalTokens: total ?? promptTokens + completionTokens
904
- });
905
- }
906
- function firstNumber(...values) {
907
- for (const value of values) {
908
- if (typeof value === "number" && Number.isFinite(value)) {
909
- return value;
910
- }
913
+ };
914
+ if (cached !== void 0) {
915
+ result.cachedTokens = cached;
911
916
  }
912
- return void 0;
917
+ if (reasoning !== void 0) {
918
+ result.reasoningTokens = reasoning;
919
+ }
920
+ return result;
913
921
  }
914
922
  function completionChoices(completion) {
915
923
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
@@ -935,7 +943,7 @@ function processCompletionSseBlock(block, enqueue, markTerminal) {
935
943
  enqueue("[DONE]");
936
944
  return;
937
945
  }
938
- const parsed = parseJson(data);
946
+ const parsed = parseJsonObject(data);
939
947
  if (!parsed) {
940
948
  return;
941
949
  }
@@ -999,19 +1007,6 @@ function encodeDataSse(data) {
999
1007
 
1000
1008
  `;
1001
1009
  }
1002
- function parseJson(data) {
1003
- try {
1004
- return asRecord(JSON.parse(data));
1005
- } catch {
1006
- return void 0;
1007
- }
1008
- }
1009
- function removeUndefined(record) {
1010
- return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
1011
- }
1012
- function randomId() {
1013
- return crypto.randomUUID().replaceAll("-", "");
1014
- }
1015
1010
  function epochSeconds() {
1016
1011
  return Math.floor(Date.now() / 1e3);
1017
1012
  }
@@ -1024,13 +1019,13 @@ var AnthropicCompatibilityError = class extends Error {
1024
1019
  }
1025
1020
  };
1026
1021
  function anthropicMessagesToResponsesRequest(request) {
1027
- return removeUndefined2({
1022
+ return removeUndefined({
1028
1023
  input: anthropicMessagesToResponsesInput(request.messages),
1029
1024
  instructions: anthropicSystemToInstructions(request.system),
1030
1025
  max_output_tokens: typeof request.max_tokens === "number" && Number.isFinite(request.max_tokens) ? request.max_tokens : void 0,
1031
1026
  metadata: request.metadata,
1032
1027
  model: normalizeRequestedModel(request.model),
1033
- parallel_tool_calls: true,
1028
+ parallel_tool_calls: asRecord(request.tool_choice).disable_parallel_tool_use !== true,
1034
1029
  reasoning: anthropicThinkingToReasoning(request.thinking),
1035
1030
  stop: anthropicStopSequences(request.stop_sequences),
1036
1031
  stream: request.stream === true,
@@ -1045,7 +1040,7 @@ function responsesResponseToAnthropicMessage(response, fallbackModel) {
1045
1040
  const usage = anthropicUsage(response.usage);
1046
1041
  return {
1047
1042
  content,
1048
- id: textValue(response.id) || `msg_${randomId2()}`,
1043
+ id: textValue(response.id) || `msg_${randomId()}`,
1049
1044
  model: textValue(response.model) || fallbackModel,
1050
1045
  role: "assistant",
1051
1046
  stop_reason: anthropicStopReason(response, content),
@@ -1122,7 +1117,7 @@ function createAnthropicStreamState(options) {
1122
1117
  return {
1123
1118
  blocks: /* @__PURE__ */ new Map(),
1124
1119
  completed: false,
1125
- messageId: options.messageId ?? `msg_${randomId2()}`,
1120
+ messageId: options.messageId ?? `msg_${randomId()}`,
1126
1121
  model: options.model,
1127
1122
  nextBlockIndex: 0,
1128
1123
  sawToolUse: false,
@@ -1176,7 +1171,7 @@ function anthropicMessagesToResponsesInput(messages) {
1176
1171
  flushMessage();
1177
1172
  input.push({
1178
1173
  arguments: JSON.stringify(asRecord(part.input)),
1179
- call_id: textValue(part.id) || `call_${randomId2()}`,
1174
+ call_id: textValue(part.id) || `call_${randomId()}`,
1180
1175
  name: textValue(part.name),
1181
1176
  type: "function_call"
1182
1177
  });
@@ -1287,7 +1282,7 @@ function anthropicTools(tools) {
1287
1282
  }
1288
1283
  const converted = tools.map((tool) => {
1289
1284
  const record = asRecord(tool);
1290
- return removeUndefined2({
1285
+ return removeUndefined({
1291
1286
  description: record.description,
1292
1287
  name: record.name,
1293
1288
  parameters: record.input_schema,
@@ -1358,7 +1353,7 @@ function anthropicContentFromResponsesOutput(response) {
1358
1353
  }
1359
1354
  if (type === "function_call") {
1360
1355
  content.push({
1361
- id: textValue(record.call_id) || textValue(record.id) || `call_${randomId2()}`,
1356
+ id: textValue(record.call_id) || textValue(record.id) || `call_${randomId()}`,
1362
1357
  input: parseToolInput(textValue(record.arguments)),
1363
1358
  name: textValue(record.name),
1364
1359
  type: "tool_use"
@@ -1385,12 +1380,12 @@ function anthropicStopReason(response, content) {
1385
1380
  }
1386
1381
  function anthropicUsage(usage) {
1387
1382
  const record = asRecord(usage);
1388
- const inputTokens = firstNumber2(record.input_tokens, record.prompt_tokens) ?? 0;
1389
- const outputTokens = firstNumber2(record.output_tokens, record.completion_tokens) ?? 0;
1383
+ const inputTokens = firstNumber(record.input_tokens, record.prompt_tokens) ?? 0;
1384
+ const outputTokens = firstNumber(record.output_tokens, record.completion_tokens) ?? 0;
1390
1385
  const details = asRecord(record.input_tokens_details);
1391
- return removeUndefined2({
1392
- cache_creation_input_tokens: firstNumber2(record.cache_creation_input_tokens),
1393
- cache_read_input_tokens: firstNumber2(record.cache_read_input_tokens, details.cached_tokens) ?? void 0,
1386
+ return removeUndefined({
1387
+ cache_creation_input_tokens: firstNumber(record.cache_creation_input_tokens),
1388
+ cache_read_input_tokens: firstNumber(record.cache_read_input_tokens, details.cached_tokens) ?? void 0,
1394
1389
  input_tokens: inputTokens,
1395
1390
  output_tokens: outputTokens
1396
1391
  });
@@ -1572,7 +1567,7 @@ function ensureToolBlock(state, payload, item, enqueue) {
1572
1567
  state.blocks.set(key, block);
1573
1568
  enqueue("content_block_start", {
1574
1569
  content_block: {
1575
- id: textValue(item.call_id) || textValue(item.id) || `call_${randomId2()}`,
1570
+ id: textValue(item.call_id) || textValue(item.id) || `call_${randomId()}`,
1576
1571
  input: {},
1577
1572
  name: textValue(item.name),
1578
1573
  type: "tool_use"
@@ -1606,13 +1601,6 @@ function parseSseBlock(block) {
1606
1601
  }
1607
1602
  return { data: data.join("\n"), event };
1608
1603
  }
1609
- function parseJsonObject(text) {
1610
- try {
1611
- return asRecord(JSON.parse(text));
1612
- } catch {
1613
- return void 0;
1614
- }
1615
- }
1616
1604
  function parseToolInput(argumentsText) {
1617
1605
  const parsed = parseJsonObject(argumentsText);
1618
1606
  return parsed ?? {};
@@ -1644,29 +1632,824 @@ function textValue(value) {
1644
1632
  }
1645
1633
  return "";
1646
1634
  }
1647
- function firstNumber2(...values) {
1648
- for (const value of values) {
1649
- if (typeof value === "number" && Number.isFinite(value)) {
1650
- return value;
1651
- }
1652
- }
1653
- return void 0;
1654
- }
1655
1635
  function indexValue(value) {
1656
1636
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
1657
1637
  }
1658
- function removeUndefined2(record) {
1659
- return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
1660
- }
1661
1638
  function encodeSse(event, data) {
1662
1639
  return `event: ${event}
1663
1640
  data: ${JSON.stringify(data)}
1664
1641
 
1665
1642
  `;
1666
1643
  }
1667
- function randomId2() {
1668
- return crypto.randomUUID().replaceAll("-", "");
1669
- }
1644
+
1645
+ // src/dashboard.ts
1646
+ var DASHBOARD_HTML = `<!doctype html>
1647
+ <html lang="en">
1648
+ <head>
1649
+ <meta charset="utf-8" />
1650
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1651
+ <meta name="color-scheme" content="dark light" />
1652
+ <title>hoopilot &middot; dashboard</title>
1653
+ <style>
1654
+ :root {
1655
+ --bg-0:#0b0e14; --bg-1:#11151c; --bg-2:#171c25; --bg-3:#1f2630;
1656
+ --border:#262d38; --border-strong:#37404d;
1657
+ --text-0:#e6edf3; --text-1:#9aa7b4; --text-2:#5e6b78; --text-dim:#3a434e; --text-inv:#0b0e14;
1658
+ --accent:#4ea1ff; --accent-2:#56d4dd; --accent-soft:rgba(78,161,255,.14);
1659
+ --amber:#f5b042;
1660
+ --ok:#3fb950; --warn:#d8a13a; --danger:#f0556a; --info:#a371f7; --cache:#7c8cff;
1661
+ --spark:#4ea1ff; --spark-fill:color-mix(in srgb, var(--accent) 14%, transparent);
1662
+ --grid-line:rgba(255,255,255,.05);
1663
+ --flash:color-mix(in srgb, var(--accent) 22%, transparent);
1664
+ --flash-up:color-mix(in srgb, var(--ok) 22%, transparent);
1665
+ --flash-down:color-mix(in srgb, var(--danger) 22%, transparent);
1666
+ --c1:#4ea1ff; --c2:#3fb950; --c3:#d8a13a; --c4:#a371f7; --c5:#56d4dd; --c6:#f0556a;
1667
+ --mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
1668
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, system-ui, sans-serif;
1669
+ }
1670
+ @media (prefers-color-scheme: light) {
1671
+ :root:not([data-theme="dark"]) {
1672
+ --bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
1673
+ --border:#d0d7de; --border-strong:#b6bec8;
1674
+ --text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
1675
+ --accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
1676
+ --amber:#b5730a;
1677
+ --ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
1678
+ --spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
1679
+ --grid-line:rgba(0,0,0,.06);
1680
+ --flash:color-mix(in srgb, var(--accent) 16%, transparent);
1681
+ --flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
1682
+ --flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
1683
+ --c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
1684
+ }
1685
+ }
1686
+ [data-theme="light"] {
1687
+ --bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
1688
+ --border:#d0d7de; --border-strong:#b6bec8;
1689
+ --text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
1690
+ --accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
1691
+ --amber:#b5730a;
1692
+ --ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
1693
+ --spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
1694
+ --grid-line:rgba(0,0,0,.06);
1695
+ --flash:color-mix(in srgb, var(--accent) 16%, transparent);
1696
+ --flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
1697
+ --flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
1698
+ --c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
1699
+ }
1700
+ * { box-sizing: border-box; }
1701
+ html, body { margin:0; padding:0; }
1702
+ body {
1703
+ background: var(--bg-0); color: var(--text-0); font-family: var(--sans);
1704
+ font-size: 13px; line-height: 1.4; -webkit-font-smoothing: antialiased;
1705
+ }
1706
+ .mono { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
1707
+ .num { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
1708
+ .shell { max-width: 1280px; margin: 0 auto; padding: 0 24px 28px; }
1709
+ @media (min-width: 1080px) { .shell { border-left:1px solid var(--border); border-right:1px solid var(--border); } }
1710
+ @media (max-width: 680px) { .shell { padding: 0 12px 24px; } }
1711
+
1712
+ /* header */
1713
+ header.bar {
1714
+ position: sticky; top: 0; z-index: 20; background: var(--bg-1);
1715
+ border-bottom: 1px solid var(--border); height: 48px;
1716
+ }
1717
+ .bar-in { max-width:1280px; margin:0 auto; height:48px; padding:0 24px; display:flex; align-items:center; gap:12px; }
1718
+ @media (max-width:680px){ .bar-in{ padding:0 12px; gap:8px; } }
1719
+ .wordmark { font-family: var(--mono); font-weight:700; font-size:14px; color:var(--text-0); letter-spacing:-.01em; }
1720
+ .caret { display:inline-block; width:7px; height:15px; background:var(--amber); margin-left:3px; vertical-align:-2px; animation: blink 1.1s steps(1) infinite; }
1721
+ .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; }
1722
+ .chip.plan-pro { background:var(--accent-soft); color:var(--accent); }
1723
+ .chip.plan-business { background:color-mix(in srgb, var(--info) 16%, transparent); color:var(--info); }
1724
+ .chip.plan-free, .chip.plan-offline { background:var(--bg-3); color:var(--text-2); }
1725
+ .spacer { flex:1; }
1726
+ .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); }
1727
+ .dot { width:7px; height:7px; border-radius:50%; background:var(--text-2); flex:none; }
1728
+ .pill.live .dot { background:var(--ok); }
1729
+ .pill.paused .dot { background:var(--text-2); }
1730
+ .pill.reconnect { color:var(--warn); } .pill.reconnect .dot { background:var(--warn); }
1731
+ .pill.authkey { color:var(--warn); } .pill.authkey .dot { background:var(--warn); }
1732
+ .heartbeat { animation: hb .5s ease-out; }
1733
+ .updated { font-family:var(--mono); font-size:11px; color:var(--text-2); white-space:nowrap; }
1734
+ .updated.warn { color:var(--warn); } .updated.danger { color:var(--danger); }
1735
+ .seg { display:inline-flex; border:1px solid var(--border); border-radius:6px; overflow:hidden; }
1736
+ .seg button { background:transparent; color:var(--text-1); border:0; font-family:var(--mono); font-size:11px; padding:3px 8px; cursor:pointer; }
1737
+ .seg button + button { border-left:1px solid var(--border); }
1738
+ .seg button.active { background:var(--accent); color:var(--text-inv); }
1739
+ .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; }
1740
+ .iconbtn:hover { background:var(--bg-3); }
1741
+ button:focus-visible, input:focus-visible, .seg button:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
1742
+ #scanbar { position:absolute; left:0; bottom:-1px; height:1px; width:100%; overflow:hidden; }
1743
+ #scanbar::after { content:""; position:absolute; left:0; top:0; height:1px; width:40%;
1744
+ background:linear-gradient(90deg, transparent, var(--accent), transparent);
1745
+ animation: scan var(--scan-ms, 4000ms) linear infinite; }
1746
+ header.bar.paused #scanbar::after, header.bar.frozen #scanbar::after { animation-play-state:paused; opacity:.35; }
1747
+
1748
+ /* disconnect banner */
1749
+ #banner { display:none; margin-top:10px; padding:7px 12px; border-radius:5px; font-family:var(--mono); font-size:12px;
1750
+ background:color-mix(in srgb, var(--danger) 16%, transparent); color:var(--danger); border:1px solid color-mix(in srgb, var(--danger) 40%, transparent); }
1751
+ #banner.ok { background:color-mix(in srgb, var(--ok) 16%, transparent); color:var(--ok); border-color:color-mix(in srgb, var(--ok) 40%, transparent); }
1752
+ #banner.show { display:block; }
1753
+
1754
+ /* hero strip */
1755
+ .hero { display:grid; grid-template-columns:repeat(4,1fr); margin:18px 0 16px; }
1756
+ .vital { padding:6px 18px; }
1757
+ .vital + .vital { border-left:1px solid var(--border); }
1758
+ .vital .eyebrow { font-size:10px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-1); }
1759
+ .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); }
1760
+ .vital .vsub { font-size:11px; color:var(--text-2); min-height:14px; }
1761
+ .vital .vspark { display:block; width:100%; height:24px; margin-top:4px; }
1762
+ .vital.active { }
1763
+ .vital.active .eyebrow { color:var(--accent); }
1764
+ @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; } }
1765
+ @media (max-width:600px){ .hero{ grid-template-columns:1fr; } .vital + .vital{ border-left:0; border-top:1px solid var(--border); } }
1766
+
1767
+ /* grid + panels */
1768
+ .grid { display:grid; grid-template-columns:repeat(12,1fr); gap:12px; }
1769
+ .panel { position:relative; background:var(--bg-1); border:1px solid var(--border); border-radius:4px; padding:16px 12px 12px; min-width:0; }
1770
+ .panel > .ptitle { position:absolute; top:-8px; left:10px; padding:0 6px; background:var(--bg-1);
1771
+ font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
1772
+ .span5{ grid-column:span 5; } .span3{ grid-column:span 3; } .span4{ grid-column:span 4; }
1773
+ .span7{ grid-column:span 7; } .span8{ grid-column:span 8; }
1774
+ @media (max-width:1079px){ .grid{ grid-template-columns:repeat(6,1fr); }
1775
+ .span5,.span7,.span8{ grid-column:span 6; } .span3{ grid-column:span 3; } .span4{ grid-column:span 6; } }
1776
+ @media (max-width:680px){ .grid{ grid-template-columns:1fr; }
1777
+ .span3,.span4,.span5,.span7,.span8{ grid-column:span 1; } }
1778
+
1779
+ .headline { font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-weight:600; font-size:22px; line-height:1.1; }
1780
+ .cap { font-size:11px; color:var(--text-2); }
1781
+ .stack-bar { display:flex; height:8px; border-radius:4px; overflow:hidden; background:var(--bg-3); margin:8px 0; }
1782
+ .stack-bar i { display:block; height:100%; }
1783
+ .stack-bar.empty { outline:1px dashed var(--border); background:transparent; }
1784
+
1785
+ table.tbl { width:100%; border-collapse:collapse; font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-size:12px; }
1786
+ .scrollx { overflow-x:auto; }
1787
+ 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; }
1788
+ table.tbl th.l { text-align:left; }
1789
+ table.tbl td { padding:3px 6px; text-align:right; white-space:nowrap; border-bottom:1px solid color-mix(in srgb, var(--border) 55%, transparent); }
1790
+ table.tbl td.l { text-align:left; max-width:160px; overflow:hidden; text-overflow:ellipsis; }
1791
+ table.tbl tr:hover td { background:var(--bg-2); }
1792
+ table.tbl tr.total td { border-top:1px solid var(--border-strong); border-bottom:0; font-weight:600; color:var(--text-0); }
1793
+ .minibar { display:inline-block; height:6px; border-radius:3px; background:var(--accent); vertical-align:middle; min-width:1px; }
1794
+ .ghost td { color:var(--text-2); text-align:center; }
1795
+ .reasoning { color:var(--info); } .cached { color:var(--cache); }
1796
+
1797
+ .legend { display:flex; flex-wrap:wrap; gap:4px 14px; margin-top:8px; }
1798
+ .legend .li { display:flex; align-items:center; gap:6px; font-family:var(--mono); font-size:11px; color:var(--text-1); }
1799
+ .legend .sw { width:8px; height:8px; border-radius:2px; flex:none; }
1800
+
1801
+ .lat-trio { display:flex; gap:18px; align-items:baseline; }
1802
+ .lat-trio .b { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
1803
+ .lat-trio .b small { display:block; font-size:10px; font-weight:600; text-transform:uppercase; color:var(--text-2); letter-spacing:.05em; }
1804
+ .lat-p95 { color:var(--info); }
1805
+ .lat-track { position:relative; height:22px; margin-top:10px; }
1806
+ .lat-track .line { position:absolute; top:11px; left:0; right:0; height:1px; background:var(--border); }
1807
+ .lat-track .tick { position:absolute; top:5px; width:2px; height:12px; border-radius:1px; }
1808
+ .lat-track .tick.p50 { background:var(--accent); } .lat-track .tick.p95 { background:var(--info); }
1809
+ .lat-track .tlab { position:absolute; top:-2px; font-family:var(--mono); font-size:9px; color:var(--text-2); transform:translateX(-50%); }
1810
+ details.routes { margin-top:10px; } details.routes summary { cursor:pointer; font-size:11px; color:var(--text-2); font-family:var(--mono); }
1811
+
1812
+ .qrow { margin:10px 0; } .qrow .qhead { display:flex; justify-content:space-between; align-items:baseline; font-size:12px; }
1813
+ .qrow .qname { color:var(--text-1); } .qrow .qval { font-family:var(--mono); font-variant-numeric:tabular-nums; color:var(--text-0); }
1814
+ .qbar { position:relative; height:8px; border-radius:4px; background:var(--bg-3); margin-top:5px; overflow:hidden; }
1815
+ .qbar i { position:absolute; left:0; top:0; height:100%; border-radius:4px; }
1816
+ .qbar.over i.ext { background:repeating-linear-gradient(45deg, var(--danger), var(--danger) 3px, transparent 3px, transparent 6px); }
1817
+ .inf { font-family:var(--mono); font-size:12px; color:var(--ok); }
1818
+ .emptybox { border:1px solid var(--border); border-radius:5px; padding:14px; text-align:center; color:var(--text-2); }
1819
+ .emptybox .keyglyph { font-size:20px; color:var(--text-1); }
1820
+ .emptybox h4 { margin:8px 0 4px; font-family:var(--sans); font-size:13px; color:var(--text-1); font-weight:600; }
1821
+ .emptybox .errline { font-family:var(--mono); font-size:11px; color:var(--text-2); word-break:break-word; margin:4px 0; }
1822
+ .prompt { font-family:var(--mono); font-size:12px; color:var(--text-1); }
1823
+
1824
+ .upblocks { display:flex; gap:18px; }
1825
+ .upblk { } .upblk .v { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
1826
+ .upblk .k { font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-2); }
1827
+ .upblk.err.hot { color:var(--danger); }
1828
+ .rate { font-family:var(--mono); font-size:12px; } .rate.warn{ color:var(--warn);} .rate.danger{ color:var(--danger);} .rate.ok{ color:var(--ok); }
1829
+ #up-spark, #thru-svg { display:block; width:100%; }
1830
+ #up-spark { height:30px; margin-top:8px; }
1831
+ #thru-svg { height:88px; margin-top:6px; }
1832
+ .flag { font-family:var(--mono); font-size:10px; color:var(--text-2); }
1833
+
1834
+ footer.foot { margin-top:14px; padding-top:10px; border-top:1px solid var(--border); display:flex; flex-wrap:wrap; gap:4px 14px;
1835
+ font-family:var(--mono); font-size:11px; color:var(--text-2); }
1836
+ footer.foot .end { margin-left:auto; }
1837
+ @media (max-width:680px){ footer.foot .end{ margin-left:0; } }
1838
+
1839
+ .skel { color:var(--text-dim); }
1840
+ .flash { animation: flash .6s ease-out; } .flash-up { animation: flashup .6s ease-out; } .flash-down { animation: flashdown .6s ease-out; }
1841
+
1842
+ /* auth takeover */
1843
+ #auth { display:none; }
1844
+ #auth.show { display:flex; justify-content:center; padding:64px 16px; }
1845
+ .authcard { width:100%; max-width:420px; background:var(--bg-1); border:1px solid var(--border); border-radius:6px; padding:22px 18px; position:relative; }
1846
+ .authcard h3 { margin:0 0 10px; font-family:var(--mono); font-size:12px; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
1847
+ .authcard p { font-size:12px; color:var(--text-2); margin:0 0 14px; }
1848
+ .authcard .row { display:flex; gap:8px; }
1849
+ .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; }
1850
+ .authcard input.bad { border-color:var(--danger); }
1851
+ .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; }
1852
+ .authcard .err { color:var(--danger); font-family:var(--mono); font-size:11px; min-height:14px; margin-top:8px; }
1853
+ .authcard .clear { position:absolute; top:14px; right:16px; font-size:11px; color:var(--text-2); cursor:pointer; }
1854
+ .dim { opacity:.45; filter:grayscale(.4); transition:opacity .2s, filter .2s; }
1855
+
1856
+ @keyframes blink { 50% { opacity:0; } }
1857
+ @keyframes scan { 0%{ transform:translateX(-100%);} 100%{ transform:translateX(350%);} }
1858
+ @keyframes hb { 0%{ transform:scale(1);} 35%{ transform:scale(1.7);} 100%{ transform:scale(1);} }
1859
+ @keyframes flash { from{ background:var(--flash);} to{ background:transparent;} }
1860
+ @keyframes flashup { from{ background:var(--flash-up);} to{ background:transparent;} }
1861
+ @keyframes flashdown { from{ background:var(--flash-down);} to{ background:transparent;} }
1862
+ @media (prefers-reduced-motion: reduce) {
1863
+ .caret { animation:none; } #scanbar::after { animation:none; opacity:.3; }
1864
+ .heartbeat { animation:none; }
1865
+ .flash, .flash-up, .flash-down { animation:none; box-shadow: inset 2px 0 0 var(--accent); }
1866
+ }
1867
+ </style>
1868
+ </head>
1869
+ <body>
1870
+ <header class="bar" id="bar">
1871
+ <div class="bar-in">
1872
+ <span class="wordmark">hoopilot<span class="caret" aria-hidden="true"></span></span>
1873
+ <span class="chip" id="version-chip">v&middot;&middot;&middot;</span>
1874
+ <span class="chip plan-offline" id="plan-chip">&mdash; offline</span>
1875
+ <span class="spacer"></span>
1876
+ <span class="pill" id="conn-pill" aria-live="polite"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting</span></span>
1877
+ <span class="updated" id="updated"></span>
1878
+ <span class="seg" id="seg" role="group" aria-label="Refresh interval">
1879
+ <button data-ms="2000">2s</button><button data-ms="4000" class="active">4s</button><button data-ms="10000">10s</button>
1880
+ </span>
1881
+ <button class="iconbtn" id="btn-pause" title="Pause / resume" aria-label="Pause or resume">&#10074;&#10074;</button>
1882
+ <button class="iconbtn" id="btn-theme" title="Theme: auto / dark / light" aria-label="Cycle theme">A</button>
1883
+ </div>
1884
+ <div id="scanbar" aria-hidden="true"></div>
1885
+ </header>
1886
+
1887
+ <div class="shell">
1888
+ <div id="banner" role="status" aria-live="polite"></div>
1889
+
1890
+ <section id="content">
1891
+ <section class="hero" aria-label="Vitals">
1892
+ <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>
1893
+ <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>
1894
+ <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>
1895
+ <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>
1896
+ </section>
1897
+
1898
+ <section class="grid">
1899
+ <div class="panel span5"><span class="ptitle">&#9508; Proxy &middot; requests &#9504;</span>
1900
+ <div class="headline"><span id="req-total" class="skel">&middot;&middot;&middot;</span> <span class="cap">requests</span></div>
1901
+ <div class="stack-bar empty" id="route-sharebar"></div>
1902
+ <div class="stack-bar empty" id="status-healthbar"></div>
1903
+ <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>
1904
+ </div>
1905
+
1906
+ <div class="panel span3"><span class="ptitle">&#9508; Status &#9504;</span>
1907
+ <div class="headline"><span id="error-rate" class="skel">&middot;&middot;&middot;</span> <span class="cap">err rate</span></div>
1908
+ <div class="stack-bar empty" id="status-bar"></div>
1909
+ <div class="legend" id="status-legend"></div>
1910
+ </div>
1911
+
1912
+ <div class="panel span4"><span class="ptitle">&#9508; Latency &middot; ms &#9504;</span>
1913
+ <div class="lat-trio">
1914
+ <div class="b"><small>p50</small><span id="lat-p50" class="skel">&middot;</span></div>
1915
+ <div class="b lat-p95"><small>p95</small><span id="lat-p95" class="skel">&middot;</span></div>
1916
+ <div class="b"><small>avg</small><span id="lat-avg" class="skel">&middot;</span></div>
1917
+ <div class="b"><small>obs</small><span id="lat-count" class="skel">&middot;</span></div>
1918
+ </div>
1919
+ <div class="lat-track" id="lat-track"><div class="line"></div></div>
1920
+ <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>
1921
+ </div>
1922
+
1923
+ <div class="panel span7"><span class="ptitle">&#9508; Tokens &middot; by model &#9504;</span>
1924
+ <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>
1925
+ <div class="stack-bar empty" id="tok-mixbar"></div>
1926
+ <div class="legend" id="tok-legend"></div>
1927
+ <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>
1928
+ </div>
1929
+
1930
+ <div class="panel span5"><span class="ptitle">&#9508; Copilot &middot; quota &#9504;</span>
1931
+ <div id="copilot-body"><div class="emptybox skel">loading&hellip;</div></div>
1932
+ </div>
1933
+
1934
+ <div class="panel span4"><span class="ptitle">&#9508; Upstream &middot; copilot edge &#9504;</span>
1935
+ <div class="upblocks">
1936
+ <div class="upblk"><div class="v" id="up-total">&middot;</div><div class="k">calls</div></div>
1937
+ <div class="upblk err" id="up-errblk"><div class="v" id="up-errors">&middot;</div><div class="k">errors</div></div>
1938
+ <div class="upblk"><div class="v rate" id="up-rate">&middot;</div><div class="k">err rate</div></div>
1939
+ </div>
1940
+ <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>
1941
+ <div class="flag" id="up-flag"></div>
1942
+ </div>
1943
+
1944
+ <div class="panel span8"><span class="ptitle">&#9508; Throughput &#9504;</span>
1945
+ <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>
1946
+ <svg id="thru-svg" viewBox="0 0 320 88" preserveAspectRatio="none" aria-hidden="true">
1947
+ <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>
1948
+ <line class="grid" x1="0" y1="22" x2="320" y2="22" stroke="var(--grid-line)"/>
1949
+ <line class="grid" x1="0" y1="44" x2="320" y2="44" stroke="var(--grid-line)"/>
1950
+ <line class="grid" x1="0" y1="66" x2="320" y2="66" stroke="var(--grid-line)"/>
1951
+ <path id="thru-tok-area" fill="url(#thrugrad)" stroke="none"/>
1952
+ <path id="thru-tok-line" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>
1953
+ <path id="thru-req-line" fill="none" stroke="var(--accent-2)" stroke-width="1.2" vector-effect="non-scaling-stroke" opacity="0.9"/>
1954
+ </svg>
1955
+ </div>
1956
+ </section>
1957
+ </section>
1958
+
1959
+ <section id="auth" aria-live="polite">
1960
+ <div class="authcard">
1961
+ <span class="clear" id="auth-clear" style="display:none">clear key</span>
1962
+ <h3>&#9508; Auth required &#9504;</h3>
1963
+ <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>
1964
+ <div class="row"><input id="auth-input" type="password" placeholder="x-api-key" autocomplete="off" spellcheck="false" /><button id="auth-connect">connect</button></div>
1965
+ <div class="err" id="auth-err"></div>
1966
+ </div>
1967
+ </section>
1968
+
1969
+ <footer class="foot">
1970
+ <span id="foot-started">started &middot;</span>
1971
+ <span id="foot-uptime">uptime &middot;</span>
1972
+ <span id="foot-total">&middot; req</span>
1973
+ <span id="foot-tokens">&middot; tokens</span>
1974
+ <span id="foot-upstream">upstream &middot;</span>
1975
+ <span class="end" id="foot-cadence"></span>
1976
+ </footer>
1977
+ </div>
1978
+
1979
+ <script>
1980
+ (function(){
1981
+ "use strict";
1982
+ var byId = function(id){ return document.getElementById(id); };
1983
+ var CAP = 60;
1984
+
1985
+ // ---- persistent state ----
1986
+ var LS = window.localStorage;
1987
+ var apiKey = "";
1988
+ try { apiKey = LS.getItem("hoopilot.apiKey") || ""; } catch (e) { apiKey = ""; }
1989
+ var theme = "auto";
1990
+ try { theme = LS.getItem("hoopilot.theme") || "auto"; } catch (e) { theme = "auto"; }
1991
+ var intervalMs = 4000;
1992
+ try { var sv = parseInt(LS.getItem("hoopilot.intervalMs") || "", 10); if (sv === 2000 || sv === 4000 || sv === 10000) intervalMs = sv; } catch (e) {}
1993
+
1994
+ // ---- runtime state ----
1995
+ var paused = false;
1996
+ var timer = null;
1997
+ var inflightFetch = null;
1998
+ var lastSuccessAt = 0;
1999
+ var prevSample = null; // { t, reqTotal, tokTotal, upTotal, startedAt }
2000
+ var lastRender = {}; // for change-flash
2001
+ var backoffMs = 0;
2002
+ var lastUptime = null; // seconds; ticked locally between polls
2003
+ var hist = { req:[], tok:[], inflight:[], up:[] };
2004
+
2005
+ // ---- formatting helpers ----
2006
+ function humanInt(n){
2007
+ if (n === null || n === undefined || !isFinite(n)) return "0";
2008
+ var a = Math.abs(n);
2009
+ if (a >= 1000000) return (n/1000000).toFixed(a >= 10000000 ? 0 : 1) + "M";
2010
+ if (a >= 1000) return (n/1000).toFixed(a >= 10000 ? 0 : 1) + "k";
2011
+ return String(Math.round(n));
2012
+ }
2013
+ function rate(n){
2014
+ if (n === null || n === undefined || !isFinite(n)) return "0";
2015
+ if (n >= 100) return String(Math.round(n));
2016
+ if (n >= 10) return n.toFixed(1);
2017
+ return n.toFixed(2);
2018
+ }
2019
+ function pct(n){ if (!isFinite(n)) return "0%"; return (n >= 10 ? Math.round(n) : Math.round(n*10)/10) + "%"; }
2020
+ 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 + ""; }
2021
+ function pad2(n){ return (n < 10 ? "0" : "") + n; }
2022
+ function fmtUptime(sec){
2023
+ sec = Math.max(0, Math.floor(sec));
2024
+ var d = Math.floor(sec/86400); sec -= d*86400;
2025
+ var h = Math.floor(sec/3600); sec -= h*3600;
2026
+ var m = Math.floor(sec/60); var s = sec - m*60;
2027
+ if (d > 0) return d + "d " + pad2(h) + ":" + pad2(m);
2028
+ if (h > 0) return h + ":" + pad2(m) + ":" + pad2(s);
2029
+ return m + ":" + pad2(s);
2030
+ }
2031
+ function titleize(key){
2032
+ var map = { premium_interactions:"Premium requests", chat:"Chat", completions:"Completions", code_review:"Code review" };
2033
+ if (map[key]) return map[key];
2034
+ return key.split("_").map(function(w){ return w ? w.charAt(0).toUpperCase() + w.slice(1) : w; }).join(" ");
2035
+ }
2036
+ function relTime(iso){
2037
+ var t = Date.parse(iso); if (!isFinite(t)) return iso || "";
2038
+ var s = Math.max(0, Math.round((Date.now() - t)/1000));
2039
+ return fmtUptime(s) + " ago";
2040
+ }
2041
+ function clearEl(el){ while (el && el.firstChild) el.removeChild(el.firstChild); }
2042
+ 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; }
2043
+
2044
+ // Set numeric text and flash on discrete change.
2045
+ function setNum(id, value, kind, num){
2046
+ var el = byId(id); if (!el) return;
2047
+ el.classList.remove("skel");
2048
+ var s = String(value);
2049
+ if (el.textContent !== s){
2050
+ el.textContent = s;
2051
+ // Compare on the raw number (num) when provided, so directional flash works
2052
+ // even when value is a pre-formatted display string.
2053
+ var n = (num !== undefined) ? num : value;
2054
+ var prev = lastRender[id];
2055
+ if (prev !== undefined){
2056
+ var cls = "flash";
2057
+ if (kind === "delta" && typeof n === "number" && typeof prev === "number"){
2058
+ cls = n > prev ? "flash-up" : (n < prev ? "flash-down" : null);
2059
+ }
2060
+ if (cls){ el.classList.remove("flash","flash-up","flash-down"); void el.offsetWidth; el.classList.add(cls); }
2061
+ }
2062
+ lastRender[id] = n;
2063
+ }
2064
+ }
2065
+ function setText(id, s){ var el = byId(id); if (el){ el.classList.remove("skel"); el.textContent = s; } }
2066
+
2067
+ // ---- sparkline rendering ----
2068
+ function pushHist(arr, v){ arr.push(v); if (arr.length > CAP) arr.shift(); }
2069
+ function buildSpark(values, w, h){
2070
+ var pts = []; for (var i=0;i<values.length;i++){ if (isFinite(values[i])) pts.push({ i:i, v:values[i] }); }
2071
+ if (pts.length < 2) return null;
2072
+ var min = Infinity, max = -Infinity;
2073
+ 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; } }
2074
+ var flat = (max - min) <= 0;
2075
+ var pad = flat ? 1 : (max - min) * 0.05; var lo = min - pad, hi = max + pad; var span = hi - lo; if (span <= 0) span = 1;
2076
+ var n = values.length;
2077
+ var line = "", lastX = 0, lastY = 0, started = false;
2078
+ for (var k=0;k<n;k++){
2079
+ var val = values[k]; if (!isFinite(val)) continue;
2080
+ var x = (n === 1) ? w : (k * (w/(n-1)));
2081
+ var norm = flat ? 0.5 : (val - lo)/span;
2082
+ var y = h - norm*(h-2) - 1;
2083
+ line += (started ? " L" : "M") + x.toFixed(2) + "," + y.toFixed(2);
2084
+ lastX = x; lastY = y; started = true;
2085
+ }
2086
+ var area = line + " L" + lastX.toFixed(2) + "," + h + " L0," + h + " Z";
2087
+ return { line:line, area:area, lastX:lastX, lastY:lastY };
2088
+ }
2089
+ function drawSpark(svgId, values){
2090
+ var svg = byId(svgId); if (!svg) return;
2091
+ var vb = svg.viewBox.baseVal; var w = vb.width || 200, h = vb.height || 24;
2092
+ var sp = buildSpark(values, w, h);
2093
+ var line = svg.querySelector(".line"), area = svg.querySelector(".area"), dot = svg.querySelector("circle");
2094
+ if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); if (dot) dot.style.display = "none"; return; }
2095
+ if (line) line.setAttribute("d", sp.line);
2096
+ if (area) area.setAttribute("d", sp.area);
2097
+ if (dot){ dot.setAttribute("cx", sp.lastX.toFixed(2)); dot.setAttribute("cy", sp.lastY.toFixed(2)); dot.style.display = ""; }
2098
+ }
2099
+
2100
+ // ---- theme ----
2101
+ function applyTheme(){
2102
+ var root = document.documentElement;
2103
+ if (theme === "dark") root.setAttribute("data-theme","dark");
2104
+ else if (theme === "light") root.setAttribute("data-theme","light");
2105
+ else root.removeAttribute("data-theme");
2106
+ byId("btn-theme").textContent = theme === "dark" ? "D" : (theme === "light" ? "L" : "A");
2107
+ }
2108
+ byId("btn-theme").addEventListener("click", function(){
2109
+ theme = theme === "auto" ? "dark" : (theme === "dark" ? "light" : "auto");
2110
+ try { LS.setItem("hoopilot.theme", theme); } catch (e) {}
2111
+ applyTheme();
2112
+ });
2113
+
2114
+ // ---- interval + pause ----
2115
+ function setActiveSeg(){
2116
+ var btns = byId("seg").querySelectorAll("button");
2117
+ for (var i=0;i<btns.length;i++){ btns[i].classList.toggle("active", parseInt(btns[i].getAttribute("data-ms"),10) === intervalMs); }
2118
+ document.documentElement.style.setProperty("--scan-ms", intervalMs + "ms");
2119
+ }
2120
+ byId("seg").addEventListener("click", function(ev){
2121
+ var b = ev.target.closest ? ev.target.closest("button") : null; if (!b) return;
2122
+ intervalMs = parseInt(b.getAttribute("data-ms"),10) || 4000;
2123
+ try { LS.setItem("hoopilot.intervalMs", String(intervalMs)); } catch (e) {}
2124
+ setActiveSeg();
2125
+ if (!paused){ schedule(0); }
2126
+ });
2127
+ byId("btn-pause").addEventListener("click", function(){
2128
+ paused = !paused;
2129
+ byId("btn-pause").innerHTML = paused ? "&#9654;" : "&#10074;&#10074;";
2130
+ byId("bar").classList.toggle("paused", paused);
2131
+ if (paused){ if (timer){ clearTimeout(timer); timer = null; } setPill("paused","PAUSED",false); }
2132
+ else { setPill("live","LIVE",false); schedule(0); }
2133
+ });
2134
+
2135
+ // ---- connection pill / banner ----
2136
+ function setPill(kind, text, beat){
2137
+ var pill = byId("conn-pill"); var dot = byId("conn-dot");
2138
+ pill.className = "pill " + kind;
2139
+ byId("conn-text").textContent = text;
2140
+ if (beat && dot){ dot.classList.remove("heartbeat"); void dot.offsetWidth; dot.classList.add("heartbeat"); }
2141
+ }
2142
+ function showBanner(text, ok){
2143
+ var b = byId("banner"); b.textContent = text; b.className = "show" + (ok ? " ok" : "");
2144
+ if (ok){ setTimeout(function(){ b.classList.remove("show"); }, 2000); }
2145
+ }
2146
+ function hideBanner(){ byId("banner").classList.remove("show"); }
2147
+ function setDimmed(on){ byId("content").classList.toggle("dim", on); }
2148
+
2149
+ // ---- auth takeover ----
2150
+ function showAuth(rejected){
2151
+ byId("content").style.display = "none";
2152
+ byId("auth").classList.add("show");
2153
+ setPill("authkey","API KEY",false);
2154
+ byId("auth-err").textContent = rejected ? "key rejected" : "";
2155
+ byId("auth-input").classList.toggle("bad", !!rejected);
2156
+ byId("auth-clear").style.display = apiKey ? "" : "none";
2157
+ byId("auth-input").focus();
2158
+ }
2159
+ function hideAuth(){ byId("auth").classList.remove("show"); byId("content").style.display = ""; }
2160
+ byId("auth-connect").addEventListener("click", function(){
2161
+ var v = byId("auth-input").value.trim(); if (!v) return;
2162
+ apiKey = v; try { LS.setItem("hoopilot.apiKey", apiKey); } catch (e) {}
2163
+ hideAuth(); schedule(0);
2164
+ });
2165
+ byId("auth-input").addEventListener("keydown", function(ev){ if (ev.key === "Enter") byId("auth-connect").click(); });
2166
+ byId("auth-clear").addEventListener("click", function(){
2167
+ apiKey = ""; try { LS.removeItem("hoopilot.apiKey"); } catch (e) {}
2168
+ byId("auth-input").value = ""; byId("auth-clear").style.display = "none"; byId("auth-input").focus();
2169
+ });
2170
+
2171
+ // ---- the poll loop (setTimeout-chained, never setInterval) ----
2172
+ var pollGen = 0;
2173
+ function schedule(delay){
2174
+ if (timer){ clearTimeout(timer); }
2175
+ if (paused) return;
2176
+ timer = setTimeout(poll, delay === undefined ? intervalMs : delay);
2177
+ }
2178
+ function poll(){
2179
+ if (paused) return;
2180
+ // A new poll supersedes any in-flight one. Bump the generation so the old
2181
+ // request's settled handlers (including its abort rejection) become no-ops
2182
+ // and never flash a false "disconnected".
2183
+ pollGen += 1; var myGen = pollGen;
2184
+ if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
2185
+ var ctrl = new AbortController(); inflightFetch = ctrl;
2186
+ var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, 3000);
2187
+ var headers = { "accept":"application/json" };
2188
+ if (apiKey) headers["x-api-key"] = apiKey;
2189
+ fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
2190
+ clearTimeout(to);
2191
+ if (myGen !== pollGen) return null;
2192
+ if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
2193
+ if (!res.ok) throw new Error("HTTP " + res.status);
2194
+ return res.json();
2195
+ }).then(function(data){
2196
+ if (myGen !== pollGen || data === null || paused) return;
2197
+ inflightFetch = null;
2198
+ onData(data);
2199
+ backoffMs = 0; lastSuccessAt = Date.now();
2200
+ hideAuth(); setDimmed(false); hideBanner();
2201
+ setPill("live","LIVE",true);
2202
+ byId("bar").classList.remove("frozen");
2203
+ schedule(intervalMs);
2204
+ }).catch(function(err){
2205
+ clearTimeout(to);
2206
+ if (myGen !== pollGen || paused) return;
2207
+ inflightFetch = null;
2208
+ onDisconnect(err);
2209
+ });
2210
+ }
2211
+ function onDisconnect(err){
2212
+ setPill("reconnect","RECONNECTING",false);
2213
+ setDimmed(true);
2214
+ byId("bar").classList.add("frozen");
2215
+ backoffMs = backoffMs ? Math.min(Math.round(backoffMs * 1.5), 30000) : intervalMs;
2216
+ showBanner("Disconnected (" + (err && err.message ? err.message : "no response") + ") \\u2014 retrying in " + Math.round(backoffMs/1000) + "s", false);
2217
+ schedule(backoffMs);
2218
+ }
2219
+
2220
+ // ---- main render ----
2221
+ function onData(usage){
2222
+ var proxy = usage.proxy || {};
2223
+ var now = Date.now();
2224
+
2225
+ setText("version-chip", "v" + (usage.version || "?"));
2226
+
2227
+ // rates
2228
+ var reqTotal = (proxy.requests && proxy.requests.total) || 0;
2229
+ var tokTotal = (proxy.tokens && proxy.tokens.total) || 0;
2230
+ var upTotal = (proxy.upstream && proxy.upstream.total) || 0;
2231
+ var startedAt = proxy.startedAt || "";
2232
+ var reqPerSec = NaN, tokPerSec = NaN, upDelta = 0, restarted = false;
2233
+ if (prevSample){
2234
+ var dt = (now - prevSample.t)/1000;
2235
+ if (prevSample.startedAt && startedAt && prevSample.startedAt !== startedAt) restarted = true;
2236
+ if (reqTotal < prevSample.reqTotal || tokTotal < prevSample.tokTotal) restarted = true;
2237
+ if (restarted){ reqPerSec = 0; tokPerSec = 0; upDelta = 0; }
2238
+ else if (dt > 0 && isFinite(dt)){
2239
+ reqPerSec = Math.max(0, (reqTotal - prevSample.reqTotal)/dt);
2240
+ tokPerSec = Math.max(0, (tokTotal - prevSample.tokTotal)/dt);
2241
+ upDelta = Math.max(0, upTotal - prevSample.upTotal);
2242
+ }
2243
+ }
2244
+ prevSample = { t:now, reqTotal:reqTotal, tokTotal:tokTotal, upTotal:upTotal, startedAt:startedAt };
2245
+
2246
+ // hero vitals
2247
+ if (isFinite(reqPerSec)){ pushHist(hist.req, reqPerSec); setNum("req-num", rate(reqPerSec)); } else setText("req-num","\\u2014");
2248
+ if (isFinite(tokPerSec)){ pushHist(hist.tok, tokPerSec); setNum("tok-num", humanInt(tokPerSec)); } else setText("tok-num","\\u2014");
2249
+ var inflight = proxy.inFlight || 0;
2250
+ pushHist(hist.inflight, inflight); setNum("inflight-num", String(inflight), "delta", inflight);
2251
+ byId("v-inflight").classList.toggle("active", inflight > 0);
2252
+ setText("uptime-num", fmtUptime(proxy.uptimeSeconds || 0));
2253
+
2254
+ setText("req-sub", hist.req.length ? ("avg " + rate(avg(hist.req)) + "/s") : "warming up");
2255
+ setText("tok-sub", hist.tok.length ? ("peak " + humanInt(Math.max.apply(null, hist.tok)) + "/s") : "warming up");
2256
+ setText("inflight-sub", inflight + " now");
2257
+ setText("uptime-sub", startedAt ? ("since " + relTime(startedAt)) : "");
2258
+
2259
+ drawSpark("req-spark", hist.req);
2260
+ drawSpark("tok-spark", hist.tok);
2261
+ drawSpark("inflight-spark", hist.inflight);
2262
+
2263
+ renderRequests(proxy);
2264
+ renderStatus(proxy);
2265
+ renderLatency(proxy.latency || {});
2266
+ renderTokens(proxy.tokens || {});
2267
+ renderCopilot(usage);
2268
+ renderUpstream(proxy.upstream || {}, upDelta, restarted);
2269
+ renderThroughput();
2270
+ renderFooter(usage, proxy);
2271
+
2272
+ setNum("req-total", humanInt(reqTotal));
2273
+ setNum("tok-total", humanInt(tokTotal));
2274
+ lastUptime = proxy.uptimeSeconds || 0;
2275
+ }
2276
+
2277
+ 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; }
2278
+
2279
+ var ROUTE_COLORS = ["var(--c1)","var(--c2)","var(--c3)","var(--c4)","var(--c5)","var(--c6)"];
2280
+ function renderRequests(proxy){
2281
+ var byRoute = (proxy.requests && proxy.requests.byRoute) || {};
2282
+ var total = (proxy.requests && proxy.requests.total) || 0;
2283
+ var rows = Object.keys(byRoute).map(function(k){ return { k:k, v:byRoute[k] }; }).sort(function(a,b){ return b.v - a.v; });
2284
+ var share = byId("route-sharebar"); clearEl(share); share.className = "stack-bar" + (total ? "" : " empty");
2285
+ var body = byId("routes-body"); clearEl(body);
2286
+ 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; }
2287
+ rows.forEach(function(r, idx){
2288
+ var p = total ? (r.v/total*100) : 0;
2289
+ 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);
2290
+ var tr = mk("tr");
2291
+ var name = mk("td","l", r.k); name.title = r.k; tr.appendChild(name);
2292
+ tr.appendChild(mk("td",null, humanInt(r.v)));
2293
+ tr.appendChild(mk("td",null, pct(p)));
2294
+ 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);
2295
+ body.appendChild(tr);
2296
+ });
2297
+ 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);
2298
+ }
2299
+
2300
+ 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"; }
2301
+ function statusColor(cls){ return cls === "ok" ? "var(--ok)" : cls === "info" ? "var(--info)" : cls === "warn" ? "var(--warn)" : cls === "danger" ? "var(--danger)" : "var(--text-2)"; }
2302
+ function renderStatus(proxy){
2303
+ var byStatus = (proxy.requests && proxy.requests.byStatus) || {};
2304
+ var total = 0, errs = 0; var groups = { ok:0, info:0, warn:0, danger:0, muted:0 };
2305
+ var codes = Object.keys(byStatus).map(function(k){ return { k:k, v:byStatus[k] }; }).sort(function(a,b){ return b.v - a.v; });
2306
+ codes.forEach(function(c){ total += c.v; var cls = statusClass(c.k); groups[cls] += c.v; if (cls === "warn" || cls === "danger") errs += c.v; });
2307
+ var bar = byId("status-bar"); clearEl(bar); bar.className = "stack-bar" + (total ? "" : " empty");
2308
+ ["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); } });
2309
+ var leg = byId("status-legend"); clearEl(leg);
2310
+ if (!codes.length){ leg.appendChild(mk("span","li","no requests yet")); }
2311
+ 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); });
2312
+ var er = total ? (errs/total*100) : 0;
2313
+ setNum("error-rate", pct(er));
2314
+ var el = byId("error-rate"); el.style.color = er > 5 ? "var(--danger)" : er > 1 ? "var(--warn)" : "var(--ok)";
2315
+ }
2316
+
2317
+ function renderLatency(lat){
2318
+ setText("lat-p50", fmtMs(lat.p50Ms)); setText("lat-avg", fmtMs(lat.avgMs)); setText("lat-count", humanInt(lat.count || 0));
2319
+ var p95 = byId("lat-p95"); p95.classList.remove("skel"); p95.textContent = fmtMs(lat.p95Ms);
2320
+ p95.style.color = (lat.p50Ms > 0 && lat.p95Ms > 2*lat.p50Ms) ? "var(--warn)" : "var(--info)";
2321
+ // track: position p50 and p95 across 0..(p95*1.15)
2322
+ var track = byId("lat-track"); var old = track.querySelectorAll(".tick,.tlab"); for (var i=0;i<old.length;i++) old[i].remove();
2323
+ var maxv = Math.max(lat.p95Ms || 0, lat.avgMs || 0, 1) * 1.15;
2324
+ 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); }
2325
+ place(lat.p50Ms, "p50"); place(lat.p95Ms, "p95");
2326
+ var lr = byId("lat-routes"); clearEl(lr);
2327
+ 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); });
2328
+ 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); });
2329
+ }
2330
+
2331
+ function renderTokens(tok){
2332
+ var prompt = tok.prompt||0, completion = tok.completion||0, reasoning = tok.reasoning||0, cached = tok.cached||0;
2333
+ var sum = prompt + completion + reasoning;
2334
+ var bar = byId("tok-mixbar"); clearEl(bar); bar.className = "stack-bar" + (sum ? "" : " empty");
2335
+ var parts = [ ["prompt", prompt, "var(--text-1)"], ["completion", completion, "var(--accent)"], ["reasoning", reasoning, "var(--info)"] ];
2336
+ 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); } });
2337
+ var leg = byId("tok-legend"); clearEl(leg);
2338
+ var legParts = parts.concat([["cached", cached, "var(--cache)"]]);
2339
+ 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); });
2340
+ var cacheRate = prompt ? (cached/prompt*100) : 0; setText("tok-cache", "cache " + pct(cacheRate));
2341
+ var body = byId("tok-body"); clearEl(body);
2342
+ 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); });
2343
+ 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; }
2344
+ 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);
2345
+ tr.appendChild(mk("td",null, humanInt(m.prompt||0))); tr.appendChild(mk("td",null, humanInt(m.completion||0)));
2346
+ tr.appendChild(mk("td","reasoning", humanInt(m.reasoning||0))); tr.appendChild(mk("td","cached", humanInt(m.cached||0)));
2347
+ tr.appendChild(mk("td",null, humanInt(m.total||0))); tr.appendChild(mk("td",null, humanInt(m.requests||0))); body.appendChild(tr); });
2348
+ }
2349
+
2350
+ 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"; }
2351
+ function renderCopilot(usage){
2352
+ var box = byId("copilot-body"); clearEl(box);
2353
+ var cp = usage.copilot; var planChip = byId("plan-chip");
2354
+ if (!cp){
2355
+ planChip.className = "chip plan-offline"; planChip.textContent = "\\u2014 offline";
2356
+ var eb = mk("div","emptybox"); eb.appendChild(mk("div","keyglyph","\\u26bf"));
2357
+ eb.appendChild(mk("h4",null,"Copilot not connected"));
2358
+ if (usage.copilot_error) eb.appendChild(mk("div","errline", usage.copilot_error));
2359
+ eb.appendChild(mk("div","prompt","$ hoopilot login"));
2360
+ box.appendChild(eb); return;
2361
+ }
2362
+ planChip.className = "chip " + planClass(cp.plan); planChip.textContent = cp.plan || "copilot";
2363
+ var head = mk("div","cap");
2364
+ var bits = [];
2365
+ if (cp.accessTypeSku) bits.push(cp.accessTypeSku);
2366
+ if (cp.chatEnabled !== undefined) bits.push(cp.chatEnabled ? "chat on" : "chat off");
2367
+ if (cp.quotaResetDate) bits.push("resets " + cp.quotaResetDate);
2368
+ head.textContent = bits.join(" \\u00b7 "); box.appendChild(head);
2369
+ var quotas = cp.quotas || {}; var keys = Object.keys(quotas);
2370
+ if (!keys.length){ box.appendChild(mk("div","cap","No metered quotas reported.")); return; }
2371
+ var order = { premium_interactions:0, chat:1, completions:2 };
2372
+ 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); });
2373
+ keys.forEach(function(k){
2374
+ var q = quotas[k]; var row = mk("div","qrow");
2375
+ var hd = mk("div","qhead"); hd.appendChild(mk("span","qname", titleize(k)));
2376
+ if (q.unlimited){ hd.appendChild(mk("span","inf","\\u221e unlimited")); row.appendChild(hd); box.appendChild(row); return; }
2377
+ var ent = q.entitlement, rem = q.remaining, used = q.used;
2378
+ var usedPct = (q.percentRemaining !== undefined) ? (100 - q.percentRemaining) : ((ent && used !== undefined) ? (used/ent*100) : 0);
2379
+ usedPct = Math.max(0, Math.min(100, usedPct));
2380
+ var valTxt = (used !== undefined && ent !== undefined) ? (humanInt(used) + " / " + humanInt(ent)) : (rem !== undefined ? (humanInt(rem) + " left") : pct(100-usedPct) + " left");
2381
+ hd.appendChild(mk("span","qval", valTxt)); row.appendChild(hd);
2382
+ var bar = mk("div","qbar"); var fill = mk("i"); fill.style.width = usedPct + "%";
2383
+ fill.style.background = usedPct > 85 ? "var(--danger)" : usedPct > 60 ? "var(--warn)" : "var(--ok)"; bar.appendChild(fill);
2384
+ 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); }
2385
+ row.appendChild(bar);
2386
+ if (q.overageCount){ var ov = mk("div","flag", humanInt(q.overageCount) + " overage" + (q.tokenBasedBilling ? " \\u00b7 token billing" : "")); row.appendChild(ov); }
2387
+ box.appendChild(row);
2388
+ });
2389
+ }
2390
+
2391
+ function renderUpstream(up, delta, restarted){
2392
+ setNum("up-total", humanInt(up.total||0));
2393
+ setNum("up-errors", humanInt(up.errors||0), "delta", up.errors||0);
2394
+ var er = up.total ? (up.errors/up.total*100) : 0;
2395
+ var rt = byId("up-rate"); rt.textContent = pct(er); rt.className = "v rate " + (er > 5 ? "danger" : er > 1 ? "warn" : "ok");
2396
+ byId("up-errblk").classList.toggle("hot", (up.errors||0) > 0);
2397
+ pushHist(hist.up, delta||0); drawSpark("up-spark", hist.up);
2398
+ byId("up-flag").textContent = restarted ? "\\u21bb restarted" : "";
2399
+ }
2400
+
2401
+ function renderThroughput(){
2402
+ drawDual("thru-tok-line","thru-tok-area", hist.tok, true);
2403
+ drawDual("thru-req-line", null, hist.req, false);
2404
+ setText("thru-tok", hist.tok.length ? rate(hist.tok[hist.tok.length-1]) : "\\u2014");
2405
+ setText("thru-req", hist.req.length ? rate(hist.req[hist.req.length-1]) : "\\u2014");
2406
+ var peakTok = hist.tok.length ? Math.max.apply(null, hist.tok) : 0;
2407
+ setText("thru-peak", "peak " + humanInt(peakTok) + " tok/s");
2408
+ }
2409
+ function drawDual(lineId, areaId, values, withArea){
2410
+ var svg = byId("thru-svg"); var vb = svg.viewBox.baseVal; var w = vb.width, h = vb.height;
2411
+ var sp = buildSpark(values, w, h);
2412
+ var line = byId(lineId); var area = areaId ? byId(areaId) : null;
2413
+ if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); return; }
2414
+ if (line) line.setAttribute("d", sp.line);
2415
+ if (area && withArea) area.setAttribute("d", sp.area);
2416
+ }
2417
+
2418
+ function renderFooter(usage, proxy){
2419
+ setText("foot-started", proxy.startedAt ? ("started " + new Date(proxy.startedAt).toLocaleString()) : "started \\u2014");
2420
+ setText("foot-uptime", "uptime " + fmtUptime(proxy.uptimeSeconds||0));
2421
+ setText("foot-total", humanInt((proxy.requests && proxy.requests.total)||0) + " req");
2422
+ setText("foot-tokens", humanInt((proxy.tokens && proxy.tokens.total)||0) + " tokens");
2423
+ var up = proxy.upstream || {}; setText("foot-upstream", "upstream " + humanInt(up.total||0) + " / " + humanInt(up.errors||0) + " err");
2424
+ setText("foot-cadence", "polling /v1/usage every " + Math.round(intervalMs/1000) + "s \\u00b7 GET /dashboard");
2425
+ }
2426
+
2427
+ // ---- 1s freshness + uptime ticker (independent of the poll loop) ----
2428
+ setInterval(function(){
2429
+ if (lastSuccessAt){
2430
+ var ago = Math.round((Date.now() - lastSuccessAt)/1000);
2431
+ var u = byId("updated"); u.textContent = "updated " + ago + "s ago";
2432
+ // Staleness only matters while polling; a deliberate pause is not "stale".
2433
+ u.className = "updated" + (paused ? "" : ago > intervalMs/1000*4 ? " danger" : ago > intervalMs/1000*2 ? " warn" : "");
2434
+ }
2435
+ // Tick uptime locally between polls so the seconds advance smoothly; each
2436
+ // successful poll re-seeds lastUptime from the authoritative server value.
2437
+ if (!paused && lastUptime !== null){
2438
+ lastUptime += 1;
2439
+ byId("uptime-num").textContent = fmtUptime(lastUptime);
2440
+ var fu = byId("foot-uptime"); if (fu) fu.textContent = "uptime " + fmtUptime(lastUptime);
2441
+ }
2442
+ }, 1000);
2443
+
2444
+ // ---- boot ----
2445
+ applyTheme(); setActiveSeg();
2446
+ setPill("","CONNECTING",false);
2447
+ poll();
2448
+ })();
2449
+ </script>
2450
+ </body>
2451
+ </html>
2452
+ `;
1670
2453
 
1671
2454
  // src/metrics.ts
1672
2455
  var PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
@@ -1752,26 +2535,23 @@ var MetricsRegistry = class {
1752
2535
  const resource = this.#rateLimitResource(rateLimit.resource);
1753
2536
  this.#githubRateLimit.set(resource, { ...rateLimit, resource });
1754
2537
  }
1755
- // Sanitize the model into a bounded label. The model can originate from a
1756
- // client request, so cap its length, strip characters that would corrupt the
1757
- // exposition format, and fold overflow past the cardinality limit into
1758
- // UNKNOWN_MODEL to keep the series count bounded.
1759
- #modelLabel(model) {
1760
- const cleaned = cleanLabel(model).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
1761
- if (!this.#tokens.has(cleaned) && this.#tokens.size >= MAX_TRACKED_MODELS) {
2538
+ // Clean a raw value into a bounded exposition-format label: cap its length,
2539
+ // strip characters that would corrupt the format, and fold overflow past the
2540
+ // cardinality limit into UNKNOWN_MODEL so the series count stays bounded.
2541
+ #boundedLabel(value, tracked, maxEntries) {
2542
+ const cleaned = cleanLabel(value).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2543
+ if (!tracked.has(cleaned) && tracked.size >= maxEntries) {
1762
2544
  return UNKNOWN_MODEL;
1763
2545
  }
1764
2546
  return cleaned;
1765
2547
  }
1766
- // The resource comes from a trusted upstream header, but clean and bound it
1767
- // with the same discipline as model labels: strip control characters that
1768
- // would corrupt the exposition format and fold overflow into "unknown".
2548
+ // The model can originate from a (possibly hostile) client request.
2549
+ #modelLabel(model) {
2550
+ return this.#boundedLabel(model, this.#tokens, MAX_TRACKED_MODELS);
2551
+ }
2552
+ // The resource comes from a trusted upstream header, but is bounded the same way.
1769
2553
  #rateLimitResource(resource) {
1770
- const cleaned = cleanLabel(resource).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
1771
- if (!this.#githubRateLimit.has(cleaned) && this.#githubRateLimit.size >= MAX_TRACKED_RATELIMIT_RESOURCES) {
1772
- return UNKNOWN_MODEL;
1773
- }
1774
- return cleaned;
2554
+ return this.#boundedLabel(resource, this.#githubRateLimit, MAX_TRACKED_RATELIMIT_RESOURCES);
1775
2555
  }
1776
2556
  #observeDuration(route, seconds) {
1777
2557
  const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
@@ -1824,6 +2604,7 @@ var MetricsRegistry = class {
1824
2604
  return {
1825
2605
  githubRateLimit,
1826
2606
  inFlight: this.#inFlight,
2607
+ latency: this.#latencySnapshot(),
1827
2608
  requests: { byRoute, byStatus, total: requestsTotal },
1828
2609
  startedAt: new Date(this.#startedAtMs).toISOString(),
1829
2610
  tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
@@ -1831,6 +2612,37 @@ var MetricsRegistry = class {
1831
2612
  uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
1832
2613
  };
1833
2614
  }
2615
+ // Summarize the duration histogram into a JSON latency view: per-route count and
2616
+ // exact average, plus overall average and estimated p50/p95. The percentiles come
2617
+ // from the buckets aggregated across routes, so they share /metrics' resolution.
2618
+ #latencySnapshot() {
2619
+ const byRoute = {};
2620
+ const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
2621
+ let totalCount = 0;
2622
+ let totalSum = 0;
2623
+ for (const [route, entry] of this.#durations) {
2624
+ byRoute[route] = {
2625
+ avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
2626
+ count: entry.count
2627
+ };
2628
+ totalCount += entry.count;
2629
+ totalSum += entry.sum;
2630
+ for (let i = 0; i < aggregateBuckets.length; i += 1) {
2631
+ aggregateBuckets[i] = (aggregateBuckets[i] ?? 0) + (entry.buckets[i] ?? 0);
2632
+ }
2633
+ }
2634
+ return {
2635
+ avgMs: totalCount > 0 ? round2(totalSum / totalCount * 1e3) : 0,
2636
+ byRoute,
2637
+ count: totalCount,
2638
+ p50Ms: round2(
2639
+ quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.5) * 1e3
2640
+ ),
2641
+ p95Ms: round2(
2642
+ quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.95) * 1e3
2643
+ )
2644
+ };
2645
+ }
1834
2646
  /** Render the Prometheus text exposition format (version 0.0.4). */
1835
2647
  renderPrometheus(now = Date.now) {
1836
2648
  const lines = [];
@@ -2065,7 +2877,7 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome)
2065
2877
  considerSseLine(line, accumulator.consider);
2066
2878
  }
2067
2879
  } else {
2068
- const parsed = safeParse(text);
2880
+ const parsed = safeJsonParse(text);
2069
2881
  if (parsed !== void 0) {
2070
2882
  accumulator.consider(parsed);
2071
2883
  }
@@ -2127,7 +2939,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
2127
2939
  considerSseLine(finalBuffer, accumulator.consider);
2128
2940
  }
2129
2941
  } else if (!overflowed && finalBuffer) {
2130
- const parsed = safeParse(finalBuffer);
2942
+ const parsed = safeJsonParse(finalBuffer);
2131
2943
  if (parsed !== void 0) {
2132
2944
  accumulator.consider(parsed);
2133
2945
  }
@@ -2170,24 +2982,37 @@ function considerSseLine(line, consider) {
2170
2982
  if (!data || data === "[DONE]") {
2171
2983
  return;
2172
2984
  }
2173
- const parsed = safeParse(data);
2985
+ const parsed = safeJsonParse(data);
2174
2986
  if (parsed !== void 0) {
2175
2987
  consider(parsed);
2176
2988
  }
2177
2989
  }
2178
- function safeParse(text) {
2179
- try {
2180
- return JSON.parse(text);
2181
- } catch {
2182
- return void 0;
2183
- }
2184
- }
2185
2990
  function modelText(value) {
2186
2991
  return typeof value === "string" ? value.trim() : "";
2187
2992
  }
2188
2993
  function nonNegative(value) {
2189
2994
  return Number.isFinite(value) && value > 0 ? value : 0;
2190
2995
  }
2996
+ function round2(value) {
2997
+ return Math.round(value * 100) / 100;
2998
+ }
2999
+ function quantileFromBuckets(bucketCounts, bounds, count, q) {
3000
+ if (count <= 0) {
3001
+ return 0;
3002
+ }
3003
+ const rank = q * count;
3004
+ let cumulative = 0;
3005
+ for (let i = 0; i < bounds.length; i += 1) {
3006
+ const inBucket = bucketCounts[i] ?? 0;
3007
+ if (inBucket > 0 && cumulative + inBucket >= rank) {
3008
+ const lower = i === 0 ? 0 : bounds[i - 1] ?? 0;
3009
+ const upper = bounds[i] ?? lower;
3010
+ return lower + (upper - lower) * ((rank - cumulative) / inBucket);
3011
+ }
3012
+ cumulative += inBucket;
3013
+ }
3014
+ return bounds[bounds.length - 1] ?? 0;
3015
+ }
2191
3016
  function cleanLabel(value) {
2192
3017
  let result = "";
2193
3018
  for (const char of value) {
@@ -2251,8 +3076,9 @@ async function getVersion() {
2251
3076
  resolved = BAKED_VERSION;
2252
3077
  } else {
2253
3078
  try {
2254
- const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json();
2255
- resolved = typeof manifest.version === "string" ? manifest.version : "0.0.0";
3079
+ const manifest = asRecord(await Bun.file(new URL("../package.json", import.meta.url)).json());
3080
+ const version = manifest.version;
3081
+ resolved = typeof version === "string" ? version : "0.0.0";
2256
3082
  } catch {
2257
3083
  resolved = "0.0.0";
2258
3084
  }
@@ -2278,6 +3104,18 @@ var RequestBodyTooLargeError = class extends Error {
2278
3104
  this.name = "RequestBodyTooLargeError";
2279
3105
  }
2280
3106
  };
3107
+ var InvalidJsonError = class extends Error {
3108
+ constructor() {
3109
+ super(INVALID_JSON_MESSAGE);
3110
+ this.name = "InvalidJsonError";
3111
+ }
3112
+ };
3113
+ var JsonNotObjectError = class extends Error {
3114
+ constructor() {
3115
+ super(JSON_OBJECT_MESSAGE);
3116
+ this.name = "JsonNotObjectError";
3117
+ }
3118
+ };
2281
3119
  function createHoopilotHandler(options = {}) {
2282
3120
  const client = new CopilotClient(options);
2283
3121
  const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
@@ -2287,8 +3125,19 @@ function createHoopilotHandler(options = {}) {
2287
3125
  const readUsage = createUsageReader(client, metrics);
2288
3126
  const recordTokens = (model, usage) => metrics.recordTokens(model, usage);
2289
3127
  const recordExtraction = (extracted) => metrics.recordTokenExtraction(extracted);
2290
- const streamingProxyMode = resolveStreamingProxyMode(options);
2291
- const bufferProxyBodies = shouldBufferProxyBodies(streamingProxyMode);
3128
+ const bufferProxyBodies = shouldBufferProxyBodies(resolveStreamingProxyMode(options));
3129
+ const requestContext = /* @__PURE__ */ new WeakMap();
3130
+ const app = buildApp({
3131
+ apiKey,
3132
+ allowedOrigins,
3133
+ bufferProxyBodies,
3134
+ client,
3135
+ metrics,
3136
+ readUsage,
3137
+ recordExtraction,
3138
+ recordTokens,
3139
+ requestContext
3140
+ });
2292
3141
  return async (request) => {
2293
3142
  const startedAt = performance.now();
2294
3143
  const url = new URL(request.url);
@@ -2304,7 +3153,24 @@ function createHoopilotHandler(options = {}) {
2304
3153
  metrics.startRequest();
2305
3154
  const origin = request.headers.get("origin")?.trim() || void 0;
2306
3155
  const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
2307
- const finish = (response) => finishResponse(response, {
3156
+ const inner = normalizeInnerRequest(request, apiPath, url);
3157
+ requestContext.set(inner, {
3158
+ apiPath,
3159
+ logger: requestLogger,
3160
+ origin,
3161
+ originalPath: url.pathname
3162
+ });
3163
+ let response;
3164
+ try {
3165
+ response = await app.handle(inner);
3166
+ } catch (error) {
3167
+ requestLogger.error(
3168
+ { err: errorDetails(error), event: "http.request.failed" },
3169
+ "request failed"
3170
+ );
3171
+ response = jsonError(500, "internal_error", errorMessage(error));
3172
+ }
3173
+ return finishResponse(response, {
2308
3174
  corsOrigin,
2309
3175
  logger: requestLogger,
2310
3176
  method: request.method,
@@ -2315,141 +3181,175 @@ function createHoopilotHandler(options = {}) {
2315
3181
  closeConnection: bufferProxyBodies,
2316
3182
  trackStreamingBody: !bufferProxyBodies
2317
3183
  });
3184
+ };
3185
+ }
3186
+ function buildApp(deps) {
3187
+ const {
3188
+ apiKey,
3189
+ allowedOrigins,
3190
+ bufferProxyBodies,
3191
+ client,
3192
+ metrics,
3193
+ readUsage,
3194
+ recordExtraction,
3195
+ recordTokens,
3196
+ requestContext
3197
+ } = deps;
3198
+ const contextFor = (request) => {
3199
+ const stored = requestContext.get(request);
3200
+ if (stored) {
3201
+ return stored;
3202
+ }
3203
+ const originalPath = new URL(request.url).pathname;
3204
+ return {
3205
+ apiPath: canonicalApiPath(originalPath),
3206
+ logger: noopLogger,
3207
+ origin: request.headers.get("origin")?.trim() || void 0,
3208
+ originalPath
3209
+ };
3210
+ };
3211
+ const loggerFor = (request) => contextFor(request).logger;
3212
+ const noBody = { parse: "none" };
3213
+ return new Elysia().onRequest(({ request }) => {
3214
+ const { apiPath, logger, origin } = contextFor(request);
2318
3215
  const browserOrigin = forbiddenBrowserOrigin(origin, request, allowedOrigins);
2319
3216
  if (browserOrigin) {
2320
- requestLogger.warn(
3217
+ logger.warn(
2321
3218
  { event: "http.request.forbidden_origin", origin: browserOrigin },
2322
3219
  "blocked cross-origin browser request"
2323
3220
  );
2324
- return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
3221
+ return jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE);
2325
3222
  }
2326
3223
  if (request.method === "OPTIONS") {
2327
- return finish(new Response(null, { headers: corsHeaders() }));
3224
+ return new Response(null, { headers: corsHeaders() });
3225
+ }
3226
+ if (request.method === "GET" && apiPath === "/dashboard") {
3227
+ return dashboardResponse();
2328
3228
  }
2329
3229
  if (!isAuthorized(request, apiKey)) {
2330
- requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
2331
- return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
3230
+ logger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
3231
+ return jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key.");
2332
3232
  }
2333
- try {
2334
- if (request.method === "GET" && (apiPath === "/" || apiPath === "/healthz")) {
2335
- return finish(jsonResponse({ name: "hoopilot", object: "health", status: "ok" }));
2336
- }
2337
- if (request.method === "GET" && apiPath === "/metrics") {
2338
- return finish(metricsResponse(metrics));
2339
- }
2340
- if (request.method === "GET" && apiPath === "/v1/usage") {
2341
- return finish(await handleUsage(metrics, readUsage, request.signal));
2342
- }
2343
- if (request.method === "GET" && apiPath === "/v1/responses") {
2344
- return finish(websocketUnsupportedResponse());
2345
- }
2346
- if (request.method === "GET" && apiPath === "/v1/models") {
2347
- return finish(await handleModels(client, metrics, request.signal, requestLogger));
2348
- }
2349
- if (request.method === "POST" && apiPath === "/v1/messages") {
2350
- return finish(
2351
- await handleAnthropicMessages(
2352
- client,
2353
- metrics,
2354
- recordTokens,
2355
- recordExtraction,
2356
- request,
2357
- requestLogger,
2358
- bufferProxyBodies
2359
- )
2360
- );
2361
- }
2362
- if (request.method === "POST" && apiPath === "/v1/messages/count_tokens") {
2363
- return finish(handleAnthropicCountTokens(await readJson(request)));
2364
- }
2365
- if (request.method === "POST" && apiPath === "/v1/chat/completions") {
2366
- return finish(
2367
- await handleChatCompletions(
2368
- client,
2369
- metrics,
2370
- recordTokens,
2371
- recordExtraction,
2372
- request,
2373
- requestLogger,
2374
- bufferProxyBodies
2375
- )
2376
- );
2377
- }
2378
- if (request.method === "POST" && apiPath === "/v1/completions") {
2379
- return finish(
2380
- await handleCompletions(
2381
- client,
2382
- metrics,
2383
- recordTokens,
2384
- recordExtraction,
2385
- request,
2386
- requestLogger,
2387
- bufferProxyBodies
2388
- )
2389
- );
2390
- }
2391
- if (request.method === "POST" && apiPath === "/v1/responses/compact") {
2392
- return finish(
2393
- await handleResponsesCompact(
2394
- client,
2395
- metrics,
2396
- recordTokens,
2397
- recordExtraction,
2398
- request,
2399
- requestLogger
2400
- )
2401
- );
2402
- }
2403
- if (request.method === "POST" && apiPath === "/v1/responses") {
2404
- return finish(
2405
- await handleResponses(
2406
- client,
2407
- metrics,
2408
- recordTokens,
2409
- recordExtraction,
2410
- request,
2411
- requestLogger,
2412
- bufferProxyBodies
2413
- )
2414
- );
2415
- }
2416
- return finish(jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`));
2417
- } catch (error) {
2418
- if (error instanceof CopilotAuthError) {
2419
- requestLogger.warn(
2420
- { err: errorDetails(error), event: "copilot.auth.missing" },
2421
- "copilot auth failed"
2422
- );
2423
- return finish(jsonError(401, "copilot_auth_error", error.message));
2424
- }
2425
- const message = errorMessage(error);
2426
- if (message === INVALID_JSON_MESSAGE || message === JSON_OBJECT_MESSAGE) {
2427
- requestLogger.warn(
2428
- { err: errorDetails(error), event: "http.request.failed" },
2429
- "request body was not usable json"
2430
- );
2431
- return finish(jsonError(400, "invalid_request_error", message));
2432
- } else if (error instanceof OpenAICompatibilityError || error instanceof AnthropicCompatibilityError) {
2433
- requestLogger.warn(
2434
- { err: errorDetails(error), event: "http.request.failed" },
2435
- "request body used unsupported compatibility fields"
2436
- );
2437
- return finish(jsonError(400, "invalid_request_error", message));
2438
- } else if (error instanceof RequestBodyTooLargeError) {
2439
- requestLogger.warn(
2440
- { err: errorDetails(error), event: "http.request.failed" },
2441
- "request body exceeded size limit"
2442
- );
2443
- return finish(jsonError(413, "request_too_large", message));
2444
- } else {
2445
- requestLogger.error(
2446
- { err: errorDetails(error), event: "http.request.failed" },
2447
- "request failed"
2448
- );
2449
- }
2450
- return finish(jsonError(500, "internal_error", message));
3233
+ }).onError(({ code, error, request }) => {
3234
+ const { logger, originalPath } = contextFor(request);
3235
+ if (code === "NOT_FOUND") {
3236
+ return jsonError(404, "not_found", `No route for ${request.method} ${originalPath}.`);
3237
+ }
3238
+ if (error instanceof CopilotAuthError) {
3239
+ logger.warn(
3240
+ { err: errorDetails(error), event: "copilot.auth.missing" },
3241
+ "copilot auth failed"
3242
+ );
3243
+ return jsonError(401, "copilot_auth_error", error.message);
3244
+ }
3245
+ const message = errorMessage(error);
3246
+ if (error instanceof InvalidJsonError || error instanceof JsonNotObjectError) {
3247
+ logger.warn(
3248
+ { err: errorDetails(error), event: "http.request.failed" },
3249
+ "request body was not usable json"
3250
+ );
3251
+ return jsonError(400, "invalid_request_error", message);
2451
3252
  }
3253
+ if (error instanceof OpenAICompatibilityError || error instanceof AnthropicCompatibilityError) {
3254
+ logger.warn(
3255
+ { err: errorDetails(error), event: "http.request.failed" },
3256
+ "request body used unsupported compatibility fields"
3257
+ );
3258
+ return jsonError(400, "invalid_request_error", message);
3259
+ }
3260
+ if (error instanceof RequestBodyTooLargeError) {
3261
+ logger.warn(
3262
+ { err: errorDetails(error), event: "http.request.failed" },
3263
+ "request body exceeded size limit"
3264
+ );
3265
+ return jsonError(413, "request_too_large", message);
3266
+ }
3267
+ logger.error({ err: errorDetails(error), event: "http.request.failed" }, "request failed");
3268
+ return jsonError(500, "internal_error", message);
3269
+ }).get("/", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/healthz", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/metrics", () => metricsResponse(metrics)).get("/v1/usage", ({ request }) => handleUsage(metrics, readUsage, request.signal)).get(
3270
+ "/v1/models",
3271
+ ({ request }) => handleModels(client, metrics, request.signal, loggerFor(request))
3272
+ ).get("/v1/responses", () => websocketUnsupportedResponse()).post(
3273
+ "/v1/messages",
3274
+ ({ request }) => handleAnthropicMessages(
3275
+ client,
3276
+ metrics,
3277
+ recordTokens,
3278
+ recordExtraction,
3279
+ request,
3280
+ loggerFor(request),
3281
+ bufferProxyBodies
3282
+ ),
3283
+ noBody
3284
+ ).post(
3285
+ "/v1/messages/count_tokens",
3286
+ ({ request }) => handleAnthropicCountTokens(request),
3287
+ noBody
3288
+ ).post(
3289
+ "/v1/chat/completions",
3290
+ ({ request }) => handleChatCompletions(
3291
+ client,
3292
+ metrics,
3293
+ recordTokens,
3294
+ recordExtraction,
3295
+ request,
3296
+ loggerFor(request),
3297
+ bufferProxyBodies
3298
+ ),
3299
+ noBody
3300
+ ).post(
3301
+ "/v1/completions",
3302
+ ({ request }) => handleCompletions(
3303
+ client,
3304
+ metrics,
3305
+ recordTokens,
3306
+ recordExtraction,
3307
+ request,
3308
+ loggerFor(request),
3309
+ bufferProxyBodies
3310
+ ),
3311
+ noBody
3312
+ ).post(
3313
+ "/v1/responses/compact",
3314
+ ({ request }) => handleResponsesCompact(
3315
+ client,
3316
+ metrics,
3317
+ recordTokens,
3318
+ recordExtraction,
3319
+ request,
3320
+ loggerFor(request)
3321
+ ),
3322
+ noBody
3323
+ ).post(
3324
+ "/v1/responses",
3325
+ ({ request }) => handleResponses(
3326
+ client,
3327
+ metrics,
3328
+ recordTokens,
3329
+ recordExtraction,
3330
+ request,
3331
+ loggerFor(request),
3332
+ bufferProxyBodies
3333
+ ),
3334
+ noBody
3335
+ );
3336
+ }
3337
+ function normalizeInnerRequest(request, canonicalPath, url) {
3338
+ if (canonicalPath === url.pathname) {
3339
+ return request;
3340
+ }
3341
+ const target = new URL(url);
3342
+ target.pathname = canonicalPath;
3343
+ const init = {
3344
+ headers: request.headers,
3345
+ method: request.method,
3346
+ signal: request.signal
2452
3347
  };
3348
+ if (request.body) {
3349
+ init.body = request.body;
3350
+ init.duplex = "half";
3351
+ }
3352
+ return new Request(target, init);
2453
3353
  }
2454
3354
  function startHoopilotServer(options = {}) {
2455
3355
  const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
@@ -2528,7 +3428,8 @@ async function handleAnthropicMessages(client, metrics, recordTokens, recordExtr
2528
3428
  recordExtraction(usage !== void 0);
2529
3429
  return jsonResponse(responsesResponseToAnthropicMessage(body, model));
2530
3430
  }
2531
- function handleAnthropicCountTokens(body) {
3431
+ async function handleAnthropicCountTokens(request) {
3432
+ const body = await readJson(request);
2532
3433
  return jsonResponse(estimateAnthropicMessageTokens(body));
2533
3434
  }
2534
3435
  async function handleModels(client, metrics, signal, logger) {
@@ -2614,14 +3515,14 @@ async function handleCompletions(client, metrics, recordTokens, recordExtraction
2614
3515
  return jsonResponse(chatCompletionToCompletion(completion));
2615
3516
  }
2616
3517
  async function handleResponses(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
2617
- const body = await readJsonText(request);
3518
+ const { json, text: body } = await readJsonText(request);
2618
3519
  const upstream = await client.responses(body, request.signal);
2619
3520
  metrics.recordUpstream("/responses", upstream.ok);
2620
3521
  if (!upstream.ok) {
2621
3522
  return proxyError(upstream, logger);
2622
3523
  }
2623
3524
  logUpstreamSuccess(logger, "/responses", upstream.status);
2624
- const model = normalizeRequestedModel(asRecord(safeParseJson(body)).model);
3525
+ const model = normalizeRequestedModel(json.model);
2625
3526
  return proxyResponse(
2626
3527
  await responseWithObservedUsage(
2627
3528
  upstream,
@@ -2709,17 +3610,16 @@ function parseJsonObject2(text) {
2709
3610
  try {
2710
3611
  parsed = JSON.parse(text);
2711
3612
  } catch {
2712
- throw new Error(INVALID_JSON_MESSAGE);
3613
+ throw new InvalidJsonError();
2713
3614
  }
2714
3615
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2715
- throw new Error(JSON_OBJECT_MESSAGE);
3616
+ throw new JsonNotObjectError();
2716
3617
  }
2717
3618
  return parsed;
2718
3619
  }
2719
3620
  async function readJsonText(request) {
2720
3621
  const text = await readRequestText(request);
2721
- parseJsonObject2(text);
2722
- return text;
3622
+ return { json: parseJsonObject2(text), text };
2723
3623
  }
2724
3624
  async function readRequestText(request) {
2725
3625
  const contentLength = request.headers.get("content-length");
@@ -2777,7 +3677,7 @@ function jsonError(status, code, message) {
2777
3677
  );
2778
3678
  }
2779
3679
  function upstreamErrorResponse(status, text) {
2780
- const parsedError = asRecord(asRecord(safeParseJson(text)).error);
3680
+ const parsedError = asRecord(asRecord(safeJsonParse(text)).error);
2781
3681
  if (Object.keys(parsedError).length > 0) {
2782
3682
  return jsonResponse({ error: parsedError }, status);
2783
3683
  }
@@ -2799,13 +3699,18 @@ function corsHeaders() {
2799
3699
  "access-control-expose-headers": "x-request-id"
2800
3700
  };
2801
3701
  }
3702
+ function secretEquals(candidate, secret) {
3703
+ const a = createHash("sha256").update(candidate).digest();
3704
+ const b = createHash("sha256").update(secret).digest();
3705
+ return timingSafeEqual(a, b);
3706
+ }
2802
3707
  function isAuthorized(request, apiKey) {
2803
3708
  if (!apiKey) {
2804
3709
  return true;
2805
3710
  }
2806
3711
  const authorization = request.headers.get("authorization") ?? "";
2807
3712
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
2808
- return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
3713
+ return bearer !== void 0 && secretEquals(bearer, apiKey) || secretEquals(request.headers.get("x-api-key") ?? "", apiKey);
2809
3714
  }
2810
3715
  function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
2811
3716
  if (origin) {
@@ -2842,7 +3747,7 @@ function upstreamAuthMessage(message) {
2842
3747
  return `GitHub Copilot rejected the credential or account access: ${message}`;
2843
3748
  }
2844
3749
  function isLoopbackHost(host) {
2845
- return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
3750
+ return isLoopbackHostname(host);
2846
3751
  }
2847
3752
  function urlHost(host) {
2848
3753
  return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
@@ -2861,9 +3766,6 @@ function normalizeServerPort(value) {
2861
3766
  }
2862
3767
  return port;
2863
3768
  }
2864
- function errorMessage(error) {
2865
- return error instanceof Error ? error.message : String(error);
2866
- }
2867
3769
  function serverLogger(options) {
2868
3770
  if (options.logger) {
2869
3771
  return options.logger.child({ component: "server" });
@@ -2879,10 +3781,7 @@ function serverLogger(options) {
2879
3781
  }
2880
3782
  function resolveStreamingProxyMode(options) {
2881
3783
  const value = options.streamingProxyMode ?? envValue(options.env?.HOOPILOT_STREAM_MODE) ?? envValue(options.env?.HOOPILOT_STREAMING_PROXY_MODE) ?? "auto";
2882
- if (value === "auto" || value === "buffer" || value === "live") {
2883
- return value;
2884
- }
2885
- throw new Error(`Invalid stream mode: ${value}. Expected auto, live, or buffer.`);
3784
+ return parseStreamingProxyMode(value);
2886
3785
  }
2887
3786
  function shouldBufferProxyBodies(mode) {
2888
3787
  if (mode === "buffer") {
@@ -2940,11 +3839,13 @@ function responseWithRequestId(response, requestId, closeConnection, corsOrigin)
2940
3839
  function trackStreamCompletion(body, onComplete) {
2941
3840
  const reader = body.getReader();
2942
3841
  let fired = false;
2943
- const fire = () => {
2944
- if (!fired) {
2945
- fired = true;
2946
- onComplete();
3842
+ const release = () => {
3843
+ if (fired) {
3844
+ return;
2947
3845
  }
3846
+ fired = true;
3847
+ onComplete();
3848
+ reader.releaseLock();
2948
3849
  };
2949
3850
  return new ReadableStream({
2950
3851
  async pull(controller) {
@@ -2952,18 +3853,25 @@ function trackStreamCompletion(body, onComplete) {
2952
3853
  const { done, value } = await reader.read();
2953
3854
  if (done) {
2954
3855
  controller.close();
2955
- fire();
3856
+ release();
2956
3857
  return;
2957
3858
  }
2958
3859
  controller.enqueue(value);
2959
3860
  } catch (error) {
2960
- fire();
3861
+ release();
2961
3862
  controller.error(error);
2962
3863
  }
2963
3864
  },
2964
- cancel(reason) {
2965
- fire();
2966
- return reader.cancel(reason);
3865
+ async cancel(reason) {
3866
+ if (!fired) {
3867
+ fired = true;
3868
+ onComplete();
3869
+ }
3870
+ try {
3871
+ await reader.cancel(reason);
3872
+ } finally {
3873
+ reader.releaseLock();
3874
+ }
2967
3875
  }
2968
3876
  });
2969
3877
  }
@@ -3011,44 +3919,26 @@ function canonicalApiPath(path) {
3011
3919
  return withoutTrailingSlash;
3012
3920
  }
3013
3921
  }
3922
+ var API_ROUTES = [
3923
+ { method: "GET", path: "/", name: "health" },
3924
+ { method: "GET", path: "/healthz", name: "health" },
3925
+ { method: "GET", path: "/dashboard", name: "dashboard" },
3926
+ { method: "GET", path: "/metrics", name: "metrics" },
3927
+ { method: "GET", path: "/v1/usage", name: "usage" },
3928
+ { method: "GET", path: "/v1/models", name: "models" },
3929
+ { method: "GET", path: "/v1/responses", name: "responses_websocket" },
3930
+ { method: "POST", path: "/v1/messages", name: "anthropic_messages" },
3931
+ { method: "POST", path: "/v1/messages/count_tokens", name: "anthropic_count_tokens" },
3932
+ { method: "POST", path: "/v1/chat/completions", name: "chat_completions" },
3933
+ { method: "POST", path: "/v1/completions", name: "completions" },
3934
+ { method: "POST", path: "/v1/responses/compact", name: "responses_compact" },
3935
+ { method: "POST", path: "/v1/responses", name: "responses" }
3936
+ ];
3014
3937
  function routeFor(method, path) {
3015
3938
  if (method === "OPTIONS") {
3016
3939
  return "cors.preflight";
3017
3940
  }
3018
- if (method === "GET" && (path === "/" || path === "/healthz")) {
3019
- return "health";
3020
- }
3021
- if (method === "GET" && path === "/metrics") {
3022
- return "metrics";
3023
- }
3024
- if (method === "GET" && path === "/v1/usage") {
3025
- return "usage";
3026
- }
3027
- if (method === "GET" && path === "/v1/models") {
3028
- return "models";
3029
- }
3030
- if (method === "POST" && path === "/v1/messages") {
3031
- return "anthropic_messages";
3032
- }
3033
- if (method === "POST" && path === "/v1/messages/count_tokens") {
3034
- return "anthropic_count_tokens";
3035
- }
3036
- if (method === "POST" && path === "/v1/chat/completions") {
3037
- return "chat_completions";
3038
- }
3039
- if (method === "POST" && path === "/v1/completions") {
3040
- return "completions";
3041
- }
3042
- if (method === "POST" && path === "/v1/responses/compact") {
3043
- return "responses_compact";
3044
- }
3045
- if (method === "POST" && path === "/v1/responses") {
3046
- return "responses";
3047
- }
3048
- if (method === "GET" && path === "/v1/responses") {
3049
- return "responses_websocket";
3050
- }
3051
- return "not_found";
3941
+ return API_ROUTES.find((entry) => entry.method === method && entry.path === path)?.name ?? "not_found";
3052
3942
  }
3053
3943
  function isStreamingResponse(response) {
3054
3944
  return response.headers.get("content-type")?.includes("text/event-stream") ?? false;
@@ -3072,10 +3962,28 @@ function metricsResponse(metrics) {
3072
3962
  status: 200
3073
3963
  });
3074
3964
  }
3965
+ function dashboardResponse() {
3966
+ return new Response(DASHBOARD_HTML, {
3967
+ headers: {
3968
+ ...corsHeaders(),
3969
+ "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'",
3970
+ "content-type": "text/html; charset=utf-8",
3971
+ "referrer-policy": "no-referrer",
3972
+ "x-content-type-options": "nosniff",
3973
+ "x-frame-options": "DENY"
3974
+ },
3975
+ status: 200
3976
+ });
3977
+ }
3075
3978
  async function handleUsage(metrics, readUsage, signal) {
3076
3979
  const { copilot, error } = await readUsage(signal);
3077
3980
  const proxy = metrics.snapshot();
3078
- const body = { copilot: copilot ?? null, object: "usage", proxy };
3981
+ const body = {
3982
+ copilot: copilot ?? null,
3983
+ object: "usage",
3984
+ proxy,
3985
+ version: await getVersion()
3986
+ };
3079
3987
  if (error) {
3080
3988
  body.copilot_error = error;
3081
3989
  }
@@ -3108,17 +4016,10 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
3108
4016
  }
3109
4017
  };
3110
4018
  }
3111
- function safeParseJson(text) {
3112
- try {
3113
- return JSON.parse(text);
3114
- } catch {
3115
- return void 0;
3116
- }
3117
- }
3118
4019
 
3119
4020
  // src/update.ts
3120
4021
  import { execFileSync } from "child_process";
3121
- import { createHash } from "crypto";
4022
+ import { createHash as createHash2 } from "crypto";
3122
4023
  import {
3123
4024
  chmodSync as chmodSync2,
3124
4025
  copyFileSync,
@@ -3126,7 +4027,7 @@ import {
3126
4027
  mkdirSync as mkdirSync2,
3127
4028
  realpathSync,
3128
4029
  renameSync as renameSync2,
3129
- rmSync,
4030
+ rmSync as rmSync2,
3130
4031
  writeFileSync as writeFileSync2
3131
4032
  } from "fs";
3132
4033
  import { readFile, writeFile } from "fs/promises";
@@ -3300,10 +4201,11 @@ Run: ${upgradeCommandFor(kind)}
3300
4201
  function parseState(text) {
3301
4202
  try {
3302
4203
  const data = JSON.parse(text);
4204
+ const record = data && typeof data === "object" ? data : {};
3303
4205
  return {
3304
- lastCheck: typeof data.lastCheck === "number" ? data.lastCheck : 0,
3305
- latestVersion: typeof data.latestVersion === "string" ? data.latestVersion : null,
3306
- etag: typeof data.etag === "string" ? data.etag : null
4206
+ lastCheck: typeof record.lastCheck === "number" ? record.lastCheck : 0,
4207
+ latestVersion: typeof record.latestVersion === "string" ? record.latestVersion : null,
4208
+ etag: typeof record.etag === "string" ? record.etag : null
3307
4209
  };
3308
4210
  } catch {
3309
4211
  return { lastCheck: 0, latestVersion: null, etag: null };
@@ -3357,6 +4259,7 @@ function latestReleaseApiUrl() {
3357
4259
 
3358
4260
  // src/update.ts
3359
4261
  var REQUEST_TIMEOUT_MS2 = 8e3;
4262
+ var DOWNLOAD_TIMEOUT_MS = REQUEST_TIMEOUT_MS2 * 10;
3360
4263
  var SHA256SUMS = "SHA256SUMS";
3361
4264
  function userAgent(version) {
3362
4265
  return `hoopilot/${version}`;
@@ -3494,15 +4397,15 @@ async function downloadToFile(url, dest, version) {
3494
4397
  const response = await fetch(url, {
3495
4398
  headers: { "User-Agent": userAgent(version) },
3496
4399
  redirect: "follow",
3497
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2 * 10)
4400
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
3498
4401
  });
3499
4402
  if (!response.ok || !response.body) {
3500
4403
  throw new Error(`Download failed (${response.status}) for ${url}`);
3501
4404
  }
3502
- await writeFile(dest, new Uint8Array(await response.arrayBuffer()));
4405
+ await Bun.write(dest, response);
3503
4406
  }
3504
4407
  async function sha256File(path) {
3505
- return createHash("sha256").update(await readFile(path)).digest("hex");
4408
+ return createHash2("sha256").update(await readFile(path)).digest("hex");
3506
4409
  }
3507
4410
  async function verifyChecksum(release, assetName, file, version) {
3508
4411
  const sums = release.assets.find((asset) => asset.name === SHA256SUMS);
@@ -3514,7 +4417,7 @@ async function verifyChecksum(release, assetName, file, version) {
3514
4417
  const response = await fetch(sums.url, {
3515
4418
  headers: { "User-Agent": userAgent(version) },
3516
4419
  redirect: "follow",
3517
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
4420
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
3518
4421
  });
3519
4422
  if (!response.ok) {
3520
4423
  throw new Error(`Could not download ${SHA256SUMS} (${response.status}).`);
@@ -3532,7 +4435,7 @@ function swapBinary(tmpFile, exePath) {
3532
4435
  if (process.platform === "win32") {
3533
4436
  const oldExe = `${exePath}.old`;
3534
4437
  try {
3535
- rmSync(oldExe, { force: true });
4438
+ rmSync2(oldExe, { force: true });
3536
4439
  } catch {
3537
4440
  }
3538
4441
  renameSync2(exePath, oldExe);
@@ -3589,7 +4492,7 @@ function refreshCodexxShim(dir, logger) {
3589
4492
  { err: errorDetails(error), event: "update.codexx_shim_failed" },
3590
4493
  "could not refresh codexx shim"
3591
4494
  );
3592
- console.warn(`Updated hoopilot, but could not refresh the codexx shim: ${errorMessage2(error)}`);
4495
+ console.warn(`Updated hoopilot, but could not refresh the codexx shim: ${errorMessage(error)}`);
3593
4496
  }
3594
4497
  }
3595
4498
  function cleanupOldBinary() {
@@ -3597,7 +4500,7 @@ function cleanupOldBinary() {
3597
4500
  return;
3598
4501
  }
3599
4502
  try {
3600
- rmSync(`${realpathSync(process.execPath)}.old`, { force: true });
4503
+ rmSync2(`${realpathSync(process.execPath)}.old`, { force: true });
3601
4504
  } catch {
3602
4505
  }
3603
4506
  }
@@ -3661,7 +4564,7 @@ async function runUpdate(currentVersion, logger) {
3661
4564
  throw error;
3662
4565
  } finally {
3663
4566
  try {
3664
- rmSync(tmpFile, { force: true });
4567
+ rmSync2(tmpFile, { force: true });
3665
4568
  } catch {
3666
4569
  }
3667
4570
  }
@@ -3674,12 +4577,8 @@ async function runUpdate(currentVersion, logger) {
3674
4577
  console.log("Restart hoopilot to run the new version.");
3675
4578
  }
3676
4579
  }
3677
- function errorMessage2(error) {
3678
- return error instanceof Error ? error.message : String(error);
3679
- }
3680
4580
 
3681
4581
  // src/cli.ts
3682
- var ALLOWED_COPILOT_API_HOSTS2 = ["api.githubcopilot.com"];
3683
4582
  async function main2(argv = Bun.argv.slice(2)) {
3684
4583
  cleanupOldBinary();
3685
4584
  const command = argv[0];
@@ -3812,7 +4711,7 @@ function parseArgs(argv) {
3812
4711
  args.logLevel = parseLogLevel(optionValue(name, inlineValue, rest));
3813
4712
  break;
3814
4713
  case "--stream-mode":
3815
- args.streamingProxyMode = parseStreamMode(optionValue(name, inlineValue, rest));
4714
+ args.streamingProxyMode = parseStreamingProxyMode(optionValue(name, inlineValue, rest));
3816
4715
  break;
3817
4716
  case "--host":
3818
4717
  args.host = optionValue(name, inlineValue, rest);
@@ -3832,12 +4731,6 @@ function parseArgs(argv) {
3832
4731
  }
3833
4732
  return args;
3834
4733
  }
3835
- function parseStreamMode(value) {
3836
- if (value === "auto" || value === "buffer" || value === "live") {
3837
- return value;
3838
- }
3839
- throw new Error(`Invalid stream mode: ${value}. Expected auto, live, or buffer.`);
3840
- }
3841
4734
  function optionValue(name, inlineValue, rest) {
3842
4735
  const value = inlineValue ?? rest.shift();
3843
4736
  if (!value) {
@@ -3898,11 +4791,7 @@ async function runModels(options = {}) {
3898
4791
  logger.debug({ event: "models.list.started" }, "fetching github copilot models");
3899
4792
  const response = await new CopilotClient(options).models();
3900
4793
  if (!response.ok) {
3901
- const message = `GitHub Copilot API model list failed with ${response.status}: ${await truncatedResponseText(response)}`;
3902
- if (response.status === 401 || response.status === 403) {
3903
- throw new CopilotAuthError(message);
3904
- }
3905
- throw new Error(message);
4794
+ await throwForCopilotResponse(response, "GitHub Copilot API model list");
3906
4795
  }
3907
4796
  const ids = modelIdsFromResponse(await response.json().catch(() => void 0));
3908
4797
  if (ids.length === 0) {
@@ -3922,11 +4811,7 @@ async function runUsage(options = {}) {
3922
4811
  logger.debug({ event: "usage.fetch.started" }, "fetching github copilot quota");
3923
4812
  const response = await new CopilotClient(options).usage();
3924
4813
  if (!response.ok) {
3925
- const message = `GitHub Copilot usage request failed with ${response.status}: ${await truncatedResponseText(response)}`;
3926
- if (response.status === 401 || response.status === 403) {
3927
- throw new CopilotAuthError(message);
3928
- }
3929
- throw new Error(message);
4814
+ await throwForCopilotResponse(response, "GitHub Copilot usage request");
3930
4815
  }
3931
4816
  const rateLimit = parseRateLimitHeaders(response.headers);
3932
4817
  const usage = normalizeCopilotUsage(await response.json().catch(() => ({})));
@@ -4026,7 +4911,7 @@ async function verifyCopilotOAuthToken(token, options = {}) {
4026
4911
  options.copilotApiBaseUrl ?? envValue(options.env?.COPILOT_API_BASE_URL) ?? DEFAULT_COPILOT_API_BASE_URL
4027
4912
  );
4028
4913
  const allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
4029
- if (!isTrustedTokenBaseUrl(apiBaseUrl, ALLOWED_COPILOT_API_HOSTS2, allowUnsafeUpstream)) {
4914
+ if (!isTrustedTokenBaseUrl(apiBaseUrl, ALLOWED_COPILOT_API_HOSTS, allowUnsafeUpstream)) {
4030
4915
  throw new Error(
4031
4916
  `Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${apiBaseUrl}`
4032
4917
  );
@@ -4037,19 +4922,22 @@ async function verifyCopilotOAuthToken(token, options = {}) {
4037
4922
  method: "GET"
4038
4923
  });
4039
4924
  if (!response.ok) {
4040
- const message = `GitHub Copilot API verification failed with ${response.status}: ${await truncatedResponseText(response)}`;
4041
- if (response.status === 401 || response.status === 403) {
4042
- throw new CopilotAuthError(message);
4043
- }
4044
- throw new Error(message);
4925
+ await throwForCopilotResponse(response, "GitHub Copilot API verification");
4045
4926
  }
4046
4927
  return {
4047
4928
  apiBaseUrl,
4048
- expiresAtMs: Date.now() + 10 * 6e4,
4929
+ expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
4049
4930
  source: "github-copilot-oauth",
4050
4931
  token
4051
4932
  };
4052
4933
  }
4934
+ async function throwForCopilotResponse(response, label) {
4935
+ const message = `${label} failed with ${response.status}: ${await truncatedResponseText(response)}`;
4936
+ if (response.status === 401 || response.status === 403) {
4937
+ throw new CopilotAuthError(message);
4938
+ }
4939
+ throw new Error(message);
4940
+ }
4053
4941
  function openBrowserBestEffort(url, spawnOpener = spawn) {
4054
4942
  const platform = process.platform;
4055
4943
  const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
@@ -4065,21 +4953,6 @@ function openBrowserBestEffort(url, spawnOpener = spawn) {
4065
4953
  } catch {
4066
4954
  }
4067
4955
  }
4068
- function modelIdsFromResponse(body) {
4069
- const record = asRecord(body);
4070
- const data = Array.isArray(record.data) ? record.data : Array.isArray(body) ? body : [];
4071
- const seen = /* @__PURE__ */ new Set();
4072
- const ids = [];
4073
- for (const model of data) {
4074
- const id = asRecord(model).id;
4075
- if (typeof id !== "string" || id.length === 0 || seen.has(id)) {
4076
- continue;
4077
- }
4078
- seen.add(id);
4079
- ids.push(id);
4080
- }
4081
- return ids;
4082
- }
4083
4956
  function withRuntimeEnv(args) {
4084
4957
  return { ...args, env: process.env };
4085
4958
  }
@@ -4129,7 +5002,7 @@ Commands:
4129
5002
 
4130
5003
  While the server runs, GET /metrics exposes Prometheus metrics (request counts,
4131
5004
  token usage, latency) and GET /v1/usage returns those metrics plus live Copilot
4132
- quota as JSON.
5005
+ quota as JSON. Open GET /dashboard in a browser for a live usage and status view.
4133
5006
 
4134
5007
  Options:
4135
5008
  -p, --port <port> Port to listen on. Default: 4141
@@ -4163,7 +5036,7 @@ Environment:
4163
5036
  }
4164
5037
  if (import.meta.main) {
4165
5038
  main2().catch((error) => {
4166
- console.error(error instanceof Error ? error.message : String(error));
5039
+ console.error(errorMessage(error));
4167
5040
  process.exit(1);
4168
5041
  });
4169
5042
  }