@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/README.md +18 -1
- package/dist/cli.js +776 -76
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +695 -76
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +153 -1
- package/dist/index.d.ts +153 -1
- package/dist/index.js +686 -76
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
|
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
|
|
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
|
|
584
|
-
|
|
585
|
-
|
|
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
|
|
591
|
-
|
|
592
|
-
|
|
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
|
|
598
|
-
logger: requestLogger,
|
|
599
|
-
requestId,
|
|
600
|
-
startedAt
|
|
601
|
-
});
|
|
1096
|
+
return finish(await handleResponses(client, metrics, recordTokens, request, requestLogger));
|
|
602
1097
|
}
|
|
603
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1389
|
+
durationMs,
|
|
848
1390
|
event: "http.request.completed",
|
|
849
|
-
status
|
|
850
|
-
stream
|
|
1391
|
+
status,
|
|
1392
|
+
stream
|
|
851
1393
|
};
|
|
852
|
-
if (
|
|
1394
|
+
if (status >= 500) {
|
|
853
1395
|
logger.error(fields, "request completed with server error");
|
|
854
1396
|
return;
|
|
855
1397
|
}
|
|
856
|
-
if (
|
|
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
|