@openhoo/hoopilot 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,56 @@
1
1
  // src/auth-store.ts
2
2
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
3
3
  import { dirname, join } from "path";
4
+
5
+ // src/util.ts
6
+ function trimTrailingSlash(value) {
7
+ return value.replace(/\/+$/, "");
8
+ }
9
+ function envValue(value) {
10
+ const trimmed = value?.trim();
11
+ return trimmed ? trimmed : void 0;
12
+ }
13
+ function isTrustedTokenBaseUrl(rawUrl, allowedHttpsHosts, allowUnsafeHttps = false) {
14
+ const url = parseUrl(rawUrl);
15
+ if (!url) {
16
+ return false;
17
+ }
18
+ if (url.username || url.password || url.search || url.hash) {
19
+ return false;
20
+ }
21
+ if (url.pathname !== "" && url.pathname !== "/") {
22
+ return false;
23
+ }
24
+ if (isLoopbackHttpUrl(url)) {
25
+ return true;
26
+ }
27
+ if (url.protocol !== "https:") {
28
+ return false;
29
+ }
30
+ const host = url.hostname.toLowerCase();
31
+ return allowedHttpsHosts.includes(host) || allowUnsafeHttps;
32
+ }
33
+ function parseUrl(rawUrl) {
34
+ let url;
35
+ try {
36
+ url = new URL(rawUrl);
37
+ } catch {
38
+ return void 0;
39
+ }
40
+ return url;
41
+ }
42
+ function isLoopbackHttpUrl(url) {
43
+ return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
44
+ }
45
+ async function truncatedResponseText(response, max = 500) {
46
+ const text = await response.text();
47
+ return text.slice(0, max);
48
+ }
49
+ function asRecord(value) {
50
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
51
+ }
52
+
53
+ // src/auth-store.ts
4
54
  var StoredCopilotAuthError = class extends Error {
5
55
  constructor(message) {
6
56
  super(message);
@@ -8,10 +58,25 @@ var StoredCopilotAuthError = class extends Error {
8
58
  }
9
59
  };
10
60
  function authStorePath(env = process.env) {
11
- if (env.HOOPILOT_AUTH_FILE) {
12
- return env.HOOPILOT_AUTH_FILE;
61
+ const explicit = envValue(env.HOOPILOT_AUTH_FILE);
62
+ if (explicit) {
63
+ return explicit;
64
+ }
65
+ const xdg = envValue(env.XDG_CONFIG_HOME);
66
+ if (xdg) {
67
+ return join(xdg, "hoopilot", "auth.json");
68
+ }
69
+ const appdata = envValue(env.APPDATA);
70
+ if (appdata) {
71
+ return join(appdata, "hoopilot", "auth.json");
13
72
  }
14
- const base = env.XDG_CONFIG_HOME ?? env.APPDATA ?? (env.HOME ? join(env.HOME, ".config") : join(process.cwd(), ".config"));
73
+ const home = envValue(env.HOME);
74
+ if (!home) {
75
+ throw new StoredCopilotAuthError(
76
+ "Cannot resolve Hoopilot auth file path without HOOPILOT_AUTH_FILE, XDG_CONFIG_HOME, APPDATA, or HOME."
77
+ );
78
+ }
79
+ const base = join(home, ".config");
15
80
  return join(base, "hoopilot", "auth.json");
16
81
  }
17
82
  function readStoredCopilotAuth(path = authStorePath()) {
@@ -68,30 +133,6 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
68
133
  }
69
134
  }
70
135
 
71
- // src/util.ts
72
- function trimTrailingSlash(value) {
73
- return value.replace(/\/+$/, "");
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
- }
87
- async function truncatedResponseText(response, max = 500) {
88
- const text = await response.text();
89
- return text.slice(0, max);
90
- }
91
- function asRecord(value) {
92
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
93
- }
94
-
95
136
  // src/auth.ts
96
137
  var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
97
138
  var REFRESH_SKEW_MS = 6e4;
@@ -105,11 +146,15 @@ var CopilotAuthError = class extends Error {
105
146
  var CopilotAuth = class {
106
147
  #authStorePath;
107
148
  #copilotApiBaseUrl;
149
+ #hasCopilotApiBaseUrlOverride;
108
150
  #cachedAccess;
109
151
  constructor(options = {}) {
110
- this.#authStorePath = options.authStorePath ?? options.env?.HOOPILOT_AUTH_FILE;
152
+ const envAuthStorePath = envValue(options.env?.HOOPILOT_AUTH_FILE);
153
+ const envCopilotApiBaseUrl = envValue(options.env?.COPILOT_API_BASE_URL);
154
+ this.#authStorePath = options.authStorePath ?? envAuthStorePath;
155
+ this.#hasCopilotApiBaseUrlOverride = Boolean(options.copilotApiBaseUrl ?? envCopilotApiBaseUrl);
111
156
  this.#copilotApiBaseUrl = trimTrailingSlash(
112
- options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
157
+ options.copilotApiBaseUrl ?? envCopilotApiBaseUrl ?? DEFAULT_COPILOT_API_BASE_URL
113
158
  );
114
159
  }
115
160
  async getAccess() {
@@ -127,7 +172,9 @@ var CopilotAuth = class {
127
172
  }
128
173
  if (stored) {
129
174
  return this.#cacheAccess({
130
- apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
175
+ apiBaseUrl: trimTrailingSlash(
176
+ this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
177
+ ),
131
178
  expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
132
179
  source: "github-copilot-oauth",
133
180
  token: stored.token
@@ -145,6 +192,8 @@ var CopilotAuth = class {
145
192
 
146
193
  // src/copilot.ts
147
194
  var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
195
+ var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
196
+ var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
148
197
  var COPILOT_USAGE_API_VERSION = "2025-04-01";
149
198
  function applyCopilotHeaders(headers, token) {
150
199
  headers.set("accept", headers.get("accept") ?? "application/json");
@@ -168,13 +217,15 @@ function applyGithubApiHeaders(headers, token) {
168
217
  }
169
218
  var CopilotClient = class {
170
219
  #auth;
220
+ #allowUnsafeUpstream;
171
221
  #fetch;
172
222
  #githubApiBaseUrl;
173
223
  constructor(options = {}) {
174
224
  this.#auth = new CopilotAuth(options);
225
+ this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
175
226
  this.#fetch = options.fetch ?? fetch;
176
227
  this.#githubApiBaseUrl = trimTrailingSlash(
177
- options.githubApiBaseUrl ?? options.env?.HOOPILOT_GITHUB_API_BASE_URL ?? DEFAULT_GITHUB_API_BASE_URL
228
+ options.githubApiBaseUrl ?? envValue(options.env?.HOOPILOT_GITHUB_API_BASE_URL) ?? DEFAULT_GITHUB_API_BASE_URL
178
229
  );
179
230
  }
180
231
  /**
@@ -183,9 +234,13 @@ var CopilotClient = class {
183
234
  * accepted directly here — no Copilot token exchange is required to read quota.
184
235
  */
185
236
  async usage(signal) {
186
- if (!isHttpsOrLoopbackUrl(this.#githubApiBaseUrl)) {
237
+ if (!isTrustedTokenBaseUrl(
238
+ this.#githubApiBaseUrl,
239
+ ALLOWED_GITHUB_API_HOSTS,
240
+ this.#allowUnsafeUpstream
241
+ )) {
187
242
  throw new Error(
188
- `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${this.#githubApiBaseUrl}`
243
+ `Refusing to send the GitHub OAuth token to an untrusted GitHub API host: ${this.#githubApiBaseUrl}`
189
244
  );
190
245
  }
191
246
  const access = await this.#auth.getAccess();
@@ -227,9 +282,13 @@ var CopilotClient = class {
227
282
  }
228
283
  async fetchCopilot(path, init) {
229
284
  const access = await this.#auth.getAccess();
230
- if (!isHttpsOrLoopbackUrl(access.apiBaseUrl)) {
285
+ if (!isTrustedTokenBaseUrl(
286
+ access.apiBaseUrl,
287
+ ALLOWED_COPILOT_API_HOSTS,
288
+ this.#allowUnsafeUpstream
289
+ )) {
231
290
  throw new Error(
232
- `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${access.apiBaseUrl}`
291
+ `Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${access.apiBaseUrl}`
233
292
  );
234
293
  }
235
294
  const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
@@ -315,9 +374,9 @@ async function githubCopilotDeviceLogin(options = {}) {
315
374
  const fetcher = options.fetch ?? fetch;
316
375
  const sleeper = options.sleep ?? sleep;
317
376
  const domain = normalizeDomain(
318
- options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
377
+ options.domain ?? envValue(env.HOOPILOT_GITHUB_DOMAIN) ?? DEFAULT_GITHUB_DOMAIN
319
378
  );
320
- const clientId = options.clientId ?? env.HOOPILOT_GITHUB_CLIENT_ID ?? env.COPILOT_GITHUB_CLIENT_ID ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
379
+ const clientId = options.clientId ?? envValue(env.HOOPILOT_GITHUB_CLIENT_ID) ?? envValue(env.COPILOT_GITHUB_CLIENT_ID) ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
321
380
  const device = await requestDeviceCode(fetcher, domain, clientId);
322
381
  const verificationUrl = device.verification_uri;
323
382
  const userCode = device.user_code;
@@ -475,8 +534,8 @@ var noopLogger = {
475
534
  };
476
535
  function createHoopilotLogger(options = {}) {
477
536
  const env = options.env ?? process.env;
478
- const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
479
- const format = parseLogFormat(options.format ?? env.HOOPILOT_LOG_FORMAT);
537
+ const level = parseLogLevel(options.level ?? envValue(env.HOOPILOT_LOG_LEVEL));
538
+ const format = parseLogFormat(options.format ?? envValue(env.HOOPILOT_LOG_FORMAT));
480
539
  const pinoOptions = {
481
540
  base: {
482
541
  service: "hoopilot",
@@ -526,7 +585,7 @@ function parseLogLevel(value) {
526
585
  }
527
586
  function shouldCreateLogger(options) {
528
587
  return Boolean(
529
- options.logger || options.logFormat || options.logLevel || options.env?.HOOPILOT_LOG_FORMAT || options.env?.HOOPILOT_LOG_LEVEL
588
+ options.logger || options.logFormat || options.logLevel || envValue(options.env?.HOOPILOT_LOG_FORMAT) || envValue(options.env?.HOOPILOT_LOG_LEVEL)
530
589
  );
531
590
  }
532
591
  function errorDetails(error) {
@@ -548,6 +607,12 @@ function isLogLevel(value) {
548
607
 
549
608
  // src/openai.ts
550
609
  var DEFAULT_MODEL = "gpt-4.1";
610
+ var OpenAICompatibilityError = class extends Error {
611
+ constructor(message) {
612
+ super(message);
613
+ this.name = "OpenAICompatibilityError";
614
+ }
615
+ };
551
616
  function responsesRequestToChatCompletion(request) {
552
617
  const messages = [];
553
618
  const instructions = contentToText(request.instructions);
@@ -581,13 +646,22 @@ function normalizeChatCompletionRequest(request) {
581
646
  });
582
647
  }
583
648
  function completionsRequestToChatCompletion(request) {
649
+ assertSupportedLegacyCompletionRequest(request);
584
650
  return removeUndefined({
651
+ frequency_penalty: request.frequency_penalty,
652
+ logit_bias: request.logit_bias,
585
653
  max_tokens: request.max_tokens,
586
- messages: [{ content: promptToText(request.prompt), role: "user" }],
654
+ messages: [{ content: legacyPromptToText(request.prompt), role: "user" }],
587
655
  model: normalizeRequestedModel(request.model),
656
+ n: request.n,
657
+ presence_penalty: request.presence_penalty,
658
+ seed: request.seed,
659
+ stop: request.stop,
588
660
  stream: request.stream === true,
661
+ stream_options: request.stream_options,
589
662
  temperature: request.temperature,
590
- top_p: request.top_p
663
+ top_p: request.top_p,
664
+ user: request.user
591
665
  });
592
666
  }
593
667
  function normalizeRequestedModel(model) {
@@ -623,21 +697,21 @@ function chatCompletionToResponse(completion, responseId) {
623
697
  });
624
698
  }
625
699
  function chatCompletionToCompletion(completion) {
626
- const choice = firstChoice(completion);
627
- const message = asRecord(choice.message);
628
700
  return removeUndefined({
629
- choices: [
630
- {
701
+ choices: completionChoices(completion).map((choice, index) => {
702
+ const message = asRecord(choice.message);
703
+ return {
631
704
  finish_reason: choice.finish_reason ?? "stop",
632
- index: 0,
633
- logprobs: null,
634
- text: contentToText(message.content)
635
- }
636
- ],
705
+ index: typeof choice.index === "number" ? choice.index : index,
706
+ logprobs: choice.logprobs ?? null,
707
+ text: contentToText(choice.text) || contentToText(message.content)
708
+ };
709
+ }),
637
710
  created: completion.created ?? epochSeconds(),
638
711
  id: completion.id ?? `cmpl_${randomId()}`,
639
712
  model: completion.model ?? DEFAULT_MODEL,
640
713
  object: "text_completion",
714
+ system_fingerprint: completion.system_fingerprint,
641
715
  usage: completion.usage
642
716
  });
643
717
  }
@@ -645,12 +719,15 @@ function completionStreamFromChatStream(chatStream) {
645
719
  const encoder = new TextEncoder();
646
720
  const decoder = new TextDecoder();
647
721
  let buffer = "";
648
- let sawDone = false;
722
+ let sawTerminalEvent = false;
649
723
  return new ReadableStream({
650
724
  async start(controller) {
651
725
  const enqueue = (data) => {
652
726
  controller.enqueue(encoder.encode(encodeDataSse(data)));
653
727
  };
728
+ const markTerminal = () => {
729
+ sawTerminalEvent = true;
730
+ };
654
731
  const reader = chatStream.getReader();
655
732
  try {
656
733
  while (true) {
@@ -659,20 +736,17 @@ function completionStreamFromChatStream(chatStream) {
659
736
  break;
660
737
  }
661
738
  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
- });
739
+ const blocks = buffer.split(/\r?\n\r?\n/);
740
+ buffer = blocks.pop() ?? "";
741
+ for (const block of blocks) {
742
+ processCompletionSseBlock(block, enqueue, markTerminal);
668
743
  }
669
744
  }
670
- if (buffer) {
671
- processCompletionSseLine(buffer, enqueue, () => {
672
- sawDone = true;
673
- });
745
+ const tail = `${buffer}${decoder.decode()}`;
746
+ if (tail.trim()) {
747
+ processCompletionSseBlock(tail, enqueue, markTerminal);
674
748
  }
675
- if (!sawDone) {
749
+ if (!sawTerminalEvent) {
676
750
  enqueue("[DONE]");
677
751
  }
678
752
  controller.close();
@@ -901,7 +975,8 @@ function inputToMessages(input) {
901
975
  const messages = [];
902
976
  for (const item of input) {
903
977
  const record = asRecord(item);
904
- if (record.type === "function_call_output") {
978
+ const type = contentToText(record.type);
979
+ if (type === "function_call_output") {
905
980
  messages.push({
906
981
  content: contentToText(record.output),
907
982
  role: "tool",
@@ -909,7 +984,7 @@ function inputToMessages(input) {
909
984
  });
910
985
  continue;
911
986
  }
912
- if (record.type === "function_call") {
987
+ if (type === "function_call") {
913
988
  messages.push({
914
989
  role: "assistant",
915
990
  tool_calls: [
@@ -925,7 +1000,10 @@ function inputToMessages(input) {
925
1000
  });
926
1001
  continue;
927
1002
  }
928
- const role = roleToChatRole(contentToText(record.role));
1003
+ if (type && type !== "message") {
1004
+ unsupportedResponsesFeature(`input item type "${type}"`);
1005
+ }
1006
+ const role = responsesRoleToChatRole(contentToText(record.role));
929
1007
  const content = chatMessageContent(record.content);
930
1008
  if (role && content !== void 0) {
931
1009
  messages.push({ content, role });
@@ -938,7 +1016,10 @@ function chatMessageContent(content) {
938
1016
  return content;
939
1017
  }
940
1018
  if (!Array.isArray(content)) {
941
- return contentToText(content) || void 0;
1019
+ if (content === void 0 || content === null) {
1020
+ return void 0;
1021
+ }
1022
+ unsupportedResponsesFeature("non-array message content objects");
942
1023
  }
943
1024
  const parts = [];
944
1025
  for (const part of content) {
@@ -946,13 +1027,31 @@ function chatMessageContent(content) {
946
1027
  const type = contentToText(record.type);
947
1028
  if (type === "input_text" || type === "output_text" || type === "text") {
948
1029
  parts.push({ text: contentToText(record.text), type: "text" });
1030
+ continue;
949
1031
  }
950
1032
  if (type === "input_image") {
1033
+ if (contentToText(record.file_id)) {
1034
+ unsupportedResponsesFeature("input_image file_id parts");
1035
+ }
951
1036
  const imageUrl = contentToText(record.image_url);
952
- if (imageUrl) {
953
- parts.push({ image_url: { url: imageUrl }, type: "image_url" });
1037
+ if (!imageUrl) {
1038
+ unsupportedResponsesFeature("input_image parts without image_url");
954
1039
  }
1040
+ const image = { url: imageUrl };
1041
+ const detail = contentToText(record.detail);
1042
+ if (detail) {
1043
+ image.detail = detail;
1044
+ }
1045
+ parts.push({ image_url: image, type: "image_url" });
1046
+ continue;
1047
+ }
1048
+ if (type === "input_file") {
1049
+ unsupportedResponsesFeature("input_file parts");
955
1050
  }
1051
+ if (type === "input_audio") {
1052
+ unsupportedResponsesFeature("input_audio parts");
1053
+ }
1054
+ unsupportedResponsesFeature(`content part type "${type || "unknown"}"`);
956
1055
  }
957
1056
  if (parts.length === 0) {
958
1057
  return void 0;
@@ -962,11 +1061,38 @@ function chatMessageContent(content) {
962
1061
  }
963
1062
  return parts;
964
1063
  }
965
- function promptToText(prompt) {
966
- if (Array.isArray(prompt)) {
967
- return prompt.map((item) => contentToText(item)).join("\n");
1064
+ function legacyPromptToText(prompt) {
1065
+ if (typeof prompt === "string") {
1066
+ return prompt;
1067
+ }
1068
+ if (Array.isArray(prompt) && prompt.length === 1 && typeof prompt[0] === "string") {
1069
+ return prompt[0];
1070
+ }
1071
+ throw new OpenAICompatibilityError(
1072
+ "Hoopilot legacy completions compatibility supports exactly one string prompt per request."
1073
+ );
1074
+ }
1075
+ function assertSupportedLegacyCompletionRequest(request) {
1076
+ if (request.echo === true) {
1077
+ throw new OpenAICompatibilityError(
1078
+ "Hoopilot legacy completions compatibility does not support echo=true."
1079
+ );
1080
+ }
1081
+ if (typeof request.best_of === "number" && request.best_of > 1) {
1082
+ throw new OpenAICompatibilityError(
1083
+ "Hoopilot legacy completions compatibility does not support best_of greater than 1."
1084
+ );
1085
+ }
1086
+ if (typeof request.logprobs === "number" && request.logprobs > 0) {
1087
+ throw new OpenAICompatibilityError(
1088
+ "Hoopilot legacy completions compatibility does not support legacy logprobs."
1089
+ );
1090
+ }
1091
+ if (contentToText(request.suffix)) {
1092
+ throw new OpenAICompatibilityError(
1093
+ "Hoopilot legacy completions compatibility does not support suffix."
1094
+ );
968
1095
  }
969
- return contentToText(prompt);
970
1096
  }
971
1097
  function contentToText(content) {
972
1098
  if (typeof content === "string") {
@@ -990,25 +1116,35 @@ function contentToText(content) {
990
1116
  }
991
1117
  return "";
992
1118
  }
993
- function roleToChatRole(role) {
994
- if (role === "assistant" || role === "developer" || role === "system" || role === "tool") {
1119
+ function responsesRoleToChatRole(role) {
1120
+ if (!role) {
1121
+ return "user";
1122
+ }
1123
+ if (role === "assistant" || role === "developer" || role === "system" || role === "tool" || role === "user") {
995
1124
  return role === "developer" ? "system" : role;
996
1125
  }
997
- return "user";
1126
+ unsupportedResponsesFeature(`message role "${role}"`);
998
1127
  }
999
1128
  function chatTools(tools) {
1000
1129
  if (!Array.isArray(tools)) {
1001
1130
  return void 0;
1002
1131
  }
1003
- const converted = tools.map((tool) => asRecord(tool)).filter((tool) => tool.type === "function").map((tool) => ({
1004
- function: removeUndefined({
1005
- description: tool.description,
1006
- name: tool.name,
1007
- parameters: tool.parameters,
1008
- strict: tool.strict
1009
- }),
1010
- type: "function"
1011
- }));
1132
+ const converted = tools.map((tool) => {
1133
+ const record = asRecord(tool);
1134
+ const type = contentToText(record.type);
1135
+ if (type !== "function") {
1136
+ unsupportedResponsesFeature(`tool type "${type || "unknown"}"`);
1137
+ }
1138
+ return {
1139
+ function: removeUndefined({
1140
+ description: record.description,
1141
+ name: record.name,
1142
+ parameters: record.parameters,
1143
+ strict: record.strict
1144
+ }),
1145
+ type: "function"
1146
+ };
1147
+ });
1012
1148
  return converted.length > 0 ? converted : void 0;
1013
1149
  }
1014
1150
  function chatToolChoice(toolChoice) {
@@ -1016,10 +1152,16 @@ function chatToolChoice(toolChoice) {
1016
1152
  return toolChoice;
1017
1153
  }
1018
1154
  const record = asRecord(toolChoice);
1019
- if (record.type === "function" && typeof record.name === "string") {
1155
+ const type = contentToText(record.type);
1156
+ if (type === "function" && typeof record.name === "string") {
1020
1157
  return { function: { name: record.name }, type: "function" };
1021
1158
  }
1022
- return toolChoice;
1159
+ unsupportedResponsesFeature(`tool_choice type "${type || "unknown"}"`);
1160
+ }
1161
+ function unsupportedResponsesFeature(feature) {
1162
+ throw new OpenAICompatibilityError(
1163
+ `Hoopilot Responses-to-chat compatibility does not support ${feature}.`
1164
+ );
1023
1165
  }
1024
1166
  function outputItemsFromMessage(message) {
1025
1167
  const output = [];
@@ -1134,20 +1276,29 @@ function firstNumber(...values) {
1134
1276
  return void 0;
1135
1277
  }
1136
1278
  function firstChoice(completion) {
1137
- const choices = Array.isArray(completion.choices) ? completion.choices : [];
1138
- return asRecord(choices[0]);
1279
+ return completionChoices(completion)[0] ?? {};
1139
1280
  }
1140
- function processCompletionSseLine(line, enqueue, markDone) {
1141
- const trimmed = line.trim();
1142
- if (!trimmed.startsWith("data:")) {
1143
- return;
1281
+ function completionChoices(completion) {
1282
+ const choices = Array.isArray(completion.choices) ? completion.choices : [];
1283
+ return choices.map((choice) => asRecord(choice));
1284
+ }
1285
+ function processCompletionSseBlock(block, enqueue, markTerminal) {
1286
+ let event = "message";
1287
+ const dataLines = [];
1288
+ for (const line of block.split(/\r?\n/)) {
1289
+ const trimmed = line.trim();
1290
+ if (trimmed.startsWith("event:")) {
1291
+ event = trimmed.slice("event:".length).trim() || event;
1292
+ } else if (trimmed.startsWith("data:")) {
1293
+ dataLines.push(trimmed.slice("data:".length).trim());
1294
+ }
1144
1295
  }
1145
- const data = trimmed.slice("data:".length).trim();
1296
+ const data = dataLines.join("\n");
1146
1297
  if (!data) {
1147
1298
  return;
1148
1299
  }
1149
1300
  if (data === "[DONE]") {
1150
- markDone();
1301
+ markTerminal();
1151
1302
  enqueue("[DONE]");
1152
1303
  return;
1153
1304
  }
@@ -1155,25 +1306,34 @@ function processCompletionSseLine(line, enqueue, markDone) {
1155
1306
  if (!parsed) {
1156
1307
  return;
1157
1308
  }
1158
- const choice = firstChoice(parsed);
1159
- const delta = asRecord(choice.delta);
1160
- const text = contentToText(delta.content);
1161
- const finishReason = choice.finish_reason ?? null;
1309
+ const error = completionStreamError(event, parsed);
1310
+ if (error) {
1311
+ markTerminal();
1312
+ enqueue({ error });
1313
+ return;
1314
+ }
1315
+ const choices = completionChoices(parsed).map((choice, index) => {
1316
+ const delta = asRecord(choice.delta);
1317
+ const text = contentToText(delta.content);
1318
+ const finishReason = choice.finish_reason ?? null;
1319
+ if (!text && finishReason === null) {
1320
+ return void 0;
1321
+ }
1322
+ return {
1323
+ finish_reason: finishReason,
1324
+ index: typeof choice.index === "number" ? choice.index : index,
1325
+ logprobs: choice.logprobs ?? null,
1326
+ text
1327
+ };
1328
+ }).filter((choice) => choice !== void 0);
1162
1329
  const usage = asRecord(parsed.usage);
1163
1330
  const hasUsage = Object.keys(usage).length > 0;
1164
- if (!text && finishReason === null && !hasUsage) {
1331
+ if (choices.length === 0 && !hasUsage) {
1165
1332
  return;
1166
1333
  }
1167
1334
  enqueue(
1168
1335
  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
- ] : [],
1336
+ choices,
1177
1337
  created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
1178
1338
  id: contentToText(parsed.id) || `cmpl_${randomId()}`,
1179
1339
  model: contentToText(parsed.model) || DEFAULT_MODEL,
@@ -1182,6 +1342,22 @@ function processCompletionSseLine(line, enqueue, markDone) {
1182
1342
  })
1183
1343
  );
1184
1344
  }
1345
+ function completionStreamError(event, parsed) {
1346
+ const responseError = asRecord(asRecord(parsed.response).error);
1347
+ const directError = asRecord(parsed.error);
1348
+ const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
1349
+ if (error) {
1350
+ return error;
1351
+ }
1352
+ if (event === "error" || parsed.type === "response.failed") {
1353
+ return removeUndefined({
1354
+ code: contentToText(parsed.code) || void 0,
1355
+ message: contentToText(parsed.message) || "Upstream streaming request failed.",
1356
+ type: contentToText(parsed.type) || "upstream_stream_error"
1357
+ });
1358
+ }
1359
+ return void 0;
1360
+ }
1185
1361
  function processChatSseLine(line, handlers) {
1186
1362
  const trimmed = line.trim();
1187
1363
  if (!trimmed.startsWith("data:")) {
@@ -1631,10 +1807,19 @@ var DEFAULT_HOST = "127.0.0.1";
1631
1807
  var DEFAULT_PORT = 4141;
1632
1808
  var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
1633
1809
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
1810
+ var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
1811
+ var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
1812
+ var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
1634
1813
  var USAGE_CACHE_TTL_MS = 6e4;
1814
+ var RequestBodyTooLargeError = class extends Error {
1815
+ constructor() {
1816
+ super(REQUEST_TOO_LARGE_MESSAGE);
1817
+ this.name = "RequestBodyTooLargeError";
1818
+ }
1819
+ };
1635
1820
  function createHoopilotHandler(options = {}) {
1636
1821
  const client = new CopilotClient(options);
1637
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1822
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
1638
1823
  const logger = serverLogger(options);
1639
1824
  const metrics = options.metrics ?? new MetricsRegistry();
1640
1825
  const readUsage = createUsageReader(client, metrics);
@@ -1720,6 +1905,18 @@ function createHoopilotHandler(options = {}) {
1720
1905
  "request body was invalid json"
1721
1906
  );
1722
1907
  return finish(jsonError(400, "invalid_request_error", message));
1908
+ } else if (error instanceof OpenAICompatibilityError) {
1909
+ requestLogger.warn(
1910
+ { err: errorDetails(error), event: "http.request.failed" },
1911
+ "request body used unsupported OpenAI compatibility fields"
1912
+ );
1913
+ return finish(jsonError(400, "invalid_request_error", message));
1914
+ } else if (error instanceof RequestBodyTooLargeError) {
1915
+ requestLogger.warn(
1916
+ { err: errorDetails(error), event: "http.request.failed" },
1917
+ "request body exceeded size limit"
1918
+ );
1919
+ return finish(jsonError(413, "request_too_large", message));
1723
1920
  } else {
1724
1921
  requestLogger.error(
1725
1922
  { err: errorDetails(error), event: "http.request.failed" },
@@ -1731,10 +1928,10 @@ function createHoopilotHandler(options = {}) {
1731
1928
  };
1732
1929
  }
1733
1930
  function startHoopilotServer(options = {}) {
1734
- const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
1735
- const port = normalizeServerPort(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1736
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1737
- const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
1931
+ const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
1932
+ const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
1933
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
1934
+ const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
1738
1935
  if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
1739
1936
  throw new Error(
1740
1937
  "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
@@ -1752,7 +1949,7 @@ function startHoopilotServer(options = {}) {
1752
1949
  });
1753
1950
  return {
1754
1951
  server,
1755
- url: `http://${host}:${server.port}`
1952
+ url: `http://${urlHost(host)}:${server.port}`
1756
1953
  };
1757
1954
  }
1758
1955
  async function handleModels(client, metrics, signal, logger) {
@@ -1861,14 +2058,15 @@ function proxyResponse(upstream) {
1861
2058
  });
1862
2059
  }
1863
2060
  async function readJson(request) {
2061
+ const text = await readRequestText(request);
1864
2062
  try {
1865
- return asRecord(await request.json());
2063
+ return asRecord(JSON.parse(text));
1866
2064
  } catch {
1867
2065
  throw new Error(INVALID_JSON_MESSAGE);
1868
2066
  }
1869
2067
  }
1870
2068
  async function readJsonText(request) {
1871
- const text = await request.text();
2069
+ const text = await readRequestText(request);
1872
2070
  try {
1873
2071
  JSON.parse(text);
1874
2072
  return text;
@@ -1876,6 +2074,40 @@ async function readJsonText(request) {
1876
2074
  throw new Error(INVALID_JSON_MESSAGE);
1877
2075
  }
1878
2076
  }
2077
+ async function readRequestText(request) {
2078
+ const contentLength = request.headers.get("content-length");
2079
+ if (contentLength) {
2080
+ const declaredBytes = Number(contentLength);
2081
+ if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
2082
+ throw new RequestBodyTooLargeError();
2083
+ }
2084
+ }
2085
+ const body = request.body;
2086
+ if (!body) {
2087
+ return "";
2088
+ }
2089
+ const reader = body.getReader();
2090
+ const decoder = new TextDecoder();
2091
+ let bytes = 0;
2092
+ let text = "";
2093
+ try {
2094
+ while (true) {
2095
+ const { done, value } = await reader.read();
2096
+ if (done) {
2097
+ return `${text}${decoder.decode()}`;
2098
+ }
2099
+ bytes += value.byteLength;
2100
+ if (bytes > MAX_REQUEST_BODY_BYTES) {
2101
+ await reader.cancel().catch(() => {
2102
+ });
2103
+ throw new RequestBodyTooLargeError();
2104
+ }
2105
+ text += decoder.decode(value, { stream: true });
2106
+ }
2107
+ } finally {
2108
+ reader.releaseLock();
2109
+ }
2110
+ }
1879
2111
  function jsonResponse(body, status = 200) {
1880
2112
  return new Response(JSON.stringify(body), {
1881
2113
  headers: {
@@ -1948,6 +2180,9 @@ function upstreamAuthMessage(message) {
1948
2180
  function isLoopbackHost(host) {
1949
2181
  return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
1950
2182
  }
2183
+ function urlHost(host) {
2184
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
2185
+ }
1951
2186
  function isLoopbackOrigin(origin) {
1952
2187
  try {
1953
2188
  return isLoopbackHost(new URL(origin).hostname.toLowerCase());
@@ -2055,7 +2290,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
2055
2290
  }
2056
2291
  function requestIdFor(request) {
2057
2292
  const existing = request.headers.get("x-request-id")?.trim();
2058
- return existing || crypto.randomUUID();
2293
+ return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
2059
2294
  }
2060
2295
  function canonicalApiPath(path) {
2061
2296
  const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;