@openhoo/hoopilot 2.1.5 → 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,7 +35,7 @@ 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
  }
@@ -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
@@ -3813,10 +3849,10 @@ footer.foot .end { margin-left:auto; }
3813
3849
  pollGen += 1; var myGen = pollGen;
3814
3850
  if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
3815
3851
  var ctrl = new AbortController(); inflightFetch = ctrl;
3816
- 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));
3817
3853
  var headers = { "accept":"application/json" };
3818
3854
  if (apiKey) headers["x-api-key"] = apiKey;
3819
- 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){
3820
3856
  clearTimeout(to);
3821
3857
  if (myGen !== pollGen) return null;
3822
3858
  if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
@@ -4127,6 +4163,15 @@ var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
4127
4163
  var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
4128
4164
  var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
4129
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"];
4130
4175
  var RequestBodyTooLargeError = class extends Error {
4131
4176
  constructor() {
4132
4177
  super(REQUEST_TOO_LARGE_MESSAGE);
@@ -4179,7 +4224,7 @@ function createHoopilotHandler(options = {}) {
4179
4224
  requestId,
4180
4225
  route
4181
4226
  });
4182
- metrics.startRequest();
4227
+ metrics.startRequest(route);
4183
4228
  const origin = request.headers.get("origin")?.trim() || void 0;
4184
4229
  const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
4185
4230
  const inner = normalizeInnerRequest(request, apiPath, url);
@@ -4302,7 +4347,7 @@ function buildApp(deps) {
4302
4347
  }
4303
4348
  logger.error({ err: errorDetails(error), event: "http.request.failed" }, "request failed");
4304
4349
  return jsonError(500, "internal_error", message);
4305
- }).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(
4306
4351
  "/v1/models",
4307
4352
  ({ request }) => handleModels(client, metrics, request.signal, loggerFor(request))
4308
4353
  ).get("/v1/responses", () => websocketUnsupportedResponse()).post(
@@ -5060,9 +5105,13 @@ function dashboardResponse() {
5060
5105
  status: 200
5061
5106
  });
5062
5107
  }
5063
- async function handleUsage(metrics, readUsage, signal) {
5064
- const { copilot, error } = await readUsage(signal);
5065
- 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();
5066
5115
  const body = {
5067
5116
  copilot: copilot ?? null,
5068
5117
  object: "usage",
@@ -5079,25 +5128,30 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
5079
5128
  let cache;
5080
5129
  return async (signal) => {
5081
5130
  if (cache && now() - cache.atMs < ttlMs) {
5082
- return { copilot: cache.value };
5131
+ return cache.result;
5083
5132
  }
5084
5133
  try {
5085
5134
  const upstream = await client.usage(signal);
5086
5135
  metrics.recordUpstream(usagePath, upstream.ok);
5087
5136
  metrics.recordGithubRateLimit(parseRateLimitHeaders(upstream.headers, now()));
5088
5137
  if (!upstream.ok) {
5089
- 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;
5090
5141
  }
5091
5142
  const value = normalizeCopilotUsage(await upstream.json().catch(() => ({})));
5092
- cache = { atMs: now(), value };
5143
+ const result = { copilot: value };
5144
+ cache = { atMs: now(), result };
5093
5145
  metrics.recordCopilotQuota(value);
5094
- return { copilot: value };
5146
+ return result;
5095
5147
  } catch (error) {
5096
5148
  if (error instanceof CopilotAuthError) {
5097
5149
  return { error: error.message };
5098
5150
  }
5099
5151
  metrics.recordUpstream(usagePath, false);
5100
- return { error: errorMessage(error) };
5152
+ const result = { error: errorMessage(error) };
5153
+ cache = { atMs: now(), result };
5154
+ return result;
5101
5155
  }
5102
5156
  };
5103
5157
  }