@openhoo/hoopilot 0.7.1 → 0.7.3

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
@@ -1,33 +1,117 @@
1
1
  // src/auth-store.ts
2
2
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
3
3
  import { dirname, join } from "path";
4
+
5
+ // src/util.ts
6
+ function trimTrailingSlash(value) {
7
+ return value.replace(/\/+$/, "");
8
+ }
9
+ function envValue(value) {
10
+ const trimmed = value?.trim();
11
+ return trimmed ? trimmed : void 0;
12
+ }
13
+ function isTrustedTokenBaseUrl(rawUrl, allowedHttpsHosts, allowUnsafeHttps = false) {
14
+ const url = parseUrl(rawUrl);
15
+ if (!url) {
16
+ return false;
17
+ }
18
+ if (url.username || url.password || url.search || url.hash) {
19
+ return false;
20
+ }
21
+ if (url.pathname !== "" && url.pathname !== "/") {
22
+ return false;
23
+ }
24
+ if (isLoopbackHttpUrl(url)) {
25
+ return true;
26
+ }
27
+ if (url.protocol !== "https:") {
28
+ return false;
29
+ }
30
+ const host = url.hostname.toLowerCase();
31
+ return allowedHttpsHosts.includes(host) || allowUnsafeHttps;
32
+ }
33
+ function parseUrl(rawUrl) {
34
+ let url;
35
+ try {
36
+ url = new URL(rawUrl);
37
+ } catch {
38
+ return void 0;
39
+ }
40
+ return url;
41
+ }
42
+ function isLoopbackHttpUrl(url) {
43
+ return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
44
+ }
45
+ async function truncatedResponseText(response, max = 500) {
46
+ const text = await response.text();
47
+ return text.slice(0, max);
48
+ }
49
+ function asRecord(value) {
50
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
51
+ }
52
+
53
+ // src/auth-store.ts
54
+ var StoredCopilotAuthError = class extends Error {
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = "StoredCopilotAuthError";
58
+ }
59
+ };
4
60
  function authStorePath(env = process.env) {
5
- if (env.HOOPILOT_AUTH_FILE) {
6
- return env.HOOPILOT_AUTH_FILE;
61
+ const explicit = envValue(env.HOOPILOT_AUTH_FILE);
62
+ if (explicit) {
63
+ return explicit;
64
+ }
65
+ const xdg = envValue(env.XDG_CONFIG_HOME);
66
+ if (xdg) {
67
+ return join(xdg, "hoopilot", "auth.json");
68
+ }
69
+ const appdata = envValue(env.APPDATA);
70
+ if (appdata) {
71
+ return join(appdata, "hoopilot", "auth.json");
72
+ }
73
+ const home = envValue(env.HOME);
74
+ if (!home) {
75
+ throw new StoredCopilotAuthError(
76
+ "Cannot resolve Hoopilot auth file path without HOOPILOT_AUTH_FILE, XDG_CONFIG_HOME, APPDATA, or HOME."
77
+ );
7
78
  }
8
- const base = env.XDG_CONFIG_HOME ?? env.APPDATA ?? (env.HOME ? join(env.HOME, ".config") : join(process.cwd(), ".config"));
79
+ const base = join(home, ".config");
9
80
  return join(base, "hoopilot", "auth.json");
10
81
  }
11
82
  function readStoredCopilotAuth(path = authStorePath()) {
83
+ let text;
12
84
  try {
13
- const parsed = JSON.parse(readFileSync(path, "utf8"));
14
- if (!parsed || typeof parsed !== "object") {
85
+ text = readFileSync(path, "utf8");
86
+ } catch (error) {
87
+ if (error.code === "ENOENT") {
15
88
  return void 0;
16
89
  }
17
- const token = typeof parsed.token === "string" ? parsed.token.trim() : "";
18
- if (!token) {
19
- return void 0;
20
- }
21
- return {
22
- apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : void 0,
23
- createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : void 0,
24
- githubDomain: typeof parsed.githubDomain === "string" ? parsed.githubDomain : void 0,
25
- source: typeof parsed.source === "string" ? parsed.source : void 0,
26
- token
27
- };
90
+ throw new StoredCopilotAuthError(`Could not read Hoopilot auth file at ${path}.`);
91
+ }
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(text);
28
95
  } catch {
29
- return void 0;
96
+ throw new StoredCopilotAuthError(
97
+ `Hoopilot auth file at ${path} is not valid JSON. Run \`hoopilot login\` to replace it.`
98
+ );
99
+ }
100
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
101
+ throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
30
102
  }
103
+ const record = parsed;
104
+ const token = typeof record.token === "string" ? record.token.trim() : "";
105
+ if (!token) {
106
+ throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
107
+ }
108
+ return {
109
+ apiBaseUrl: typeof record.apiBaseUrl === "string" ? record.apiBaseUrl : void 0,
110
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : void 0,
111
+ githubDomain: typeof record.githubDomain === "string" ? record.githubDomain : void 0,
112
+ source: typeof record.source === "string" ? record.source : void 0,
113
+ token
114
+ };
31
115
  }
