@openhoo/hoopilot 0.6.0 → 0.7.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/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { spawn } from "child_process";
5
5
 
6
6
  // src/auth-store.ts
7
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
7
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
8
8
  import { dirname, join } from "path";
9
9
  function authStorePath(env = process.env) {
10
10
  if (env.HOOPILOT_AUTH_FILE) {
@@ -36,25 +36,36 @@ function readStoredCopilotAuth(path = authStorePath()) {
36
36
  }
37
37
  function writeStoredCopilotAuth(auth, path = authStorePath()) {
38
38
  mkdirSync(dirname(path), { recursive: true });
39
- writeFileSync(
40
- path,
41
- `${JSON.stringify(
42
- {
43
- ...auth,
44
- createdAt: auth.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
45
- },
46
- null,
47
- 2
48
- )}
49
- `,
50
- { mode: 384 }
51
- );
39
+ const data = `${JSON.stringify(
40
+ {
41
+ ...auth,
42
+ createdAt: auth.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
43
+ },
44
+ null,
45
+ 2
46
+ )}
47
+ `;
48
+ const tmpPath = `${path}.${process.pid}.tmp`;
49
+ writeFileSync(tmpPath, data, { mode: 384 });
50
+ renameSync(tmpPath, path);
52
51
  try {
53
52
  chmodSync(path, 384);
54
53
  } catch {
55
54
  }
56
55
  }
57
56
 
57
+ // src/util.ts
58
+ function trimTrailingSlash(value) {
59
+ return value.replace(/\/+$/, "");
60
+ }
61
+ async function truncatedResponseText(response, max = 500) {
62
+ const text = await response.text();
63
+ return text.slice(0, max);
64
+ }
65
+ function asRecord(value) {
66
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
67
+ }
68
+
58
69
  // src/auth.ts
59
70
  var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
60
71
  var REFRESH_SKEW_MS = 6e4;
@@ -97,17 +108,59 @@ var CopilotAuth = class {
97
108
  return access;
98
109
  }
99
110
  };
100
- function trimTrailingSlash(value) {
101
- return value.replace(/\/+$/, "");
102
- }
103
111
 
104
112
  // src/copilot.ts
113
+ var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
114
+ var COPILOT_USAGE_API_VERSION = "2025-04-01";
115
+ function applyCopilotHeaders(headers, token) {
116
+ headers.set("accept", headers.get("accept") ?? "application/json");
117
+ headers.set("authorization", `Bearer ${token}`);
118
+ headers.set("copilot-integration-id", "vscode-chat");
119
+ headers.set("editor-plugin-version", "hoopilot/0.1.0");
120
+ headers.set("editor-version", "Hoopilot/0.1.0");
121
+ headers.set("openai-intent", "conversation-panel");
122
+ headers.set("user-agent", "hoopilot/0.1.0");
123
+ headers.set("x-github-api-version", "2026-06-01");
124
+ return headers;
125
+ }
126
+ function applyGithubApiHeaders(headers, token) {
127
+ headers.set("accept", headers.get("accept") ?? "application/json");
128
+ headers.set("authorization", `token ${token}`);
129
+ headers.set("editor-plugin-version", "hoopilot/0.1.0");
130
+ headers.set("editor-version", "Hoopilot/0.1.0");
131
+ headers.set("user-agent", "hoopilot/0.1.0");
132
+ headers.set("x-github-api-version", COPILOT_USAGE_API_VERSION);
133
+ return headers;
134
+ }
105
135
  var CopilotClient = class {
106
136
  #auth;
107
137
  #fetch;
138
+ #githubApiBaseUrl;
108
139
  constructor(options = {}) {
109
140
  this.#auth = new CopilotAuth(options);
110
141
  this.#fetch = options.fetch ?? fetch;
142
+ this.#githubApiBaseUrl = trimTrailingSlash(
143
+ options.githubApiBaseUrl ?? options.env?.HOOPILOT_GITHUB_API_BASE_URL ?? DEFAULT_GITHUB_API_BASE_URL
144
+ );
145
+ }
146
+ /**
147
+ * Fetch the Copilot account's quota / premium-request usage from the GitHub
148
+ * REST `copilot_internal/user` endpoint. The stored device-flow OAuth token is
149
+ * accepted directly here — no Copilot token exchange is required to read quota.
150
+ */
151
+ async usage(signal) {
152
+ if (!isHttpsOrLoopback(this.#githubApiBaseUrl)) {
153
+ throw new Error(
154
+ `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${this.#githubApiBaseUrl}`
155
+ );
156
+ }
157
+ const access = await this.#auth.getAccess();
158
+ const headers = applyGithubApiHeaders(new Headers(), access.token);
159
+ return this.#fetch(`${this.#githubApiBaseUrl}/copilot_internal/user`, {
160
+ headers,
161
+ method: "GET",
162
+ signal
163
+ });
111
164
  }
112
165
  async chatCompletions(body, signal) {
113
166
  return this.fetchCopilot("/chat/completions", {
@@ -140,21 +193,88 @@ var CopilotClient = class {
140
193
  }
141
194
  async fetchCopilot(path, init) {
142
195
  const access = await this.#auth.getAccess();
143
- const headers = new Headers(init.headers);
144
- headers.set("accept", headers.get("accept") ?? "application/json");
145
- headers.set("authorization", `Bearer ${access.token}`);
146
- headers.set("copilot-integration-id", "vscode-chat");
147
- headers.set("editor-plugin-version", "hoopilot/0.1.0");
148
- headers.set("editor-version", "Hoopilot/0.1.0");
149
- headers.set("openai-intent", "conversation-panel");
150
- headers.set("user-agent", "hoopilot/0.1.0");
151
- headers.set("x-github-api-version", "2026-06-01");
196
+ const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
152
197
  return this.#fetch(`${access.apiBaseUrl}${path}`, {
153
198
  ...init,
154
199
  headers
155
200
  });
156
201
  }
157
202
  };
203
+ function normalizeCopilotUsage(body) {
204
+ const record = asRecord(body);
205
+ const quotas = {};
206
+ const snapshots = asRecord(record.quota_snapshots);
207
+ for (const [category, detail] of Object.entries(snapshots)) {
208
+ quotas[category] = normalizeQuotaDetail(asRecord(detail));
209
+ }
210
+ if (Object.keys(quotas).length === 0) {
211
+ const remaining = asRecord(record.limited_user_quotas);
212
+ const monthly = asRecord(record.monthly_quotas);
213
+ for (const category of /* @__PURE__ */ new Set([...Object.keys(remaining), ...Object.keys(monthly)])) {
214
+ const entitlement = numberOrUndefined(monthly[category]);
215
+ const left = numberOrUndefined(remaining[category]);
216
+ quotas[category] = removeUndefinedQuota({
217
+ entitlement,
218
+ percentRemaining: entitlement !== void 0 && entitlement > 0 && left !== void 0 ? left / entitlement * 100 : void 0,
219
+ remaining: left,
220
+ used: usedFrom(entitlement, left)
221
+ });
222
+ }
223
+ }
224
+ return removeUndefinedUsage({
225
+ accessTypeSku: stringOrUndefined(record.access_type_sku),
226
+ chatEnabled: typeof record.chat_enabled === "boolean" ? record.chat_enabled : void 0,
227
+ plan: stringOrUndefined(record.copilot_plan),
228
+ quotaResetDate: stringOrUndefined(record.quota_reset_date) ?? stringOrUndefined(record.quota_reset_date_utc) ?? stringOrUndefined(record.limited_user_reset_date),
229
+ quotas
230
+ });
231
+ }
232
+ function normalizeQuotaDetail(detail) {
233
+ const entitlement = numberOrUndefined(detail.entitlement);
234
+ const remaining = numberOrUndefined(detail.remaining) ?? numberOrUndefined(detail.quota_remaining);
235
+ return removeUndefinedQuota({
236
+ entitlement,
237
+ overageCount: numberOrUndefined(detail.overage_count),
238
+ overagePermitted: typeof detail.overage_permitted === "boolean" ? detail.overage_permitted : void 0,
239
+ percentRemaining: numberOrUndefined(detail.percent_remaining),
240
+ remaining,
241
+ unlimited: typeof detail.unlimited === "boolean" ? detail.unlimited : void 0,
242
+ used: usedFrom(entitlement, remaining)
243
+ });
244
+ }
245
+ function usedFrom(entitlement, remaining) {
246
+ if (entitlement === void 0 || remaining === void 0) {
247
+ return void 0;
248
+ }
249
+ return Math.max(0, entitlement - remaining);
250
+ }
251
+ function isHttpsOrLoopback(rawUrl) {
252
+ let url;
253
+ try {
254
+ url = new URL(rawUrl);
255
+ } catch {
256
+ return false;
257
+ }
258
+ if (url.protocol === "https:") {
259
+ return true;
260
+ }
261
+ return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1");
262
+ }
263
+ function numberOrUndefined(value) {
264
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
265
+ }
266
+ function stringOrUndefined(value) {
267
+ return typeof value === "string" && value.length > 0 ? value : void 0;
268
+ }
269
+ function removeUndefinedQuota(quota) {
270
+ return Object.fromEntries(
271
+ Object.entries(quota).filter(([, value]) => value !== void 0)
272
+ );
273
+ }
274
+ function removeUndefinedUsage(usage) {
275
+ const entries = Object.entries(usage).filter(([, value]) => value !== void 0);
276
+ return Object.fromEntries(entries);
277
+ }
158
278
 
