@openhoo/hoopilot 0.6.1 → 0.7.1

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
@@ -110,6 +110,8 @@ var CopilotAuth = class {
110
110
  };
111
111
 
112
112
  // src/copilot.ts
113
+ var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
114
+ var COPILOT_USAGE_API_VERSION = "2025-04-01";
113
115
  function applyCopilotHeaders(headers, token) {
114
116
  headers.set("accept", headers.get("accept") ?? "application/json");
115
117
  headers.set("authorization", `Bearer ${token}`);
@@ -121,12 +123,44 @@ function applyCopilotHeaders(headers, token) {
121
123
  headers.set("x-github-api-version", "2026-06-01");
122
124
  return headers;
123
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
+ }
124
135
  var CopilotClient = class {
125
136
  #auth;
126
137
  #fetch;
138
+ #githubApiBaseUrl;
127
139
  constructor(options = {}) {
128
140
  this.#auth = new CopilotAuth(options);
129
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
+ });
130
164
  }
131
165
  async chatCompletions(body, signal) {
132
166
  return this.fetchCopilot("/chat/completions", {
@@ -166,6 +200,81 @@ var CopilotClient = class {
166
200
  });
167
201
  }
168
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
+ }
169
278
 
170
279
  // src/github-device.ts
171
280
  import { setTimeout as sleep } from "timers/promises";
@@ -503,6 +612,40 @@ function contentToText(content) {
503
612
  }
504
613
  return "";
