@openhoo/hoopilot 1.0.0 → 1.1.0

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.cjs CHANGED
@@ -63,6 +63,7 @@ __export(index_exports, {
63
63
  observeResponseUsage: () => observeResponseUsage,
64
64
  parseLogFormat: () => parseLogFormat,
65
65
  parseLogLevel: () => parseLogLevel,
66
+ parseRateLimitHeaders: () => parseRateLimitHeaders,
66
67
  readStoredCopilotAuth: () => readStoredCopilotAuth,
67
68
  responsesCompactionResult: () => responsesCompactionResult,
68
69
  responsesRequestToChatCompletion: () => responsesRequestToChatCompletion,
@@ -1833,6 +1834,38 @@ function applyGithubApiHeaders(headers, token) {
1833
1834
  headers.set("x-github-api-version", COPILOT_USAGE_API_VERSION);
1834
1835
  return headers;
1835
1836
  }
1837
+ function parseRateLimitHeaders(headers, nowMs = Date.now()) {
1838
+ const limit = headerInt(headers, "x-ratelimit-limit");
1839
+ const remaining = headerInt(headers, "x-ratelimit-remaining");
1840
+ const used = headerInt(headers, "x-ratelimit-used");
1841
+ const resetEpochSeconds = headerInt(headers, "x-ratelimit-reset");
1842
+ const retryAfterSeconds = headerInt(headers, "retry-after");
1843
+ if (limit === void 0 && remaining === void 0 && used === void 0 && resetEpochSeconds === void 0 && retryAfterSeconds === void 0) {
1844
+ return void 0;
1845
+ }
1846
+ return removeUndefinedRateLimit({
1847
+ limit,
1848
+ observedAtMs: nowMs,
1849
+ remaining,
1850
+ resetEpochSeconds,
1851
+ resource: headers.get("x-ratelimit-resource")?.trim() || "unknown",
1852
+ retryAfterSeconds,
1853
+ used
1854
+ });
1855
+ }
1856
+ function headerInt(headers, name) {
1857
+ const raw = headers.get(name);
1858
+ if (raw === null) {
1859
+ return void 0;
1860
+ }
1861
+ const value = Number.parseInt(raw.trim(), 10);
1862
+ return Number.isFinite(value) && value >= 0 ? value : void 0;
1863
+ }
1864
+ function removeUndefinedRateLimit(rateLimit) {
1865
+ return Object.fromEntries(
1866
+ Object.entries(rateLimit).filter(([, value]) => value !== void 0)
1867
+ );
1868
+ }
1836
1869
  var CopilotClient = class {
1837
1870
  #auth;
1838
1871
  #allowUnsafeUpstream;
@@ -2249,6 +2282,7 @@ var DURATION_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
2249
2282
  var USAGE_BUFFER_LIMIT_BYTES = 16 * 1024 * 1024;
2250
2283
  var MAX_TRACKED_MODELS = 200;
2251
2284
  var MAX_MODEL_LABEL_LENGTH = 200;
2285
+ var MAX_TRACKED_RATELIMIT_RESOURCES = 32;
2252
2286
  var LABEL_SEPARATOR = "";
2253
2287
  var UNKNOWN_MODEL = "unknown";
2254
2288
  function emptyModelTotals() {
@@ -2262,6 +2296,7 @@ var MetricsRegistry = class {
2262
2296
  #tokens = /* @__PURE__ */ new Map();
2263
2297
  #upstream = /* @__PURE__ */ new Map();
2264
2298
  #copilotQuota;
2299
+ #githubRateLimit = /* @__PURE__ */ new Map();
2265
2300
  constructor(options = {}) {
2266
2301
  this.#startedAtMs = (options.now ?? Date.now)();
2267
2302
  }
@@ -2299,17 +2334,39 @@ var MetricsRegistry = class {
2299
2334
  recordCopilotQuota(usage) {
2300
2335
  this.#copilotQuota = usage;
2301
2336
  }
2302
- // Sanitize the model into a bounded, control-char-free label. The model can
2303
- // originate from a client request, so cap its length, strip characters that
2304
- // would corrupt the exposition format, and fold overflow past the cardinality
2305
- // limit into UNKNOWN_MODEL to keep the series count bounded.
2337
+ /**
2338
+ * Store the latest GitHub REST rate-limit budget, keyed by its resource bucket.
2339
+ * A no-op when `rateLimit` is undefined (the response carried no rate-limit
2340
+ * headers) so callers can pass {@link parseRateLimitHeaders} output directly.
2341
+ */
2342
+ recordGithubRateLimit(rateLimit) {
2343
+ if (!rateLimit) {
2344
+ return;
2345
+ }
2346
+ const resource = this.#rateLimitResource(rateLimit.resource);
2347
+ this.#githubRateLimit.set(resource, { ...rateLimit, resource });
2348
+ }
2349
+ // Sanitize the model into a bounded label. The model can originate from a
2350
+ // client request, so cap its length, strip characters that would corrupt the
2351
+ // exposition format, and fold overflow past the cardinality limit into
2352
+ // UNKNOWN_MODEL to keep the series count bounded.
2306
2353
  #modelLabel(model) {
2307
- const cleaned = model.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2354
+ const cleaned = cleanLabel(model).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2308
2355
  if (!this.#tokens.has(cleaned) && this.#tokens.size >= MAX_TRACKED_MODELS) {
2309
2356
  return UNKNOWN_MODEL;
2310
2357
  }
2311
2358
  return cleaned;
2312
2359
  }
2360
+ // The resource comes from a trusted upstream header, but clean and bound it
2361
+ // with the same discipline as model labels: strip control characters that
2362
+ // would corrupt the exposition format and fold overflow into "unknown".
2363
+ #rateLimitResource(resource) {
2364
+ const cleaned = cleanLabel(resource).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2365
+ if (!this.#githubRateLimit.has(cleaned) && this.#githubRateLimit.size >= MAX_TRACKED_RATELIMIT_RESOURCES) {
2366
+ return UNKNOWN_MODEL;
2367
+ }
2368
+ return cleaned;
2369
+ }
2313
2370
  #observeDuration(route, seconds) {
2314
2371
  const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
2315
2372
  const entry = this.#durations.get(route) ?? {
@@ -2354,7 +2411,12 @@ var MetricsRegistry = class {
2354
2411
  upstreamErrors += count;
2355
2412
  }
2356
2413
  }
2414
+ const githubRateLimit = {};
2415
+ for (const [resource, rateLimit] of this.#githubRateLimit) {
2416
+ githubRateLimit[resource] = toRateLimitSnapshot(rateLimit);
2417
+ }
2357
2418
  return {
2419
+ githubRateLimit,
2358
2420
  inFlight: this.#inFlight,
2359
2421
  requests: { byRoute, byStatus, total: requestsTotal },
2360
2422
  startedAt: new Date(this.#startedAtMs).toISOString(),
@@ -2425,10 +2487,43 @@ var MetricsRegistry = class {
2425
2487
  lines.push(`hoopilot_request_duration_seconds_sum${labels({ route })} ${entry.sum}`);
2426
2488
  lines.push(`hoopilot_request_duration_seconds_count${labels({ route })} ${entry.count}`);
2427
2489
  }
2490
+ this.#renderGithubRateLimit(lines);
2428
2491
  this.#renderCopilotQuota(lines);
2429
2492
  return `${lines.join("\n")}
2430
2493
  `;
2431
2494
  }
2495
+ #renderGithubRateLimit(lines) {
2496
+ const entries = [...this.#githubRateLimit.values()];
2497
+ if (entries.length === 0) {
2498
+ return;
2499
+ }
2500
+ const gauge = (suffix, help, pick) => {
2501
+ const present = entries.filter((rateLimit) => pick(rateLimit) !== void 0);
2502
+ if (present.length === 0) {
2503
+ return;
2504
+ }
2505
+ lines.push(`# HELP hoopilot_github_ratelimit_${suffix} ${help}`);
2506
+ lines.push(`# TYPE hoopilot_github_ratelimit_${suffix} gauge`);
2507
+ for (const rateLimit of present) {
2508
+ lines.push(
2509
+ `hoopilot_github_ratelimit_${suffix}${labels({ resource: rateLimit.resource })} ${pick(rateLimit)}`
2510
+ );
2511
+ }
2512
+ };
2513
+ gauge("limit", "GitHub REST API request ceiling for the resource window.", (r) => r.limit);
2514
+ gauge("remaining", "Requests remaining in the GitHub REST API window.", (r) => r.remaining);
2515
+ gauge("used", "Requests used in the GitHub REST API window.", (r) => r.used);
2516
+ gauge(
2517
+ "reset_timestamp_seconds",
2518
+ "Unix epoch when the GitHub REST API window resets.",
2519
+ (r) => r.resetEpochSeconds
2520
+ );
2521
+ gauge(
2522
+ "retry_after_seconds",
2523
+ "Seconds to wait after a GitHub secondary-limit response.",
2524
+ (r) => r.retryAfterSeconds
2525
+ );
2526
+ }
2432
2527
  #renderCopilotQuota(lines) {
2433
2528
  const usage = this.#copilotQuota;
2434
2529
  if (!usage) {
@@ -2669,6 +2764,37 @@ function modelText(value) {
2669
2764
  function nonNegative(value) {
2670
2765
  return Number.isFinite(value) && value > 0 ? value : 0;
2671
2766
  }
2767
+ function cleanLabel(value) {
2768
+ let result = "";
2769
+ for (const char of value) {
2770
+ const code = char.charCodeAt(0);
2771
+ if (code > 31 && code !== 127) {
2772
+ result += char;
2773
+ }
2774
+ }
2775
+ return result.trim();
2776
+ }
2777
+ function toRateLimitSnapshot(rateLimit) {
2778
+ const snapshot = {
2779
+ observedAt: new Date(rateLimit.observedAtMs).toISOString()
2780
+ };
2781
+ if (rateLimit.limit !== void 0) {
2782
+ snapshot.limit = rateLimit.limit;
2783
+ }
2784
+ if (rateLimit.remaining !== void 0) {
2785
+ snapshot.remaining = rateLimit.remaining;
2786
+ }
2787
+ if (rateLimit.used !== void 0) {
2788
+ snapshot.used = rateLimit.used;
2789
+ }
2790
+ if (rateLimit.resetEpochSeconds !== void 0) {
2791
+ snapshot.resetAt = new Date(rateLimit.resetEpochSeconds * 1e3).toISOString();
2792
+ }
2793
+ if (rateLimit.retryAfterSeconds !== void 0) {
2794
+ snapshot.retryAfterSeconds = rateLimit.retryAfterSeconds;
2795
+ }
2796
+ return snapshot;
2797
+ }
2672
2798
  function labelKey(...parts) {
2673
2799
  return parts.join(LABEL_SEPARATOR);
2674
2800
  }
@@ -3492,6 +3618,7 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
3492
3618
  try {
3493
3619
  const upstream = await client.usage(signal);
3494
3620
  metrics.recordUpstream(usagePath, upstream.ok);
3621
+ metrics.recordGithubRateLimit(parseRateLimitHeaders(upstream.headers, now()));
3495
3622
  if (!upstream.ok) {
3496
3623
  return { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
3497
3624
  }
@@ -3550,6 +3677,7 @@ function safeParseJson(text) {
3550
3677
  observeResponseUsage,
3551
3678
  parseLogFormat,
3552
3679
  parseLogLevel,
3680
+ parseRateLimitHeaders,
3553
3681
  readStoredCopilotAuth,
3554
3682
  responsesCompactionResult,
3555
3683
  responsesRequestToChatCompletion,