@openhoo/hoopilot 0.7.0 → 0.7.2

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,6 +71,12 @@ 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
+ var StoredCopilotAuthError = class extends Error {
75
+ constructor(message) {
76
+ super(message);
77
+ this.name = "StoredCopilotAuthError";
78
+ }
79
+ };
73
80
  function authStorePath(env = process.env) {
74
81
  if (env.HOOPILOT_AUTH_FILE) {
75
82
  return env.HOOPILOT_AUTH_FILE;
@@ -78,25 +85,38 @@ function authStorePath(env = process.env) {
78
85
  return (0, import_node_path.join)(base, "hoopilot", "auth.json");
79
86
  }
80
87
  function readStoredCopilotAuth(path = authStorePath()) {
88
+ let text;
81
89
  try {
82
- const parsed = JSON.parse((0, import_node_fs.readFileSync)(path, "utf8"));
83
- if (!parsed || typeof parsed !== "object") {
84
- return void 0;
85
- }
86
- const token = typeof parsed.token === "string" ? parsed.token.trim() : "";
87
- if (!token) {
90
+ text = (0, import_node_fs.readFileSync)(path, "utf8");
91
+ } catch (error) {
92
+ if (error.code === "ENOENT") {
88
93
  return void 0;
89
94
  }
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
- };
95
+ throw new StoredCopilotAuthError(`Could not read Hoopilot auth file at ${path}.`);
96
+ }
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(text);
97
100
  } catch {
98
- return void 0;
101
+ throw new StoredCopilotAuthError(
102
+ `Hoopilot auth file at ${path} is not valid JSON. Run \`hoopilot login\` to replace it.`
103
+ );
99
104
  }
105
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
106
+ throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
107
+ }
108
+ const record = parsed;
109
+ const token = typeof record.token === "string" ? record.token.trim() : "";
110
+ if (!token) {
111
+ throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
112
+ }
113
+ return {
114
+ apiBaseUrl: typeof record.apiBaseUrl === "string" ? record.apiBaseUrl : void 0,
115
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : void 0,
116
+ githubDomain: typeof record.githubDomain === "string" ? record.githubDomain : void 0,
117
+ source: typeof record.source === "string" ? record.source : void 0,
118
+ token
119
+ };
100
120
  }