505
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
+ }
506
649
  function firstChoice(completion) {
507
650
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
508
651
  return asRecord(choices[0]);
@@ -517,104 +660,449 @@ function epochSeconds() {
517
660
  return Math.floor(Date.now() / 1e3);
518
661
  }
519
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
+
520
1029
  // src/server.ts
521
1030
  var DEFAULT_HOST = "127.0.0.1";
522
1031
  var DEFAULT_PORT = 4141;
523
1032
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
1033
+ var USAGE_CACHE_TTL_MS = 6e4;
524
1034
  function createHoopilotHandler(options = {}) {
525
1035
  const client = new CopilotClient(options);
526
1036
  const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
527
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);
528
1041
  return async (request) => {
529
1042
  const startedAt = performance.now();
530
1043
  const url = new URL(request.url);
531
1044
  const apiPath = canonicalApiPath(url.pathname);
532
1045
  const requestId = requestIdFor(request);
1046
+ const route = routeFor(request.method, apiPath);
533
1047
  const requestLogger = logger.child({
534
1048
  method: request.method,
535
1049
  path: url.pathname,
536
1050
  requestId,
537
- 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
538
1061
  });
539
1062
  if (request.method === "OPTIONS") {
540
- return finishResponse(new Response(null, { headers: corsHeaders() }), {
541
- logger: requestLogger,
542
- requestId,
543
- startedAt
544
- });
1063
+ return finish(new Response(null, { headers: corsHeaders() }));
545
1064
  }
546
1065
  if (!isAuthorized(request, apiKey)) {
547
1066
  requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
548
- return finishResponse(
549
- jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."),
550
- {
551
- logger: requestLogger,
552
- requestId,
553
- startedAt
554
- }
555
- );
1067
+ return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
556
1068
  }
557
1069
  try {
558
1070
  if (request.method === "GET" && (apiPath === "/" || apiPath === "/healthz")) {
559
- return finishResponse(
560
- jsonResponse({
561
- name: "hoopilot",
562
- object: "health",
563
- status: "ok"
564
- }),
565
- { logger: requestLogger, requestId, startedAt }
566
- );
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));
567
1078
  }
568
1079
  if (request.method === "GET" && apiPath === "/v1/responses") {
569
- return finishResponse(websocketUnsupportedResponse(), {
570
- logger: requestLogger,
571
- requestId,
572
- startedAt
573
- });
1080
+ return finish(websocketUnsupportedResponse());
574
1081
  }
575
1082
  if (request.method === "GET" && apiPath === "/v1/models") {
576
- return finishResponse(await handleModels(client, request.signal, requestLogger), {
577
- logger: requestLogger,
578
- requestId,
579
- startedAt
580
- });
1083
+ return finish(await handleModels(client, metrics, request.signal, requestLogger));
581
1084
  }
582
1085
  if (request.method === "POST" && apiPath === "/v1/chat/completions") {
583
- return finishResponse(await handleChatCompletions(client, request, requestLogger), {
584
- logger: requestLogger,
585
- requestId,
586
- startedAt
587
- });
1086
+ return finish(
1087
+ await handleChatCompletions(client, metrics, recordTokens, request, requestLogger)
1088
+ );
588
1089
  }
589
1090
  if (request.method === "POST" && apiPath === "/v1/completions") {
590
- return finishResponse(await handleCompletions(client, request, requestLogger), {
591
- logger: requestLogger,
592
- requestId,
593
- startedAt
594
- });
1091
+ return finish(
1092
+ await handleCompletions(client, metrics, recordTokens, request, requestLogger)
1093
+ );
595
1094
  }
596
1095
  if (request.method === "POST" && apiPath === "/v1/responses") {
597
- return finishResponse(await handleResponses(client, request, requestLogger), {
598
- logger: requestLogger,
599
- requestId,
600
- startedAt
601
- });
1096
+ return finish(await handleResponses(client, metrics, recordTokens, request, requestLogger));
602
1097
  }
603
- return finishResponse(
604
- jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`),
605
- { logger: requestLogger, requestId, startedAt }
606
- );
1098
+ return finish(jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`));
607
1099
  } catch (error) {
608
1100
  if (error instanceof CopilotAuthError) {
609
1101
  requestLogger.warn(
610
1102
  { err: errorDetails(error), event: "copilot.auth.missing" },
611
1103
  "copilot auth failed"
612
1104
  );
613
- return finishResponse(jsonError(401, "copilot_auth_error", error.message), {
614
- logger: requestLogger,
615
- requestId,
616
- startedAt
617
- });
1105
+ return finish(jsonError(401, "copilot_auth_error", error.message));
618
1106
  }
619
1107
  const message = errorMessage(error);
620
1108
  if (message === INVALID_JSON_MESSAGE) {
@@ -622,17 +1110,14 @@ function createHoopilotHandler(options = {}) {
622
1110
  { err: errorDetails(error), event: "http.request.failed" },
623
1111
  "request body was invalid json"
624
1112
  );
1113
+ return finish(jsonError(400, "invalid_request_error", message));
625
1114
  } else {
626
1115
  requestLogger.error(
627
1116
  { err: errorDetails(error), event: "http.request.failed" },
628
1117
  "request failed"
629
1118
  );
630
1119
  }
631
- return finishResponse(jsonError(500, "internal_error", message), {
632
- logger: requestLogger,
633
- requestId,
634
- startedAt
635
- });
1120
+ return finish(jsonError(500, "internal_error", message));
636
1121
  }
637
1122
  };
638
1123
  }
@@ -661,8 +1146,9 @@ function startHoopilotServer(options = {}) {
661
1146
  url: `http://${host}:${server.port}`
662
1147
  };
663
1148
  }
