@openhoo/hoopilot 0.7.2 → 0.7.4

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
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
- main
4
- } from "./chunk-TEDEVCKM.js";
3
+ asRecord,
4
+ envValue,
5
+ isTrustedTokenBaseUrl,
6
+ main,
7
+ trimTrailingSlash,
8
+ truncatedResponseText
9
+ } from "./chunk-7GSQVYYT.js";
5
10
 
6
11
  // src/cli.ts
7
12
  import { spawn } from "child_process";
@@ -17,10 +22,25 @@ var StoredCopilotAuthError = class extends Error {
17
22
  }
18
23
  };
19
24
  function authStorePath(env = process.env) {
20
- if (env.HOOPILOT_AUTH_FILE) {
21
- return env.HOOPILOT_AUTH_FILE;
25
+ const explicit = envValue(env.HOOPILOT_AUTH_FILE);
26
+ if (explicit) {
27
+ return explicit;
22
28
  }
23
- const base = env.XDG_CONFIG_HOME ?? env.APPDATA ?? (env.HOME ? join(env.HOME, ".config") : join(process.cwd(), ".config"));
29
+ const xdg = envValue(env.XDG_CONFIG_HOME);
30
+ if (xdg) {
31
+ return join(xdg, "hoopilot", "auth.json");
32
+ }
33
+ const appdata = envValue(env.APPDATA);
34
+ if (appdata) {
35
+ return join(appdata, "hoopilot", "auth.json");
36
+ }
37
+ const home = envValue(env.HOME);
38
+ if (!home) {
39
+ throw new StoredCopilotAuthError(
40
+ "Cannot resolve Hoopilot auth file path without HOOPILOT_AUTH_FILE, XDG_CONFIG_HOME, APPDATA, or HOME."
41
+ );
42
+ }
43
+ const base = join(home, ".config");
24
44
  return join(base, "hoopilot", "auth.json");
25
45
  }
26
46
  function readStoredCopilotAuth(path = authStorePath()) {
@@ -77,30 +97,6 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
77
97
  }
78
98
  }
79
99
 
80
- // src/util.ts
81
- function trimTrailingSlash(value) {
82
- return value.replace(/\/+$/, "");
83
- }
84
- function isHttpsOrLoopbackUrl(rawUrl) {
85
- let url;
86
- try {
87
- url = new URL(rawUrl);
88
- } catch {
89
- return false;
90
- }
91
- if (url.protocol === "https:") {
92
- return true;
93
- }
94
- return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
95
- }
96
- async function truncatedResponseText(response, max = 500) {
97
- const text = await response.text();
98
- return text.slice(0, max);
99
- }
100
- function asRecord(value) {
101
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
102
- }
103
-
104
100
  // src/auth.ts
105
101
  var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
106
102
  var REFRESH_SKEW_MS = 6e4;
