@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.js CHANGED
@@ -1,6 +1,12 @@
1
1
  // src/auth-store.ts
2
2
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
3
3
  import { dirname, join } from "path";
4
+ var StoredCopilotAuthError = class extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "StoredCopilotAuthError";
8
+ }
9
+ };
4
10
  function authStorePath(env = process.env) {
5
11
  if (env.HOOPILOT_AUTH_FILE) {
6
12
  return env.HOOPILOT_AUTH_FILE;
@@ -9,25 +15,38 @@ function authStorePath(env = process.env) {
9
15
  return join(base, "hoopilot", "auth.json");
10
16
  }
11
17
  function readStoredCopilotAuth(path = authStorePath()) {
18
+ let text;
12
19
  try {
13
- const parsed = JSON.parse(readFileSync(path, "utf8"));
14
- if (!parsed || typeof parsed !== "object") {
15
- return void 0;
16
- }
17
- const token = typeof parsed.token === "string" ? parsed.token.trim() : "";
18
- if (!token) {
20
+ text = readFileSync(path, "utf8");
21
+ } catch (error) {
22
+ if (error.code === "ENOENT") {
19
23
  return void 0;
20
24
  }
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
- };
25
+ throw new StoredCopilotAuthError(`Could not read Hoopilot auth file at ${path}.`);
26
+ }
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(text);
28
30
  } catch {
29
- return void 0;
31
+ throw new StoredCopilotAuthError(
32
+ `Hoopilot auth file at ${path} is not valid JSON. Run \`hoopilot login\` to replace it.`
33
+ );
30
34
  }
35
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
36
+ throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
37
+ }
38
+ const record = parsed;
39
+ const token = typeof record.token === "string" ? record.token.trim() : "";
40
+ if (!token) {
41
+ throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
42
+ }
43
+ return {
44
+ apiBaseUrl: typeof record.apiBaseUrl === "string" ? record.apiBaseUrl : void 0,
45
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : void 0,
46
+ githubDomain: typeof record.githubDomain === "string" ? record.githubDomain : void 0,
47
+ source: typeof record.source === "string" ? record.source : void 0,
48
+ token
49
+ };
31
50
  }
