@openhoo/hoopilot 0.7.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/{chunk-TEDEVCKM.js → chunk-7GSQVYYT.js} +61 -7
- package/dist/chunk-7GSQVYYT.js.map +1 -0
- package/dist/cli.js +192 -84
- package/dist/cli.js.map +1 -1
- package/dist/codexx.js +1 -1
- package/dist/index.cjs +207 -67
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +207 -67
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/dist/chunk-TEDEVCKM.js.map +0 -1
package/dist/codexx.js
CHANGED
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
|
-
|
|
82
|
-
|
|
131
|
+
const explicit = envValue(env.HOOPILOT_AUTH_FILE);
|
|
132
|
+
if (explicit) {
|
|
133
|
+
return explicit;
|
|
134
|
+
}
|
|
135
|
+
const xdg = envValue(env.XDG_CONFIG_HOME);
|
|
136
|
+
if (xdg) {
|
|
137
|
+
return (0, import_node_path.join)(xdg, "hoopilot", "auth.json");
|
|
138
|
+
}
|
|
139
|
+
const appdata = envValue(env.APPDATA);
|
|
140
|
+
if (appdata) {
|
|
141
|
+
return (0, import_node_path.join)(appdata, "hoopilot", "auth.json");
|
|
142
|
+
}
|
|
143
|
+
const home = envValue(env.HOME);
|
|
144
|
+
if (!home) {
|
|
145
|
+
throw new StoredCopilotAuthError(
|
|
146
|
+
"Cannot resolve Hoopilot auth file path without HOOPILOT_AUTH_FILE, XDG_CONFIG_HOME, APPDATA, or HOME."
|
|
147
|
+
);
|
|
83
148
|
}
|
|
84
|
-
const base =
|
|
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
|
-
|
|
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 ??
|
|
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(
|
|
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 (!
|
|
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
|
|
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 (!
|
|
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
|
|
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) {
|
|
@@ -715,12 +774,15 @@ function completionStreamFromChatStream(chatStream) {
|
|
|
715
774
|
const encoder = new TextEncoder();
|
|
716
775
|
const decoder = new TextDecoder();
|
|
717
776
|
let buffer = "";
|
|
718
|
-
let
|
|
777
|
+
let sawTerminalEvent = false;
|
|
719
778
|
return new ReadableStream({
|
|
720
779
|
async start(controller) {
|
|
721
780
|
const enqueue = (data) => {
|
|
722
781
|
controller.enqueue(encoder.encode(encodeDataSse(data)));
|
|
723
782
|
};
|
|
783
|
+
const markTerminal = () => {
|
|
784
|
+
sawTerminalEvent = true;
|
|
785
|
+
};
|
|
724
786
|
const reader = chatStream.getReader();
|
|
725
787
|
try {
|
|
726
788
|
while (true) {
|
|
@@ -729,20 +791,17 @@ function completionStreamFromChatStream(chatStream) {
|
|
|
729
791
|
break;
|
|
730
792
|
}
|
|
731
793
|
buffer += decoder.decode(result.value, { stream: true });
|
|
732
|
-
const
|
|
733
|
-
buffer =
|
|
734
|
-
for (const
|
|
735
|
-
|
|
736
|
-
sawDone = true;
|
|
737
|
-
});
|
|
794
|
+
const blocks = buffer.split(/\r?\n\r?\n/);
|
|
795
|
+
buffer = blocks.pop() ?? "";
|
|
796
|
+
for (const block of blocks) {
|
|
797
|
+
processCompletionSseBlock(block, enqueue, markTerminal);
|
|
738
798
|
}
|
|
739
799
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
});
|
|
800
|
+
const tail = `${buffer}${decoder.decode()}`;
|
|
801
|
+
if (tail.trim()) {
|
|
802
|
+
processCompletionSseBlock(tail, enqueue, markTerminal);
|
|
744
803
|
}
|
|
745
|
-
if (!
|
|
804
|
+
if (!sawTerminalEvent) {
|
|
746
805
|
enqueue("[DONE]");
|
|
747
806
|
}
|
|
748
807
|
controller.close();
|
|
@@ -1207,17 +1266,23 @@ function firstChoice(completion) {
|
|
|
1207
1266
|
const choices = Array.isArray(completion.choices) ? completion.choices : [];
|
|
1208
1267
|
return asRecord(choices[0]);
|
|
1209
1268
|
}
|
|
1210
|
-
function
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1269
|
+
function processCompletionSseBlock(block, enqueue, markTerminal) {
|
|
1270
|
+
let event = "message";
|
|
1271
|
+
const dataLines = [];
|
|
1272
|
+
for (const line of block.split(/\r?\n/)) {
|
|
1273
|
+
const trimmed = line.trim();
|
|
1274
|
+
if (trimmed.startsWith("event:")) {
|
|
1275
|
+
event = trimmed.slice("event:".length).trim() || event;
|
|
1276
|
+
} else if (trimmed.startsWith("data:")) {
|
|
1277
|
+
dataLines.push(trimmed.slice("data:".length).trim());
|
|
1278
|
+
}
|
|
1214
1279
|
}
|
|
1215
|
-
const data =
|
|
1280
|
+
const data = dataLines.join("\n");
|
|
1216
1281
|
if (!data) {
|
|
1217
1282
|
return;
|
|
1218
1283
|
}
|
|
1219
1284
|
if (data === "[DONE]") {
|
|
1220
|
-
|
|
1285
|
+
markTerminal();
|
|
1221
1286
|
enqueue("[DONE]");
|
|
1222
1287
|
return;
|
|
1223
1288
|
}
|
|
@@ -1225,6 +1290,12 @@ function processCompletionSseLine(line, enqueue, markDone) {
|
|
|
1225
1290
|
if (!parsed) {
|
|
1226
1291
|
return;
|
|
1227
1292
|
}
|
|
1293
|
+
const error = completionStreamError(event, parsed);
|
|
1294
|
+
if (error) {
|
|
1295
|
+
markTerminal();
|
|
1296
|
+
enqueue({ error });
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1228
1299
|
const choice = firstChoice(parsed);
|
|
1229
1300
|
const delta = asRecord(choice.delta);
|
|
1230
1301
|
const text = contentToText(delta.content);
|
|
@@ -1252,6 +1323,22 @@ function processCompletionSseLine(line, enqueue, markDone) {
|
|
|
1252
1323
|
})
|
|
1253
1324
|
);
|
|
1254
1325
|
}
|
|
1326
|
+
function completionStreamError(event, parsed) {
|
|
1327
|
+
const responseError = asRecord(asRecord(parsed.response).error);
|
|
1328
|
+
const directError = asRecord(parsed.error);
|
|
1329
|
+
const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
|
|
1330
|
+
if (error) {
|
|
1331
|
+
return error;
|
|
1332
|
+
}
|
|
1333
|
+
if (event === "error" || parsed.type === "response.failed") {
|
|
1334
|
+
return removeUndefined({
|
|
1335
|
+
code: contentToText(parsed.code) || void 0,
|
|
1336
|
+
message: contentToText(parsed.message) || "Upstream streaming request failed.",
|
|
1337
|
+
type: contentToText(parsed.type) || "upstream_stream_error"
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
return void 0;
|
|
1341
|
+
}
|
|
1255
1342
|
function processChatSseLine(line, handlers) {
|
|
1256
1343
|
const trimmed = line.trim();
|
|
1257
1344
|
if (!trimmed.startsWith("data:")) {
|
|
@@ -1701,10 +1788,19 @@ var DEFAULT_HOST = "127.0.0.1";
|
|
|
1701
1788
|
var DEFAULT_PORT = 4141;
|
|
1702
1789
|
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
|
|
1703
1790
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
1791
|
+
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
1792
|
+
var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
1793
|
+
var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
|
|
1704
1794
|
var USAGE_CACHE_TTL_MS = 6e4;
|
|
1795
|
+
var RequestBodyTooLargeError = class extends Error {
|
|
1796
|
+
constructor() {
|
|
1797
|
+
super(REQUEST_TOO_LARGE_MESSAGE);
|
|
1798
|
+
this.name = "RequestBodyTooLargeError";
|
|
1799
|
+
}
|
|
1800
|
+
};
|
|
1705
1801
|
function createHoopilotHandler(options = {}) {
|
|
1706
1802
|
const client = new CopilotClient(options);
|
|
1707
|
-
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
1803
|
+
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
1708
1804
|
const logger = serverLogger(options);
|
|
1709
1805
|
const metrics = options.metrics ?? new MetricsRegistry();
|
|
1710
1806
|
const readUsage = createUsageReader(client, metrics);
|
|
@@ -1790,6 +1886,12 @@ function createHoopilotHandler(options = {}) {
|
|
|
1790
1886
|
"request body was invalid json"
|
|
1791
1887
|
);
|
|
1792
1888
|
return finish(jsonError(400, "invalid_request_error", message));
|
|
1889
|
+
} else if (error instanceof RequestBodyTooLargeError) {
|
|
1890
|
+
requestLogger.warn(
|
|
1891
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
1892
|
+
"request body exceeded size limit"
|
|
1893
|
+
);
|
|
1894
|
+
return finish(jsonError(413, "request_too_large", message));
|
|
1793
1895
|
} else {
|
|
1794
1896
|
requestLogger.error(
|
|
1795
1897
|
{ err: errorDetails(error), event: "http.request.failed" },
|
|
@@ -1801,10 +1903,10 @@ function createHoopilotHandler(options = {}) {
|
|
|
1801
1903
|
};
|
|
1802
1904
|
}
|
|
1803
1905
|
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";
|
|
1906
|
+
const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
|
|
1907
|
+
const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
|
|
1908
|
+
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
1909
|
+
const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
|
|
1808
1910
|
if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
|
|
1809
1911
|
throw new Error(
|
|
1810
1912
|
"Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
|
|
@@ -1822,7 +1924,7 @@ function startHoopilotServer(options = {}) {
|
|
|
1822
1924
|
});
|
|
1823
1925
|
return {
|
|
1824
1926
|
server,
|
|
1825
|
-
url: `http://${host}:${server.port}`
|
|
1927
|
+
url: `http://${urlHost(host)}:${server.port}`
|
|
1826
1928
|
};
|
|
1827
1929
|
}
|
|
1828
1930
|
async function handleModels(client, metrics, signal, logger) {
|
|
@@ -1931,14 +2033,15 @@ function proxyResponse(upstream) {
|
|
|
1931
2033
|
});
|
|
1932
2034
|
}
|
|
1933
2035
|
async function readJson(request) {
|
|
2036
|
+
const text = await readRequestText(request);
|
|
1934
2037
|
try {
|
|
1935
|
-
return asRecord(
|
|
2038
|
+
return asRecord(JSON.parse(text));
|
|
1936
2039
|
} catch {
|
|
1937
2040
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1938
2041
|
}
|
|
1939
2042
|
}
|
|
1940
2043
|
async function readJsonText(request) {
|
|
1941
|
-
const text = await request
|
|
2044
|
+
const text = await readRequestText(request);
|
|
1942
2045
|
try {
|
|
1943
2046
|
JSON.parse(text);
|
|
1944
2047
|
return text;
|
|
@@ -1946,6 +2049,40 @@ async function readJsonText(request) {
|
|
|
1946
2049
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1947
2050
|
}
|
|
1948
2051
|
}
|
|
2052
|
+
async function readRequestText(request) {
|
|
2053
|
+
const contentLength = request.headers.get("content-length");
|
|
2054
|
+
if (contentLength) {
|
|
2055
|
+
const declaredBytes = Number(contentLength);
|
|
2056
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
|
|
2057
|
+
throw new RequestBodyTooLargeError();
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
const body = request.body;
|
|
2061
|
+
if (!body) {
|
|
2062
|
+
return "";
|
|
2063
|
+
}
|
|
2064
|
+
const reader = body.getReader();
|
|
2065
|
+
const decoder = new TextDecoder();
|
|
2066
|
+
let bytes = 0;
|
|
2067
|
+
let text = "";
|
|
2068
|
+
try {
|
|
2069
|
+
while (true) {
|
|
2070
|
+
const { done, value } = await reader.read();
|
|
2071
|
+
if (done) {
|
|
2072
|
+
return `${text}${decoder.decode()}`;
|
|
2073
|
+
}
|
|
2074
|
+
bytes += value.byteLength;
|
|
2075
|
+
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
2076
|
+
await reader.cancel().catch(() => {
|
|
2077
|
+
});
|
|
2078
|
+
throw new RequestBodyTooLargeError();
|
|
2079
|
+
}
|
|
2080
|
+
text += decoder.decode(value, { stream: true });
|
|
2081
|
+
}
|
|
2082
|
+
} finally {
|
|
2083
|
+
reader.releaseLock();
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
1949
2086
|
function jsonResponse(body, status = 200) {
|
|
1950
2087
|
return new Response(JSON.stringify(body), {
|
|
1951
2088
|
headers: {
|
|
@@ -2018,6 +2155,9 @@ function upstreamAuthMessage(message) {
|
|
|
2018
2155
|
function isLoopbackHost(host) {
|
|
2019
2156
|
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
2020
2157
|
}
|
|
2158
|
+
function urlHost(host) {
|
|
2159
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
2160
|
+
}
|
|
2021
2161
|
function isLoopbackOrigin(origin) {
|
|
2022
2162
|
try {
|
|
2023
2163
|
return isLoopbackHost(new URL(origin).hostname.toLowerCase());
|
|
@@ -2125,7 +2265,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
|
|
|
2125
2265
|
}
|
|
2126
2266
|
function requestIdFor(request) {
|
|
2127
2267
|
const existing = request.headers.get("x-request-id")?.trim();
|
|
2128
|
-
return existing
|
|
2268
|
+
return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
|
|
2129
2269
|
}
|
|
2130
2270
|
function canonicalApiPath(path) {
|
|
2131
2271
|
const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;
|