32
116
  function writeStoredCopilotAuth(auth, path = authStorePath()) {
33
117
  mkdirSync(dirname(path), { recursive: true });
@@ -49,18 +133,6 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
49
133
  }
50
134
  }
51
135
 
52
- // src/util.ts
53
- function trimTrailingSlash(value) {
54
- return value.replace(/\/+$/, "");
55
- }
56
- async function truncatedResponseText(response, max = 500) {
57
- const text = await response.text();
58
- return text.slice(0, max);
59
- }
60
- function asRecord(value) {
61
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
62
- }
63
-
64
136
  // src/auth.ts
65
137
  var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
66
138
  var REFRESH_SKEW_MS = 6e4;
@@ -74,21 +146,35 @@ var CopilotAuthError = class extends Error {
74
146
  var CopilotAuth = class {
75
147
  #authStorePath;
76
148
  #copilotApiBaseUrl;
149
+ #hasCopilotApiBaseUrlOverride;
77
150
  #cachedAccess;
78
151
  constructor(options = {}) {
79
- this.#authStorePath = options.authStorePath ?? options.env?.HOOPILOT_AUTH_FILE;
152
+ const envAuthStorePath = envValue(options.env?.HOOPILOT_AUTH_FILE);
153
+ const envCopilotApiBaseUrl = envValue(options.env?.COPILOT_API_BASE_URL);
154
+ this.#authStorePath = options.authStorePath ?? envAuthStorePath;
155
+ this.#hasCopilotApiBaseUrlOverride = Boolean(options.copilotApiBaseUrl ?? envCopilotApiBaseUrl);
80
156
  this.#copilotApiBaseUrl = trimTrailingSlash(
81
- options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
157
+ options.copilotApiBaseUrl ?? envCopilotApiBaseUrl ?? DEFAULT_COPILOT_API_BASE_URL
82
158
  );
83
159
  }
84
160
  async getAccess() {
85
161
  if (this.#cachedAccess && this.#cachedAccess.expiresAtMs - REFRESH_SKEW_MS > Date.now()) {
86
162
  return this.#cachedAccess;
87
163
  }
88
- const stored = readStoredCopilotAuth(this.#authStorePath);
164
+ let stored;
165
+ try {
166
+ stored = readStoredCopilotAuth(this.#authStorePath);
167
+ } catch (error) {
168
+ if (error instanceof StoredCopilotAuthError) {
169
+ throw new CopilotAuthError(error.message);
170
+ }
171
+ throw error;
172
+ }
89
173
  if (stored) {
90
174
  return this.#cacheAccess({
91
- apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
175
+ apiBaseUrl: trimTrailingSlash(
176
+ this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
177
+ ),
92
178
  expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
93
179
  source: "github-copilot-oauth",
94
180
  token: stored.token
@@ -106,6 +192,8 @@ var CopilotAuth = class {
106
192
 
107
193
  // src/copilot.ts
108
194
  var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
195
+ var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
196
+ var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
109
197
  var COPILOT_USAGE_API_VERSION = "2025-04-01";
110
198
  function applyCopilotHeaders(headers, token) {
111
199
  headers.set("accept", headers.get("accept") ?? "application/json");
@@ -129,13 +217,15 @@ function applyGithubApiHeaders(headers, token) {
129
217
  }
130
218
  var CopilotClient = class {
131
219
  #auth;
220
+ #allowUnsafeUpstream;
132
221
  #fetch;
133
222
  #githubApiBaseUrl;
134
223
  constructor(options = {}) {
135
224
  this.#auth = new CopilotAuth(options);
225
+ this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
136
226
  this.#fetch = options.fetch ?? fetch;
137
227
  this.#githubApiBaseUrl = trimTrailingSlash(
138
- options.githubApiBaseUrl ?? options.env?.HOOPILOT_GITHUB_API_BASE_URL ?? DEFAULT_GITHUB_API_BASE_URL
228
+ options.githubApiBaseUrl ?? envValue(options.env?.HOOPILOT_GITHUB_API_BASE_URL) ?? DEFAULT_GITHUB_API_BASE_URL
139
229
  );
140
230
  }
141
231
  /**
@@ -144,9 +234,13 @@ var CopilotClient = class {
144
234
  * accepted directly here — no Copilot token exchange is required to read quota.
145
235
  */
146
236
  async usage(signal) {
147
- if (!isHttpsOrLoopback(this.#githubApiBaseUrl)) {
237
+ if (!isTrustedTokenBaseUrl(
238
+ this.#githubApiBaseUrl,
239
+ ALLOWED_GITHUB_API_HOSTS,
240
+ this.#allowUnsafeUpstream
241
+ )) {
148
242
  throw new Error(
149
- `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${this.#githubApiBaseUrl}`
243
+ `Refusing to send the GitHub OAuth token to an untrusted GitHub API host: ${this.#githubApiBaseUrl}`
150
244
  );
151
245
  }
152
246
  const access = await this.#auth.getAccess();
@@ -188,6 +282,15 @@ var CopilotClient = class {
188
282
  }
189
283
  async fetchCopilot(path, init) {
190
284
  const access = await this.#auth.getAccess();
285
+ if (!isTrustedTokenBaseUrl(
286
+ access.apiBaseUrl,
287
+ ALLOWED_COPILOT_API_HOSTS,
288
+ this.#allowUnsafeUpstream
289
+ )) {
290
+ throw new Error(
291
+ `Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${access.apiBaseUrl}`
292
+ );
293
+ }
191
294
  const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
192
295
  return this.#fetch(`${access.apiBaseUrl}${path}`, {
193
296
  ...init,
@@ -243,18 +346,6 @@ function usedFrom(entitlement, remaining) {
243
346
  }
244
347
  return Math.max(0, entitlement - remaining);
245
348
  }
246
- function isHttpsOrLoopback(rawUrl) {
247
- let url;
248
- try {
249
- url = new URL(rawUrl);
250
- } catch {
251
- return false;
252
- }
253
- if (url.protocol === "https:") {
254
- return true;
255
- }
256
- return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1");
257
- }
258
349
  function numberOrUndefined(value) {
259
350
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
260
351
  }
@@ -283,9 +374,9 @@ async function githubCopilotDeviceLogin(options = {}) {
283
374
  const fetcher = options.fetch ?? fetch;
284
375
  const sleeper = options.sleep ?? sleep;
285
376
  const domain = normalizeDomain(
286
- options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
377
+ options.domain ?? envValue(env.HOOPILOT_GITHUB_DOMAIN) ?? DEFAULT_GITHUB_DOMAIN
287
378
  );
288
- const clientId = options.clientId ?? env.HOOPILOT_GITHUB_CLIENT_ID ?? env.COPILOT_GITHUB_CLIENT_ID ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
379
+ const clientId = options.clientId ?? envValue(env.HOOPILOT_GITHUB_CLIENT_ID) ?? envValue(env.COPILOT_GITHUB_CLIENT_ID) ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
289
380
  const device = await requestDeviceCode(fetcher, domain, clientId);
290
381
  const verificationUrl = device.verification_uri;
291
382
  const userCode = device.user_code;
@@ -443,8 +534,8 @@ var noopLogger = {
443
534
  };
444
535
  function createHoopilotLogger(options = {}) {
445
536
  const env = options.env ?? process.env;
446
- const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
447
- const format = parseLogFormat(options.format ?? env.HOOPILOT_LOG_FORMAT);
537
+ const level = parseLogLevel(options.level ?? envValue(env.HOOPILOT_LOG_LEVEL));
538
+ const format = parseLogFormat(options.format ?? envValue(env.HOOPILOT_LOG_FORMAT));
448
539
  const pinoOptions = {
449
540
  base: {
450
541
  service: "hoopilot",
@@ -494,7 +585,7 @@ function parseLogLevel(value) {
494
585
  }
495
586
  function shouldCreateLogger(options) {
496
587
  return Boolean(
497
- options.logger || options.logFormat || options.logLevel || options.env?.HOOPILOT_LOG_FORMAT || options.env?.HOOPILOT_LOG_LEVEL
588
+ options.logger || options.logFormat || options.logLevel || envValue(options.env?.HOOPILOT_LOG_FORMAT) || envValue(options.env?.HOOPILOT_LOG_LEVEL)
498
589
  );
499
590
  }
500
591
  function errorDetails(error) {
@@ -609,6 +700,51 @@ function chatCompletionToCompletion(completion) {
609
700
  usage: completion.usage
610
701
  });
611
702
  }
703
+ function completionStreamFromChatStream(chatStream) {
704
+ const encoder = new TextEncoder();
705
+ const decoder = new TextDecoder();
706
+ let buffer = "";
707
+ let sawTerminalEvent = false;
708
+ return new ReadableStream({
709
+ async start(controller) {
710
+ const enqueue = (data) => {
711
+ controller.enqueue(encoder.encode(encodeDataSse(data)));
712
+ };
713
+ const markTerminal = () => {
714
+ sawTerminalEvent = true;
715
+ };
716
+ const reader = chatStream.getReader();
717
+ try {
718
+ while (true) {
719
+ const result = await reader.read();
720
+ if (result.done) {
721
+ break;
722
+ }
723
+ buffer += decoder.decode(result.value, { stream: true });
724
+ const blocks = buffer.split(/\r?\n\r?\n/);
725
+ buffer = blocks.pop() ?? "";
726
+ for (const block of blocks) {
727
+ processCompletionSseBlock(block, enqueue, markTerminal);
728
+ }
729
+ }
730
+ const tail = `${buffer}${decoder.decode()}`;
731
+ if (tail.trim()) {
732
+ processCompletionSseBlock(tail, enqueue, markTerminal);
733
+ }
734
+ if (!sawTerminalEvent) {
735
+ enqueue("[DONE]");
736
+ }
737
+ controller.close();
738
+ } catch (error) {
739
+ await reader.cancel(error).catch(() => {
740
+ });
741
+ controller.error(error);
742
+ } finally {
743
+ reader.releaseLock();
744
+ }
745
+ }
746
+ });
747
+ }
612
748
  function normalizeModelsResponse(upstream) {
613
749
  const record = asRecord(upstream);
614
750
  const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
@@ -641,38 +777,99 @@ function responsesStreamFromChatStream(chatStream, options) {
641
777
  const createdAt = epochSeconds();
642
778
  let buffer = "";
643
779
  let text = "";
780
+ let messageOutputIndex;
781
+ let nextOutputIndex = 0;
782
+ let sequenceNumber = 0;
644
783
  const tools = /* @__PURE__ */ new Map();
645
784
  return new ReadableStream({
646
785
  async start(controller) {
647
786
  const enqueue = (event, data) => {
648
- controller.enqueue(encoder.encode(encodeSse(event, data)));
787
+ controller.enqueue(
788
+ encoder.encode(
789
+ encodeSse(
790
+ event,
791
+ data === "[DONE]" ? data : { ...data, sequence_number: sequenceNumber++ }
792
+ )
793
+ )
794
+ );
649
795
  };
650
796
  enqueue("response.created", {
651
797
  response: baseStreamResponse(responseId, options.model, createdAt, "in_progress", []),
652
798
  type: "response.created"
653
799
  });
654
- enqueue("response.output_item.added", {
655
- item: {
656
- content: [],
657
- id: messageId,
658
- role: "assistant",
659
- status: "in_progress",
660
- type: "message"
661
- },
662
- output_index: 0,
663
- type: "response.output_item.added"
664
- });
665
- enqueue("response.content_part.added", {
666
- content_index: 0,
667
- item_id: messageId,
668
- output_index: 0,
669
- part: {
670
- annotations: [],
671
- text: "",
672
- type: "output_text"
673
- },
674
- type: "response.content_part.added"
675
- });
800
+ const ensureMessageStarted = () => {
801
+ if (messageOutputIndex !== void 0) {
802
+ return;
803
+ }
804
+ messageOutputIndex = nextOutputIndex++;
805
+ enqueue("response.output_item.added", {
806
+ item: {
807
+ content: [],
808
+ id: messageId,
809
+ role: "assistant",
810
+ status: "in_progress",
811
+ type: "message"
812
+ },
813
+ output_index: messageOutputIndex,
814
+ type: "response.output_item.added"
815
+ });
816
+ enqueue("response.content_part.added", {
817
+ content_index: 0,
818
+ item_id: messageId,
819
+ output_index: messageOutputIndex,
820
+ part: {
821
+ annotations: [],
822
+ text: "",
823
+ type: "output_text"
824
+ },
825
+ type: "response.content_part.added"
826
+ });
827
+ };
828
+ const appendText = (delta) => {
829
+ ensureMessageStarted();
830
+ text += delta;
831
+ enqueue("response.output_text.delta", {
832
+ content_index: 0,
833
+ delta,
834
+ item_id: messageId,
835
+ output_index: messageOutputIndex ?? 0,
836
+ type: "response.output_text.delta"
837
+ });
838
+ };
839
+ const appendToolCall = (toolCall) => {
840
+ const fn = asRecord(toolCall.function);
841
+ const index = typeof toolCall.index === "number" ? toolCall.index : tools.size;
842
+ let existing = tools.get(index);
843
+ const isNew = !existing;
844
+ existing ??= {
845
+ arguments: "",
846
+ id: contentToText(toolCall.id) || `call_${randomId()}`,
847
+ index,
848
+ itemId: `fc_${randomId()}`,
849
+ name: "",
850
+ outputIndex: nextOutputIndex++
851
+ };
852
+ existing.id = contentToText(toolCall.id) || existing.id;
853
+ existing.name += contentToText(fn.name);
854
+ tools.set(index, existing);
855
+ if (isNew) {
856
+ enqueue("response.output_item.added", {
857
+ item: functionCallItem(existing, "in_progress"),
858
+ output_index: existing.outputIndex ?? 0,
859
+ type: "response.output_item.added"
860
+ });
861
+ }
862
+ const argumentDelta = contentToText(fn.arguments);
863
+ if (argumentDelta) {
864
+ existing.arguments += argumentDelta;
865
+ enqueue("response.function_call_arguments.delta", {
866
+ delta: argumentDelta,
867
+ item_id: existing.itemId,
868
+ output_index: existing.outputIndex ?? 0,
869
+ type: "response.function_call_arguments.delta"
870
+ });
871
+ }
872
+ };
676
873
  const reader = chatStream.getReader();
677
874
  try {
678
875
  while (true) {
@@ -684,50 +881,48 @@ function responsesStreamFromChatStream(chatStream, options) {
684
881
  const lines = buffer.split(/\r?\n/);
685
882
  buffer = lines.pop() ?? "";
686
883
  for (const line of lines) {
687
- processChatSseLine(messageId, line, enqueue, tools, (delta) => {
688
- text += delta;
689
- });
884
+ processChatSseLine(line, { appendText, appendToolCall });
690
885
  }
691
886
  }
692
887
  if (buffer) {
693
- processChatSseLine(messageId, buffer, enqueue, tools, (delta) => {
694
- text += delta;
695
- });
888
+ processChatSseLine(buffer, { appendText, appendToolCall });
696
889
  }
697
- const toolItems = [...tools.values()].map(functionCallItem);
698
- const output = [messageOutputItem(text, messageId), ...toolItems];
699
- enqueue("response.output_text.done", {
700
- content_index: 0,
701
- item_id: messageId,
702
- output_index: 0,
703
- text,
704
- type: "response.output_text.done"
705
- });
706
- enqueue("response.content_part.done", {
707
- content_index: 0,
708
- item_id: messageId,
709
- output_index: 0,
710
- part: {
711
- annotations: [],
890
+ const outputEntries = [];
891
+ if (messageOutputIndex !== void 0) {
892
+ const item = messageOutputItem(text, messageId);
893
+ outputEntries.push([messageOutputIndex, item]);
894
+ enqueue("response.output_text.done", {
895
+ content_index: 0,
896
+ item_id: messageId,
897
+ output_index: messageOutputIndex,
712
898
  text,
713
- type: "output_text"
714
- },
715
- type: "response.content_part.done"
716
- });
717
- enqueue("response.output_item.done", {
718
- item: output[0],
719
- output_index: 0,
720
- type: "response.output_item.done"
721
- });
722
- toolItems.forEach((item, index) => {
723
- const outputIndex = index + 1;
724
- enqueue("response.output_item.added", {
899
+ type: "response.output_text.done"
900
+ });
901
+ enqueue("response.content_part.done", {
902
+ content_index: 0,
903
+ item_id: messageId,
904
+ output_index: messageOutputIndex,
905
+ part: {
906
+ annotations: [],
907
+ text,
908
+ type: "output_text"
909
+ },
910
+ type: "response.content_part.done"
911
+ });
912
+ enqueue("response.output_item.done", {
725
913
  item,
726
- output_index: outputIndex,
727
- type: "response.output_item.added"
914
+ output_index: messageOutputIndex,
915
+ type: "response.output_item.done"
728
916
  });
917
+ }
918
+ for (const tool of [...tools.values()].sort(
919
+ (a, b) => (a.outputIndex ?? 0) - (b.outputIndex ?? 0)
920
+ )) {
921
+ const item = functionCallItem(tool);
922
+ const outputIndex = tool.outputIndex ?? 0;
923
+ outputEntries.push([outputIndex, item]);
729
924
  enqueue("response.function_call_arguments.done", {
730
- arguments: item.arguments,
925
+ arguments: tool.arguments,
731
926
  item_id: item.id,
732
927
  output_index: outputIndex,
733
928
  type: "response.function_call_arguments.done"
@@ -737,7 +932,8 @@ function responsesStreamFromChatStream(chatStream, options) {
737
932
  output_index: outputIndex,
738
933
  type: "response.output_item.done"
739
934
  });
740
- });
935
+ }
936
+ const output = outputEntries.sort(([left], [right]) => left - right).map(([, item]) => item);
741
937
  enqueue("response.completed", {
742
938
  response: baseStreamResponse(responseId, options.model, createdAt, "completed", output),
743
939
  type: "response.completed"
@@ -920,13 +1116,13 @@ function messageOutputItem(text, id = `msg_${randomId()}`) {
920
1116
  type: "message"
921
1117
  };
922
1118
  }
923
- function functionCallItem(tool) {
1119
+ function functionCallItem(tool, status = "completed") {
924
1120
  return {
925
1121
  arguments: tool.arguments,
926
1122
  call_id: tool.id,
927
- id: `fc_${randomId()}`,
1123
+ id: tool.itemId ?? `fc_${randomId()}`,
928
1124
  name: tool.name,
929
- status: "completed",
1125
+ status,
930
1126
  type: "function_call"
931
1127
  };
932
1128
  }
@@ -941,14 +1137,27 @@ function responseUsage(usage) {
941
1137
  if (Object.keys(record).length === 0) {
942
1138
  return null;
943
1139
  }
1140
+ const inputTokens = record.prompt_tokens;
1141
+ const outputTokens = record.completion_tokens;
944
1142
  return removeUndefined({
945
- input_tokens: record.prompt_tokens,
946
- input_tokens_details: record.prompt_tokens_details,
947
- output_tokens: record.completion_tokens,
948
- output_tokens_details: record.completion_tokens_details,
1143
+ input_tokens: inputTokens,
1144
+ input_tokens_details: responseUsageDetails(record.prompt_tokens_details, inputTokens, {
1145
+ cached_tokens: 0
1146
+ }),
1147
+ output_tokens: outputTokens,
1148
+ output_tokens_details: responseUsageDetails(record.completion_tokens_details, outputTokens, {
1149
+ reasoning_tokens: 0
1150
+ }),
949
1151
  total_tokens: record.total_tokens
950
1152
  });
951
1153
  }
1154
+ function responseUsageDetails(value, tokenCount, fallback) {
1155
+ const record = asRecord(value);
1156
+ if (Object.keys(record).length > 0) {
1157
+ return record;
1158
+ }
1159
+ return typeof tokenCount === "number" && Number.isFinite(tokenCount) ? fallback : void 0;
1160
+ }
952
1161
  function extractTokenUsage(usage) {
953
1162
  const record = asRecord(usage);
954
1163
  const prompt = firstNumber(record.prompt_tokens, record.input_tokens);
@@ -987,7 +1196,80 @@ function firstChoice(completion) {
987
1196
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
988
1197
  return asRecord(choices[0]);
989
1198
  }
990
- function processChatSseLine(messageId, line, enqueue, tools, appendText) {
1199
+ function processCompletionSseBlock(block, enqueue, markTerminal) {
1200
+ let event = "message";
1201
+ const dataLines = [];
1202
+ for (const line of block.split(/\r?\n/)) {
1203
+ const trimmed = line.trim();
1204
+ if (trimmed.startsWith("event:")) {
1205
+ event = trimmed.slice("event:".length).trim() || event;
1206
+ } else if (trimmed.startsWith("data:")) {
1207
+ dataLines.push(trimmed.slice("data:".length).trim());
1208
+ }
1209
+ }
1210
+ const data = dataLines.join("\n");
1211
+ if (!data) {
1212
+ return;
1213
+ }
1214
+ if (data === "[DONE]") {
1215
+ markTerminal();
1216
+ enqueue("[DONE]");
1217
+ return;
1218
+ }
1219
+ const parsed = parseJson(data);
1220
+ if (!parsed) {
1221
+ return;
1222
+ }
1223
+ const error = completionStreamError(event, parsed);
1224
+ if (error) {
1225
+ markTerminal();
1226
+ enqueue({ error });
1227
+ return;
1228
+ }
1229
+ const choice = firstChoice(parsed);
1230
+ const delta = asRecord(choice.delta);
1231
+ const text = contentToText(delta.content);
1232
+ const finishReason = choice.finish_reason ?? null;
1233
+ const usage = asRecord(parsed.usage);
1234
+ const hasUsage = Object.keys(usage).length > 0;
1235
+ if (!text && finishReason === null && !hasUsage) {
1236
+ return;
1237
+ }
1238
+ enqueue(
1239
+ removeUndefined({
1240
+ choices: text || finishReason !== null ? [
1241
+ {
1242
+ finish_reason: finishReason,
1243
+ index: typeof choice.index === "number" ? choice.index : 0,
1244
+ logprobs: null,
1245
+ text
1246
+ }
1247
+ ] : [],
1248
+ created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
1249
+ id: contentToText(parsed.id) || `cmpl_${randomId()}`,
1250
+ model: contentToText(parsed.model) || DEFAULT_MODEL,
1251
+ object: "text_completion",
1252
+ usage: hasUsage ? usage : void 0
1253
+ })
1254
+ );
1255
+ }
1256
+ function completionStreamError(event, parsed) {
1257
+ const responseError = asRecord(asRecord(parsed.response).error);
1258
+ const directError = asRecord(parsed.error);
1259
+ const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
1260
+ if (error) {
1261
+ return error;
1262
+ }
1263
+ if (event === "error" || parsed.type === "response.failed") {
1264
+ return removeUndefined({
1265
+ code: contentToText(parsed.code) || void 0,
1266
+ message: contentToText(parsed.message) || "Upstream streaming request failed.",
1267
+ type: contentToText(parsed.type) || "upstream_stream_error"
1268
+ });
1269
+ }
1270
+ return void 0;
1271
+ }
1272
+ function processChatSseLine(line, handlers) {
991
1273
  const trimmed = line.trim();
992
1274
  if (!trimmed.startsWith("data:")) {
993
1275
  return;
@@ -1004,30 +1286,11 @@ function processChatSseLine(messageId, line, enqueue, tools, appendText) {
1004
1286
  const delta = asRecord(choice.delta);
1005
1287
  const content = contentToText(delta.content);
1006
1288
  if (content) {
1007
- appendText(content);
1008
- enqueue("response.output_text.delta", {
1009
- content_index: 0,
1010
- delta: content,
1011
- item_id: messageId,
1012
- output_index: 0,
1013
- type: "response.output_text.delta"
1014
- });
1289
+ handlers.appendText(content);
1015
1290
  }
1016
1291
  const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
1017
1292
  for (const toolCall of toolCalls) {
1018
- const record = asRecord(toolCall);
1019
- const fn = asRecord(record.function);
1020
- const index = typeof record.index === "number" ? record.index : tools.size;
1021
- const existing = tools.get(index) ?? {
1022
- arguments: "",
1023
- id: contentToText(record.id) || `call_${randomId()}`,
1024
- index,
1025
- name: ""
1026
- };
1027
- existing.id = contentToText(record.id) || existing.id;
1028
- existing.name += contentToText(fn.name);
1029
- existing.arguments += contentToText(fn.arguments);
1030
- tools.set(index, existing);
1293
+ handlers.appendToolCall(asRecord(toolCall));
1031
1294
  }
1032
1295
  }
1033
1296
  function baseStreamResponse(id, model, createdAt, status, output) {
@@ -1057,6 +1320,14 @@ function encodeSse(event, data) {
1057
1320
  return `event: ${event}
1058
1321
  data: ${JSON.stringify(data)}
1059
1322
 
1323
+ `;
1324
+ }
1325
+ function encodeDataSse(data) {
1326
+ if (data === "[DONE]") {
1327
+ return "data: [DONE]\n\n";
1328
+ }
1329
+ return `data: ${JSON.stringify(data)}
1330
+
1060
1331
  `;
1061
1332
  }
1062
1333
  function parseJson(data) {
@@ -1445,11 +1716,21 @@ function formatNumber(value) {
1445
1716
  // src/server.ts
1446
1717
  var DEFAULT_HOST = "127.0.0.1";
1447
1718
  var DEFAULT_PORT = 4141;
1719
+ var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
1448
1720
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
1721
+ var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
1722
+ var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
1723
+ var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
1449
1724
  var USAGE_CACHE_TTL_MS = 6e4;
1725
+ var RequestBodyTooLargeError = class extends Error {
1726
+ constructor() {
1727
+ super(REQUEST_TOO_LARGE_MESSAGE);
1728
+ this.name = "RequestBodyTooLargeError";
1729
+ }
1730
+ };
1450
1731
  function createHoopilotHandler(options = {}) {
1451
1732
  const client = new CopilotClient(options);
1452
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1733
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
1453
1734
  const logger = serverLogger(options);
1454
1735
  const metrics = options.metrics ?? new MetricsRegistry();
1455
1736
  const readUsage = createUsageReader(client, metrics);
@@ -1475,6 +1756,14 @@ function createHoopilotHandler(options = {}) {
1475
1756
  route,
1476
1757
  startedAt
1477
1758
  });
1759
+ const browserOrigin = forbiddenBrowserOrigin(request, apiKey);
1760
+ if (browserOrigin) {
1761
+ requestLogger.warn(
1762
+ { event: "http.request.forbidden_origin", origin: browserOrigin },
1763
+ "blocked unauthenticated browser-origin request"
1764
+ );
1765
+ return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
1766
+ }
1478
1767
  if (request.method === "OPTIONS") {
1479
1768
  return finish(new Response(null, { headers: corsHeaders() }));
1480
1769
  }
@@ -1527,6 +1816,12 @@ function createHoopilotHandler(options = {}) {
1527
1816
  "request body was invalid json"
1528
1817
  );
1529
1818
  return finish(jsonError(400, "invalid_request_error", message));
1819
+ } else if (error instanceof RequestBodyTooLargeError) {
1820
+ requestLogger.warn(
1821
+ { err: errorDetails(error), event: "http.request.failed" },
1822
+ "request body exceeded size limit"
1823
+ );
1824
+ return finish(jsonError(413, "request_too_large", message));
1530
1825
  } else {
1531
1826
  requestLogger.error(
1532
1827
  { err: errorDetails(error), event: "http.request.failed" },
@@ -1538,10 +1833,10 @@ function createHoopilotHandler(options = {}) {
1538
1833
  };
1539
1834
  }
1540
1835
  function startHoopilotServer(options = {}) {
1541
- const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
1542
- const port = Number(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1543
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1544
- const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
1836
+ const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
1837
+ const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
1838
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
1839
+ const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
1545
1840
  if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
1546
1841
  throw new Error(
1547
1842
  "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
@@ -1559,7 +1854,7 @@ function startHoopilotServer(options = {}) {
1559
1854
  });
1560
1855
  return {
1561
1856
  server,
1562
- url: `http://${host}:${server.port}`
1857
+ url: `http://${urlHost(host)}:${server.port}`
1563
1858
  };
1564
1859
  }
1565
1860
  async function handleModels(client, metrics, signal, logger) {
@@ -1605,8 +1900,19 @@ async function handleCompletions(client, metrics, recordTokens, request, logger)
1605
1900
  }
1606
1901
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
1607
1902
  const model = normalizeRequestedModel(body.model);
1608
- if (isStreamingResponse(upstream)) {
1609
- return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
1903
+ if (isStreamingResponse(upstream) && upstream.body) {
1904
+ return proxyResponse(
1905
+ observeResponseUsage(
1906
+ new Response(completionStreamFromChatStream(upstream.body), {
1907
+ headers: upstream.headers,
1908
+ status: upstream.status,
1909
+ statusText: upstream.statusText
1910
+ }),
1911
+ model,
1912
+ recordTokens,
1913
+ request.signal
1914
+ )
1915
+ );
1610
1916
  }
1611
1917
  const completion = asRecord(await upstream.json());
1612
1918
  const usage = extractTokenUsage(completion.usage);
@@ -1640,7 +1946,7 @@ async function proxyError(upstream, logger) {
1640
1946
  { event: "copilot.request.failed", upstreamStatus: upstream.status },
1641
1947
  "copilot upstream request failed"
1642
1948
  );
1643
- return jsonError(upstream.status, "copilot_error", text || upstream.statusText);
1949
+ return upstreamErrorResponse(upstream.status, text || upstream.statusText);
1644
1950
  }
1645
1951
  function proxyResponse(upstream) {
1646
1952
  const headers = new Headers(upstream.headers);
@@ -1657,14 +1963,15 @@ function proxyResponse(upstream) {
1657
1963
  });
1658
1964
  }
1659
1965
  async function readJson(request) {
1966
+ const text = await readRequestText(request);
1660
1967
  try {
1661
- return asRecord(await request.json());
1968
+ return asRecord(JSON.parse(text));
1662
1969
  } catch {
1663
1970
  throw new Error(INVALID_JSON_MESSAGE);
1664
1971
  }
1665
1972
  }
1666
1973
  async function readJsonText(request) {
1667
- const text = await request.text();
1974
+ const text = await readRequestText(request);
1668
1975
  try {
1669
1976
  JSON.parse(text);
1670
1977
  return text;
@@ -1672,6 +1979,40 @@ async function readJsonText(request) {
1672
1979
  throw new Error(INVALID_JSON_MESSAGE);
1673
1980
  }
1674
1981
  }
1982
+ async function readRequestText(request) {
1983
+ const contentLength = request.headers.get("content-length");
1984
+ if (contentLength) {
1985
+ const declaredBytes = Number(contentLength);
1986
+ if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
1987
+ throw new RequestBodyTooLargeError();
1988
+ }
1989
+ }
1990
+ const body = request.body;
1991
+ if (!body) {
1992
+ return "";
1993
+ }
1994
+ const reader = body.getReader();
1995
+ const decoder = new TextDecoder();
1996
+ let bytes = 0;
1997
+ let text = "";
1998
+ try {
1999
+ while (true) {
2000
+ const { done, value } = await reader.read();
2001
+ if (done) {
2002
+ return `${text}${decoder.decode()}`;
2003
+ }
2004
+ bytes += value.byteLength;
2005
+ if (bytes > MAX_REQUEST_BODY_BYTES) {
2006
+ await reader.cancel().catch(() => {
2007
+ });
2008
+ throw new RequestBodyTooLargeError();
2009
+ }
2010
+ text += decoder.decode(value, { stream: true });
2011
+ }
2012
+ } finally {
2013
+ reader.releaseLock();
2014
+ }
2015
+ }
1675
2016
  function jsonResponse(body, status = 200) {
1676
2017
  return new Response(JSON.stringify(body), {
1677
2018
  headers: {
@@ -1693,6 +2034,13 @@ function jsonError(status, code, message) {
1693
2034
  status
1694
2035
  );
1695
2036
  }
2037
+ function upstreamErrorResponse(status, text) {
2038
+ const parsedError = asRecord(asRecord(safeParseJson(text)).error);
2039
+ if (Object.keys(parsedError).length > 0) {
2040
+ return jsonResponse({ error: parsedError }, status);
2041
+ }
2042
+ return jsonError(status, "copilot_error", text);
2043
+ }
1696
2044
  function websocketUnsupportedResponse() {
1697
2045
  const response = jsonError(
1698
2046
  426,
@@ -1717,6 +2065,17 @@ function isAuthorized(request, apiKey) {
1717
2065
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
1718
2066
  return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
1719
2067
  }
2068
+ function forbiddenBrowserOrigin(request, apiKey) {
2069
+ if (apiKey) {
2070
+ return void 0;
2071
+ }
2072
+ const origin = request.headers.get("origin")?.trim();
2073
+ if (origin) {
2074
+ return isLoopbackOrigin(origin) ? void 0 : origin;
2075
+ }
2076
+ const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
2077
+ return fetchSite === "cross-site" ? "cross-site" : void 0;
2078
+ }
1720
2079
  function isUpstreamAuthStatus(status) {
1721
2080
  return status === 401 || status === 403;
1722
2081
  }
@@ -1724,7 +2083,24 @@ function upstreamAuthMessage(message) {
1724
2083
  return `GitHub Copilot rejected the credential or account access: ${message}`;
1725
2084
  }
1726
2085
  function isLoopbackHost(host) {
1727
- return host === "localhost" || host === "127.0.0.1" || host === "::1";
2086
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
2087
+ }
2088
+ function urlHost(host) {
2089
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
2090
+ }
2091
+ function isLoopbackOrigin(origin) {
2092
+ try {
2093
+ return isLoopbackHost(new URL(origin).hostname.toLowerCase());
2094
+ } catch {
2095
+ return false;
2096
+ }
2097
+ }
2098
+ function normalizeServerPort(value) {
2099
+ const port = Number(value);
2100
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
2101
+ throw new Error(`Invalid port: ${value}.`);
2102
+ }
2103
+ return port;
1728
2104
  }
1729
2105
  function errorMessage(error) {
1730
2106
  return error instanceof Error ? error.message : String(error);
@@ -1819,7 +2195,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
1819
2195
  }
1820
2196
  function requestIdFor(request) {
1821
2197
  const existing = request.headers.get("x-request-id")?.trim();
1822
- return existing || crypto.randomUUID();
2198
+ return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
1823
2199
  }
1824
2200
  function canonicalApiPath(path) {
1825
2201
  const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;
@@ -1948,6 +2324,7 @@ export {
1948
2324
  authStorePath,
1949
2325
  chatCompletionToCompletion,
1950
2326
  chatCompletionToResponse,
2327
+ completionStreamFromChatStream,
1951
2328
  completionsRequestToChatCompletion,
1952
2329
  createHoopilotHandler,
1953
2330
  createHoopilotLogger,