@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.cjs CHANGED
@@ -71,6 +71,56 @@ module.exports = __toCommonJS(index_exports);
71
71
  // src/auth-store.ts
72
72
  var import_node_fs = require("fs");
73
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
74
124
  var StoredCopilotAuthError = class extends Error {
75
125
  constructor(message) {
76
126
  super(message);
@@ -78,10 +128,25 @@ var StoredCopilotAuthError = class extends Error {
78
128
  }
79
129
  };
80
130
  function authStorePath(env = process.env) {
81
- if (env.HOOPILOT_AUTH_FILE) {
82
- 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");
83
142
  }
84
- 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"));
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
+ );
148
+ }
149
+ const base = (0, import_node_path.join)(home, ".config");
85
150
  return (0, import_node_path.join)(base, "hoopilot", "auth.json");
86
151
  }
87
152
  function readStoredCopilotAuth(path = authStorePath()) {
@@ -138,30 +203,6 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
138
203
  }
139
204
  }
140
205
 
141
- // src/util.ts
142
- function trimTrailingSlash(value) {
143
- return value.replace(/\/+$/, "");
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
- }
157
- async function truncatedResponseText(response, max = 500) {
158
- const text = await response.text();
159
- return text.slice(0, max);
160
- }
161
- function asRecord(value) {
162
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
163
- }
164
-
165
206
  // src/auth.ts
166
207
  var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
167
208
  var REFRESH_SKEW_MS = 6e4;
@@ -175,11 +216,15 @@ var CopilotAuthError = class extends Error {
175
216
  var CopilotAuth = class {
176
217
  #authStorePath;
177
218
  #copilotApiBaseUrl;
219
+ #hasCopilotApiBaseUrlOverride;
178
220
  #cachedAccess;
179
221
  constructor(options = {}) {
180
- 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);
181
226
  this.#copilotApiBaseUrl = trimTrailingSlash(
182
- options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
227
+ options.copilotApiBaseUrl ?? envCopilotApiBaseUrl ?? DEFAULT_COPILOT_API_BASE_URL
183
228
  );
184
229
  }