32
51
  function writeStoredCopilotAuth(auth, path = authStorePath()) {
33
52
  mkdirSync(dirname(path), { recursive: true });
@@ -53,6 +72,18 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
53
72
  function trimTrailingSlash(value) {
54
73
  return value.replace(/\/+$/, "");
55
74
  }
75
+ function isHttpsOrLoopbackUrl(rawUrl) {
76
+ let url;
77
+ try {
78
+ url = new URL(rawUrl);
79
+ } catch {
80
+ return false;
81
+ }
82
+ if (url.protocol === "https:") {
83
+ return true;
84
+ }
85
+ return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
86
+ }
56
87
  async function truncatedResponseText(response, max = 500) {
57
88
  const text = await response.text();
58
89
  return text.slice(0, max);
@@ -85,7 +116,15 @@ var CopilotAuth = class {
85
116
  if (this.#cachedAccess && this.#cachedAccess.expiresAtMs - REFRESH_SKEW_MS > Date.now()) {
86
117
  return this.#cachedAccess;
87
118
  }
88
- const stored = readStoredCopilotAuth(this.#authStorePath);
119
+ let stored;
120
+ try {
121
+ stored = readStoredCopilotAuth(this.#authStorePath);
122
+ } catch (error) {
123
+ if (error instanceof StoredCopilotAuthError) {
124
+ throw new CopilotAuthError(error.message);
125
+ }
126
+ throw error;
127
+ }
89
128
  if (stored) {
90
129
  return this.#cacheAccess({
91
130
  apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
@@ -144,7 +183,7 @@ var CopilotClient = class {
144
183
  * accepted directly here — no Copilot token exchange is required to read quota.
145
184
  */
146
185
  async usage(signal) {
147
- if (!isHttpsOrLoopback(this.#githubApiBaseUrl)) {
186
+ if (!isHttpsOrLoopbackUrl(this.#githubApiBaseUrl)) {
148
187
  throw new Error(
149
188
  `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${this.#githubApiBaseUrl}`
150
189
  );
@@ -188,6 +227,11 @@ var CopilotClient = class {
188
227
  }
189
228
  async fetchCopilot(path, init) {
190
229
  const access = await this.#auth.getAccess();
230
+ if (!isHttpsOrLoopbackUrl(access.apiBaseUrl)) {
231
+ throw new Error(
232
+ `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${access.apiBaseUrl}`
233
+ );
234
+ }
191
235
  const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
192
236
  return this.#fetch(`${access.apiBaseUrl}${path}`, {
193
237
  ...init,
@@ -243,18 +287,6 @@ function usedFrom(entitlement, remaining) {
243
287
  }
244
288
  return Math.max(0, entitlement - remaining);
245
289
  }
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
290
  function numberOrUndefined(value) {
259
291
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
260
292
  }
@@ -609,6 +641,51 @@ function chatCompletionToCompletion(completion) {
609
641
  usage: completion.usage
610
642
  });
611
643
  }
644
+ function completionStreamFromChatStream(chatStream) {
645
+ const encoder = new TextEncoder();
646
+ const decoder = new TextDecoder();
647
+ let buffer = "";
648
+ let sawDone = false;
649
+ return new ReadableStream({
650
+ async start(controller) {
651
+ const enqueue = (data) => {
652
+ controller.enqueue(encoder.encode(encodeDataSse(data)));
653
+ };
654
+ const reader = chatStream.getReader();
655
+ try {
656
+ while (true) {
657
+ const result = await reader.read();
658
+ if (result.done) {
659
+ break;
660
+ }
661
+ buffer += decoder.decode(result.value, { stream: true });
662
+ const lines = buffer.split(/\r?\n/);
663
+ buffer = lines.pop() ?? "";
664
+ for (const line of lines) {
665
+ processCompletionSseLine(line, enqueue, () => {
666
+ sawDone = true;
667
+ });
668
+ }
669
+ }
670
+ if (buffer) {
671
+ processCompletionSseLine(buffer, enqueue, () => {
672
+ sawDone = true;
673
+ });
674
+ }
675
+ if (!sawDone) {
676
+ enqueue("[DONE]");
677
+ }
678
+ controller.close();
679
+ } catch (error) {
680
+ await reader.cancel(error).catch(() => {
681
+ });
682
+ controller.error(error);
683
+ } finally {
684
+ reader.releaseLock();
685
+ }
686
+ }
687
+ });
688
+ }
612
689
  function normalizeModelsResponse(upstream) {
613
690
  const record = asRecord(upstream);
614
691
  const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
@@ -641,38 +718,99 @@ function responsesStreamFromChatStream(chatStream, options) {
641
718
  const createdAt = epochSeconds();
642
719
  let buffer = "";
643
720
  let text = "";
721
+ let messageOutputIndex;
722
+ let nextOutputIndex = 0;
723
+ let sequenceNumber = 0;
644
724
  const tools = /* @__PURE__ */ new Map();
645
725
  return new ReadableStream({
646
726
  async start(controller) {
647
727
  const enqueue = (event, data) => {
648
- controller.enqueue(encoder.encode(encodeSse(event, data)));
728
+ controller.enqueue(
729
+ encoder.encode(
730
+ encodeSse(
731
+ event,
732
+ data === "[DONE]" ? data : { ...data, sequence_number: sequenceNumber++ }
733
+ )
734
+ )
735
+ );
649
736
  };
650
737
  enqueue("response.created", {
651
738
  response: baseStreamResponse(responseId, options.model, createdAt, "in_progress", []),
652
739
  type: "response.created"
653
740
  });
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
- });
741
+ const ensureMessageStarted = () => {
742
+ if (messageOutputIndex !== void 0) {
743
+ return;
744
+ }
745
+ messageOutputIndex = nextOutputIndex++;
746
+ enqueue("response.output_item.added", {
747
+ item: {
748
+ content: [],
749
+ id: messageId,
750
+ role: "assistant",
751
+ status: "in_progress",
752
+ type: "message"
753
+ },
754
+ output_index: messageOutputIndex,
755
+ type: "response.output_item.added"
756
+ });
757
+ enqueue("response.content_part.added", {
758
+ content_index: 0,
759
+ item_id: messageId,
760
+ output_index: messageOutputIndex,
761
+ part: {
762
+ annotations: [],
763
+ text: "",
764
+ type: "output_text"
765
+ },
766
+ type: "response.content_part.added"
767
+ });
768
+ };
769
+ const appendText = (delta) => {
770
+ ensureMessageStarted();
771
+ text += delta;
772
+ enqueue("response.output_text.delta", {
773
+ content_index: 0,
774
+ delta,
775
+ item_id: messageId,
776
+ output_index: messageOutputIndex ?? 0,
777
+ type: "response.output_text.delta"
778
+ });
779
+ };
780
+ const appendToolCall = (toolCall) => {
781
+ const fn = asRecord(toolCall.function);
782
+ const index = typeof toolCall.index === "number" ? toolCall.index : tools.size;
783
+ let existing = tools.get(index);
784
+ const isNew = !existing;
785
+ existing ??= {
786
+ arguments: "",
787
+ id: contentToText(toolCall.id) || `call_${randomId()}`,
788
+ index,
789
+ itemId: `fc_${randomId()}`,
790
+ name: "",
791
+ outputIndex: nextOutputIndex++
792
+ };
793
+ existing.id = contentToText(toolCall.id) || existing.id;
794
+ existing.name += contentToText(fn.name);
795
+ tools.set(index, existing);
796
+ if (isNew) {
797
+ enqueue("response.output_item.added", {
798
+ item: functionCallItem(existing, "in_progress"),
799
+ output_index: existing.outputIndex ?? 0,
800
+ type: "response.output_item.added"
801
+ });
802
+ }
803
+ const argumentDelta = contentToText(fn.arguments);
804
+ if (argumentDelta) {
805
+ existing.arguments += argumentDelta;
806
+ enqueue("response.function_call_arguments.delta", {
807
+ delta: argumentDelta,
808
+ item_id: existing.itemId,
809
+ output_index: existing.outputIndex ?? 0,
810
+ type: "response.function_call_arguments.delta"
811
+ });
812
+ }
813
+ };
676
814
  const reader = chatStream.getReader();
677
815
  try {
678
816
  while (true) {
@@ -684,50 +822,48 @@ function responsesStreamFromChatStream(chatStream, options) {
684
822
  const lines = buffer.split(/\r?\n/);
685
823
  buffer = lines.pop() ?? "";
686
824
  for (const line of lines) {
687
- processChatSseLine(messageId, line, enqueue, tools, (delta) => {
688
- text += delta;
689
- });
825
+ processChatSseLine(line, { appendText, appendToolCall });
690
826
  }
691
827
  }
692
828
  if (buffer) {
693
- processChatSseLine(messageId, buffer, enqueue, tools, (delta) => {
694
- text += delta;
695
- });
829
+ processChatSseLine(buffer, { appendText, appendToolCall });
696
830
  }
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: [],
831
+ const outputEntries = [];
832
+ if (messageOutputIndex !== void 0) {
833
+ const item = messageOutputItem(text, messageId);
834
+ outputEntries.push([messageOutputIndex, item]);
835
+ enqueue("response.output_text.done", {
836
+ content_index: 0,
837
+ item_id: messageId,
838
+ output_index: messageOutputIndex,
712
839
  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", {
840
+ type: "response.output_text.done"
841
+ });
842
+ enqueue("response.content_part.done", {
843
+ content_index: 0,
844
+ item_id: messageId,
845
+ output_index: messageOutputIndex,
846
+ part: {
847
+ annotations: [],
848
+ text,
849
+ type: "output_text"
850
+ },
851
+ type: "response.content_part.done"
852
+ });
853
+ enqueue("response.output_item.done", {
725
854
  item,
726
- output_index: outputIndex,
727
- type: "response.output_item.added"
855
+ output_index: messageOutputIndex,
856
+ type: "response.output_item.done"
728
857
  });
858
+ }
859
+ for (const tool of [...tools.values()].sort(
860
+ (a, b) => (a.outputIndex ?? 0) - (b.outputIndex ?? 0)
861
+ )) {
862
+ const item = functionCallItem(tool);
863
+ const outputIndex = tool.outputIndex ?? 0;
864
+ outputEntries.push([outputIndex, item]);
729
865
  enqueue("response.function_call_arguments.done", {
730
- arguments: item.arguments,
866
+ arguments: tool.arguments,
731
867
  item_id: item.id,
732
868
  output_index: outputIndex,
733
869
  type: "response.function_call_arguments.done"
@@ -737,7 +873,8 @@ function responsesStreamFromChatStream(chatStream, options) {
737
873
  output_index: outputIndex,
738
874
  type: "response.output_item.done"
739
875
  });
740
- });
876
+ }
877
+ const output = outputEntries.sort(([left], [right]) => left - right).map(([, item]) => item);
741
878
  enqueue("response.completed", {
742
879
  response: baseStreamResponse(responseId, options.model, createdAt, "completed", output),
743
880
  type: "response.completed"
@@ -920,13 +1057,13 @@ function messageOutputItem(text, id = `msg_${randomId()}`) {
920
1057
  type: "message"
921
1058
  };
922
1059
  }
923
- function functionCallItem(tool) {
1060
+ function functionCallItem(tool, status = "completed") {
924
1061
  return {
925
1062
  arguments: tool.arguments,
926
1063
  call_id: tool.id,
927
- id: `fc_${randomId()}`,
1064
+ id: tool.itemId ?? `fc_${randomId()}`,
928
1065
  name: tool.name,
929
- status: "completed",
1066
+ status,
930
1067
  type: "function_call"
931
1068
  };
932
1069
  }
@@ -941,14 +1078,27 @@ function responseUsage(usage) {
941
1078
  if (Object.keys(record).length === 0) {
942
1079
  return null;
943
1080
  }
1081
+ const inputTokens = record.prompt_tokens;
1082
+ const outputTokens = record.completion_tokens;
944
1083
  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,
1084
+ input_tokens: inputTokens,
1085
+ input_tokens_details: responseUsageDetails(record.prompt_tokens_details, inputTokens, {
1086
+ cached_tokens: 0
1087
+ }),
1088
+ output_tokens: outputTokens,
1089
+ output_tokens_details: responseUsageDetails(record.completion_tokens_details, outputTokens, {
1090
+ reasoning_tokens: 0
1091
+ }),
949
1092
  total_tokens: record.total_tokens
950
1093
  });
951
1094
  }
1095
+ function responseUsageDetails(value, tokenCount, fallback) {
1096
+ const record = asRecord(value);
1097
+ if (Object.keys(record).length > 0) {
1098
+ return record;
1099
+ }
1100
+ return typeof tokenCount === "number" && Number.isFinite(tokenCount) ? fallback : void 0;
1101
+ }
952
1102
  function extractTokenUsage(usage) {
953
1103
  const record = asRecord(usage);
954
1104
  const prompt = firstNumber(record.prompt_tokens, record.input_tokens);
@@ -987,7 +1137,52 @@ function firstChoice(completion) {
987
1137
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
988
1138
  return asRecord(choices[0]);
989
1139
  }
990
- function processChatSseLine(messageId, line, enqueue, tools, appendText) {
1140
+ function processCompletionSseLine(line, enqueue, markDone) {
1141
+ const trimmed = line.trim();
1142
+ if (!trimmed.startsWith("data:")) {
1143
+ return;
1144
+ }
1145
+ const data = trimmed.slice("data:".length).trim();
1146
+ if (!data) {
1147
+ return;
1148
+ }
1149
+ if (data === "[DONE]") {
1150
+ markDone();
1151
+ enqueue("[DONE]");
1152
+ return;
1153
+ }
1154
+ const parsed = parseJson(data);
1155
+ if (!parsed) {
1156
+ return;
1157
+ }
1158
+ const choice = firstChoice(parsed);
1159
+ const delta = asRecord(choice.delta);
1160
+ const text = contentToText(delta.content);
1161
+ const finishReason = choice.finish_reason ?? null;
1162
+ const usage = asRecord(parsed.usage);
1163
+ const hasUsage = Object.keys(usage).length > 0;
1164
+ if (!text && finishReason === null && !hasUsage) {
1165
+ return;
1166
+ }
1167
+ enqueue(
1168
+ removeUndefined({
1169
+ choices: text || finishReason !== null ? [
1170
+ {
1171
+ finish_reason: finishReason,
1172
+ index: typeof choice.index === "number" ? choice.index : 0,
1173
+ logprobs: null,
1174
+ text
1175
+ }
1176
+ ] : [],
1177
+ created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
1178
+ id: contentToText(parsed.id) || `cmpl_${randomId()}`,
1179
+ model: contentToText(parsed.model) || DEFAULT_MODEL,
1180
+ object: "text_completion",
1181
+ usage: hasUsage ? usage : void 0
1182
+ })
1183
+ );
1184
+ }
1185
+ function processChatSseLine(line, handlers) {
991
1186
  const trimmed = line.trim();
992
1187
  if (!trimmed.startsWith("data:")) {
993
1188
  return;
@@ -1004,30 +1199,11 @@ function processChatSseLine(messageId, line, enqueue, tools, appendText) {
1004
1199
  const delta = asRecord(choice.delta);
1005
1200
  const content = contentToText(delta.content);
1006
1201
  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
- });
1202
+ handlers.appendText(content);
1015
1203
  }
1016
1204
  const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
1017
1205
  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);
1206
+ handlers.appendToolCall(asRecord(toolCall));
1031
1207
  }
1032
1208
  }
1033
1209
  function baseStreamResponse(id, model, createdAt, status, output) {
@@ -1057,6 +1233,14 @@ function encodeSse(event, data) {
1057
1233
  return `event: ${event}
1058
1234
  data: ${JSON.stringify(data)}
1059
1235
 
1236
+ `;
1237
+ }
1238
+ function encodeDataSse(data) {
1239
+ if (data === "[DONE]") {
1240
+ return "data: [DONE]\n\n";
1241
+ }
1242
+ return `data: ${JSON.stringify(data)}
1243
+
1060
1244
  `;
1061
1245
  }
1062
1246
  function parseJson(data) {
@@ -1445,6 +1629,7 @@ function formatNumber(value) {
1445
1629
  // src/server.ts
1446
1630
  var DEFAULT_HOST = "127.0.0.1";
1447
1631
  var DEFAULT_PORT = 4141;
1632
+ var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
1448
1633
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
1449
1634
  var USAGE_CACHE_TTL_MS = 6e4;
1450
1635
  function createHoopilotHandler(options = {}) {
@@ -1475,6 +1660,14 @@ function createHoopilotHandler(options = {}) {
1475
1660
  route,
1476
1661
  startedAt
1477
1662
  });
1663
+ const browserOrigin = forbiddenBrowserOrigin(request, apiKey);
1664
+ if (browserOrigin) {
1665
+ requestLogger.warn(
1666
+ { event: "http.request.forbidden_origin", origin: browserOrigin },
1667
+ "blocked unauthenticated browser-origin request"
1668
+ );
1669
+ return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
1670
+ }
1478
1671
  if (request.method === "OPTIONS") {
1479
1672
  return finish(new Response(null, { headers: corsHeaders() }));
1480
1673
  }
@@ -1526,6 +1719,7 @@ function createHoopilotHandler(options = {}) {
1526
1719
  { err: errorDetails(error), event: "http.request.failed" },
1527
1720
  "request body was invalid json"
1528
1721
  );
1722
+ return finish(jsonError(400, "invalid_request_error", message));
1529
1723
  } else {
1530
1724
  requestLogger.error(
1531
1725
  { err: errorDetails(error), event: "http.request.failed" },
@@ -1538,7 +1732,7 @@ function createHoopilotHandler(options = {}) {
1538
1732
  }
1539
1733
  function startHoopilotServer(options = {}) {
1540
1734
  const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
1541
- const port = Number(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1735
+ const port = normalizeServerPort(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1542
1736
  const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1543
1737
  const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
1544
1738
  if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
@@ -1604,8 +1798,19 @@ async function handleCompletions(client, metrics, recordTokens, request, logger)
1604
1798
  }
1605
1799
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
1606
1800
  const model = normalizeRequestedModel(body.model);
1607
- if (isStreamingResponse(upstream)) {
1608
- return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
1801
+ if (isStreamingResponse(upstream) && upstream.body) {
1802
+ return proxyResponse(
1803
+ observeResponseUsage(
1804
+ new Response(completionStreamFromChatStream(upstream.body), {
1805
+ headers: upstream.headers,
1806
+ status: upstream.status,
1807
+ statusText: upstream.statusText
1808
+ }),
1809
+ model,
1810
+ recordTokens,
1811
+ request.signal
1812
+ )
1813
+ );
1609
1814
  }
1610
1815
  const completion = asRecord(await upstream.json());
1611
1816
  const usage = extractTokenUsage(completion.usage);
@@ -1639,7 +1844,7 @@ async function proxyError(upstream, logger) {
1639
1844
  { event: "copilot.request.failed", upstreamStatus: upstream.status },
1640
1845
  "copilot upstream request failed"
1641
1846
  );
1642
- return jsonError(upstream.status, "copilot_error", text || upstream.statusText);
1847
+ return upstreamErrorResponse(upstream.status, text || upstream.statusText);
1643
1848
  }
1644
1849
  function proxyResponse(upstream) {
1645
1850
  const headers = new Headers(upstream.headers);
@@ -1692,6 +1897,13 @@ function jsonError(status, code, message) {
1692
1897
  status
1693
1898
  );
1694
1899
  }
1900
+ function upstreamErrorResponse(status, text) {
1901
+ const parsedError = asRecord(asRecord(safeParseJson(text)).error);
1902
+ if (Object.keys(parsedError).length > 0) {
1903
+ return jsonResponse({ error: parsedError }, status);
1904
+ }
1905
+ return jsonError(status, "copilot_error", text);
1906
+ }
1695
1907
  function websocketUnsupportedResponse() {
1696
1908
  const response = jsonError(
1697
1909
  426,
@@ -1716,6 +1928,17 @@ function isAuthorized(request, apiKey) {
1716
1928
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
1717
1929
  return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
1718
1930
  }
1931
+ function forbiddenBrowserOrigin(request, apiKey) {
1932
+ if (apiKey) {
1933
+ return void 0;
1934
+ }
1935
+ const origin = request.headers.get("origin")?.trim();
1936
+ if (origin) {
1937
+ return isLoopbackOrigin(origin) ? void 0 : origin;
1938
+ }
1939
+ const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
1940
+ return fetchSite === "cross-site" ? "cross-site" : void 0;
1941
+ }
1719
1942
  function isUpstreamAuthStatus(status) {
1720
1943
  return status === 401 || status === 403;
1721
1944
  }
@@ -1723,7 +1946,21 @@ function upstreamAuthMessage(message) {
1723
1946
  return `GitHub Copilot rejected the credential or account access: ${message}`;
1724
1947
  }
1725
1948
  function isLoopbackHost(host) {
1726
- return host === "localhost" || host === "127.0.0.1" || host === "::1";
1949
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
1950
+ }
1951
+ function isLoopbackOrigin(origin) {
1952
+ try {
1953
+ return isLoopbackHost(new URL(origin).hostname.toLowerCase());
1954
+ } catch {
1955
+ return false;
1956
+ }
1957
+ }
1958
+ function normalizeServerPort(value) {
1959
+ const port = Number(value);
1960
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
1961
+ throw new Error(`Invalid port: ${value}.`);
1962
+ }
1963
+ return port;
1727
1964
  }
1728
1965
  function errorMessage(error) {
1729
1966
  return error instanceof Error ? error.message : String(error);
@@ -1947,6 +2184,7 @@ export {
1947
2184
  authStorePath,
1948
2185
  chatCompletionToCompletion,
1949
2186
  chatCompletionToResponse,
2187
+ completionStreamFromChatStream,
1950
2188
  completionsRequestToChatCompletion,
1951
2189
  createHoopilotHandler,
1952
2190
  createHoopilotLogger,