101
121
  function writeStoredCopilotAuth(auth, path = authStorePath()) {
102
122
  (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
@@ -122,6 +142,18 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
122
142
  function trimTrailingSlash(value) {
123
143
  return value.replace(/\/+$/, "");
124
144
  }
145
+ function isHttpsOrLoopbackUrl(rawUrl) {
146
+ let url;
147
+ try {
148
+ url = new URL(rawUrl);
149
+ } catch {
150
+ return false;
151
+ }
152
+ if (url.protocol === "https:") {
153
+ return true;
154
+ }
155
+ return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
156
+ }
125
157
  async function truncatedResponseText(response, max = 500) {
126
158
  const text = await response.text();
127
159
  return text.slice(0, max);
@@ -154,7 +186,15 @@ var CopilotAuth = class {
154
186
  if (this.#cachedAccess && this.#cachedAccess.expiresAtMs - REFRESH_SKEW_MS > Date.now()) {
155
187
  return this.#cachedAccess;
156
188
  }
157
- const stored = readStoredCopilotAuth(this.#authStorePath);
189
+ let stored;
190
+ try {
191
+ stored = readStoredCopilotAuth(this.#authStorePath);
192
+ } catch (error) {
193
+ if (error instanceof StoredCopilotAuthError) {
194
+ throw new CopilotAuthError(error.message);
195
+ }
196
+ throw error;
197
+ }
158
198
  if (stored) {
159
199
  return this.#cacheAccess({
160
200
  apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
@@ -213,7 +253,7 @@ var CopilotClient = class {
213
253
  * accepted directly here — no Copilot token exchange is required to read quota.
214
254
  */
215
255
  async usage(signal) {
216
- if (!isHttpsOrLoopback(this.#githubApiBaseUrl)) {
256
+ if (!isHttpsOrLoopbackUrl(this.#githubApiBaseUrl)) {
217
257
  throw new Error(
218
258
  `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${this.#githubApiBaseUrl}`
219
259
  );
@@ -257,6 +297,11 @@ var CopilotClient = class {
257
297
  }
258
298
  async fetchCopilot(path, init) {
259
299
  const access = await this.#auth.getAccess();
300
+ if (!isHttpsOrLoopbackUrl(access.apiBaseUrl)) {
301
+ throw new Error(
302
+ `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${access.apiBaseUrl}`
303
+ );
304
+ }
260
305
  const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
261
306
  return this.#fetch(`${access.apiBaseUrl}${path}`, {
262
307
  ...init,
@@ -312,18 +357,6 @@ function usedFrom(entitlement, remaining) {
312
357
  }
313
358
  return Math.max(0, entitlement - remaining);
314
359
  }
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
360
  function numberOrUndefined(value) {
328
361
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
329
362
  }
@@ -678,6 +711,51 @@ function chatCompletionToCompletion(completion) {
678
711
  usage: completion.usage
679
712
  });
680
713
  }
714
+ function completionStreamFromChatStream(chatStream) {
715
+ const encoder = new TextEncoder();
716
+ const decoder = new TextDecoder();
717
+ let buffer = "";
718
+ let sawDone = false;
719
+ return new ReadableStream({
720
+ async start(controller) {
721
+ const enqueue = (data) => {
722
+ controller.enqueue(encoder.encode(encodeDataSse(data)));
723
+ };
724
+ const reader = chatStream.getReader();
725
+ try {
726
+ while (true) {
727
+ const result = await reader.read();
728
+ if (result.done) {
729
+ break;
730
+ }
731
+ buffer += decoder.decode(result.value, { stream: true });
732
+ const lines = buffer.split(/\r?\n/);
733
+ buffer = lines.pop() ?? "";
734
+ for (const line of lines) {
735
+ processCompletionSseLine(line, enqueue, () => {
736
+ sawDone = true;
737
+ });
738
+ }
739
+ }
740
+ if (buffer) {
741
+ processCompletionSseLine(buffer, enqueue, () => {
742
+ sawDone = true;
743
+ });
744
+ }
745
+ if (!sawDone) {
746
+ enqueue("[DONE]");
747
+ }
748
+ controller.close();
749
+ } catch (error) {
750
+ await reader.cancel(error).catch(() => {
751
+ });
752
+ controller.error(error);
753
+ } finally {
754
+ reader.releaseLock();
755
+ }
756
+ }
757
+ });
758
+ }
681
759
  function normalizeModelsResponse(upstream) {
682
760
  const record = asRecord(upstream);
683
761
  const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
@@ -710,38 +788,99 @@ function responsesStreamFromChatStream(chatStream, options) {
710
788
  const createdAt = epochSeconds();
711
789
  let buffer = "";
712
790
  let text = "";
791
+ let messageOutputIndex;
792
+ let nextOutputIndex = 0;
793
+ let sequenceNumber = 0;
713
794
  const tools = /* @__PURE__ */ new Map();
714
795
  return new ReadableStream({
715
796
  async start(controller) {
716
797
  const enqueue = (event, data) => {
717
- controller.enqueue(encoder.encode(encodeSse(event, data)));
798
+ controller.enqueue(
799
+ encoder.encode(
800
+ encodeSse(
801
+ event,
802
+ data === "[DONE]" ? data : { ...data, sequence_number: sequenceNumber++ }
803
+ )
804
+ )
805
+ );
718
806
  };
719
807
  enqueue("response.created", {
720
808
  response: baseStreamResponse(responseId, options.model, createdAt, "in_progress", []),
721
809
  type: "response.created"
722
810
  });
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
- });
811
+ const ensureMessageStarted = () => {
812
+ if (messageOutputIndex !== void 0) {
813
+ return;
814
+ }
815
+ messageOutputIndex = nextOutputIndex++;
816
+ enqueue("response.output_item.added", {
817
+ item: {
818
+ content: [],
819
+ id: messageId,
820
+ role: "assistant",
821
+ status: "in_progress",
822
+ type: "message"
823
+ },
824
+ output_index: messageOutputIndex,
825
+ type: "response.output_item.added"
826
+ });
827
+ enqueue("response.content_part.added", {
828
+ content_index: 0,
829
+ item_id: messageId,
830
+ output_index: messageOutputIndex,
831
+ part: {
832
+ annotations: [],
833
+ text: "",
834
+ type: "output_text"
835
+ },
836
+ type: "response.content_part.added"
837
+ });
838
+ };
839
+ const appendText = (delta) => {
840
+ ensureMessageStarted();
841
+ text += delta;
842
+ enqueue("response.output_text.delta", {
843
+ content_index: 0,
844
+ delta,
845
+ item_id: messageId,
846
+ output_index: messageOutputIndex ?? 0,
847
+ type: "response.output_text.delta"
848
+ });
849
+ };
850
+ const appendToolCall = (toolCall) => {
851
+ const fn = asRecord(toolCall.function);
852
+ const index = typeof toolCall.index === "number" ? toolCall.index : tools.size;
853
+ let existing = tools.get(index);
854
+ const isNew = !existing;
855
+ existing ??= {
856
+ arguments: "",
857
+ id: contentToText(toolCall.id) || `call_${randomId()}`,
858
+ index,
859
+ itemId: `fc_${randomId()}`,
860
+ name: "",
861
+ outputIndex: nextOutputIndex++
862
+ };
863
+ existing.id = contentToText(toolCall.id) || existing.id;
864
+ existing.name += contentToText(fn.name);
865
+ tools.set(index, existing);
866
+ if (isNew) {
867
+ enqueue("response.output_item.added", {
868
+ item: functionCallItem(existing, "in_progress"),
869
+ output_index: existing.outputIndex ?? 0,
870
+ type: "response.output_item.added"
871
+ });
872
+ }
873
+ const argumentDelta = contentToText(fn.arguments);
874
+ if (argumentDelta) {
875
+ existing.arguments += argumentDelta;
876
+ enqueue("response.function_call_arguments.delta", {
877
+ delta: argumentDelta,
878
+ item_id: existing.itemId,
879
+ output_index: existing.outputIndex ?? 0,
880
+ type: "response.function_call_arguments.delta"
881
+ });
882
+ }
883
+ };
745
884
  const reader = chatStream.getReader();
746
885
  try {
747
886
  while (true) {
@@ -753,50 +892,48 @@ function responsesStreamFromChatStream(chatStream, options) {
753
892
  const lines = buffer.split(/\r?\n/);
754
893
  buffer = lines.pop() ?? "";
755
894
  for (const line of lines) {
756
- processChatSseLine(messageId, line, enqueue, tools, (delta) => {
757
- text += delta;
758
- });
895
+ processChatSseLine(line, { appendText, appendToolCall });
759
896
  }
760
897
  }
761
898
  if (buffer) {
762
- processChatSseLine(messageId, buffer, enqueue, tools, (delta) => {
763
- text += delta;
764
- });
899
+ processChatSseLine(buffer, { appendText, appendToolCall });
765
900
  }
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: [],
901
+ const outputEntries = [];
902
+ if (messageOutputIndex !== void 0) {
903
+ const item = messageOutputItem(text, messageId);
904
+ outputEntries.push([messageOutputIndex, item]);
905
+ enqueue("response.output_text.done", {
906
+ content_index: 0,
907
+ item_id: messageId,
908
+ output_index: messageOutputIndex,
781
909
  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", {
910
+ type: "response.output_text.done"
911
+ });
912
+ enqueue("response.content_part.done", {
913
+ content_index: 0,
914
+ item_id: messageId,
915
+ output_index: messageOutputIndex,
916
+ part: {
917
+ annotations: [],
918
+ text,
919
+ type: "output_text"
920
+ },
921
+ type: "response.content_part.done"
922
+ });
923
+ enqueue("response.output_item.done", {
794
924
  item,
795
- output_index: outputIndex,
796
- type: "response.output_item.added"
925
+ output_index: messageOutputIndex,
926
+ type: "response.output_item.done"
797
927
  });
928
+ }
929
+ for (const tool of [...tools.values()].sort(
930
+ (a, b) => (a.outputIndex ?? 0) - (b.outputIndex ?? 0)
931
+ )) {
932
+ const item = functionCallItem(tool);
933
+ const outputIndex = tool.outputIndex ?? 0;
934
+ outputEntries.push([outputIndex, item]);
798
935
  enqueue("response.function_call_arguments.done", {
799
- arguments: item.arguments,
936
+ arguments: tool.arguments,
800
937
  item_id: item.id,
801
938
  output_index: outputIndex,
802
939
  type: "response.function_call_arguments.done"
@@ -806,7 +943,8 @@ function responsesStreamFromChatStream(chatStream, options) {
806
943
  output_index: outputIndex,
807
944
  type: "response.output_item.done"
808
945
  });
809
- });
946
+ }
947
+ const output = outputEntries.sort(([left], [right]) => left - right).map(([, item]) => item);
810
948
  enqueue("response.completed", {
811
949
  response: baseStreamResponse(responseId, options.model, createdAt, "completed", output),
812
950
  type: "response.completed"
@@ -989,13 +1127,13 @@ function messageOutputItem(text, id = `msg_${randomId()}`) {
989
1127
  type: "message"
990
1128
  };
991
1129
  }
992
- function functionCallItem(tool) {
1130
+ function functionCallItem(tool, status = "completed") {
993
1131
  return {
994
1132
  arguments: tool.arguments,
995
1133
  call_id: tool.id,
996
- id: `fc_${randomId()}`,
1134
+ id: tool.itemId ?? `fc_${randomId()}`,
997
1135
  name: tool.name,
998
- status: "completed",
1136
+ status,
999
1137
  type: "function_call"
1000
1138
  };
1001
1139
  }
@@ -1010,14 +1148,27 @@ function responseUsage(usage) {
1010
1148
  if (Object.keys(record).length === 0) {
1011
1149
  return null;
1012
1150
  }
1151
+ const inputTokens = record.prompt_tokens;
1152
+ const outputTokens = record.completion_tokens;
1013
1153
  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,
1154
+ input_tokens: inputTokens,
1155
+ input_tokens_details: responseUsageDetails(record.prompt_tokens_details, inputTokens, {
1156
+ cached_tokens: 0
1157
+ }),
1158
+ output_tokens: outputTokens,
1159
+ output_tokens_details: responseUsageDetails(record.completion_tokens_details, outputTokens, {
1160
+ reasoning_tokens: 0
1161
+ }),
1018
1162
  total_tokens: record.total_tokens
1019
1163
  });
1020
1164
  }
1165
+ function responseUsageDetails(value, tokenCount, fallback) {
1166
+ const record = asRecord(value);
1167
+ if (Object.keys(record).length > 0) {
1168
+ return record;
1169
+ }
1170
+ return typeof tokenCount === "number" && Number.isFinite(tokenCount) ? fallback : void 0;
1171
+ }
1021
1172
  function extractTokenUsage(usage) {
1022
1173
  const record = asRecord(usage);
1023
1174
  const prompt = firstNumber(record.prompt_tokens, record.input_tokens);
@@ -1056,7 +1207,52 @@ function firstChoice(completion) {
1056
1207
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
1057
1208
  return asRecord(choices[0]);
1058
1209
  }
1059
- function processChatSseLine(messageId, line, enqueue, tools, appendText) {
1210
+ function processCompletionSseLine(line, enqueue, markDone) {
1211
+ const trimmed = line.trim();
1212
+ if (!trimmed.startsWith("data:")) {
1213
+ return;
1214
+ }
1215
+ const data = trimmed.slice("data:".length).trim();
1216
+ if (!data) {
1217
+ return;
1218
+ }
1219
+ if (data === "[DONE]") {
1220
+ markDone();
1221
+ enqueue("[DONE]");
1222
+ return;
1223
+ }
1224
+ const parsed = parseJson(data);
1225
+ if (!parsed) {
1226
+ return;
1227
+ }
1228
+ const choice = firstChoice(parsed);
1229
+ const delta = asRecord(choice.delta);
1230
+ const text = contentToText(delta.content);
1231
+ const finishReason = choice.finish_reason ?? null;
1232
+ const usage = asRecord(parsed.usage);
1233
+ const hasUsage = Object.keys(usage).length > 0;
1234
+ if (!text && finishReason === null && !hasUsage) {
1235
+ return;
1236
+ }
1237
+ enqueue(
1238
+ removeUndefined({
1239
+ choices: text || finishReason !== null ? [
1240
+ {
1241
+ finish_reason: finishReason,
1242
+ index: typeof choice.index === "number" ? choice.index : 0,
1243
+ logprobs: null,
1244
+ text
1245
+ }
1246
+ ] : [],
1247
+ created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
1248
+ id: contentToText(parsed.id) || `cmpl_${randomId()}`,
1249
+ model: contentToText(parsed.model) || DEFAULT_MODEL,
1250
+ object: "text_completion",
1251
+ usage: hasUsage ? usage : void 0
1252
+ })
1253
+ );
1254
+ }
1255
+ function processChatSseLine(line, handlers) {
1060
1256
  const trimmed = line.trim();
1061
1257
  if (!trimmed.startsWith("data:")) {
1062
1258
  return;
@@ -1073,30 +1269,11 @@ function processChatSseLine(messageId, line, enqueue, tools, appendText) {
1073
1269
  const delta = asRecord(choice.delta);
1074
1270
  const content = contentToText(delta.content);
1075
1271
  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
- });
1272
+ handlers.appendText(content);
1084
1273
  }
1085
1274
  const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
1086
1275
  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);
1276
+ handlers.appendToolCall(asRecord(toolCall));
1100
1277
  }
1101
1278
  }
1102
1279
  function baseStreamResponse(id, model, createdAt, status, output) {
@@ -1126,6 +1303,14 @@ function encodeSse(event, data) {
1126
1303
  return `event: ${event}
1127
1304
  data: ${JSON.stringify(data)}
1128
1305
 
1306
+ `;
1307
+ }
1308
+ function encodeDataSse(data) {
1309
+ if (data === "[DONE]") {
1310
+ return "data: [DONE]\n\n";
1311
+ }
1312
+ return `data: ${JSON.stringify(data)}
1313
+
1129
1314
  `;
1130
1315
  }
1131
1316
  function parseJson(data) {
@@ -1514,6 +1699,7 @@ function formatNumber(value) {
1514
1699
  // src/server.ts
1515
1700
  var DEFAULT_HOST = "127.0.0.1";
1516
1701
  var DEFAULT_PORT = 4141;
1702
+ var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
1517
1703
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
1518
1704
  var USAGE_CACHE_TTL_MS = 6e4;
1519
1705
  function createHoopilotHandler(options = {}) {
@@ -1544,6 +1730,14 @@ function createHoopilotHandler(options = {}) {
1544
1730
  route,
1545
1731
  startedAt
1546
1732
  });
1733
+ const browserOrigin = forbiddenBrowserOrigin(request, apiKey);
1734
+ if (browserOrigin) {
1735
+ requestLogger.warn(
1736
+ { event: "http.request.forbidden_origin", origin: browserOrigin },
1737
+ "blocked unauthenticated browser-origin request"
1738
+ );
1739
+ return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
1740
+ }
1547
1741
  if (request.method === "OPTIONS") {
1548
1742
  return finish(new Response(null, { headers: corsHeaders() }));
1549
1743
  }
@@ -1595,6 +1789,7 @@ function createHoopilotHandler(options = {}) {
1595
1789
  { err: errorDetails(error), event: "http.request.failed" },
1596
1790
  "request body was invalid json"
1597
1791
  );
1792
+ return finish(jsonError(400, "invalid_request_error", message));
1598
1793
  } else {
1599
1794
  requestLogger.error(
1600
1795
  { err: errorDetails(error), event: "http.request.failed" },
@@ -1607,7 +1802,7 @@ function createHoopilotHandler(options = {}) {
1607
1802
  }
1608
1803
  function startHoopilotServer(options = {}) {
1609
1804
  const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
1610
- const port = Number(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1805
+ const port = normalizeServerPort(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1611
1806
  const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1612
1807
  const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
1613
1808
  if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
@@ -1673,8 +1868,19 @@ async function handleCompletions(client, metrics, recordTokens, request, logger)
1673
1868
  }
1674
1869
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
1675
1870
  const model = normalizeRequestedModel(body.model);
1676
- if (isStreamingResponse(upstream)) {
1677
- return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
1871
+ if (isStreamingResponse(upstream) && upstream.body) {
1872
+ return proxyResponse(
1873
+ observeResponseUsage(
1874
+ new Response(completionStreamFromChatStream(upstream.body), {
1875
+ headers: upstream.headers,
1876
+ status: upstream.status,
1877
+ statusText: upstream.statusText
1878
+ }),
1879
+ model,
1880
+ recordTokens,
1881
+ request.signal
1882
+ )
1883
+ );
1678
1884
  }
1679
1885
  const completion = asRecord(await upstream.json());
1680
1886
  const usage = extractTokenUsage(completion.usage);
@@ -1708,7 +1914,7 @@ async function proxyError(upstream, logger) {
1708
1914
  { event: "copilot.request.failed", upstreamStatus: upstream.status },
1709
1915
  "copilot upstream request failed"
1710
1916
  );
1711
- return jsonError(upstream.status, "copilot_error", text || upstream.statusText);
1917
+ return upstreamErrorResponse(upstream.status, text || upstream.statusText);
1712
1918
  }
1713
1919
  function proxyResponse(upstream) {
1714
1920
  const headers = new Headers(upstream.headers);
@@ -1761,6 +1967,13 @@ function jsonError(status, code, message) {
1761
1967
  status
1762
1968
  );
1763
1969
  }
1970
+ function upstreamErrorResponse(status, text) {
1971
+ const parsedError = asRecord(asRecord(safeParseJson(text)).error);
1972
+ if (Object.keys(parsedError).length > 0) {
1973
+ return jsonResponse({ error: parsedError }, status);
1974
+ }
1975
+ return jsonError(status, "copilot_error", text);
1976
+ }
1764
1977
  function websocketUnsupportedResponse() {
1765
1978
  const response = jsonError(
1766
1979
  426,
@@ -1785,6 +1998,17 @@ function isAuthorized(request, apiKey) {
1785
1998
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
1786
1999
  return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
1787
2000
  }
2001
+ function forbiddenBrowserOrigin(request, apiKey) {
2002
+ if (apiKey) {
2003
+ return void 0;
2004
+ }
2005
+ const origin = request.headers.get("origin")?.trim();
2006
+ if (origin) {
2007
+ return isLoopbackOrigin(origin) ? void 0 : origin;
2008
+ }
2009
+ const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
2010
+ return fetchSite === "cross-site" ? "cross-site" : void 0;
2011
+ }
1788
2012
  function isUpstreamAuthStatus(status) {
1789
2013
  return status === 401 || status === 403;
1790
2014
  }
@@ -1792,7 +2016,21 @@ function upstreamAuthMessage(message) {
1792
2016
  return `GitHub Copilot rejected the credential or account access: ${message}`;
1793
2017
  }
1794
2018
  function isLoopbackHost(host) {
1795
- return host === "localhost" || host === "127.0.0.1" || host === "::1";
2019
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
2020
+ }
2021
+ function isLoopbackOrigin(origin) {
2022
+ try {
2023
+ return isLoopbackHost(new URL(origin).hostname.toLowerCase());
2024
+ } catch {
2025
+ return false;
2026
+ }
2027
+ }
2028
+ function normalizeServerPort(value) {
2029
+ const port = Number(value);
2030
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
2031
+ throw new Error(`Invalid port: ${value}.`);
2032
+ }
2033
+ return port;
1796
2034
  }
1797
2035
  function errorMessage(error) {
1798
2036
  return error instanceof Error ? error.message : String(error);
@@ -2017,6 +2255,7 @@ function safeParseJson(text) {
2017
2255
  authStorePath,
2018
2256
  chatCompletionToCompletion,
2019
2257
  chatCompletionToResponse,
2258
+ completionStreamFromChatStream,
2020
2259
  completionsRequestToChatCompletion,
2021
2260
  createHoopilotHandler,
2022
2261
  createHoopilotLogger,