@@ -114,11 +110,15 @@ var CopilotAuthError = class extends Error {
114
110
  var CopilotAuth = class {
115
111
  #authStorePath;
116
112
  #copilotApiBaseUrl;
113
+ #hasCopilotApiBaseUrlOverride;
117
114
  #cachedAccess;
118
115
  constructor(options = {}) {
119
- this.#authStorePath = options.authStorePath ?? options.env?.HOOPILOT_AUTH_FILE;
116
+ const envAuthStorePath = envValue(options.env?.HOOPILOT_AUTH_FILE);
117
+ const envCopilotApiBaseUrl = envValue(options.env?.COPILOT_API_BASE_URL);
118
+ this.#authStorePath = options.authStorePath ?? envAuthStorePath;
119
+ this.#hasCopilotApiBaseUrlOverride = Boolean(options.copilotApiBaseUrl ?? envCopilotApiBaseUrl);
120
120
  this.#copilotApiBaseUrl = trimTrailingSlash(
121
- options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
121
+ options.copilotApiBaseUrl ?? envCopilotApiBaseUrl ?? DEFAULT_COPILOT_API_BASE_URL
122
122
  );
123
123
  }
124
124
  async getAccess() {
@@ -136,7 +136,9 @@ var CopilotAuth = class {
136
136
  }
137
137
  if (stored) {
138
138
  return this.#cacheAccess({
139
- apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
139
+ apiBaseUrl: trimTrailingSlash(
140
+ this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
141
+ ),
140
142
  expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
141
143
  source: "github-copilot-oauth",
142
144
  token: stored.token
@@ -154,6 +156,8 @@ var CopilotAuth = class {
154
156
 
155
157
  // src/copilot.ts
156
158
  var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
159
+ var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
160
+ var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
157
161
  var COPILOT_USAGE_API_VERSION = "2025-04-01";
158
162
  function applyCopilotHeaders(headers, token) {
159
163
  headers.set("accept", headers.get("accept") ?? "application/json");
@@ -177,13 +181,15 @@ function applyGithubApiHeaders(headers, token) {
177
181
  }
178
182
  var CopilotClient = class {
179
183
  #auth;
184
+ #allowUnsafeUpstream;
180
185
  #fetch;
181
186
  #githubApiBaseUrl;
182
187
  constructor(options = {}) {
183
188
  this.#auth = new CopilotAuth(options);
189
+ this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
184
190
  this.#fetch = options.fetch ?? fetch;
185
191
  this.#githubApiBaseUrl = trimTrailingSlash(
186
- options.githubApiBaseUrl ?? options.env?.HOOPILOT_GITHUB_API_BASE_URL ?? DEFAULT_GITHUB_API_BASE_URL
192
+ options.githubApiBaseUrl ?? envValue(options.env?.HOOPILOT_GITHUB_API_BASE_URL) ?? DEFAULT_GITHUB_API_BASE_URL
187
193
  );
188
194
  }
189
195
  /**
@@ -192,9 +198,13 @@ var CopilotClient = class {
192
198
  * accepted directly here — no Copilot token exchange is required to read quota.
193
199
  */
194
200
  async usage(signal) {
195
- if (!isHttpsOrLoopbackUrl(this.#githubApiBaseUrl)) {
201
+ if (!isTrustedTokenBaseUrl(
202
+ this.#githubApiBaseUrl,
203
+ ALLOWED_GITHUB_API_HOSTS,
204
+ this.#allowUnsafeUpstream
205
+ )) {
196
206
  throw new Error(
197
- `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${this.#githubApiBaseUrl}`
207
+ `Refusing to send the GitHub OAuth token to an untrusted GitHub API host: ${this.#githubApiBaseUrl}`
198
208
  );
199
209
  }
200
210
  const access = await this.#auth.getAccess();
@@ -236,9 +246,13 @@ var CopilotClient = class {
236
246
  }
237
247
  async fetchCopilot(path, init) {
238
248
  const access = await this.#auth.getAccess();
239
- if (!isHttpsOrLoopbackUrl(access.apiBaseUrl)) {
249
+ if (!isTrustedTokenBaseUrl(
250
+ access.apiBaseUrl,
251
+ ALLOWED_COPILOT_API_HOSTS,
252
+ this.#allowUnsafeUpstream
253
+ )) {
240
254
  throw new Error(
241
- `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${access.apiBaseUrl}`
255
+ `Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${access.apiBaseUrl}`
242
256
  );
243
257
  }
244
258
  const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
@@ -324,9 +338,9 @@ async function githubCopilotDeviceLogin(options = {}) {
324
338
  const fetcher = options.fetch ?? fetch;
325
339
  const sleeper = options.sleep ?? sleep;
326
340
  const domain = normalizeDomain(
327
- options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
341
+ options.domain ?? envValue(env.HOOPILOT_GITHUB_DOMAIN) ?? DEFAULT_GITHUB_DOMAIN
328
342
  );
329
- const clientId = options.clientId ?? env.HOOPILOT_GITHUB_CLIENT_ID ?? env.COPILOT_GITHUB_CLIENT_ID ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
343
+ const clientId = options.clientId ?? envValue(env.HOOPILOT_GITHUB_CLIENT_ID) ?? envValue(env.COPILOT_GITHUB_CLIENT_ID) ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
330
344
  const device = await requestDeviceCode(fetcher, domain, clientId);
331
345
  const verificationUrl = device.verification_uri;
332
346
  const userCode = device.user_code;
@@ -484,8 +498,8 @@ var noopLogger = {
484
498
  };
485
499
  function createHoopilotLogger(options = {}) {
486
500
  const env = options.env ?? process.env;
487
- const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
488
- const format = parseLogFormat(options.format ?? env.HOOPILOT_LOG_FORMAT);
501
+ const level = parseLogLevel(options.level ?? envValue(env.HOOPILOT_LOG_LEVEL));
502
+ const format = parseLogFormat(options.format ?? envValue(env.HOOPILOT_LOG_FORMAT));
489
503
  const pinoOptions = {
490
504
  base: {
491
505
  service: "hoopilot",
@@ -535,7 +549,7 @@ function parseLogLevel(value) {
535
549
  }
536
550
  function shouldCreateLogger(options) {
537
551
  return Boolean(
538
- options.logger || options.logFormat || options.logLevel || options.env?.HOOPILOT_LOG_FORMAT || options.env?.HOOPILOT_LOG_LEVEL
552
+ options.logger || options.logFormat || options.logLevel || envValue(options.env?.HOOPILOT_LOG_FORMAT) || envValue(options.env?.HOOPILOT_LOG_LEVEL)
539
553
  );
540
554
  }
541
555
  function errorDetails(error) {
@@ -557,6 +571,12 @@ function isLogLevel(value) {
557
571
 
558
572
  // src/openai.ts
559
573
  var DEFAULT_MODEL = "gpt-4.1";
574
+ var OpenAICompatibilityError = class extends Error {
575
+ constructor(message) {
576
+ super(message);
577
+ this.name = "OpenAICompatibilityError";
578
+ }
579
+ };
560
580
  function normalizeChatCompletionRequest(request) {
561
581
  return removeUndefined({
562
582
  ...request,
@@ -564,13 +584,22 @@ function normalizeChatCompletionRequest(request) {
564
584
  });
565
585
  }
566
586
  function completionsRequestToChatCompletion(request) {
587
+ assertSupportedLegacyCompletionRequest(request);
567
588
  return removeUndefined({
589
+ frequency_penalty: request.frequency_penalty,
590
+ logit_bias: request.logit_bias,
568
591
  max_tokens: request.max_tokens,
569
- messages: [{ content: promptToText(request.prompt), role: "user" }],
592
+ messages: [{ content: legacyPromptToText(request.prompt), role: "user" }],
570
593
  model: normalizeRequestedModel(request.model),
594
+ n: request.n,
595
+ presence_penalty: request.presence_penalty,
596
+ seed: request.seed,
597
+ stop: request.stop,
571
598
  stream: request.stream === true,
599
+ stream_options: request.stream_options,
572
600
  temperature: request.temperature,
573
- top_p: request.top_p
601
+ top_p: request.top_p,
602
+ user: request.user
574
603
  });
575
604
  }
576
605
  function normalizeRequestedModel(model) {
@@ -578,21 +607,21 @@ function normalizeRequestedModel(model) {
578
607
  return requested || DEFAULT_MODEL;
579
608
  }
580
609
  function chatCompletionToCompletion(completion) {
581
- const choice = firstChoice(completion);
582
- const message = asRecord(choice.message);
583
610
  return removeUndefined({
584
- choices: [
585
- {
611
+ choices: completionChoices(completion).map((choice, index) => {
612
+ const message = asRecord(choice.message);
613
+ return {
586
614
  finish_reason: choice.finish_reason ?? "stop",
587
- index: 0,
588
- logprobs: null,
589
- text: contentToText(message.content)
590
- }
591
- ],
615
+ index: typeof choice.index === "number" ? choice.index : index,
616
+ logprobs: choice.logprobs ?? null,
617
+ text: contentToText(choice.text) || contentToText(message.content)
618
+ };
619
+ }),
592
620
  created: completion.created ?? epochSeconds(),
593
621
  id: completion.id ?? `cmpl_${randomId()}`,
594
622
  model: completion.model ?? DEFAULT_MODEL,
595
623
  object: "text_completion",
624
+ system_fingerprint: completion.system_fingerprint,
596
625
  usage: completion.usage
597
626
  });
598
627
  }
@@ -600,12 +629,15 @@ function completionStreamFromChatStream(chatStream) {
600
629
  const encoder = new TextEncoder();
601
630
  const decoder = new TextDecoder();
602
631
  let buffer = "";
603
- let sawDone = false;
632
+ let sawTerminalEvent = false;
604
633
  return new ReadableStream({
605
634
  async start(controller) {
606
635
  const enqueue = (data) => {
607
636
  controller.enqueue(encoder.encode(encodeDataSse(data)));
608
637
  };
638
+ const markTerminal = () => {
639
+ sawTerminalEvent = true;
640
+ };
609
641
  const reader = chatStream.getReader();
610
642
  try {
611
643
  while (true) {
@@ -614,20 +646,17 @@ function completionStreamFromChatStream(chatStream) {
614
646
  break;
615
647
  }
616
648
  buffer += decoder.decode(result.value, { stream: true });
617
- const lines = buffer.split(/\r?\n/);
618
- buffer = lines.pop() ?? "";
619
- for (const line of lines) {
620
- processCompletionSseLine(line, enqueue, () => {
621
- sawDone = true;
622
- });
649
+ const blocks = buffer.split(/\r?\n\r?\n/);
650
+ buffer = blocks.pop() ?? "";
651
+ for (const block of blocks) {
652
+ processCompletionSseBlock(block, enqueue, markTerminal);
623
653
  }
624
654
  }
625
- if (buffer) {
626
- processCompletionSseLine(buffer, enqueue, () => {
627
- sawDone = true;
628
- });
655
+ const tail = `${buffer}${decoder.decode()}`;
656
+ if (tail.trim()) {
657
+ processCompletionSseBlock(tail, enqueue, markTerminal);
629
658
  }
630
- if (!sawDone) {
659
+ if (!sawTerminalEvent) {
631
660
  enqueue("[DONE]");
632
661
  }
633
662
  controller.close();
@@ -665,11 +694,38 @@ function fallbackModels() {
665
694
  }
666
695
  ];
667
696
  }
668
- function promptToText(prompt) {
669
- if (Array.isArray(prompt)) {
670
- return prompt.map((item) => contentToText(item)).join("\n");
697
+ function legacyPromptToText(prompt) {
698
+ if (typeof prompt === "string") {
699
+ return prompt;
700
+ }
701
+ if (Array.isArray(prompt) && prompt.length === 1 && typeof prompt[0] === "string") {
702
+ return prompt[0];
703
+ }
704
+ throw new OpenAICompatibilityError(
705
+ "Hoopilot legacy completions compatibility supports exactly one string prompt per request."
706
+ );
707
+ }
708
+ function assertSupportedLegacyCompletionRequest(request) {
709
+ if (request.echo === true) {
710
+ throw new OpenAICompatibilityError(
711
+ "Hoopilot legacy completions compatibility does not support echo=true."
712
+ );
713
+ }
714
+ if (typeof request.best_of === "number" && request.best_of > 1) {
715
+ throw new OpenAICompatibilityError(
716
+ "Hoopilot legacy completions compatibility does not support best_of greater than 1."
717
+ );
718
+ }
719
+ if (typeof request.logprobs === "number" && request.logprobs > 0) {
720
+ throw new OpenAICompatibilityError(
721
+ "Hoopilot legacy completions compatibility does not support legacy logprobs."
722
+ );
723
+ }
724
+ if (contentToText(request.suffix)) {
725
+ throw new OpenAICompatibilityError(
726
+ "Hoopilot legacy completions compatibility does not support suffix."
727
+ );
671
728
  }
672
- return contentToText(prompt);
673
729
  }
674
730
  function contentToText(content) {
675
731
  if (typeof content === "string") {
@@ -727,21 +783,27 @@ function firstNumber(...values) {
727
783
  }
728
784
  return void 0;
729
785
  }
730
- function firstChoice(completion) {
786
+ function completionChoices(completion) {
731
787
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
732
- return asRecord(choices[0]);
788
+ return choices.map((choice) => asRecord(choice));
733
789
  }
734
- function processCompletionSseLine(line, enqueue, markDone) {
735
- const trimmed = line.trim();
736
- if (!trimmed.startsWith("data:")) {
737
- return;
790
+ function processCompletionSseBlock(block, enqueue, markTerminal) {
791
+ let event = "message";
792
+ const dataLines = [];
793
+ for (const line of block.split(/\r?\n/)) {
794
+ const trimmed = line.trim();
795
+ if (trimmed.startsWith("event:")) {
796
+ event = trimmed.slice("event:".length).trim() || event;
797
+ } else if (trimmed.startsWith("data:")) {
798
+ dataLines.push(trimmed.slice("data:".length).trim());
799
+ }
738
800
  }
739
- const data = trimmed.slice("data:".length).trim();
801
+ const data = dataLines.join("\n");
740
802
  if (!data) {
741
803
  return;
742
804
  }
743
805
  if (data === "[DONE]") {
744
- markDone();
806
+ markTerminal();
745
807
  enqueue("[DONE]");
746
808
  return;
747
809
  }
@@ -749,25 +811,34 @@ function processCompletionSseLine(line, enqueue, markDone) {
749
811
  if (!parsed) {
750
812
  return;
751
813
  }
752
- const choice = firstChoice(parsed);
753
- const delta = asRecord(choice.delta);
754
- const text = contentToText(delta.content);
755
- const finishReason = choice.finish_reason ?? null;
814
+ const error = completionStreamError(event, parsed);
815
+ if (error) {
816
+ markTerminal();
817
+ enqueue({ error });
818
+ return;
819
+ }
820
+ const choices = completionChoices(parsed).map((choice, index) => {
821
+ const delta = asRecord(choice.delta);
822
+ const text = contentToText(delta.content);
823
+ const finishReason = choice.finish_reason ?? null;
824
+ if (!text && finishReason === null) {
825
+ return void 0;
826
+ }
827
+ return {
828
+ finish_reason: finishReason,
829
+ index: typeof choice.index === "number" ? choice.index : index,
830
+ logprobs: choice.logprobs ?? null,
831
+ text
832
+ };
833
+ }).filter((choice) => choice !== void 0);
756
834
  const usage = asRecord(parsed.usage);
757
835
  const hasUsage = Object.keys(usage).length > 0;
758
- if (!text && finishReason === null && !hasUsage) {
836
+ if (choices.length === 0 && !hasUsage) {
759
837
  return;
760
838
  }
761
839
  enqueue(
762
840
  removeUndefined({
763
- choices: text || finishReason !== null ? [
764
- {
765
- finish_reason: finishReason,
766
- index: typeof choice.index === "number" ? choice.index : 0,
767
- logprobs: null,
768
- text
769
- }
770
- ] : [],
841
+ choices,
771
842
  created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
772
843
  id: contentToText(parsed.id) || `cmpl_${randomId()}`,
773
844
  model: contentToText(parsed.model) || DEFAULT_MODEL,
@@ -776,6 +847,22 @@ function processCompletionSseLine(line, enqueue, markDone) {
776
847
  })
777
848
  );
778
849
  }
850
+ function completionStreamError(event, parsed) {
851
+ const responseError = asRecord(asRecord(parsed.response).error);
852
+ const directError = asRecord(parsed.error);
853
+ const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
854
+ if (error) {
855
+ return error;
856
+ }
857
+ if (event === "error" || parsed.type === "response.failed") {
858
+ return removeUndefined({
859
+ code: contentToText(parsed.code) || void 0,
860
+ message: contentToText(parsed.message) || "Upstream streaming request failed.",
861
+ type: contentToText(parsed.type) || "upstream_stream_error"
862
+ });
863
+ }
864
+ return void 0;
865
+ }
779
866
  function encodeDataSse(data) {
780
867
  if (data === "[DONE]") {
781
868
  return "data: [DONE]\n\n";
@@ -1172,10 +1259,19 @@ var DEFAULT_HOST = "127.0.0.1";
1172
1259
  var DEFAULT_PORT = 4141;
1173
1260
  var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
1174
1261
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
1262
+ var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
1263
+ var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
1264
+ var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
1175
1265
  var USAGE_CACHE_TTL_MS = 6e4;
1266
+ var RequestBodyTooLargeError = class extends Error {
1267
+ constructor() {
1268
+ super(REQUEST_TOO_LARGE_MESSAGE);
1269
+ this.name = "RequestBodyTooLargeError";
1270
+ }
1271
+ };
1176
1272
  function createHoopilotHandler(options = {}) {
1177
1273
  const client = new CopilotClient(options);
1178
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1274
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
1179
1275
  const logger = serverLogger(options);
1180
1276
  const metrics = options.metrics ?? new MetricsRegistry();
1181
1277
  const readUsage = createUsageReader(client, metrics);
@@ -1261,6 +1357,18 @@ function createHoopilotHandler(options = {}) {
1261
1357
  "request body was invalid json"
1262
1358
  );
1263
1359
  return finish(jsonError(400, "invalid_request_error", message));
1360
+ } else if (error instanceof OpenAICompatibilityError) {
1361
+ requestLogger.warn(
1362
+ { err: errorDetails(error), event: "http.request.failed" },
1363
+ "request body used unsupported OpenAI compatibility fields"
1364
+ );
1365
+ return finish(jsonError(400, "invalid_request_error", message));
1366
+ } else if (error instanceof RequestBodyTooLargeError) {
1367
+ requestLogger.warn(
1368
+ { err: errorDetails(error), event: "http.request.failed" },
1369
+ "request body exceeded size limit"
1370
+ );
1371
+ return finish(jsonError(413, "request_too_large", message));
1264
1372
  } else {
1265
1373
  requestLogger.error(
1266
1374
  { err: errorDetails(error), event: "http.request.failed" },
@@ -1272,10 +1380,10 @@ function createHoopilotHandler(options = {}) {
1272
1380
  };
1273
1381
  }
1274
1382
  function startHoopilotServer(options = {}) {
1275
- const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
1276
- const port = normalizeServerPort(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1277
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1278
- const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
1383
+ const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
1384
+ const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
1385
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
1386
+ const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
1279
1387
  if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
1280
1388
  throw new Error(
1281
1389
  "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
@@ -1293,7 +1401,7 @@ function startHoopilotServer(options = {}) {
1293
1401
  });
1294
1402
  return {
1295
1403
  server,
1296
- url: `http://${host}:${server.port}`
1404
+ url: `http://${urlHost(host)}:${server.port}`
1297
1405
  };
1298
1406
  }
1299
1407
  async function handleModels(client, metrics, signal, logger) {
@@ -1402,14 +1510,15 @@ function proxyResponse(upstream) {
1402
1510
  });
1403
1511
  }
1404
1512
  async function readJson(request) {
1513
+ const text = await readRequestText(request);
1405
1514
  try {
1406
- return asRecord(await request.json());
1515
+ return asRecord(JSON.parse(text));
1407
1516
  } catch {
1408
1517
  throw new Error(INVALID_JSON_MESSAGE);
1409
1518
  }
1410
1519
  }
1411
1520
  async function readJsonText(request) {
1412
- const text = await request.text();
1521
+ const text = await readRequestText(request);
1413
1522
  try {
1414
1523
  JSON.parse(text);
1415
1524
  return text;
@@ -1417,6 +1526,40 @@ async function readJsonText(request) {
1417
1526
  throw new Error(INVALID_JSON_MESSAGE);
1418
1527
  }
1419
1528
  }
1529
+ async function readRequestText(request) {
1530
+ const contentLength = request.headers.get("content-length");
1531
+ if (contentLength) {
1532
+ const declaredBytes = Number(contentLength);
1533
+ if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
1534
+ throw new RequestBodyTooLargeError();
1535
+ }
1536
+ }
1537
+ const body = request.body;
1538
+ if (!body) {
1539
+ return "";
1540
+ }
1541
+ const reader = body.getReader();
1542
+ const decoder = new TextDecoder();
1543
+ let bytes = 0;
1544
+ let text = "";
1545
+ try {
1546
+ while (true) {
1547
+ const { done, value } = await reader.read();
1548
+ if (done) {
1549
+ return `${text}${decoder.decode()}`;
1550
+ }
1551
+ bytes += value.byteLength;
1552
+ if (bytes > MAX_REQUEST_BODY_BYTES) {
1553
+ await reader.cancel().catch(() => {
1554
+ });
1555
+ throw new RequestBodyTooLargeError();
1556
+ }
1557
+ text += decoder.decode(value, { stream: true });
1558
+ }
1559
+ } finally {
1560
+ reader.releaseLock();
1561
+ }
1562
+ }
1420
1563
  function jsonResponse(body, status = 200) {
1421
1564
  return new Response(JSON.stringify(body), {
1422
1565
  headers: {
@@ -1489,6 +1632,9 @@ function upstreamAuthMessage(message) {
1489
1632
  function isLoopbackHost(host) {
1490
1633
  return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
1491
1634
  }
1635
+ function urlHost(host) {
1636
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
1637
+ }
1492
1638
  function isLoopbackOrigin(origin) {
1493
1639
  try {
1494
1640
  return isLoopbackHost(new URL(origin).hostname.toLowerCase());
@@ -1596,7 +1742,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
1596
1742
  }
1597
1743
  function requestIdFor(request) {
1598
1744
  const existing = request.headers.get("x-request-id")?.trim();
1599
- return existing || crypto.randomUUID();
1745
+ return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
1600
1746
  }
1601
1747
  function canonicalApiPath(path) {
1602
1748
  const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;
@@ -2297,6 +2443,7 @@ function errorMessage2(error) {
2297
2443
  }
2298
2444
 
2299
2445
  // src/cli.ts
2446
+ var ALLOWED_COPILOT_API_HOSTS2 = ["api.githubcopilot.com"];
2300
2447
  async function main2(argv = Bun.argv.slice(2)) {
2301
2448
  cleanupOldBinary();
2302
2449
  const command = argv[0];
@@ -2399,46 +2546,54 @@ function parseArgs(argv) {
2399
2546
  args.noUpdateCheck = true;
2400
2547
  continue;
2401
2548
  }
2402
- const [name, inlineValue] = splitOption(arg);
2403
- const value = inlineValue ?? rest.shift();
2404
- if (!value) {
2405
- throw new Error(`Missing value for ${arg}.`);
2549
+ if (!arg.startsWith("-")) {
2550
+ throw new Error(`Unknown argument: ${arg}.`);
2406
2551
  }
2552
+ const [name, inlineValue] = splitOption(arg);
2407
2553
  switch (name) {
2408
2554
  case "--api-key":
2409
- args.apiKey = value;
2555
+ args.apiKey = optionValue(name, inlineValue, rest);
2410
2556
  break;
2411
2557
  case "--api-key-file":
2412
- args.apiKey = readApiKeyFile(value);
2558
+ args.apiKey = readApiKeyFile(optionValue(name, inlineValue, rest));
2413
2559
  break;
2414
2560
  case "--auth-file":
2415
- args.authStorePath = value;
2561
+ args.authStorePath = optionValue(name, inlineValue, rest);
2416
2562
  break;
2417
2563
  case "--copilot-api-base-url":
2418
- args.copilotApiBaseUrl = value;
2564
+ args.copilotApiBaseUrl = optionValue(name, inlineValue, rest);
2419
2565
  break;
2420
2566
  case "--log-format":
2421
- args.logFormat = parseLogFormat(value);
2567
+ args.logFormat = parseLogFormat(optionValue(name, inlineValue, rest));
2422
2568
  break;
2423
2569
  case "--log-level":
2424
- args.logLevel = parseLogLevel(value);
2570
+ args.logLevel = parseLogLevel(optionValue(name, inlineValue, rest));
2425
2571
  break;
2426
2572
  case "--host":
2427
- args.host = value;
2573
+ args.host = optionValue(name, inlineValue, rest);
2428
2574
  break;
2429
2575
  case "--port":
2430
- case "-p":
2576
+ case "-p": {
2577
+ const value = optionValue(name, inlineValue, rest);
2431
2578
  args.port = Number(value);
2432
2579
  if (!Number.isInteger(args.port) || args.port <= 0 || args.port > 65535) {
2433
2580
  throw new Error(`Invalid port: ${value}.`);
2434
2581
  }
2435
2582
  break;
2583
+ }
2436
2584
  default:
2437
2585
  throw new Error(`Unknown option: ${name}.`);
2438
2586
  }
2439
2587
  }
2440
2588
  return args;
2441
2589
  }
2590
+ function optionValue(name, inlineValue, rest) {
2591
+ const value = inlineValue ?? rest.shift();
2592
+ if (!value) {
2593
+ throw new Error(`Missing value for ${name}.`);
2594
+ }
2595
+ return value;
2596
+ }
2442
2597
  function splitOption(arg) {
2443
2598
  const separator = arg.indexOf("=");
2444
2599
  if (separator === -1) {
@@ -2589,10 +2744,13 @@ function roundQuota(value) {
2589
2744
  }
2590
2745
  async function verifyCopilotOAuthToken(token, options = {}) {
2591
2746
  const apiBaseUrl = trimTrailingSlash(
2592
- options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
2747
+ options.copilotApiBaseUrl ?? envValue(options.env?.COPILOT_API_BASE_URL) ?? DEFAULT_COPILOT_API_BASE_URL
2593
2748
  );
2594
- if (!isHttpsOrLoopbackUrl(apiBaseUrl)) {
2595
- throw new Error(`Refusing to send the GitHub OAuth token to a non-HTTPS host: ${apiBaseUrl}`);
2749
+ const allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
2750
+ if (!isTrustedTokenBaseUrl(apiBaseUrl, ALLOWED_COPILOT_API_HOSTS2, allowUnsafeUpstream)) {
2751
+ throw new Error(
2752
+ `Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${apiBaseUrl}`
2753
+ );
2596
2754
  }
2597
2755
  const fetcher = options.fetch ?? fetch;
2598
2756
  const response = await fetcher(`${apiBaseUrl}/models`, {
@@ -2700,6 +2858,7 @@ Environment:
2700
2858
  HOOPILOT_LOG_LEVEL trace, debug, info, warn, error, fatal, or silent
2701
2859
  COPILOT_API_BASE_URL
2702
2860
  HOOPILOT_GITHUB_API_BASE_URL GitHub REST base for the usage/quota lookup. Default: https://api.github.com
2861
+ HOOPILOT_ALLOW_UNSAFE_UPSTREAM Set to 1 to allow nonstandard HTTPS token hosts
2703
2862
  HOOPILOT_NO_UPDATE_CHECK Set to disable update checks (also NO_UPDATE_NOTIFIER)
2704
2863
  `;
2705
2864
  }