@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/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
|
-
|
|
12
|
-
|
|
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");
|
|
72
|
+
}
|
|
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
|
+
);
|
|
13
78
|
}
|
|
14
|
-
const base =
|
|
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
|
-
|
|
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 ??
|
|
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(
|
|
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 (!
|
|
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
|
|
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 (!
|
|
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
|
|
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) {
|
|
@@ -645,12 +704,15 @@ function completionStreamFromChatStream(chatStream) {
|
|
|
645
704
|
const encoder = new TextEncoder();
|
|
646
705
|
const decoder = new TextDecoder();
|
|
647
706
|
let buffer = "";
|
|
648
|
-
let
|
|
707
|
+
let sawTerminalEvent = false;
|
|
649
708
|
return new ReadableStream({
|
|
650
709
|
async start(controller) {
|
|
651
710
|
const enqueue = (data) => {
|
|
652
711
|
controller.enqueue(encoder.encode(encodeDataSse(data)));
|
|
653
712
|
};
|
|
713
|
+
const markTerminal = () => {
|
|
714
|
+
sawTerminalEvent = true;
|
|
715
|
+
};
|
|
654
716
|
const reader = chatStream.getReader();
|
|
655
717
|
try {
|
|
656
718
|
while (true) {
|
|
@@ -659,20 +721,17 @@ function completionStreamFromChatStream(chatStream) {
|
|
|
659
721
|
break;
|
|
660
722
|
}
|
|
661
723
|
buffer += decoder.decode(result.value, { stream: true });
|
|
662
|
-
const
|
|
663
|
-
buffer =
|
|
664
|
-
for (const
|
|
665
|
-
|
|
666
|
-
sawDone = true;
|
|
667
|
-
});
|
|
724
|
+
const blocks = buffer.split(/\r?\n\r?\n/);
|
|
725
|
+
buffer = blocks.pop() ?? "";
|
|
726
|
+
for (const block of blocks) {
|
|
727
|
+
processCompletionSseBlock(block, enqueue, markTerminal);
|
|
668
728
|
}
|
|
669
729
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
});
|
|
730
|
+
const tail = `${buffer}${decoder.decode()}`;
|
|
731
|
+
if (tail.trim()) {
|
|
732
|
+
processCompletionSseBlock(tail, enqueue, markTerminal);
|
|
674
733
|
}
|
|
675
|
-
if (!
|
|
734
|
+
if (!sawTerminalEvent) {
|
|
676
735
|
enqueue("[DONE]");
|
|
677
736
|
}
|
|
678
737
|
controller.close();
|
|
@@ -1137,17 +1196,23 @@ function firstChoice(completion) {
|
|
|
1137
1196
|
const choices = Array.isArray(completion.choices) ? completion.choices : [];
|
|
1138
1197
|
return asRecord(choices[0]);
|
|
1139
1198
|
}
|
|
1140
|
-
function
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1199
|
+
function processCompletionSseBlock(block, enqueue, markTerminal) {
|
|
1200
|
+
let event = "message";
|
|
1201
|
+
const dataLines = [];
|
|
1202
|
+
for (const line of block.split(/\r?\n/)) {
|
|
1203
|
+
const trimmed = line.trim();
|
|
1204
|
+
if (trimmed.startsWith("event:")) {
|
|
1205
|
+
event = trimmed.slice("event:".length).trim() || event;
|
|
1206
|
+
} else if (trimmed.startsWith("data:")) {
|
|
1207
|
+
dataLines.push(trimmed.slice("data:".length).trim());
|
|
1208
|
+
}
|
|
1144
1209
|
}
|
|
1145
|
-
const data =
|
|
1210
|
+
const data = dataLines.join("\n");
|
|
1146
1211
|
if (!data) {
|
|
1147
1212
|
return;
|
|
1148
1213
|
}
|
|
1149
1214
|
if (data === "[DONE]") {
|
|
1150
|
-
|
|
1215
|
+
markTerminal();
|
|
1151
1216
|
enqueue("[DONE]");
|
|
1152
1217
|
return;
|
|
1153
1218
|
}
|
|
@@ -1155,6 +1220,12 @@ function processCompletionSseLine(line, enqueue, markDone) {
|
|
|
1155
1220
|
if (!parsed) {
|
|
1156
1221
|
return;
|
|
1157
1222
|
}
|
|
1223
|
+
const error = completionStreamError(event, parsed);
|
|
1224
|
+
if (error) {
|
|
1225
|
+
markTerminal();
|
|
1226
|
+
enqueue({ error });
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1158
1229
|
const choice = firstChoice(parsed);
|
|
1159
1230
|
const delta = asRecord(choice.delta);
|
|
1160
1231
|
const text = contentToText(delta.content);
|
|
@@ -1182,6 +1253,22 @@ function processCompletionSseLine(line, enqueue, markDone) {
|
|
|
1182
1253
|
})
|
|
1183
1254
|
);
|
|
1184
1255
|
}
|
|
1256
|
+
function completionStreamError(event, parsed) {
|
|
1257
|
+
const responseError = asRecord(asRecord(parsed.response).error);
|
|
1258
|
+
const directError = asRecord(parsed.error);
|
|
1259
|
+
const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
|
|
1260
|
+
if (error) {
|
|
1261
|
+
return error;
|
|
1262
|
+
}
|
|
1263
|
+
if (event === "error" || parsed.type === "response.failed") {
|
|
1264
|
+
return removeUndefined({
|
|
1265
|
+
code: contentToText(parsed.code) || void 0,
|
|
1266
|
+
message: contentToText(parsed.message) || "Upstream streaming request failed.",
|
|
1267
|
+
type: contentToText(parsed.type) || "upstream_stream_error"
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
return void 0;
|
|
1271
|
+
}
|
|
1185
1272
|
function processChatSseLine(line, handlers) {
|
|
1186
1273
|
const trimmed = line.trim();
|
|
1187
1274
|
if (!trimmed.startsWith("data:")) {
|
|
@@ -1631,10 +1718,19 @@ var DEFAULT_HOST = "127.0.0.1";
|
|
|
1631
1718
|
var DEFAULT_PORT = 4141;
|
|
1632
1719
|
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
|
|
1633
1720
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
1721
|
+
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
1722
|
+
var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
1723
|
+
var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
|
|
1634
1724
|
var USAGE_CACHE_TTL_MS = 6e4;
|
|
1725
|
+
var RequestBodyTooLargeError = class extends Error {
|
|
1726
|
+
constructor() {
|
|
1727
|
+
super(REQUEST_TOO_LARGE_MESSAGE);
|
|
1728
|
+
this.name = "RequestBodyTooLargeError";
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1635
1731
|
function createHoopilotHandler(options = {}) {
|
|
1636
1732
|
const client = new CopilotClient(options);
|
|
1637
|
-
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
1733
|
+
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
1638
1734
|
const logger = serverLogger(options);
|
|
1639
1735
|
const metrics = options.metrics ?? new MetricsRegistry();
|
|
1640
1736
|
const readUsage = createUsageReader(client, metrics);
|
|
@@ -1720,6 +1816,12 @@ function createHoopilotHandler(options = {}) {
|
|
|
1720
1816
|
"request body was invalid json"
|
|
1721
1817
|
);
|
|
1722
1818
|
return finish(jsonError(400, "invalid_request_error", message));
|
|
1819
|
+
} else if (error instanceof RequestBodyTooLargeError) {
|
|
1820
|
+
requestLogger.warn(
|
|
1821
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
1822
|
+
"request body exceeded size limit"
|
|
1823
|
+
);
|
|
1824
|
+
return finish(jsonError(413, "request_too_large", message));
|
|
1723
1825
|
} else {
|
|
1724
1826
|
requestLogger.error(
|
|
1725
1827
|
{ err: errorDetails(error), event: "http.request.failed" },
|
|
@@ -1731,10 +1833,10 @@ function createHoopilotHandler(options = {}) {
|
|
|
1731
1833
|
};
|
|
1732
1834
|
}
|
|
1733
1835
|
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";
|
|
1836
|
+
const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
|
|
1837
|
+
const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
|
|
1838
|
+
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
1839
|
+
const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
|
|
1738
1840
|
if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
|
|
1739
1841
|
throw new Error(
|
|
1740
1842
|
"Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
|
|
@@ -1752,7 +1854,7 @@ function startHoopilotServer(options = {}) {
|
|
|
1752
1854
|
});
|
|
1753
1855
|
return {
|
|
1754
1856
|
server,
|
|
1755
|
-
url: `http://${host}:${server.port}`
|
|
1857
|
+
url: `http://${urlHost(host)}:${server.port}`
|
|
1756
1858
|
};
|
|
1757
1859
|
}
|
|
1758
1860
|
async function handleModels(client, metrics, signal, logger) {
|
|
@@ -1861,14 +1963,15 @@ function proxyResponse(upstream) {
|
|
|
1861
1963
|
});
|
|
1862
1964
|
}
|
|
1863
1965
|
async function readJson(request) {
|
|
1966
|
+
const text = await readRequestText(request);
|
|
1864
1967
|
try {
|
|
1865
|
-
return asRecord(
|
|
1968
|
+
return asRecord(JSON.parse(text));
|
|
1866
1969
|
} catch {
|
|
1867
1970
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1868
1971
|
}
|
|
1869
1972
|
}
|
|
1870
1973
|
async function readJsonText(request) {
|
|
1871
|
-
const text = await request
|
|
1974
|
+
const text = await readRequestText(request);
|
|
1872
1975
|
try {
|
|
1873
1976
|
JSON.parse(text);
|
|
1874
1977
|
return text;
|
|
@@ -1876,6 +1979,40 @@ async function readJsonText(request) {
|
|
|
1876
1979
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1877
1980
|
}
|
|
1878
1981
|
}
|
|
1982
|
+
async function readRequestText(request) {
|
|
1983
|
+
const contentLength = request.headers.get("content-length");
|
|
1984
|
+
if (contentLength) {
|
|
1985
|
+
const declaredBytes = Number(contentLength);
|
|
1986
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
|
|
1987
|
+
throw new RequestBodyTooLargeError();
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
const body = request.body;
|
|
1991
|
+
if (!body) {
|
|
1992
|
+
return "";
|
|
1993
|
+
}
|
|
1994
|
+
const reader = body.getReader();
|
|
1995
|
+
const decoder = new TextDecoder();
|
|
1996
|
+
let bytes = 0;
|
|
1997
|
+
let text = "";
|
|
1998
|
+
try {
|
|
1999
|
+
while (true) {
|
|
2000
|
+
const { done, value } = await reader.read();
|
|
2001
|
+
if (done) {
|
|
2002
|
+
return `${text}${decoder.decode()}`;
|
|
2003
|
+
}
|
|
2004
|
+
bytes += value.byteLength;
|
|
2005
|
+
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
2006
|
+
await reader.cancel().catch(() => {
|
|
2007
|
+
});
|
|
2008
|
+
throw new RequestBodyTooLargeError();
|
|
2009
|
+
}
|
|
2010
|
+
text += decoder.decode(value, { stream: true });
|
|
2011
|
+
}
|
|
2012
|
+
} finally {
|
|
2013
|
+
reader.releaseLock();
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
1879
2016
|
function jsonResponse(body, status = 200) {
|
|
1880
2017
|
return new Response(JSON.stringify(body), {
|
|
1881
2018
|
headers: {
|
|
@@ -1948,6 +2085,9 @@ function upstreamAuthMessage(message) {
|
|
|
1948
2085
|
function isLoopbackHost(host) {
|
|
1949
2086
|
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
1950
2087
|
}
|
|
2088
|
+
function urlHost(host) {
|
|
2089
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
2090
|
+
}
|
|
1951
2091
|
function isLoopbackOrigin(origin) {
|
|
1952
2092
|
try {
|
|
1953
2093
|
return isLoopbackHost(new URL(origin).hostname.toLowerCase());
|
|
@@ -2055,7 +2195,7 @@ function logRequestCompleted(logger, status, stream, durationMs) {
|
|
|
2055
2195
|
}
|
|
2056
2196
|
function requestIdFor(request) {
|
|
2057
2197
|
const existing = request.headers.get("x-request-id")?.trim();
|
|
2058
|
-
return existing
|
|
2198
|
+
return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
|
|
2059
2199
|
}
|
|
2060
2200
|
function canonicalApiPath(path) {
|
|
2061
2201
|
const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;
|