@openhoo/hoopilot 0.10.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/codexx.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  buildCodexxInvocation,
4
4
  main,
5
5
  verifyCodexxModel
6
- } from "./chunk-7GSQVYYT.js";
6
+ } from "./chunk-JU6F5L34.js";
7
7
  export {
8
8
  buildCodexxInvocation,
9
9
  main,
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
  }
@@ -2694,7 +2820,8 @@ var IS_STANDALONE_BINARY = BAKED_VERSION !== void 0;
2694
2820
  // src/server.ts
2695
2821
  var DEFAULT_HOST = "127.0.0.1";
2696
2822
  var DEFAULT_PORT = 4141;
2697
- var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
2823
+ var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
2824
+ var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set(["local-key"]);
2698
2825
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
2699
2826
  var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
2700
2827
  var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
@@ -2710,6 +2837,7 @@ var RequestBodyTooLargeError = class extends Error {
2710
2837
  function createHoopilotHandler(options = {}) {
2711
2838
  const client = new CopilotClient(options);
2712
2839
  const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
2840
+ const allowedOrigins = parseAllowedOrigins(options.env);
2713
2841
  const logger = serverLogger(options);
2714
2842
  const metrics = options.metrics ?? new MetricsRegistry();
2715
2843
  const readUsage = createUsageReader(client, metrics);
@@ -2729,7 +2857,10 @@ function createHoopilotHandler(options = {}) {
2729
2857
  route
2730
2858
  });
2731
2859
  metrics.startRequest();
2860
+ const origin = request.headers.get("origin")?.trim() || void 0;
2861
+ const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
2732
2862
  const finish = (response) => finishResponse(response, {
2863
+ corsOrigin,
2733
2864
  logger: requestLogger,
2734
2865
  method: request.method,
2735
2866
  metrics,
@@ -2739,11 +2870,11 @@ function createHoopilotHandler(options = {}) {
2739
2870
  closeConnection: bufferProxyBodies,
2740
2871
  trackStreamingBody: !bufferProxyBodies
2741
2872
  });
2742
- const browserOrigin = forbiddenBrowserOrigin(request, apiKey);
2873
+ const browserOrigin = forbiddenBrowserOrigin(origin, request, allowedOrigins);
2743
2874
  if (browserOrigin) {
2744
2875
  requestLogger.warn(
2745
2876
  { event: "http.request.forbidden_origin", origin: browserOrigin },
2746
- "blocked unauthenticated browser-origin request"
2877
+ "blocked cross-origin browser request"
2747
2878
  );
2748
2879
  return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
2749
2880
  }
@@ -2869,10 +3000,17 @@ function startHoopilotServer(options = {}) {
2869
3000
  const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
2870
3001
  const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
2871
3002
  const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
2872
- if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
2873
- throw new Error(
2874
- "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
2875
- );
3003
+ if (!isLoopbackHost(host)) {
3004
+ if (!apiKey && !allowUnauthenticated) {
3005
+ throw new Error(
3006
+ "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
3007
+ );
3008
+ }
3009
+ if (apiKey && isWellKnownDemoApiKey(apiKey)) {
3010
+ throw new Error(
3011
+ "Refusing to listen on a non-loopback host with a well-known demo HOOPILOT_API_KEY. Set a strong, unique API key."
3012
+ );
3013
+ }
2876
3014
  }
2877
3015
  const server = Bun.serve({
2878
3016
  fetch: createHoopilotHandler({
@@ -3185,7 +3323,6 @@ function corsHeaders() {
3185
3323
  return {
3186
3324
  "access-control-allow-headers": "anthropic-beta, anthropic-dangerous-direct-browser-access, anthropic-version, authorization, content-type, x-api-key, x-request-id",
3187
3325
  "access-control-allow-methods": "GET, POST, OPTIONS",
3188
- "access-control-allow-origin": "*",
3189
3326
  "access-control-expose-headers": "x-request-id"
3190
3327
  };
3191
3328
  }
@@ -3197,17 +3334,34 @@ function isAuthorized(request, apiKey) {
3197
3334
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
3198
3335
  return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
3199
3336
  }
3200
- function forbiddenBrowserOrigin(request, apiKey) {
3201
- if (apiKey) {
3202
- return void 0;
3203
- }
3204
- const origin = request.headers.get("origin")?.trim();
3337
+ function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
3205
3338
  if (origin) {
3206
- return isLoopbackOrigin(origin) ? void 0 : origin;
3339
+ return isAllowedOrigin(origin, allowedOrigins) ? void 0 : origin;
3207
3340
  }
3208
3341
  const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
3209
3342
  return fetchSite === "cross-site" ? "cross-site" : void 0;
3210
3343
  }
3344
+ function parseAllowedOrigins(env) {
3345
+ const raw = envValue(env?.HOOPILOT_ALLOWED_ORIGINS);
3346
+ if (!raw) {
3347
+ return /* @__PURE__ */ new Set();
3348
+ }
3349
+ return new Set(
3350
+ raw.split(",").map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)
3351
+ );
3352
+ }
3353
+ function isAllowedOrigin(origin, allowedOrigins) {
3354
+ return isLoopbackOrigin(origin) || allowedOrigins.has(origin.toLowerCase());
3355
+ }
3356
+ function resolveCorsAllowOrigin(origin, allowedOrigins) {
3357
+ if (!origin) {
3358
+ return "*";
3359
+ }
3360
+ return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
3361
+ }
3362
+ function isWellKnownDemoApiKey(apiKey) {
3363
+ return WELL_KNOWN_DEMO_API_KEYS.has(apiKey.trim().toLowerCase());
3364
+ }
3211
3365
  function isUpstreamAuthStatus(status) {
3212
3366
  return status === 401 || status === 403;
3213
3367
  }
@@ -3267,7 +3421,12 @@ function shouldBufferProxyBodies(mode) {
3267
3421
  return process.platform === "win32" && IS_STANDALONE_BINARY;
3268
3422
  }
3269
3423
  function finishResponse(response, options) {
3270
- const withRequestId = responseWithRequestId(response, options.requestId, options.closeConnection);
3424
+ const withRequestId = responseWithRequestId(
3425
+ response,
3426
+ options.requestId,
3427
+ options.closeConnection,
3428
+ options.corsOrigin
3429
+ );
3271
3430
  const stream = isStreamingResponse(withRequestId);
3272
3431
  const status = withRequestId.status;
3273
3432
  const complete = () => {
@@ -3285,9 +3444,17 @@ function finishResponse(response, options) {
3285
3444
  complete();
3286
3445
  return withRequestId;
3287
3446
  }
3288
- function responseWithRequestId(response, requestId, closeConnection) {
3447
+ function responseWithRequestId(response, requestId, closeConnection, corsOrigin) {
3289
3448
  const headers = new Headers(response.headers);
3290
3449
  headers.set("x-request-id", requestId);
3450
+ if (corsOrigin) {
3451
+ headers.set("access-control-allow-origin", corsOrigin);
3452
+ if (corsOrigin !== "*") {
3453
+ headers.append("vary", "Origin");
3454
+ }
3455
+ } else {
3456
+ headers.delete("access-control-allow-origin");
3457
+ }
3291
3458
  if (closeConnection) {
3292
3459
  headers.set("connection", "close");
3293
3460
  }
@@ -3451,6 +3618,7 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
3451
3618
  try {
3452
3619
  const upstream = await client.usage(signal);
3453
3620
  metrics.recordUpstream(usagePath, upstream.ok);
3621
+ metrics.recordGithubRateLimit(parseRateLimitHeaders(upstream.headers, now()));
3454
3622
  if (!upstream.ok) {
3455
3623
  return { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
3456
3624
  }
@@ -3509,6 +3677,7 @@ function safeParseJson(text) {
3509
3677
  observeResponseUsage,
3510
3678
  parseLogFormat,
3511
3679
  parseLogLevel,
3680
+ parseRateLimitHeaders,
3512
3681
  readStoredCopilotAuth,
3513
3682
  responsesCompactionResult,
3514
3683
  responsesRequestToChatCompletion,