@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 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` (well-known demo keys are rejected). Clients then send that key as `Authorization: Bearer <key>` or `x-api-key: <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. Well-known demo keys are always rejected on a non-loopback host, even with the unauthenticated 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) {} }, 3000);
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(now = Date.now) {
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 (key.endsWith(`${LABEL_SEPARATOR}error`)) {
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.#inFlight,
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
- void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal, onOutcome).catch(
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
- async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
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 (signal?.aborted) {
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 (!signal?.aborted) {
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
- try {
3340
- while (true) {
3341
- const result = await reader.read();
3342
- if (result.done) {
3343
- break;
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
- const chunk = decoder.decode(result.value, { stream: true });
3346
- if (isSse) {
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
- } finally {
3378
- signal?.removeEventListener("abort", onAbort);
3379
- reader.releaseLock();
3380
- }
3381
- accumulator.finish();
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 WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set(["local-key"]);
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.signal)).get(
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
- if (apiKey && isWellKnownDemoApiKey(apiKey)) {
3803
- throw new Error(
3804
- "Refusing to listen on a non-loopback host with a well-known demo HOOPILOT_API_KEY. Set a strong, unique API key."
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
- let text = "";
4202
+ const chunks = [];
4104
4203
  try {
4105
4204
  while (true) {
4106
4205
  const { done, value } = await reader.read();
4107
4206
  if (done) {
4108
- return `${text}${decoder.decode()}`;
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
- text += decoder.decode(value, { stream: true });
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 isWellKnownDemoApiKey(apiKey) {
4214
- return WELL_KNOWN_DEMO_API_KEYS.has(apiKey.trim().toLowerCase());
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, signal) {
4452
- const { copilot, error } = await readUsage(signal);
4453
- const proxy = metrics.snapshot();
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 { copilot: cache.value };
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
- return { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
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
- cache = { atMs: now(), value };
4599
+ const result = { copilot: value };
4600
+ cache = { atMs: now(), result };
4481
4601
  metrics.recordCopilotQuota(value);
4482
- return { copilot: value };
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
- return { error: errorMessage(error) };
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" : "linux";
4595
- const cpu = arch === "arm64" || arch === "aarch64" ? "arm64" : "x64";
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