@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.cjs CHANGED
@@ -45,6 +45,7 @@ __export(index_exports, {
45
45
  authStorePath: () => authStorePath,
46
46
  chatCompletionToCompletion: () => chatCompletionToCompletion,
47
47
  chatCompletionToResponse: () => chatCompletionToResponse,
48
+ completionStreamFromChatStream: () => completionStreamFromChatStream,
48
49
  completionsRequestToChatCompletion: () => completionsRequestToChatCompletion,
49
50
  createHoopilotHandler: () => createHoopilotHandler,
50
51
  createHoopilotLogger: () => createHoopilotLogger,
@@ -70,33 +71,117 @@ module.exports = __toCommonJS(index_exports);
70
71
  // src/auth-store.ts
71
72
  var import_node_fs = require("fs");
72
73
  var import_node_path = require("path");
74
+
75
+ // src/util.ts
76
+ function trimTrailingSlash(value) {
77
+ return value.replace(/\/+$/, "");
78
+ }
79
+ function envValue(value) {
80
+ const trimmed = value?.trim();
81
+ return trimmed ? trimmed : void 0;
82
+ }
83
+ function isTrustedTokenBaseUrl(rawUrl, allowedHttpsHosts, allowUnsafeHttps = false) {
84
+ const url = parseUrl(rawUrl);
85
+ if (!url) {
86
+ return false;
87
+ }
88
+ if (url.username || url.password || url.search || url.hash) {
89
+ return false;
90
+ }
91
+ if (url.pathname !== "" && url.pathname !== "/") {
92
+ return false;
93
+ }
94
+ if (isLoopbackHttpUrl(url)) {
95
+ return true;
96
+ }
97
+ if (url.protocol !== "https:") {
98
+ return false;
99
+ }
100
+ const host = url.hostname.toLowerCase();
101
+ return allowedHttpsHosts.includes(host) || allowUnsafeHttps;
102
+ }
103
+ function parseUrl(rawUrl) {
104
+ let url;
105
+ try {
106
+ url = new URL(rawUrl);
107
+ } catch {
108
+ return void 0;
109
+ }
110
+ return url;
111
+ }
112
+ function isLoopbackHttpUrl(url) {
113
+ return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
114
+ }
115
+ async function truncatedResponseText(response, max = 500) {
116
+ const text = await response.text();
117
+ return text.slice(0, max);
118
+ }
119
+ function asRecord(value) {
120
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
121
+ }
122
+
123
+ // src/auth-store.ts
124
+ var StoredCopilotAuthError = class extends Error {
125
+ constructor(message) {
126
+ super(message);
127
+ this.name = "StoredCopilotAuthError";
128
+ }
129
+ };
73
130
  function authStorePath(env = process.env) {
74
- if (env.HOOPILOT_AUTH_FILE) {
75
- return env.HOOPILOT_AUTH_FILE;
131
+ const explicit = envValue(env.HOOPILOT_AUTH_FILE);
132
+ if (explicit) {
133
+ return explicit;
134
+ }
135
+ const xdg = envValue(env.XDG_CONFIG_HOME);
136
+ if (xdg) {
137
+ return (0, import_node_path.join)(xdg, "hoopilot", "auth.json");
138
+ }
139
+ const appdata = envValue(env.APPDATA);
140
+ if (appdata) {
141
+ return (0, import_node_path.join)(appdata, "hoopilot", "auth.json");
142
+ }
143
+ const home = envValue(env.HOME);
144
+ if (!home) {
145
+ throw new StoredCopilotAuthError(
146
+ "Cannot resolve Hoopilot auth file path without HOOPILOT_AUTH_FILE, XDG_CONFIG_HOME, APPDATA, or HOME."
147
+ );
76
148
  }
77
- const base = env.XDG_CONFIG_HOME ?? env.APPDATA ?? (env.HOME ? (0, import_node_path.join)(env.HOME, ".config") : (0, import_node_path.join)(process.cwd(), ".config"));
149
+ const base = (0, import_node_path.join)(home, ".config");
78
150
  return (0, import_node_path.join)(base, "hoopilot", "auth.json");
79
151
  }
80
152
  function readStoredCopilotAuth(path = authStorePath()) {
153
+ let text;
81
154
  try {
82
- const parsed = JSON.parse((0, import_node_fs.readFileSync)(path, "utf8"));
83
- if (!parsed || typeof parsed !== "object") {
155
+ text = (0, import_node_fs.readFileSync)(path, "utf8");
156
+ } catch (error) {
157
+ if (error.code === "ENOENT") {
84
158
  return void 0;
85
159
  }
86
- const token = typeof parsed.token === "string" ? parsed.token.trim() : "";
87
- if (!token) {
88
- return void 0;
89
- }
90
- return {
91
- apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : void 0,
92
- createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : void 0,
93
- githubDomain: typeof parsed.githubDomain === "string" ? parsed.githubDomain : void 0,
94
- source: typeof parsed.source === "string" ? parsed.source : void 0,
95
- token
96
- };
160
+ throw new StoredCopilotAuthError(`Could not read Hoopilot auth file at ${path}.`);
161
+ }
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(text);
97
165
  } catch {
98
- return void 0;
166
+ throw new StoredCopilotAuthError(
167
+ `Hoopilot auth file at ${path} is not valid JSON. Run \`hoopilot login\` to replace it.`
168
+ );
169
+ }
170
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
171
+ throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
99
172
  }
173
+ const record = parsed;
174
+ const token = typeof record.token === "string" ? record.token.trim() : "";
175
+ if (!token) {
176
+ throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
177
+ }
178
+ return {
179
+ apiBaseUrl: typeof record.apiBaseUrl === "string" ? record.apiBaseUrl : void 0,
180
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : void 0,
181
+ githubDomain: typeof record.githubDomain === "string" ? record.githubDomain : void 0,
182
+ source: typeof record.source === "string" ? record.source : void 0,
183
+ token
184
+ };
100
185
  }
101
186
  function writeStoredCopilotAuth(auth, path = authStorePath()) {
102
187
  (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
@@ -118,18 +203,6 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
118
203
  }
119
204
  }
120
205
 
121
- // src/util.ts
122
- function trimTrailingSlash(value) {
123
- return value.replace(/\/+$/, "");
124
- }
125
- async function truncatedResponseText(response, max = 500) {
126
- const text = await response.text();
127
- return text.slice(0, max);
128
- }
129
- function asRecord(value) {
130
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
131
- }
132
-
133
206
  // src/auth.ts
134
207
  var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
135
208
  var REFRESH_SKEW_MS = 6e4;
@@ -143,21 +216,35 @@ var CopilotAuthError = class extends Error {
143
216
  var CopilotAuth = class {
144
217
  #authStorePath;
145
218
  #copilotApiBaseUrl;
219
+ #hasCopilotApiBaseUrlOverride;
146
220
  #cachedAccess;
147
221
  constructor(options = {}) {
148
- this.#authStorePath = options.authStorePath ?? options.env?.HOOPILOT_AUTH_FILE;
222
+ const envAuthStorePath = envValue(options.env?.HOOPILOT_AUTH_FILE);
223
+ const envCopilotApiBaseUrl = envValue(options.env?.COPILOT_API_BASE_URL);
224
+ this.#authStorePath = options.authStorePath ?? envAuthStorePath;
225
+ this.#hasCopilotApiBaseUrlOverride = Boolean(options.copilotApiBaseUrl ?? envCopilotApiBaseUrl);
149
226
  this.#copilotApiBaseUrl = trimTrailingSlash(
150
- options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
227
+ options.copilotApiBaseUrl ?? envCopilotApiBaseUrl ?? DEFAULT_COPILOT_API_BASE_URL
151
228
  );
152
229
  }
153
230
  async getAccess() {
154
231
  if (this.#cachedAccess && this.#cachedAccess.expiresAtMs - REFRESH_SKEW_MS > Date.now()) {
155
232
  return this.#cachedAccess;
156
233
  }
157
- const stored = readStoredCopilotAuth(this.#authStorePath);
234
+ let stored;
235
+ try {
236
+ stored = readStoredCopilotAuth(this.#authStorePath);
237
+ } catch (error) {
238
+ if (error instanceof StoredCopilotAuthError) {
239
+ throw new CopilotAuthError(error.message);
240
+ }
241
+ throw error;
242
+ }
158
243
  if (stored) {
159
244
  return this.#cacheAccess({
160
- apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
245
+ apiBaseUrl: trimTrailingSlash(
246
+ this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
247
+ ),
161
248
  expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
162
249
  source: "github-copilot-oauth",
163
250
  token: stored.token
@@ -175,6 +262,8 @@ var CopilotAuth = class {
175
262
 
176
263
  // src/copilot.ts
177
264
  var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
265
+ var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
266
+ var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
178
267
  var COPILOT_USAGE_API_VERSION = "2025-04-01";
179
268
  function applyCopilotHeaders(headers, token) {
180
269
  headers.set("accept", headers.get("accept") ?? "application/json");
@@ -198,13 +287,15 @@ function applyGithubApiHeaders(headers, token) {
198
287
  }
199
288
  var CopilotClient = class {
200
289
  #auth;
290
+ #allowUnsafeUpstream;
201
291
  #fetch;
202
292
  #githubApiBaseUrl;
203
293
  constructor(options = {}) {
204
294
  this.#auth = new CopilotAuth(options);
295
+ this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
205
296
  this.#fetch = options.fetch ?? fetch;
206
297
  this.#githubApiBaseUrl = trimTrailingSlash(
207
- options.githubApiBaseUrl ?? options.env?.HOOPILOT_GITHUB_API_BASE_URL ?? DEFAULT_GITHUB_API_BASE_URL
298
+ options.githubApiBaseUrl ?? envValue(options.env?.HOOPILOT_GITHUB_API_BASE_URL) ?? DEFAULT_GITHUB_API_BASE_URL
208
299
  );
209
300
  }
210
301
  /**
@@ -213,9 +304,13 @@ var CopilotClient = class {
213
304
  * accepted directly here — no Copilot token exchange is required to read quota.
214
305
  */
215
306
  async usage(signal) {
216
- if (!isHttpsOrLoopback(this.#githubApiBaseUrl)) {
307
+ if (!isTrustedTokenBaseUrl(
308
+ this.#githubApiBaseUrl,
309
+ ALLOWED_GITHUB_API_HOSTS,
310
+ this.#allowUnsafeUpstream
311
+ )) {
217
312
  throw new Error(
218
- `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${this.#githubApiBaseUrl}`
313
+ `Refusing to send the GitHub OAuth token to an untrusted GitHub API host: ${this.#githubApiBaseUrl}`
219
314
  );
220
315
  }
221
316
  const access = await this.#auth.getAccess();
@@ -257,6 +352,15 @@ var CopilotClient = class {
257
352
  }
258
353
  async fetchCopilot(path, init) {
259
354
  const access = await this.#auth.getAccess();
355
+ if (!isTrustedTokenBaseUrl(
356
+ access.apiBaseUrl,
357
+ ALLOWED_COPILOT_API_HOSTS,
358
+ this.#allowUnsafeUpstream
359
+ )) {
360
+ throw new Error(
361
+ `Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${access.apiBaseUrl}`
362
+ );
363
+ }
260
364
  const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
261
365
  return this.#fetch(`${access.apiBaseUrl}${path}`, {
262
366
  ...init,
@@ -312,18 +416,6 @@ function usedFrom(entitlement, remaining) {
312
416
  }
313
417
  return Math.max(0, entitlement - remaining);
314
418
  }
315
- function isHttpsOrLoopback(rawUrl) {
316
- let url;
317
- try {
318
- url = new URL(rawUrl);
319
- } catch {
320
- return false;
321
- }
322
- if (url.protocol === "https:") {
323
- return true;
324
- }
325
- return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1");
326
- }
327
419
  function numberOrUndefined(value) {
328
420
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
329
421
  }
@@ -352,9 +444,9 @@ async function githubCopilotDeviceLogin(options = {}) {
352
444
  const fetcher = options.fetch ?? fetch;
353
445
  const sleeper = options.sleep ?? import_promises.setTimeout;
354
446
  const domain = normalizeDomain(
355
- options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
447
+ options.domain ?? envValue(env.HOOPILOT_GITHUB_DOMAIN) ?? DEFAULT_GITHUB_DOMAIN
356
448
  );
357
- const clientId = options.clientId ?? env.HOOPILOT_GITHUB_CLIENT_ID ?? env.COPILOT_GITHUB_CLIENT_ID ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
449
+ const clientId = options.clientId ?? envValue(env.HOOPILOT_GITHUB_CLIENT_ID) ?? envValue(env.COPILOT_GITHUB_CLIENT_ID) ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
358
450
  const device = await requestDeviceCode(fetcher, domain, clientId);
359
451
  const verificationUrl = device.verification_uri;
360
452
  const userCode = device.user_code;
@@ -512,8 +604,8 @@ var noopLogger = {
512
604
  };
513
605
  function createHoopilotLogger(options = {}) {
514
606
  const env = options.env ?? process.env;
515
- const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
516
- const format = parseLogFormat(options.format ?? env.HOOPILOT_LOG_FORMAT);
607
+ const level = parseLogLevel(options.level ?? envValue(env.HOOPILOT_LOG_LEVEL));
608
+ const format = parseLogFormat(options.format ?? envValue(env.HOOPILOT_LOG_FORMAT));
517
609
  const pinoOptions = {
518
610
  base: {
519
611
  service: "hoopilot",
@@ -563,7 +655,7 @@ function parseLogLevel(value) {
563
655
  }
564
656
  function shouldCreateLogger(options) {
565
657
  return Boolean(
566
- options.logger || options.logFormat || options.logLevel || options.env?.HOOPILOT_LOG_FORMAT || options.env?.HOOPILOT_LOG_LEVEL
658
+ options.logger || options.logFormat || options.logLevel || envValue(options.env?.HOOPILOT_LOG_FORMAT) || envValue(options.env?.HOOPILOT_LOG_LEVEL)
567
659
  );
568
660
  }
569
661
  function errorDetails(error) {
@@ -678,6 +770,51 @@ function chatCompletionToCompletion(completion) {
678
770
  usage: completion.usage
679
771
  });
680
772
  }
773
+ function completionStreamFromChatStream(chatStream) {
774
+ const encoder = new TextEncoder();
775
+ const decoder = new TextDecoder();
776
+ let buffer = "";
777
+ let sawTerminalEvent = false;
778
+ return new ReadableStream({
779
+ async start(controller) {
780
+ const enqueue = (data) => {
781
+ controller.enqueue(encoder.encode(encodeDataSse(data)));
782
+ };
783
+ const markTerminal = () => {
784
+ sawTerminalEvent = true;
785
+ };
786
+ const reader = chatStream.getReader();
787
+ try {
788
+ while (true) {
789
+ const result = await reader.read();
790
+ if (result.done) {
791
+ break;
792
+ }
793
+ buffer += decoder.decode(result.value, { stream: true });
794
+ const blocks = buffer.split(/\r?\n\r?\n/);
795
+ buffer = blocks.pop() ?? "";
796
+ for (const block of blocks) {
797
+ processCompletionSseBlock(block, enqueue, markTerminal);
798
+ }
799
+ }
800
+ const tail = `${buffer}${decoder.decode()}`;
801
+ if (tail.trim()) {
802
+ processCompletionSseBlock(tail, enqueue, markTerminal);
803
+ }
804
+ if (!sawTerminalEvent) {
805
+ enqueue("[DONE]");
806
+ }
807
+ controller.close();
808
+ } catch (error) {
809
+ await reader.cancel(error).catch(() => {
810
+ });
811
+ controller.error(error);
812
+ } finally {
813
+ reader.releaseLock();
814
+ }
815
+ }
816
+ });
817
+ }
681
818
  function normalizeModelsResponse(upstream) {
682
819
  const record = asRecord(upstream);
683
820
  const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
@@ -710,38 +847,99 @@ function responsesStreamFromChatStream(chatStream, options) {
710
847
  const createdAt = epochSeconds();
711
848
  let buffer = "";
712
849
  let text = "";
850
+ let messageOutputIndex;
851
+ let nextOutputIndex = 0;
852
+ let sequenceNumber = 0;
713
853
  const tools = /* @__PURE__ */ new Map();
714
854
  return new ReadableStream({
715
855
  async start(controller) {
716
856
  const enqueue = (event, data) => {
717
- controller.enqueue(encoder.encode(encodeSse(event, data)));
857
+ controller.enqueue(
858
+ encoder.encode(
859
+ encodeSse(
860
+ event,
861
+ data === "[DONE]" ? data : { ...data, sequence_number: sequenceNumber++ }
862
+ )
863
+ )
864
+ );
718
865
  };
719
866
  enqueue("response.created", {
720
867
  response: baseStreamResponse(responseId, options.model, createdAt, "in_progress", []),
721
868
  type: "response.created"
722
869
  });
723
- enqueue("response.output_item.added", {
724
- item: {
725
- content: [],
726
- id: messageId,
727
- role: "assistant",
728
- status: "in_progress",
729
- type: "message"
730
- },
731
- output_index: 0,
732
- type: "response.output_item.added"
733
- });
734
- enqueue("response.content_part.added", {
735
- content_index: 0,
736
- item_id: messageId,
737
- output_index: 0,
738
- part: {
739
- annotations: [],
740
- text: "",
741
- type: "output_text"
742
- },
743
- type: "response.content_part.added"
744
- });
870
+ const ensureMessageStarted = () => {
871
+ if (messageOutputIndex !== void 0) {
872
+ return;
873
+ }
874
+ messageOutputIndex = nextOutputIndex++;
875
+ enqueue("response.output_item.added", {
876
+ item: {
877
+ content: [],
878
+ id: messageId,
879
+ role: "assistant",
880
+ status: "in_progress",
881
+ type: "message"
882
+ },
883
+ output_index: messageOutputIndex,
884
+ type: "response.output_item.added"
885
+ });
886
+ enqueue("response.content_part.added", {
887
+ content_index: 0,
888
+ item_id: messageId,
889
+ output_index: messageOutputIndex,
890
+ part: {
891
+ annotations: [],
892
+ text: "",
893
+ type: "output_text"
894
+ },
895
+ type: "response.content_part.added"
896
+ });
897
+ };
898
+ const appendText = (delta) => {
899
+ ensureMessageStarted();
900
+ text += delta;
901
+ enqueue("response.output_text.delta", {
902
+ content_index: 0,
903
+ delta,
904
+ item_id: messageId,
905
+ output_index: messageOutputIndex ?? 0,
906
+ type: "response.output_text.delta"
907
+ });
908
+ };
909
+ const appendToolCall = (toolCall) => {
910
+ const fn = asRecord(toolCall.function);
911
+ const index = typeof toolCall.index === "number" ? toolCall.index : tools.size;
912
+ let existing = tools.get(index);
913
+ const isNew = !existing;
914
+ existing ??= {
915
+ arguments: "",
916
+ id: contentToText(toolCall.id) || `call_${randomId()}`,
917
+ index,
918
+ itemId: `fc_${randomId()}`,
919
+ name: "",
920
+ outputIndex: nextOutputIndex++
921
+ };
922
+ existing.id = contentToText(toolCall.id) || existing.id;
923
+ existing.name += contentToText(fn.name);
924
+ tools.set(index, existing);
925
+ if (isNew) {
926
+ enqueue("response.output_item.added", {
927
+ item: functionCallItem(existing, "in_progress"),
928
+ output_index: existing.outputIndex ?? 0,
929
+ type: "response.output_item.added"
930
+ });
931
+ }
932
+ const argumentDelta = contentToText(fn.arguments);
933
+ if (argumentDelta) {
934
+ existing.arguments += argumentDelta;
935
+ enqueue("response.function_call_arguments.delta", {
936
+ delta: argumentDelta,
937
+ item_id: existing.itemId,
938
+ output_index: existing.outputIndex ?? 0,
939
+ type: "response.function_call_arguments.delta"
940
+ });
941
+ }
942
+ };
745
943
  const reader = chatStream.getReader();
746
944
  try {
747
945
  while (true) {
@@ -753,50 +951,48 @@ function responsesStreamFromChatStream(chatStream, options) {
753
951
  const lines = buffer.split(/\r?\n/);
754
952
  buffer = lines.pop() ?? "";
755
953
  for (const line of lines) {
756
- processChatSseLine(messageId, line, enqueue, tools, (delta) => {
757
- text += delta;
758
- });
954
+ processChatSseLine(line, { appendText, appendToolCall });
759
955
  }
760
956
  }
761
957
  if (buffer) {
762
- processChatSseLine(messageId, buffer, enqueue, tools, (delta) => {
763
- text += delta;
764
- });
958
+ processChatSseLine(buffer, { appendText, appendToolCall });
765
959
  }
766
- const toolItems = [...tools.values()].map(functionCallItem);
767
- const output = [messageOutputItem(text, messageId), ...toolItems];
768
- enqueue("response.output_text.done", {
769
- content_index: 0,
770
- item_id: messageId,
771
- output_index: 0,
772
- text,
773
- type: "response.output_text.done"
774
- });
775
- enqueue("response.content_part.done", {
776
- content_index: 0,
777
- item_id: messageId,
778
- output_index: 0,
779
- part: {
780
- annotations: [],
960
+ const outputEntries = [];
961
+ if (messageOutputIndex !== void 0) {
962
+ const item = messageOutputItem(text, messageId);
963
+ outputEntries.push([messageOutputIndex, item]);
964
+ enqueue("response.output_text.done", {
965
+ content_index: 0,
966
+ item_id: messageId,
967
+ output_index: messageOutputIndex,
781
968
  text,
782
- type: "output_text"
783
- },
784
- type: "response.content_part.done"
785
- });
786
- enqueue("response.output_item.done", {
787
- item: output[0],
788
- output_index: 0,
789
- type: "response.output_item.done"
790
- });
791
- toolItems.forEach((item, index) => {
792
- const outputIndex = index + 1;
793
- enqueue("response.output_item.added", {
969
+ type: "response.output_text.done"
970
+ });
971
+ enqueue("response.content_part.done", {
972
+ content_index: 0,
973
+ item_id: messageId,
974
+ output_index: messageOutputIndex,
975
+ part: {
976
+ annotations: [],
977
+ text,
978
+ type: "output_text"
979
+ },
980
+ type: "response.content_part.done"
981
+ });
982
+ enqueue("response.output_item.done", {
794
983
  item,
795
- output_index: outputIndex,
796
- type: "response.output_item.added"
984
+ output_index: messageOutputIndex,
985
+ type: "response.output_item.done"
797
986
  });
987
+ }
988
+ for (const tool of [...tools.values()].sort(
989
+ (a, b) => (a.outputIndex ?? 0) - (b.outputIndex ?? 0)
990
+ )) {
991
+ const item = functionCallItem(tool);
992
+ const outputIndex = tool.outputIndex ?? 0;
993
+ outputEntries.push([outputIndex, item]);
798
994
  enqueue("response.function_call_arguments.done", {
799
- arguments: item.arguments,
995
+ arguments: tool.arguments,
800
996
  item_id: item.id,
801
997
  output_index: outputIndex,
802
998
  type: "response.function_call_arguments.done"
@@ -806,7 +1002,8 @@ function responsesStreamFromChatStream(chatStream, options) {
806
1002
  output_index: outputIndex,
807
1003
  type: "response.output_item.done"
808
1004
  });
809
- });
1005
+ }
1006
+ const output = outputEntries.sort(([left], [right]) => left - right).map(([, item]) => item);
810
1007
  enqueue("response.completed", {
811
1008
  response: baseStreamResponse(responseId, options.model, createdAt, "completed", output),
812
1009
  type: "response.completed"
@@ -989,13 +1186,13 @@ function messageOutputItem(text, id = `msg_${randomId()}`) {
989
1186
  type: "message"
990
1187
  };
991
1188
  }
992
- function functionCallItem(tool) {
1189
+ function functionCallItem(tool, status = "completed") {
993
1190
  return {
994
1191
  arguments: tool.arguments,
995
1192
  call_id: tool.id,
996
- id: `fc_${randomId()}`,
1193
+ id: tool.itemId ?? `fc_${randomId()}`,
997
1194
  name: tool.name,
998
- status: "completed",
1195
+ status,
999
1196
  type: "function_call"
1000
1197
  };
1001
1198
  }
@@ -1010,14 +1207,27 @@ function responseUsage(usage) {
1010
1207
  if (Object.keys(record).length === 0) {
1011
1208
  return null;
1012
1209
  }
1210
+ const inputTokens = record.prompt_tokens;
1211
+ const outputTokens = record.completion_tokens;
1013
1212
  return removeUndefined({
1014
- input_tokens: record.prompt_tokens,
1015
- input_tokens_details: record.prompt_tokens_details,
1016
- output_tokens: record.completion_tokens,
1017
- output_tokens_details: record.completion_tokens_details,
1213
+ input_tokens: inputTokens,
1214
+ input_tokens_details: responseUsageDetails(record.prompt_tokens_details, inputTokens, {
1215
+ cached_tokens: 0
1216
+ }),
1217
+ output_tokens: outputTokens,
1218
+ output_tokens_details: responseUsageDetails(record.completion_tokens_details, outputTokens, {
1219
+ reasoning_tokens: 0
1220
+ }),
1018
1221
  total_tokens: record.total_tokens
1019
1222
  });
1020
1223
  }
1224
+ function responseUsageDetails(value, tokenCount, fallback) {
1225
+ const record = asRecord(value);
1226
+ if (Object.keys(record).length > 0) {
1227
+ return record;
1228
+ }
1229
+ return typeof tokenCount === "number" && Number.isFinite(tokenCount) ? fallback : void 0;
1230
+ }
1021
1231
  function extractTokenUsage(usage) {
1022
1232
  const record = asRecord(usage);
1023
1233
  const prompt = firstNumber(record.prompt_tokens, record.input_tokens);
@@ -1056,7 +1266,80 @@ function firstChoice(completion) {
1056
1266
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
1057
1267
  return asRecord(choices[0]);
1058
1268
  }
1059
- function processChatSseLine(messageId, line, enqueue, tools, appendText) {
1269
+ function processCompletionSseBlock(block, enqueue, markTerminal) {
1270
+ let event = "message";
1271
+ const dataLines = [];
1272
+ for (const line of block.split(/\r?\n/)) {
1273
+ const trimmed = line.trim();
1274
+ if (trimmed.startsWith("event:")) {
1275
+ event = trimmed.slice("event:".length).trim() || event;
1276
+ } else if (trimmed.startsWith("data:")) {
1277
+ dataLines.push(trimmed.slice("data:".length).trim());
1278
+ }
1279
+ }
1280
+ const data = dataLines.join("\n");
1281
+ if (!data) {
1282
+ return;
1283
+ }
1284
+ if (data === "[DONE]") {
1285
+ markTerminal();
1286
+ enqueue("[DONE]");
1287
+ return;
1288
+ }
1289
+ const parsed = parseJson(data);
1290
+ if (!parsed) {
1291
+ return;
1292
+ }
1293
+ const error = completionStreamError(event, parsed);
1294
+ if (error) {
1295
+ markTerminal();
1296
+ enqueue({ error });
1297
+ return;
1298
+ }
1299
+ const choice = firstChoice(parsed);
1300
+ const delta = asRecord(choice.delta);
1301
+ const text = contentToText(delta.content);
1302
+ const finishReason = choice.finish_reason ?? null;
1303
+ const usage = asRecord(parsed.usage);
1304
+ const hasUsage = Object.keys(usage).length > 0;
1305
+ if (!text && finishReason === null && !hasUsage) {
1306
+ return;
1307
+ }
1308
+ enqueue(
1309
+ removeUndefined({
1310
+ choices: text || finishReason !== null ? [
1311
+ {
1312
+ finish_reason: finishReason,
1313
+ index: typeof choice.index === "number" ? choice.index : 0,
1314
+ logprobs: null,
1315
+ text
1316
+ }
1317
+ ] : [],
1318
+ created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
1319
+ id: contentToText(parsed.id) || `cmpl_${randomId()}`,
1320
+ model: contentToText(parsed.model) || DEFAULT_MODEL,
1321
+ object: "text_completion",
1322
+ usage: hasUsage ? usage : void 0
1323
+ })
1324
+ );
1325
+ }
1326
+ function completionStreamError(event, parsed) {
1327
+ const responseError = asRecord(asRecord(parsed.response).error);
1328
+ const directError = asRecord(parsed.error);
1329
+ const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
1330
+ if (error) {
1331
+ return error;
1332
+ }
1333
+ if (event === "error" || parsed.type === "response.failed") {
1334
+ return removeUndefined({
1335
+ code: contentToText(parsed.code) || void 0,
1336
+ message: contentToText(parsed.message) || "Upstream streaming request failed.",
1337
+ type: contentToText(parsed.type) || "upstream_stream_error"
1338
+ });
1339
+ }
1340
+ return void 0;
1341
+ }
1342
+ function processChatSseLine(line, handlers) {
1060
1343
  const trimmed = line.trim();
1061
1344
  if (!trimmed.startsWith("data:")) {
1062
1345
  return;
@@ -1073,30 +1356,11 @@ function processChatSseLine(messageId, line, enqueue, tools, appendText) {
1073
1356
  const delta = asRecord(choice.delta);
1074
1357
  const content = contentToText(delta.content);
1075
1358
  if (content) {
1076
- appendText(content);
1077
- enqueue("response.output_text.delta", {
1078
- content_index: 0,
1079
- delta: content,
1080
- item_id: messageId,
1081
- output_index: 0,
1082
- type: "response.output_text.delta"
1083
- });
1359
+ handlers.appendText(content);
1084
1360
  }
1085
1361
  const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
1086
1362
  for (const toolCall of toolCalls) {
1087
- const record = asRecord(toolCall);
1088
- const fn = asRecord(record.function);
1089
- const index = typeof record.index === "number" ? record.index : tools.size;
1090
- const existing = tools.get(index) ?? {
1091
- arguments: "",
1092
- id: contentToText(record.id) || `call_${randomId()}`,
1093
- index,
1094
- name: ""
1095
- };
1096
- existing.id = contentToText(record.id) || existing.id;
1097
- existing.name += contentToText(fn.name);
1098
- existing.arguments += contentToText(fn.arguments);
1099
- tools.set(index, existing);
1363
+ handlers.appendToolCall(asRecord(toolCall));
1100
1364
  }
1101
1365
  }
1102
1366
  function baseStreamResponse(id, model, createdAt, status, output) {
@@ -1126,6 +1390,14 @@ function encodeSse(event, data) {
1126
1390
  return `event: ${event}
1127
1391
  data: ${JSON.stringify(data)}
1128
1392
 
1393
+ `;
1394
+ }
1395
+ function encodeDataSse(data) {
1396
+ if (data === "[DONE]") {
1397
+ return "data: [DONE]\n\n";
1398
+ }
1399
+ return `data: ${JSON.stringify(data)}
1400
+
1129
1401
  `;
1130
1402
  }
1131
1403
  function parseJson(data) {
@@ -1514,11 +1786,21 @@ function formatNumber(value) {
1514
1786
  // src/server.ts
1515
1787
  var DEFAULT_HOST = "127.0.0.1";
1516
1788
  var DEFAULT_PORT = 4141;
1789
+ var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
1517
1790
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
1791
+ var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
1792
+ var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
1793
+ var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
1518
1794
  var USAGE_CACHE_TTL_MS = 6e4;
1795
+ var RequestBodyTooLargeError = class extends Error {
1796
+ constructor() {
1797
+ super(REQUEST_TOO_LARGE_MESSAGE);
1798
+ this.name = "RequestBodyTooLargeError";
1799
+ }
1800
+ };
1519
1801
  function createHoopilotHandler(options = {}) {
1520
1802
  const client = new CopilotClient(options);
1521
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1803
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
1522
1804
  const logger = serverLogger(options);
1523
1805
  const metrics = options.metrics ?? new MetricsRegistry();
1524
1806
  const readUsage = createUsageReader(client, metrics);
@@ -1544,6 +1826,14 @@ function createHoopilotHandler(options = {}) {
1544
1826
  route,
1545
1827
  startedAt
1546
1828
  });
1829
+ const browserOrigin = forbiddenBrowserOrigin(request, apiKey);
1830
+ if (browserOrigin) {
1831
+ requestLogger.warn(
1832
+ { event: "http.request.forbidden_origin", origin: browserOrigin },
1833
+ "blocked unauthenticated browser-origin request"
1834
+ );
1835
+ return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
1836
+ }
1547
1837
  if (request.method === "OPTIONS") {
1548
1838
  return finish(new Response(null, { headers: corsHeaders() }));
1549
1839
  }
@@ -1596,6 +1886,12 @@ function createHoopilotHandler(options = {}) {
1596
1886
  "request body was invalid json"
1597
1887
  );
1598
1888
  return finish(jsonError(400, "invalid_request_error", message));
1889
+ } else if (error instanceof RequestBodyTooLargeError) {
1890
+ requestLogger.warn(
1891
+ { err: errorDetails(error), event: "http.request.failed" },
1892
+ "request body exceeded size limit"
1893
+ );
1894
+ return finish(jsonError(413, "request_too_large", message));
1599
1895
  } else {
1600
1896
  requestLogger.error(
1601
1897
  { err: errorDetails(error), event: "http.request.failed" },
@@ -1607,10 +1903,10 @@ function createHoopilotHandler(options = {}) {
1607
1903
  };
1608
1904
  }
1609
1905
  function startHoopilotServer(options = {}) {
1610
- const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
1611
- const port = Number(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1612
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1613
- const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
1906
+ const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
1907
+ const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
1908
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
1909
+ const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
1614
1910
  if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
1615
1911
  throw new Error(
1616
1912
  "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
@@ -1628,7 +1924,7 @@ function startHoopilotServer(options = {}) {
1628
1924
  });
1629
1925
  return {
1630
1926
  server,
1631
- url: `http://${host}:${server.port}`
1927
+ url: `http://${urlHost(host)}:${server.port}`
1632
1928
  };
1633
1929
  }
1634
1930
  async function handleModels(client, metrics, signal, logger) {
@@ -1674,8 +1970,19 @@ async function handleCompletions(client, metrics, recordTokens, request, logger)
1674
1970
  }
1675
1971
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
1676
1972
  const model = normalizeRequestedModel(body.model);
1677
- if (isStreamingResponse(upstream)) {
1678
- return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
1973
+ if (isStreamingResponse(upstream) && upstream.body) {
1974
+ return proxyResponse(
1975
+ observeResponseUsage(
1976
+ new Response(completionStreamFromChatStream(upstream.body), {
1977
+ headers: upstream.headers,
1978
+ status: upstream.status,
1979
+ statusText: upstream.statusText
1980
+ }),
1981
+ model,
1982
+ recordTokens,
1983
+ request.signal
1984
+ )
1985
+ );
1679
1986
  }
1680
1987
  const completion = asRecord(await upstream.json());
1681
1988
  const usage = extractTokenUsage(completion.usage);
@@ -1709,7 +2016,7 @@ async function proxyError(upstream, logger) {
1709
2016
  { event: "copilot.request.failed", upstreamStatus: upstream.status },
1710
2017
  "copilot upstream request failed"
1711
2018
  );
1712
- return jsonError(upstream.status, "copilot_error", text || upstream.statusText);
2019
+ return upstreamErrorResponse(upstream.status, text || upstream.statusText);
1713
2020
  }
1714
2021
  function proxyResponse(upstream) {
1715
2022
  const headers = new Headers(upstream.headers);
@@ -1726,14 +2033,15 @@ function proxyResponse(upstream) {
1726
2033
  });
1727
2034
  }
1728
2035
  async function readJson(request) {
2036
+ const text = await readRequestText(request);
1729
2037
  try {
1730
- return asRecord(await request.json());
2038
+ return asRecord(JSON.parse(text));
1731
2039
  } catch {
1732
2040
  throw new Error(INVALID_JSON_MESSAGE);
1733
2041
  }
1734
2042
  }
1735
2043
  async function readJsonText(request) {
1736
- const text = await request.text();
2044
+ const text = await readRequestText(request);
1737
2045
  try {
1738
2046
  JSON.parse(text);
1739
2047
  return text;
@@ -1741,6 +2049,40 @@ async function readJsonText(request) {
1741
2049
  throw new Error(INVALID_JSON_MESSAGE);
1742
2050
  }
1743
2051
  }
2052
+ async function readRequestText(request) {
2053
+ const contentLength = request.headers.get("content-length");
2054
+ if (contentLength) {
2055
+ const declaredBytes = Number(contentLength);
2056
+ if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
2057
+ throw new RequestBodyTooLargeError();
2058
+ }
2059
+ }
2060
+ const body = request.body;
2061
+ if (!body) {
2062
+ return "";
2063
+ }
2064
+ const reader = body.getReader();
2065
+ const decoder = new TextDecoder();
2066
+ let bytes = 0;
2067
+ let text = "";
2068
+ try {
2069
+ while (true) {
2070
+ const { done, value } = await reader.read();
2071
+ if (done) {
2072
+ return `${text}${decoder.decode()}`;
2073
+ }
2074
+ bytes += value.byteLength;
2075
+ if (bytes > MAX_REQUEST_BODY_BYTES) {
2076
+ await reader.cancel().catch(() => {
2077
+ });
2078
+ throw new RequestBodyTooLargeError();
2079
+ }
2080
+ text += decoder.decode(value, { stream: true });
2081
+ }
2082
+ } finally {
2083
+ reader.releaseLock();
2084
+ }
2085
+ }
1744
2086
  function jsonResponse(body, status = 200) {
1745
2087
  return new Response(JSON.stringify(body), {
1746
2088
  headers: {
@@ -1762,6 +2104,13 @@ function jsonError(status, code, message) {
1762
2104
  status
1763
2105
  );
1764
2106
  }
2107
+ function upstreamErrorResponse(status, text) {
2108
+ const parsedError = asRecord(asRecord(safeParseJson(text)).error);
2109
+ if (Object.keys(parsedError).length > 0) {
2110
+ return jsonResponse({ error: parsedError }, status);
2111
+ }
2112
+ return jsonError(status, "copilot_error", text);
2113
+ }
1765
2114
  function websocketUnsupportedResponse() {
1766
2115
  const response = jsonError(
1767
2116
  426,
@@ -1786,6 +2135,17 @@ function isAuthorized(request, apiKey) {
1786
2135
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
1787
2136
  return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
1788
2137
  }
2138
+ function forbiddenBrowserOrigin(request, apiKey) {
2139
+ if (apiKey) {
2140
+ return void 0;
2141
+ }
2142
+ const origin = request.headers.get("origin")?.trim();
2143
+ if (origin) {
2144
+ return isLoopbackOrigin(origin) ? void 0 : origin;
2145
+ }
2146
+ const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
2147
+ return fetchSite === "cross-site" ? "cross-site" : void 0;
2148
+ }
1789
2149
  function isUpstreamAuthStatus(status) {
1790
2150
  return status === 401 || status === 403;
1791
2151
  }
@@ -1793,7 +2153,24 @@ function upstreamAuthMessage(message) {
1793
2153
  return `GitHub Copilot rejected the credential or account access: ${message}`;
1794
2154
  }
1795
2155
  function isLoopbackHost(host) {
1796
- return host === "localhost" || host === "127.0.0.1" || host === "::1";
2156
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
2157
+ }
2158
+ function urlHost(host) {
2159
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
2160
+ }
2161
+ function isLoopbackOrigin(origin) {
2162
+ try {
2163
+ return isLoopbackHost(new URL(origin).hostname.toLowerCase());
2164
+ } catch {
2165
+ return false;
2166
+ }
2167
+ }
2168
+ function normalizeServerPort(value) {
2169
+ const port = Number(value);
2170
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
2171
+ throw new Error(`Invalid port: ${value}.`);
2172
+ }
2173
+ return port;
1797
2174
  }
1798
2175
  function errorMessage(error) {
1799
2176
  return error instanceof Error ? error.message : String(error);
@@ -1888,7 +2265,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
1888
2265
  }
1889
2266
  function requestIdFor(request) {
1890
2267
  const existing = request.headers.get("x-request-id")?.trim();
1891
- return existing || crypto.randomUUID();
2268
+ return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
1892
2269
  }
1893
2270
  function canonicalApiPath(path) {
1894
2271
  const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;
@@ -2018,6 +2395,7 @@ function safeParseJson(text) {
2018
2395
  authStorePath,
2019
2396
  chatCompletionToCompletion,
2020
2397
  chatCompletionToResponse,
2398
+ completionStreamFromChatStream,
2021
2399
  completionsRequestToChatCompletion,
2022
2400
  createHoopilotHandler,
2023
2401
  createHoopilotLogger,