159
279
  // src/github-device.ts
160
280
  import { setTimeout as sleep } from "timers/promises";
@@ -162,6 +282,7 @@ var DEFAULT_GITHUB_COPILOT_CLIENT_ID = "Ov23li8tweQw6odWQebz";
162
282
  var DEFAULT_GITHUB_DOMAIN = "github.com";
163
283
  var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
164
284
  var POLLING_SAFETY_MARGIN_MS = 3e3;
285
+ var REQUEST_TIMEOUT_MS = 15e3;
165
286
  async function githubCopilotDeviceLogin(options = {}) {
166
287
  const env = options.env ?? process.env;
167
288
  const fetcher = options.fetch ?? fetch;
@@ -196,16 +317,20 @@ async function requestDeviceCode(fetcher, domain, clientId) {
196
317
  scope: "read:user"
197
318
  }),
198
319
  headers: oauthHeaders(),
199
- method: "POST"
320
+ method: "POST",
321
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
200
322
  });
201
323
  if (!response.ok) {
202
324
  throw new Error(
203
- `GitHub device authorization failed with ${response.status}: ${await safeResponseText(
325
+ `GitHub device authorization failed with ${response.status}: ${await truncatedResponseText(
204
326
  response
205
327
  )}`
206
328
  );
207
329
  }
208
- return await response.json();
330
+ return parseJsonResponse(
331
+ response,
332
+ "GitHub device authorization response was not valid JSON"
333
+ );
209
334
  }
210
335
  async function pollForAccessToken(fetcher, sleeper, domain, clientId, device) {
211
336
  let intervalMs = device.interval * 1e3 + POLLING_SAFETY_MARGIN_MS;
@@ -219,16 +344,20 @@ async function pollForAccessToken(fetcher, sleeper, domain, clientId, device) {
219
344
  grant_type: DEVICE_GRANT_TYPE
220
345
  }),
221
346
  headers: oauthHeaders(),
222
- method: "POST"
347
+ method: "POST",
348
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
223
349
  });
224
350
  if (!response.ok) {
225
351
  throw new Error(
226
- `GitHub device token exchange failed with ${response.status}: ${await safeResponseText(
352
+ `GitHub device token exchange failed with ${response.status}: ${await truncatedResponseText(
227
353
  response
228
354
  )}`
229
355
  );
230
356
  }
231
- const data = await response.json();
357
+ const data = await parseJsonResponse(
358
+ response,
359
+ "GitHub device token response was not valid JSON"
360
+ );
232
361
  if (data.access_token) {
233
362
  return data.access_token;
234
363
  }
@@ -264,9 +393,13 @@ function normalizeDomain(value) {
264
393
  function positiveSeconds(value, fallback) {
265
394
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
266
395
  }
267
- async function safeResponseText(response) {
396
+ async function parseJsonResponse(response, context) {
268
397
  const text = await response.text();
269
- return text.slice(0, 500);
398
+ try {
399
+ return JSON.parse(text);
400
+ } catch {
401
+ throw new Error(`${context}: ${text.slice(0, 500)}`);
402
+ }
270
403
  }
271
404
 
272
405
  // src/logger.ts
@@ -369,6 +502,16 @@ function shouldCreateLogger(options) {
369
502
  options.logger || options.logFormat || options.logLevel || options.env?.HOOPILOT_LOG_FORMAT || options.env?.HOOPILOT_LOG_LEVEL
370
503
  );
371
504
  }
505
+ function errorDetails(error) {
506
+ if (error instanceof Error) {
507
+ return {
508
+ message: error.message,
509
+ name: error.name,
510
+ stack: error.stack
511
+ };
512
+ }
513
+ return { message: String(error) };
514
+ }
372
515
  function isLogFormat(value) {
373
516
  return LOG_FORMATS.includes(value);
374
517
  }
@@ -469,6 +612,40 @@ function contentToText(content) {
469
612
  }
470
613
  return "";
471
614
  }
615
+ function extractTokenUsage(usage) {
616
+ const record = asRecord(usage);
617
+ const prompt = firstNumber(record.prompt_tokens, record.input_tokens);
618
+ const completion = firstNumber(record.completion_tokens, record.output_tokens);
619
+ const total = firstNumber(record.total_tokens);
620
+ if (prompt === void 0 && completion === void 0 && total === void 0) {
621
+ return void 0;
622
+ }
623
+ const promptTokens = prompt ?? 0;
624
+ const completionTokens = completion ?? 0;
625
+ const reasoning = firstNumber(
626
+ asRecord(record.completion_tokens_details).reasoning_tokens,
627
+ asRecord(record.output_tokens_details).reasoning_tokens
628
+ );
629
+ const cached = firstNumber(
630
+ asRecord(record.prompt_tokens_details).cached_tokens,
631
+ asRecord(record.input_tokens_details).cached_tokens
632
+ );
633
+ return removeUndefined({
634
+ cachedTokens: cached,
635
+ completionTokens,
636
+ promptTokens,
637
+ reasoningTokens: reasoning,
638
+ totalTokens: total ?? promptTokens + completionTokens
639
+ });
640
+ }
641
+ function firstNumber(...values) {
642
+ for (const value of values) {
643
+ if (typeof value === "number" && Number.isFinite(value)) {
644
+ return value;
645
+ }
646
+ }
647
+ return void 0;
648
+ }
472
649
  function firstChoice(completion) {
473
650
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
474
651
  return asRecord(choices[0]);
@@ -476,9 +653,6 @@ function firstChoice(completion) {
476
653
  function removeUndefined(record) {
477
654
  return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
478
655
  }
479
- function asRecord(value) {
480
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
481
- }
482
656
  function randomId() {
483
657
  return crypto.randomUUID().replaceAll("-", "");
484
658
  }
@@ -486,104 +660,449 @@ function epochSeconds() {
486
660
  return Math.floor(Date.now() / 1e3);
487
661
  }
488
662
 
663
+ // src/metrics.ts
664
+ var PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
665
+ var DURATION_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
666
+ var USAGE_BUFFER_LIMIT_BYTES = 16 * 1024 * 1024;
667
+ var MAX_TRACKED_MODELS = 200;
668
+ var MAX_MODEL_LABEL_LENGTH = 200;
669
+ var LABEL_SEPARATOR = "";
670
+ var UNKNOWN_MODEL = "unknown";
671
+ function emptyModelTotals() {
672
+ return { cached: 0, completion: 0, prompt: 0, reasoning: 0, requests: 0, total: 0 };
673
+ }
674
+ var MetricsRegistry = class {
675
+ #startedAtMs;
676
+ #inFlight = 0;
677
+ #requests = /* @__PURE__ */ new Map();
678
+ #durations = /* @__PURE__ */ new Map();
679
+ #tokens = /* @__PURE__ */ new Map();
680
+ #upstream = /* @__PURE__ */ new Map();
681
+ #copilotQuota;
682
+ constructor(options = {}) {
683
+ this.#startedAtMs = (options.now ?? Date.now)();
684
+ }
685
+ /** Mark a request as started; pair with exactly one {@link observe}. */
686
+ startRequest() {
687
+ this.#inFlight += 1;
688
+ }
689
+ /** Record a completed request and clear its in-flight slot. */
690
+ observe(observation) {
691
+ if (this.#inFlight > 0) {
692
+ this.#inFlight -= 1;
693
+ }
694
+ const key = labelKey(observation.route, observation.method, String(observation.status));
695
+ this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
696
+ this.#observeDuration(observation.route, observation.durationMs / 1e3);
697
+ }
698
+ /** Accumulate token counts for a model from one upstream completion. */
699
+ recordTokens(model, usage) {
700
+ const name = this.#modelLabel(model);
701
+ const totals = this.#tokens.get(name) ?? emptyModelTotals();
702
+ totals.requests += 1;
703
+ totals.prompt += nonNegative(usage.promptTokens);
704
+ totals.completion += nonNegative(usage.completionTokens);
705
+ totals.total += nonNegative(usage.totalTokens);
706
+ totals.reasoning += nonNegative(usage.reasoningTokens ?? 0);
707
+ totals.cached += nonNegative(usage.cachedTokens ?? 0);
708
+ this.#tokens.set(name, totals);
709
+ }
710
+ /** Record one upstream Copilot call and whether it succeeded. */
711
+ recordUpstream(path, ok) {
712
+ const key = labelKey(path, ok ? "ok" : "error");
713
+ this.#upstream.set(key, (this.#upstream.get(key) ?? 0) + 1);
714
+ }
715
+ /** Store the latest Copilot quota so /metrics can expose it as gauges. */
716
+ recordCopilotQuota(usage) {
717
+ this.#copilotQuota = usage;
718
+ }
719
+ // Sanitize the model into a bounded, control-char-free label. The model can
720
+ // originate from a client request, so cap its length, strip characters that
721
+ // would corrupt the exposition format, and fold overflow past the cardinality
722
+ // limit into UNKNOWN_MODEL to keep the series count bounded.
723
+ #modelLabel(model) {
724
+ const cleaned = model.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
725
+ if (!this.#tokens.has(cleaned) && this.#tokens.size >= MAX_TRACKED_MODELS) {
726
+ return UNKNOWN_MODEL;
727
+ }
728
+ return cleaned;
729
+ }
730
+ #observeDuration(route, seconds) {
731
+ const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
732
+ const entry = this.#durations.get(route) ?? {
733
+ buckets: new Array(DURATION_BUCKETS_SECONDS.length).fill(0),
734
+ count: 0,
735
+ sum: 0
736
+ };
737
+ entry.count += 1;
738
+ entry.sum += value;
739
+ const index = DURATION_BUCKETS_SECONDS.findIndex((bound) => value <= bound);
740
+ if (index !== -1) {
741
+ entry.buckets[index] = (entry.buckets[index] ?? 0) + 1;
742
+ }
743
+ this.#durations.set(route, entry);
744
+ }
745
+ /** A JSON-friendly view of the current counters. */
746
+ snapshot(now = Date.now) {
747
+ const byRoute = {};
748
+ const byStatus = {};
749
+ let requestsTotal = 0;
750
+ for (const [key, count] of this.#requests) {
751
+ const [route = "", , status = ""] = key.split(LABEL_SEPARATOR);
752
+ byRoute[route] = (byRoute[route] ?? 0) + count;
753
+ byStatus[status] = (byStatus[status] ?? 0) + count;
754
+ requestsTotal += count;
755
+ }
756
+ const byModel = {};
757
+ const tokenTotals = { cached: 0, completion: 0, prompt: 0, reasoning: 0, total: 0 };
758
+ for (const [model, totals] of this.#tokens) {
759
+ byModel[model] = { ...totals };
760
+ tokenTotals.prompt += totals.prompt;
761
+ tokenTotals.completion += totals.completion;
762
+ tokenTotals.total += totals.total;
763
+ tokenTotals.reasoning += totals.reasoning;
764
+ tokenTotals.cached += totals.cached;
765
+ }
766
+ let upstreamTotal = 0;
767
+ let upstreamErrors = 0;
768
+ for (const [key, count] of this.#upstream) {
769
+ upstreamTotal += count;
770
+ if (key.endsWith(`${LABEL_SEPARATOR}error`)) {
771
+ upstreamErrors += count;
772
+ }
773
+ }
774
+ return {
775
+ inFlight: this.#inFlight,
776
+ requests: { byRoute, byStatus, total: requestsTotal },
777
+ startedAt: new Date(this.#startedAtMs).toISOString(),
778
+ tokens: { byModel, ...tokenTotals },
779
+ upstream: { errors: upstreamErrors, total: upstreamTotal },
780
+ uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
781
+ };
782
+ }
783
+ /** Render the Prometheus text exposition format (version 0.0.4). */
784
+ renderPrometheus(now = Date.now) {
785
+ const lines = [];
786
+ lines.push("# HELP hoopilot_process_start_time_seconds Unix epoch when the proxy started.");
787
+ lines.push("# TYPE hoopilot_process_start_time_seconds gauge");
788
+ lines.push(`hoopilot_process_start_time_seconds ${this.#startedAtMs / 1e3}`);
789
+ lines.push("# HELP hoopilot_uptime_seconds Seconds since the proxy started.");
790
+ lines.push("# TYPE hoopilot_uptime_seconds gauge");
791
+ lines.push(`hoopilot_uptime_seconds ${Math.max(0, (now() - this.#startedAtMs) / 1e3)}`);
792
+ lines.push("# HELP hoopilot_requests_in_flight Requests currently being served.");
793
+ lines.push("# TYPE hoopilot_requests_in_flight gauge");
794
+ lines.push(`hoopilot_requests_in_flight ${this.#inFlight}`);
795
+ lines.push("# HELP hoopilot_requests_total Completed requests by route, method, and status.");
796
+ lines.push("# TYPE hoopilot_requests_total counter");
797
+ for (const [key, count] of this.#requests) {
798
+ const [route = "", method = "", status = ""] = key.split(LABEL_SEPARATOR);
799
+ lines.push(`hoopilot_requests_total${labels({ method, route, status })} ${count}`);
800
+ }
801
+ lines.push(
802
+ "# HELP hoopilot_upstream_requests_total Copilot upstream calls by path and outcome."
803
+ );
804
+ lines.push("# TYPE hoopilot_upstream_requests_total counter");
805
+ for (const [key, count] of this.#upstream) {
806
+ const [path = "", outcome = ""] = key.split(LABEL_SEPARATOR);
807
+ lines.push(`hoopilot_upstream_requests_total${labels({ outcome, path })} ${count}`);
808
+ }
809
+ lines.push(
810
+ "# HELP hoopilot_tokens_total Tokens reported by upstream usage, by model and type."
811
+ );
812
+ lines.push("# TYPE hoopilot_tokens_total counter");
813
+ for (const [model, totals] of this.#tokens) {
814
+ lines.push(`hoopilot_tokens_total${labels({ model, type: "prompt" })} ${totals.prompt}`);
815
+ lines.push(
816
+ `hoopilot_tokens_total${labels({ model, type: "completion" })} ${totals.completion}`
817
+ );
818
+ lines.push(
819
+ `hoopilot_tokens_total${labels({ model, type: "reasoning" })} ${totals.reasoning}`
820
+ );
821
+ lines.push(`hoopilot_tokens_total${labels({ model, type: "cached" })} ${totals.cached}`);
822
+ }
823
+ lines.push("# HELP hoopilot_model_requests_total Completions with usage observed, by model.");
824
+ lines.push("# TYPE hoopilot_model_requests_total counter");
825
+ for (const [model, totals] of this.#tokens) {
826
+ lines.push(`hoopilot_model_requests_total${labels({ model })} ${totals.requests}`);
827
+ }
828
+ lines.push("# HELP hoopilot_request_duration_seconds Request duration by route.");
829
+ lines.push("# TYPE hoopilot_request_duration_seconds histogram");
830
+ for (const [route, entry] of this.#durations) {
831
+ let cumulative = 0;
832
+ for (let i = 0; i < DURATION_BUCKETS_SECONDS.length; i += 1) {
833
+ cumulative += entry.buckets[i] ?? 0;
834
+ const le = formatNumber(DURATION_BUCKETS_SECONDS[i] ?? 0);
835
+ lines.push(
836
+ `hoopilot_request_duration_seconds_bucket${labels({ le, route })} ${cumulative}`
837
+ );
838
+ }
839
+ lines.push(
840
+ `hoopilot_request_duration_seconds_bucket${labels({ le: "+Inf", route })} ${entry.count}`
841
+ );
842
+ lines.push(`hoopilot_request_duration_seconds_sum${labels({ route })} ${entry.sum}`);
843
+ lines.push(`hoopilot_request_duration_seconds_count${labels({ route })} ${entry.count}`);
844
+ }
845
+ this.#renderCopilotQuota(lines);
846
+ return `${lines.join("\n")}
847
+ `;
848
+ }
849
+ #renderCopilotQuota(lines) {
850
+ const usage = this.#copilotQuota;
851
+ if (!usage) {
852
+ return;
853
+ }
854
+ const categories = Object.entries(usage.quotas);
855
+ const gauge = (suffix, help, pick) => {
856
+ const present = categories.filter(([, quota]) => pick(quota) !== void 0);
857
+ if (present.length === 0) {
858
+ return;
859
+ }
860
+ lines.push(`# HELP hoopilot_copilot_quota_${suffix} ${help}`);
861
+ lines.push(`# TYPE hoopilot_copilot_quota_${suffix} gauge`);
862
+ for (const [category, quota] of present) {
863
+ lines.push(`hoopilot_copilot_quota_${suffix}${labels({ category })} ${pick(quota)}`);
864
+ }
865
+ };
866
+ gauge("remaining", "Remaining quota for the Copilot category.", (q) => q.remaining);
867
+ gauge("entitlement", "Quota entitlement for the Copilot category.", (q) => q.entitlement);
868
+ gauge("used", "Used quota (entitlement minus remaining) for the category.", (q) => q.used);
869
+ gauge(
870
+ "percent_remaining",
871
+ "Percent of quota remaining for the Copilot category.",
872
+ (q) => q.percentRemaining
873
+ );
874
+ const resetMs = usage.quotaResetDate ? Date.parse(usage.quotaResetDate) : Number.NaN;
875
+ if (Number.isFinite(resetMs)) {
876
+ lines.push(
877
+ "# HELP hoopilot_copilot_quota_reset_timestamp_seconds Unix epoch of the next reset."
878
+ );
879
+ lines.push("# TYPE hoopilot_copilot_quota_reset_timestamp_seconds gauge");
880
+ lines.push(`hoopilot_copilot_quota_reset_timestamp_seconds ${resetMs / 1e3}`);
881
+ }
882
+ if (usage.plan || usage.accessTypeSku) {
883
+ lines.push("# HELP hoopilot_copilot_info Copilot plan metadata as a constant-1 info gauge.");
884
+ lines.push("# TYPE hoopilot_copilot_info gauge");
885
+ lines.push(
886
+ `hoopilot_copilot_info${labels({
887
+ access_type_sku: usage.accessTypeSku ?? "",
888
+ plan: usage.plan ?? ""
889
+ })} 1`
890
+ );
891
+ }
892
+ }
893
+ };
894
+ function observeResponseUsage(response, fallbackModel, onUsage, signal) {
895
+ const body = response.body;
896
+ if (!body) {
897
+ return response;
898
+ }
899
+ const [clientBranch, observerBranch] = body.tee();
900
+ const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
901
+ void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal).catch(() => {
902
+ });
903
+ return new Response(clientBranch, {
904
+ headers: response.headers,
905
+ status: response.status,
906
+ statusText: response.statusText
907
+ });
908
+ }
909
+ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal) {
910
+ const reader = stream.getReader();
911
+ const onAbort = () => {
912
+ reader.cancel().catch(() => {
913
+ });
914
+ };
915
+ if (signal?.aborted) {
916
+ reader.cancel().catch(() => {
917
+ });
918
+ } else {
919
+ signal?.addEventListener("abort", onAbort, { once: true });
920
+ }
921
+ const decoder = new TextDecoder();
922
+ let model = fallbackModel;
923
+ let usage;
924
+ let buffer = "";
925
+ let bufferedBytes = 0;
926
+ let overflowed = false;
927
+ const consider = (payload) => {
928
+ const record = asRecord(payload);
929
+ const found = extractTokenUsage(record.usage) ?? extractTokenUsage(asRecord(record.response).usage);
930
+ if (found) {
931
+ usage = found;
932
+ }
933
+ const candidateModel = modelText(record.model) || modelText(asRecord(record.response).model);
934
+ if (candidateModel) {
935
+ model = candidateModel;
936
+ }
937
+ };
938
+ try {
939
+ while (true) {
940
+ const result = await reader.read();
941
+ if (result.done) {
942
+ break;
943
+ }
944
+ const chunk = decoder.decode(result.value, { stream: true });
945
+ if (isSse) {
946
+ buffer += chunk;
947
+ const lines = buffer.split(/\r?\n/);
948
+ buffer = lines.pop() ?? "";
949
+ for (const line of lines) {
950
+ considerSseLine(line, consider);
951
+ }
952
+ if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
953
+ buffer = "";
954
+ }
955
+ } else if (!overflowed) {
956
+ bufferedBytes += result.value.byteLength;
957
+ if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
958
+ overflowed = true;
959
+ buffer = "";
960
+ } else {
961
+ buffer += chunk;
962
+ }
963
+ }
964
+ }
965
+ const finalBuffer = buffer + decoder.decode();
966
+ if (isSse) {
967
+ if (finalBuffer) {
968
+ considerSseLine(finalBuffer, consider);
969
+ }
970
+ } else if (!overflowed && finalBuffer) {
971
+ const parsed = safeParse(finalBuffer);
972
+ if (parsed !== void 0) {
973
+ consider(parsed);
974
+ }
975
+ }
976
+ } finally {
977
+ signal?.removeEventListener("abort", onAbort);
978
+ reader.releaseLock();
979
+ }
980
+ if (usage) {
981
+ onUsage(model, usage);
982
+ }
983
+ }
984
+ function considerSseLine(line, consider) {
985
+ const trimmed = line.trim();
986
+ if (!trimmed.startsWith("data:")) {
987
+ return;
988
+ }
989
+ const data = trimmed.slice("data:".length).trim();
990
+ if (!data || data === "[DONE]") {
991
+ return;
992
+ }
993
+ const parsed = safeParse(data);
994
+ if (parsed !== void 0) {
995
+ consider(parsed);
996
+ }
997
+ }
998
+ function safeParse(text) {
999
+ try {
1000
+ return JSON.parse(text);
1001
+ } catch {
1002
+ return void 0;
1003
+ }
1004
+ }
1005
+ function modelText(value) {
1006
+ return typeof value === "string" ? value.trim() : "";
1007
+ }
1008
+ function nonNegative(value) {
1009
+ return Number.isFinite(value) && value > 0 ? value : 0;
1010
+ }
1011
+ function labelKey(...parts) {
1012
+ return parts.join(LABEL_SEPARATOR);
1013
+ }
1014
+ function labels(pairs) {
1015
+ const entries = Object.entries(pairs);
1016
+ if (entries.length === 0) {
1017
+ return "";
1018
+ }
1019
+ const rendered = entries.map(([name, value]) => `${name}="${escapeLabelValue(value)}"`);
1020
+ return `{${rendered.join(",")}}`;
1021
+ }
1022
+ function escapeLabelValue(value) {
1023
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r");
1024
+ }
1025
+ function formatNumber(value) {
1026
+ return Number.isInteger(value) ? value.toString() : String(value);
1027
+ }
1028
+
489
1029
  // src/server.ts
