@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/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ declare class MetricsRegistry {
12
12
  now?: () => number;
13
13
  });
14
14
  /** Mark a request as started; pair with exactly one {@link observe}. */
15
- startRequest(): void;
15
+ startRequest(route?: string): void;
16
16
  /** Record a completed request and clear its in-flight slot. */
17
17
  observe(observation: RequestObservation): void;
18
18
  /**
@@ -35,16 +35,16 @@ declare class MetricsRegistry {
35
35
  */
36
36
  recordGithubRateLimit(rateLimit: GithubRateLimit | undefined): void;
37
37
  /** A JSON-friendly view of the current counters. */
38
- snapshot(now?: () => number): MetricsSnapshot;
38
+ snapshot(nowOrOptions?: (() => number) | MetricsSnapshotOptions): MetricsSnapshot;
39
39
  /** Render the Prometheus text exposition format (version 0.0.4). */
40
40
  renderPrometheus(now?: () => number): string;
41
41
  }
42
42
  /**
43
- * Tee `response`'s body so the client receives an unchanged copy while a
44
- * background reader extracts token usage. Returns a new Response carrying the
45
- * client-facing branch and the original status/headers. Usage extraction never
46
- * throws into the client stream: a parse failure or an aborted client simply
47
- * yields no usage. When the body is absent the response is returned untouched.
43
+ * Wrap `response`'s body so the client receives unchanged bytes while the same
44
+ * read pass extracts token usage. Returns a new Response carrying the observed
45
+ * body and the original status/headers. Usage extraction never throws into the
46
+ * client stream: a parse failure or an aborted client simply yields no usage.
47
+ * When the body is absent the response is returned untouched.
48
48
  *
49
49
  * Pass the request's `signal` so a client disconnect cancels the observer
50
50
  * branch; combined with the runtime cancelling the client branch, that releases
@@ -243,6 +243,12 @@ interface MetricsSnapshot {
243
243
  };
244
244
  uptimeSeconds: number;
245
245
  }
246
+ /** Options for deriving a JSON metrics snapshot without changing raw counters. */
247
+ interface MetricsSnapshotOptions {
248
+ excludeRoutes?: readonly string[];
249
+ excludeUpstreamPaths?: readonly string[];
250
+ now?: () => number;
251
+ }
246
252
  /** JSON body returned by the proxy's `/v1/usage` route. */