664
- async function handleModels(client, signal, logger) {
1149
+ async function handleModels(client, metrics, signal, logger) {
665
1150
  const upstream = await client.models(signal);
1151
+ metrics.recordUpstream("/models", upstream.ok);
666
1152
  if (!upstream.ok) {
667
1153
  if (isUpstreamAuthStatus(upstream.status)) {
668
1154
  return proxyError(upstream, logger);
@@ -680,38 +1166,50 @@ async function handleModels(client, signal, logger) {
680
1166
  logUpstreamSuccess(logger, "/models", upstream.status);
681
1167
  return jsonResponse(normalizeModelsResponse(await upstream.json()));
682
1168
  }
683
- async function handleChatCompletions(client, request, logger) {
1169
+ async function handleChatCompletions(client, metrics, recordTokens, request, logger) {
684
1170
  const chatRequest = normalizeChatCompletionRequest(await readJson(request));
685
1171
  const upstream = await client.chatCompletions(chatRequest, request.signal);
1172
+ metrics.recordUpstream("/chat/completions", upstream.ok);
686
1173
  if (!upstream.ok) {
687
1174
  return proxyError(upstream, logger);
688
1175
  }
689
1176
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
690
- return proxyResponse(upstream);
1177
+ const model = normalizeRequestedModel(chatRequest.model);
1178
+ return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
691
1179
  }
692
- async function handleCompletions(client, request, logger) {
1180
+ async function handleCompletions(client, metrics, recordTokens, request, logger) {
693
1181
  const body = await readJson(request);
694
1182
  const upstream = await client.chatCompletions(
695
1183
  completionsRequestToChatCompletion(body),
696
1184
  request.signal
697
1185
  );
1186
+ metrics.recordUpstream("/chat/completions", upstream.ok);
698
1187
  if (!upstream.ok) {
699
1188
  return proxyError(upstream, logger);
700
1189
  }
701
1190
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
1191
+ const model = normalizeRequestedModel(body.model);
702
1192
  if (isStreamingResponse(upstream)) {
703
- return proxyResponse(upstream);
1193
+ return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
704
1194
  }
705
- return jsonResponse(chatCompletionToCompletion(await upstream.json()));
1195
+ const completion = asRecord(await upstream.json());
1196
+ const usage = extractTokenUsage(completion.usage);
1197
+ if (usage) {
1198
+ const responseModel = typeof completion.model === "string" ? completion.model.trim() : "";
1199
+ recordTokens(responseModel || model, usage);
1200
+ }
1201
+ return jsonResponse(chatCompletionToCompletion(completion));
706
1202
  }
707
- async function handleResponses(client, request, logger) {
1203
+ async function handleResponses(client, metrics, recordTokens, request, logger) {
708
1204
  const body = await readJsonText(request);
709
1205
  const upstream = await client.responses(body, request.signal);
1206
+ metrics.recordUpstream("/responses", upstream.ok);
710
1207
  if (!upstream.ok) {
711
1208
  return proxyError(upstream, logger);
712
1209
  }
713
1210
  logUpstreamSuccess(logger, "/responses", upstream.status);
714
- return proxyResponse(upstream);
1211
+ const model = normalizeRequestedModel(asRecord(safeParseJson(body)).model);
1212
+ return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
715
1213
  }
716
1214
  async function proxyError(upstream, logger) {
717
1215
  const text = await upstream.text();
@@ -830,7 +1328,21 @@ function serverLogger(options) {
830
1328
  }
831
1329
  function finishResponse(response, options) {
832
1330
  const withRequestId = responseWithRequestId(response, options.requestId);
833
- logRequestCompleted(options.logger, withRequestId, options.startedAt);
1331
+ const stream = isStreamingResponse(withRequestId);
1332
+ const status = withRequestId.status;
1333
+ const complete = () => {
1334
+ const durationMs = Math.round((performance.now() - options.startedAt) * 100) / 100;
1335
+ options.metrics.observe({ durationMs, method: options.method, route: options.route, status });
1336
+ logRequestCompleted(options.logger, status, stream, durationMs);
1337
+ };
1338
+ if (stream && withRequestId.body) {
1339
+ return new Response(trackStreamCompletion(withRequestId.body, complete), {
1340
+ headers: withRequestId.headers,
1341
+ status,
1342
+ statusText: withRequestId.statusText
1343
+ });
1344
+ }
1345
+ complete();
834
1346
  return withRequestId;
835
1347
  }
836
1348
  function responseWithRequestId(response, requestId) {
@@ -842,18 +1354,48 @@ function responseWithRequestId(response, requestId) {
842
1354
  statusText: response.statusText
843
1355
  });
844
1356
  }
845
- function logRequestCompleted(logger, response, startedAt) {
1357
+ function trackStreamCompletion(body, onComplete) {
1358
+ const reader = body.getReader();
1359
+ let fired = false;
1360
+ const fire = () => {
1361
+ if (!fired) {
1362
+ fired = true;
1363
+ onComplete();
1364
+ }
1365
+ };
1366
+ return new ReadableStream({
1367
+ async pull(controller) {
1368
+ try {
1369
+ const { done, value } = await reader.read();
1370
+ if (done) {
1371
+ controller.close();
1372
+ fire();
1373
+ return;
1374
+ }
1375
+ controller.enqueue(value);
1376
+ } catch (error) {
1377
+ fire();
1378
+ controller.error(error);
1379
+ }
1380
+ },
1381
+ cancel(reason) {
1382
+ fire();
1383
+ return reader.cancel(reason);
1384
+ }
1385
+ });
1386
+ }
1387
+ function logRequestCompleted(logger, status, stream, durationMs) {
846
1388
  const fields = {
847
- durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
1389
+ durationMs,
848
1390
  event: "http.request.completed",
849
- status: response.status,
850
- stream: isStreamingResponse(response)
1391
+ status,
1392
+ stream
851
1393
  };
852
- if (response.status >= 500) {
1394
+ if (status >= 500) {
853
1395
  logger.error(fields, "request completed with server error");
854
1396
  return;
855
1397
  }
856
- if (response.status >= 400) {
1398
+ if (status >= 400) {
857
1399
  logger.warn(fields, "request completed with client error");
858
1400
  return;
859
1401
  }
@@ -874,6 +1416,8 @@ function canonicalApiPath(path) {
874
1416
  return "/v1/completions";
875
1417
  case "/responses":
876
1418
  return "/v1/responses";
1419
+ case "/usage":
1420
+ return "/v1/usage";
877
1421
  default:
878
1422
  return withoutTrailingSlash;
879
1423
  }
@@ -885,6 +1429,12 @@ function routeFor(method, path) {
885
1429
  if (method === "GET" && (path === "/" || path === "/healthz")) {
886
1430
  return "health";
887
1431
  }
1432
+ if (method === "GET" && path === "/metrics") {
1433
+ return "metrics";
1434
+ }
1435
+ if (method === "GET" && path === "/v1/usage") {
1436
+ return "usage";
1437
+ }
888
1438
  if (method === "GET" && path === "/v1/models") {
889
1439
  return "models";
890
1440
  }
@@ -915,6 +1465,57 @@ function logUpstreamSuccess(logger, upstreamPath, status) {
915
1465
  "copilot upstream request completed"
916
1466
  );
917
1467
  }
1468
+ function metricsResponse(metrics) {
1469
+ return new Response(metrics.renderPrometheus(), {
1470
+ headers: {
1471
+ ...corsHeaders(),
1472
+ "content-type": PROMETHEUS_CONTENT_TYPE
1473
+ },
1474
+ status: 200
1475
+ });
1476
+ }
1477
+ async function handleUsage(metrics, readUsage, signal) {
1478
+ const proxy = metrics.snapshot();
1479
+ const { copilot, error } = await readUsage(signal);
1480
+ const body = { copilot: copilot ?? null, object: "usage", proxy };
1481
+ if (error) {
1482
+ body.copilot_error = error;
1483
+ }
1484
+ return jsonResponse(body);
1485
+ }
1486
+ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_TTL_MS) {
1487
+ const usagePath = "/copilot_internal/user";
1488
+ let cache;
1489
+ return async (signal) => {
1490
+ if (cache && now() - cache.atMs < ttlMs) {
1491
+ return { copilot: cache.value };
1492
+ }
1493
+ try {
1494
+ const upstream = await client.usage(signal);
1495
+ metrics.recordUpstream(usagePath, upstream.ok);
1496
+ if (!upstream.ok) {
1497
+ return { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
1498
+ }
1499
+ const value = normalizeCopilotUsage(await upstream.json().catch(() => ({})));
1500
+ cache = { atMs: now(), value };
1501
+ metrics.recordCopilotQuota(value);
1502
+ return { copilot: value };
1503
+ } catch (error) {
1504
+ metrics.recordUpstream(usagePath, false);
1505
+ if (error instanceof CopilotAuthError) {
1506
+ return { error: error.message };
1507
+ }
1508
+ return { error: errorMessage(error) };
1509
+ }
1510
+ };
1511
+ }
1512
+ function safeParseJson(text) {
1513
+ try {
1514
+ return JSON.parse(text);
1515
+ } catch {
1516
+ return void 0;
1517
+ }
1518
+ }
918
1519
 
919
1520
  // src/update.ts
920
1521
  import { execFileSync } from "child_process";
@@ -1474,6 +2075,16 @@ async function main(argv = Bun.argv.slice(2)) {
1474
2075
  await runModels(args2);
1475
2076
  return;
1476
2077
  }
2078
+ if (command === "usage") {
2079
+ const args2 = withRuntimeEnv(parseArgs(argv.slice(1)));
2080
+ if (args2.help) {
2081
+ console.log(helpText(await getVersion()));
2082
+ return;
2083
+ }
2084
+ args2.logger = commandLogger(args2, "usage");
2085
+ await runUsage(args2);
2086
+ return;
2087
+ }
1477
2088
  const args = withRuntimeEnv(parseArgs(argv));
1478
2089
  if (args.help) {
1479
2090
  console.log(helpText(await getVersion()));
@@ -1619,6 +2230,87 @@ async function runModels(options = {}) {
1619
2230
  }
1620
2231
  return ids;
1621
2232
  }
2233
+ async function runUsage(options = {}) {
2234
+ const logger = options.logger?.child({ component: "usage" }) ?? noopLogger;
2235
+ logger.debug({ event: "usage.fetch.started" }, "fetching github copilot quota");
2236
+ const response = await new CopilotClient(options).usage();
2237
+ if (!response.ok) {
2238
+ const message = `GitHub Copilot usage request failed with ${response.status}: ${await truncatedResponseText(response)}`;
2239
+ if (response.status === 401 || response.status === 403) {
2240
+ throw new CopilotAuthError(message);
2241
+ }
2242
+ throw new Error(message);
2243
+ }
2244
+ const usage = normalizeCopilotUsage(await response.json().catch(() => ({})));
2245
+ logger.debug(
2246
+ { event: "usage.fetch.succeeded", plan: usage.plan },
2247
+ "github copilot quota fetched"
2248
+ );
2249
+ for (const line of formatCopilotUsage(usage)) {
2250
+ console.log(line);
2251
+ }
2252
+ return usage;
2253
+ }
2254
+ function formatCopilotUsage(usage) {
2255
+ const lines = [];
2256
+ if (usage.plan) {
2257
+ lines.push(`Plan: ${usage.plan}`);
2258
+ }
2259
+ if (usage.quotaResetDate) {
2260
+ lines.push(`Quota resets: ${usage.quotaResetDate}`);
2261
+ }
2262
+ const order = ["premium_interactions", "chat", "completions"];
2263
+ const names = Object.keys(usage.quotas).sort(
2264
+ (a, b) => quotaRank(order, a) - quotaRank(order, b) || a.localeCompare(b)
2265
+ );
2266
+ for (const name of names) {
2267
+ const quota = usage.quotas[name];
2268
+ if (quota) {
2269
+ lines.push(`${quotaLabel(name)}: ${formatQuota(quota)}`);
2270
+ }
2271
+ }
2272
+ if (lines.length === 0) {
2273
+ lines.push("No GitHub Copilot quota information available for this account.");
2274
+ }
2275
+ return lines;
2276
+ }
2277
+ function quotaRank(order, name) {
2278
+ const index = order.indexOf(name);
2279
+ return index === -1 ? order.length : index;
2280
+ }
2281
+ function quotaLabel(name) {
2282
+ switch (name) {
2283
+ case "premium_interactions":
2284
+ return "Premium requests";
2285
+ case "chat":
2286
+ return "Chat";
2287
+ case "completions":
2288
+ return "Completions";
2289
+ default:
2290
+ return name;
2291
+ }
2292
+ }
2293
+ function formatQuota(quota) {
2294
+ if (quota.unlimited) {
2295
+ return "unlimited";
2296
+ }
2297
+ const parts = [];
2298
+ if (quota.used !== void 0 && quota.entitlement !== void 0) {
2299
+ parts.push(`${roundQuota(quota.used)}/${roundQuota(quota.entitlement)} used`);
2300
+ } else if (quota.remaining !== void 0) {
2301
+ parts.push(`${roundQuota(quota.remaining)} remaining`);
2302
+ }
2303
+ if (quota.percentRemaining !== void 0) {
2304
+ parts.push(`${roundQuota(quota.percentRemaining)}% remaining`);
2305
+ }
2306
+ if (quota.overageCount) {
2307
+ parts.push(`${roundQuota(quota.overageCount)} overage`);
2308
+ }
2309
+ return parts.length > 0 ? parts.join(", ") : "n/a";
2310
+ }
2311
+ function roundQuota(value) {
2312
+ return Number.isInteger(value) ? value : Math.round(value * 10) / 10;
2313
+ }
1622
2314
  async function verifyCopilotOAuthToken(token, options = {}) {
1623
2315
  const apiBaseUrl = trimTrailingSlash(
1624
2316
  options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
@@ -1689,6 +2381,7 @@ Usage:
1689
2381
  hoopilot [serve] [options]
1690
2382
  hoopilot login [options]
1691
2383
  hoopilot models [options]
2384
+ hoopilot usage [options]
1692
2385
  hoopilot update
1693
2386
  npx @openhoo/hoopilot [options]
1694
2387
 
@@ -1696,8 +2389,13 @@ Commands:
1696
2389
  serve Start the proxy server (default)
1697
2390
  login Sign in through GitHub OAuth in a browser and verify Copilot access
1698
2391
  models List available GitHub Copilot model IDs
2392
+ usage Show GitHub Copilot quota and premium-request usage
1699
2393
  update, upgrade Update hoopilot to the latest release
1700
2394
 
2395
+ While the server runs, GET /metrics exposes Prometheus metrics (request counts,
2396
+ token usage, latency) and GET /v1/usage returns those metrics plus live Copilot
2397
+ quota as JSON.
2398
+
1701
2399
  Options:
1702
2400
  -p, --port <port> Port to listen on. Default: 4141
1703
2401
  --host <host> Host to listen on. Default: 127.0.0.1
@@ -1719,6 +2417,7 @@ Environment:
1719
2417
  HOOPILOT_LOG_FORMAT json or pretty. Default: pretty
1720
2418
  HOOPILOT_LOG_LEVEL trace, debug, info, warn, error, fatal, or silent
1721
2419
  COPILOT_API_BASE_URL
2420
+ HOOPILOT_GITHUB_API_BASE_URL GitHub REST base for the usage/quota lookup. Default: https://api.github.com
1722
2421
  HOOPILOT_NO_UPDATE_CHECK Set to disable update checks (also NO_UPDATE_NOTIFIER)
1723
2422
  `;
1724
2423
  }
@@ -1732,6 +2431,7 @@ export {
1732
2431
  main,
1733
2432
  parseArgs,
1734
2433
  runModels,
2434
+ runUsage,
1735
2435
  verifyCopilotOAuthToken
1736
2436
  };
1737
2437
  //# sourceMappingURL=cli.js.map