@openhoo/hoopilot 2.1.4 → 2.1.5
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 +4 -4
- package/dist/cli.js +127 -52
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +118 -50
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ $env:OPENAI_BASE_URL = "http://127.0.0.1:4141/v1"
|
|
|
66
66
|
$env:OPENAI_API_KEY = "hoopilot"
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
To require clients to authenticate — recommended whenever you expose the proxy beyond localhost — set `HOOPILOT_API_KEY` to a strong, unique secret and send that value as the client key:
|
|
69
|
+
To require clients to authenticate — recommended whenever you expose the proxy beyond localhost — set `HOOPILOT_API_KEY` to a strong, unique secret of at least 24 characters and send that value as the client key:
|
|
70
70
|
|
|
71
71
|
```sh
|
|
72
72
|
export HOOPILOT_API_KEY=$(openssl rand -hex 24)
|
|
@@ -161,7 +161,7 @@ Tags follow the release version, for example `ghcr.io/openhoo/hoopilot:1.3`, `:1
|
|
|
161
161
|
|
|
162
162
|
#### Exposing the proxy beyond loopback
|
|
163
163
|
|
|
164
|
-
The image binds `0.0.0.0` and cannot tell whether the published port is loopback-only, so it fails closed: drop the `-e HOOPILOT_ALLOW_UNAUTHENTICATED=1` opt-in (or map the port to a non-loopback interface) and it refuses to start without a strong, unique `HOOPILOT_API_KEY`
|
|
164
|
+
The image binds `0.0.0.0` and cannot tell whether the published port is loopback-only, so it fails closed: drop the `-e HOOPILOT_ALLOW_UNAUTHENTICATED=1` opt-in (or map the port to a non-loopback interface) and it refuses to start without a strong, unique `HOOPILOT_API_KEY` of at least 24 characters. Short, repeated, and well-known demo keys are rejected. Clients then send that key as `Authorization: Bearer <key>` or `x-api-key: <key>`:
|
|
165
165
|
|
|
166
166
|
```sh
|
|
167
167
|
export HOOPILOT_API_KEY=$(openssl rand -hex 24)
|
|
@@ -201,7 +201,7 @@ Start the server:
|
|
|
201
201
|
hoopilot --port 4141
|
|
202
202
|
```
|
|
203
203
|
|
|
204
|
-
By default Hoopilot listens on `127.0.0.1:4141`. If `HOOPILOT_API_KEY` is unset, local requests are accepted without client authentication. Binding to a non-loopback host requires either a strong, unique `HOOPILOT_API_KEY` or the explicit `--allow-unauthenticated` / `HOOPILOT_ALLOW_UNAUTHENTICATED=1` opt-in.
|
|
204
|
+
By default Hoopilot listens on `127.0.0.1:4141`. If `HOOPILOT_API_KEY` is unset, local requests are accepted without client authentication. Binding to a non-loopback host requires either a strong, unique `HOOPILOT_API_KEY` of at least 24 characters or the explicit `--allow-unauthenticated` / `HOOPILOT_ALLOW_UNAUTHENTICATED=1` opt-in. Short, repeated, and well-known demo keys are always rejected on a non-loopback host, even with the unauthenticated opt-in.
|
|
205
205
|
|
|
206
206
|
When an API key is configured, clients may send it as either `Authorization: Bearer <key>` or `x-api-key: <key>`.
|
|
207
207
|
|
|
@@ -366,7 +366,7 @@ Server and local-client settings:
|
|
|
366
366
|
| --- | --- |
|
|
367
367
|
| `HOST` / `--host` | Host to listen on. Default: `127.0.0.1` for local runs; Docker sets `0.0.0.0`. |
|
|
368
368
|
| `PORT` / `--port` | Port to listen on. Default: `4141`. |
|
|
369
|
-
| `HOOPILOT_API_KEY` / `--api-key` | Require clients to send `Authorization: Bearer <key>` or `x-api-key: <key>`. Must be a strong, unique secret on non-loopback binds; well-known demo keys are rejected. |
|
|
369
|
+
| `HOOPILOT_API_KEY` / `--api-key` | Require clients to send `Authorization: Bearer <key>` or `x-api-key: <key>`. Must be a strong, unique secret of at least 24 characters on non-loopback binds; short, repeated, and well-known demo keys are rejected. |
|
|
370
370
|
| `--api-key-file` | Read the local API key from a file instead of argv. |
|
|
371
371
|
| `HOOPILOT_ALLOWED_ORIGINS` | Comma-separated browser origins allowed to make cross-origin requests. Loopback origins are always allowed; every other origin is blocked. |
|
|
372
372
|
| `HOOPILOT_ALLOW_UNAUTHENTICATED` / `--allow-unauthenticated` | Allow non-loopback binds without a local API key. |
|
package/dist/cli.js
CHANGED
|
@@ -3288,17 +3288,15 @@ function observeResponseUsage(response, fallbackModel, onUsage, signal, onOutcom
|
|
|
3288
3288
|
if (!body) {
|
|
3289
3289
|
return response;
|
|
3290
3290
|
}
|
|
3291
|
-
const [clientBranch, observerBranch] = body.tee();
|
|
3292
3291
|
const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
3293
|
-
|
|
3294
|
-
(
|
|
3292
|
+
return new Response(
|
|
3293
|
+
streamWithUsageObservation(body, isSse, fallbackModel, onUsage, signal, onOutcome),
|
|
3294
|
+
{
|
|
3295
|
+
headers: response.headers,
|
|
3296
|
+
status: response.status,
|
|
3297
|
+
statusText: response.statusText
|
|
3295
3298
|
}
|
|
3296
3299
|
);
|
|
3297
|
-
return new Response(clientBranch, {
|
|
3298
|
-
headers: response.headers,
|
|
3299
|
-
status: response.status,
|
|
3300
|
-
statusText: response.statusText
|
|
3301
|
-
});
|
|
3302
3300
|
}
|
|
3303
3301
|
function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome) {
|
|
3304
3302
|
const accumulator = createUsageAccumulator(fallbackModel, onUsage, onOutcome);
|
|
@@ -3314,13 +3312,16 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome)
|
|
|
3314
3312
|
}
|
|
3315
3313
|
accumulator.finish();
|
|
3316
3314
|
}
|
|
3317
|
-
|
|
3315
|
+
function streamWithUsageObservation(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
|
|
3318
3316
|
const reader = stream.getReader();
|
|
3317
|
+
let aborted = signal?.aborted ?? false;
|
|
3318
|
+
let released = false;
|
|
3319
3319
|
const onAbort = () => {
|
|
3320
|
+
aborted = true;
|
|
3320
3321
|
reader.cancel().catch(() => {
|
|
3321
3322
|
});
|
|
3322
3323
|
};
|
|
3323
|
-
if (
|
|
3324
|
+
if (aborted) {
|
|
3324
3325
|
reader.cancel().catch(() => {
|
|
3325
3326
|
});
|
|
3326
3327
|
} else {
|
|
@@ -3328,7 +3329,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3328
3329
|
}
|
|
3329
3330
|
const decoder = new TextDecoder();
|
|
3330
3331
|
const guardedOutcome = onOutcome ? (extracted) => {
|
|
3331
|
-
if (!
|
|
3332
|
+
if (!aborted) {
|
|
3332
3333
|
onOutcome(extracted);
|
|
3333
3334
|
}
|
|
3334
3335
|
} : void 0;
|
|
@@ -3336,33 +3337,40 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3336
3337
|
let buffer = "";
|
|
3337
3338
|
let bufferedBytes = 0;
|
|
3338
3339
|
let overflowed = false;
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3340
|
+
const release = () => {
|
|
3341
|
+
if (released) {
|
|
3342
|
+
return;
|
|
3343
|
+
}
|
|
3344
|
+
released = true;
|
|
3345
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3346
|
+
reader.releaseLock();
|
|
3347
|
+
};
|
|
3348
|
+
const observeChunk = (chunkBytes) => {
|
|
3349
|
+
const chunk = decoder.decode(chunkBytes, { stream: true });
|
|
3350
|
+
if (isSse) {
|
|
3351
|
+
buffer += chunk;
|
|
3352
|
+
const lines = buffer.split(/\r?\n/);
|
|
3353
|
+
buffer = lines.pop() ?? "";
|
|
3354
|
+
for (const line of lines) {
|
|
3355
|
+
considerSseLine(line, accumulator.consider);
|
|
3344
3356
|
}
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
buffer += chunk;
|
|
3348
|
-
const lines = buffer.split(/\r?\n/);
|
|
3349
|
-
buffer = lines.pop() ?? "";
|
|
3350
|
-
for (const line of lines) {
|
|
3351
|
-
considerSseLine(line, accumulator.consider);
|
|
3352
|
-
}
|
|
3353
|
-
if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3354
|
-
buffer = "";
|
|
3355
|
-
}
|
|
3356
|
-
} else if (!overflowed) {
|
|
3357
|
-
bufferedBytes += result.value.byteLength;
|
|
3358
|
-
if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3359
|
-
overflowed = true;
|
|
3360
|
-
buffer = "";
|
|
3361
|
-
} else {
|
|
3362
|
-
buffer += chunk;
|
|
3363
|
-
}
|
|
3357
|
+
if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3358
|
+
buffer = "";
|
|
3364
3359
|
}
|
|
3360
|
+
return;
|
|
3365
3361
|
}
|
|
3362
|
+
if (overflowed) {
|
|
3363
|
+
return;
|
|
3364
|
+
}
|
|
3365
|
+
bufferedBytes += chunkBytes.byteLength;
|
|
3366
|
+
if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3367
|
+
overflowed = true;
|
|
3368
|
+
buffer = "";
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
buffer += chunk;
|
|
3372
|
+
};
|
|
3373
|
+
const finishObservation = () => {
|
|
3366
3374
|
const finalBuffer = buffer + decoder.decode();
|
|
3367
3375
|
if (isSse) {
|
|
3368
3376
|
if (finalBuffer) {
|
|
@@ -3374,11 +3382,41 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3374
3382
|
accumulator.consider(parsed);
|
|
3375
3383
|
}
|
|
3376
3384
|
}
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
}
|
|
3381
|
-
|
|
3385
|
+
if (!aborted) {
|
|
3386
|
+
safeFinishAccumulator(accumulator);
|
|
3387
|
+
}
|
|
3388
|
+
};
|
|
3389
|
+
return new ReadableStream({
|
|
3390
|
+
async pull(controller) {
|
|
3391
|
+
const result = await reader.read().catch((error) => {
|
|
3392
|
+
release();
|
|
3393
|
+
controller.error(error);
|
|
3394
|
+
return void 0;
|
|
3395
|
+
});
|
|
3396
|
+
if (!result) {
|
|
3397
|
+
return;
|
|
3398
|
+
}
|
|
3399
|
+
if (result.done) {
|
|
3400
|
+
finishObservation();
|
|
3401
|
+
controller.close();
|
|
3402
|
+
release();
|
|
3403
|
+
return;
|
|
3404
|
+
}
|
|
3405
|
+
try {
|
|
3406
|
+
observeChunk(result.value);
|
|
3407
|
+
} catch {
|
|
3408
|
+
}
|
|
3409
|
+
controller.enqueue(result.value);
|
|
3410
|
+
},
|
|
3411
|
+
async cancel(reason) {
|
|
3412
|
+
aborted = true;
|
|
3413
|
+
try {
|
|
3414
|
+
await reader.cancel(reason);
|
|
3415
|
+
} finally {
|
|
3416
|
+
release();
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
});
|
|
3382
3420
|
}
|
|
3383
3421
|
function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
|
|
3384
3422
|
let model = fallbackModel;
|
|
@@ -3403,6 +3441,12 @@ function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
|
|
|
3403
3441
|
}
|
|
3404
3442
|
};
|
|
3405
3443
|
}
|
|
3444
|
+
function safeFinishAccumulator(accumulator) {
|
|
3445
|
+
try {
|
|
3446
|
+
accumulator.finish();
|
|
3447
|
+
} catch {
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3406
3450
|
function considerSseLine(line, consider) {
|
|
3407
3451
|
const trimmed = line.trim();
|
|
3408
3452
|
if (!trimmed.startsWith("data:")) {
|
|
@@ -3521,7 +3565,18 @@ async function getVersion() {
|
|
|
3521
3565
|
var DEFAULT_HOST = "127.0.0.1";
|
|
3522
3566
|
var DEFAULT_PORT = 4141;
|
|
3523
3567
|
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
|
|
3524
|
-
var
|
|
3568
|
+
var MIN_NON_LOOPBACK_API_KEY_LENGTH = 24;
|
|
3569
|
+
var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set([
|
|
3570
|
+
"changeme",
|
|
3571
|
+
"demo",
|
|
3572
|
+
"example",
|
|
3573
|
+
"hoopilot",
|
|
3574
|
+
"local-key",
|
|
3575
|
+
"password",
|
|
3576
|
+
"password123",
|
|
3577
|
+
"secret",
|
|
3578
|
+
"test"
|
|
3579
|
+
]);
|
|
3525
3580
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
3526
3581
|
var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
|
|
3527
3582
|
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
@@ -3799,10 +3854,9 @@ function startHoopilotServer(options = {}) {
|
|
|
3799
3854
|
"Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
|
|
3800
3855
|
);
|
|
3801
3856
|
}
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
);
|
|
3857
|
+
const rejection = apiKey ? apiKeyRejectionReason(apiKey) : void 0;
|
|
3858
|
+
if (rejection) {
|
|
3859
|
+
throw new Error(`Refusing to listen on a non-loopback host: ${rejection}`);
|
|
3806
3860
|
}
|
|
3807
3861
|
}
|
|
3808
3862
|
const server = Bun.serve({
|
|
@@ -4100,12 +4154,16 @@ async function readRequestText(request) {
|
|
|
4100
4154
|
const reader = body.getReader();
|
|
4101
4155
|
const decoder = new TextDecoder();
|
|
4102
4156
|
let bytes = 0;
|
|
4103
|
-
|
|
4157
|
+
const chunks = [];
|
|
4104
4158
|
try {
|
|
4105
4159
|
while (true) {
|
|
4106
4160
|
const { done, value } = await reader.read();
|
|
4107
4161
|
if (done) {
|
|
4108
|
-
|
|
4162
|
+
const tail = decoder.decode();
|
|
4163
|
+
if (tail) {
|
|
4164
|
+
chunks.push(tail);
|
|
4165
|
+
}
|
|
4166
|
+
return chunks.join("");
|
|
4109
4167
|
}
|
|
4110
4168
|
bytes += value.byteLength;
|
|
4111
4169
|
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
@@ -4113,7 +4171,7 @@ async function readRequestText(request) {
|
|
|
4113
4171
|
});
|
|
4114
4172
|
throw new RequestBodyTooLargeError();
|
|
4115
4173
|
}
|
|
4116
|
-
|
|
4174
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
4117
4175
|
}
|
|
4118
4176
|
} finally {
|
|
4119
4177
|
reader.releaseLock();
|
|
@@ -4210,8 +4268,18 @@ function resolveCorsAllowOrigin(origin, allowedOrigins) {
|
|
|
4210
4268
|
}
|
|
4211
4269
|
return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
|
|
4212
4270
|
}
|
|
4213
|
-
function
|
|
4214
|
-
|
|
4271
|
+
function apiKeyRejectionReason(apiKey) {
|
|
4272
|
+
const normalized = apiKey.trim();
|
|
4273
|
+
if (WELL_KNOWN_DEMO_API_KEYS.has(normalized.toLowerCase())) {
|
|
4274
|
+
return "HOOPILOT_API_KEY is a well-known demo value. Set a strong, unique API key.";
|
|
4275
|
+
}
|
|
4276
|
+
if (normalized.length < MIN_NON_LOOPBACK_API_KEY_LENGTH) {
|
|
4277
|
+
return `HOOPILOT_API_KEY must be at least ${MIN_NON_LOOPBACK_API_KEY_LENGTH} characters when listening on a non-loopback host.`;
|
|
4278
|
+
}
|
|
4279
|
+
if (/^(.)\1+$/.test(normalized)) {
|
|
4280
|
+
return "HOOPILOT_API_KEY must not be a repeated single character. Set a strong, unique API key.";
|
|
4281
|
+
}
|
|
4282
|
+
return void 0;
|
|
4215
4283
|
}
|
|
4216
4284
|
function isUpstreamAuthStatus(status) {
|
|
4217
4285
|
return status === 401 || status === 403;
|
|
@@ -4591,8 +4659,14 @@ function versionFromTag(tag) {
|
|
|
4591
4659
|
return tag.trim().replace(/^v/, "");
|
|
4592
4660
|
}
|
|
4593
4661
|
function assetSuffixFor(platform, arch, isMusl) {
|
|
4594
|
-
const os = platform === "win32" ? "windows" : platform === "darwin" ? "darwin" :
|
|
4595
|
-
|
|
4662
|
+
const os = platform === "linux" ? "linux" : platform === "win32" ? "windows" : platform === "darwin" ? "darwin" : void 0;
|
|
4663
|
+
if (!os) {
|
|
4664
|
+
throw new Error(`Unsupported platform for standalone updates: ${platform}.`);
|
|
4665
|
+
}
|
|
4666
|
+
const cpu = arch === "x64" || arch === "amd64" ? "x64" : arch === "arm64" || arch === "aarch64" ? "arm64" : void 0;
|
|
4667
|
+
if (!cpu) {
|
|
4668
|
+
throw new Error(`Unsupported architecture for standalone updates: ${arch}.`);
|
|
4669
|
+
}
|
|
4596
4670
|
const libc = os === "linux" && isMusl ? "-musl" : "";
|
|
4597
4671
|
return `${os}-${cpu}${libc}`;
|
|
4598
4672
|
}
|
|
@@ -5481,6 +5555,7 @@ Options:
|
|
|
5481
5555
|
-p, --port <port> Port to listen on. Default: 4141
|
|
5482
5556
|
--host <host> Host to listen on. Default: 127.0.0.1
|
|
5483
5557
|
--api-key <key> Require clients to send Authorization: Bearer <key> or x-api-key: <key>
|
|
5558
|
+
Non-loopback binds require at least 24 characters.
|
|
5484
5559
|
--api-key-file <path> Read the local API key from a file instead of argv
|
|
5485
5560
|
--auth-file <path> OAuth credential store path
|
|
5486
5561
|
--copilot-api-base-url <url> Copilot API base URL override
|