@openhoo/hoopilot 2.1.4 → 2.1.6
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 +199 -70
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +13 -7
- package/dist/index.js +190 -68
- 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
|
@@ -2613,10 +2613,10 @@ footer.foot .end { margin-left:auto; }
|
|
|
2613
2613
|
pollGen += 1; var myGen = pollGen;
|
|
2614
2614
|
if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
|
|
2615
2615
|
var ctrl = new AbortController(); inflightFetch = ctrl;
|
|
2616
|
-
var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} },
|
|
2616
|
+
var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, Math.max(10000, intervalMs * 2));
|
|
2617
2617
|
var headers = { "accept":"application/json" };
|
|
2618
2618
|
if (apiKey) headers["x-api-key"] = apiKey;
|
|
2619
|
-
fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
|
|
2619
|
+
fetch("/v1/usage?view=dashboard", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
|
|
2620
2620
|
clearTimeout(to);
|
|
2621
2621
|
if (myGen !== pollGen) return null;
|
|
2622
2622
|
if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
|
|
@@ -2898,6 +2898,7 @@ var MetricsRegistry = class {
|
|
|
2898
2898
|
#inFlight = 0;
|
|
2899
2899
|
#requests = /* @__PURE__ */ new Map();
|
|
2900
2900
|
#durations = /* @__PURE__ */ new Map();
|
|
2901
|
+
#inFlightByRoute = /* @__PURE__ */ new Map();
|
|
2901
2902
|
#tokens = /* @__PURE__ */ new Map();
|
|
2902
2903
|
#upstream = /* @__PURE__ */ new Map();
|
|
2903
2904
|
#copilotQuota;
|
|
@@ -2907,14 +2908,23 @@ var MetricsRegistry = class {
|
|
|
2907
2908
|
this.#startedAtMs = (options.now ?? Date.now)();
|
|
2908
2909
|
}
|
|
2909
2910
|
/** Mark a request as started; pair with exactly one {@link observe}. */
|
|
2910
|
-
startRequest() {
|
|
2911
|
+
startRequest(route) {
|
|
2911
2912
|
this.#inFlight += 1;
|
|
2913
|
+
if (route) {
|
|
2914
|
+
this.#inFlightByRoute.set(route, (this.#inFlightByRoute.get(route) ?? 0) + 1);
|
|
2915
|
+
}
|
|
2912
2916
|
}
|
|
2913
2917
|
/** Record a completed request and clear its in-flight slot. */
|
|
2914
2918
|
observe(observation) {
|
|
2915
2919
|
if (this.#inFlight > 0) {
|
|
2916
2920
|
this.#inFlight -= 1;
|
|
2917
2921
|
}
|
|
2922
|
+
const inFlightForRoute = this.#inFlightByRoute.get(observation.route) ?? 0;
|
|
2923
|
+
if (inFlightForRoute > 1) {
|
|
2924
|
+
this.#inFlightByRoute.set(observation.route, inFlightForRoute - 1);
|
|
2925
|
+
} else if (inFlightForRoute === 1) {
|
|
2926
|
+
this.#inFlightByRoute.delete(observation.route);
|
|
2927
|
+
}
|
|
2918
2928
|
const key = labelKey(observation.route, observation.method, String(observation.status));
|
|
2919
2929
|
this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
|
|
2920
2930
|
this.#observeDuration(observation.route, observation.durationMs / 1e3);
|
|
@@ -2999,12 +3009,19 @@ var MetricsRegistry = class {
|
|
|
2999
3009
|
this.#durations.set(route, entry);
|
|
3000
3010
|
}
|
|
3001
3011
|
/** A JSON-friendly view of the current counters. */
|
|
3002
|
-
snapshot(
|
|
3012
|
+
snapshot(nowOrOptions = Date.now) {
|
|
3013
|
+
const options = typeof nowOrOptions === "function" ? { now: nowOrOptions } : nowOrOptions;
|
|
3014
|
+
const now = options.now ?? Date.now;
|
|
3015
|
+
const excludeRoutes = new Set(options.excludeRoutes ?? []);
|
|
3016
|
+
const excludeUpstreamPaths = new Set(options.excludeUpstreamPaths ?? []);
|
|
3003
3017
|
const byRoute = {};
|
|
3004
3018
|
const byStatus = {};
|
|
3005
3019
|
let requestsTotal = 0;
|
|
3006
3020
|
for (const [key, count] of this.#requests) {
|
|
3007
3021
|
const [route = "", , status = ""] = key.split(LABEL_SEPARATOR);
|
|
3022
|
+
if (excludeRoutes.has(route)) {
|
|
3023
|
+
continue;
|
|
3024
|
+
}
|
|
3008
3025
|
byRoute[route] = (byRoute[route] ?? 0) + count;
|
|
3009
3026
|
byStatus[status] = (byStatus[status] ?? 0) + count;
|
|
3010
3027
|
requestsTotal += count;
|
|
@@ -3022,8 +3039,12 @@ var MetricsRegistry = class {
|
|
|
3022
3039
|
let upstreamTotal = 0;
|
|
3023
3040
|
let upstreamErrors = 0;
|
|
3024
3041
|
for (const [key, count] of this.#upstream) {
|
|
3042
|
+
const [path = "", outcome = ""] = key.split(LABEL_SEPARATOR);
|
|
3043
|
+
if (excludeUpstreamPaths.has(path)) {
|
|
3044
|
+
continue;
|
|
3045
|
+
}
|
|
3025
3046
|
upstreamTotal += count;
|
|
3026
|
-
if (
|
|
3047
|
+
if (outcome === "error") {
|
|
3027
3048
|
upstreamErrors += count;
|
|
3028
3049
|
}
|
|
3029
3050
|
}
|
|
@@ -3033,8 +3054,8 @@ var MetricsRegistry = class {
|
|
|
3033
3054
|
}
|
|
3034
3055
|
return {
|
|
3035
3056
|
githubRateLimit,
|
|
3036
|
-
inFlight: this.#
|
|
3037
|
-
latency: this.#latencySnapshot(),
|
|
3057
|
+
inFlight: this.#filteredInFlight(excludeRoutes),
|
|
3058
|
+
latency: this.#latencySnapshot(excludeRoutes),
|
|
3038
3059
|
requests: { byRoute, byStatus, total: requestsTotal },
|
|
3039
3060
|
startedAt: new Date(this.#startedAtMs).toISOString(),
|
|
3040
3061
|
tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
|
|
@@ -3042,15 +3063,30 @@ var MetricsRegistry = class {
|
|
|
3042
3063
|
uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
|
|
3043
3064
|
};
|
|
3044
3065
|
}
|
|
3066
|
+
#filteredInFlight(excludeRoutes) {
|
|
3067
|
+
if (excludeRoutes.size === 0) {
|
|
3068
|
+
return this.#inFlight;
|
|
3069
|
+
}
|
|
3070
|
+
let excluded = 0;
|
|
3071
|
+
for (const [route, count] of this.#inFlightByRoute) {
|
|
3072
|
+
if (excludeRoutes.has(route)) {
|
|
3073
|
+
excluded += count;
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
return Math.max(0, this.#inFlight - excluded);
|
|
3077
|
+
}
|
|
3045
3078
|
// Summarize the duration histogram into a JSON latency view: per-route count and
|
|
3046
3079
|
// exact average, plus overall average and estimated p50/p95. The percentiles come
|
|
3047
3080
|
// from the buckets aggregated across routes, so they share /metrics' resolution.
|
|
3048
|
-
#latencySnapshot() {
|
|
3081
|
+
#latencySnapshot(excludeRoutes = /* @__PURE__ */ new Set()) {
|
|
3049
3082
|
const byRoute = {};
|
|
3050
3083
|
const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
|
|
3051
3084
|
let totalCount = 0;
|
|
3052
3085
|
let totalSum = 0;
|
|
3053
3086
|
for (const [route, entry] of this.#durations) {
|
|
3087
|
+
if (excludeRoutes.has(route)) {
|
|
3088
|
+
continue;
|
|
3089
|
+
}
|
|
3054
3090
|
byRoute[route] = {
|
|
3055
3091
|
avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
|
|
3056
3092
|
count: entry.count
|
|
@@ -3288,17 +3324,15 @@ function observeResponseUsage(response, fallbackModel, onUsage, signal, onOutcom
|
|
|
3288
3324
|
if (!body) {
|
|
3289
3325
|
return response;
|
|
3290
3326
|
}
|
|
3291
|
-
const [clientBranch, observerBranch] = body.tee();
|
|
3292
3327
|
const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
3293
|
-
|
|
3294
|
-
(
|
|
3328
|
+
return new Response(
|
|
3329
|
+
streamWithUsageObservation(body, isSse, fallbackModel, onUsage, signal, onOutcome),
|
|
3330
|
+
{
|
|
3331
|
+
headers: response.headers,
|
|
3332
|
+
status: response.status,
|
|
3333
|
+
statusText: response.statusText
|
|
3295
3334
|
}
|
|
3296
3335
|
);
|
|
3297
|
-
return new Response(clientBranch, {
|
|
3298
|
-
headers: response.headers,
|
|
3299
|
-
status: response.status,
|
|
3300
|
-
statusText: response.statusText
|
|
3301
|
-
});
|
|
3302
3336
|
}
|
|
3303
3337
|
function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome) {
|
|
3304
3338
|
const accumulator = createUsageAccumulator(fallbackModel, onUsage, onOutcome);
|
|
@@ -3314,13 +3348,16 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome)
|
|
|
3314
3348
|
}
|
|
3315
3349
|
accumulator.finish();
|
|
3316
3350
|
}
|
|
3317
|
-
|
|
3351
|
+
function streamWithUsageObservation(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
|
|
3318
3352
|
const reader = stream.getReader();
|
|
3353
|
+
let aborted = signal?.aborted ?? false;
|
|
3354
|
+
let released = false;
|
|
3319
3355
|
const onAbort = () => {
|
|
3356
|
+
aborted = true;
|
|
3320
3357
|
reader.cancel().catch(() => {
|
|
3321
3358
|
});
|
|
3322
3359
|
};
|
|
3323
|
-
if (
|
|
3360
|
+
if (aborted) {
|
|
3324
3361
|
reader.cancel().catch(() => {
|
|
3325
3362
|
});
|
|
3326
3363
|
} else {
|
|
@@ -3328,7 +3365,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3328
3365
|
}
|
|
3329
3366
|
const decoder = new TextDecoder();
|
|
3330
3367
|
const guardedOutcome = onOutcome ? (extracted) => {
|
|
3331
|
-
if (!
|
|
3368
|
+
if (!aborted) {
|
|
3332
3369
|
onOutcome(extracted);
|
|
3333
3370
|
}
|
|
3334
3371
|
} : void 0;
|
|
@@ -3336,33 +3373,40 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3336
3373
|
let buffer = "";
|
|
3337
3374
|
let bufferedBytes = 0;
|
|
3338
3375
|
let overflowed = false;
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3376
|
+
const release = () => {
|
|
3377
|
+
if (released) {
|
|
3378
|
+
return;
|
|
3379
|
+
}
|
|
3380
|
+
released = true;
|
|
3381
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3382
|
+
reader.releaseLock();
|
|
3383
|
+
};
|
|
3384
|
+
const observeChunk = (chunkBytes) => {
|
|
3385
|
+
const chunk = decoder.decode(chunkBytes, { stream: true });
|
|
3386
|
+
if (isSse) {
|
|
3387
|
+
buffer += chunk;
|
|
3388
|
+
const lines = buffer.split(/\r?\n/);
|
|
3389
|
+
buffer = lines.pop() ?? "";
|
|
3390
|
+
for (const line of lines) {
|
|
3391
|
+
considerSseLine(line, accumulator.consider);
|
|
3344
3392
|
}
|
|
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
|
-
}
|
|
3393
|
+
if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3394
|
+
buffer = "";
|
|
3364
3395
|
}
|
|
3396
|
+
return;
|
|
3397
|
+
}
|
|
3398
|
+
if (overflowed) {
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
bufferedBytes += chunkBytes.byteLength;
|
|
3402
|
+
if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
|
|
3403
|
+
overflowed = true;
|
|
3404
|
+
buffer = "";
|
|
3405
|
+
return;
|
|
3365
3406
|
}
|
|
3407
|
+
buffer += chunk;
|
|
3408
|
+
};
|
|
3409
|
+
const finishObservation = () => {
|
|
3366
3410
|
const finalBuffer = buffer + decoder.decode();
|
|
3367
3411
|
if (isSse) {
|
|
3368
3412
|
if (finalBuffer) {
|
|
@@ -3374,11 +3418,41 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
3374
3418
|
accumulator.consider(parsed);
|
|
3375
3419
|
}
|
|
3376
3420
|
}
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
}
|
|
3381
|
-
|
|
3421
|
+
if (!aborted) {
|
|
3422
|
+
safeFinishAccumulator(accumulator);
|
|
3423
|
+
}
|
|
3424
|
+
};
|
|
3425
|
+
return new ReadableStream({
|
|
3426
|
+
async pull(controller) {
|
|
3427
|
+
const result = await reader.read().catch((error) => {
|
|
3428
|
+
release();
|
|
3429
|
+
controller.error(error);
|
|
3430
|
+
return void 0;
|
|
3431
|
+
});
|
|
3432
|
+
if (!result) {
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
if (result.done) {
|
|
3436
|
+
finishObservation();
|
|
3437
|
+
controller.close();
|
|
3438
|
+
release();
|
|
3439
|
+
return;
|
|
3440
|
+
}
|
|
3441
|
+
try {
|
|
3442
|
+
observeChunk(result.value);
|
|
3443
|
+
} catch {
|
|
3444
|
+
}
|
|
3445
|
+
controller.enqueue(result.value);
|
|
3446
|
+
},
|
|
3447
|
+
async cancel(reason) {
|
|
3448
|
+
aborted = true;
|
|
3449
|
+
try {
|
|
3450
|
+
await reader.cancel(reason);
|
|
3451
|
+
} finally {
|
|
3452
|
+
release();
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
});
|
|
3382
3456
|
}
|
|
3383
3457
|
function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
|
|
3384
3458
|
let model = fallbackModel;
|
|
@@ -3403,6 +3477,12 @@ function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
|
|
|
3403
3477
|
}
|
|
3404
3478
|
};
|
|
3405
3479
|
}
|
|
3480
|
+
function safeFinishAccumulator(accumulator) {
|
|
3481
|
+
try {
|
|
3482
|
+
accumulator.finish();
|
|
3483
|
+
} catch {
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3406
3486
|
function considerSseLine(line, consider) {
|
|
3407
3487
|
const trimmed = line.trim();
|
|
3408
3488
|
if (!trimmed.startsWith("data:")) {
|
|
@@ -3521,13 +3601,33 @@ async function getVersion() {
|
|
|
3521
3601
|
var DEFAULT_HOST = "127.0.0.1";
|
|
3522
3602
|
var DEFAULT_PORT = 4141;
|
|
3523
3603
|
var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
|
|
3524
|
-
var
|
|
3604
|
+
var MIN_NON_LOOPBACK_API_KEY_LENGTH = 24;
|
|
3605
|
+
var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set([
|
|
3606
|
+
"changeme",
|
|
3607
|
+
"demo",
|
|
3608
|
+
"example",
|
|
3609
|
+
"hoopilot",
|
|
3610
|
+
"local-key",
|
|
3611
|
+
"password",
|
|
3612
|
+
"password123",
|
|
3613
|
+
"secret",
|
|
3614
|
+
"test"
|
|
3615
|
+
]);
|
|
3525
3616
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
3526
3617
|
var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
|
|
3527
3618
|
var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
|
|
3528
3619
|
var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
3529
3620
|
var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
|
|
3530
3621
|
var USAGE_CACHE_TTL_MS = 6e4;
|
|
3622
|
+
var DASHBOARD_USAGE_VIEW = "dashboard";
|
|
3623
|
+
var DASHBOARD_EXCLUDED_ROUTES = [
|
|
3624
|
+
"cors.preflight",
|
|
3625
|
+
"dashboard",
|
|
3626
|
+
"health",
|
|
3627
|
+
"metrics",
|
|
3628
|
+
"usage"
|
|
3629
|
+
];
|
|
3630
|
+
var DASHBOARD_EXCLUDED_UPSTREAM_PATHS = ["/copilot_internal/user"];
|
|
3531
3631
|
var RequestBodyTooLargeError = class extends Error {
|
|
3532
3632
|
constructor() {
|
|
3533
3633
|
super(REQUEST_TOO_LARGE_MESSAGE);
|
|
@@ -3580,7 +3680,7 @@ function createHoopilotHandler(options = {}) {
|
|
|
3580
3680
|
requestId,
|
|
3581
3681
|
route
|
|
3582
3682
|
});
|
|
3583
|
-
metrics.startRequest();
|
|
3683
|
+
metrics.startRequest(route);
|
|
3584
3684
|
const origin = request.headers.get("origin")?.trim() || void 0;
|
|
3585
3685
|
const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
|
|
3586
3686
|
const inner = normalizeInnerRequest(request, apiPath, url);
|
|
@@ -3703,7 +3803,7 @@ function buildApp(deps) {
|
|
|
3703
3803
|
}
|
|
3704
3804
|
logger.error({ err: errorDetails(error), event: "http.request.failed" }, "request failed");
|
|
3705
3805
|
return jsonError(500, "internal_error", message);
|
|
3706
|
-
}).get("/", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/healthz", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/metrics", () => metricsResponse(metrics)).get("/v1/usage", ({ request }) => handleUsage(metrics, readUsage, request
|
|
3806
|
+
}).get("/", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/healthz", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/metrics", () => metricsResponse(metrics)).get("/v1/usage", ({ request }) => handleUsage(metrics, readUsage, request)).get(
|
|
3707
3807
|
"/v1/models",
|
|
3708
3808
|
({ request }) => handleModels(client, metrics, request.signal, loggerFor(request))
|
|
3709
3809
|
).get("/v1/responses", () => websocketUnsupportedResponse()).post(
|
|
@@ -3799,10 +3899,9 @@ function startHoopilotServer(options = {}) {
|
|
|
3799
3899
|
"Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
|
|
3800
3900
|
);
|
|
3801
3901
|
}
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
);
|
|
3902
|
+
const rejection = apiKey ? apiKeyRejectionReason(apiKey) : void 0;
|
|
3903
|
+
if (rejection) {
|
|
3904
|
+
throw new Error(`Refusing to listen on a non-loopback host: ${rejection}`);
|
|
3806
3905
|
}
|
|
3807
3906
|
}
|
|
3808
3907
|
const server = Bun.serve({
|
|
@@ -4100,12 +4199,16 @@ async function readRequestText(request) {
|
|
|
4100
4199
|
const reader = body.getReader();
|
|
4101
4200
|
const decoder = new TextDecoder();
|
|
4102
4201
|
let bytes = 0;
|
|
4103
|
-
|
|
4202
|
+
const chunks = [];
|
|
4104
4203
|
try {
|
|
4105
4204
|
while (true) {
|
|
4106
4205
|
const { done, value } = await reader.read();
|
|
4107
4206
|
if (done) {
|
|
4108
|
-
|
|
4207
|
+
const tail = decoder.decode();
|
|
4208
|
+
if (tail) {
|
|
4209
|
+
chunks.push(tail);
|
|
4210
|
+
}
|
|
4211
|
+
return chunks.join("");
|
|
4109
4212
|
}
|
|
4110
4213
|
bytes += value.byteLength;
|
|
4111
4214
|
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
@@ -4113,7 +4216,7 @@ async function readRequestText(request) {
|
|
|
4113
4216
|
});
|
|
4114
4217
|
throw new RequestBodyTooLargeError();
|
|
4115
4218
|
}
|
|
4116
|
-
|
|
4219
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
4117
4220
|
}
|
|
4118
4221
|
} finally {
|
|
4119
4222
|
reader.releaseLock();
|
|
@@ -4210,8 +4313,18 @@ function resolveCorsAllowOrigin(origin, allowedOrigins) {
|
|
|
4210
4313
|
}
|
|
4211
4314
|
return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
|
|
4212
4315
|
}
|
|
4213
|
-
function
|
|
4214
|
-
|
|
4316
|
+
function apiKeyRejectionReason(apiKey) {
|
|
4317
|
+
const normalized = apiKey.trim();
|
|
4318
|
+
if (WELL_KNOWN_DEMO_API_KEYS.has(normalized.toLowerCase())) {
|
|
4319
|
+
return "HOOPILOT_API_KEY is a well-known demo value. Set a strong, unique API key.";
|
|
4320
|
+
}
|
|
4321
|
+
if (normalized.length < MIN_NON_LOOPBACK_API_KEY_LENGTH) {
|
|
4322
|
+
return `HOOPILOT_API_KEY must be at least ${MIN_NON_LOOPBACK_API_KEY_LENGTH} characters when listening on a non-loopback host.`;
|
|
4323
|
+
}
|
|
4324
|
+
if (/^(.)\1+$/.test(normalized)) {
|
|
4325
|
+
return "HOOPILOT_API_KEY must not be a repeated single character. Set a strong, unique API key.";
|
|
4326
|
+
}
|
|
4327
|
+
return void 0;
|
|
4215
4328
|
}
|
|
4216
4329
|
function isUpstreamAuthStatus(status) {
|
|
4217
4330
|
return status === 401 || status === 403;
|
|
@@ -4448,9 +4561,13 @@ function dashboardResponse() {
|
|
|
4448
4561
|
status: 200
|
|
4449
4562
|
});
|
|
4450
4563
|
}
|
|
4451
|
-
async function handleUsage(metrics, readUsage,
|
|
4452
|
-
const
|
|
4453
|
-
const
|
|
4564
|
+
async function handleUsage(metrics, readUsage, request) {
|
|
4565
|
+
const view = new URL(request.url).searchParams.get("view");
|
|
4566
|
+
const { copilot, error } = await readUsage(request.signal);
|
|
4567
|
+
const proxy = view === DASHBOARD_USAGE_VIEW ? metrics.snapshot({
|
|
4568
|
+
excludeRoutes: DASHBOARD_EXCLUDED_ROUTES,
|
|
4569
|
+
excludeUpstreamPaths: DASHBOARD_EXCLUDED_UPSTREAM_PATHS
|
|
4570
|
+
}) : metrics.snapshot();
|
|
4454
4571
|
const body = {
|
|
4455
4572
|
copilot: copilot ?? null,
|
|
4456
4573
|
object: "usage",
|
|
@@ -4467,25 +4584,30 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
|
|
|
4467
4584
|
let cache;
|
|
4468
4585
|
return async (signal) => {
|
|
4469
4586
|
if (cache && now() - cache.atMs < ttlMs) {
|
|
4470
|
-
return
|
|
4587
|
+
return cache.result;
|
|
4471
4588
|
}
|
|
4472
4589
|
try {
|
|
4473
4590
|
const upstream = await client.usage(signal);
|
|
4474
4591
|
metrics.recordUpstream(usagePath, upstream.ok);
|
|
4475
4592
|
metrics.recordGithubRateLimit(parseRateLimitHeaders(upstream.headers, now()));
|
|
4476
4593
|
if (!upstream.ok) {
|
|
4477
|
-
|
|
4594
|
+
const result2 = { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
|
|
4595
|
+
cache = { atMs: now(), result: result2 };
|
|
4596
|
+
return result2;
|
|
4478
4597
|
}
|
|
4479
4598
|
const value = normalizeCopilotUsage(await upstream.json().catch(() => ({})));
|
|
4480
|
-
|
|
4599
|
+
const result = { copilot: value };
|
|
4600
|
+
cache = { atMs: now(), result };
|
|
4481
4601
|
metrics.recordCopilotQuota(value);
|
|
4482
|
-
return
|
|
4602
|
+
return result;
|
|
4483
4603
|
} catch (error) {
|
|
4484
4604
|
if (error instanceof CopilotAuthError) {
|
|
4485
4605
|
return { error: error.message };
|
|
4486
4606
|
}
|
|
4487
4607
|
metrics.recordUpstream(usagePath, false);
|
|
4488
|
-
|
|
4608
|
+
const result = { error: errorMessage(error) };
|
|
4609
|
+
cache = { atMs: now(), result };
|
|
4610
|
+
return result;
|
|
4489
4611
|
}
|
|
4490
4612
|
};
|
|
4491
4613
|
}
|
|
@@ -4591,8 +4713,14 @@ function versionFromTag(tag) {
|
|
|
4591
4713
|
return tag.trim().replace(/^v/, "");
|
|
4592
4714
|
}
|
|
4593
4715
|
function assetSuffixFor(platform, arch, isMusl) {
|
|
4594
|
-
const os = platform === "win32" ? "windows" : platform === "darwin" ? "darwin" :
|
|
4595
|
-
|
|
4716
|
+
const os = platform === "linux" ? "linux" : platform === "win32" ? "windows" : platform === "darwin" ? "darwin" : void 0;
|
|
4717
|
+
if (!os) {
|
|
4718
|
+
throw new Error(`Unsupported platform for standalone updates: ${platform}.`);
|
|
4719
|
+
}
|
|
4720
|
+
const cpu = arch === "x64" || arch === "amd64" ? "x64" : arch === "arm64" || arch === "aarch64" ? "arm64" : void 0;
|
|
4721
|
+
if (!cpu) {
|
|
4722
|
+
throw new Error(`Unsupported architecture for standalone updates: ${arch}.`);
|
|
4723
|
+
}
|
|
4596
4724
|
const libc = os === "linux" && isMusl ? "-musl" : "";
|
|
4597
4725
|
return `${os}-${cpu}${libc}`;
|
|
4598
4726
|
}
|
|
@@ -5481,6 +5609,7 @@ Options:
|
|
|
5481
5609
|
-p, --port <port> Port to listen on. Default: 4141
|
|
5482
5610
|
--host <host> Host to listen on. Default: 127.0.0.1
|
|
5483
5611
|
--api-key <key> Require clients to send Authorization: Bearer <key> or x-api-key: <key>
|
|
5612
|
+
Non-loopback binds require at least 24 characters.
|
|
5484
5613
|
--api-key-file <path> Read the local API key from a file instead of argv
|
|
5485
5614
|
--auth-file <path> OAuth credential store path
|
|
5486
5615
|
--copilot-api-base-url <url> Copilot API base URL override
|