185
230
  async getAccess() {
@@ -197,7 +242,9 @@ var CopilotAuth = class {
197
242
  }
198
243
  if (stored) {
199
244
  return this.#cacheAccess({
200
- apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
245
+ apiBaseUrl: trimTrailingSlash(
246
+ this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
247
+ ),
201
248
  expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
202
249
  source: "github-copilot-oauth",
203
250
  token: stored.token
@@ -215,6 +262,8 @@ var CopilotAuth = class {
215
262
 
216
263
  // src/copilot.ts
217
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"];
218
267
  var COPILOT_USAGE_API_VERSION = "2025-04-01";
219
268
  function applyCopilotHeaders(headers, token) {
220
269
  headers.set("accept", headers.get("accept") ?? "application/json");
@@ -238,13 +287,15 @@ function applyGithubApiHeaders(headers, token) {
238
287
  }
239
288
  var CopilotClient = class {
240
289
  #auth;
290
+ #allowUnsafeUpstream;
241
291
  #fetch;
242
292
  #githubApiBaseUrl;
243
293
  constructor(options = {}) {
244
294
  this.#auth = new CopilotAuth(options);
295
+ this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
245
296
  this.#fetch = options.fetch ?? fetch;
246
297
  this.#githubApiBaseUrl = trimTrailingSlash(
247
- 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
248
299
  );
249
300
  }
250
301
  /**
@@ -253,9 +304,13 @@ var CopilotClient = class {
253
304
  * accepted directly here — no Copilot token exchange is required to read quota.
254
305
  */
255
306
  async usage(signal) {
256
- if (!isHttpsOrLoopbackUrl(this.#githubApiBaseUrl)) {
307
+ if (!isTrustedTokenBaseUrl(
308
+ this.#githubApiBaseUrl,
309
+ ALLOWED_GITHUB_API_HOSTS,
310
+ this.#allowUnsafeUpstream
311
+ )) {
257
312
  throw new Error(
258
- `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}`
259
314
  );
260
315
  }
261
316
  const access = await this.#auth.getAccess();
@@ -297,9 +352,13 @@ var CopilotClient = class {
297
352
  }
298
353
  async fetchCopilot(path, init) {
299
354
  const access = await this.#auth.getAccess();
300
- if (!isHttpsOrLoopbackUrl(access.apiBaseUrl)) {
355
+ if (!isTrustedTokenBaseUrl(
356
+ access.apiBaseUrl,
357
+ ALLOWED_COPILOT_API_HOSTS,
358
+ this.#allowUnsafeUpstream
359
+ )) {
301
360
  throw new Error(
302
- `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${access.apiBaseUrl}`
361
+ `Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${access.apiBaseUrl}`
303
362
  );
304
363
  }
305
364
  const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
@@ -385,9 +444,9 @@ async function githubCopilotDeviceLogin(options = {}) {
385
444
  const fetcher = options.fetch ?? fetch;
386
445
  const sleeper = options.sleep ?? import_promises.setTimeout;
387
446
  const domain = normalizeDomain(
388
- options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
447
+ options.domain ?? envValue(env.HOOPILOT_GITHUB_DOMAIN) ?? DEFAULT_GITHUB_DOMAIN
389
448
  );
390
- 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;
391
450
  const device = await requestDeviceCode(fetcher, domain, clientId);
392
451
  const verificationUrl = device.verification_uri;
393
452
  const userCode = device.user_code;
@@ -545,8 +604,8 @@ var noopLogger = {
545
604
  };
546
605
  function createHoopilotLogger(options = {}) {
547
606
  const env = options.env ?? process.env;
548
- const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
549
- 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));
550
609
  const pinoOptions = {
551
610
  base: {
552
611
  service: "hoopilot",
@@ -596,7 +655,7 @@ function parseLogLevel(value) {
596
655
  }
597
656
  function shouldCreateLogger(options) {
598
657
  return Boolean(
599
- 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)
600
659
  );
601
660
  }
602
661
  function errorDetails(error) {
@@ -618,6 +677,12 @@ function isLogLevel(value) {
618
677
 
619
678
  // src/openai.ts
620
679
  var DEFAULT_MODEL = "gpt-4.1";
680
+ var OpenAICompatibilityError = class extends Error {
681
+ constructor(message) {
682
+ super(message);
683
+ this.name = "OpenAICompatibilityError";
684
+ }
685
+ };
621
686
  function responsesRequestToChatCompletion(request) {
622
687
  const messages = [];
623
688
  const instructions = contentToText(request.instructions);
@@ -651,13 +716,22 @@ function normalizeChatCompletionRequest(request) {
651
716
  });
652
717
  }
653
718
  function completionsRequestToChatCompletion(request) {
719
+ assertSupportedLegacyCompletionRequest(request);
654
720
  return removeUndefined({
721
+ frequency_penalty: request.frequency_penalty,
722
+ logit_bias: request.logit_bias,
655
723
  max_tokens: request.max_tokens,
656
- messages: [{ content: promptToText(request.prompt), role: "user" }],
724
+ messages: [{ content: legacyPromptToText(request.prompt), role: "user" }],
657
725
  model: normalizeRequestedModel(request.model),
726
+ n: request.n,
727
+ presence_penalty: request.presence_penalty,
728
+ seed: request.seed,
729
+ stop: request.stop,
658
730
  stream: request.stream === true,
731
+ stream_options: request.stream_options,
659
732
  temperature: request.temperature,
660
- top_p: request.top_p
733
+ top_p: request.top_p,
734
+ user: request.user
661
735
  });
662
736
  }
663
737
  function normalizeRequestedModel(model) {
@@ -693,21 +767,21 @@ function chatCompletionToResponse(completion, responseId) {
693
767
  });
694
768
  }
695
769
  function chatCompletionToCompletion(completion) {
696
- const choice = firstChoice(completion);
697
- const message = asRecord(choice.message);
698
770
  return removeUndefined({
699
- choices: [
700
- {
771
+ choices: completionChoices(completion).map((choice, index) => {
772
+ const message = asRecord(choice.message);
773
+ return {
701
774
  finish_reason: choice.finish_reason ?? "stop",
702
- index: 0,
703
- logprobs: null,
704
- text: contentToText(message.content)
705
- }
706
- ],
775
+ index: typeof choice.index === "number" ? choice.index : index,
776
+ logprobs: choice.logprobs ?? null,
777
+ text: contentToText(choice.text) || contentToText(message.content)
778
+ };
779
+ }),
707
780
  created: completion.created ?? epochSeconds(),
708
781
  id: completion.id ?? `cmpl_${randomId()}`,
709
782
  model: completion.model ?? DEFAULT_MODEL,
710
783
  object: "text_completion",
784
+ system_fingerprint: completion.system_fingerprint,
711
785
  usage: completion.usage
712
786
  });
713
787
  }
@@ -715,12 +789,15 @@ function completionStreamFromChatStream(chatStream) {
715
789
  const encoder = new TextEncoder();
716
790
  const decoder = new TextDecoder();
717
791
  let buffer = "";
718
- let sawDone = false;
792
+ let sawTerminalEvent = false;
719
793
  return new ReadableStream({
720
794
  async start(controller) {
721
795
  const enqueue = (data) => {
722
796
  controller.enqueue(encoder.encode(encodeDataSse(data)));
723
797
  };
798
+ const markTerminal = () => {
799
+ sawTerminalEvent = true;
800
+ };
724
801
  const reader = chatStream.getReader();
725
802
  try {
726
803
  while (true) {
@@ -729,20 +806,17 @@ function completionStreamFromChatStream(chatStream) {
729
806
  break;
730
807
  }
731
808
  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
- });
809
+ const blocks = buffer.split(/\r?\n\r?\n/);
810
+ buffer = blocks.pop() ?? "";
811
+ for (const block of blocks) {
812
+ processCompletionSseBlock(block, enqueue, markTerminal);
738
813
  }
739
814
  }
740
- if (buffer) {
741
- processCompletionSseLine(buffer, enqueue, () => {
742
- sawDone = true;
743
- });
815
+ const tail = `${buffer}${decoder.decode()}`;
816
+ if (tail.trim()) {
817
+ processCompletionSseBlock(tail, enqueue, markTerminal);
744
818
  }
745
- if (!sawDone) {
819
+ if (!sawTerminalEvent) {
746
820
  enqueue("[DONE]");
747
821
  }
748
822
  controller.close();
@@ -971,7 +1045,8 @@ function inputToMessages(input) {
971
1045
  const messages = [];
972
1046
  for (const item of input) {
973
1047
  const record = asRecord(item);
974
- if (record.type === "function_call_output") {
1048
+ const type = contentToText(record.type);
1049
+ if (type === "function_call_output") {
975
1050
  messages.push({
976
1051
  content: contentToText(record.output),
977
1052
  role: "tool",
@@ -979,7 +1054,7 @@ function inputToMessages(input) {
979
1054
  });
980
1055
  continue;
981
1056
  }
982
- if (record.type === "function_call") {
1057
+ if (type === "function_call") {
983
1058
  messages.push({
984
1059
  role: "assistant",
985
1060
  tool_calls: [
@@ -995,7 +1070,10 @@ function inputToMessages(input) {
995
1070
  });
996
1071
  continue;
997
1072
  }
998
- const role = roleToChatRole(contentToText(record.role));
1073
+ if (type && type !== "message") {
1074
+ unsupportedResponsesFeature(`input item type "${type}"`);
1075
+ }
1076
+ const role = responsesRoleToChatRole(contentToText(record.role));
999
1077
  const content = chatMessageContent(record.content);
1000
1078
  if (role && content !== void 0) {
1001
1079
  messages.push({ content, role });
@@ -1008,7 +1086,10 @@ function chatMessageContent(content) {
1008
1086
  return content;
1009
1087
  }
1010
1088
  if (!Array.isArray(content)) {
1011
- return contentToText(content) || void 0;
1089
+ if (content === void 0 || content === null) {
1090
+ return void 0;
1091
+ }
1092
+ unsupportedResponsesFeature("non-array message content objects");
1012
1093
  }
1013
1094
  const parts = [];
1014
1095
  for (const part of content) {
@@ -1016,13 +1097,31 @@ function chatMessageContent(content) {
1016
1097
  const type = contentToText(record.type);
1017
1098
  if (type === "input_text" || type === "output_text" || type === "text") {
1018
1099
  parts.push({ text: contentToText(record.text), type: "text" });
1100
+ continue;
1019
1101
  }
1020
1102
  if (type === "input_image") {
1103
+ if (contentToText(record.file_id)) {
1104
+ unsupportedResponsesFeature("input_image file_id parts");
1105
+ }
1021
1106
  const imageUrl = contentToText(record.image_url);
1022
- if (imageUrl) {
1023
- parts.push({ image_url: { url: imageUrl }, type: "image_url" });
1107
+ if (!imageUrl) {
1108
+ unsupportedResponsesFeature("input_image parts without image_url");
1024
1109
  }
1110
+ const image = { url: imageUrl };
1111
+ const detail = contentToText(record.detail);
1112
+ if (detail) {
1113
+ image.detail = detail;
1114
+ }
1115
+ parts.push({ image_url: image, type: "image_url" });
1116
+ continue;
1117
+ }
1118
+ if (type === "input_file") {
1119
+ unsupportedResponsesFeature("input_file parts");
1025
1120
  }
1121
+ if (type === "input_audio") {
1122
+ unsupportedResponsesFeature("input_audio parts");
1123
+ }
1124
+ unsupportedResponsesFeature(`content part type "${type || "unknown"}"`);
1026
1125
  }
1027
1126
  if (parts.length === 0) {
1028
1127
  return void 0;
@@ -1032,11 +1131,38 @@ function chatMessageContent(content) {
1032
1131
  }
1033
1132
  return parts;
1034
1133
  }
1035
- function promptToText(prompt) {
1036
- if (Array.isArray(prompt)) {
1037
- return prompt.map((item) => contentToText(item)).join("\n");
1134
+ function legacyPromptToText(prompt) {
1135
+ if (typeof prompt === "string") {
1136
+ return prompt;
1137
+ }
1138
+ if (Array.isArray(prompt) && prompt.length === 1 && typeof prompt[0] === "string") {
1139
+ return prompt[0];
1140
+ }
1141
+ throw new OpenAICompatibilityError(
1142
+ "Hoopilot legacy completions compatibility supports exactly one string prompt per request."
1143
+ );
1144
+ }
1145
+ function assertSupportedLegacyCompletionRequest(request) {
1146
+ if (request.echo === true) {
1147
+ throw new OpenAICompatibilityError(
1148
+ "Hoopilot legacy completions compatibility does not support echo=true."
1149
+ );
1150
+ }
1151
+ if (typeof request.best_of === "number" && request.best_of > 1) {
1152
+ throw new OpenAICompatibilityError(
1153
+ "Hoopilot legacy completions compatibility does not support best_of greater than 1."
1154
+ );
1155
+ }
1156
+ if (typeof request.logprobs === "number" && request.logprobs > 0) {
1157
+ throw new OpenAICompatibilityError(
1158
+ "Hoopilot legacy completions compatibility does not support legacy logprobs."
1159
+ );
1160
+ }
1161
+ if (contentToText(request.suffix)) {
1162
+ throw new OpenAICompatibilityError(
1163
+ "Hoopilot legacy completions compatibility does not support suffix."
1164
+ );
1038
1165
  }
1039
- return contentToText(prompt);
1040
1166
  }
1041
1167
  function contentToText(content) {
1042
1168
  if (typeof content === "string") {
@@ -1060,25 +1186,35 @@ function contentToText(content) {
1060
1186
  }
1061
1187
  return "";
1062
1188
  }
1063
- function roleToChatRole(role) {
1064
- if (role === "assistant" || role === "developer" || role === "system" || role === "tool") {
1189
+ function responsesRoleToChatRole(role) {
1190
+ if (!role) {
1191
+ return "user";
1192
+ }
1193
+ if (role === "assistant" || role === "developer" || role === "system" || role === "tool" || role === "user") {
1065
1194
  return role === "developer" ? "system" : role;
1066
1195
  }
1067
- return "user";
1196
+ unsupportedResponsesFeature(`message role "${role}"`);
1068
1197
  }
1069
1198
  function chatTools(tools) {
1070
1199
  if (!Array.isArray(tools)) {
1071
1200
  return void 0;
1072
1201
  }
1073
- const converted = tools.map((tool) => asRecord(tool)).filter((tool) => tool.type === "function").map((tool) => ({
1074
- function: removeUndefined({
1075
- description: tool.description,
1076
- name: tool.name,
1077
- parameters: tool.parameters,
1078
- strict: tool.strict
1079
- }),
1080
- type: "function"
1081
- }));
1202
+ const converted = tools.map((tool) => {
1203
+ const record = asRecord(tool);
1204
+ const type = contentToText(record.type);
1205
+ if (type !== "function") {
1206
+ unsupportedResponsesFeature(`tool type "${type || "unknown"}"`);
1207
+ }
1208
+ return {
1209
+ function: removeUndefined({
1210
+ description: record.description,
1211
+ name: record.name,
1212
+ parameters: record.parameters,
1213
+ strict: record.strict
1214
+ }),
1215
+ type: "function"
1216
+ };
1217
+ });
1082
1218
  return converted.length > 0 ? converted : void 0;
1083
1219
  }
1084
1220
  function chatToolChoice(toolChoice) {
@@ -1086,10 +1222,16 @@ function chatToolChoice(toolChoice) {
1086
1222
  return toolChoice;
1087
1223
  }
1088
1224
  const record = asRecord(toolChoice);
1089
- if (record.type === "function" && typeof record.name === "string") {
1225
+ const type = contentToText(record.type);
1226
+ if (type === "function" && typeof record.name === "string") {
1090
1227
  return { function: { name: record.name }, type: "function" };
1091
1228
  }
1092
- return toolChoice;
1229
+ unsupportedResponsesFeature(`tool_choice type "${type || "unknown"}"`);
1230
+ }
1231
+ function unsupportedResponsesFeature(feature) {
1232
+ throw new OpenAICompatibilityError(
1233
+ `Hoopilot Responses-to-chat compatibility does not support ${feature}.`
1234
+ );
1093
1235
  }
1094
1236
  function outputItemsFromMessage(message) {
1095
1237
  const output = [];
@@ -1204,20 +1346,29 @@ function firstNumber(...values) {
1204
1346
  return void 0;
1205
1347
  }
1206
1348
  function firstChoice(completion) {
1207
- const choices = Array.isArray(completion.choices) ? completion.choices : [];
1208
- return asRecord(choices[0]);
1349
+ return completionChoices(completion)[0] ?? {};
1209
1350
  }
1210
- function processCompletionSseLine(line, enqueue, markDone) {
1211
- const trimmed = line.trim();
1212
- if (!trimmed.startsWith("data:")) {
1213
- return;
1351
+ function completionChoices(completion) {
1352
+ const choices = Array.isArray(completion.choices) ? completion.choices : [];
1353
+ return choices.map((choice) => asRecord(choice));
1354
+ }
1355
+ function processCompletionSseBlock(block, enqueue, markTerminal) {
1356
+ let event = "message";
1357
+ const dataLines = [];
1358
+ for (const line of block.split(/\r?\n/)) {
1359
+ const trimmed = line.trim();
1360
+ if (trimmed.startsWith("event:")) {
1361
+ event = trimmed.slice("event:".length).trim() || event;
1362
+ } else if (trimmed.startsWith("data:")) {
1363
+ dataLines.push(trimmed.slice("data:".length).trim());
1364
+ }
1214
1365
  }
1215
- const data = trimmed.slice("data:".length).trim();
1366
+ const data = dataLines.join("\n");
1216
1367
  if (!data) {
1217
1368
  return;
1218
1369
  }
1219
1370
  if (data === "[DONE]") {
1220
- markDone();
1371
+ markTerminal();
1221
1372
  enqueue("[DONE]");
1222
1373
  return;
1223
1374
  }
@@ -1225,25 +1376,34 @@ function processCompletionSseLine(line, enqueue, markDone) {
1225
1376
  if (!parsed) {
1226
1377
  return;
1227
1378
  }
1228
- const choice = firstChoice(parsed);
1229
- const delta = asRecord(choice.delta);
1230
- const text = contentToText(delta.content);
1231
- const finishReason = choice.finish_reason ?? null;
1379
+ const error = completionStreamError(event, parsed);
1380
+ if (error) {
1381
+ markTerminal();
1382
+ enqueue({ error });
1383
+ return;
1384
+ }
1385
+ const choices = completionChoices(parsed).map((choice, index) => {
1386
+ const delta = asRecord(choice.delta);
1387
+ const text = contentToText(delta.content);
1388
+ const finishReason = choice.finish_reason ?? null;
1389
+ if (!text && finishReason === null) {
1390
+ return void 0;
1391
+ }
1392
+ return {
1393
+ finish_reason: finishReason,
1394
+ index: typeof choice.index === "number" ? choice.index : index,
1395
+ logprobs: choice.logprobs ?? null,
1396
+ text
1397
+ };
1398
+ }).filter((choice) => choice !== void 0);
1232
1399
  const usage = asRecord(parsed.usage);
1233
1400
  const hasUsage = Object.keys(usage).length > 0;
1234
- if (!text && finishReason === null && !hasUsage) {
1401
+ if (choices.length === 0 && !hasUsage) {
1235
1402
  return;
1236
1403
  }
1237
1404
  enqueue(
1238
1405
  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
- ] : [],
1406
+ choices,
1247
1407
  created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
1248
1408
  id: contentToText(parsed.id) || `cmpl_${randomId()}`,
1249
1409
  model: contentToText(parsed.model) || DEFAULT_MODEL,
@@ -1252,6 +1412,22 @@ function processCompletionSseLine(line, enqueue, markDone) {
1252
1412
  })
1253
1413
  );
1254
1414
  }
1415
+ function completionStreamError(event, parsed) {
1416
+ const responseError = asRecord(asRecord(parsed.response).error);
1417
+ const directError = asRecord(parsed.error);
1418
+ const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
1419
+ if (error) {
1420
+ return error;
1421
+ }
1422
+ if (event === "error" || parsed.type === "response.failed") {
1423
+ return removeUndefined({
1424
+ code: contentToText(parsed.code) || void 0,
1425
+ message: contentToText(parsed.message) || "Upstream streaming request failed.",
1426
+ type: contentToText(parsed.type) || "upstream_stream_error"
1427
+ });
1428
+ }
1429
+ return void 0;
1430
+ }
1255
1431
  function processChatSseLine(line, handlers) {
1256
1432
  const trimmed = line.trim();
1257
1433
  if (!trimmed.startsWith("data:")) {
@@ -1701,10 +1877,19 @@ var DEFAULT_HOST = "127.0.0.1";
1701
1877
  var DEFAULT_PORT = 4141;
1702
1878
  var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
1703
1879
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
1880
+ var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
1881
+ var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
1882
+ var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
1704
1883
  var USAGE_CACHE_TTL_MS = 6e4;
1884
+ var RequestBodyTooLargeError = class extends Error {
1885
+ constructor() {
1886
+ super(REQUEST_TOO_LARGE_MESSAGE);
1887
+ this.name = "RequestBodyTooLargeError";
1888
+ }
1889
+ };
1705
1890
  function createHoopilotHandler(options = {}) {
1706
1891
  const client = new CopilotClient(options);
1707
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1892
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
1708
1893
  const logger = serverLogger(options);
1709
1894
  const metrics = options.metrics ?? new MetricsRegistry();
1710
1895
  const readUsage = createUsageReader(client, metrics);
@@ -1790,6 +1975,18 @@ function createHoopilotHandler(options = {}) {
1790
1975
  "request body was invalid json"
1791
1976
  );
1792
1977
  return finish(jsonError(400, "invalid_request_error", message));
1978
+ } else if (error instanceof OpenAICompatibilityError) {
1979
+ requestLogger.warn(
1980
+ { err: errorDetails(error), event: "http.request.failed" },
1981
+ "request body used unsupported OpenAI compatibility fields"
1982
+ );
1983
+ return finish(jsonError(400, "invalid_request_error", message));
1984
+ } else if (error instanceof RequestBodyTooLargeError) {
1985
+ requestLogger.warn(
1986
+ { err: errorDetails(error), event: "http.request.failed" },
1987
+ "request body exceeded size limit"
1988
+ );
1989
+ return finish(jsonError(413, "request_too_large", message));
1793
1990
  } else {
1794
1991
  requestLogger.error(
1795
1992
  { err: errorDetails(error), event: "http.request.failed" },
@@ -1801,10 +1998,10 @@ function createHoopilotHandler(options = {}) {
1801
1998
  };
1802
1999
  }
1803
2000
  function startHoopilotServer(options = {}) {
1804
- const host = options.host ?? options.env?.HOST ?? DEFAULT_HOST;
1805
- const port = normalizeServerPort(options.port ?? options.env?.PORT ?? DEFAULT_PORT);
1806
- const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
1807
- const allowUnauthenticated = options.allowUnauthenticated ?? options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED === "1";
2001
+ const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
2002
+ const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
2003
+ const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
2004
+ const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
1808
2005
  if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
1809
2006
  throw new Error(
1810
2007
  "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
@@ -1822,7 +2019,7 @@ function startHoopilotServer(options = {}) {
1822
2019
  });
1823
2020
  return {
1824
2021
  server,
1825
- url: `http://${host}:${server.port}`
2022
+ url: `http://${urlHost(host)}:${server.port}`
1826
2023
  };
1827
2024
  }
1828
2025
  async function handleModels(client, metrics, signal, logger) {
@@ -1931,14 +2128,15 @@ function proxyResponse(upstream) {
1931
2128
  });
1932
2129
  }
1933
2130
  async function readJson(request) {
2131
+ const text = await readRequestText(request);
1934
2132
  try {
1935
- return asRecord(await request.json());
2133
+ return asRecord(JSON.parse(text));
1936
2134
  } catch {
1937
2135
  throw new Error(INVALID_JSON_MESSAGE);
1938
2136
  }
1939
2137
  }
1940
2138
  async function readJsonText(request) {
1941
- const text = await request.text();
2139
+ const text = await readRequestText(request);
1942
2140
  try {
1943
2141
  JSON.parse(text);
1944
2142
  return text;
@@ -1946,6 +2144,40 @@ async function readJsonText(request) {
1946
2144
  throw new Error(INVALID_JSON_MESSAGE);
1947
2145
  }
1948
2146
  }
2147
+ async function readRequestText(request) {
2148
+ const contentLength = request.headers.get("content-length");
2149
+ if (contentLength) {
2150
+ const declaredBytes = Number(contentLength);
2151
+ if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
2152
+ throw new RequestBodyTooLargeError();
2153
+ }
2154
+ }
2155
+ const body = request.body;
2156
+ if (!body) {
2157
+ return "";
2158
+ }
2159
+ const reader = body.getReader();
2160
+ const decoder = new TextDecoder();
2161
+ let bytes = 0;
2162
+ let text = "";
2163
+ try {
2164
+ while (true) {
2165
+ const { done, value } = await reader.read();
2166
+ if (done) {
2167
+ return `${text}${decoder.decode()}`;
2168
+ }
2169
+ bytes += value.byteLength;
2170
+ if (bytes > MAX_REQUEST_BODY_BYTES) {
2171
+ await reader.cancel().catch(() => {
2172
+ });
2173
+ throw new RequestBodyTooLargeError();
2174
+ }
2175
+ text += decoder.decode(value, { stream: true });
2176
+ }
2177
+ } finally {
2178
+ reader.releaseLock();
2179
+ }
2180
+ }
1949
2181
  function jsonResponse(body, status = 200) {
1950
2182
  return new Response(JSON.stringify(body), {
1951
2183
  headers: {
@@ -2018,6 +2250,9 @@ function upstreamAuthMessage(message) {
2018
2250
  function isLoopbackHost(host) {
2019
2251
  return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
2020
2252
  }
2253
+ function urlHost(host) {
2254
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
2255
+ }
2021
2256
  function isLoopbackOrigin(origin) {
2022
2257
  try {
2023
2258
  return isLoopbackHost(new URL(origin).hostname.toLowerCase());
@@ -2125,7 +2360,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
2125
2360
  }
2126
2361
  function requestIdFor(request) {
2127
2362
  const existing = request.headers.get("x-request-id")?.trim();
2128
- return existing || crypto.randomUUID();
2363
+ return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
2129
2364
  }
2130
2365
  function canonicalApiPath(path) {
2131
2366
  const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;