490
1030
  var DEFAULT_HOST = "127.0.0.1";
491
1031
  var DEFAULT_PORT = 4141;
492
1032
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
1033
+ var USAGE_CACHE_TTL_MS = 6e4;
493
1034
  function createHoopilotHandler(options = {}) {
494
1035
  const client = new CopilotClient(options);
495
1036
  const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
496
1037
  const logger = serverLogger(options);
1038
+ const metrics = options.metrics ?? new MetricsRegistry();
1039
+ const readUsage = createUsageReader(client, metrics);
1040
+ const recordTokens = (model, usage) => metrics.recordTokens(model, usage);
497
1041
  return async (request) => {
498
1042
  const startedAt = performance.now();
499
1043
  const url = new URL(request.url);
500
1044
  const apiPath = canonicalApiPath(url.pathname);
501
1045
  const requestId = requestIdFor(request);
1046
+ const route = routeFor(request.method, apiPath);
502
1047
  const requestLogger = logger.child({
503
1048
  method: request.method,
504
1049
  path: url.pathname,
505
1050
  requestId,
506
- route: routeFor(request.method, apiPath)
1051
+ route
1052
+ });
1053
+ metrics.startRequest();
1054
+ const finish = (response) => finishResponse(response, {
1055
+ logger: requestLogger,
1056
+ method: request.method,
1057
+ metrics,
1058
+ requestId,
1059
+ route,
1060
+ startedAt
507
1061
  });
508
1062
  if (request.method === "OPTIONS") {
509
- return finishResponse(new Response(null, { headers: corsHeaders() }), {
510
- logger: requestLogger,
511
- requestId,
512
- startedAt
513
- });
1063
+ return finish(new Response(null, { headers: corsHeaders() }));
514
1064
  }
515
1065
  if (!isAuthorized(request, apiKey)) {
516
1066
  requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
517
- return finishResponse(
518
- jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."),
519
- {
520
- logger: requestLogger,
521
- requestId,
522
- startedAt
523
- }
524
- );
1067
+ return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
525
1068
  }
526
1069
  try {
527
1070
  if (request.method === "GET" && (apiPath === "/" || apiPath === "/healthz")) {
528
- return finishResponse(
529
- jsonResponse({
530
- name: "hoopilot",
531
- object: "health",
532
- status: "ok"
533
- }),
534
- { logger: requestLogger, requestId, startedAt }
535
- );
1071
+ return finish(jsonResponse({ name: "hoopilot", object: "health", status: "ok" }));
1072
+ }
1073
+ if (request.method === "GET" && apiPath === "/metrics") {
1074
+ return finish(metricsResponse(metrics));
1075
+ }
1076
+ if (request.method === "GET" && apiPath === "/v1/usage") {
1077
+ return finish(await handleUsage(metrics, readUsage, request.signal));
536
1078
  }
537
1079
  if (request.method === "GET" && apiPath === "/v1/responses") {
538
- return finishResponse(websocketUnsupportedResponse(), {
539
- logger: requestLogger,
540
- requestId,
541
- startedAt
542
- });
1080
+ return finish(websocketUnsupportedResponse());
543
1081
  }
544
1082
  if (request.method === "GET" && apiPath === "/v1/models") {
545
- return finishResponse(await handleModels(client, request.signal, requestLogger), {
546
- logger: requestLogger,
547
- requestId,
548
- startedAt
549
- });
1083
+ return finish(await handleModels(client, metrics, request.signal, requestLogger));
550
1084
  }
551
1085
  if (request.method === "POST" && apiPath === "/v1/chat/completions") {
552
- return finishResponse(await handleChatCompletions(client, request, requestLogger), {
553
- logger: requestLogger,
554
- requestId,
555
- startedAt
556
- });
1086
+ return finish(
1087
+ await handleChatCompletions(client, metrics, recordTokens, request, requestLogger)
1088
+ );
557
1089
  }
558
1090
  if (request.method === "POST" && apiPath === "/v1/completions") {
559
- return finishResponse(await handleCompletions(client, request, requestLogger), {
560
- logger: requestLogger,
561
- requestId,
562
- startedAt
563
- });
1091
+ return finish(
1092
+ await handleCompletions(client, metrics, recordTokens, request, requestLogger)
1093
+ );
564
1094
  }
565
1095
  if (request.method === "POST" && apiPath === "/v1/responses") {
566
- return finishResponse(await handleResponses(client, request, requestLogger), {
567
- logger: requestLogger,
568
- requestId,
569
- startedAt
570
- });
1096
+ return finish(await handleResponses(client, metrics, recordTokens, request, requestLogger));
571
1097
  }
572
- return finishResponse(
573
- jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`),
574
- { logger: requestLogger, requestId, startedAt }
575
- );
1098
+ return finish(jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`));
576
1099
  } catch (error) {
577
1100
  if (error instanceof CopilotAuthError) {
578
1101
  requestLogger.warn(
579
1102
  { err: errorDetails(error), event: "copilot.auth.missing" },
580
1103
  "copilot auth failed"
581
1104
  );
582
- return finishResponse(jsonError(401, "copilot_auth_error", error.message), {
583
- logger: requestLogger,
584
- requestId,
585
- startedAt
586
- });
1105
+ return finish(jsonError(401, "copilot_auth_error", error.message));
587
1106
  }
588
1107
  const message = errorMessage(error);
589
1108
  if (message === INVALID_JSON_MESSAGE) {
@@ -597,11 +1116,7 @@ function createHoopilotHandler(options = {}) {
597
1116
  "request failed"
598
1117
  );
599
1118
  }
600
- return finishResponse(jsonError(500, "internal_error", message), {
601
- logger: requestLogger,
602
- requestId,
603
- startedAt
604
- });
1119
+ return finish(jsonError(500, "internal_error", message));
605
1120
  }
606
1121
  };
