@openhoo/hoopilot 1.3.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,18 +2,27 @@
2
2
  import {
3
3
  asRecord,
4
4
  envValue,
5
+ errorMessage,
6
+ firstNumber,
7
+ isLoopbackHostname,
5
8
  isTrustedTokenBaseUrl,
6
9
  main,
10
+ modelIdsFromResponse,
11
+ parseJsonObject,
12
+ parseStreamingProxyMode,
13
+ randomId,
14
+ removeUndefined,
15
+ safeJsonParse,
7
16
  trimTrailingSlash,
8
17
  truncatedResponseText
9
- } from "./chunk-JU6F5L34.js";
18
+ } from "./chunk-6ALEIJJM.js";
10
19
 
11
20
  // src/cli.ts
12
21
  import { spawn } from "child_process";
13
22
  import { readFileSync as readFileSync2 } from "fs";
14
23
 
15
24
  // src/auth-store.ts
16
- import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
25
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
17
26
  import { dirname, join } from "path";
18
27
  var StoredCopilotAuthError = class extends Error {
19
28
  constructor(message) {
@@ -64,7 +73,7 @@ function readStoredCopilotAuth(path = authStorePath()) {
64
73
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
65
74
  throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
66
75
  }
67
- const record = parsed;
76
+ const record = asRecord(parsed);
68
77
  const token = typeof record.token === "string" ? record.token.trim() : "";
69
78
  if (!token) {
70
79
  throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
@@ -90,7 +99,15 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
90
99
  `;
91
100
  const tmpPath = `${path}.${process.pid}.tmp`;
92
101
  writeFileSync(tmpPath, data, { mode: 384 });
93
- renameSync(tmpPath, path);
102
+ try {
103
+ renameSync(tmpPath, path);
104
+ } catch (error) {
105
+ try {
106
+ rmSync(tmpPath, { force: true });
107
+ } catch {
108
+ }
109
+ throw error;
110
+ }
94
111
  try {
95
112
  chmodSync(path, 384);
96
113
  } catch {
@@ -135,23 +152,20 @@ var CopilotAuth = class {
135
152
  throw error;
136
153
  }
137
154
  if (stored) {
138
- return this.#cacheAccess({
155
+ this.#cachedAccess = {
139
156
  apiBaseUrl: trimTrailingSlash(
140
157
  this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
141
158
  ),
142
159
  expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
143
160
  source: "github-copilot-oauth",
144
161
  token: stored.token
145
- });
162
+ };
163
+ return this.#cachedAccess;
146
164
  }
147
165
  throw new CopilotAuthError(
148
166
  "No GitHub Copilot OAuth credential found. Run `hoopilot login` to sign in through your browser."
149
167
  );
150
168
  }
151
- #cacheAccess(access) {
152
- this.#cachedAccess = access;
153
- return access;
154
- }
155
169
  };
156
170
 
157
171
  // src/copilot.ts
@@ -159,23 +173,26 @@ var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
159
173
  var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
160
174
  var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
161
175
  var COPILOT_USAGE_API_VERSION = "2025-04-01";
176
+ var EDITOR_PLUGIN_VERSION = "hoopilot/0.1.0";
177
+ var EDITOR_VERSION = "Hoopilot/0.1.0";
178
+ var HOOPILOT_USER_AGENT = "hoopilot/0.1.0";
162
179
  function applyCopilotHeaders(headers, token) {
163
180
  headers.set("accept", headers.get("accept") ?? "application/json");
164
181
  headers.set("authorization", `Bearer ${token}`);
165
182
  headers.set("copilot-integration-id", "vscode-chat");
166
- headers.set("editor-plugin-version", "hoopilot/0.1.0");
167
- headers.set("editor-version", "Hoopilot/0.1.0");
183
+ headers.set("editor-plugin-version", EDITOR_PLUGIN_VERSION);
184
+ headers.set("editor-version", EDITOR_VERSION);
168
185
  headers.set("openai-intent", "conversation-panel");
169
- headers.set("user-agent", "hoopilot/0.1.0");
186
+ headers.set("user-agent", HOOPILOT_USER_AGENT);
170
187
  headers.set("x-github-api-version", "2026-06-01");
171
188
  return headers;
172
189
  }
173
190
  function applyGithubApiHeaders(headers, token) {
174
191
  headers.set("accept", headers.get("accept") ?? "application/json");
175
192
  headers.set("authorization", `token ${token}`);
176
- headers.set("editor-plugin-version", "hoopilot/0.1.0");
177
- headers.set("editor-version", "Hoopilot/0.1.0");
178
- headers.set("user-agent", "hoopilot/0.1.0");
193
+ headers.set("editor-plugin-version", EDITOR_PLUGIN_VERSION);
194
+ headers.set("editor-version", EDITOR_VERSION);
195
+ headers.set("user-agent", HOOPILOT_USER_AGENT);
179
196
  headers.set("x-github-api-version", COPILOT_USAGE_API_VERSION);
180
197
  return headers;
181
198
  }
@@ -188,7 +205,7 @@ function parseRateLimitHeaders(headers, nowMs = Date.now()) {
188
205
  if (limit === void 0 && remaining === void 0 && used === void 0 && resetEpochSeconds === void 0 && retryAfterSeconds === void 0) {
189
206
  return void 0;
190
207
  }
191
- return removeUndefinedRateLimit({
208
+ return removeUndefined({
192
209
  limit,
193
210
  observedAtMs: nowMs,
194
211
  remaining,
@@ -206,11 +223,6 @@ function headerInt(headers, name) {
206
223
  const value = Number.parseInt(raw.trim(), 10);
207
224
  return Number.isFinite(value) && value >= 0 ? value : void 0;
208
225
  }
209
- function removeUndefinedRateLimit(rateLimit) {
210
- return Object.fromEntries(
211
- Object.entries(rateLimit).filter(([, value]) => value !== void 0)
212
- );
213
- }
214
226
  var CopilotClient = class {
215
227
  #auth;
216
228
  #allowUnsafeUpstream;
@@ -307,7 +319,7 @@ function normalizeCopilotUsage(body) {
307
319
  for (const category of /* @__PURE__ */ new Set([...Object.keys(remaining), ...Object.keys(monthly)])) {
308
320
  const entitlement = numberOrUndefined(monthly[category]);
309
321
  const left = numberOrUndefined(remaining[category]);
310
- quotas[category] = removeUndefinedQuota({
322
+ quotas[category] = removeUndefined({
311
323
  entitlement,
312
324
  percentRemaining: entitlement !== void 0 && entitlement > 0 && left !== void 0 ? left / entitlement * 100 : void 0,
313
325
  remaining: left,
@@ -315,7 +327,7 @@ function normalizeCopilotUsage(body) {
315
327
  });
316
328
  }
317
329
  }
318
- return removeUndefinedUsage({
330
+ return removeUndefined({
319
331
  accessTypeSku: stringOrUndefined(record.access_type_sku),
320
332
  chatEnabled: typeof record.chat_enabled === "boolean" ? record.chat_enabled : void 0,
321
333
  plan: stringOrUndefined(record.copilot_plan),
@@ -327,7 +339,7 @@ function normalizeQuotaDetail(detail) {
327
339
  const entitlement = numberOrUndefined(detail.entitlement);
328
340
  const overageCount = numberOrUndefined(detail.overage_count);
329
341
  const remaining = numberOrUndefined(detail.remaining) ?? numberOrUndefined(detail.quota_remaining);
330
- return removeUndefinedQuota({
342
+ return removeUndefined({
331
343
  entitlement,
332
344
  hasQuota: typeof detail.has_quota === "boolean" ? detail.has_quota : void 0,
333
345
  overageCount,
@@ -351,21 +363,10 @@ function usedFrom(entitlement, remaining, overageCount) {
351
363
  const overage = remaining === 0 ? overageCount ?? 0 : 0;
352
364
  return Math.max(0, base + overage);
353
365
  }
354
- function numberOrUndefined(value) {
355
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
356
- }
366
+ var numberOrUndefined = firstNumber;
357
367
  function stringOrUndefined(value) {
358
368
  return typeof value === "string" && value.length > 0 ? value : void 0;
359
369
  }
360
- function removeUndefinedQuota(quota) {
361
- return Object.fromEntries(
362
- Object.entries(quota).filter(([, value]) => value !== void 0)
363
- );
364
- }
365
- function removeUndefinedUsage(usage) {
366
- const entries = Object.entries(usage).filter(([, value]) => value !== void 0);
367
- return Object.fromEntries(entries);
368
- }
369
370
 
370
371
  // src/github-device.ts
371
372
  import { setTimeout as sleep } from "timers/promises";
@@ -497,11 +498,16 @@ function positiveSeconds(value, fallback) {
497
498
  }
498
499
  async function parseJsonResponse(response, context) {
499
500
  const text = await response.text();
501
+ let value;
500
502
  try {
501
- return JSON.parse(text);
503
+ value = JSON.parse(text);
502
504
  } catch {
503
505
  throw new Error(`${context}: ${text.slice(0, 500)}`);
504
506
  }
507
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
508
+ throw new Error(`${context}: ${text.slice(0, 500)}`);
509
+ }
510
+ return value;
505
511
  }
506
512
 
507
513
  // src/logger.ts
@@ -565,21 +571,29 @@ function createHoopilotLogger(options = {}) {
565
571
  timestamp: pino.stdTimeFunctions.isoTime
566
572
  };
567
573
  if (format === "pretty") {
568
- return pino(
569
- pinoOptions,
570
- pretty({
571
- colorize: options.colorize ?? process.stderr.isTTY,
572
- destination: options.stream ?? 1,
573
- ignore: "pid,hostname",
574
- singleLine: true,
575
- translateTime: "SYS:standard"
576
- })
574
+ return asHoopilotLogger(
575
+ pino(
576
+ pinoOptions,
577
+ pretty({
578
+ // Probe the same sink we write to (stdout / fd 1), so colors are not
579
+ // emitted into a redirected file when only stderr is a TTY. A custom
580
+ // stream's TTY-ness is unknown, so default to no color there.
581
+ colorize: options.colorize ?? (options.stream ? false : process.stdout.isTTY),
582
+ destination: options.stream ?? 1,
583
+ ignore: "pid,hostname",
584
+ singleLine: true,
585
+ translateTime: "SYS:standard"
586
+ })
587
+ )
577
588
  );
578
589
  }
579
590
  if (options.stream) {
580
- return pino(pinoOptions, options.stream);
591
+ return asHoopilotLogger(pino(pinoOptions, options.stream));
581
592
  }
582
- return pino(pinoOptions);
593
+ return asHoopilotLogger(pino(pinoOptions));
594
+ }
595
+ function asHoopilotLogger(logger) {
596
+ return logger;
583
597
  }
584
598
  function parseLogFormat(value) {
585
599
  if (!value) {
@@ -621,6 +635,10 @@ function isLogLevel(value) {
621
635
  return LOG_LEVELS.includes(value);
622
636
  }
623
637
 
638
+ // src/server.ts
639
+ import { createHash, timingSafeEqual } from "crypto";
640
+ import { Elysia } from "elysia";
641
+
624
642
  // src/openai.ts
625
643
  var DEFAULT_MODEL = "gpt-4.1";
626
644
  var OpenAICompatibilityError = class extends Error {
@@ -693,13 +711,6 @@ function compactionOutputFromResponsesSse(text) {
693
711
  }
694
712
  return deltas ? [messageOutputItem(deltas)] : [];
695
713
  }
696
- function safeJsonParse(text) {
697
- try {
698
- return JSON.parse(text);
699
- } catch {
700
- return void 0;
701
- }
702
- }
703
714
  function chatCompletionToCompletion(completion) {
704
715
  return removeUndefined({
705
716
  choices: completionChoices(completion).map((choice, index) => {
@@ -895,21 +906,18 @@ function extractTokenUsage(usage) {
895
906
  asRecord(record.prompt_tokens_details).cached_tokens,
896
907
  asRecord(record.input_tokens_details).cached_tokens
897
908
  );
898
- return removeUndefined({
899
- cachedTokens: cached,
909
+ const result = {
900
910
  completionTokens,
901
911
  promptTokens,
902
- reasoningTokens: reasoning,
903
912
  totalTokens: total ?? promptTokens + completionTokens
904
- });
905
- }
906
- function firstNumber(...values) {
907
- for (const value of values) {
908
- if (typeof value === "number" && Number.isFinite(value)) {
909
- return value;
910
- }
913
+ };
914
+ if (cached !== void 0) {
915
+ result.cachedTokens = cached;
911
916
  }
912
- return void 0;
917
+ if (reasoning !== void 0) {
918
+ result.reasoningTokens = reasoning;
919
+ }
920
+ return result;
913
921
  }
914
922
  function completionChoices(completion) {
915
923
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
@@ -935,7 +943,7 @@ function processCompletionSseBlock(block, enqueue, markTerminal) {
935
943
  enqueue("[DONE]");
936
944
  return;
937
945
  }
938
- const parsed = parseJson(data);
946
+ const parsed = parseJsonObject(data);
939
947
  if (!parsed) {
940
948
  return;
941
949
  }
@@ -999,19 +1007,6 @@ function encodeDataSse(data) {
999
1007
 
1000
1008
  `;
1001
1009
  }
1002
- function parseJson(data) {
1003
- try {
1004
- return asRecord(JSON.parse(data));
1005
- } catch {
1006
- return void 0;
1007
- }
1008
- }
1009
- function removeUndefined(record) {
1010
- return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
1011
- }
1012
- function randomId() {
1013
- return crypto.randomUUID().replaceAll("-", "");
1014
- }
1015
1010
  function epochSeconds() {
1016
1011
  return Math.floor(Date.now() / 1e3);
1017
1012
  }
@@ -1024,13 +1019,13 @@ var AnthropicCompatibilityError = class extends Error {
1024
1019
  }
1025
1020
  };
1026
1021
  function anthropicMessagesToResponsesRequest(request) {
1027
- return removeUndefined2({
1022
+ return removeUndefined({
1028
1023
  input: anthropicMessagesToResponsesInput(request.messages),
1029
1024
  instructions: anthropicSystemToInstructions(request.system),
1030
1025
  max_output_tokens: typeof request.max_tokens === "number" && Number.isFinite(request.max_tokens) ? request.max_tokens : void 0,
1031
1026
  metadata: request.metadata,
1032
1027
  model: normalizeRequestedModel(request.model),
1033
- parallel_tool_calls: true,
1028
+ parallel_tool_calls: asRecord(request.tool_choice).disable_parallel_tool_use !== true,
1034
1029
  reasoning: anthropicThinkingToReasoning(request.thinking),
1035
1030
  stop: anthropicStopSequences(request.stop_sequences),
1036
1031
  stream: request.stream === true,
@@ -1045,7 +1040,7 @@ function responsesResponseToAnthropicMessage(response, fallbackModel) {
1045
1040
  const usage = anthropicUsage(response.usage);
1046
1041
  return {
1047
1042
  content,
1048
- id: textValue(response.id) || `msg_${randomId2()}`,
1043
+ id: textValue(response.id) || `msg_${randomId()}`,
1049
1044
  model: textValue(response.model) || fallbackModel,
1050
1045
  role: "assistant",
1051
1046
  stop_reason: anthropicStopReason(response, content),
@@ -1122,7 +1117,7 @@ function createAnthropicStreamState(options) {
1122
1117
  return {
1123
1118
  blocks: /* @__PURE__ */ new Map(),
1124
1119
  completed: false,
1125
- messageId: options.messageId ?? `msg_${randomId2()}`,
1120
+ messageId: options.messageId ?? `msg_${randomId()}`,
1126
1121
  model: options.model,
1127
1122
  nextBlockIndex: 0,
1128
1123
  sawToolUse: false,
@@ -1176,7 +1171,7 @@ function anthropicMessagesToResponsesInput(messages) {
1176
1171
  flushMessage();
1177
1172
  input.push({
1178
1173
  arguments: JSON.stringify(asRecord(part.input)),
1179
- call_id: textValue(part.id) || `call_${randomId2()}`,
1174
+ call_id: textValue(part.id) || `call_${randomId()}`,
1180
1175
  name: textValue(part.name),
1181
1176
  type: "function_call"
1182
1177
  });
@@ -1287,7 +1282,7 @@ function anthropicTools(tools) {
1287
1282
  }
1288
1283
  const converted = tools.map((tool) => {
1289
1284
  const record = asRecord(tool);
1290
- return removeUndefined2({
1285
+ return removeUndefined({
1291
1286
  description: record.description,
1292
1287
  name: record.name,
1293
1288
  parameters: record.input_schema,
@@ -1358,7 +1353,7 @@ function anthropicContentFromResponsesOutput(response) {
1358
1353
  }
1359
1354
  if (type === "function_call") {
1360
1355
  content.push({
1361
- id: textValue(record.call_id) || textValue(record.id) || `call_${randomId2()}`,
1356
+ id: textValue(record.call_id) || textValue(record.id) || `call_${randomId()}`,
1362
1357
  input: parseToolInput(textValue(record.arguments)),
1363
1358
  name: textValue(record.name),
1364
1359
  type: "tool_use"
@@ -1385,12 +1380,12 @@ function anthropicStopReason(response, content) {
1385
1380
  }
1386
1381
  function anthropicUsage(usage) {
1387
1382
  const record = asRecord(usage);
1388
- const inputTokens = firstNumber2(record.input_tokens, record.prompt_tokens) ?? 0;
1389
- const outputTokens = firstNumber2(record.output_tokens, record.completion_tokens) ?? 0;
1383
+ const inputTokens = firstNumber(record.input_tokens, record.prompt_tokens) ?? 0;
1384
+ const outputTokens = firstNumber(record.output_tokens, record.completion_tokens) ?? 0;
1390
1385
  const details = asRecord(record.input_tokens_details);
1391
- return removeUndefined2({
1392
- cache_creation_input_tokens: firstNumber2(record.cache_creation_input_tokens),
1393
- cache_read_input_tokens: firstNumber2(record.cache_read_input_tokens, details.cached_tokens) ?? void 0,
1386
+ return removeUndefined({
1387
+ cache_creation_input_tokens: firstNumber(record.cache_creation_input_tokens),
1388
+ cache_read_input_tokens: firstNumber(record.cache_read_input_tokens, details.cached_tokens) ?? void 0,
1394
1389
  input_tokens: inputTokens,
1395
1390
  output_tokens: outputTokens
1396
1391
  });
@@ -1572,7 +1567,7 @@ function ensureToolBlock(state, payload, item, enqueue) {
1572
1567
  state.blocks.set(key, block);
1573
1568
  enqueue("content_block_start", {
1574
1569
  content_block: {
1575
- id: textValue(item.call_id) || textValue(item.id) || `call_${randomId2()}`,
1570
+ id: textValue(item.call_id) || textValue(item.id) || `call_${randomId()}`,
1576
1571
  input: {},
1577
1572
  name: textValue(item.name),
1578
1573
  type: "tool_use"
@@ -1606,13 +1601,6 @@ function parseSseBlock(block) {
1606
1601
  }
1607
1602
  return { data: data.join("\n"), event };
1608
1603
  }
1609
- function parseJsonObject(text) {
1610
- try {
1611
- return asRecord(JSON.parse(text));
1612
- } catch {
1613
- return void 0;
1614
- }
1615
- }
1616
1604
  function parseToolInput(argumentsText) {
1617
1605
  const parsed = parseJsonObject(argumentsText);
1618
1606
  return parsed ?? {};
@@ -1644,29 +1632,15 @@ function textValue(value) {
1644
1632
  }
1645
1633
  return "";
1646
1634
  }
1647
- function firstNumber2(...values) {
1648
- for (const value of values) {
1649
- if (typeof value === "number" && Number.isFinite(value)) {
1650
- return value;
1651
- }
1652
- }
1653
- return void 0;
1654
- }
1655
1635
  function indexValue(value) {
1656
1636
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
1657
1637
  }
1658
- function removeUndefined2(record) {
1659
- return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
1660
- }
1661
1638
  function encodeSse(event, data) {
1662
1639
  return `event: ${event}
1663
1640
  data: ${JSON.stringify(data)}
1664
1641
 
1665
1642
  `;
1666
1643
  }
1667
- function randomId2() {
1668
- return crypto.randomUUID().replaceAll("-", "");
1669
- }
1670
1644
 
1671
1645
  // src/dashboard.ts
1672
1646
  var DASHBOARD_HTML = `<!doctype html>
@@ -2068,21 +2042,24 @@ footer.foot .end { margin-left:auto; }
2068
2042
  function mk(tag, cls, txt){ var e = document.createElement(tag); if (cls) e.className = cls; if (txt !== undefined && txt !== null) e.textContent = txt; return e; }
2069
2043
 
2070
2044
  // Set numeric text and flash on discrete change.
2071
- function setNum(id, value, kind){
2045
+ function setNum(id, value, kind, num){
2072
2046
  var el = byId(id); if (!el) return;
2073
2047
  el.classList.remove("skel");
2074
2048
  var s = String(value);
2075
2049
  if (el.textContent !== s){
2076
2050
  el.textContent = s;
2051
+ // Compare on the raw number (num) when provided, so directional flash works
2052
+ // even when value is a pre-formatted display string.
2053
+ var n = (num !== undefined) ? num : value;
2077
2054
  var prev = lastRender[id];
2078
2055
  if (prev !== undefined){
2079
2056
  var cls = "flash";
2080
- if (kind === "delta" && typeof value === "number" && typeof prev === "number"){
2081
- cls = value > prev ? "flash-up" : (value < prev ? "flash-down" : null);
2057
+ if (kind === "delta" && typeof n === "number" && typeof prev === "number"){
2058
+ cls = n > prev ? "flash-up" : (n < prev ? "flash-down" : null);
2082
2059
  }
2083
2060
  if (cls){ el.classList.remove("flash","flash-up","flash-down"); void el.offsetWidth; el.classList.add(cls); }
2084
2061
  }
2085
- lastRender[id] = value;
2062
+ lastRender[id] = n;
2086
2063
  }
2087
2064
  }
2088
2065
  function setText(id, s){ var el = byId(id); if (el){ el.classList.remove("skel"); el.textContent = s; } }
@@ -2163,7 +2140,7 @@ footer.foot .end { margin-left:auto; }
2163
2140
  if (beat && dot){ dot.classList.remove("heartbeat"); void dot.offsetWidth; dot.classList.add("heartbeat"); }
2164
2141
  }
2165
2142
  function showBanner(text, ok){
2166
- var b = byId("banner"); b.textContent = text; b.className = "banner show" + (ok ? " ok" : ""); b.classList.add("show");
2143
+ var b = byId("banner"); b.textContent = text; b.className = "show" + (ok ? " ok" : "");
2167
2144
  if (ok){ setTimeout(function(){ b.classList.remove("show"); }, 2000); }
2168
2145
  }
2169
2146
  function hideBanner(){ byId("banner").classList.remove("show"); }
@@ -2270,7 +2247,7 @@ footer.foot .end { margin-left:auto; }
2270
2247
  if (isFinite(reqPerSec)){ pushHist(hist.req, reqPerSec); setNum("req-num", rate(reqPerSec)); } else setText("req-num","\\u2014");
2271
2248
  if (isFinite(tokPerSec)){ pushHist(hist.tok, tokPerSec); setNum("tok-num", humanInt(tokPerSec)); } else setText("tok-num","\\u2014");
2272
2249
  var inflight = proxy.inFlight || 0;
2273
- pushHist(hist.inflight, inflight); setNum("inflight-num", String(inflight), "delta");
2250
+ pushHist(hist.inflight, inflight); setNum("inflight-num", String(inflight), "delta", inflight);
2274
2251
  byId("v-inflight").classList.toggle("active", inflight > 0);
2275
2252
  setText("uptime-num", fmtUptime(proxy.uptimeSeconds || 0));
2276
2253
 
@@ -2413,7 +2390,7 @@ footer.foot .end { margin-left:auto; }
2413
2390
 
2414
2391
  function renderUpstream(up, delta, restarted){
2415
2392
  setNum("up-total", humanInt(up.total||0));
2416
- setNum("up-errors", humanInt(up.errors||0), "delta");
2393
+ setNum("up-errors", humanInt(up.errors||0), "delta", up.errors||0);
2417
2394
  var er = up.total ? (up.errors/up.total*100) : 0;
2418
2395
  var rt = byId("up-rate"); rt.textContent = pct(er); rt.className = "v rate " + (er > 5 ? "danger" : er > 1 ? "warn" : "ok");
2419
2396
  byId("up-errblk").classList.toggle("hot", (up.errors||0) > 0);
@@ -2558,26 +2535,23 @@ var MetricsRegistry = class {
2558
2535
  const resource = this.#rateLimitResource(rateLimit.resource);
2559
2536
  this.#githubRateLimit.set(resource, { ...rateLimit, resource });
2560
2537
  }
2561
- // Sanitize the model into a bounded label. The model can originate from a
2562
- // client request, so cap its length, strip characters that would corrupt the
2563
- // exposition format, and fold overflow past the cardinality limit into
2564
- // UNKNOWN_MODEL to keep the series count bounded.
2565
- #modelLabel(model) {
2566
- const cleaned = cleanLabel(model).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2567
- if (!this.#tokens.has(cleaned) && this.#tokens.size >= MAX_TRACKED_MODELS) {
2538
+ // Clean a raw value into a bounded exposition-format label: cap its length,
2539
+ // strip characters that would corrupt the format, and fold overflow past the
2540
+ // cardinality limit into UNKNOWN_MODEL so the series count stays bounded.
2541
+ #boundedLabel(value, tracked, maxEntries) {
2542
+ const cleaned = cleanLabel(value).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2543
+ if (!tracked.has(cleaned) && tracked.size >= maxEntries) {
2568
2544
  return UNKNOWN_MODEL;
2569
2545
  }
2570
2546
  return cleaned;
2571
2547
  }
2572
- // The resource comes from a trusted upstream header, but clean and bound it
2573
- // with the same discipline as model labels: strip control characters that
2574
- // would corrupt the exposition format and fold overflow into "unknown".
2548
+ // The model can originate from a (possibly hostile) client request.
2549
+ #modelLabel(model) {
2550
+ return this.#boundedLabel(model, this.#tokens, MAX_TRACKED_MODELS);
2551
+ }
2552
+ // The resource comes from a trusted upstream header, but is bounded the same way.
2575
2553
  #rateLimitResource(resource) {
2576
- const cleaned = cleanLabel(resource).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2577
- if (!this.#githubRateLimit.has(cleaned) && this.#githubRateLimit.size >= MAX_TRACKED_RATELIMIT_RESOURCES) {
2578
- return UNKNOWN_MODEL;
2579
- }
2580
- return cleaned;
2554
+ return this.#boundedLabel(resource, this.#githubRateLimit, MAX_TRACKED_RATELIMIT_RESOURCES);
2581
2555
  }
2582
2556
  #observeDuration(route, seconds) {
2583
2557
  const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
@@ -2903,7 +2877,7 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome)
2903
2877
  considerSseLine(line, accumulator.consider);
2904
2878
  }
2905
2879
  } else {
2906
- const parsed = safeParse(text);
2880
+ const parsed = safeJsonParse(text);
2907
2881
  if (parsed !== void 0) {
2908
2882
  accumulator.consider(parsed);
2909
2883
  }
@@ -2965,7 +2939,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
2965
2939
  considerSseLine(finalBuffer, accumulator.consider);
2966
2940
  }
2967
2941
  } else if (!overflowed && finalBuffer) {
2968
- const parsed = safeParse(finalBuffer);
2942
+ const parsed = safeJsonParse(finalBuffer);
2969
2943
  if (parsed !== void 0) {
2970
2944
  accumulator.consider(parsed);
2971
2945
  }
@@ -3008,18 +2982,11 @@ function considerSseLine(line, consider) {
3008
2982
  if (!data || data === "[DONE]") {
3009
2983
  return;
3010
2984
  }
3011
- const parsed = safeParse(data);
2985
+ const parsed = safeJsonParse(data);
3012
2986
  if (parsed !== void 0) {
3013
2987
  consider(parsed);
3014
2988
  }
3015
2989
  }
3016
- function safeParse(text) {
3017
- try {
3018
- return JSON.parse(text);
3019
- } catch {
3020
- return void 0;
3021
- }
3022
- }
3023
2990
  function modelText(value) {
3024
2991
  return typeof value === "string" ? value.trim() : "";
3025
2992
  }
@@ -3109,8 +3076,9 @@ async function getVersion() {
3109
3076
  resolved = BAKED_VERSION;
3110
3077
  } else {
3111
3078
  try {
3112
- const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json();
3113
- resolved = typeof manifest.version === "string" ? manifest.version : "0.0.0";
3079
+ const manifest = asRecord(await Bun.file(new URL("../package.json", import.meta.url)).json());
3080
+ const version = manifest.version;
3081
+ resolved = typeof version === "string" ? version : "0.0.0";
3114
3082
  } catch {
3115
3083
  resolved = "0.0.0";
3116
3084
  }
@@ -3136,6 +3104,18 @@ var RequestBodyTooLargeError = class extends Error {
3136
3104
  this.name = "RequestBodyTooLargeError";
3137
3105
  }
3138
3106
  };
3107
+ var InvalidJsonError = class extends Error {
3108
+ constructor() {
3109
+ super(INVALID_JSON_MESSAGE);
3110
+ this.name = "InvalidJsonError";
3111
+ }
3112
+ };
3113
+ var JsonNotObjectError = class extends Error {
3114
+ constructor() {
3115
+ super(JSON_OBJECT_MESSAGE);
3116
+ this.name = "JsonNotObjectError";
3117
+ }
3118
+ };
3139
3119
  function createHoopilotHandler(options = {}) {
3140
3120
  const client = new CopilotClient(options);
3141
3121
  const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
@@ -3145,8 +3125,19 @@ function createHoopilotHandler(options = {}) {
3145
3125
  const readUsage = createUsageReader(client, metrics);
3146
3126
  const recordTokens = (model, usage) => metrics.recordTokens(model, usage);
3147
3127
  const recordExtraction = (extracted) => metrics.recordTokenExtraction(extracted);
3148
- const streamingProxyMode = resolveStreamingProxyMode(options);
3149
- const bufferProxyBodies = shouldBufferProxyBodies(streamingProxyMode);
3128
+ const bufferProxyBodies = shouldBufferProxyBodies(resolveStreamingProxyMode(options));
3129
+ const requestContext = /* @__PURE__ */ new WeakMap();
3130
+ const app = buildApp({
3131
+ apiKey,
3132
+ allowedOrigins,
3133
+ bufferProxyBodies,
3134
+ client,
3135
+ metrics,
3136
+ readUsage,
3137
+ recordExtraction,
3138
+ recordTokens,
3139
+ requestContext
3140
+ });
3150
3141
  return async (request) => {
3151
3142
  const startedAt = performance.now();
3152
3143
  const url = new URL(request.url);
@@ -3162,7 +3153,24 @@ function createHoopilotHandler(options = {}) {
3162
3153
  metrics.startRequest();
3163
3154
  const origin = request.headers.get("origin")?.trim() || void 0;
3164
3155
  const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
3165
- const finish = (response) => finishResponse(response, {
3156
+ const inner = normalizeInnerRequest(request, apiPath, url);
3157
+ requestContext.set(inner, {
3158
+ apiPath,
3159
+ logger: requestLogger,
3160
+ origin,
3161
+ originalPath: url.pathname
3162
+ });
3163
+ let response;
3164
+ try {
3165
+ response = await app.handle(inner);
3166
+ } catch (error) {
3167
+ requestLogger.error(
3168
+ { err: errorDetails(error), event: "http.request.failed" },
3169
+ "request failed"
3170
+ );
3171
+ response = jsonError(500, "internal_error", errorMessage(error));
3172
+ }
3173
+ return finishResponse(response, {
3166
3174
  corsOrigin,
3167
3175
  logger: requestLogger,
3168
3176
  method: request.method,
@@ -3173,144 +3181,175 @@ function createHoopilotHandler(options = {}) {
3173
3181
  closeConnection: bufferProxyBodies,
3174
3182
  trackStreamingBody: !bufferProxyBodies
3175
3183
  });
3184
+ };
3185
+ }
3186
+ function buildApp(deps) {
3187
+ const {
3188
+ apiKey,
3189
+ allowedOrigins,
3190
+ bufferProxyBodies,
3191
+ client,
3192
+ metrics,
3193
+ readUsage,
3194
+ recordExtraction,
3195
+ recordTokens,
3196
+ requestContext
3197
+ } = deps;
3198
+ const contextFor = (request) => {
3199
+ const stored = requestContext.get(request);
3200
+ if (stored) {
3201
+ return stored;
3202
+ }
3203
+ const originalPath = new URL(request.url).pathname;
3204
+ return {
3205
+ apiPath: canonicalApiPath(originalPath),
3206
+ logger: noopLogger,
3207
+ origin: request.headers.get("origin")?.trim() || void 0,
3208
+ originalPath
3209
+ };
3210
+ };
3211
+ const loggerFor = (request) => contextFor(request).logger;
3212
+ const noBody = { parse: "none" };
3213
+ return new Elysia().onRequest(({ request }) => {
3214
+ const { apiPath, logger, origin } = contextFor(request);
3176
3215
  const browserOrigin = forbiddenBrowserOrigin(origin, request, allowedOrigins);
3177
3216
  if (browserOrigin) {
3178
- requestLogger.warn(
3217
+ logger.warn(
3179
3218
  { event: "http.request.forbidden_origin", origin: browserOrigin },
3180
3219
  "blocked cross-origin browser request"
3181
3220
  );
3182
- return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
3221
+ return jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE);
3183
3222
  }
3184
3223
  if (request.method === "OPTIONS") {
3185
- return finish(new Response(null, { headers: corsHeaders() }));
3224
+ return new Response(null, { headers: corsHeaders() });
3186
3225
  }
3187
3226
  if (request.method === "GET" && apiPath === "/dashboard") {
3188
- return finish(dashboardResponse());
3227
+ return dashboardResponse();
3189
3228
  }
3190
3229
  if (!isAuthorized(request, apiKey)) {
3191
- requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
3192
- return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
3230
+ logger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
3231
+ return jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key.");
3232
+ }
3233
+ }).onError(({ code, error, request }) => {
3234
+ const { logger, originalPath } = contextFor(request);
3235
+ if (code === "NOT_FOUND") {
3236
+ return jsonError(404, "not_found", `No route for ${request.method} ${originalPath}.`);
3237
+ }
3238
+ if (error instanceof CopilotAuthError) {
3239
+ logger.warn(
3240
+ { err: errorDetails(error), event: "copilot.auth.missing" },
3241
+ "copilot auth failed"
3242
+ );
3243
+ return jsonError(401, "copilot_auth_error", error.message);
3193
3244
  }
3194
- try {
3195
- if (request.method === "GET" && (apiPath === "/" || apiPath === "/healthz")) {
3196
- return finish(jsonResponse({ name: "hoopilot", object: "health", status: "ok" }));
3197
- }
3198
- if (request.method === "GET" && apiPath === "/metrics") {
3199
- return finish(metricsResponse(metrics));
3200
- }
3201
- if (request.method === "GET" && apiPath === "/v1/usage") {
3202
- return finish(await handleUsage(metrics, readUsage, request.signal));
3203
- }
3204
- if (request.method === "GET" && apiPath === "/v1/responses") {
3205
- return finish(websocketUnsupportedResponse());
3206
- }
3207
- if (request.method === "GET" && apiPath === "/v1/models") {
3208
- return finish(await handleModels(client, metrics, request.signal, requestLogger));
3209
- }
3210
- if (request.method === "POST" && apiPath === "/v1/messages") {
3211
- return finish(
3212
- await handleAnthropicMessages(
3213
- client,
3214
- metrics,
3215
- recordTokens,
3216
- recordExtraction,
3217
- request,
3218
- requestLogger,
3219
- bufferProxyBodies
3220
- )
3221
- );
3222
- }
3223
- if (request.method === "POST" && apiPath === "/v1/messages/count_tokens") {
3224
- return finish(handleAnthropicCountTokens(await readJson(request)));
3225
- }
3226
- if (request.method === "POST" && apiPath === "/v1/chat/completions") {
3227
- return finish(
3228
- await handleChatCompletions(
3229
- client,
3230
- metrics,
3231
- recordTokens,
3232
- recordExtraction,
3233
- request,
3234
- requestLogger,
3235
- bufferProxyBodies
3236
- )
3237
- );
3238
- }
3239
- if (request.method === "POST" && apiPath === "/v1/completions") {
3240
- return finish(
3241
- await handleCompletions(
3242
- client,
3243
- metrics,
3244
- recordTokens,
3245
- recordExtraction,
3246
- request,
3247
- requestLogger,
3248
- bufferProxyBodies
3249
- )
3250
- );
3251
- }
3252
- if (request.method === "POST" && apiPath === "/v1/responses/compact") {
3253
- return finish(
3254
- await handleResponsesCompact(
3255
- client,
3256
- metrics,
3257
- recordTokens,
3258
- recordExtraction,
3259
- request,
3260
- requestLogger
3261
- )
3262
- );
3263
- }
3264
- if (request.method === "POST" && apiPath === "/v1/responses") {
3265
- return finish(
3266
- await handleResponses(
3267
- client,
3268
- metrics,
3269
- recordTokens,
3270
- recordExtraction,
3271
- request,
3272
- requestLogger,
3273
- bufferProxyBodies
3274
- )
3275
- );
3276
- }
3277
- return finish(jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`));
3278
- } catch (error) {
3279
- if (error instanceof CopilotAuthError) {
3280
- requestLogger.warn(
3281
- { err: errorDetails(error), event: "copilot.auth.missing" },
3282
- "copilot auth failed"
3283
- );
3284
- return finish(jsonError(401, "copilot_auth_error", error.message));
3285
- }
3286
- const message = errorMessage(error);
3287
- if (message === INVALID_JSON_MESSAGE || message === JSON_OBJECT_MESSAGE) {
3288
- requestLogger.warn(
3289
- { err: errorDetails(error), event: "http.request.failed" },
3290
- "request body was not usable json"
3291
- );
3292
- return finish(jsonError(400, "invalid_request_error", message));
3293
- } else if (error instanceof OpenAICompatibilityError || error instanceof AnthropicCompatibilityError) {
3294
- requestLogger.warn(
3295
- { err: errorDetails(error), event: "http.request.failed" },
3296
- "request body used unsupported compatibility fields"
3297
- );
3298
- return finish(jsonError(400, "invalid_request_error", message));
3299
- } else if (error instanceof RequestBodyTooLargeError) {
3300
- requestLogger.warn(
3301
- { err: errorDetails(error), event: "http.request.failed" },
3302
- "request body exceeded size limit"
3303
- );
3304
- return finish(jsonError(413, "request_too_large", message));
3305
- } else {
3306
- requestLogger.error(
3307
- { err: errorDetails(error), event: "http.request.failed" },
3308
- "request failed"
3309
- );
3310
- }
3311
- return finish(jsonError(500, "internal_error", message));
3245
+ const message = errorMessage(error);
3246
+ if (error instanceof InvalidJsonError || error instanceof JsonNotObjectError) {
3247
+ logger.warn(
3248
+ { err: errorDetails(error), event: "http.request.failed" },
3249
+ "request body was not usable json"
3250
+ );
3251
+ return jsonError(400, "invalid_request_error", message);
3252
+ }
3253
+ if (error instanceof OpenAICompatibilityError || error instanceof AnthropicCompatibilityError) {
3254
+ logger.warn(
3255
+ { err: errorDetails(error), event: "http.request.failed" },
3256
+ "request body used unsupported compatibility fields"
3257
+ );
3258
+ return jsonError(400, "invalid_request_error", message);
3312
3259
  }
3260
+ if (error instanceof RequestBodyTooLargeError) {
3261
+ logger.warn(
3262
+ { err: errorDetails(error), event: "http.request.failed" },
3263
+ "request body exceeded size limit"
3264
+ );
3265
+ return jsonError(413, "request_too_large", message);
3266
+ }
3267
+ logger.error({ err: errorDetails(error), event: "http.request.failed" }, "request failed");
3268
+ return jsonError(500, "internal_error", message);
3269
+ }).get("/", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/healthz", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/metrics", () => metricsResponse(metrics)).get("/v1/usage", ({ request }) => handleUsage(metrics, readUsage, request.signal)).get(
3270
+ "/v1/models",
3271
+ ({ request }) => handleModels(client, metrics, request.signal, loggerFor(request))
3272
+ ).get("/v1/responses", () => websocketUnsupportedResponse()).post(
3273
+ "/v1/messages",
3274
+ ({ request }) => handleAnthropicMessages(
3275
+ client,
3276
+ metrics,
3277
+ recordTokens,
3278
+ recordExtraction,
3279
+ request,
3280
+ loggerFor(request),
3281
+ bufferProxyBodies
3282
+ ),
3283
+ noBody
3284
+ ).post(
3285
+ "/v1/messages/count_tokens",
3286
+ ({ request }) => handleAnthropicCountTokens(request),
3287
+ noBody
3288
+ ).post(
3289
+ "/v1/chat/completions",
3290
+ ({ request }) => handleChatCompletions(
3291
+ client,
3292
+ metrics,
3293
+ recordTokens,
3294
+ recordExtraction,
3295
+ request,
3296
+ loggerFor(request),
3297
+ bufferProxyBodies
3298
+ ),
3299
+ noBody
3300
+ ).post(
3301
+ "/v1/completions",
3302
+ ({ request }) => handleCompletions(
3303
+ client,
3304
+ metrics,
3305
+ recordTokens,
3306
+ recordExtraction,
3307
+ request,
3308
+ loggerFor(request),
3309
+ bufferProxyBodies
3310
+ ),
3311
+ noBody
3312
+ ).post(
3313
+ "/v1/responses/compact",
3314
+ ({ request }) => handleResponsesCompact(
3315
+ client,
3316
+ metrics,
3317
+ recordTokens,
3318
+ recordExtraction,
3319
+ request,
3320
+ loggerFor(request)
3321
+ ),
3322
+ noBody
3323
+ ).post(
3324
+ "/v1/responses",
3325
+ ({ request }) => handleResponses(
3326
+ client,
3327
+ metrics,
3328
+ recordTokens,
3329
+ recordExtraction,
3330
+ request,
3331
+ loggerFor(request),
3332
+ bufferProxyBodies
3333
+ ),
3334
+ noBody
3335
+ );
3336
+ }
3337
+ function normalizeInnerRequest(request, canonicalPath, url) {
3338
+ if (canonicalPath === url.pathname) {
3339
+ return request;
3340
+ }
3341
+ const target = new URL(url);
3342
+ target.pathname = canonicalPath;
3343
+ const init = {
3344
+ headers: request.headers,
3345
+ method: request.method,
3346
+ signal: request.signal
3313
3347
  };
3348
+ if (request.body) {
3349
+ init.body = request.body;
3350
+ init.duplex = "half";
3351
+ }
3352
+ return new Request(target, init);
3314
3353
  }
3315
3354
  function startHoopilotServer(options = {}) {
3316
3355
  const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
@@ -3389,7 +3428,8 @@ async function handleAnthropicMessages(client, metrics, recordTokens, recordExtr
3389
3428
  recordExtraction(usage !== void 0);
3390
3429
  return jsonResponse(responsesResponseToAnthropicMessage(body, model));
3391
3430
  }
3392
- function handleAnthropicCountTokens(body) {
3431
+ async function handleAnthropicCountTokens(request) {
3432
+ const body = await readJson(request);
3393
3433
  return jsonResponse(estimateAnthropicMessageTokens(body));
3394
3434
  }
3395
3435
  async function handleModels(client, metrics, signal, logger) {
@@ -3475,14 +3515,14 @@ async function handleCompletions(client, metrics, recordTokens, recordExtraction
3475
3515
  return jsonResponse(chatCompletionToCompletion(completion));
3476
3516
  }
3477
3517
  async function handleResponses(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
3478
- const body = await readJsonText(request);
3518
+ const { json, text: body } = await readJsonText(request);
3479
3519
  const upstream = await client.responses(body, request.signal);
3480
3520
  metrics.recordUpstream("/responses", upstream.ok);
3481
3521
  if (!upstream.ok) {
3482
3522
  return proxyError(upstream, logger);
3483
3523
  }
3484
3524
  logUpstreamSuccess(logger, "/responses", upstream.status);
3485
- const model = normalizeRequestedModel(asRecord(safeParseJson(body)).model);
3525
+ const model = normalizeRequestedModel(json.model);
3486
3526
  return proxyResponse(
3487
3527
  await responseWithObservedUsage(
3488
3528
  upstream,
@@ -3570,17 +3610,16 @@ function parseJsonObject2(text) {
3570
3610
  try {
3571
3611
  parsed = JSON.parse(text);
3572
3612
  } catch {
3573
- throw new Error(INVALID_JSON_MESSAGE);
3613
+ throw new InvalidJsonError();
3574
3614
  }
3575
3615
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3576
- throw new Error(JSON_OBJECT_MESSAGE);
3616
+ throw new JsonNotObjectError();
3577
3617
  }
3578
3618
  return parsed;
3579
3619
  }
3580
3620
  async function readJsonText(request) {
3581
3621
  const text = await readRequestText(request);
3582
- parseJsonObject2(text);
3583
- return text;
3622
+ return { json: parseJsonObject2(text), text };
3584
3623
  }
3585
3624
  async function readRequestText(request) {
3586
3625
  const contentLength = request.headers.get("content-length");
@@ -3638,7 +3677,7 @@ function jsonError(status, code, message) {
3638
3677
  );
3639
3678
  }
3640
3679
  function upstreamErrorResponse(status, text) {
3641
- const parsedError = asRecord(asRecord(safeParseJson(text)).error);
3680
+ const parsedError = asRecord(asRecord(safeJsonParse(text)).error);
3642
3681
  if (Object.keys(parsedError).length > 0) {
3643
3682
  return jsonResponse({ error: parsedError }, status);
3644
3683
  }
@@ -3660,13 +3699,18 @@ function corsHeaders() {
3660
3699
  "access-control-expose-headers": "x-request-id"
3661
3700
  };
3662
3701
  }
3702
+ function secretEquals(candidate, secret) {
3703
+ const a = createHash("sha256").update(candidate).digest();
3704
+ const b = createHash("sha256").update(secret).digest();
3705
+ return timingSafeEqual(a, b);
3706
+ }
3663
3707
  function isAuthorized(request, apiKey) {
3664
3708
  if (!apiKey) {
3665
3709
  return true;
3666
3710
  }
3667
3711
  const authorization = request.headers.get("authorization") ?? "";
3668
3712
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
3669
- return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
3713
+ return bearer !== void 0 && secretEquals(bearer, apiKey) || secretEquals(request.headers.get("x-api-key") ?? "", apiKey);
3670
3714
  }
3671
3715
  function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
3672
3716
  if (origin) {
@@ -3703,7 +3747,7 @@ function upstreamAuthMessage(message) {
3703
3747
  return `GitHub Copilot rejected the credential or account access: ${message}`;
3704
3748
  }
3705
3749
  function isLoopbackHost(host) {
3706
- return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
3750
+ return isLoopbackHostname(host);
3707
3751
  }
3708
3752
  function urlHost(host) {
3709
3753
  return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
@@ -3722,9 +3766,6 @@ function normalizeServerPort(value) {
3722
3766
  }
3723
3767
  return port;
3724
3768
  }
3725
- function errorMessage(error) {
3726
- return error instanceof Error ? error.message : String(error);
3727
- }
3728
3769
  function serverLogger(options) {
3729
3770
  if (options.logger) {
3730
3771
  return options.logger.child({ component: "server" });
@@ -3740,10 +3781,7 @@ function serverLogger(options) {
3740
3781
  }
3741
3782
  function resolveStreamingProxyMode(options) {
3742
3783
  const value = options.streamingProxyMode ?? envValue(options.env?.HOOPILOT_STREAM_MODE) ?? envValue(options.env?.HOOPILOT_STREAMING_PROXY_MODE) ?? "auto";
3743
- if (value === "auto" || value === "buffer" || value === "live") {
3744
- return value;
3745
- }
3746
- throw new Error(`Invalid stream mode: ${value}. Expected auto, live, or buffer.`);
3784
+ return parseStreamingProxyMode(value);
3747
3785
  }
3748
3786
  function shouldBufferProxyBodies(mode) {
3749
3787
  if (mode === "buffer") {
@@ -3801,11 +3839,13 @@ function responseWithRequestId(response, requestId, closeConnection, corsOrigin)
3801
3839
  function trackStreamCompletion(body, onComplete) {
3802
3840
  const reader = body.getReader();
3803
3841
  let fired = false;
3804
- const fire = () => {
3805
- if (!fired) {
3806
- fired = true;
3807
- onComplete();
3842
+ const release = () => {
3843
+ if (fired) {
3844
+ return;
3808
3845
  }
3846
+ fired = true;
3847
+ onComplete();
3848
+ reader.releaseLock();
3809
3849
  };
3810
3850
  return new ReadableStream({
3811
3851
  async pull(controller) {
@@ -3813,18 +3853,25 @@ function trackStreamCompletion(body, onComplete) {
3813
3853
  const { done, value } = await reader.read();
3814
3854
  if (done) {
3815
3855
  controller.close();
3816
- fire();
3856
+ release();
3817
3857
  return;
3818
3858
  }
3819
3859
  controller.enqueue(value);
3820
3860
  } catch (error) {
3821
- fire();
3861
+ release();
3822
3862
  controller.error(error);
3823
3863
  }
3824
3864
  },
3825
- cancel(reason) {
3826
- fire();
3827
- return reader.cancel(reason);
3865
+ async cancel(reason) {
3866
+ if (!fired) {
3867
+ fired = true;
3868
+ onComplete();
3869
+ }
3870
+ try {
3871
+ await reader.cancel(reason);
3872
+ } finally {
3873
+ reader.releaseLock();
3874
+ }
3828
3875
  }
3829
3876
  });
3830
3877
  }
@@ -3872,47 +3919,26 @@ function canonicalApiPath(path) {
3872
3919
  return withoutTrailingSlash;
3873
3920
  }
3874
3921
  }
3922
+ var API_ROUTES = [
3923
+ { method: "GET", path: "/", name: "health" },
3924
+ { method: "GET", path: "/healthz", name: "health" },
3925
+ { method: "GET", path: "/dashboard", name: "dashboard" },
3926
+ { method: "GET", path: "/metrics", name: "metrics" },
3927
+ { method: "GET", path: "/v1/usage", name: "usage" },
3928
+ { method: "GET", path: "/v1/models", name: "models" },
3929
+ { method: "GET", path: "/v1/responses", name: "responses_websocket" },
3930
+ { method: "POST", path: "/v1/messages", name: "anthropic_messages" },
3931
+ { method: "POST", path: "/v1/messages/count_tokens", name: "anthropic_count_tokens" },
3932
+ { method: "POST", path: "/v1/chat/completions", name: "chat_completions" },
3933
+ { method: "POST", path: "/v1/completions", name: "completions" },
3934
+ { method: "POST", path: "/v1/responses/compact", name: "responses_compact" },
3935
+ { method: "POST", path: "/v1/responses", name: "responses" }
3936
+ ];
3875
3937
  function routeFor(method, path) {
3876
3938
  if (method === "OPTIONS") {
3877
3939
  return "cors.preflight";
3878
3940
  }
3879
- if (method === "GET" && (path === "/" || path === "/healthz")) {
3880
- return "health";
3881
- }
3882
- if (method === "GET" && path === "/dashboard") {
3883
- return "dashboard";
3884
- }
3885
- if (method === "GET" && path === "/metrics") {
3886
- return "metrics";
3887
- }
3888
- if (method === "GET" && path === "/v1/usage") {
3889
- return "usage";
3890
- }
3891
- if (method === "GET" && path === "/v1/models") {
3892
- return "models";
3893
- }
3894
- if (method === "POST" && path === "/v1/messages") {
3895
- return "anthropic_messages";
3896
- }
3897
- if (method === "POST" && path === "/v1/messages/count_tokens") {
3898
- return "anthropic_count_tokens";
3899
- }
3900
- if (method === "POST" && path === "/v1/chat/completions") {
3901
- return "chat_completions";
3902
- }
3903
- if (method === "POST" && path === "/v1/completions") {
3904
- return "completions";
3905
- }
3906
- if (method === "POST" && path === "/v1/responses/compact") {
3907
- return "responses_compact";
3908
- }
3909
- if (method === "POST" && path === "/v1/responses") {
3910
- return "responses";
3911
- }
3912
- if (method === "GET" && path === "/v1/responses") {
3913
- return "responses_websocket";
3914
- }
3915
- return "not_found";
3941
+ return API_ROUTES.find((entry) => entry.method === method && entry.path === path)?.name ?? "not_found";
3916
3942
  }
3917
3943
  function isStreamingResponse(response) {
3918
3944
  return response.headers.get("content-type")?.includes("text/event-stream") ?? false;
@@ -3990,17 +4016,10 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
3990
4016
  }
3991
4017
  };
3992
4018
  }
3993
- function safeParseJson(text) {
3994
- try {
3995
- return JSON.parse(text);
3996
- } catch {
3997
- return void 0;
3998
- }
3999
- }
4000
4019
 
4001
4020
  // src/update.ts
4002
4021
  import { execFileSync } from "child_process";
4003
- import { createHash } from "crypto";
4022
+ import { createHash as createHash2 } from "crypto";
4004
4023
  import {
4005
4024
  chmodSync as chmodSync2,
4006
4025
  copyFileSync,
@@ -4008,7 +4027,7 @@ import {
4008
4027
  mkdirSync as mkdirSync2,
4009
4028
  realpathSync,
4010
4029
  renameSync as renameSync2,
4011
- rmSync,
4030
+ rmSync as rmSync2,
4012
4031
  writeFileSync as writeFileSync2
4013
4032
  } from "fs";
4014
4033
  import { readFile, writeFile } from "fs/promises";
@@ -4182,10 +4201,11 @@ Run: ${upgradeCommandFor(kind)}
4182
4201
  function parseState(text) {
4183
4202
  try {
4184
4203
  const data = JSON.parse(text);
4204
+ const record = data && typeof data === "object" ? data : {};
4185
4205
  return {
4186
- lastCheck: typeof data.lastCheck === "number" ? data.lastCheck : 0,
4187
- latestVersion: typeof data.latestVersion === "string" ? data.latestVersion : null,
4188
- etag: typeof data.etag === "string" ? data.etag : null
4206
+ lastCheck: typeof record.lastCheck === "number" ? record.lastCheck : 0,
4207
+ latestVersion: typeof record.latestVersion === "string" ? record.latestVersion : null,
4208
+ etag: typeof record.etag === "string" ? record.etag : null
4189
4209
  };
4190
4210
  } catch {
4191
4211
  return { lastCheck: 0, latestVersion: null, etag: null };
@@ -4239,6 +4259,7 @@ function latestReleaseApiUrl() {
4239
4259
 
4240
4260
  // src/update.ts
4241
4261
  var REQUEST_TIMEOUT_MS2 = 8e3;
4262
+ var DOWNLOAD_TIMEOUT_MS = REQUEST_TIMEOUT_MS2 * 10;
4242
4263
  var SHA256SUMS = "SHA256SUMS";
4243
4264
  function userAgent(version) {
4244
4265
  return `hoopilot/${version}`;
@@ -4376,15 +4397,15 @@ async function downloadToFile(url, dest, version) {
4376
4397
  const response = await fetch(url, {
4377
4398
  headers: { "User-Agent": userAgent(version) },
4378
4399
  redirect: "follow",
4379
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2 * 10)
4400
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
4380
4401
  });
4381
4402
  if (!response.ok || !response.body) {
4382
4403
  throw new Error(`Download failed (${response.status}) for ${url}`);
4383
4404
  }
4384
- await writeFile(dest, new Uint8Array(await response.arrayBuffer()));
4405
+ await Bun.write(dest, response);
4385
4406
  }
4386
4407
  async function sha256File(path) {
4387
- return createHash("sha256").update(await readFile(path)).digest("hex");
4408
+ return createHash2("sha256").update(await readFile(path)).digest("hex");
4388
4409
  }
4389
4410
  async function verifyChecksum(release, assetName, file, version) {
4390
4411
  const sums = release.assets.find((asset) => asset.name === SHA256SUMS);
@@ -4396,7 +4417,7 @@ async function verifyChecksum(release, assetName, file, version) {
4396
4417
  const response = await fetch(sums.url, {
4397
4418
  headers: { "User-Agent": userAgent(version) },
4398
4419
  redirect: "follow",
4399
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
4420
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
4400
4421
  });
4401
4422
  if (!response.ok) {
4402
4423
  throw new Error(`Could not download ${SHA256SUMS} (${response.status}).`);
@@ -4414,7 +4435,7 @@ function swapBinary(tmpFile, exePath) {
4414
4435
  if (process.platform === "win32") {
4415
4436
  const oldExe = `${exePath}.old`;
4416
4437
  try {
4417
- rmSync(oldExe, { force: true });
4438
+ rmSync2(oldExe, { force: true });
4418
4439
  } catch {
4419
4440
  }
4420
4441
  renameSync2(exePath, oldExe);
@@ -4471,7 +4492,7 @@ function refreshCodexxShim(dir, logger) {
4471
4492
  { err: errorDetails(error), event: "update.codexx_shim_failed" },
4472
4493
  "could not refresh codexx shim"
4473
4494
  );
4474
- console.warn(`Updated hoopilot, but could not refresh the codexx shim: ${errorMessage2(error)}`);
4495
+ console.warn(`Updated hoopilot, but could not refresh the codexx shim: ${errorMessage(error)}`);
4475
4496
  }
4476
4497
  }
4477
4498
  function cleanupOldBinary() {
@@ -4479,7 +4500,7 @@ function cleanupOldBinary() {
4479
4500
  return;
4480
4501
  }
4481
4502
  try {
4482
- rmSync(`${realpathSync(process.execPath)}.old`, { force: true });
4503
+ rmSync2(`${realpathSync(process.execPath)}.old`, { force: true });
4483
4504
  } catch {
4484
4505
  }
4485
4506
  }
@@ -4543,7 +4564,7 @@ async function runUpdate(currentVersion, logger) {
4543
4564
  throw error;
4544
4565
  } finally {
4545
4566
  try {
4546
- rmSync(tmpFile, { force: true });
4567
+ rmSync2(tmpFile, { force: true });
4547
4568
  } catch {
4548
4569
  }
4549
4570
  }
@@ -4556,12 +4577,8 @@ async function runUpdate(currentVersion, logger) {
4556
4577
  console.log("Restart hoopilot to run the new version.");
4557
4578
  }
4558
4579
  }
4559
- function errorMessage2(error) {
4560
- return error instanceof Error ? error.message : String(error);
4561
- }
4562
4580
 
4563
4581
  // src/cli.ts
4564
- var ALLOWED_COPILOT_API_HOSTS2 = ["api.githubcopilot.com"];
4565
4582
  async function main2(argv = Bun.argv.slice(2)) {
4566
4583
  cleanupOldBinary();
4567
4584
  const command = argv[0];
@@ -4694,7 +4711,7 @@ function parseArgs(argv) {
4694
4711
  args.logLevel = parseLogLevel(optionValue(name, inlineValue, rest));
4695
4712
  break;
4696
4713
  case "--stream-mode":
4697
- args.streamingProxyMode = parseStreamMode(optionValue(name, inlineValue, rest));
4714
+ args.streamingProxyMode = parseStreamingProxyMode(optionValue(name, inlineValue, rest));
4698
4715
  break;
4699
4716
  case "--host":
4700
4717
  args.host = optionValue(name, inlineValue, rest);
@@ -4714,12 +4731,6 @@ function parseArgs(argv) {
4714
4731
  }
4715
4732
  return args;
4716
4733
  }
4717
- function parseStreamMode(value) {
4718
- if (value === "auto" || value === "buffer" || value === "live") {
4719
- return value;
4720
- }
4721
- throw new Error(`Invalid stream mode: ${value}. Expected auto, live, or buffer.`);
4722
- }
4723
4734
  function optionValue(name, inlineValue, rest) {
4724
4735
  const value = inlineValue ?? rest.shift();
4725
4736
  if (!value) {
@@ -4780,11 +4791,7 @@ async function runModels(options = {}) {
4780
4791
  logger.debug({ event: "models.list.started" }, "fetching github copilot models");
4781
4792
  const response = await new CopilotClient(options).models();
4782
4793
  if (!response.ok) {
4783
- const message = `GitHub Copilot API model list failed with ${response.status}: ${await truncatedResponseText(response)}`;
4784
- if (response.status === 401 || response.status === 403) {
4785
- throw new CopilotAuthError(message);
4786
- }
4787
- throw new Error(message);
4794
+ await throwForCopilotResponse(response, "GitHub Copilot API model list");
4788
4795
  }
4789
4796
  const ids = modelIdsFromResponse(await response.json().catch(() => void 0));
4790
4797
  if (ids.length === 0) {
@@ -4804,11 +4811,7 @@ async function runUsage(options = {}) {
4804
4811
  logger.debug({ event: "usage.fetch.started" }, "fetching github copilot quota");
4805
4812
  const response = await new CopilotClient(options).usage();
4806
4813
  if (!response.ok) {
4807
- const message = `GitHub Copilot usage request failed with ${response.status}: ${await truncatedResponseText(response)}`;
4808
- if (response.status === 401 || response.status === 403) {
4809
- throw new CopilotAuthError(message);
4810
- }
4811
- throw new Error(message);
4814
+ await throwForCopilotResponse(response, "GitHub Copilot usage request");
4812
4815
  }
4813
4816
  const rateLimit = parseRateLimitHeaders(response.headers);
4814
4817
  const usage = normalizeCopilotUsage(await response.json().catch(() => ({})));
@@ -4908,7 +4911,7 @@ async function verifyCopilotOAuthToken(token, options = {}) {
4908
4911
  options.copilotApiBaseUrl ?? envValue(options.env?.COPILOT_API_BASE_URL) ?? DEFAULT_COPILOT_API_BASE_URL
4909
4912
  );
4910
4913
  const allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
4911
- if (!isTrustedTokenBaseUrl(apiBaseUrl, ALLOWED_COPILOT_API_HOSTS2, allowUnsafeUpstream)) {
4914
+ if (!isTrustedTokenBaseUrl(apiBaseUrl, ALLOWED_COPILOT_API_HOSTS, allowUnsafeUpstream)) {
4912
4915
  throw new Error(
4913
4916
  `Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${apiBaseUrl}`
4914
4917
  );
@@ -4919,19 +4922,22 @@ async function verifyCopilotOAuthToken(token, options = {}) {
4919
4922
  method: "GET"
4920
4923
  });
4921
4924
  if (!response.ok) {
4922
- const message = `GitHub Copilot API verification failed with ${response.status}: ${await truncatedResponseText(response)}`;
4923
- if (response.status === 401 || response.status === 403) {
4924
- throw new CopilotAuthError(message);
4925
- }
4926
- throw new Error(message);
4925
+ await throwForCopilotResponse(response, "GitHub Copilot API verification");
4927
4926
  }
4928
4927
  return {
4929
4928
  apiBaseUrl,
4930
- expiresAtMs: Date.now() + 10 * 6e4,
4929
+ expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
4931
4930
  source: "github-copilot-oauth",
4932
4931
  token
4933
4932
  };
4934
4933
  }
4934
+ async function throwForCopilotResponse(response, label) {
4935
+ const message = `${label} failed with ${response.status}: ${await truncatedResponseText(response)}`;
4936
+ if (response.status === 401 || response.status === 403) {
4937
+ throw new CopilotAuthError(message);
4938
+ }
4939
+ throw new Error(message);
4940
+ }
4935
4941
  function openBrowserBestEffort(url, spawnOpener = spawn) {
4936
4942
  const platform = process.platform;
4937
4943
  const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
@@ -4947,21 +4953,6 @@ function openBrowserBestEffort(url, spawnOpener = spawn) {
4947
4953
  } catch {
4948
4954
  }
4949
4955
  }
4950
- function modelIdsFromResponse(body) {
4951
- const record = asRecord(body);
4952
- const data = Array.isArray(record.data) ? record.data : Array.isArray(body) ? body : [];
4953
- const seen = /* @__PURE__ */ new Set();
4954
- const ids = [];
4955
- for (const model of data) {
4956
- const id = asRecord(model).id;
4957
- if (typeof id !== "string" || id.length === 0 || seen.has(id)) {
4958
- continue;
4959
- }
4960
- seen.add(id);
4961
- ids.push(id);
4962
- }
4963
- return ids;
4964
- }
4965
4956
  function withRuntimeEnv(args) {
4966
4957
  return { ...args, env: process.env };
4967
4958
  }
@@ -5045,7 +5036,7 @@ Environment:
5045
5036
  }
5046
5037
  if (import.meta.main) {
5047
5038
  main2().catch((error) => {
5048
- console.error(error instanceof Error ? error.message : String(error));
5039
+ console.error(errorMessage(error));
5049
5040
  process.exit(1);
5050
5041
  });
5051
5042
  }