@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/index.js CHANGED
@@ -35,8 +35,12 @@ function parseUrl(rawUrl) {
35
35
  }
36
36
  return url;
37
37
  }
38
+ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
39
+ function isLoopbackHostname(host) {
40
+ return LOOPBACK_HOSTNAMES.has(host);
41
+ }
38
42
  function isLoopbackHttpUrl(url) {
39
- return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
43
+ return url.protocol === "http:" && isLoopbackHostname(url.hostname);
40
44
  }
41
45
  async function truncatedResponseText(response, max = 500) {
42
46
  const text = await response.text();
@@ -45,6 +49,48 @@ async function truncatedResponseText(response, max = 500) {
45
49
  function asRecord(value) {
46
50
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
47
51
  }
52
+ function errorMessage(error) {
53
+ return error instanceof Error ? error.message : String(error);
54
+ }
55
+ function firstNumber(...values) {
56
+ for (const value of values) {
57
+ if (typeof value === "number" && Number.isFinite(value)) {
58
+ return value;
59
+ }
60
+ }
61
+ return void 0;
62
+ }
63
+ function randomId() {
64
+ return crypto.randomUUID().replaceAll("-", "");
65
+ }
66
+ function removeUndefined(value) {
67
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
68
+ }
69
+ function safeJsonParse(text) {
70
+ try {
71
+ return JSON.parse(text);
72
+ } catch {
73
+ return void 0;
74
+ }
75
+ }
76
+ function parseJsonObject(text) {
77
+ try {
78
+ return asRecord(JSON.parse(text));
79
+ } catch {
80
+ return void 0;
81
+ }
82
+ }
83
+ var STREAMING_PROXY_MODES = [
84
+ "auto",
85
+ "buffer",
86
+ "live"
87
+ ];
88
+ function parseStreamingProxyMode(value) {
89
+ if (STREAMING_PROXY_MODES.includes(value)) {
90
+ return value;
91
+ }
92
+ throw new Error(`Invalid stream mode: ${value}. Expected ${STREAMING_PROXY_MODES.join(", ")}.`);
93
+ }
48
94
 
49
95
  // src/openai.ts
50
96
  var DEFAULT_MODEL = "gpt-4.1";
@@ -172,13 +218,6 @@ function compactionOutputFromResponsesSse(text) {
172
218
  }
173
219
  return deltas ? [messageOutputItem(deltas)] : [];
174
220
  }
175
- function safeJsonParse(text) {
176
- try {
177
- return JSON.parse(text);
178
- } catch {
179
- return void 0;
180
- }
181
- }
182
221
  function chatCompletionToCompletion(completion) {
183
222
  return removeUndefined({
184
223
  choices: completionChoices(completion).map((choice, index) => {
@@ -372,7 +411,7 @@ function responsesStreamFromChatStream(chatStream, options) {
372
411
  if (isNew) {
373
412
  enqueue("response.output_item.added", {
374
413
  item: functionCallItem(existing, "in_progress"),
375
- output_index: existing.outputIndex ?? 0,
414
+ output_index: existing.outputIndex,
376
415
  type: "response.output_item.added"
377
416
  });
378
417
  }
@@ -382,7 +421,7 @@ function responsesStreamFromChatStream(chatStream, options) {
382
421
  enqueue("response.function_call_arguments.delta", {
383
422
  delta: argumentDelta,
384
423
  item_id: existing.itemId,
385
- output_index: existing.outputIndex ?? 0,
424
+ output_index: existing.outputIndex,
386
425
  type: "response.function_call_arguments.delta"
387
426
  });
388
427
  }
@@ -432,11 +471,9 @@ function responsesStreamFromChatStream(chatStream, options) {
432
471
  type: "response.output_item.done"
433
472
  });
434
473
  }
435
- for (const tool of [...tools.values()].sort(
436
- (a, b) => (a.outputIndex ?? 0) - (b.outputIndex ?? 0)
437
- )) {
474
+ for (const tool of [...tools.values()].sort((a, b) => a.outputIndex - b.outputIndex)) {
438
475
  const item = functionCallItem(tool);
439
- const outputIndex = tool.outputIndex ?? 0;
476
+ const outputIndex = tool.outputIndex;
440
477
  outputEntries.push([outputIndex, item]);
441
478
  enqueue("response.function_call_arguments.done", {
442
479
  arguments: tool.arguments,
@@ -679,7 +716,6 @@ function outputItemsFromMessage(message) {
679
716
  functionCallItem({
680
717
  arguments: contentToText(fn.arguments),
681
718
  id: contentToText(record.id) || `call_${randomId()}`,
682
- index: output.length,
683
719
  name: contentToText(fn.name)
684
720
  })
685
721
  );
@@ -761,21 +797,18 @@ function extractTokenUsage(usage) {
761
797
  asRecord(record.prompt_tokens_details).cached_tokens,
762
798
  asRecord(record.input_tokens_details).cached_tokens
763
799
  );
764
- return removeUndefined({
765
- cachedTokens: cached,
800
+ const result = {
766
801
  completionTokens,
767
802
  promptTokens,
768
- reasoningTokens: reasoning,
769
803
  totalTokens: total ?? promptTokens + completionTokens
770
- });
771
- }
772
- function firstNumber(...values) {
773
- for (const value of values) {
774
- if (typeof value === "number" && Number.isFinite(value)) {
775
- return value;
776
- }
804
+ };
805
+ if (cached !== void 0) {
806
+ result.cachedTokens = cached;
777
807
  }
778
- return void 0;
808
+ if (reasoning !== void 0) {
809
+ result.reasoningTokens = reasoning;
810
+ }
811
+ return result;
779
812
  }
780
813
  function firstChoice(completion) {
781
814
  return completionChoices(completion)[0] ?? {};
@@ -804,7 +837,7 @@ function processCompletionSseBlock(block, enqueue, markTerminal) {
804
837
  enqueue("[DONE]");
805
838
  return;
806
839
  }
807
- const parsed = parseJson(data);
840
+ const parsed = parseJsonObject(data);
808
841
  if (!parsed) {
809
842
  return;
810
843
  }
@@ -869,7 +902,7 @@ function processChatSseLine(line, handlers) {
869
902
  if (!data || data === "[DONE]") {
870
903
  return;
871
904
  }
872
- const parsed = parseJson(data);
905
+ const parsed = parseJsonObject(data);
873
906
  if (!parsed) {
874
907
  return;
875
908
  }
@@ -921,19 +954,6 @@ function encodeDataSse(data) {
921
954
 
922
955
  `;
923
956
  }
924
- function parseJson(data) {
925
- try {
926
- return asRecord(JSON.parse(data));
927
- } catch {
928
- return void 0;
929
- }
930
- }
931
- function removeUndefined(record) {
932
- return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
933
- }
934
- function randomId() {
935
- return crypto.randomUUID().replaceAll("-", "");
936
- }
937
957
  function epochSeconds() {
938
958
  return Math.floor(Date.now() / 1e3);
939
959
  }
@@ -946,13 +966,13 @@ var AnthropicCompatibilityError = class extends Error {
946
966
  }
947
967
  };
948
968
  function anthropicMessagesToResponsesRequest(request) {
949
- return removeUndefined2({
969
+ return removeUndefined({
950
970
  input: anthropicMessagesToResponsesInput(request.messages),
951
971
  instructions: anthropicSystemToInstructions(request.system),
952
972
  max_output_tokens: typeof request.max_tokens === "number" && Number.isFinite(request.max_tokens) ? request.max_tokens : void 0,
953
973
  metadata: request.metadata,
954
974
  model: normalizeRequestedModel(request.model),
955
- parallel_tool_calls: true,
975
+ parallel_tool_calls: asRecord(request.tool_choice).disable_parallel_tool_use !== true,
956
976
  reasoning: anthropicThinkingToReasoning(request.thinking),
957
977
  stop: anthropicStopSequences(request.stop_sequences),
958
978
  stream: request.stream === true,
@@ -967,7 +987,7 @@ function responsesResponseToAnthropicMessage(response, fallbackModel) {
967
987
  const usage = anthropicUsage(response.usage);
968
988
  return {
969
989
  content,
970
- id: textValue(response.id) || `msg_${randomId2()}`,
990
+ id: textValue(response.id) || `msg_${randomId()}`,
971
991
  model: textValue(response.model) || fallbackModel,
972
992
  role: "assistant",
973
993
  stop_reason: anthropicStopReason(response, content),
@@ -1044,7 +1064,7 @@ function createAnthropicStreamState(options) {
1044
1064
  return {
1045
1065
  blocks: /* @__PURE__ */ new Map(),
1046
1066
  completed: false,
1047
- messageId: options.messageId ?? `msg_${randomId2()}`,
1067
+ messageId: options.messageId ?? `msg_${randomId()}`,
1048
1068
  model: options.model,
1049
1069
  nextBlockIndex: 0,
1050
1070
  sawToolUse: false,
@@ -1098,7 +1118,7 @@ function anthropicMessagesToResponsesInput(messages) {
1098
1118
  flushMessage();
1099
1119
  input.push({
1100
1120
  arguments: JSON.stringify(asRecord(part.input)),
1101
- call_id: textValue(part.id) || `call_${randomId2()}`,
1121
+ call_id: textValue(part.id) || `call_${randomId()}`,
1102
1122
  name: textValue(part.name),
1103
1123
  type: "function_call"
1104
1124
  });
@@ -1209,7 +1229,7 @@ function anthropicTools(tools) {
1209
1229
  }
1210
1230
  const converted = tools.map((tool) => {
1211
1231
  const record = asRecord(tool);
1212
- return removeUndefined2({
1232
+ return removeUndefined({
1213
1233
  description: record.description,
1214
1234
  name: record.name,
1215
1235
  parameters: record.input_schema,
@@ -1280,7 +1300,7 @@ function anthropicContentFromResponsesOutput(response) {
1280
1300
  }
1281
1301
  if (type === "function_call") {
1282
1302
  content.push({
1283
- id: textValue(record.call_id) || textValue(record.id) || `call_${randomId2()}`,
1303
+ id: textValue(record.call_id) || textValue(record.id) || `call_${randomId()}`,
1284
1304
  input: parseToolInput(textValue(record.arguments)),
1285
1305
  name: textValue(record.name),
1286
1306
  type: "tool_use"
@@ -1307,12 +1327,12 @@ function anthropicStopReason(response, content) {
1307
1327
  }
1308
1328
  function anthropicUsage(usage) {
1309
1329
  const record = asRecord(usage);
1310
- const inputTokens = firstNumber2(record.input_tokens, record.prompt_tokens) ?? 0;
1311
- const outputTokens = firstNumber2(record.output_tokens, record.completion_tokens) ?? 0;
1330
+ const inputTokens = firstNumber(record.input_tokens, record.prompt_tokens) ?? 0;
1331
+ const outputTokens = firstNumber(record.output_tokens, record.completion_tokens) ?? 0;
1312
1332
  const details = asRecord(record.input_tokens_details);
1313
- return removeUndefined2({
1314
- cache_creation_input_tokens: firstNumber2(record.cache_creation_input_tokens),
1315
- cache_read_input_tokens: firstNumber2(record.cache_read_input_tokens, details.cached_tokens) ?? void 0,
1333
+ return removeUndefined({
1334
+ cache_creation_input_tokens: firstNumber(record.cache_creation_input_tokens),
1335
+ cache_read_input_tokens: firstNumber(record.cache_read_input_tokens, details.cached_tokens) ?? void 0,
1316
1336
  input_tokens: inputTokens,
1317
1337
  output_tokens: outputTokens
1318
1338
  });
@@ -1494,7 +1514,7 @@ function ensureToolBlock(state, payload, item, enqueue) {
1494
1514
  state.blocks.set(key, block);
1495
1515
  enqueue("content_block_start", {
1496
1516
  content_block: {
1497
- id: textValue(item.call_id) || textValue(item.id) || `call_${randomId2()}`,
1517
+ id: textValue(item.call_id) || textValue(item.id) || `call_${randomId()}`,
1498
1518
  input: {},
1499
1519
  name: textValue(item.name),
1500
1520
  type: "tool_use"
@@ -1528,13 +1548,6 @@ function parseSseBlock(block) {
1528
1548
  }
1529
1549
  return { data: data.join("\n"), event };
1530
1550
  }
1531
- function parseJsonObject(text) {
1532
- try {
1533
- return asRecord(JSON.parse(text));
1534
- } catch {
1535
- return void 0;
1536
- }
1537
- }
1538
1551
  function parseToolInput(argumentsText) {
1539
1552
  const parsed = parseJsonObject(argumentsText);
1540
1553
  return parsed ?? {};
@@ -1566,32 +1579,18 @@ function textValue(value) {
1566
1579
  }
1567
1580
  return "";
1568
1581
  }
1569
- function firstNumber2(...values) {
1570
- for (const value of values) {
1571
- if (typeof value === "number" && Number.isFinite(value)) {
1572
- return value;
1573
- }
1574
- }
1575
- return void 0;
1576
- }
1577
1582
  function indexValue(value) {
1578
1583
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
1579
1584
  }
1580
- function removeUndefined2(record) {
1581
- return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
1582
- }
1583
1585
  function encodeSse2(event, data) {
1584
1586
  return `event: ${event}
1585
1587
  data: ${JSON.stringify(data)}
1586
1588
 
1587
1589
  `;
1588
1590
  }
1589
- function randomId2() {
1590
- return crypto.randomUUID().replaceAll("-", "");
1591
- }
1592
1591
 
1593
1592
  // src/auth-store.ts
1594
- import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
1593
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
1595
1594
  import { dirname, join } from "path";
1596
1595
  var StoredCopilotAuthError = class extends Error {
1597
1596
  constructor(message) {
@@ -1642,7 +1641,7 @@ function readStoredCopilotAuth(path = authStorePath()) {
1642
1641
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1643
1642
  throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
1644
1643
  }
1645
- const record = parsed;
1644
+ const record = asRecord(parsed);
1646
1645
  const token = typeof record.token === "string" ? record.token.trim() : "";
1647
1646
  if (!token) {
1648
1647
  throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
@@ -1668,7 +1667,15 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
1668
1667
  `;
1669
1668
  const tmpPath = `${path}.${process.pid}.tmp`;
1670
1669
  writeFileSync(tmpPath, data, { mode: 384 });
1671
- renameSync(tmpPath, path);
1670
+ try {
1671
+ renameSync(tmpPath, path);
1672
+ } catch (error) {
1673
+ try {
1674
+ rmSync(tmpPath, { force: true });
1675
+ } catch {
1676
+ }
1677
+ throw error;
1678
+ }
1672
1679
  try {
1673
1680
  chmodSync(path, 384);
1674
1681
  } catch {
@@ -1713,23 +1720,20 @@ var CopilotAuth = class {
1713
1720
  throw error;
1714
1721
  }
1715
1722
  if (stored) {
1716
- return this.#cacheAccess({
1723
+ this.#cachedAccess = {
1717
1724
  apiBaseUrl: trimTrailingSlash(
1718
1725
  this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
1719
1726
  ),
1720
1727
  expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
1721
1728
  source: "github-copilot-oauth",
1722
1729
  token: stored.token
1723
- });
1730
+ };
1731
+ return this.#cachedAccess;
1724
1732
  }
1725
1733
  throw new CopilotAuthError(
1726
1734
  "No GitHub Copilot OAuth credential found. Run `hoopilot login` to sign in through your browser."
1727
1735
  );
1728
1736
  }
1729
- #cacheAccess(access) {
1730
- this.#cachedAccess = access;
1731
- return access;
1732
- }
1733
1737
  };
1734
1738
 
1735
1739
  // src/copilot.ts
@@ -1737,23 +1741,26 @@ var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
1737
1741
  var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
1738
1742
  var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
1739
1743
  var COPILOT_USAGE_API_VERSION = "2025-04-01";
1744
+ var EDITOR_PLUGIN_VERSION = "hoopilot/0.1.0";
1745
+ var EDITOR_VERSION = "Hoopilot/0.1.0";
1746
+ var HOOPILOT_USER_AGENT = "hoopilot/0.1.0";
1740
1747
  function applyCopilotHeaders(headers, token) {
1741
1748
  headers.set("accept", headers.get("accept") ?? "application/json");
1742
1749
  headers.set("authorization", `Bearer ${token}`);
1743
1750
  headers.set("copilot-integration-id", "vscode-chat");
1744
- headers.set("editor-plugin-version", "hoopilot/0.1.0");
1745
- headers.set("editor-version", "Hoopilot/0.1.0");
1751
+ headers.set("editor-plugin-version", EDITOR_PLUGIN_VERSION);
1752
+ headers.set("editor-version", EDITOR_VERSION);
1746
1753
  headers.set("openai-intent", "conversation-panel");
1747
- headers.set("user-agent", "hoopilot/0.1.0");
1754
+ headers.set("user-agent", HOOPILOT_USER_AGENT);
1748
1755
  headers.set("x-github-api-version", "2026-06-01");
1749
1756
  return headers;
1750
1757
  }
1751
1758
  function applyGithubApiHeaders(headers, token) {
1752
1759
  headers.set("accept", headers.get("accept") ?? "application/json");
1753
1760
  headers.set("authorization", `token ${token}`);
1754
- headers.set("editor-plugin-version", "hoopilot/0.1.0");
1755
- headers.set("editor-version", "Hoopilot/0.1.0");
1756
- headers.set("user-agent", "hoopilot/0.1.0");
1761
+ headers.set("editor-plugin-version", EDITOR_PLUGIN_VERSION);
1762
+ headers.set("editor-version", EDITOR_VERSION);
1763
+ headers.set("user-agent", HOOPILOT_USER_AGENT);
1757
1764
  headers.set("x-github-api-version", COPILOT_USAGE_API_VERSION);
1758
1765
  return headers;
1759
1766
  }
@@ -1766,7 +1773,7 @@ function parseRateLimitHeaders(headers, nowMs = Date.now()) {
1766
1773
  if (limit === void 0 && remaining === void 0 && used === void 0 && resetEpochSeconds === void 0 && retryAfterSeconds === void 0) {
1767
1774
  return void 0;
1768
1775
  }
1769
- return removeUndefinedRateLimit({
1776
+ return removeUndefined({
1770
1777
  limit,
1771
1778
  observedAtMs: nowMs,
1772
1779
  remaining,
@@ -1784,11 +1791,6 @@ function headerInt(headers, name) {
1784
1791
  const value = Number.parseInt(raw.trim(), 10);
1785
1792
  return Number.isFinite(value) && value >= 0 ? value : void 0;
1786
1793
  }
1787
- function removeUndefinedRateLimit(rateLimit) {
1788
- return Object.fromEntries(
1789
- Object.entries(rateLimit).filter(([, value]) => value !== void 0)
1790
- );
1791
- }
1792
1794
  var CopilotClient = class {
1793
1795
  #auth;
1794
1796
  #allowUnsafeUpstream;
@@ -1885,7 +1887,7 @@ function normalizeCopilotUsage(body) {
1885
1887
  for (const category of /* @__PURE__ */ new Set([...Object.keys(remaining), ...Object.keys(monthly)])) {
1886
1888
  const entitlement = numberOrUndefined(monthly[category]);
1887
1889
  const left = numberOrUndefined(remaining[category]);
1888
- quotas[category] = removeUndefinedQuota({
1890
+ quotas[category] = removeUndefined({
1889
1891
  entitlement,
1890
1892
  percentRemaining: entitlement !== void 0 && entitlement > 0 && left !== void 0 ? left / entitlement * 100 : void 0,
1891
1893
  remaining: left,
@@ -1893,7 +1895,7 @@ function normalizeCopilotUsage(body) {
1893
1895
  });
1894
1896
  }
1895
1897
  }
1896
- return removeUndefinedUsage({
1898
+ return removeUndefined({
1897
1899
  accessTypeSku: stringOrUndefined(record.access_type_sku),
1898
1900
  chatEnabled: typeof record.chat_enabled === "boolean" ? record.chat_enabled : void 0,
1899
1901
  plan: stringOrUndefined(record.copilot_plan),
@@ -1905,7 +1907,7 @@ function normalizeQuotaDetail(detail) {
1905
1907
  const entitlement = numberOrUndefined(detail.entitlement);
1906
1908
  const overageCount = numberOrUndefined(detail.overage_count);
1907
1909
  const remaining = numberOrUndefined(detail.remaining) ?? numberOrUndefined(detail.quota_remaining);
1908
- return removeUndefinedQuota({
1910
+ return removeUndefined({
1909
1911
  entitlement,
1910
1912
  hasQuota: typeof detail.has_quota === "boolean" ? detail.has_quota : void 0,
1911
1913
  overageCount,
@@ -1929,21 +1931,10 @@ function usedFrom(entitlement, remaining, overageCount) {
1929
1931
  const overage = remaining === 0 ? overageCount ?? 0 : 0;
1930
1932
  return Math.max(0, base + overage);
1931
1933
  }
1932
- function numberOrUndefined(value) {
1933
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1934
- }
1934
+ var numberOrUndefined = firstNumber;
1935
1935
  function stringOrUndefined(value) {
1936
1936
  return typeof value === "string" && value.length > 0 ? value : void 0;
1937
1937
  }
1938
- function removeUndefinedQuota(quota) {
1939
- return Object.fromEntries(
1940
- Object.entries(quota).filter(([, value]) => value !== void 0)
1941
- );
1942
- }
1943
- function removeUndefinedUsage(usage) {
1944
- const entries = Object.entries(usage).filter(([, value]) => value !== void 0);
1945
- return Object.fromEntries(entries);
1946
- }
1947
1938
 
1948
1939
  // src/github-device.ts
1949
1940
  import { setTimeout as sleep } from "timers/promises";
@@ -2075,11 +2066,16 @@ function positiveSeconds(value, fallback) {
2075
2066
  }
2076
2067
  async function parseJsonResponse(response, context) {
2077
2068
  const text = await response.text();
2069
+ let value;
2078
2070
  try {
2079
- return JSON.parse(text);
2071
+ value = JSON.parse(text);
2080
2072
  } catch {
2081
2073
  throw new Error(`${context}: ${text.slice(0, 500)}`);
2082
2074
  }
2075
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2076
+ throw new Error(`${context}: ${text.slice(0, 500)}`);
2077
+ }
2078
+ return value;
2083
2079
  }
2084
2080
 
2085
2081
  // src/logger.ts
@@ -2143,21 +2139,29 @@ function createHoopilotLogger(options = {}) {
2143
2139
  timestamp: pino.stdTimeFunctions.isoTime
2144
2140
  };
2145
2141
  if (format === "pretty") {
2146
- return pino(
2147
- pinoOptions,
2148
- pretty({
2149
- colorize: options.colorize ?? process.stderr.isTTY,
2150
- destination: options.stream ?? 1,
2151
- ignore: "pid,hostname",
2152
- singleLine: true,
2153
- translateTime: "SYS:standard"
2154
- })
2142
+ return asHoopilotLogger(
2143
+ pino(
2144
+ pinoOptions,
2145
+ pretty({
2146
+ // Probe the same sink we write to (stdout / fd 1), so colors are not
2147
+ // emitted into a redirected file when only stderr is a TTY. A custom
2148
+ // stream's TTY-ness is unknown, so default to no color there.
2149
+ colorize: options.colorize ?? (options.stream ? false : process.stdout.isTTY),
2150
+ destination: options.stream ?? 1,
2151
+ ignore: "pid,hostname",
2152
+ singleLine: true,
2153
+ translateTime: "SYS:standard"
2154
+ })
2155
+ )
2155
2156
  );
2156
2157
  }
2157
2158
  if (options.stream) {
2158
- return pino(pinoOptions, options.stream);
2159
+ return asHoopilotLogger(pino(pinoOptions, options.stream));
2159
2160
  }
2160
- return pino(pinoOptions);
2161
+ return asHoopilotLogger(pino(pinoOptions));
2162
+ }
2163
+ function asHoopilotLogger(logger) {
2164
+ return logger;
2161
2165
  }
2162
2166
  function parseLogFormat(value) {
2163
2167
  if (!value) {
@@ -2283,26 +2287,23 @@ var MetricsRegistry = class {
2283
2287
  const resource = this.#rateLimitResource(rateLimit.resource);
2284
2288
  this.#githubRateLimit.set(resource, { ...rateLimit, resource });
2285
2289
  }
2286
- // Sanitize the model into a bounded label. The model can originate from a
2287
- // client request, so cap its length, strip characters that would corrupt the
2288
- // exposition format, and fold overflow past the cardinality limit into
2289
- // UNKNOWN_MODEL to keep the series count bounded.
2290
- #modelLabel(model) {
2291
- const cleaned = cleanLabel(model).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2292
- if (!this.#tokens.has(cleaned) && this.#tokens.size >= MAX_TRACKED_MODELS) {
2290
+ // Clean a raw value into a bounded exposition-format label: cap its length,
2291
+ // strip characters that would corrupt the format, and fold overflow past the
2292
+ // cardinality limit into UNKNOWN_MODEL so the series count stays bounded.
2293
+ #boundedLabel(value, tracked, maxEntries) {
2294
+ const cleaned = cleanLabel(value).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2295
+ if (!tracked.has(cleaned) && tracked.size >= maxEntries) {
2293
2296
  return UNKNOWN_MODEL;
2294
2297
  }
2295
2298
  return cleaned;
2296
2299
  }
2297
- // The resource comes from a trusted upstream header, but clean and bound it
2298
- // with the same discipline as model labels: strip control characters that
2299
- // would corrupt the exposition format and fold overflow into "unknown".
2300
+ // The model can originate from a (possibly hostile) client request.
2301
+ #modelLabel(model) {
2302
+ return this.#boundedLabel(model, this.#tokens, MAX_TRACKED_MODELS);
2303
+ }
2304
+ // The resource comes from a trusted upstream header, but is bounded the same way.
2300
2305
  #rateLimitResource(resource) {
2301
- const cleaned = cleanLabel(resource).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2302
- if (!this.#githubRateLimit.has(cleaned) && this.#githubRateLimit.size >= MAX_TRACKED_RATELIMIT_RESOURCES) {
2303
- return UNKNOWN_MODEL;
2304
- }
2305
- return cleaned;
2306
+ return this.#boundedLabel(resource, this.#githubRateLimit, MAX_TRACKED_RATELIMIT_RESOURCES);
2306
2307
  }
2307
2308
  #observeDuration(route, seconds) {
2308
2309
  const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
@@ -2355,6 +2356,7 @@ var MetricsRegistry = class {
2355
2356
  return {
2356
2357
  githubRateLimit,
2357
2358
  inFlight: this.#inFlight,
2359
+ latency: this.#latencySnapshot(),
2358
2360
  requests: { byRoute, byStatus, total: requestsTotal },
2359
2361
  startedAt: new Date(this.#startedAtMs).toISOString(),
2360
2362
  tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
@@ -2362,6 +2364,37 @@ var MetricsRegistry = class {
2362
2364
  uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
2363
2365
  };
2364
2366
  }
2367
+ // Summarize the duration histogram into a JSON latency view: per-route count and
2368
+ // exact average, plus overall average and estimated p50/p95. The percentiles come
2369
+ // from the buckets aggregated across routes, so they share /metrics' resolution.
2370
+ #latencySnapshot() {
2371
+ const byRoute = {};
2372
+ const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
2373
+ let totalCount = 0;
2374
+ let totalSum = 0;
2375
+ for (const [route, entry] of this.#durations) {
2376
+ byRoute[route] = {
2377
+ avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
2378
+ count: entry.count
2379
+ };
2380
+ totalCount += entry.count;
2381
+ totalSum += entry.sum;
2382
+ for (let i = 0; i < aggregateBuckets.length; i += 1) {
2383
+ aggregateBuckets[i] = (aggregateBuckets[i] ?? 0) + (entry.buckets[i] ?? 0);
2384
+ }
2385
+ }
2386
+ return {
2387
+ avgMs: totalCount > 0 ? round2(totalSum / totalCount * 1e3) : 0,
2388
+ byRoute,
2389
+ count: totalCount,
2390
+ p50Ms: round2(
2391
+ quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.5) * 1e3
2392
+ ),
2393
+ p95Ms: round2(
2394
+ quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.95) * 1e3
2395
+ )
2396
+ };
2397
+ }
2365
2398
  /** Render the Prometheus text exposition format (version 0.0.4). */
2366
2399
  renderPrometheus(now = Date.now) {
2367
2400
  const lines = [];
@@ -2596,7 +2629,7 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome)
2596
2629
  considerSseLine(line, accumulator.consider);
2597
2630
  }
2598
2631
  } else {
2599
- const parsed = safeParse(text);
2632
+ const parsed = safeJsonParse(text);
2600
2633
  if (parsed !== void 0) {
2601
2634
  accumulator.consider(parsed);
2602
2635
  }
@@ -2658,7 +2691,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
2658
2691
  considerSseLine(finalBuffer, accumulator.consider);
2659
2692
  }
2660
2693
  } else if (!overflowed && finalBuffer) {
2661
- const parsed = safeParse(finalBuffer);
2694
+ const parsed = safeJsonParse(finalBuffer);
2662
2695
  if (parsed !== void 0) {
2663
2696
  accumulator.consider(parsed);
2664
2697
  }
@@ -2701,24 +2734,37 @@ function considerSseLine(line, consider) {
2701
2734
  if (!data || data === "[DONE]") {
2702
2735
  return;
2703
2736
  }
2704
- const parsed = safeParse(data);
2737
+ const parsed = safeJsonParse(data);
2705
2738
  if (parsed !== void 0) {
2706
2739
  consider(parsed);
2707
2740
  }
2708
2741
  }
2709
- function safeParse(text) {
2710
- try {
2711
- return JSON.parse(text);
2712
- } catch {
2713
- return void 0;
2714
- }
2715
- }
2716
2742
  function modelText(value) {
2717
2743
  return typeof value === "string" ? value.trim() : "";
2718
2744
  }
2719
2745
  function nonNegative(value) {
2720
2746
  return Number.isFinite(value) && value > 0 ? value : 0;
2721
2747
  }
2748
+ function round2(value) {
2749
+ return Math.round(value * 100) / 100;
2750
+ }
2751
+ function quantileFromBuckets(bucketCounts, bounds, count, q) {
2752
+ if (count <= 0) {
2753
+ return 0;
2754
+ }
2755
+ const rank = q * count;
2756
+ let cumulative = 0;
2757
+ for (let i = 0; i < bounds.length; i += 1) {
2758
+ const inBucket = bucketCounts[i] ?? 0;
2759
+ if (inBucket > 0 && cumulative + inBucket >= rank) {
2760
+ const lower = i === 0 ? 0 : bounds[i - 1] ?? 0;
2761
+ const upper = bounds[i] ?? lower;
2762
+ return lower + (upper - lower) * ((rank - cumulative) / inBucket);
2763
+ }
2764
+ cumulative += inBucket;
2765
+ }
2766
+ return bounds[bounds.length - 1] ?? 0;
2767
+ }
2722
2768
  function cleanLabel(value) {
2723
2769
  let result = "";
2724
2770
  for (const char of value) {
@@ -2768,9 +2814,842 @@ function formatNumber(value) {
2768
2814
  return Number.isInteger(value) ? value.toString() : String(value);
2769
2815
  }
2770
2816
 
2817
+ // src/server.ts
2818
+ import { createHash, timingSafeEqual } from "crypto";
2819
+ import { Elysia } from "elysia";
2820
+
2821
+ // src/dashboard.ts
2822
+ var DASHBOARD_HTML = `<!doctype html>
2823
+ <html lang="en">
2824
+ <head>
2825
+ <meta charset="utf-8" />
2826
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2827
+ <meta name="color-scheme" content="dark light" />
2828
+ <title>hoopilot &middot; dashboard</title>
2829
+ <style>
2830
+ :root {
2831
+ --bg-0:#0b0e14; --bg-1:#11151c; --bg-2:#171c25; --bg-3:#1f2630;
2832
+ --border:#262d38; --border-strong:#37404d;
2833
+ --text-0:#e6edf3; --text-1:#9aa7b4; --text-2:#5e6b78; --text-dim:#3a434e; --text-inv:#0b0e14;
2834
+ --accent:#4ea1ff; --accent-2:#56d4dd; --accent-soft:rgba(78,161,255,.14);
2835
+ --amber:#f5b042;
2836
+ --ok:#3fb950; --warn:#d8a13a; --danger:#f0556a; --info:#a371f7; --cache:#7c8cff;
2837
+ --spark:#4ea1ff; --spark-fill:color-mix(in srgb, var(--accent) 14%, transparent);
2838
+ --grid-line:rgba(255,255,255,.05);
2839
+ --flash:color-mix(in srgb, var(--accent) 22%, transparent);
2840
+ --flash-up:color-mix(in srgb, var(--ok) 22%, transparent);
2841
+ --flash-down:color-mix(in srgb, var(--danger) 22%, transparent);
2842
+ --c1:#4ea1ff; --c2:#3fb950; --c3:#d8a13a; --c4:#a371f7; --c5:#56d4dd; --c6:#f0556a;
2843
+ --mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
2844
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, system-ui, sans-serif;
2845
+ }
2846
+ @media (prefers-color-scheme: light) {
2847
+ :root:not([data-theme="dark"]) {
2848
+ --bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
2849
+ --border:#d0d7de; --border-strong:#b6bec8;
2850
+ --text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
2851
+ --accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
2852
+ --amber:#b5730a;
2853
+ --ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
2854
+ --spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
2855
+ --grid-line:rgba(0,0,0,.06);
2856
+ --flash:color-mix(in srgb, var(--accent) 16%, transparent);
2857
+ --flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
2858
+ --flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
2859
+ --c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
2860
+ }
2861
+ }
2862
+ [data-theme="light"] {
2863
+ --bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
2864
+ --border:#d0d7de; --border-strong:#b6bec8;
2865
+ --text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
2866
+ --accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
2867
+ --amber:#b5730a;
2868
+ --ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
2869
+ --spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
2870
+ --grid-line:rgba(0,0,0,.06);
2871
+ --flash:color-mix(in srgb, var(--accent) 16%, transparent);
2872
+ --flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
2873
+ --flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
2874
+ --c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
2875
+ }
2876
+ * { box-sizing: border-box; }
2877
+ html, body { margin:0; padding:0; }
2878
+ body {
2879
+ background: var(--bg-0); color: var(--text-0); font-family: var(--sans);
2880
+ font-size: 13px; line-height: 1.4; -webkit-font-smoothing: antialiased;
2881
+ }
2882
+ .mono { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
2883
+ .num { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
2884
+ .shell { max-width: 1280px; margin: 0 auto; padding: 0 24px 28px; }
2885
+ @media (min-width: 1080px) { .shell { border-left:1px solid var(--border); border-right:1px solid var(--border); } }
2886
+ @media (max-width: 680px) { .shell { padding: 0 12px 24px; } }
2887
+
2888
+ /* header */
2889
+ header.bar {
2890
+ position: sticky; top: 0; z-index: 20; background: var(--bg-1);
2891
+ border-bottom: 1px solid var(--border); height: 48px;
2892
+ }
2893
+ .bar-in { max-width:1280px; margin:0 auto; height:48px; padding:0 24px; display:flex; align-items:center; gap:12px; }
2894
+ @media (max-width:680px){ .bar-in{ padding:0 12px; gap:8px; } }
2895
+ .wordmark { font-family: var(--mono); font-weight:700; font-size:14px; color:var(--text-0); letter-spacing:-.01em; }
2896
+ .caret { display:inline-block; width:7px; height:15px; background:var(--amber); margin-left:3px; vertical-align:-2px; animation: blink 1.1s steps(1) infinite; }
2897
+ .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; }
2898
+ .chip.plan-pro { background:var(--accent-soft); color:var(--accent); }
2899
+ .chip.plan-business { background:color-mix(in srgb, var(--info) 16%, transparent); color:var(--info); }
2900
+ .chip.plan-free, .chip.plan-offline { background:var(--bg-3); color:var(--text-2); }
2901
+ .spacer { flex:1; }
2902
+ .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); }
2903
+ .dot { width:7px; height:7px; border-radius:50%; background:var(--text-2); flex:none; }
2904
+ .pill.live .dot { background:var(--ok); }
2905
+ .pill.paused .dot { background:var(--text-2); }
2906
+ .pill.reconnect { color:var(--warn); } .pill.reconnect .dot { background:var(--warn); }
2907
+ .pill.authkey { color:var(--warn); } .pill.authkey .dot { background:var(--warn); }
2908
+ .heartbeat { animation: hb .5s ease-out; }
2909
+ .updated { font-family:var(--mono); font-size:11px; color:var(--text-2); white-space:nowrap; }
2910
+ .updated.warn { color:var(--warn); } .updated.danger { color:var(--danger); }
2911
+ .seg { display:inline-flex; border:1px solid var(--border); border-radius:6px; overflow:hidden; }
2912
+ .seg button { background:transparent; color:var(--text-1); border:0; font-family:var(--mono); font-size:11px; padding:3px 8px; cursor:pointer; }
2913
+ .seg button + button { border-left:1px solid var(--border); }
2914
+ .seg button.active { background:var(--accent); color:var(--text-inv); }
2915
+ .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; }
2916
+ .iconbtn:hover { background:var(--bg-3); }
2917
+ button:focus-visible, input:focus-visible, .seg button:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
2918
+ #scanbar { position:absolute; left:0; bottom:-1px; height:1px; width:100%; overflow:hidden; }
2919
+ #scanbar::after { content:""; position:absolute; left:0; top:0; height:1px; width:40%;
2920
+ background:linear-gradient(90deg, transparent, var(--accent), transparent);
2921
+ animation: scan var(--scan-ms, 4000ms) linear infinite; }
2922
+ header.bar.paused #scanbar::after, header.bar.frozen #scanbar::after { animation-play-state:paused; opacity:.35; }
2923
+
2924
+ /* disconnect banner */
2925
+ #banner { display:none; margin-top:10px; padding:7px 12px; border-radius:5px; font-family:var(--mono); font-size:12px;
2926
+ background:color-mix(in srgb, var(--danger) 16%, transparent); color:var(--danger); border:1px solid color-mix(in srgb, var(--danger) 40%, transparent); }
2927
+ #banner.ok { background:color-mix(in srgb, var(--ok) 16%, transparent); color:var(--ok); border-color:color-mix(in srgb, var(--ok) 40%, transparent); }
2928
+ #banner.show { display:block; }
2929
+
2930
+ /* hero strip */
2931
+ .hero { display:grid; grid-template-columns:repeat(4,1fr); margin:18px 0 16px; }
2932
+ .vital { padding:6px 18px; }
2933
+ .vital + .vital { border-left:1px solid var(--border); }
2934
+ .vital .eyebrow { font-size:10px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-1); }
2935
+ .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); }
2936
+ .vital .vsub { font-size:11px; color:var(--text-2); min-height:14px; }
2937
+ .vital .vspark { display:block; width:100%; height:24px; margin-top:4px; }
2938
+ .vital.active { }
2939
+ .vital.active .eyebrow { color:var(--accent); }
2940
+ @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; } }
2941
+ @media (max-width:600px){ .hero{ grid-template-columns:1fr; } .vital + .vital{ border-left:0; border-top:1px solid var(--border); } }
2942
+
2943
+ /* grid + panels */
2944
+ .grid { display:grid; grid-template-columns:repeat(12,1fr); gap:12px; }
2945
+ .panel { position:relative; background:var(--bg-1); border:1px solid var(--border); border-radius:4px; padding:16px 12px 12px; min-width:0; }
2946
+ .panel > .ptitle { position:absolute; top:-8px; left:10px; padding:0 6px; background:var(--bg-1);
2947
+ font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
2948
+ .span5{ grid-column:span 5; } .span3{ grid-column:span 3; } .span4{ grid-column:span 4; }
2949
+ .span7{ grid-column:span 7; } .span8{ grid-column:span 8; }
2950
+ @media (max-width:1079px){ .grid{ grid-template-columns:repeat(6,1fr); }
2951
+ .span5,.span7,.span8{ grid-column:span 6; } .span3{ grid-column:span 3; } .span4{ grid-column:span 6; } }
2952
+ @media (max-width:680px){ .grid{ grid-template-columns:1fr; }
2953
+ .span3,.span4,.span5,.span7,.span8{ grid-column:span 1; } }
2954
+
2955
+ .headline { font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-weight:600; font-size:22px; line-height:1.1; }
2956
+ .cap { font-size:11px; color:var(--text-2); }
2957
+ .stack-bar { display:flex; height:8px; border-radius:4px; overflow:hidden; background:var(--bg-3); margin:8px 0; }
2958
+ .stack-bar i { display:block; height:100%; }
2959
+ .stack-bar.empty { outline:1px dashed var(--border); background:transparent; }
2960
+
2961
+ table.tbl { width:100%; border-collapse:collapse; font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-size:12px; }
2962
+ .scrollx { overflow-x:auto; }
2963
+ 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; }
2964
+ table.tbl th.l { text-align:left; }
2965
+ table.tbl td { padding:3px 6px; text-align:right; white-space:nowrap; border-bottom:1px solid color-mix(in srgb, var(--border) 55%, transparent); }
2966
+ table.tbl td.l { text-align:left; max-width:160px; overflow:hidden; text-overflow:ellipsis; }
2967
+ table.tbl tr:hover td { background:var(--bg-2); }
2968
+ table.tbl tr.total td { border-top:1px solid var(--border-strong); border-bottom:0; font-weight:600; color:var(--text-0); }
2969
+ .minibar { display:inline-block; height:6px; border-radius:3px; background:var(--accent); vertical-align:middle; min-width:1px; }
2970
+ .ghost td { color:var(--text-2); text-align:center; }
2971
+ .reasoning { color:var(--info); } .cached { color:var(--cache); }
2972
+
2973
+ .legend { display:flex; flex-wrap:wrap; gap:4px 14px; margin-top:8px; }
2974
+ .legend .li { display:flex; align-items:center; gap:6px; font-family:var(--mono); font-size:11px; color:var(--text-1); }
2975
+ .legend .sw { width:8px; height:8px; border-radius:2px; flex:none; }
2976
+
2977
+ .lat-trio { display:flex; gap:18px; align-items:baseline; }
2978
+ .lat-trio .b { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
2979
+ .lat-trio .b small { display:block; font-size:10px; font-weight:600; text-transform:uppercase; color:var(--text-2); letter-spacing:.05em; }
2980
+ .lat-p95 { color:var(--info); }
2981
+ .lat-track { position:relative; height:22px; margin-top:10px; }
2982
+ .lat-track .line { position:absolute; top:11px; left:0; right:0; height:1px; background:var(--border); }
2983
+ .lat-track .tick { position:absolute; top:5px; width:2px; height:12px; border-radius:1px; }
2984
+ .lat-track .tick.p50 { background:var(--accent); } .lat-track .tick.p95 { background:var(--info); }
2985
+ .lat-track .tlab { position:absolute; top:-2px; font-family:var(--mono); font-size:9px; color:var(--text-2); transform:translateX(-50%); }
2986
+ details.routes { margin-top:10px; } details.routes summary { cursor:pointer; font-size:11px; color:var(--text-2); font-family:var(--mono); }
2987
+
2988
+ .qrow { margin:10px 0; } .qrow .qhead { display:flex; justify-content:space-between; align-items:baseline; font-size:12px; }
2989
+ .qrow .qname { color:var(--text-1); } .qrow .qval { font-family:var(--mono); font-variant-numeric:tabular-nums; color:var(--text-0); }
2990
+ .qbar { position:relative; height:8px; border-radius:4px; background:var(--bg-3); margin-top:5px; overflow:hidden; }
2991
+ .qbar i { position:absolute; left:0; top:0; height:100%; border-radius:4px; }
2992
+ .qbar.over i.ext { background:repeating-linear-gradient(45deg, var(--danger), var(--danger) 3px, transparent 3px, transparent 6px); }
2993
+ .inf { font-family:var(--mono); font-size:12px; color:var(--ok); }
2994
+ .emptybox { border:1px solid var(--border); border-radius:5px; padding:14px; text-align:center; color:var(--text-2); }
2995
+ .emptybox .keyglyph { font-size:20px; color:var(--text-1); }
2996
+ .emptybox h4 { margin:8px 0 4px; font-family:var(--sans); font-size:13px; color:var(--text-1); font-weight:600; }
2997
+ .emptybox .errline { font-family:var(--mono); font-size:11px; color:var(--text-2); word-break:break-word; margin:4px 0; }
2998
+ .prompt { font-family:var(--mono); font-size:12px; color:var(--text-1); }
2999
+
3000
+ .upblocks { display:flex; gap:18px; }
3001
+ .upblk { } .upblk .v { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
3002
+ .upblk .k { font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-2); }
3003
+ .upblk.err.hot { color:var(--danger); }
3004
+ .rate { font-family:var(--mono); font-size:12px; } .rate.warn{ color:var(--warn);} .rate.danger{ color:var(--danger);} .rate.ok{ color:var(--ok); }
3005
+ #up-spark, #thru-svg { display:block; width:100%; }
3006
+ #up-spark { height:30px; margin-top:8px; }
3007
+ #thru-svg { height:88px; margin-top:6px; }
3008
+ .flag { font-family:var(--mono); font-size:10px; color:var(--text-2); }
3009
+
3010
+ footer.foot { margin-top:14px; padding-top:10px; border-top:1px solid var(--border); display:flex; flex-wrap:wrap; gap:4px 14px;
3011
+ font-family:var(--mono); font-size:11px; color:var(--text-2); }
3012
+ footer.foot .end { margin-left:auto; }
3013
+ @media (max-width:680px){ footer.foot .end{ margin-left:0; } }
3014
+
3015
+ .skel { color:var(--text-dim); }
3016
+ .flash { animation: flash .6s ease-out; } .flash-up { animation: flashup .6s ease-out; } .flash-down { animation: flashdown .6s ease-out; }
3017
+
3018
+ /* auth takeover */
3019
+ #auth { display:none; }
3020
+ #auth.show { display:flex; justify-content:center; padding:64px 16px; }
3021
+ .authcard { width:100%; max-width:420px; background:var(--bg-1); border:1px solid var(--border); border-radius:6px; padding:22px 18px; position:relative; }
3022
+ .authcard h3 { margin:0 0 10px; font-family:var(--mono); font-size:12px; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
3023
+ .authcard p { font-size:12px; color:var(--text-2); margin:0 0 14px; }
3024
+ .authcard .row { display:flex; gap:8px; }
3025
+ .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; }
3026
+ .authcard input.bad { border-color:var(--danger); }
3027
+ .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; }
3028
+ .authcard .err { color:var(--danger); font-family:var(--mono); font-size:11px; min-height:14px; margin-top:8px; }
3029
+ .authcard .clear { position:absolute; top:14px; right:16px; font-size:11px; color:var(--text-2); cursor:pointer; }
3030
+ .dim { opacity:.45; filter:grayscale(.4); transition:opacity .2s, filter .2s; }
3031
+
3032
+ @keyframes blink { 50% { opacity:0; } }
3033
+ @keyframes scan { 0%{ transform:translateX(-100%);} 100%{ transform:translateX(350%);} }
3034
+ @keyframes hb { 0%{ transform:scale(1);} 35%{ transform:scale(1.7);} 100%{ transform:scale(1);} }
3035
+ @keyframes flash { from{ background:var(--flash);} to{ background:transparent;} }
3036
+ @keyframes flashup { from{ background:var(--flash-up);} to{ background:transparent;} }
3037
+ @keyframes flashdown { from{ background:var(--flash-down);} to{ background:transparent;} }
3038
+ @media (prefers-reduced-motion: reduce) {
3039
+ .caret { animation:none; } #scanbar::after { animation:none; opacity:.3; }
3040
+ .heartbeat { animation:none; }
3041
+ .flash, .flash-up, .flash-down { animation:none; box-shadow: inset 2px 0 0 var(--accent); }
3042
+ }
3043
+ </style>
3044
+ </head>
3045
+ <body>
3046
+ <header class="bar" id="bar">
3047
+ <div class="bar-in">
3048
+ <span class="wordmark">hoopilot<span class="caret" aria-hidden="true"></span></span>
3049
+ <span class="chip" id="version-chip">v&middot;&middot;&middot;</span>
3050
+ <span class="chip plan-offline" id="plan-chip">&mdash; offline</span>
3051
+ <span class="spacer"></span>
3052
+ <span class="pill" id="conn-pill" aria-live="polite"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting</span></span>
3053
+ <span class="updated" id="updated"></span>
3054
+ <span class="seg" id="seg" role="group" aria-label="Refresh interval">
3055
+ <button data-ms="2000">2s</button><button data-ms="4000" class="active">4s</button><button data-ms="10000">10s</button>
3056
+ </span>
3057
+ <button class="iconbtn" id="btn-pause" title="Pause / resume" aria-label="Pause or resume">&#10074;&#10074;</button>
3058
+ <button class="iconbtn" id="btn-theme" title="Theme: auto / dark / light" aria-label="Cycle theme">A</button>
3059
+ </div>
3060
+ <div id="scanbar" aria-hidden="true"></div>
3061
+ </header>
3062
+
3063
+ <div class="shell">
3064
+ <div id="banner" role="status" aria-live="polite"></div>
3065
+
3066
+ <section id="content">
3067
+ <section class="hero" aria-label="Vitals">
3068
+ <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>
3069
+ <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>
3070
+ <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>
3071
+ <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>
3072
+ </section>
3073
+
3074
+ <section class="grid">
3075
+ <div class="panel span5"><span class="ptitle">&#9508; Proxy &middot; requests &#9504;</span>
3076
+ <div class="headline"><span id="req-total" class="skel">&middot;&middot;&middot;</span> <span class="cap">requests</span></div>
3077
+ <div class="stack-bar empty" id="route-sharebar"></div>
3078
+ <div class="stack-bar empty" id="status-healthbar"></div>
3079
+ <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>
3080
+ </div>
3081
+
3082
+ <div class="panel span3"><span class="ptitle">&#9508; Status &#9504;</span>
3083
+ <div class="headline"><span id="error-rate" class="skel">&middot;&middot;&middot;</span> <span class="cap">err rate</span></div>
3084
+ <div class="stack-bar empty" id="status-bar"></div>
3085
+ <div class="legend" id="status-legend"></div>
3086
+ </div>
3087
+
3088
+ <div class="panel span4"><span class="ptitle">&#9508; Latency &middot; ms &#9504;</span>
3089
+ <div class="lat-trio">
3090
+ <div class="b"><small>p50</small><span id="lat-p50" class="skel">&middot;</span></div>
3091
+ <div class="b lat-p95"><small>p95</small><span id="lat-p95" class="skel">&middot;</span></div>
3092
+ <div class="b"><small>avg</small><span id="lat-avg" class="skel">&middot;</span></div>
3093
+ <div class="b"><small>obs</small><span id="lat-count" class="skel">&middot;</span></div>
3094
+ </div>
3095
+ <div class="lat-track" id="lat-track"><div class="line"></div></div>
3096
+ <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>
3097
+ </div>
3098
+
3099
+ <div class="panel span7"><span class="ptitle">&#9508; Tokens &middot; by model &#9504;</span>
3100
+ <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>
3101
+ <div class="stack-bar empty" id="tok-mixbar"></div>
3102
+ <div class="legend" id="tok-legend"></div>
3103
+ <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>
3104
+ </div>
3105
+
3106
+ <div class="panel span5"><span class="ptitle">&#9508; Copilot &middot; quota &#9504;</span>
3107
+ <div id="copilot-body"><div class="emptybox skel">loading&hellip;</div></div>
3108
+ </div>
3109
+
3110
+ <div class="panel span4"><span class="ptitle">&#9508; Upstream &middot; copilot edge &#9504;</span>
3111
+ <div class="upblocks">
3112
+ <div class="upblk"><div class="v" id="up-total">&middot;</div><div class="k">calls</div></div>
3113
+ <div class="upblk err" id="up-errblk"><div class="v" id="up-errors">&middot;</div><div class="k">errors</div></div>
3114
+ <div class="upblk"><div class="v rate" id="up-rate">&middot;</div><div class="k">err rate</div></div>
3115
+ </div>
3116
+ <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>
3117
+ <div class="flag" id="up-flag"></div>
3118
+ </div>
3119
+
3120
+ <div class="panel span8"><span class="ptitle">&#9508; Throughput &#9504;</span>
3121
+ <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>
3122
+ <svg id="thru-svg" viewBox="0 0 320 88" preserveAspectRatio="none" aria-hidden="true">
3123
+ <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>
3124
+ <line class="grid" x1="0" y1="22" x2="320" y2="22" stroke="var(--grid-line)"/>
3125
+ <line class="grid" x1="0" y1="44" x2="320" y2="44" stroke="var(--grid-line)"/>
3126
+ <line class="grid" x1="0" y1="66" x2="320" y2="66" stroke="var(--grid-line)"/>
3127
+ <path id="thru-tok-area" fill="url(#thrugrad)" stroke="none"/>
3128
+ <path id="thru-tok-line" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>
3129
+ <path id="thru-req-line" fill="none" stroke="var(--accent-2)" stroke-width="1.2" vector-effect="non-scaling-stroke" opacity="0.9"/>
3130
+ </svg>
3131
+ </div>
3132
+ </section>
3133
+ </section>
3134
+
3135
+ <section id="auth" aria-live="polite">
3136
+ <div class="authcard">
3137
+ <span class="clear" id="auth-clear" style="display:none">clear key</span>
3138
+ <h3>&#9508; Auth required &#9504;</h3>
3139
+ <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>
3140
+ <div class="row"><input id="auth-input" type="password" placeholder="x-api-key" autocomplete="off" spellcheck="false" /><button id="auth-connect">connect</button></div>
3141
+ <div class="err" id="auth-err"></div>
3142
+ </div>
3143
+ </section>
3144
+
3145
+ <footer class="foot">
3146
+ <span id="foot-started">started &middot;</span>
3147
+ <span id="foot-uptime">uptime &middot;</span>
3148
+ <span id="foot-total">&middot; req</span>
3149
+ <span id="foot-tokens">&middot; tokens</span>
3150
+ <span id="foot-upstream">upstream &middot;</span>
3151
+ <span class="end" id="foot-cadence"></span>
3152
+ </footer>
3153
+ </div>
3154
+
3155
+ <script>
3156
+ (function(){
3157
+ "use strict";
3158
+ var byId = function(id){ return document.getElementById(id); };
3159
+ var CAP = 60;
3160
+
3161
+ // ---- persistent state ----
3162
+ var LS = window.localStorage;
3163
+ var apiKey = "";
3164
+ try { apiKey = LS.getItem("hoopilot.apiKey") || ""; } catch (e) { apiKey = ""; }
3165
+ var theme = "auto";
3166
+ try { theme = LS.getItem("hoopilot.theme") || "auto"; } catch (e) { theme = "auto"; }
3167
+ var intervalMs = 4000;
3168
+ try { var sv = parseInt(LS.getItem("hoopilot.intervalMs") || "", 10); if (sv === 2000 || sv === 4000 || sv === 10000) intervalMs = sv; } catch (e) {}
3169
+
3170
+ // ---- runtime state ----
3171
+ var paused = false;
3172
+ var timer = null;
3173
+ var inflightFetch = null;
3174
+ var lastSuccessAt = 0;
3175
+ var prevSample = null; // { t, reqTotal, tokTotal, upTotal, startedAt }
3176
+ var lastRender = {}; // for change-flash
3177
+ var backoffMs = 0;
3178
+ var lastUptime = null; // seconds; ticked locally between polls
3179
+ var hist = { req:[], tok:[], inflight:[], up:[] };
3180
+
3181
+ // ---- formatting helpers ----
3182
+ function humanInt(n){
3183
+ if (n === null || n === undefined || !isFinite(n)) return "0";
3184
+ var a = Math.abs(n);
3185
+ if (a >= 1000000) return (n/1000000).toFixed(a >= 10000000 ? 0 : 1) + "M";
3186
+ if (a >= 1000) return (n/1000).toFixed(a >= 10000 ? 0 : 1) + "k";
3187
+ return String(Math.round(n));
3188
+ }
3189
+ function rate(n){
3190
+ if (n === null || n === undefined || !isFinite(n)) return "0";
3191
+ if (n >= 100) return String(Math.round(n));
3192
+ if (n >= 10) return n.toFixed(1);
3193
+ return n.toFixed(2);
3194
+ }
3195
+ function pct(n){ if (!isFinite(n)) return "0%"; return (n >= 10 ? Math.round(n) : Math.round(n*10)/10) + "%"; }
3196
+ 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 + ""; }
3197
+ function pad2(n){ return (n < 10 ? "0" : "") + n; }
3198
+ function fmtUptime(sec){
3199
+ sec = Math.max(0, Math.floor(sec));
3200
+ var d = Math.floor(sec/86400); sec -= d*86400;
3201
+ var h = Math.floor(sec/3600); sec -= h*3600;
3202
+ var m = Math.floor(sec/60); var s = sec - m*60;
3203
+ if (d > 0) return d + "d " + pad2(h) + ":" + pad2(m);
3204
+ if (h > 0) return h + ":" + pad2(m) + ":" + pad2(s);
3205
+ return m + ":" + pad2(s);
3206
+ }
3207
+ function titleize(key){
3208
+ var map = { premium_interactions:"Premium requests", chat:"Chat", completions:"Completions", code_review:"Code review" };
3209
+ if (map[key]) return map[key];
3210
+ return key.split("_").map(function(w){ return w ? w.charAt(0).toUpperCase() + w.slice(1) : w; }).join(" ");
3211
+ }
3212
+ function relTime(iso){
3213
+ var t = Date.parse(iso); if (!isFinite(t)) return iso || "";
3214
+ var s = Math.max(0, Math.round((Date.now() - t)/1000));
3215
+ return fmtUptime(s) + " ago";
3216
+ }
3217
+ function clearEl(el){ while (el && el.firstChild) el.removeChild(el.firstChild); }
3218
+ 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; }
3219
+
3220
+ // Set numeric text and flash on discrete change.
3221
+ function setNum(id, value, kind, num){
3222
+ var el = byId(id); if (!el) return;
3223
+ el.classList.remove("skel");
3224
+ var s = String(value);
3225
+ if (el.textContent !== s){
3226
+ el.textContent = s;
3227
+ // Compare on the raw number (num) when provided, so directional flash works
3228
+ // even when value is a pre-formatted display string.
3229
+ var n = (num !== undefined) ? num : value;
3230
+ var prev = lastRender[id];
3231
+ if (prev !== undefined){
3232
+ var cls = "flash";
3233
+ if (kind === "delta" && typeof n === "number" && typeof prev === "number"){
3234
+ cls = n > prev ? "flash-up" : (n < prev ? "flash-down" : null);
3235
+ }
3236
+ if (cls){ el.classList.remove("flash","flash-up","flash-down"); void el.offsetWidth; el.classList.add(cls); }
3237
+ }
3238
+ lastRender[id] = n;
3239
+ }
3240
+ }
3241
+ function setText(id, s){ var el = byId(id); if (el){ el.classList.remove("skel"); el.textContent = s; } }
3242
+
3243
+ // ---- sparkline rendering ----
3244
+ function pushHist(arr, v){ arr.push(v); if (arr.length > CAP) arr.shift(); }
3245
+ function buildSpark(values, w, h){
3246
+ var pts = []; for (var i=0;i<values.length;i++){ if (isFinite(values[i])) pts.push({ i:i, v:values[i] }); }
3247
+ if (pts.length < 2) return null;
3248
+ var min = Infinity, max = -Infinity;
3249
+ 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; } }
3250
+ var flat = (max - min) <= 0;
3251
+ var pad = flat ? 1 : (max - min) * 0.05; var lo = min - pad, hi = max + pad; var span = hi - lo; if (span <= 0) span = 1;
3252
+ var n = values.length;
3253
+ var line = "", lastX = 0, lastY = 0, started = false;
3254
+ for (var k=0;k<n;k++){
3255
+ var val = values[k]; if (!isFinite(val)) continue;
3256
+ var x = (n === 1) ? w : (k * (w/(n-1)));
3257
+ var norm = flat ? 0.5 : (val - lo)/span;
3258
+ var y = h - norm*(h-2) - 1;
3259
+ line += (started ? " L" : "M") + x.toFixed(2) + "," + y.toFixed(2);
3260
+ lastX = x; lastY = y; started = true;
3261
+ }
3262
+ var area = line + " L" + lastX.toFixed(2) + "," + h + " L0," + h + " Z";
3263
+ return { line:line, area:area, lastX:lastX, lastY:lastY };
3264
+ }
3265
+ function drawSpark(svgId, values){
3266
+ var svg = byId(svgId); if (!svg) return;
3267
+ var vb = svg.viewBox.baseVal; var w = vb.width || 200, h = vb.height || 24;
3268
+ var sp = buildSpark(values, w, h);
3269
+ var line = svg.querySelector(".line"), area = svg.querySelector(".area"), dot = svg.querySelector("circle");
3270
+ if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); if (dot) dot.style.display = "none"; return; }
3271
+ if (line) line.setAttribute("d", sp.line);
3272
+ if (area) area.setAttribute("d", sp.area);
3273
+ if (dot){ dot.setAttribute("cx", sp.lastX.toFixed(2)); dot.setAttribute("cy", sp.lastY.toFixed(2)); dot.style.display = ""; }
3274
+ }
3275
+
3276
+ // ---- theme ----
3277
+ function applyTheme(){
3278
+ var root = document.documentElement;
3279
+ if (theme === "dark") root.setAttribute("data-theme","dark");
3280
+ else if (theme === "light") root.setAttribute("data-theme","light");
3281
+ else root.removeAttribute("data-theme");
3282
+ byId("btn-theme").textContent = theme === "dark" ? "D" : (theme === "light" ? "L" : "A");
3283
+ }
3284
+ byId("btn-theme").addEventListener("click", function(){
3285
+ theme = theme === "auto" ? "dark" : (theme === "dark" ? "light" : "auto");
3286
+ try { LS.setItem("hoopilot.theme", theme); } catch (e) {}
3287
+ applyTheme();
3288
+ });
3289
+
3290
+ // ---- interval + pause ----
3291
+ function setActiveSeg(){
3292
+ var btns = byId("seg").querySelectorAll("button");
3293
+ for (var i=0;i<btns.length;i++){ btns[i].classList.toggle("active", parseInt(btns[i].getAttribute("data-ms"),10) === intervalMs); }
3294
+ document.documentElement.style.setProperty("--scan-ms", intervalMs + "ms");
3295
+ }
3296
+ byId("seg").addEventListener("click", function(ev){
3297
+ var b = ev.target.closest ? ev.target.closest("button") : null; if (!b) return;
3298
+ intervalMs = parseInt(b.getAttribute("data-ms"),10) || 4000;
3299
+ try { LS.setItem("hoopilot.intervalMs", String(intervalMs)); } catch (e) {}
3300
+ setActiveSeg();
3301
+ if (!paused){ schedule(0); }
3302
+ });
3303
+ byId("btn-pause").addEventListener("click", function(){
3304
+ paused = !paused;
3305
+ byId("btn-pause").innerHTML = paused ? "&#9654;" : "&#10074;&#10074;";
3306
+ byId("bar").classList.toggle("paused", paused);
3307
+ if (paused){ if (timer){ clearTimeout(timer); timer = null; } setPill("paused","PAUSED",false); }
3308
+ else { setPill("live","LIVE",false); schedule(0); }
3309
+ });
3310
+
3311
+ // ---- connection pill / banner ----
3312
+ function setPill(kind, text, beat){
3313
+ var pill = byId("conn-pill"); var dot = byId("conn-dot");
3314
+ pill.className = "pill " + kind;
3315
+ byId("conn-text").textContent = text;
3316
+ if (beat && dot){ dot.classList.remove("heartbeat"); void dot.offsetWidth; dot.classList.add("heartbeat"); }
3317
+ }
3318
+ function showBanner(text, ok){
3319
+ var b = byId("banner"); b.textContent = text; b.className = "show" + (ok ? " ok" : "");
3320
+ if (ok){ setTimeout(function(){ b.classList.remove("show"); }, 2000); }
3321
+ }
3322
+ function hideBanner(){ byId("banner").classList.remove("show"); }
3323
+ function setDimmed(on){ byId("content").classList.toggle("dim", on); }
3324
+
3325
+ // ---- auth takeover ----
3326
+ function showAuth(rejected){
3327
+ byId("content").style.display = "none";
3328
+ byId("auth").classList.add("show");
3329
+ setPill("authkey","API KEY",false);
3330
+ byId("auth-err").textContent = rejected ? "key rejected" : "";
3331
+ byId("auth-input").classList.toggle("bad", !!rejected);
3332
+ byId("auth-clear").style.display = apiKey ? "" : "none";
3333
+ byId("auth-input").focus();
3334
+ }
3335
+ function hideAuth(){ byId("auth").classList.remove("show"); byId("content").style.display = ""; }
3336
+ byId("auth-connect").addEventListener("click", function(){
3337
+ var v = byId("auth-input").value.trim(); if (!v) return;
3338
+ apiKey = v; try { LS.setItem("hoopilot.apiKey", apiKey); } catch (e) {}
3339
+ hideAuth(); schedule(0);
3340
+ });
3341
+ byId("auth-input").addEventListener("keydown", function(ev){ if (ev.key === "Enter") byId("auth-connect").click(); });
3342
+ byId("auth-clear").addEventListener("click", function(){
3343
+ apiKey = ""; try { LS.removeItem("hoopilot.apiKey"); } catch (e) {}
3344
+ byId("auth-input").value = ""; byId("auth-clear").style.display = "none"; byId("auth-input").focus();
3345
+ });
3346
+
3347
+ // ---- the poll loop (setTimeout-chained, never setInterval) ----
3348
+ var pollGen = 0;
3349
+ function schedule(delay){
3350
+ if (timer){ clearTimeout(timer); }
3351
+ if (paused) return;
3352
+ timer = setTimeout(poll, delay === undefined ? intervalMs : delay);
3353
+ }
3354
+ function poll(){
3355
+ if (paused) return;
3356
+ // A new poll supersedes any in-flight one. Bump the generation so the old
3357
+ // request's settled handlers (including its abort rejection) become no-ops
3358
+ // and never flash a false "disconnected".
3359
+ pollGen += 1; var myGen = pollGen;
3360
+ if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
3361
+ var ctrl = new AbortController(); inflightFetch = ctrl;
3362
+ var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, 3000);
3363
+ var headers = { "accept":"application/json" };
3364
+ if (apiKey) headers["x-api-key"] = apiKey;
3365
+ fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
3366
+ clearTimeout(to);
3367
+ if (myGen !== pollGen) return null;
3368
+ if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
3369
+ if (!res.ok) throw new Error("HTTP " + res.status);
3370
+ return res.json();
3371
+ }).then(function(data){
3372
+ if (myGen !== pollGen || data === null || paused) return;
3373
+ inflightFetch = null;
3374
+ onData(data);
3375
+ backoffMs = 0; lastSuccessAt = Date.now();
3376
+ hideAuth(); setDimmed(false); hideBanner();
3377
+ setPill("live","LIVE",true);
3378
+ byId("bar").classList.remove("frozen");
3379
+ schedule(intervalMs);
3380
+ }).catch(function(err){
3381
+ clearTimeout(to);
3382
+ if (myGen !== pollGen || paused) return;
3383
+ inflightFetch = null;
3384
+ onDisconnect(err);
3385
+ });
3386
+ }
3387
+ function onDisconnect(err){
3388
+ setPill("reconnect","RECONNECTING",false);
3389
+ setDimmed(true);
3390
+ byId("bar").classList.add("frozen");
3391
+ backoffMs = backoffMs ? Math.min(Math.round(backoffMs * 1.5), 30000) : intervalMs;
3392
+ showBanner("Disconnected (" + (err && err.message ? err.message : "no response") + ") \\u2014 retrying in " + Math.round(backoffMs/1000) + "s", false);
3393
+ schedule(backoffMs);
3394
+ }
3395
+
3396
+ // ---- main render ----
3397
+ function onData(usage){
3398
+ var proxy = usage.proxy || {};
3399
+ var now = Date.now();
3400
+
3401
+ setText("version-chip", "v" + (usage.version || "?"));
3402
+
3403
+ // rates
3404
+ var reqTotal = (proxy.requests && proxy.requests.total) || 0;
3405
+ var tokTotal = (proxy.tokens && proxy.tokens.total) || 0;
3406
+ var upTotal = (proxy.upstream && proxy.upstream.total) || 0;
3407
+ var startedAt = proxy.startedAt || "";
3408
+ var reqPerSec = NaN, tokPerSec = NaN, upDelta = 0, restarted = false;
3409
+ if (prevSample){
3410
+ var dt = (now - prevSample.t)/1000;
3411
+ if (prevSample.startedAt && startedAt && prevSample.startedAt !== startedAt) restarted = true;
3412
+ if (reqTotal < prevSample.reqTotal || tokTotal < prevSample.tokTotal) restarted = true;
3413
+ if (restarted){ reqPerSec = 0; tokPerSec = 0; upDelta = 0; }
3414
+ else if (dt > 0 && isFinite(dt)){
3415
+ reqPerSec = Math.max(0, (reqTotal - prevSample.reqTotal)/dt);
3416
+ tokPerSec = Math.max(0, (tokTotal - prevSample.tokTotal)/dt);
3417
+ upDelta = Math.max(0, upTotal - prevSample.upTotal);
3418
+ }
3419
+ }
3420
+ prevSample = { t:now, reqTotal:reqTotal, tokTotal:tokTotal, upTotal:upTotal, startedAt:startedAt };
3421
+
3422
+ // hero vitals
3423
+ if (isFinite(reqPerSec)){ pushHist(hist.req, reqPerSec); setNum("req-num", rate(reqPerSec)); } else setText("req-num","\\u2014");
3424
+ if (isFinite(tokPerSec)){ pushHist(hist.tok, tokPerSec); setNum("tok-num", humanInt(tokPerSec)); } else setText("tok-num","\\u2014");
3425
+ var inflight = proxy.inFlight || 0;
3426
+ pushHist(hist.inflight, inflight); setNum("inflight-num", String(inflight), "delta", inflight);
3427
+ byId("v-inflight").classList.toggle("active", inflight > 0);
3428
+ setText("uptime-num", fmtUptime(proxy.uptimeSeconds || 0));
3429
+
3430
+ setText("req-sub", hist.req.length ? ("avg " + rate(avg(hist.req)) + "/s") : "warming up");
3431
+ setText("tok-sub", hist.tok.length ? ("peak " + humanInt(Math.max.apply(null, hist.tok)) + "/s") : "warming up");
3432
+ setText("inflight-sub", inflight + " now");
3433
+ setText("uptime-sub", startedAt ? ("since " + relTime(startedAt)) : "");
3434
+
3435
+ drawSpark("req-spark", hist.req);
3436
+ drawSpark("tok-spark", hist.tok);
3437
+ drawSpark("inflight-spark", hist.inflight);
3438
+
3439
+ renderRequests(proxy);
3440
+ renderStatus(proxy);
3441
+ renderLatency(proxy.latency || {});
3442
+ renderTokens(proxy.tokens || {});
3443
+ renderCopilot(usage);
3444
+ renderUpstream(proxy.upstream || {}, upDelta, restarted);
3445
+ renderThroughput();
3446
+ renderFooter(usage, proxy);
3447
+
3448
+ setNum("req-total", humanInt(reqTotal));
3449
+ setNum("tok-total", humanInt(tokTotal));
3450
+ lastUptime = proxy.uptimeSeconds || 0;
3451
+ }
3452
+
3453
+ 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; }
3454
+
3455
+ var ROUTE_COLORS = ["var(--c1)","var(--c2)","var(--c3)","var(--c4)","var(--c5)","var(--c6)"];
3456
+ function renderRequests(proxy){
3457
+ var byRoute = (proxy.requests && proxy.requests.byRoute) || {};
3458
+ var total = (proxy.requests && proxy.requests.total) || 0;
3459
+ var rows = Object.keys(byRoute).map(function(k){ return { k:k, v:byRoute[k] }; }).sort(function(a,b){ return b.v - a.v; });
3460
+ var share = byId("route-sharebar"); clearEl(share); share.className = "stack-bar" + (total ? "" : " empty");
3461
+ var body = byId("routes-body"); clearEl(body);
3462
+ 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; }
3463
+ rows.forEach(function(r, idx){
3464
+ var p = total ? (r.v/total*100) : 0;
3465
+ 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);
3466
+ var tr = mk("tr");
3467
+ var name = mk("td","l", r.k); name.title = r.k; tr.appendChild(name);
3468
+ tr.appendChild(mk("td",null, humanInt(r.v)));
3469
+ tr.appendChild(mk("td",null, pct(p)));
3470
+ 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);
3471
+ body.appendChild(tr);
3472
+ });
3473
+ 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);
3474
+ }
3475
+
3476
+ 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"; }
3477
+ function statusColor(cls){ return cls === "ok" ? "var(--ok)" : cls === "info" ? "var(--info)" : cls === "warn" ? "var(--warn)" : cls === "danger" ? "var(--danger)" : "var(--text-2)"; }
3478
+ function renderStatus(proxy){
3479
+ var byStatus = (proxy.requests && proxy.requests.byStatus) || {};
3480
+ var total = 0, errs = 0; var groups = { ok:0, info:0, warn:0, danger:0, muted:0 };
3481
+ var codes = Object.keys(byStatus).map(function(k){ return { k:k, v:byStatus[k] }; }).sort(function(a,b){ return b.v - a.v; });
3482
+ codes.forEach(function(c){ total += c.v; var cls = statusClass(c.k); groups[cls] += c.v; if (cls === "warn" || cls === "danger") errs += c.v; });
3483
+ var bar = byId("status-bar"); clearEl(bar); bar.className = "stack-bar" + (total ? "" : " empty");
3484
+ ["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); } });
3485
+ var leg = byId("status-legend"); clearEl(leg);
3486
+ if (!codes.length){ leg.appendChild(mk("span","li","no requests yet")); }
3487
+ 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); });
3488
+ var er = total ? (errs/total*100) : 0;
3489
+ setNum("error-rate", pct(er));
3490
+ var el = byId("error-rate"); el.style.color = er > 5 ? "var(--danger)" : er > 1 ? "var(--warn)" : "var(--ok)";
3491
+ }
3492
+
3493
+ function renderLatency(lat){
3494
+ setText("lat-p50", fmtMs(lat.p50Ms)); setText("lat-avg", fmtMs(lat.avgMs)); setText("lat-count", humanInt(lat.count || 0));
3495
+ var p95 = byId("lat-p95"); p95.classList.remove("skel"); p95.textContent = fmtMs(lat.p95Ms);
3496
+ p95.style.color = (lat.p50Ms > 0 && lat.p95Ms > 2*lat.p50Ms) ? "var(--warn)" : "var(--info)";
3497
+ // track: position p50 and p95 across 0..(p95*1.15)
3498
+ var track = byId("lat-track"); var old = track.querySelectorAll(".tick,.tlab"); for (var i=0;i<old.length;i++) old[i].remove();
3499
+ var maxv = Math.max(lat.p95Ms || 0, lat.avgMs || 0, 1) * 1.15;
3500
+ 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); }
3501
+ place(lat.p50Ms, "p50"); place(lat.p95Ms, "p95");
3502
+ var lr = byId("lat-routes"); clearEl(lr);
3503
+ 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); });
3504
+ 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); });
3505
+ }
3506
+
3507
+ function renderTokens(tok){
3508
+ var prompt = tok.prompt||0, completion = tok.completion||0, reasoning = tok.reasoning||0, cached = tok.cached||0;
3509
+ var sum = prompt + completion + reasoning;
3510
+ var bar = byId("tok-mixbar"); clearEl(bar); bar.className = "stack-bar" + (sum ? "" : " empty");
3511
+ var parts = [ ["prompt", prompt, "var(--text-1)"], ["completion", completion, "var(--accent)"], ["reasoning", reasoning, "var(--info)"] ];
3512
+ 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); } });
3513
+ var leg = byId("tok-legend"); clearEl(leg);
3514
+ var legParts = parts.concat([["cached", cached, "var(--cache)"]]);
3515
+ 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); });
3516
+ var cacheRate = prompt ? (cached/prompt*100) : 0; setText("tok-cache", "cache " + pct(cacheRate));
3517
+ var body = byId("tok-body"); clearEl(body);
3518
+ 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); });
3519
+ 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; }
3520
+ 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);
3521
+ tr.appendChild(mk("td",null, humanInt(m.prompt||0))); tr.appendChild(mk("td",null, humanInt(m.completion||0)));
3522
+ tr.appendChild(mk("td","reasoning", humanInt(m.reasoning||0))); tr.appendChild(mk("td","cached", humanInt(m.cached||0)));
3523
+ tr.appendChild(mk("td",null, humanInt(m.total||0))); tr.appendChild(mk("td",null, humanInt(m.requests||0))); body.appendChild(tr); });
3524
+ }
3525
+
3526
+ 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"; }
3527
+ function renderCopilot(usage){
3528
+ var box = byId("copilot-body"); clearEl(box);
3529
+ var cp = usage.copilot; var planChip = byId("plan-chip");
3530
+ if (!cp){
3531
+ planChip.className = "chip plan-offline"; planChip.textContent = "\\u2014 offline";
3532
+ var eb = mk("div","emptybox"); eb.appendChild(mk("div","keyglyph","\\u26bf"));
3533
+ eb.appendChild(mk("h4",null,"Copilot not connected"));
3534
+ if (usage.copilot_error) eb.appendChild(mk("div","errline", usage.copilot_error));
3535
+ eb.appendChild(mk("div","prompt","$ hoopilot login"));
3536
+ box.appendChild(eb); return;
3537
+ }
3538
+ planChip.className = "chip " + planClass(cp.plan); planChip.textContent = cp.plan || "copilot";
3539
+ var head = mk("div","cap");
3540
+ var bits = [];
3541
+ if (cp.accessTypeSku) bits.push(cp.accessTypeSku);
3542
+ if (cp.chatEnabled !== undefined) bits.push(cp.chatEnabled ? "chat on" : "chat off");
3543
+ if (cp.quotaResetDate) bits.push("resets " + cp.quotaResetDate);
3544
+ head.textContent = bits.join(" \\u00b7 "); box.appendChild(head);
3545
+ var quotas = cp.quotas || {}; var keys = Object.keys(quotas);
3546
+ if (!keys.length){ box.appendChild(mk("div","cap","No metered quotas reported.")); return; }
3547
+ var order = { premium_interactions:0, chat:1, completions:2 };
3548
+ 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); });
3549
+ keys.forEach(function(k){
3550
+ var q = quotas[k]; var row = mk("div","qrow");
3551
+ var hd = mk("div","qhead"); hd.appendChild(mk("span","qname", titleize(k)));
3552
+ if (q.unlimited){ hd.appendChild(mk("span","inf","\\u221e unlimited")); row.appendChild(hd); box.appendChild(row); return; }
3553
+ var ent = q.entitlement, rem = q.remaining, used = q.used;
3554
+ var usedPct = (q.percentRemaining !== undefined) ? (100 - q.percentRemaining) : ((ent && used !== undefined) ? (used/ent*100) : 0);
3555
+ usedPct = Math.max(0, Math.min(100, usedPct));
3556
+ var valTxt = (used !== undefined && ent !== undefined) ? (humanInt(used) + " / " + humanInt(ent)) : (rem !== undefined ? (humanInt(rem) + " left") : pct(100-usedPct) + " left");
3557
+ hd.appendChild(mk("span","qval", valTxt)); row.appendChild(hd);
3558
+ var bar = mk("div","qbar"); var fill = mk("i"); fill.style.width = usedPct + "%";
3559
+ fill.style.background = usedPct > 85 ? "var(--danger)" : usedPct > 60 ? "var(--warn)" : "var(--ok)"; bar.appendChild(fill);
3560
+ 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); }
3561
+ row.appendChild(bar);
3562
+ if (q.overageCount){ var ov = mk("div","flag", humanInt(q.overageCount) + " overage" + (q.tokenBasedBilling ? " \\u00b7 token billing" : "")); row.appendChild(ov); }
3563
+ box.appendChild(row);
3564
+ });
3565
+ }
3566
+
3567
+ function renderUpstream(up, delta, restarted){
3568
+ setNum("up-total", humanInt(up.total||0));
3569
+ setNum("up-errors", humanInt(up.errors||0), "delta", up.errors||0);
3570
+ var er = up.total ? (up.errors/up.total*100) : 0;
3571
+ var rt = byId("up-rate"); rt.textContent = pct(er); rt.className = "v rate " + (er > 5 ? "danger" : er > 1 ? "warn" : "ok");
3572
+ byId("up-errblk").classList.toggle("hot", (up.errors||0) > 0);
3573
+ pushHist(hist.up, delta||0); drawSpark("up-spark", hist.up);
3574
+ byId("up-flag").textContent = restarted ? "\\u21bb restarted" : "";
3575
+ }
3576
+
3577
+ function renderThroughput(){
3578
+ drawDual("thru-tok-line","thru-tok-area", hist.tok, true);
3579
+ drawDual("thru-req-line", null, hist.req, false);
3580
+ setText("thru-tok", hist.tok.length ? rate(hist.tok[hist.tok.length-1]) : "\\u2014");
3581
+ setText("thru-req", hist.req.length ? rate(hist.req[hist.req.length-1]) : "\\u2014");
3582
+ var peakTok = hist.tok.length ? Math.max.apply(null, hist.tok) : 0;
3583
+ setText("thru-peak", "peak " + humanInt(peakTok) + " tok/s");
3584
+ }
3585
+ function drawDual(lineId, areaId, values, withArea){
3586
+ var svg = byId("thru-svg"); var vb = svg.viewBox.baseVal; var w = vb.width, h = vb.height;
3587
+ var sp = buildSpark(values, w, h);
3588
+ var line = byId(lineId); var area = areaId ? byId(areaId) : null;
3589
+ if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); return; }
3590
+ if (line) line.setAttribute("d", sp.line);
3591
+ if (area && withArea) area.setAttribute("d", sp.area);
3592
+ }
3593
+
3594
+ function renderFooter(usage, proxy){
3595
+ setText("foot-started", proxy.startedAt ? ("started " + new Date(proxy.startedAt).toLocaleString()) : "started \\u2014");
3596
+ setText("foot-uptime", "uptime " + fmtUptime(proxy.uptimeSeconds||0));
3597
+ setText("foot-total", humanInt((proxy.requests && proxy.requests.total)||0) + " req");
3598
+ setText("foot-tokens", humanInt((proxy.tokens && proxy.tokens.total)||0) + " tokens");
3599
+ var up = proxy.upstream || {}; setText("foot-upstream", "upstream " + humanInt(up.total||0) + " / " + humanInt(up.errors||0) + " err");
3600
+ setText("foot-cadence", "polling /v1/usage every " + Math.round(intervalMs/1000) + "s \\u00b7 GET /dashboard");
3601
+ }
3602
+
3603
+ // ---- 1s freshness + uptime ticker (independent of the poll loop) ----
3604
+ setInterval(function(){
3605
+ if (lastSuccessAt){
3606
+ var ago = Math.round((Date.now() - lastSuccessAt)/1000);
3607
+ var u = byId("updated"); u.textContent = "updated " + ago + "s ago";
3608
+ // Staleness only matters while polling; a deliberate pause is not "stale".
3609
+ u.className = "updated" + (paused ? "" : ago > intervalMs/1000*4 ? " danger" : ago > intervalMs/1000*2 ? " warn" : "");
3610
+ }
3611
+ // Tick uptime locally between polls so the seconds advance smoothly; each
3612
+ // successful poll re-seeds lastUptime from the authoritative server value.
3613
+ if (!paused && lastUptime !== null){
3614
+ lastUptime += 1;
3615
+ byId("uptime-num").textContent = fmtUptime(lastUptime);
3616
+ var fu = byId("foot-uptime"); if (fu) fu.textContent = "uptime " + fmtUptime(lastUptime);
3617
+ }
3618
+ }, 1000);
3619
+
3620
+ // ---- boot ----
3621
+ applyTheme(); setActiveSeg();
3622
+ setPill("","CONNECTING",false);
3623
+ poll();
3624
+ })();
3625
+ </script>
3626
+ </body>
3627
+ </html>
3628
+ `;
3629
+
2771
3630
  // src/version.ts
2772
3631
  var BAKED_VERSION = typeof HOOPILOT_VERSION !== "undefined" ? HOOPILOT_VERSION : void 0;
2773
3632
  var IS_STANDALONE_BINARY = BAKED_VERSION !== void 0;
3633
+ var cachedVersion;
3634
+ async function getVersion() {
3635
+ if (cachedVersion !== void 0) {
3636
+ return cachedVersion;
3637
+ }
3638
+ let resolved;
3639
+ if (BAKED_VERSION) {
3640
+ resolved = BAKED_VERSION;
3641
+ } else {
3642
+ try {
3643
+ const manifest = asRecord(await Bun.file(new URL("../package.json", import.meta.url)).json());
3644
+ const version = manifest.version;
3645
+ resolved = typeof version === "string" ? version : "0.0.0";
3646
+ } catch {
3647
+ resolved = "0.0.0";
3648
+ }
3649
+ }
3650
+ cachedVersion = resolved;
3651
+ return resolved;
3652
+ }
2774
3653
 
2775
3654
  // src/server.ts
2776
3655
  var DEFAULT_HOST = "127.0.0.1";
@@ -2789,6 +3668,18 @@ var RequestBodyTooLargeError = class extends Error {
2789
3668
  this.name = "RequestBodyTooLargeError";
2790
3669
  }
2791
3670
  };
3671
+ var InvalidJsonError = class extends Error {
3672
+ constructor() {
3673
+ super(INVALID_JSON_MESSAGE);
3674
+ this.name = "InvalidJsonError";
3675
+ }
3676
+ };
3677
+ var JsonNotObjectError = class extends Error {
3678
+ constructor() {
3679
+ super(JSON_OBJECT_MESSAGE);
3680
+ this.name = "JsonNotObjectError";
3681
+ }
3682
+ };
2792
3683
  function createHoopilotHandler(options = {}) {
2793
3684
  const client = new CopilotClient(options);
2794
3685
  const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
@@ -2798,8 +3689,19 @@ function createHoopilotHandler(options = {}) {
2798
3689
  const readUsage = createUsageReader(client, metrics);
2799
3690
  const recordTokens = (model, usage) => metrics.recordTokens(model, usage);
2800
3691
  const recordExtraction = (extracted) => metrics.recordTokenExtraction(extracted);
2801
- const streamingProxyMode = resolveStreamingProxyMode(options);
2802
- const bufferProxyBodies = shouldBufferProxyBodies(streamingProxyMode);
3692
+ const bufferProxyBodies = shouldBufferProxyBodies(resolveStreamingProxyMode(options));
3693
+ const requestContext = /* @__PURE__ */ new WeakMap();
3694
+ const app = buildApp({
3695
+ apiKey,
3696
+ allowedOrigins,
3697
+ bufferProxyBodies,
3698
+ client,
3699
+ metrics,
3700
+ readUsage,
3701
+ recordExtraction,
3702
+ recordTokens,
3703
+ requestContext
3704
+ });
2803
3705
  return async (request) => {
2804
3706
  const startedAt = performance.now();
2805
3707
  const url = new URL(request.url);
@@ -2815,7 +3717,24 @@ function createHoopilotHandler(options = {}) {
2815
3717
  metrics.startRequest();
2816
3718
  const origin = request.headers.get("origin")?.trim() || void 0;
2817
3719
  const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
2818
- const finish = (response) => finishResponse(response, {
3720
+ const inner = normalizeInnerRequest(request, apiPath, url);
3721
+ requestContext.set(inner, {
3722
+ apiPath,
3723
+ logger: requestLogger,
3724
+ origin,
3725
+ originalPath: url.pathname
3726
+ });
3727
+ let response;
3728
+ try {
3729
+ response = await app.handle(inner);
3730
+ } catch (error) {
3731
+ requestLogger.error(
3732
+ { err: errorDetails(error), event: "http.request.failed" },
3733
+ "request failed"
3734
+ );
3735
+ response = jsonError(500, "internal_error", errorMessage(error));
3736
+ }
3737
+ return finishResponse(response, {
2819
3738
  corsOrigin,
2820
3739
  logger: requestLogger,
2821
3740
  method: request.method,
@@ -2826,141 +3745,175 @@ function createHoopilotHandler(options = {}) {
2826
3745
  closeConnection: bufferProxyBodies,
2827
3746
  trackStreamingBody: !bufferProxyBodies
2828
3747
  });
3748
+ };
3749
+ }
3750
+ function buildApp(deps) {
3751
+ const {
3752
+ apiKey,
3753
+ allowedOrigins,
3754
+ bufferProxyBodies,
3755
+ client,
3756
+ metrics,
3757
+ readUsage,
3758
+ recordExtraction,
3759
+ recordTokens,
3760
+ requestContext
3761
+ } = deps;
3762
+ const contextFor = (request) => {
3763
+ const stored = requestContext.get(request);
3764
+ if (stored) {
3765
+ return stored;
3766
+ }
3767
+ const originalPath = new URL(request.url).pathname;
3768
+ return {
3769
+ apiPath: canonicalApiPath(originalPath),
3770
+ logger: noopLogger,
3771
+ origin: request.headers.get("origin")?.trim() || void 0,
3772
+ originalPath
3773
+ };
3774
+ };
3775
+ const loggerFor = (request) => contextFor(request).logger;
3776
+ const noBody = { parse: "none" };
3777
+ return new Elysia().onRequest(({ request }) => {
3778
+ const { apiPath, logger, origin } = contextFor(request);
2829
3779
  const browserOrigin = forbiddenBrowserOrigin(origin, request, allowedOrigins);
2830
3780
  if (browserOrigin) {
2831
- requestLogger.warn(
3781
+ logger.warn(
2832
3782
  { event: "http.request.forbidden_origin", origin: browserOrigin },
2833
3783
  "blocked cross-origin browser request"
2834
3784
  );
2835
- return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
3785
+ return jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE);
2836
3786
  }
2837
3787
  if (request.method === "OPTIONS") {
2838
- return finish(new Response(null, { headers: corsHeaders() }));
3788
+ return new Response(null, { headers: corsHeaders() });
3789
+ }
3790
+ if (request.method === "GET" && apiPath === "/dashboard") {
3791
+ return dashboardResponse();
2839
3792
  }
2840
3793
  if (!isAuthorized(request, apiKey)) {
2841
- requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
2842
- return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
3794
+ logger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
3795
+ return jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key.");
2843
3796
  }
2844
- try {
2845
- if (request.method === "GET" && (apiPath === "/" || apiPath === "/healthz")) {
2846
- return finish(jsonResponse({ name: "hoopilot", object: "health", status: "ok" }));
2847
- }
2848
- if (request.method === "GET" && apiPath === "/metrics") {
2849
- return finish(metricsResponse(metrics));
2850
- }
2851
- if (request.method === "GET" && apiPath === "/v1/usage") {
2852
- return finish(await handleUsage(metrics, readUsage, request.signal));
2853
- }
2854
- if (request.method === "GET" && apiPath === "/v1/responses") {
2855
- return finish(websocketUnsupportedResponse());
2856
- }
2857
- if (request.method === "GET" && apiPath === "/v1/models") {
2858
- return finish(await handleModels(client, metrics, request.signal, requestLogger));
2859
- }
2860
- if (request.method === "POST" && apiPath === "/v1/messages") {
2861
- return finish(
2862
- await handleAnthropicMessages(
2863
- client,
2864
- metrics,
2865
- recordTokens,
2866
- recordExtraction,
2867
- request,
2868
- requestLogger,
2869
- bufferProxyBodies
2870
- )
2871
- );
2872
- }
2873
- if (request.method === "POST" && apiPath === "/v1/messages/count_tokens") {
2874
- return finish(handleAnthropicCountTokens(await readJson(request)));
2875
- }
2876
- if (request.method === "POST" && apiPath === "/v1/chat/completions") {
2877
- return finish(
2878
- await handleChatCompletions(
2879
- client,
2880
- metrics,
2881
- recordTokens,
2882
- recordExtraction,
2883
- request,
2884
- requestLogger,
2885
- bufferProxyBodies
2886
- )
2887
- );
2888
- }
2889
- if (request.method === "POST" && apiPath === "/v1/completions") {
2890
- return finish(
2891
- await handleCompletions(
2892
- client,
2893
- metrics,
2894
- recordTokens,
2895
- recordExtraction,
2896
- request,
2897
- requestLogger,
2898
- bufferProxyBodies
2899
- )
2900
- );
2901
- }
2902
- if (request.method === "POST" && apiPath === "/v1/responses/compact") {
2903
- return finish(
2904
- await handleResponsesCompact(
2905
- client,
2906
- metrics,
2907
- recordTokens,
2908
- recordExtraction,
2909
- request,
2910
- requestLogger
2911
- )
2912
- );
2913
- }
2914
- if (request.method === "POST" && apiPath === "/v1/responses") {
2915
- return finish(
2916
- await handleResponses(
2917
- client,
2918
- metrics,
2919
- recordTokens,
2920
- recordExtraction,
2921
- request,
2922
- requestLogger,
2923
- bufferProxyBodies
2924
- )
2925
- );
2926
- }
2927
- return finish(jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`));
2928
- } catch (error) {
2929
- if (error instanceof CopilotAuthError) {
2930
- requestLogger.warn(
2931
- { err: errorDetails(error), event: "copilot.auth.missing" },
2932
- "copilot auth failed"
2933
- );
2934
- return finish(jsonError(401, "copilot_auth_error", error.message));
2935
- }
2936
- const message = errorMessage(error);
2937
- if (message === INVALID_JSON_MESSAGE || message === JSON_OBJECT_MESSAGE) {
2938
- requestLogger.warn(
2939
- { err: errorDetails(error), event: "http.request.failed" },
2940
- "request body was not usable json"
2941
- );
2942
- return finish(jsonError(400, "invalid_request_error", message));
2943
- } else if (error instanceof OpenAICompatibilityError || error instanceof AnthropicCompatibilityError) {
2944
- requestLogger.warn(
2945
- { err: errorDetails(error), event: "http.request.failed" },
2946
- "request body used unsupported compatibility fields"
2947
- );
2948
- return finish(jsonError(400, "invalid_request_error", message));
2949
- } else if (error instanceof RequestBodyTooLargeError) {
2950
- requestLogger.warn(
2951
- { err: errorDetails(error), event: "http.request.failed" },
2952
- "request body exceeded size limit"
2953
- );
2954
- return finish(jsonError(413, "request_too_large", message));
2955
- } else {
2956
- requestLogger.error(
2957
- { err: errorDetails(error), event: "http.request.failed" },
2958
- "request failed"
2959
- );
2960
- }
2961
- return finish(jsonError(500, "internal_error", message));
3797
+ }).onError(({ code, error, request }) => {
3798
+ const { logger, originalPath } = contextFor(request);
3799
+ if (code === "NOT_FOUND") {
3800
+ return jsonError(404, "not_found", `No route for ${request.method} ${originalPath}.`);
3801
+ }
3802
+ if (error instanceof CopilotAuthError) {
3803
+ logger.warn(
3804
+ { err: errorDetails(error), event: "copilot.auth.missing" },
3805
+ "copilot auth failed"
3806
+ );
3807
+ return jsonError(401, "copilot_auth_error", error.message);
3808
+ }
3809
+ const message = errorMessage(error);
3810
+ if (error instanceof InvalidJsonError || error instanceof JsonNotObjectError) {
3811
+ logger.warn(
3812
+ { err: errorDetails(error), event: "http.request.failed" },
3813
+ "request body was not usable json"
3814
+ );
3815
+ return jsonError(400, "invalid_request_error", message);
3816
+ }
3817
+ if (error instanceof OpenAICompatibilityError || error instanceof AnthropicCompatibilityError) {
3818
+ logger.warn(
3819
+ { err: errorDetails(error), event: "http.request.failed" },
3820
+ "request body used unsupported compatibility fields"
3821
+ );
3822
+ return jsonError(400, "invalid_request_error", message);
3823
+ }
3824
+ if (error instanceof RequestBodyTooLargeError) {
3825
+ logger.warn(
3826
+ { err: errorDetails(error), event: "http.request.failed" },
3827
+ "request body exceeded size limit"
3828
+ );
3829
+ return jsonError(413, "request_too_large", message);
2962
3830
  }
3831
+ logger.error({ err: errorDetails(error), event: "http.request.failed" }, "request failed");
3832
+ return jsonError(500, "internal_error", message);
3833
+ }).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(
3834
+ "/v1/models",
3835
+ ({ request }) => handleModels(client, metrics, request.signal, loggerFor(request))
3836
+ ).get("/v1/responses", () => websocketUnsupportedResponse()).post(
3837
+ "/v1/messages",
3838
+ ({ request }) => handleAnthropicMessages(
3839
+ client,
3840
+ metrics,
3841
+ recordTokens,
3842
+ recordExtraction,
3843
+ request,
3844
+ loggerFor(request),
3845
+ bufferProxyBodies
3846
+ ),
3847
+ noBody
3848
+ ).post(
3849
+ "/v1/messages/count_tokens",
3850
+ ({ request }) => handleAnthropicCountTokens(request),
3851
+ noBody
3852
+ ).post(
3853
+ "/v1/chat/completions",
3854
+ ({ request }) => handleChatCompletions(
3855
+ client,
3856
+ metrics,
3857
+ recordTokens,
3858
+ recordExtraction,
3859
+ request,
3860
+ loggerFor(request),
3861
+ bufferProxyBodies
3862
+ ),
3863
+ noBody
3864
+ ).post(
3865
+ "/v1/completions",
3866
+ ({ request }) => handleCompletions(
3867
+ client,
3868
+ metrics,
3869
+ recordTokens,
3870
+ recordExtraction,
3871
+ request,
3872
+ loggerFor(request),
3873
+ bufferProxyBodies
3874
+ ),
3875
+ noBody
3876
+ ).post(
3877
+ "/v1/responses/compact",
3878
+ ({ request }) => handleResponsesCompact(
3879
+ client,
3880
+ metrics,
3881
+ recordTokens,
3882
+ recordExtraction,
3883
+ request,
3884
+ loggerFor(request)
3885
+ ),
3886
+ noBody
3887
+ ).post(
3888
+ "/v1/responses",
3889
+ ({ request }) => handleResponses(
3890
+ client,
3891
+ metrics,
3892
+ recordTokens,
3893
+ recordExtraction,
3894
+ request,
3895
+ loggerFor(request),
3896
+ bufferProxyBodies
3897
+ ),
3898
+ noBody
3899
+ );
3900
+ }
3901
+ function normalizeInnerRequest(request, canonicalPath, url) {
3902
+ if (canonicalPath === url.pathname) {
3903
+ return request;
3904
+ }
3905
+ const target = new URL(url);
3906
+ target.pathname = canonicalPath;
3907
+ const init = {
3908
+ headers: request.headers,
3909
+ method: request.method,
3910
+ signal: request.signal
2963
3911
  };
3912
+ if (request.body) {
3913
+ init.body = request.body;
3914
+ init.duplex = "half";
3915
+ }
3916
+ return new Request(target, init);
2964
3917
  }
2965
3918
  function startHoopilotServer(options = {}) {
2966
3919
  const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
@@ -3039,7 +3992,8 @@ async function handleAnthropicMessages(client, metrics, recordTokens, recordExtr
3039
3992
  recordExtraction(usage !== void 0);
3040
3993
  return jsonResponse(responsesResponseToAnthropicMessage(body, model));
3041
3994
  }
3042
- function handleAnthropicCountTokens(body) {
3995
+ async function handleAnthropicCountTokens(request) {
3996
+ const body = await readJson(request);
3043
3997
  return jsonResponse(estimateAnthropicMessageTokens(body));
3044
3998
  }
3045
3999
  async function handleModels(client, metrics, signal, logger) {
@@ -3125,14 +4079,14 @@ async function handleCompletions(client, metrics, recordTokens, recordExtraction
3125
4079
  return jsonResponse(chatCompletionToCompletion(completion));
3126
4080
  }
3127
4081
  async function handleResponses(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
3128
- const body = await readJsonText(request);
4082
+ const { json, text: body } = await readJsonText(request);
3129
4083
  const upstream = await client.responses(body, request.signal);
3130
4084
  metrics.recordUpstream("/responses", upstream.ok);
3131
4085
  if (!upstream.ok) {
3132
4086
  return proxyError(upstream, logger);
3133
4087
  }
3134
4088
  logUpstreamSuccess(logger, "/responses", upstream.status);
3135
- const model = normalizeRequestedModel(asRecord(safeParseJson(body)).model);
4089
+ const model = normalizeRequestedModel(json.model);
3136
4090
  return proxyResponse(
3137
4091
  await responseWithObservedUsage(
3138
4092
  upstream,
@@ -3220,17 +4174,16 @@ function parseJsonObject2(text) {
3220
4174
  try {
3221
4175
  parsed = JSON.parse(text);
3222
4176
  } catch {
3223
- throw new Error(INVALID_JSON_MESSAGE);
4177
+ throw new InvalidJsonError();
3224
4178
  }
3225
4179
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3226
- throw new Error(JSON_OBJECT_MESSAGE);
4180
+ throw new JsonNotObjectError();
3227
4181
  }
3228
4182
  return parsed;
3229
4183
  }
3230
4184
  async function readJsonText(request) {
3231
4185
  const text = await readRequestText(request);
3232
- parseJsonObject2(text);
3233
- return text;
4186
+ return { json: parseJsonObject2(text), text };
3234
4187
  }
3235
4188
  async function readRequestText(request) {
3236
4189
  const contentLength = request.headers.get("content-length");
@@ -3288,7 +4241,7 @@ function jsonError(status, code, message) {
3288
4241
  );
3289
4242
  }
3290
4243
  function upstreamErrorResponse(status, text) {
3291
- const parsedError = asRecord(asRecord(safeParseJson(text)).error);
4244
+ const parsedError = asRecord(asRecord(safeJsonParse(text)).error);
3292
4245
  if (Object.keys(parsedError).length > 0) {
3293
4246
  return jsonResponse({ error: parsedError }, status);
3294
4247
  }
@@ -3310,13 +4263,18 @@ function corsHeaders() {
3310
4263
  "access-control-expose-headers": "x-request-id"
3311
4264
  };
3312
4265
  }
4266
+ function secretEquals(candidate, secret) {
4267
+ const a = createHash("sha256").update(candidate).digest();
4268
+ const b = createHash("sha256").update(secret).digest();
4269
+ return timingSafeEqual(a, b);
4270
+ }
3313
4271
  function isAuthorized(request, apiKey) {
3314
4272
  if (!apiKey) {
3315
4273
  return true;
3316
4274
  }
3317
4275
  const authorization = request.headers.get("authorization") ?? "";
3318
4276
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
3319
- return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
4277
+ return bearer !== void 0 && secretEquals(bearer, apiKey) || secretEquals(request.headers.get("x-api-key") ?? "", apiKey);
3320
4278
  }
3321
4279
  function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
3322
4280
  if (origin) {
@@ -3353,7 +4311,7 @@ function upstreamAuthMessage(message) {
3353
4311
  return `GitHub Copilot rejected the credential or account access: ${message}`;
3354
4312
  }
3355
4313
  function isLoopbackHost(host) {
3356
- return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
4314
+ return isLoopbackHostname(host);
3357
4315
  }
3358
4316
  function urlHost(host) {
3359
4317
  return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
@@ -3372,9 +4330,6 @@ function normalizeServerPort(value) {
3372
4330
  }
3373
4331
  return port;
3374
4332
  }
3375
- function errorMessage(error) {
3376
- return error instanceof Error ? error.message : String(error);
3377
- }
3378
4333
  function serverLogger(options) {
3379
4334
  if (options.logger) {
3380
4335
  return options.logger.child({ component: "server" });
@@ -3390,10 +4345,7 @@ function serverLogger(options) {
3390
4345
  }
3391
4346
  function resolveStreamingProxyMode(options) {
3392
4347
  const value = options.streamingProxyMode ?? envValue(options.env?.HOOPILOT_STREAM_MODE) ?? envValue(options.env?.HOOPILOT_STREAMING_PROXY_MODE) ?? "auto";
3393
- if (value === "auto" || value === "buffer" || value === "live") {
3394
- return value;
3395
- }
3396
- throw new Error(`Invalid stream mode: ${value}. Expected auto, live, or buffer.`);
4348
+ return parseStreamingProxyMode(value);
3397
4349
  }
3398
4350
  function shouldBufferProxyBodies(mode) {
3399
4351
  if (mode === "buffer") {
@@ -3451,11 +4403,13 @@ function responseWithRequestId(response, requestId, closeConnection, corsOrigin)
3451
4403
  function trackStreamCompletion(body, onComplete) {
3452
4404
  const reader = body.getReader();
3453
4405
  let fired = false;
3454
- const fire = () => {
3455
- if (!fired) {
3456
- fired = true;
3457
- onComplete();
4406
+ const release = () => {
4407
+ if (fired) {
4408
+ return;
3458
4409
  }
4410
+ fired = true;
4411
+ onComplete();
4412
+ reader.releaseLock();
3459
4413
  };
3460
4414
  return new ReadableStream({
3461
4415
  async pull(controller) {
@@ -3463,18 +4417,25 @@ function trackStreamCompletion(body, onComplete) {
3463
4417
  const { done, value } = await reader.read();
3464
4418
  if (done) {
3465
4419
  controller.close();
3466
- fire();
4420
+ release();
3467
4421
  return;
3468
4422
  }
3469
4423
  controller.enqueue(value);
3470
4424
  } catch (error) {
3471
- fire();
4425
+ release();
3472
4426
  controller.error(error);
3473
4427
  }
3474
4428
  },
3475
- cancel(reason) {
3476
- fire();
3477
- return reader.cancel(reason);
4429
+ async cancel(reason) {
4430
+ if (!fired) {
4431
+ fired = true;
4432
+ onComplete();
4433
+ }
4434
+ try {
4435
+ await reader.cancel(reason);
4436
+ } finally {
4437
+ reader.releaseLock();
4438
+ }
3478
4439
  }
3479
4440
  });
3480
4441
  }
@@ -3522,44 +4483,26 @@ function canonicalApiPath(path) {
3522
4483
  return withoutTrailingSlash;
3523
4484
  }
3524
4485
  }
4486
+ var API_ROUTES = [
4487
+ { method: "GET", path: "/", name: "health" },
4488
+ { method: "GET", path: "/healthz", name: "health" },
4489
+ { method: "GET", path: "/dashboard", name: "dashboard" },
4490
+ { method: "GET", path: "/metrics", name: "metrics" },
4491
+ { method: "GET", path: "/v1/usage", name: "usage" },
4492
+ { method: "GET", path: "/v1/models", name: "models" },
4493
+ { method: "GET", path: "/v1/responses", name: "responses_websocket" },
4494
+ { method: "POST", path: "/v1/messages", name: "anthropic_messages" },
4495
+ { method: "POST", path: "/v1/messages/count_tokens", name: "anthropic_count_tokens" },
4496
+ { method: "POST", path: "/v1/chat/completions", name: "chat_completions" },
4497
+ { method: "POST", path: "/v1/completions", name: "completions" },
4498
+ { method: "POST", path: "/v1/responses/compact", name: "responses_compact" },
4499
+ { method: "POST", path: "/v1/responses", name: "responses" }
4500
+ ];
3525
4501
  function routeFor(method, path) {
3526
4502
  if (method === "OPTIONS") {
3527
4503
  return "cors.preflight";
3528
4504
  }
3529
- if (method === "GET" && (path === "/" || path === "/healthz")) {
3530
- return "health";
3531
- }
3532
- if (method === "GET" && path === "/metrics") {
3533
- return "metrics";
3534
- }
3535
- if (method === "GET" && path === "/v1/usage") {
3536
- return "usage";
3537
- }
3538
- if (method === "GET" && path === "/v1/models") {
3539
- return "models";
3540
- }
3541
- if (method === "POST" && path === "/v1/messages") {
3542
- return "anthropic_messages";
3543
- }
3544
- if (method === "POST" && path === "/v1/messages/count_tokens") {
3545
- return "anthropic_count_tokens";
3546
- }
3547
- if (method === "POST" && path === "/v1/chat/completions") {
3548
- return "chat_completions";
3549
- }
3550
- if (method === "POST" && path === "/v1/completions") {
3551
- return "completions";
3552
- }
3553
- if (method === "POST" && path === "/v1/responses/compact") {
3554
- return "responses_compact";
3555
- }
3556
- if (method === "POST" && path === "/v1/responses") {
3557
- return "responses";
3558
- }
3559
- if (method === "GET" && path === "/v1/responses") {
3560
- return "responses_websocket";
3561
- }
3562
- return "not_found";
4505
+ return API_ROUTES.find((entry) => entry.method === method && entry.path === path)?.name ?? "not_found";
3563
4506
  }
3564
4507
  function isStreamingResponse(response) {
3565
4508
  return response.headers.get("content-type")?.includes("text/event-stream") ?? false;
@@ -3583,10 +4526,28 @@ function metricsResponse(metrics) {
3583
4526
  status: 200
3584
4527
  });
3585
4528
  }
4529
+ function dashboardResponse() {
4530
+ return new Response(DASHBOARD_HTML, {
4531
+ headers: {
4532
+ ...corsHeaders(),
4533
+ "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'",
4534
+ "content-type": "text/html; charset=utf-8",
4535
+ "referrer-policy": "no-referrer",
4536
+ "x-content-type-options": "nosniff",
4537
+ "x-frame-options": "DENY"
4538
+ },
4539
+ status: 200
4540
+ });
4541
+ }
3586
4542
  async function handleUsage(metrics, readUsage, signal) {
3587
4543
  const { copilot, error } = await readUsage(signal);
3588
4544
  const proxy = metrics.snapshot();
3589
- const body = { copilot: copilot ?? null, object: "usage", proxy };
4545
+ const body = {
4546
+ copilot: copilot ?? null,
4547
+ object: "usage",
4548
+ proxy,
4549
+ version: await getVersion()
4550
+ };
3590
4551
  if (error) {
3591
4552
  body.copilot_error = error;
3592
4553
  }
@@ -3619,24 +4580,19 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
3619
4580
  }
3620
4581
  };
3621
4582
  }
3622
- function safeParseJson(text) {
3623
- try {
3624
- return JSON.parse(text);
3625
- } catch {
3626
- return void 0;
3627
- }
3628
- }
3629
4583
  export {
3630
4584
  AnthropicCompatibilityError,
3631
4585
  COPILOT_USAGE_API_VERSION,
3632
4586
  CopilotAuth,
3633
4587
  CopilotAuthError,
3634
4588
  CopilotClient,
4589
+ DEFAULT_COPILOT_API_BASE_URL,
3635
4590
  DEFAULT_GITHUB_API_BASE_URL,
3636
4591
  DEFAULT_LOG_FORMAT,
3637
4592
  DEFAULT_LOG_LEVEL,
3638
4593
  DEFAULT_MODEL,
3639
4594
  MetricsRegistry,
4595
+ OpenAICompatibilityError,
3640
4596
  PROMETHEUS_CONTENT_TYPE,
3641
4597
  anthropicMessagesToResponsesRequest,
3642
4598
  applyCopilotHeaders,
@@ -3662,6 +4618,7 @@ export {
3662
4618
  parseLogLevel,
3663
4619
  parseRateLimitHeaders,
3664
4620
  readStoredCopilotAuth,
4621
+ recordResponseTextUsage,
3665
4622
  responsesCompactionResult,
3666
4623
  responsesRequestToChatCompletion,
3667
4624
  responsesResponseToAnthropicMessage,