607
1122
  }
@@ -630,8 +1145,9 @@ function startHoopilotServer(options = {}) {
630
1145
  url: `http://${host}:${server.port}`
631
1146
  };
632
1147
  }
633
- async function handleModels(client, signal, logger) {
1148
+ async function handleModels(client, metrics, signal, logger) {
634
1149
  const upstream = await client.models(signal);
1150
+ metrics.recordUpstream("/models", upstream.ok);
635
1151
  if (!upstream.ok) {
636
1152
  if (isUpstreamAuthStatus(upstream.status)) {
637
1153
  return proxyError(upstream, logger);
@@ -649,35 +1165,50 @@ async function handleModels(client, signal, logger) {
649
1165
  logUpstreamSuccess(logger, "/models", upstream.status);
650
1166
  return jsonResponse(normalizeModelsResponse(await upstream.json()));
651
1167
  }
652
- async function handleChatCompletions(client, request, logger) {
1168
+ async function handleChatCompletions(client, metrics, recordTokens, request, logger) {
653
1169
  const chatRequest = normalizeChatCompletionRequest(await readJson(request));
654
1170
  const upstream = await client.chatCompletions(chatRequest, request.signal);
1171
+ metrics.recordUpstream("/chat/completions", upstream.ok);
655
1172
  if (!upstream.ok) {
656
1173
  return proxyError(upstream, logger);
657
1174
  }
658
1175
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
659
- return proxyResponse(upstream);
1176
+ const model = normalizeRequestedModel(chatRequest.model);
1177
+ return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
660
1178
  }
661
- async function handleCompletions(client, request, logger) {
1179
+ async function handleCompletions(client, metrics, recordTokens, request, logger) {
662
1180
  const body = await readJson(request);
663
1181
  const upstream = await client.chatCompletions(
664
1182
  completionsRequestToChatCompletion(body),
665
1183
  request.signal
666
1184
  );
1185
+ metrics.recordUpstream("/chat/completions", upstream.ok);
667
1186
  if (!upstream.ok) {
668
1187
  return proxyError(upstream, logger);
669
1188
  }
670
1189
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
671
- return jsonResponse(chatCompletionToCompletion(await upstream.json()));
1190
+ const model = normalizeRequestedModel(body.model);
1191
+ if (isStreamingResponse(upstream)) {
1192
+ return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
1193
+ }
1194
+ const completion = asRecord(await upstream.json());
1195
+ const usage = extractTokenUsage(completion.usage);
1196
+ if (usage) {
1197
+ const responseModel = typeof completion.model === "string" ? completion.model.trim() : "";
1198
+ recordTokens(responseModel || model, usage);
1199
+ }
1200
+ return jsonResponse(chatCompletionToCompletion(completion));
672
1201
  }
673
- async function handleResponses(client, request, logger) {
1202
+ async function handleResponses(client, metrics, recordTokens, request, logger) {
674
1203
  const body = await readJsonText(request);
675
1204
  const upstream = await client.responses(body, request.signal);
1205
+ metrics.recordUpstream("/responses", upstream.ok);
676
1206
  if (!upstream.ok) {
677
1207
  return proxyError(upstream, logger);
678
1208
  }
679
1209
  logUpstreamSuccess(logger, "/responses", upstream.status);
680
- return proxyResponse(upstream);
1210
+ const model = normalizeRequestedModel(asRecord(safeParseJson(body)).model);
1211
+ return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
681
1212
  }
682
1213
  async function proxyError(upstream, logger) {
683
1214
  const text = await upstream.text();
@@ -710,8 +1241,7 @@ function proxyResponse(upstream) {
710
1241
  }
711
1242
  async function readJson(request) {
712
1243
  try {
713
- const value = await request.json();
714
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1244
+ return asRecord(await request.json());
715
1245
  } catch {
716
1246
  throw new Error(INVALID_JSON_MESSAGE);
717
1247
  }
@@ -797,7 +1327,21 @@ function serverLogger(options) {
797
1327
  }
798
1328
  function finishResponse(response, options) {
799
1329
  const withRequestId = responseWithRequestId(response, options.requestId);
800
- logRequestCompleted(options.logger, withRequestId, options.startedAt);
1330
+ const stream = isStreamingResponse(withRequestId);
1331
+ const status = withRequestId.status;
1332
+ const complete = () => {
1333
+ const durationMs = Math.round((performance.now() - options.startedAt) * 100) / 100;
1334
+ options.metrics.observe({ durationMs, method: options.method, route: options.route, status });
1335
+ logRequestCompleted(options.logger, status, stream, durationMs);
1336
+ };
1337
+ if (stream && withRequestId.body) {
1338
+ return new Response(trackStreamCompletion(withRequestId.body, complete), {
1339
+ headers: withRequestId.headers,
1340
+ status,
1341
+ statusText: withRequestId.statusText
1342
+ });
1343
+ }
1344
+ complete();
801
1345
  return withRequestId;
802
1346
  }
803
1347
  function responseWithRequestId(response, requestId) {
@@ -809,18 +1353,48 @@ function responseWithRequestId(response, requestId) {
809
1353
  statusText: response.statusText
810
1354
  });
811
1355
  }
812
- function logRequestCompleted(logger, response, startedAt) {
1356
+ function trackStreamCompletion(body, onComplete) {
1357
+ const reader = body.getReader();
1358
+ let fired = false;
1359
+ const fire = () => {
1360
+ if (!fired) {
1361
+ fired = true;
1362
+ onComplete();
1363
+ }
1364
+ };
1365
+ return new ReadableStream({
1366
+ async pull(controller) {
1367
+ try {
1368
+ const { done, value } = await reader.read();
1369
+ if (done) {
1370
+ controller.close();
1371
+ fire();
1372
+ return;
1373
+ }
1374
+ controller.enqueue(value);
1375
+ } catch (error) {
1376
+ fire();
1377
+ controller.error(error);
1378
+ }
1379
+ },
1380
+ cancel(reason) {
1381
+ fire();
1382
+ return reader.cancel(reason);
1383
+ }
1384
+ });
1385
+ }
1386
+ function logRequestCompleted(logger, status, stream, durationMs) {
813
1387
  const fields = {
814
- durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
1388
+ durationMs,
815
1389
  event: "http.request.completed",
816
- status: response.status,
817
- stream: isStreamingResponse(response)
1390
+ status,
1391
+ stream
818
1392
  };
819
- if (response.status >= 500) {
1393
+ if (status >= 500) {
820
1394
  logger.error(fields, "request completed with server error");
821
1395
  return;
822
1396
  }
823
- if (response.status >= 400) {
1397
+ if (status >= 400) {
824
1398
  logger.warn(fields, "request completed with client error");
825
1399
  return;
826
1400
  }
@@ -841,6 +1415,8 @@ function canonicalApiPath(path) {
841
1415
  return "/v1/completions";
842
1416
  case "/responses":
843
1417
  return "/v1/responses";
1418
+ case "/usage":
1419
+ return "/v1/usage";
844
1420
  default:
845
1421
  return withoutTrailingSlash;
846
1422
  }
@@ -852,6 +1428,12 @@ function routeFor(method, path) {
852
1428
  if (method === "GET" && (path === "/" || path === "/healthz")) {
853
1429
  return "health";
854
1430
  }
1431
+ if (method === "GET" && path === "/metrics") {
1432
+ return "metrics";
1433
+ }
1434
+ if (method === "GET" && path === "/v1/usage") {
1435
+ return "usage";
1436
+ }
855
1437
  if (method === "GET" && path === "/v1/models") {
856
1438
  return "models";
857
1439
  }
@@ -882,15 +1464,56 @@ function logUpstreamSuccess(logger, upstreamPath, status) {
882
1464
  "copilot upstream request completed"
883
1465
  );
884
1466
  }
885
- function errorDetails(error) {
886
- if (error instanceof Error) {
887
- return {
888
- message: error.message,
889
- name: error.name,
890
- stack: error.stack
891
- };
1467
+ function metricsResponse(metrics) {
1468
+ return new Response(metrics.renderPrometheus(), {
1469
+ headers: {
1470
+ ...corsHeaders(),
1471
+ "content-type": PROMETHEUS_CONTENT_TYPE
1472
+ },
1473
+ status: 200
1474
+ });
1475
+ }
1476
+ async function handleUsage(metrics, readUsage, signal) {
1477
+ const proxy = metrics.snapshot();
1478
+ const { copilot, error } = await readUsage(signal);
1479
+ const body = { copilot: copilot ?? null, object: "usage", proxy };
1480
+ if (error) {
1481
+ body.copilot_error = error;
1482
+ }
1483
+ return jsonResponse(body);
1484
+ }
1485
+ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_TTL_MS) {
1486
+ const usagePath = "/copilot_internal/user";
1487
+ let cache;
1488
+ return async (signal) => {
1489
+ if (cache && now() - cache.atMs < ttlMs) {
1490
+ return { copilot: cache.value };
1491
+ }
1492
+ try {
1493
+ const upstream = await client.usage(signal);
1494
+ metrics.recordUpstream(usagePath, upstream.ok);
1495
+ if (!upstream.ok) {
1496
+ return { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
1497
+ }
1498
+ const value = normalizeCopilotUsage(await upstream.json().catch(() => ({})));
1499
+ cache = { atMs: now(), value };
1500
+ metrics.recordCopilotQuota(value);
1501
+ return { copilot: value };
1502
+ } catch (error) {
1503
+ metrics.recordUpstream(usagePath, false);
1504
+ if (error instanceof CopilotAuthError) {
1505
+ return { error: error.message };
1506
+ }
1507
+ return { error: errorMessage(error) };
1508
+ }
1509
+ };
1510
+ }
1511
+ function safeParseJson(text) {
1512
+ try {
1513
+ return JSON.parse(text);
1514
+ } catch {
1515
+ return void 0;
892
1516
  }
893
- return { message: String(error) };
894
1517
  }
895
1518
 
896
1519
  // src/update.ts
@@ -902,7 +1525,7 @@ import {
902
1525
  existsSync,
903
1526
  mkdirSync as mkdirSync2,
904
1527
  realpathSync,
905
- renameSync,
1528
+ renameSync as renameSync2,
906
1529
  rmSync
907
1530
  } from "fs";
908
1531
  import { readFile, writeFile } from "fs/promises";
@@ -1116,7 +1739,7 @@ async function getVersion() {
1116
1739
  }
1117
1740
 
1118
1741
  // src/update.ts
1119
- var REQUEST_TIMEOUT_MS = 8e3;
1742
+ var REQUEST_TIMEOUT_MS2 = 8e3;
1120
1743
  var SHA256SUMS = "SHA256SUMS";
1121
1744
  function userAgent(version) {
1122
1745
  return `hoopilot/${version}`;
@@ -1153,7 +1776,7 @@ async function fetchLatest(version, etag) {
1153
1776
  }
1154
1777
  const response = await fetch(latestReleaseApiUrl(), {
1155
1778
  headers,
1156
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1779
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
1157
1780
  });
1158
1781
  if (response.status === 304) {
1159
1782
  return { status: 304, etag: etag ?? null, release: null };
@@ -1192,7 +1815,7 @@ async function maybeNotifyUpdate(currentVersion, kind, logger) {
1192
1815
  logger?.debug({ event: "update.check.refresh_queued" }, "queued update check refresh");
1193
1816
  void refreshState(currentVersion, state.etag ?? null, logger).catch((error) => {
1194
1817
  logger?.debug(
1195
- { err: errorDetails2(error), event: "update.check.refresh_failed" },
1818
+ { err: errorDetails(error), event: "update.check.refresh_failed" },
1196
1819
  "update check refresh failed"
1197
1820
  );
1198
1821
  });
@@ -1254,7 +1877,7 @@ async function downloadToFile(url, dest, version) {
1254
1877
  const response = await fetch(url, {
1255
1878
  headers: { "User-Agent": userAgent(version) },
1256
1879
  redirect: "follow",
1257
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS * 10)
1880
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2 * 10)
1258
1881
  });
1259
1882
  if (!response.ok || !response.body) {
1260
1883
  throw new Error(`Download failed (${response.status}) for ${url}`);
@@ -1274,7 +1897,7 @@ async function verifyChecksum(release, assetName, file, version) {
1274
1897
  const response = await fetch(sums.url, {
1275
1898
  headers: { "User-Agent": userAgent(version) },
1276
1899
  redirect: "follow",
1277
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1900
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
1278
1901
  });
1279
1902
  if (!response.ok) {
1280
1903
  throw new Error(`Could not download ${SHA256SUMS} (${response.status}).`);
@@ -1295,15 +1918,15 @@ function swapBinary(tmpFile, exePath) {
1295
1918
  rmSync(oldExe, { force: true });
1296
1919
  } catch {
1297
1920
  }
1298
- renameSync(exePath, oldExe);
1921
+ renameSync2(exePath, oldExe);
1299
1922
  const restore = () => {
1300
1923
  try {
1301
- renameSync(oldExe, exePath);
1924
+ renameSync2(oldExe, exePath);
1302
1925
  } catch {
1303
1926
  }
1304
1927
  };
1305
1928
  try {
1306
- renameSync(tmpFile, exePath);
1929
+ renameSync2(tmpFile, exePath);
1307
1930
  } catch (error) {
1308
1931
  if (error.code === "EXDEV") {
1309
1932
  try {
@@ -1320,7 +1943,7 @@ function swapBinary(tmpFile, exePath) {
1320
1943
  return;
1321
1944
  }
1322
1945
  try {
1323
- renameSync(tmpFile, exePath);
1946
+ renameSync2(tmpFile, exePath);
1324
1947
  } catch (error) {
1325
1948
  const code = error.code;
1326
1949
  if (code === "EXDEV") {
@@ -1416,19 +2039,8 @@ async function runUpdate(currentVersion, logger) {
1416
2039
  console.log("Restart hoopilot to run the new version.");
1417
2040
  }
1418
2041
  }
1419
- function errorDetails2(error) {
1420
- if (error instanceof Error) {
1421
- return {
1422
- message: error.message,
1423
- name: error.name,
1424
- stack: error.stack
1425
- };
1426
- }
1427
- return { message: String(error) };
1428
- }
1429
2042
 
1430
2043
  // src/cli.ts
1431
- var DEFAULT_COPILOT_API_BASE_URL2 = "https://api.githubcopilot.com";
1432
2044
  async function main(argv = Bun.argv.slice(2)) {
1433
2045
  cleanupOldBinary();
1434
2046
  const command = argv[0];
@@ -1438,11 +2050,7 @@ async function main(argv = Bun.argv.slice(2)) {
1438
2050
  console.log(helpText(await getVersion()));
1439
2051
  return;
1440
2052
  }
1441
- const logger2 = createHoopilotLogger({
1442
- env: args2.env,
1443
- format: args2.logFormat,
1444
- level: args2.logLevel
1445
- }).child({ component: "cli", command });
2053
+ const logger2 = commandLogger(args2, command);
1446
2054
  await runUpdate(await getVersion(), logger2);
1447
2055
  return;
1448
2056
  }
@@ -1452,11 +2060,7 @@ async function main(argv = Bun.argv.slice(2)) {
1452
2060
  console.log(helpText(await getVersion()));
1453
2061
  return;
1454
2062
  }
1455
- args2.logger = createHoopilotLogger({
1456
- env: args2.env,
1457
- format: args2.logFormat,
1458
- level: args2.logLevel
1459
- }).child({ component: "cli", command: "login" });
2063
+ args2.logger = commandLogger(args2, "login");
1460
2064
  await runLogin(args2);
1461
2065
  return;
1462
2066
  }
@@ -1466,14 +2070,20 @@ async function main(argv = Bun.argv.slice(2)) {
1466
2070
  console.log(helpText(await getVersion()));
1467
2071
  return;
1468
2072
  }
1469
- args2.logger = createHoopilotLogger({
1470
- env: args2.env,
1471
- format: args2.logFormat,
1472
- level: args2.logLevel
1473
- }).child({ component: "cli", command: "models" });
2073
+ args2.logger = commandLogger(args2, "models");
1474
2074
  await runModels(args2);
1475
2075
  return;
1476
2076
  }
2077
+ if (command === "usage") {
2078
+ const args2 = withRuntimeEnv(parseArgs(argv.slice(1)));
2079
+ if (args2.help) {
2080
+ console.log(helpText(await getVersion()));
2081
+ return;
2082
+ }
2083
+ args2.logger = commandLogger(args2, "usage");
2084
+ await runUsage(args2);
2085
+ return;
2086
+ }
1477
2087
  const args = withRuntimeEnv(parseArgs(argv));
1478
2088
  if (args.help) {
1479
2089
  console.log(helpText(await getVersion()));
@@ -1483,11 +2093,7 @@ async function main(argv = Bun.argv.slice(2)) {
1483
2093
  console.log(await getVersion());
1484
2094
  return;
1485
2095
  }
1486
- const logger = createHoopilotLogger({
1487
- env: args.env,
1488
- format: args.logFormat,
1489
- level: args.logLevel
1490
- }).child({ component: "cli", command: "serve" });
2096
+ const logger = commandLogger(args, "serve");
1491
2097
  args.logger = logger;
1492
2098
  const started = startHoopilotServer(args);
1493
2099
  logger.info(
@@ -1498,7 +2104,7 @@ async function main(argv = Bun.argv.slice(2)) {
1498
2104
  },
1499
2105
  "hoopilot server started"
1500
2106
  );
1501
- if (!args.noUpdateCheck && process.env.HOOPILOT_NO_UPDATE_CHECK !== "1") {
2107
+ if (!args.noUpdateCheck) {
1502
2108
  void maybeNotifyUpdate(
1503
2109
  await getVersion(),
1504
2110
  IS_STANDALONE_BINARY ? "binary" : "npm",
@@ -1604,7 +2210,7 @@ async function runModels(options = {}) {
1604
2210
  logger.debug({ event: "models.list.started" }, "fetching github copilot models");
1605
2211
  const response = await new CopilotClient(options).models();
1606
2212
  if (!response.ok) {
1607
- const message = `GitHub Copilot API model list failed with ${response.status}: ${await safeResponseText2(response)}`;
2213
+ const message = `GitHub Copilot API model list failed with ${response.status}: ${await truncatedResponseText(response)}`;
1608
2214
  if (response.status === 401 || response.status === 403) {
1609
2215
  throw new CopilotAuthError(message);
1610
2216
  }
@@ -1623,17 +2229,98 @@ async function runModels(options = {}) {
1623
2229
  }
1624
2230
  return ids;
1625
2231
  }
2232
+ async function runUsage(options = {}) {
2233
+ const logger = options.logger?.child({ component: "usage" }) ?? noopLogger;
2234
+ logger.debug({ event: "usage.fetch.started" }, "fetching github copilot quota");
2235
+ const response = await new CopilotClient(options).usage();
2236
+ if (!response.ok) {
2237
+ const message = `GitHub Copilot usage request failed with ${response.status}: ${await truncatedResponseText(response)}`;
2238
+ if (response.status === 401 || response.status === 403) {
2239
+ throw new CopilotAuthError(message);
2240
+ }
2241
+ throw new Error(message);
2242
+ }
2243
+ const usage = normalizeCopilotUsage(await response.json().catch(() => ({})));
2244
+ logger.debug(
2245
+ { event: "usage.fetch.succeeded", plan: usage.plan },
2246
+ "github copilot quota fetched"
2247
+ );
2248
+ for (const line of formatCopilotUsage(usage)) {
2249
+ console.log(line);
2250
+ }
2251
+ return usage;
2252
+ }
2253
+ function formatCopilotUsage(usage) {
2254
+ const lines = [];
2255
+ if (usage.plan) {
2256
+ lines.push(`Plan: ${usage.plan}`);
2257
+ }
2258
+ if (usage.quotaResetDate) {
2259
+ lines.push(`Quota resets: ${usage.quotaResetDate}`);
2260
+ }
2261
+ const order = ["premium_interactions", "chat", "completions"];
2262
+ const names = Object.keys(usage.quotas).sort(
2263
+ (a, b) => quotaRank(order, a) - quotaRank(order, b) || a.localeCompare(b)
2264
+ );
2265
+ for (const name of names) {
2266
+ const quota = usage.quotas[name];
2267
+ if (quota) {
2268
+ lines.push(`${quotaLabel(name)}: ${formatQuota(quota)}`);
2269
+ }
2270
+ }
2271
+ if (lines.length === 0) {
2272
+ lines.push("No GitHub Copilot quota information available for this account.");
2273
+ }
2274
+ return lines;
2275
+ }
2276
+ function quotaRank(order, name) {
2277
+ const index = order.indexOf(name);
2278
+ return index === -1 ? order.length : index;
2279
+ }
2280
+ function quotaLabel(name) {
2281
+ switch (name) {
2282
+ case "premium_interactions":
2283
+ return "Premium requests";
2284
+ case "chat":
2285
+ return "Chat";
2286
+ case "completions":
2287
+ return "Completions";
2288
+ default:
2289
+ return name;
2290
+ }
2291
+ }
2292
+ function formatQuota(quota) {
2293
+ if (quota.unlimited) {
2294
+ return "unlimited";
2295
+ }
2296
+ const parts = [];
2297
+ if (quota.used !== void 0 && quota.entitlement !== void 0) {
2298
+ parts.push(`${roundQuota(quota.used)}/${roundQuota(quota.entitlement)} used`);
2299
+ } else if (quota.remaining !== void 0) {
2300
+ parts.push(`${roundQuota(quota.remaining)} remaining`);
2301
+ }
2302
+ if (quota.percentRemaining !== void 0) {
2303
+ parts.push(`${roundQuota(quota.percentRemaining)}% remaining`);
2304
+ }
2305
+ if (quota.overageCount) {
2306
+ parts.push(`${roundQuota(quota.overageCount)} overage`);
2307
+ }
2308
+ return parts.length > 0 ? parts.join(", ") : "n/a";
2309
+ }
2310
+ function roundQuota(value) {
2311
+ return Number.isInteger(value) ? value : Math.round(value * 10) / 10;
2312
+ }
1626
2313
  async function verifyCopilotOAuthToken(token, options = {}) {
1627
- const apiBaseUrl = trimTrailingSlash2(
1628
- options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL2
2314
+ const apiBaseUrl = trimTrailingSlash(
2315
+ options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
1629
2316
  );
1630
2317
  const fetcher = options.fetch ?? fetch;
1631
2318
  const response = await fetcher(`${apiBaseUrl}/models`, {
1632
- headers: copilotHeaders(token),
2319
+ headers: applyCopilotHeaders(new Headers(), token),
1633
2320
  method: "GET"
1634
2321
  });
1635
2322
  if (!response.ok) {
1636
- const message = `GitHub Copilot API verification failed with ${response.status}: ${await safeResponseText2(response)}`;
2323
+ const message = `GitHub Copilot API verification failed with ${response.status}: ${await truncatedResponseText(response)}`;
1637
2324
  if (response.status === 401 || response.status === 403) {
1638
2325
  throw new CopilotAuthError(message);
1639
2326
  }
@@ -1659,32 +2346,13 @@ function openBrowserBestEffort(url) {
1659
2346
  } catch {
1660
2347
  }
1661
2348
  }
1662
- function copilotHeaders(token) {
1663
- const headers = new Headers();
1664
- headers.set("accept", "application/json");
1665
- headers.set("authorization", `Bearer ${token}`);
1666
- headers.set("copilot-integration-id", "vscode-chat");
1667
- headers.set("editor-plugin-version", "hoopilot/0.1.0");
1668
- headers.set("editor-version", "Hoopilot/0.1.0");
1669
- headers.set("openai-intent", "conversation-panel");
1670
- headers.set("user-agent", "hoopilot/0.1.0");
1671
- headers.set("x-github-api-version", "2026-06-01");
1672
- return headers;
1673
- }
1674
- async function safeResponseText2(response) {
1675
- const text = await response.text();
1676
- return text.slice(0, 500);
1677
- }
1678
- function trimTrailingSlash2(value) {
1679
- return value.replace(/\/+$/, "");
1680
- }
1681
2349
  function modelIdsFromResponse(body) {
1682
- const record = asRecord2(body);
2350
+ const record = asRecord(body);
1683
2351
  const data = Array.isArray(record.data) ? record.data : Array.isArray(body) ? body : [];
1684
2352
  const seen = /* @__PURE__ */ new Set();
1685
2353
  const ids = [];
1686
2354
  for (const model of data) {
1687
- const id = asRecord2(model).id;
2355
+ const id = asRecord(model).id;
1688
2356
  if (typeof id !== "string" || id.length === 0 || seen.has(id)) {
1689
2357
  continue;
1690
2358
  }
@@ -1693,12 +2361,16 @@ function modelIdsFromResponse(body) {
1693
2361
  }
1694
2362
  return ids;
1695
2363
  }
1696
- function asRecord2(value) {
1697
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1698
- }
1699
2364
  function withRuntimeEnv(args) {
1700
2365
  return { ...args, env: process.env };
1701
2366
  }
2367
+ function commandLogger(args, command) {
2368
+ return createHoopilotLogger({
2369
+ env: args.env,
2370
+ format: args.logFormat,
2371
+ level: args.logLevel
2372
+ }).child({ command, component: "cli" });
2373
+ }
1702
2374
  function helpText(version) {
1703
2375
  return `hoopilot ${version}
1704
2376
 
@@ -1708,6 +2380,7 @@ Usage:
1708
2380
  hoopilot [serve] [options]
1709
2381
  hoopilot login [options]
1710
2382
  hoopilot models [options]
2383
+ hoopilot usage [options]
1711
2384
  hoopilot update
1712
2385
  npx @openhoo/hoopilot [options]
1713
2386
 
@@ -1715,12 +2388,17 @@ Commands:
1715
2388
  serve Start the proxy server (default)
1716
2389
  login Sign in through GitHub OAuth in a browser and verify Copilot access
1717
2390
  models List available GitHub Copilot model IDs
2391
+ usage Show GitHub Copilot quota and premium-request usage
1718
2392
  update, upgrade Update hoopilot to the latest release
1719
2393
 
2394
+ While the server runs, GET /metrics exposes Prometheus metrics (request counts,
2395
+ token usage, latency) and GET /v1/usage returns those metrics plus live Copilot
2396
+ quota as JSON.
2397
+
1720
2398
  Options:
1721
2399
  -p, --port <port> Port to listen on. Default: 4141
1722
2400
  --host <host> Host to listen on. Default: 127.0.0.1
1723
- --api-key <key> Require clients to send Authorization: Bearer <key>
2401
+ --api-key <key> Require clients to send Authorization: Bearer <key> or x-api-key: <key>
1724
2402
  --auth-file <path> OAuth credential store path
1725
2403
  --copilot-api-base-url <url> Copilot API base URL override
1726
2404
  --log-level <level> trace, debug, info, warn, error, fatal, or silent
@@ -1738,6 +2416,7 @@ Environment:
1738
2416
  HOOPILOT_LOG_FORMAT json or pretty. Default: pretty
1739
2417
  HOOPILOT_LOG_LEVEL trace, debug, info, warn, error, fatal, or silent
1740
2418
  COPILOT_API_BASE_URL
2419
+ HOOPILOT_GITHUB_API_BASE_URL GitHub REST base for the usage/quota lookup. Default: https://api.github.com
1741
2420
  HOOPILOT_NO_UPDATE_CHECK Set to disable update checks (also NO_UPDATE_NOTIFIER)
1742
2421
  `;
1743
2422
  }
@@ -1751,6 +2430,7 @@ export {
1751
2430
  main,
1752
2431
  parseArgs,
1753
2432
  runModels,
2433
+ runUsage,
1754
2434
  verifyCopilotOAuthToken
1755
2435
  };
1756
2436
  //# sourceMappingURL=cli.js.map