247
253
  interface UsageResponseBody {
248
254
  copilot: CopilotUsage | null;
package/dist/index.js CHANGED
@@ -2630,6 +2630,7 @@ var MetricsRegistry = class {
2630
2630
  #inFlight = 0;
2631
2631
  #requests = /* @__PURE__ */ new Map();
2632
2632
  #durations = /* @__PURE__ */ new Map();
2633
+ #inFlightByRoute = /* @__PURE__ */ new Map();
2633
2634
  #tokens = /* @__PURE__ */ new Map();
2634
2635
  #upstream = /* @__PURE__ */ new Map();
2635
2636
  #copilotQuota;
@@ -2639,14 +2640,23 @@ var MetricsRegistry = class {
2639
2640
  this.#startedAtMs = (options.now ?? Date.now)();
2640
2641
  }
2641
2642
  /** Mark a request as started; pair with exactly one {@link observe}. */
2642
- startRequest() {
2643
+ startRequest(route) {
2643
2644
  this.#inFlight += 1;
2645
+ if (route) {
2646
+ this.#inFlightByRoute.set(route, (this.#inFlightByRoute.get(route) ?? 0) + 1);
2647
+ }
2644
2648
  }
2645
2649
  /** Record a completed request and clear its in-flight slot. */
2646
2650
  observe(observation) {
2647
2651
  if (this.#inFlight > 0) {
2648
2652
  this.#inFlight -= 1;
2649
2653
  }
2654
+ const inFlightForRoute = this.#inFlightByRoute.get(observation.route) ?? 0;
2655
+ if (inFlightForRoute > 1) {
2656
+ this.#inFlightByRoute.set(observation.route, inFlightForRoute - 1);
2657
+ } else if (inFlightForRoute === 1) {
2658
+ this.#inFlightByRoute.delete(observation.route);
2659
+ }
2650
2660
  const key = labelKey(observation.route, observation.method, String(observation.status));
2651
2661
  this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
2652
2662
  this.#observeDuration(observation.route, observation.durationMs / 1e3);
@@ -2731,12 +2741,19 @@ var MetricsRegistry = class {
2731
2741
  this.#durations.set(route, entry);
2732
2742
  }
2733
2743
  /** A JSON-friendly view of the current counters. */
2734
- snapshot(now = Date.now) {
2744
+ snapshot(nowOrOptions = Date.now) {
2745
+ const options = typeof nowOrOptions === "function" ? { now: nowOrOptions } : nowOrOptions;
2746
+ const now = options.now ?? Date.now;
2747
+ const excludeRoutes = new Set(options.excludeRoutes ?? []);
2748
+ const excludeUpstreamPaths = new Set(options.excludeUpstreamPaths ?? []);
2735
2749
  const byRoute = {};
2736
2750
  const byStatus = {};
2737
2751
  let requestsTotal = 0;
2738
2752
  for (const [key, count] of this.#requests) {
2739
2753
  const [route = "", , status = ""] = key.split(LABEL_SEPARATOR);
2754
+ if (excludeRoutes.has(route)) {
2755
+ continue;
2756
+ }
2740
2757
  byRoute[route] = (byRoute[route] ?? 0) + count;
2741
2758
  byStatus[status] = (byStatus[status] ?? 0) + count;
2742
2759
  requestsTotal += count;
@@ -2754,8 +2771,12 @@ var MetricsRegistry = class {
2754
2771
  let upstreamTotal = 0;
2755
2772
  let upstreamErrors = 0;
2756
2773
  for (const [key, count] of this.#upstream) {
2774
+ const [path = "", outcome = ""] = key.split(LABEL_SEPARATOR);
2775
+ if (excludeUpstreamPaths.has(path)) {
2776
+ continue;
2777
+ }
2757
2778
  upstreamTotal += count;
2758
- if (key.endsWith(`${LABEL_SEPARATOR}error`)) {
2779
+ if (outcome === "error") {
2759
2780
  upstreamErrors += count;
2760
2781
  }
2761
2782
  }
@@ -2765,8 +2786,8 @@ var MetricsRegistry = class {
2765
2786
  }
2766
2787
  return {
2767
2788
  githubRateLimit,
2768
- inFlight: this.#inFlight,
2769
- latency: this.#latencySnapshot(),
2789
+ inFlight: this.#filteredInFlight(excludeRoutes),
2790
+ latency: this.#latencySnapshot(excludeRoutes),
2770
2791
  requests: { byRoute, byStatus, total: requestsTotal },
2771
2792
  startedAt: new Date(this.#startedAtMs).toISOString(),
2772
2793
  tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
@@ -2774,15 +2795,30 @@ var MetricsRegistry = class {
2774
2795
  uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
2775
2796
  };
2776
2797
  }
2798
+ #filteredInFlight(excludeRoutes) {
2799
+ if (excludeRoutes.size === 0) {
2800
+ return this.#inFlight;
2801
+ }
2802
+ let excluded = 0;
2803
+ for (const [route, count] of this.#inFlightByRoute) {
2804
+ if (excludeRoutes.has(route)) {
2805
+ excluded += count;
2806
+ }
2807
+ }
2808
+ return Math.max(0, this.#inFlight - excluded);
2809
+ }
2777
2810
  // Summarize the duration histogram into a JSON latency view: per-route count and
2778
2811
  // exact average, plus overall average and estimated p50/p95. The percentiles come
2779
2812
  // from the buckets aggregated across routes, so they share /metrics' resolution.
2780
- #latencySnapshot() {
2813
+ #latencySnapshot(excludeRoutes = /* @__PURE__ */ new Set()) {
2781
2814
  const byRoute = {};
2782
2815
  const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
2783
2816
  let totalCount = 0;
2784
2817
  let totalSum = 0;
2785
2818
  for (const [route, entry] of this.#durations) {
2819
+ if (excludeRoutes.has(route)) {
2820
+ continue;
2821
+ }
2786
2822
  byRoute[route] = {
2787
2823
  avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
2788
2824
  count: entry.count
@@ -3020,17 +3056,15 @@ function observeResponseUsage(response, fallbackModel, onUsage, signal, onOutcom
3020
3056
  if (!body) {
3021
3057
  return response;
3022
3058
  }
3023
- const [clientBranch, observerBranch] = body.tee();
3024
3059
  const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
3025
- void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal, onOutcome).catch(
3026
- () => {
3060
+ return new Response(
3061
+ streamWithUsageObservation(body, isSse, fallbackModel, onUsage, signal, onOutcome),
3062
+ {
3063
+ headers: response.headers,
3064
+ status: response.status,
3065
+ statusText: response.statusText
3027
3066
  }
3028
3067
  );
3029
- return new Response(clientBranch, {
3030
- headers: response.headers,
3031
- status: response.status,
3032
- statusText: response.statusText
3033
- });
3034
3068
  }
3035
3069
  function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome) {
3036
3070
  const accumulator = createUsageAccumulator(fallbackModel, onUsage, onOutcome);
@@ -3046,13 +3080,16 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome)
3046
3080
  }
3047
3081
  accumulator.finish();
3048
3082
  }
3049
- async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
3083
+ function streamWithUsageObservation(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
3050
3084
  const reader = stream.getReader();
3085
+ let aborted = signal?.aborted ?? false;
3086
+ let released = false;
3051
3087
  const onAbort = () => {
3088
+ aborted = true;
3052
3089
  reader.cancel().catch(() => {
3053
3090
  });
3054
3091
  };
3055
- if (signal?.aborted) {
3092
+ if (aborted) {
3056
3093
  reader.cancel().catch(() => {
3057
3094
  });
3058
3095
  } else {
@@ -3060,7 +3097,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
3060
3097
  }
3061
3098
  const decoder = new TextDecoder();
3062
3099
  const guardedOutcome = onOutcome ? (extracted) => {
3063
- if (!signal?.aborted) {
3100
+ if (!aborted) {
3064
3101
  onOutcome(extracted);
3065
3102
  }
3066
3103
  } : void 0;
@@ -3068,33 +3105,40 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
3068
3105
  let buffer = "";
3069
3106
  let bufferedBytes = 0;
3070
3107
  let overflowed = false;
3071
- try {
3072
- while (true) {
3073
- const result = await reader.read();
3074
- if (result.done) {
3075
- break;
3108
+ const release = () => {
3109
+ if (released) {
3110
+ return;
3111
+ }
3112
+ released = true;
3113
+ signal?.removeEventListener("abort", onAbort);
3114
+ reader.releaseLock();
3115
+ };
3116
+ const observeChunk = (chunkBytes) => {
3117
+ const chunk = decoder.decode(chunkBytes, { stream: true });
3118
+ if (isSse) {
3119
+ buffer += chunk;
3120
+ const lines = buffer.split(/\r?\n/);
3121
+ buffer = lines.pop() ?? "";
3122
+ for (const line of lines) {
3123
+ considerSseLine(line, accumulator.consider);
3076
3124
  }
3077
- const chunk = decoder.decode(result.value, { stream: true });
3078
- if (isSse) {
3079
- buffer += chunk;
3080
- const lines = buffer.split(/\r?\n/);
3081
- buffer = lines.pop() ?? "";
3082
- for (const line of lines) {
3083
- considerSseLine(line, accumulator.consider);
3084
- }
3085
- if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
3086
- buffer = "";
3087
- }
3088
- } else if (!overflowed) {
3089
- bufferedBytes += result.value.byteLength;
3090
- if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
3091
- overflowed = true;
3092
- buffer = "";
3093
- } else {
3094
- buffer += chunk;
3095
- }
3125
+ if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
3126
+ buffer = "";
3096
3127
  }
3128
+ return;
3129
+ }
3130
+ if (overflowed) {
3131
+ return;
3132
+ }
3133
+ bufferedBytes += chunkBytes.byteLength;
3134
+ if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
3135
+ overflowed = true;
3136
+ buffer = "";
3137
+ return;
3097
3138
  }
3139
+ buffer += chunk;
3140
+ };
3141
+ const finishObservation = () => {
3098
3142
  const finalBuffer = buffer + decoder.decode();
3099
3143
  if (isSse) {
3100
3144
  if (finalBuffer) {
@@ -3106,11 +3150,41 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
3106
3150
  accumulator.consider(parsed);
3107
3151
  }
3108
3152
  }
3109
- } finally {
3110
- signal?.removeEventListener("abort", onAbort);
3111
- reader.releaseLock();
3112
- }
3113
- accumulator.finish();
3153
+ if (!aborted) {
3154
+ safeFinishAccumulator(accumulator);
3155
+ }
3156
+ };
3157
+ return new ReadableStream({
3158
+ async pull(controller) {
3159
+ const result = await reader.read().catch((error) => {
3160
+ release();
3161
+ controller.error(error);
3162
+ return void 0;
3163
+ });
3164
+ if (!result) {
3165
+ return;
3166
+ }
3167
+ if (result.done) {
3168
+ finishObservation();
3169
+ controller.close();
3170
+ release();
3171
+ return;
3172
+ }
3173
+ try {
3174
+ observeChunk(result.value);
3175
+ } catch {
3176
+ }
3177
+ controller.enqueue(result.value);
3178
+ },
3179
+ async cancel(reason) {
3180
+ aborted = true;
3181
+ try {
3182
+ await reader.cancel(reason);
3183
+ } finally {
3184
+ release();
3185
+ }
3186
+ }
3187
+ });
3114
3188
  }
3115
3189
  function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
3116
3190
  let model = fallbackModel;
@@ -3135,6 +3209,12 @@ function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
3135
3209
  }
3136
3210
  };
3137
3211
  }
3212
+ function safeFinishAccumulator(accumulator) {
3213
+ try {
3214
+ accumulator.finish();
3215
+ } catch {
3216
+ }
3217
+ }
3138
3218
  function considerSseLine(line, consider) {
3139
3219
  const trimmed = line.trim();
3140
3220
  if (!trimmed.startsWith("data:")) {
@@ -3769,10 +3849,10 @@ footer.foot .end { margin-left:auto; }
3769
3849
  pollGen += 1; var myGen = pollGen;
3770
3850
  if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
3771
3851
  var ctrl = new AbortController(); inflightFetch = ctrl;
3772
- var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, 3000);
3852
+ var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, Math.max(10000, intervalMs * 2));
3773
3853
  var headers = { "accept":"application/json" };
3774
3854
  if (apiKey) headers["x-api-key"] = apiKey;
3775
- fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
3855
+ fetch("/v1/usage?view=dashboard", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
3776
3856
  clearTimeout(to);
3777
3857
  if (myGen !== pollGen) return null;
3778
3858
  if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
@@ -4065,13 +4145,33 @@ async function getVersion() {
4065
4145
  var DEFAULT_HOST = "127.0.0.1";
4066
4146
  var DEFAULT_PORT = 4141;
4067
4147
  var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
4068
- var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set(["local-key"]);
4148
+ var MIN_NON_LOOPBACK_API_KEY_LENGTH = 24;
4149
+ var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set([
4150
+ "changeme",
4151
+ "demo",
4152
+ "example",
4153
+ "hoopilot",
4154
+ "local-key",
4155
+ "password",
4156
+ "password123",
4157
+ "secret",
4158
+ "test"
4159
+ ]);
4069
4160
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
4070
4161
  var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
4071
4162
  var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
4072
4163
  var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
4073
4164
  var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
4074
4165
  var USAGE_CACHE_TTL_MS = 6e4;
4166
+ var DASHBOARD_USAGE_VIEW = "dashboard";
4167
+ var DASHBOARD_EXCLUDED_ROUTES = [
4168
+ "cors.preflight",
4169
+ "dashboard",
4170
+ "health",
4171
+ "metrics",
4172
+ "usage"
4173
+ ];
4174
+ var DASHBOARD_EXCLUDED_UPSTREAM_PATHS = ["/copilot_internal/user"];
4075
4175
  var RequestBodyTooLargeError = class extends Error {
4076
4176
  constructor() {
4077
4177
  super(REQUEST_TOO_LARGE_MESSAGE);
@@ -4124,7 +4224,7 @@ function createHoopilotHandler(options = {}) {
4124
4224
  requestId,
4125
4225
  route
4126
4226
  });
4127
- metrics.startRequest();
4227
+ metrics.startRequest(route);
4128
4228
  const origin = request.headers.get("origin")?.trim() || void 0;
4129
4229
  const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
4130
4230
  const inner = normalizeInnerRequest(request, apiPath, url);
@@ -4247,7 +4347,7 @@ function buildApp(deps) {
4247
4347
  }
4248
4348
  logger.error({ err: errorDetails(error), event: "http.request.failed" }, "request failed");
4249
4349
  return jsonError(500, "internal_error", message);
4250
- }).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(
4350
+ }).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(
4251
4351
  "/v1/models",
4252
4352
  ({ request }) => handleModels(client, metrics, request.signal, loggerFor(request))
4253
4353
  ).get("/v1/responses", () => websocketUnsupportedResponse()).post(
@@ -4343,10 +4443,9 @@ function startHoopilotServer(options = {}) {
4343
4443
  "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
4344
4444
  );
4345
4445
  }
4346
- if (apiKey && isWellKnownDemoApiKey(apiKey)) {
4347
- throw new Error(
4348
- "Refusing to listen on a non-loopback host with a well-known demo HOOPILOT_API_KEY. Set a strong, unique API key."
4349
- );
4446
+ const rejection = apiKey ? apiKeyRejectionReason(apiKey) : void 0;
4447
+ if (rejection) {
4448
+ throw new Error(`Refusing to listen on a non-loopback host: ${rejection}`);
4350
4449
  }
4351
4450
  }
4352
4451
  const server = Bun.serve({
@@ -4644,12 +4743,16 @@ async function readRequestText(request) {
4644
4743
  const reader = body.getReader();
4645
4744
  const decoder = new TextDecoder();
4646
4745
  let bytes = 0;
4647
- let text = "";
4746
+ const chunks = [];
4648
4747
  try {
4649
4748
  while (true) {
4650
4749
  const { done, value } = await reader.read();
4651
4750
  if (done) {
4652
- return `${text}${decoder.decode()}`;
4751
+ const tail = decoder.decode();
4752
+ if (tail) {
4753
+ chunks.push(tail);
4754
+ }
4755
+ return chunks.join("");
4653
4756
  }
4654
4757
  bytes += value.byteLength;
4655
4758
  if (bytes > MAX_REQUEST_BODY_BYTES) {
@@ -4657,7 +4760,7 @@ async function readRequestText(request) {
4657
4760
  });
4658
4761
  throw new RequestBodyTooLargeError();
4659
4762
  }
4660
- text += decoder.decode(value, { stream: true });
4763
+ chunks.push(decoder.decode(value, { stream: true }));
4661
4764
  }
4662
4765
  } finally {
4663
4766
  reader.releaseLock();
@@ -4754,8 +4857,18 @@ function resolveCorsAllowOrigin(origin, allowedOrigins) {
4754
4857
  }
4755
4858
  return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
4756
4859
  }
4757
- function isWellKnownDemoApiKey(apiKey) {
4758
- return WELL_KNOWN_DEMO_API_KEYS.has(apiKey.trim().toLowerCase());
4860
+ function apiKeyRejectionReason(apiKey) {
4861
+ const normalized = apiKey.trim();
4862
+ if (WELL_KNOWN_DEMO_API_KEYS.has(normalized.toLowerCase())) {
4863
+ return "HOOPILOT_API_KEY is a well-known demo value. Set a strong, unique API key.";
4864
+ }
4865
+ if (normalized.length < MIN_NON_LOOPBACK_API_KEY_LENGTH) {
4866
+ return `HOOPILOT_API_KEY must be at least ${MIN_NON_LOOPBACK_API_KEY_LENGTH} characters when listening on a non-loopback host.`;
4867
+ }
4868
+ if (/^(.)\1+$/.test(normalized)) {
4869
+ return "HOOPILOT_API_KEY must not be a repeated single character. Set a strong, unique API key.";
4870
+ }
4871
+ return void 0;
4759
4872
  }
4760
4873
  function isUpstreamAuthStatus(status) {
4761
4874
  return status === 401 || status === 403;
@@ -4992,9 +5105,13 @@ function dashboardResponse() {
4992
5105
  status: 200
4993
5106
  });
4994
5107
  }
4995
- async function handleUsage(metrics, readUsage, signal) {
4996
- const { copilot, error } = await readUsage(signal);
4997
- const proxy = metrics.snapshot();
5108
+ async function handleUsage(metrics, readUsage, request) {
5109
+ const view = new URL(request.url).searchParams.get("view");
5110
+ const { copilot, error } = await readUsage(request.signal);
5111
+ const proxy = view === DASHBOARD_USAGE_VIEW ? metrics.snapshot({
5112
+ excludeRoutes: DASHBOARD_EXCLUDED_ROUTES,
5113
+ excludeUpstreamPaths: DASHBOARD_EXCLUDED_UPSTREAM_PATHS
5114
+ }) : metrics.snapshot();
4998
5115
  const body = {
4999
5116
  copilot: copilot ?? null,
5000
5117
  object: "usage",
@@ -5011,25 +5128,30 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
5011
5128
  let cache;
5012
5129
  return async (signal) => {
5013
5130
  if (cache && now() - cache.atMs < ttlMs) {
5014
- return { copilot: cache.value };
5131
+ return cache.result;
5015
5132
  }
5016
5133
  try {
5017
5134
  const upstream = await client.usage(signal);
5018
5135
  metrics.recordUpstream(usagePath, upstream.ok);
5019
5136
  metrics.recordGithubRateLimit(parseRateLimitHeaders(upstream.headers, now()));
5020
5137
  if (!upstream.ok) {
5021
- return { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
5138
+ const result2 = { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
5139
+ cache = { atMs: now(), result: result2 };
5140
+ return result2;
5022
5141
  }
5023
5142
  const value = normalizeCopilotUsage(await upstream.json().catch(() => ({})));
5024
- cache = { atMs: now(), value };
5143
+ const result = { copilot: value };
5144
+ cache = { atMs: now(), result };
5025
5145
  metrics.recordCopilotQuota(value);
5026
- return { copilot: value };
5146
+ return result;
5027
5147
  } catch (error) {
5028
5148
  if (error instanceof CopilotAuthError) {
5029
5149
  return { error: error.message };
5030
5150
  }
5031
5151
  metrics.recordUpstream(usagePath, false);
5032
- return { error: errorMessage(error) };
5152
+ const result = { error: errorMessage(error) };
5153
+ cache = { atMs: now(), result };
5154
+ return result;
5033
5155
  }
5034
5156
  };
5035
5157
  }