@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/index.js CHANGED
@@ -105,6 +105,8 @@ var CopilotAuth = class {
105
105
  };
106
106
 
107
107
  // src/copilot.ts
108
+ var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
109
+ var COPILOT_USAGE_API_VERSION = "2025-04-01";
108
110
  function applyCopilotHeaders(headers, token) {
109
111
  headers.set("accept", headers.get("accept") ?? "application/json");
110
112
  headers.set("authorization", `Bearer ${token}`);
@@ -116,12 +118,44 @@ function applyCopilotHeaders(headers, token) {
116
118
  headers.set("x-github-api-version", "2026-06-01");
117
119
  return headers;
118
120
  }
121
+ function applyGithubApiHeaders(headers, token) {
122
+ headers.set("accept", headers.get("accept") ?? "application/json");
123
+ headers.set("authorization", `token ${token}`);
124
+ headers.set("editor-plugin-version", "hoopilot/0.1.0");
125
+ headers.set("editor-version", "Hoopilot/0.1.0");
126
+ headers.set("user-agent", "hoopilot/0.1.0");
127
+ headers.set("x-github-api-version", COPILOT_USAGE_API_VERSION);
128
+ return headers;
129
+ }
119
130
  var CopilotClient = class {
120
131
  #auth;
121
132
  #fetch;
133
+ #githubApiBaseUrl;
122
134
  constructor(options = {}) {
123
135
  this.#auth = new CopilotAuth(options);
124
136
  this.#fetch = options.fetch ?? fetch;
137
+ this.#githubApiBaseUrl = trimTrailingSlash(
138
+ options.githubApiBaseUrl ?? options.env?.HOOPILOT_GITHUB_API_BASE_URL ?? DEFAULT_GITHUB_API_BASE_URL
139
+ );
140
+ }
141
+ /**
142
+ * Fetch the Copilot account's quota / premium-request usage from the GitHub
143
+ * REST `copilot_internal/user` endpoint. The stored device-flow OAuth token is
144
+ * accepted directly here — no Copilot token exchange is required to read quota.
145
+ */
146
+ async usage(signal) {
147
+ if (!isHttpsOrLoopback(this.#githubApiBaseUrl)) {
148
+ throw new Error(
149
+ `Refusing to send the GitHub OAuth token to a non-HTTPS host: ${this.#githubApiBaseUrl}`
150
+ );
151
+ }
152
+ const access = await this.#auth.getAccess();
153
+ const headers = applyGithubApiHeaders(new Headers(), access.token);
154
+ return this.#fetch(`${this.#githubApiBaseUrl}/copilot_internal/user`, {
155
+ headers,
156
+ method: "GET",
157
+ signal
158
+ });
125
159
  }
126
160
  async chatCompletions(body, signal) {
127
161
  return this.fetchCopilot("/chat/completions", {
@@ -161,6 +195,81 @@ var CopilotClient = class {
161
195
  });
162
196
  }
163
197
  };
198
+ function normalizeCopilotUsage(body) {
199
+ const record = asRecord(body);
200
+ const quotas = {};
201
+ const snapshots = asRecord(record.quota_snapshots);
202
+ for (const [category, detail] of Object.entries(snapshots)) {
203
+ quotas[category] = normalizeQuotaDetail(asRecord(detail));
204
+ }
205
+ if (Object.keys(quotas).length === 0) {
206
+ const remaining = asRecord(record.limited_user_quotas);
207
+ const monthly = asRecord(record.monthly_quotas);
208
+ for (const category of /* @__PURE__ */ new Set([...Object.keys(remaining), ...Object.keys(monthly)])) {
209
+ const entitlement = numberOrUndefined(monthly[category]);
210
+ const left = numberOrUndefined(remaining[category]);
211
+ quotas[category] = removeUndefinedQuota({
212
+ entitlement,
213
+ percentRemaining: entitlement !== void 0 && entitlement > 0 && left !== void 0 ? left / entitlement * 100 : void 0,
214
+ remaining: left,
215
+ used: usedFrom(entitlement, left)
216
+ });
217
+ }
218
+ }
219
+ return removeUndefinedUsage({
220
+ accessTypeSku: stringOrUndefined(record.access_type_sku),
221
+ chatEnabled: typeof record.chat_enabled === "boolean" ? record.chat_enabled : void 0,
222
+ plan: stringOrUndefined(record.copilot_plan),
223
+ quotaResetDate: stringOrUndefined(record.quota_reset_date) ?? stringOrUndefined(record.quota_reset_date_utc) ?? stringOrUndefined(record.limited_user_reset_date),
224
+ quotas
225
+ });
226
+ }
227
+ function normalizeQuotaDetail(detail) {
228
+ const entitlement = numberOrUndefined(detail.entitlement);
229
+ const remaining = numberOrUndefined(detail.remaining) ?? numberOrUndefined(detail.quota_remaining);
230
+ return removeUndefinedQuota({
231
+ entitlement,
232
+ overageCount: numberOrUndefined(detail.overage_count),
233
+ overagePermitted: typeof detail.overage_permitted === "boolean" ? detail.overage_permitted : void 0,
234
+ percentRemaining: numberOrUndefined(detail.percent_remaining),
235
+ remaining,
236
+ unlimited: typeof detail.unlimited === "boolean" ? detail.unlimited : void 0,
237
+ used: usedFrom(entitlement, remaining)
238
+ });
239
+ }
240
+ function usedFrom(entitlement, remaining) {
241
+ if (entitlement === void 0 || remaining === void 0) {
242
+ return void 0;
243
+ }
244
+ return Math.max(0, entitlement - remaining);
245
+ }
246
+ function isHttpsOrLoopback(rawUrl) {
247
+ let url;
248
+ try {
249
+ url = new URL(rawUrl);
250
+ } catch {
251
+ return false;
252
+ }
253
+ if (url.protocol === "https:") {
254
+ return true;
255
+ }
256
+ return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1");
257
+ }
258
+ function numberOrUndefined(value) {
259
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
260
+ }
261
+ function stringOrUndefined(value) {
262
+ return typeof value === "string" && value.length > 0 ? value : void 0;
263
+ }
264
+ function removeUndefinedQuota(quota) {
265
+ return Object.fromEntries(
266
+ Object.entries(quota).filter(([, value]) => value !== void 0)
267
+ );
268
+ }
269
+ function removeUndefinedUsage(usage) {
270
+ const entries = Object.entries(usage).filter(([, value]) => value !== void 0);
271
+ return Object.fromEntries(entries);
272
+ }
164
273
 
165
274
  // src/github-device.ts
166
275
  import { setTimeout as sleep } from "timers/promises";
@@ -840,6 +949,40 @@ function responseUsage(usage) {
840
949
  total_tokens: record.total_tokens
841
950
  });
842
951
  }
952
+ function extractTokenUsage(usage) {
953
+ const record = asRecord(usage);
954
+ const prompt = firstNumber(record.prompt_tokens, record.input_tokens);
955
+ const completion = firstNumber(record.completion_tokens, record.output_tokens);
956
+ const total = firstNumber(record.total_tokens);
957
+ if (prompt === void 0 && completion === void 0 && total === void 0) {
958
+ return void 0;
959
+ }
960
+ const promptTokens = prompt ?? 0;
961
+ const completionTokens = completion ?? 0;
962
+ const reasoning = firstNumber(
963
+ asRecord(record.completion_tokens_details).reasoning_tokens,
964
+ asRecord(record.output_tokens_details).reasoning_tokens
965
+ );
966
+ const cached = firstNumber(
967
+ asRecord(record.prompt_tokens_details).cached_tokens,
968
+ asRecord(record.input_tokens_details).cached_tokens
969
+ );
970
+ return removeUndefined({
971
+ cachedTokens: cached,
972
+ completionTokens,
973
+ promptTokens,
974
+ reasoningTokens: reasoning,
975
+ totalTokens: total ?? promptTokens + completionTokens
976
+ });
977
+ }
978
+ function firstNumber(...values) {
979
+ for (const value of values) {
980
+ if (typeof value === "number" && Number.isFinite(value)) {
981
+ return value;
982
+ }
983
+ }
984
+ return void 0;
985
+ }
843
986
  function firstChoice(completion) {
844
987
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
845
988
  return asRecord(choices[0]);
@@ -933,104 +1076,449 @@ function epochSeconds() {
933
1076
  return Math.floor(Date.now() / 1e3);
934
1077
  }
935
1078
 
1079
+ // src/metrics.ts
1080
+ var PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
1081
+ var DURATION_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
1082
+ var USAGE_BUFFER_LIMIT_BYTES = 16 * 1024 * 1024;
1083
+ var MAX_TRACKED_MODELS = 200;
1084
+ var MAX_MODEL_LABEL_LENGTH = 200;
1085
+ var LABEL_SEPARATOR = "";
1086
+ var UNKNOWN_MODEL = "unknown";
1087
+ function emptyModelTotals() {
1088
+ return { cached: 0, completion: 0, prompt: 0, reasoning: 0, requests: 0, total: 0 };
1089
+ }
1090
+ var MetricsRegistry = class {
1091
+ #startedAtMs;
1092
+ #inFlight = 0;
1093
+ #requests = /* @__PURE__ */ new Map();
1094
+ #durations = /* @__PURE__ */ new Map();
1095
+ #tokens = /* @__PURE__ */ new Map();
1096
+ #upstream = /* @__PURE__ */ new Map();
1097
+ #copilotQuota;
1098
+ constructor(options = {}) {
1099
+ this.#startedAtMs = (options.now ?? Date.now)();
1100
+ }
1101
+ /** Mark a request as started; pair with exactly one {@link observe}. */
1102
+ startRequest() {
1103
+ this.#inFlight += 1;
1104
+ }
1105
+ /** Record a completed request and clear its in-flight slot. */
1106
+ observe(observation) {
1107
+ if (this.#inFlight > 0) {
1108
+ this.#inFlight -= 1;
1109
+ }
1110
+ const key = labelKey(observation.route, observation.method, String(observation.status));
1111
+ this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
1112
+ this.#observeDuration(observation.route, observation.durationMs / 1e3);
1113
+ }
1114
+ /** Accumulate token counts for a model from one upstream completion. */
1115
+ recordTokens(model, usage) {
1116
+ const name = this.#modelLabel(model);
1117
+ const totals = this.#tokens.get(name) ?? emptyModelTotals();
1118
+ totals.requests += 1;
1119
+ totals.prompt += nonNegative(usage.promptTokens);
1120
+ totals.completion += nonNegative(usage.completionTokens);
1121
+ totals.total += nonNegative(usage.totalTokens);
1122
+ totals.reasoning += nonNegative(usage.reasoningTokens ?? 0);
1123
+ totals.cached += nonNegative(usage.cachedTokens ?? 0);
1124
+ this.#tokens.set(name, totals);
1125
+ }
1126
+ /** Record one upstream Copilot call and whether it succeeded. */
1127
+ recordUpstream(path, ok) {
1128
+ const key = labelKey(path, ok ? "ok" : "error");
1129
+ this.#upstream.set(key, (this.#upstream.get(key) ?? 0) + 1);
1130
+ }
1131
+ /** Store the latest Copilot quota so /metrics can expose it as gauges. */
1132
+ recordCopilotQuota(usage) {
1133
+ this.#copilotQuota = usage;
1134
+ }
1135
+ // Sanitize the model into a bounded, control-char-free label. The model can
1136
+ // originate from a client request, so cap its length, strip characters that
1137
+ // would corrupt the exposition format, and fold overflow past the cardinality
1138
+ // limit into UNKNOWN_MODEL to keep the series count bounded.
1139
+ #modelLabel(model) {
1140
+ const cleaned = model.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
1141
+ if (!this.#tokens.has(cleaned) && this.#tokens.size >= MAX_TRACKED_MODELS) {
1142
+ return UNKNOWN_MODEL;
1143
+ }
1144
+ return cleaned;
1145
+ }
1146
+ #observeDuration(route, seconds) {
1147
+ const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
1148
+ const entry = this.#durations.get(route) ?? {
1149
+ buckets: new Array(DURATION_BUCKETS_SECONDS.length).fill(0),
1150
+ count: 0,
1151
+ sum: 0
1152
+ };
1153
+ entry.count += 1;
1154
+ entry.sum += value;
1155
+ const index = DURATION_BUCKETS_SECONDS.findIndex((bound) => value <= bound);
1156
+ if (index !== -1) {
1157
+ entry.buckets[index] = (entry.buckets[index] ?? 0) + 1;
1158
+ }
1159
+ this.#durations.set(route, entry);
1160
+ }
1161
+ /** A JSON-friendly view of the current counters. */
1162
+ snapshot(now = Date.now) {
1163
+ const byRoute = {};
1164
+ const byStatus = {};
1165
+ let requestsTotal = 0;
1166
+ for (const [key, count] of this.#requests) {
1167
+ const [route = "", , status = ""] = key.split(LABEL_SEPARATOR);
1168
+ byRoute[route] = (byRoute[route] ?? 0) + count;
1169
+ byStatus[status] = (byStatus[status] ?? 0) + count;
1170
+ requestsTotal += count;
1171
+ }
1172
+ const byModel = {};
1173
+ const tokenTotals = { cached: 0, completion: 0, prompt: 0, reasoning: 0, total: 0 };
1174
+ for (const [model, totals] of this.#tokens) {
1175
+ byModel[model] = { ...totals };
1176
+ tokenTotals.prompt += totals.prompt;
1177
+ tokenTotals.completion += totals.completion;
1178
+ tokenTotals.total += totals.total;
1179
+ tokenTotals.reasoning += totals.reasoning;
1180
+ tokenTotals.cached += totals.cached;
1181
+ }
1182
+ let upstreamTotal = 0;
1183
+ let upstreamErrors = 0;
1184
+ for (const [key, count] of this.#upstream) {
1185
+ upstreamTotal += count;
1186
+ if (key.endsWith(`${LABEL_SEPARATOR}error`)) {
1187
+ upstreamErrors += count;
1188
+ }
1189
+ }
1190
+ return {
1191
+ inFlight: this.#inFlight,
1192
+ requests: { byRoute, byStatus, total: requestsTotal },
1193
+ startedAt: new Date(this.#startedAtMs).toISOString(),
1194
+ tokens: { byModel, ...tokenTotals },
1195
+ upstream: { errors: upstreamErrors, total: upstreamTotal },
1196
+ uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
1197
+ };
1198
+ }
1199
+ /** Render the Prometheus text exposition format (version 0.0.4). */
1200
+ renderPrometheus(now = Date.now) {
1201
+ const lines = [];
1202
+ lines.push("# HELP hoopilot_process_start_time_seconds Unix epoch when the proxy started.");
1203
+ lines.push("# TYPE hoopilot_process_start_time_seconds gauge");
1204
+ lines.push(`hoopilot_process_start_time_seconds ${this.#startedAtMs / 1e3}`);
1205
+ lines.push("# HELP hoopilot_uptime_seconds Seconds since the proxy started.");
1206
+ lines.push("# TYPE hoopilot_uptime_seconds gauge");
1207
+ lines.push(`hoopilot_uptime_seconds ${Math.max(0, (now() - this.#startedAtMs) / 1e3)}`);
1208
+ lines.push("# HELP hoopilot_requests_in_flight Requests currently being served.");
1209
+ lines.push("# TYPE hoopilot_requests_in_flight gauge");
1210
+ lines.push(`hoopilot_requests_in_flight ${this.#inFlight}`);
1211
+ lines.push("# HELP hoopilot_requests_total Completed requests by route, method, and status.");
1212
+ lines.push("# TYPE hoopilot_requests_total counter");
1213
+ for (const [key, count] of this.#requests) {
1214
+ const [route = "", method = "", status = ""] = key.split(LABEL_SEPARATOR);
1215
+ lines.push(`hoopilot_requests_total${labels({ method, route, status })} ${count}`);
1216
+ }
1217
+ lines.push(
1218
+ "# HELP hoopilot_upstream_requests_total Copilot upstream calls by path and outcome."
1219
+ );
1220
+ lines.push("# TYPE hoopilot_upstream_requests_total counter");
1221
+ for (const [key, count] of this.#upstream) {
1222
+ const [path = "", outcome = ""] = key.split(LABEL_SEPARATOR);
1223
+ lines.push(`hoopilot_upstream_requests_total${labels({ outcome, path })} ${count}`);
1224
+ }
1225
+ lines.push(
1226
+ "# HELP hoopilot_tokens_total Tokens reported by upstream usage, by model and type."
1227
+ );
1228
+ lines.push("# TYPE hoopilot_tokens_total counter");
1229
+ for (const [model, totals] of this.#tokens) {
1230
+ lines.push(`hoopilot_tokens_total${labels({ model, type: "prompt" })} ${totals.prompt}`);
1231
+ lines.push(
1232
+ `hoopilot_tokens_total${labels({ model, type: "completion" })} ${totals.completion}`
1233
+ );
1234
+ lines.push(
1235
+ `hoopilot_tokens_total${labels({ model, type: "reasoning" })} ${totals.reasoning}`
1236
+ );
1237
+ lines.push(`hoopilot_tokens_total${labels({ model, type: "cached" })} ${totals.cached}`);
1238
+ }
1239
+ lines.push("# HELP hoopilot_model_requests_total Completions with usage observed, by model.");
1240
+ lines.push("# TYPE hoopilot_model_requests_total counter");
1241
+ for (const [model, totals] of this.#tokens) {
1242
+ lines.push(`hoopilot_model_requests_total${labels({ model })} ${totals.requests}`);
1243
+ }
1244
+ lines.push("# HELP hoopilot_request_duration_seconds Request duration by route.");
1245
+ lines.push("# TYPE hoopilot_request_duration_seconds histogram");
1246
+ for (const [route, entry] of this.#durations) {
1247
+ let cumulative = 0;
1248
+ for (let i = 0; i < DURATION_BUCKETS_SECONDS.length; i += 1) {
1249
+ cumulative += entry.buckets[i] ?? 0;
1250
+ const le = formatNumber(DURATION_BUCKETS_SECONDS[i] ?? 0);
1251
+ lines.push(
1252
+ `hoopilot_request_duration_seconds_bucket${labels({ le, route })} ${cumulative}`
1253
+ );
1254
+ }
1255
+ lines.push(
1256
+ `hoopilot_request_duration_seconds_bucket${labels({ le: "+Inf", route })} ${entry.count}`
1257
+ );
1258
+ lines.push(`hoopilot_request_duration_seconds_sum${labels({ route })} ${entry.sum}`);
1259
+ lines.push(`hoopilot_request_duration_seconds_count${labels({ route })} ${entry.count}`);
1260
+ }
1261
+ this.#renderCopilotQuota(lines);
1262
+ return `${lines.join("\n")}
1263
+ `;
1264
+ }
1265
+ #renderCopilotQuota(lines) {
1266
+ const usage = this.#copilotQuota;
1267
+ if (!usage) {
1268
+ return;
1269
+ }
1270
+ const categories = Object.entries(usage.quotas);
1271
+ const gauge = (suffix, help, pick) => {
1272
+ const present = categories.filter(([, quota]) => pick(quota) !== void 0);
1273
+ if (present.length === 0) {
1274
+ return;
1275
+ }
1276
+ lines.push(`# HELP hoopilot_copilot_quota_${suffix} ${help}`);
1277
+ lines.push(`# TYPE hoopilot_copilot_quota_${suffix} gauge`);
1278
+ for (const [category, quota] of present) {
1279
+ lines.push(`hoopilot_copilot_quota_${suffix}${labels({ category })} ${pick(quota)}`);
1280
+ }
1281
+ };
1282
+ gauge("remaining", "Remaining quota for the Copilot category.", (q) => q.remaining);
1283
+ gauge("entitlement", "Quota entitlement for the Copilot category.", (q) => q.entitlement);
1284
+ gauge("used", "Used quota (entitlement minus remaining) for the category.", (q) => q.used);
1285
+ gauge(
1286
+ "percent_remaining",
1287
+ "Percent of quota remaining for the Copilot category.",
1288
+ (q) => q.percentRemaining
1289
+ );
1290
+ const resetMs = usage.quotaResetDate ? Date.parse(usage.quotaResetDate) : Number.NaN;
1291
+ if (Number.isFinite(resetMs)) {
1292
+ lines.push(
1293
+ "# HELP hoopilot_copilot_quota_reset_timestamp_seconds Unix epoch of the next reset."
1294
+ );
1295
+ lines.push("# TYPE hoopilot_copilot_quota_reset_timestamp_seconds gauge");
1296
+ lines.push(`hoopilot_copilot_quota_reset_timestamp_seconds ${resetMs / 1e3}`);
1297
+ }
1298
+ if (usage.plan || usage.accessTypeSku) {
1299
+ lines.push("# HELP hoopilot_copilot_info Copilot plan metadata as a constant-1 info gauge.");
1300
+ lines.push("# TYPE hoopilot_copilot_info gauge");
1301
+ lines.push(
1302
+ `hoopilot_copilot_info${labels({
1303
+ access_type_sku: usage.accessTypeSku ?? "",
1304
+ plan: usage.plan ?? ""
1305
+ })} 1`
1306
+ );
1307
+ }
1308
+ }
1309
+ };
1310
+ function observeResponseUsage(response, fallbackModel, onUsage, signal) {
1311
+ const body = response.body;
1312
+ if (!body) {
1313
+ return response;
1314
+ }
1315
+ const [clientBranch, observerBranch] = body.tee();
1316
+ const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
1317
+ void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal).catch(() => {
1318
+ });
1319
+ return new Response(clientBranch, {
1320
+ headers: response.headers,
1321
+ status: response.status,
1322
+ statusText: response.statusText
1323
+ });
1324
+ }
1325
+ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal) {
1326
+ const reader = stream.getReader();
1327
+ const onAbort = () => {
1328
+ reader.cancel().catch(() => {
1329
+ });
1330
+ };
1331
+ if (signal?.aborted) {
1332
+ reader.cancel().catch(() => {
1333
+ });
1334
+ } else {
1335
+ signal?.addEventListener("abort", onAbort, { once: true });
1336
+ }
1337
+ const decoder = new TextDecoder();
1338
+ let model = fallbackModel;
1339
+ let usage;
1340
+ let buffer = "";
1341
+ let bufferedBytes = 0;
1342
+ let overflowed = false;
1343
+ const consider = (payload) => {
1344
+ const record = asRecord(payload);
1345
+ const found = extractTokenUsage(record.usage) ?? extractTokenUsage(asRecord(record.response).usage);
1346
+ if (found) {
1347
+ usage = found;
1348
+ }
1349
+ const candidateModel = modelText(record.model) || modelText(asRecord(record.response).model);
1350
+ if (candidateModel) {
1351
+ model = candidateModel;
1352
+ }
1353
+ };
1354
+ try {
1355
+ while (true) {
1356
+ const result = await reader.read();
1357
+ if (result.done) {
1358
+ break;
1359
+ }
1360
+ const chunk = decoder.decode(result.value, { stream: true });
1361
+ if (isSse) {
1362
+ buffer += chunk;
1363
+ const lines = buffer.split(/\r?\n/);
1364
+ buffer = lines.pop() ?? "";
1365
+ for (const line of lines) {
1366
+ considerSseLine(line, consider);
1367
+ }
1368
+ if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
1369
+ buffer = "";
1370
+ }
1371
+ } else if (!overflowed) {
1372
+ bufferedBytes += result.value.byteLength;
1373
+ if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
1374
+ overflowed = true;
1375
+ buffer = "";
1376
+ } else {
1377
+ buffer += chunk;
1378
+ }
1379
+ }
1380
+ }
1381
+ const finalBuffer = buffer + decoder.decode();
1382
+ if (isSse) {
1383
+ if (finalBuffer) {
1384
+ considerSseLine(finalBuffer, consider);
1385
+ }
1386
+ } else if (!overflowed && finalBuffer) {
1387
+ const parsed = safeParse(finalBuffer);
1388
+ if (parsed !== void 0) {
1389
+ consider(parsed);
1390
+ }
1391
+ }
1392
+ } finally {
1393
+ signal?.removeEventListener("abort", onAbort);
1394
+ reader.releaseLock();
1395
+ }
1396
+ if (usage) {
1397
+ onUsage(model, usage);
1398
+ }
1399
+ }
1400
+ function considerSseLine(line, consider) {
1401
+ const trimmed = line.trim();
1402
+ if (!trimmed.startsWith("data:")) {
1403
+ return;
1404
+ }
1405
+ const data = trimmed.slice("data:".length).trim();
1406
+ if (!data || data === "[DONE]") {
1407
+ return;
1408
+ }
1409
+ const parsed = safeParse(data);
1410
+ if (parsed !== void 0) {
1411
+ consider(parsed);
1412
+ }
1413
+ }
1414
+ function safeParse(text) {
1415
+ try {
1416
+ return JSON.parse(text);
1417
+ } catch {
1418
+ return void 0;
1419
+ }
1420
+ }
1421
+ function modelText(value) {
1422
+ return typeof value === "string" ? value.trim() : "";
1423
+ }
1424
+ function nonNegative(value) {
1425
+ return Number.isFinite(value) && value > 0 ? value : 0;
1426
+ }
1427
+ function labelKey(...parts) {
1428
+ return parts.join(LABEL_SEPARATOR);
1429
+ }
1430
+ function labels(pairs) {
1431
+ const entries = Object.entries(pairs);
1432
+ if (entries.length === 0) {
1433
+ return "";
1434
+ }
1435
+ const rendered = entries.map(([name, value]) => `${name}="${escapeLabelValue(value)}"`);
1436
+ return `{${rendered.join(",")}}`;
1437
+ }
1438
+ function escapeLabelValue(value) {
1439
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r");
1440
+ }
1441
+ function formatNumber(value) {
1442
+ return Number.isInteger(value) ? value.toString() : String(value);
1443
+ }
1444
+
936
1445
  // src/server.ts
937
1446
  var DEFAULT_HOST = "127.0.0.1";
938
1447
  var DEFAULT_PORT = 4141;
939
1448
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
1449
+ var USAGE_CACHE_TTL_MS = 6e4;
940
1450
  function createHoopilotHandler(options = {}) {
941
1451
  const client = new CopilotClient(options);
942
1452
  const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
943
1453
  const logger = serverLogger(options);
1454
+ const metrics = options.metrics ?? new MetricsRegistry();
1455
+ const readUsage = createUsageReader(client, metrics);
1456
+ const recordTokens = (model, usage) => metrics.recordTokens(model, usage);
944
1457
  return async (request) => {
945
1458
  const startedAt = performance.now();
946
1459
  const url = new URL(request.url);
947
1460
  const apiPath = canonicalApiPath(url.pathname);
948
1461
  const requestId = requestIdFor(request);
1462
+ const route = routeFor(request.method, apiPath);
949
1463
  const requestLogger = logger.child({
950
1464
  method: request.method,
951
1465
  path: url.pathname,
952
1466
  requestId,
953
- route: routeFor(request.method, apiPath)
1467
+ route
1468
+ });
1469
+ metrics.startRequest();
1470
+ const finish = (response) => finishResponse(response, {
1471
+ logger: requestLogger,
1472
+ method: request.method,
1473
+ metrics,
1474
+ requestId,
1475
+ route,
1476
+ startedAt
954
1477
  });
955
1478
  if (request.method === "OPTIONS") {
956
- return finishResponse(new Response(null, { headers: corsHeaders() }), {
957
- logger: requestLogger,
958
- requestId,
959
- startedAt
960
- });
1479
+ return finish(new Response(null, { headers: corsHeaders() }));
961
1480
  }
962
1481
  if (!isAuthorized(request, apiKey)) {
963
1482
  requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
964
- return finishResponse(
965
- jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."),
966
- {
967
- logger: requestLogger,
968
- requestId,
969
- startedAt
970
- }
971
- );
1483
+ return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
972
1484
  }
973
1485
  try {
974
1486
  if (request.method === "GET" && (apiPath === "/" || apiPath === "/healthz")) {
975
- return finishResponse(
976
- jsonResponse({
977
- name: "hoopilot",
978
- object: "health",
979
- status: "ok"
980
- }),
981
- { logger: requestLogger, requestId, startedAt }
982
- );
1487
+ return finish(jsonResponse({ name: "hoopilot", object: "health", status: "ok" }));
1488
+ }
1489
+ if (request.method === "GET" && apiPath === "/metrics") {
1490
+ return finish(metricsResponse(metrics));
1491
+ }
1492
+ if (request.method === "GET" && apiPath === "/v1/usage") {
1493
+ return finish(await handleUsage(metrics, readUsage, request.signal));
983
1494
  }
984
1495
  if (request.method === "GET" && apiPath === "/v1/responses") {
985
- return finishResponse(websocketUnsupportedResponse(), {
986
- logger: requestLogger,
987
- requestId,
988
- startedAt
989
- });
1496
+ return finish(websocketUnsupportedResponse());
990
1497
  }
991
1498
  if (request.method === "GET" && apiPath === "/v1/models") {
992
- return finishResponse(await handleModels(client, request.signal, requestLogger), {
993
- logger: requestLogger,
994
- requestId,
995
- startedAt
996
- });
1499
+ return finish(await handleModels(client, metrics, request.signal, requestLogger));
997
1500
  }
998
1501
  if (request.method === "POST" && apiPath === "/v1/chat/completions") {
999
- return finishResponse(await handleChatCompletions(client, request, requestLogger), {
1000
- logger: requestLogger,
1001
- requestId,
1002
- startedAt
1003
- });
1502
+ return finish(
1503
+ await handleChatCompletions(client, metrics, recordTokens, request, requestLogger)
1504
+ );
1004
1505
  }
1005
1506
  if (request.method === "POST" && apiPath === "/v1/completions") {
1006
- return finishResponse(await handleCompletions(client, request, requestLogger), {
1007
- logger: requestLogger,
1008
- requestId,
1009
- startedAt
1010
- });
1507
+ return finish(
1508
+ await handleCompletions(client, metrics, recordTokens, request, requestLogger)
1509
+ );
1011
1510
  }
1012
1511
  if (request.method === "POST" && apiPath === "/v1/responses") {
1013
- return finishResponse(await handleResponses(client, request, requestLogger), {
1014
- logger: requestLogger,
1015
- requestId,
1016
- startedAt
1017
- });
1512
+ return finish(await handleResponses(client, metrics, recordTokens, request, requestLogger));
1018
1513
  }
1019
- return finishResponse(
1020
- jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`),
1021
- { logger: requestLogger, requestId, startedAt }
1022
- );
1514
+ return finish(jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`));
1023
1515
  } catch (error) {
1024
1516
  if (error instanceof CopilotAuthError) {
1025
1517
  requestLogger.warn(
1026
1518
  { err: errorDetails(error), event: "copilot.auth.missing" },
1027
1519
  "copilot auth failed"
1028
1520
  );
1029
- return finishResponse(jsonError(401, "copilot_auth_error", error.message), {
1030
- logger: requestLogger,
1031
- requestId,
1032
- startedAt
1033
- });
1521
+ return finish(jsonError(401, "copilot_auth_error", error.message));
1034
1522
  }
1035
1523
  const message = errorMessage(error);
1036
1524
  if (message === INVALID_JSON_MESSAGE) {
@@ -1038,17 +1526,14 @@ function createHoopilotHandler(options = {}) {
1038
1526
  { err: errorDetails(error), event: "http.request.failed" },
1039
1527
  "request body was invalid json"
1040
1528
  );
1529
+ return finish(jsonError(400, "invalid_request_error", message));
1041
1530
  } else {
1042
1531
  requestLogger.error(
1043
1532
  { err: errorDetails(error), event: "http.request.failed" },
1044
1533
  "request failed"
1045
1534
  );
1046
1535
  }
1047
- return finishResponse(jsonError(500, "internal_error", message), {
1048
- logger: requestLogger,
1049
- requestId,
1050
- startedAt
1051
- });
1536
+ return finish(jsonError(500, "internal_error", message));
1052
1537
  }
1053
1538
  };
1054
1539
  }
@@ -1077,8 +1562,9 @@ function startHoopilotServer(options = {}) {
1077
1562
  url: `http://${host}:${server.port}`
1078
1563
  };
1079
1564
  }
1080
- async function handleModels(client, signal, logger) {
1565
+ async function handleModels(client, metrics, signal, logger) {
1081
1566
  const upstream = await client.models(signal);
1567
+ metrics.recordUpstream("/models", upstream.ok);
1082
1568
  if (!upstream.ok) {
1083
1569
  if (isUpstreamAuthStatus(upstream.status)) {
1084
1570
  return proxyError(upstream, logger);
@@ -1096,38 +1582,50 @@ async function handleModels(client, signal, logger) {
1096
1582
  logUpstreamSuccess(logger, "/models", upstream.status);
1097
1583
  return jsonResponse(normalizeModelsResponse(await upstream.json()));
1098
1584
  }
1099
- async function handleChatCompletions(client, request, logger) {
1585
+ async function handleChatCompletions(client, metrics, recordTokens, request, logger) {
1100
1586
  const chatRequest = normalizeChatCompletionRequest(await readJson(request));
1101
1587
  const upstream = await client.chatCompletions(chatRequest, request.signal);
1588
+ metrics.recordUpstream("/chat/completions", upstream.ok);
1102
1589
  if (!upstream.ok) {
1103
1590
  return proxyError(upstream, logger);
1104
1591
  }
1105
1592
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
1106
- return proxyResponse(upstream);
1593
+ const model = normalizeRequestedModel(chatRequest.model);
1594
+ return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
1107
1595
  }
1108
- async function handleCompletions(client, request, logger) {
1596
+ async function handleCompletions(client, metrics, recordTokens, request, logger) {
1109
1597
  const body = await readJson(request);
1110
1598
  const upstream = await client.chatCompletions(
1111
1599
  completionsRequestToChatCompletion(body),
1112
1600
  request.signal
1113
1601
  );
1602
+ metrics.recordUpstream("/chat/completions", upstream.ok);
1114
1603
  if (!upstream.ok) {
1115
1604
  return proxyError(upstream, logger);
1116
1605
  }
1117
1606
  logUpstreamSuccess(logger, "/chat/completions", upstream.status);
1607
+ const model = normalizeRequestedModel(body.model);
1118
1608
  if (isStreamingResponse(upstream)) {
1119
- return proxyResponse(upstream);
1609
+ return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
1610
+ }
1611
+ const completion = asRecord(await upstream.json());
1612
+ const usage = extractTokenUsage(completion.usage);
1613
+ if (usage) {
1614
+ const responseModel = typeof completion.model === "string" ? completion.model.trim() : "";
1615
+ recordTokens(responseModel || model, usage);
1120
1616
  }
1121
- return jsonResponse(chatCompletionToCompletion(await upstream.json()));
1617
+ return jsonResponse(chatCompletionToCompletion(completion));
1122
1618
  }
1123
- async function handleResponses(client, request, logger) {
1619
+ async function handleResponses(client, metrics, recordTokens, request, logger) {
1124
1620
  const body = await readJsonText(request);
1125
1621
  const upstream = await client.responses(body, request.signal);
1622
+ metrics.recordUpstream("/responses", upstream.ok);
1126
1623
  if (!upstream.ok) {
1127
1624
  return proxyError(upstream, logger);
1128
1625
  }
1129
1626
  logUpstreamSuccess(logger, "/responses", upstream.status);
1130
- return proxyResponse(upstream);
1627
+ const model = normalizeRequestedModel(asRecord(safeParseJson(body)).model);
1628
+ return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
1131
1629
  }
1132
1630
  async function proxyError(upstream, logger) {
1133
1631
  const text = await upstream.text();
@@ -1246,7 +1744,21 @@ function serverLogger(options) {
1246
1744
  }
1247
1745
  function finishResponse(response, options) {
1248
1746
  const withRequestId = responseWithRequestId(response, options.requestId);
1249
- logRequestCompleted(options.logger, withRequestId, options.startedAt);
1747
+ const stream = isStreamingResponse(withRequestId);
1748
+ const status = withRequestId.status;
1749
+ const complete = () => {
1750
+ const durationMs = Math.round((performance.now() - options.startedAt) * 100) / 100;
1751
+ options.metrics.observe({ durationMs, method: options.method, route: options.route, status });
1752
+ logRequestCompleted(options.logger, status, stream, durationMs);
1753
+ };
1754
+ if (stream && withRequestId.body) {
1755
+ return new Response(trackStreamCompletion(withRequestId.body, complete), {
1756
+ headers: withRequestId.headers,
1757
+ status,
1758
+ statusText: withRequestId.statusText
1759
+ });
1760
+ }
1761
+ complete();
1250
1762
  return withRequestId;
1251
1763
  }
1252
1764
  function responseWithRequestId(response, requestId) {
@@ -1258,18 +1770,48 @@ function responseWithRequestId(response, requestId) {
1258
1770
  statusText: response.statusText
1259
1771
  });
1260
1772
  }
1261
- function logRequestCompleted(logger, response, startedAt) {
1773
+ function trackStreamCompletion(body, onComplete) {
1774
+ const reader = body.getReader();
1775
+ let fired = false;
1776
+ const fire = () => {
1777
+ if (!fired) {
1778
+ fired = true;
1779
+ onComplete();
1780
+ }
1781
+ };
1782
+ return new ReadableStream({
1783
+ async pull(controller) {
1784
+ try {
1785
+ const { done, value } = await reader.read();
1786
+ if (done) {
1787
+ controller.close();
1788
+ fire();
1789
+ return;
1790
+ }
1791
+ controller.enqueue(value);
1792
+ } catch (error) {
1793
+ fire();
1794
+ controller.error(error);
1795
+ }
1796
+ },
1797
+ cancel(reason) {
1798
+ fire();
1799
+ return reader.cancel(reason);
1800
+ }
1801
+ });
1802
+ }
1803
+ function logRequestCompleted(logger, status, stream, durationMs) {
1262
1804
  const fields = {
1263
- durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
1805
+ durationMs,
1264
1806
  event: "http.request.completed",
1265
- status: response.status,
1266
- stream: isStreamingResponse(response)
1807
+ status,
1808
+ stream
1267
1809
  };
1268
- if (response.status >= 500) {
1810
+ if (status >= 500) {
1269
1811
  logger.error(fields, "request completed with server error");
1270
1812
  return;
1271
1813
  }
1272
- if (response.status >= 400) {
1814
+ if (status >= 400) {
1273
1815
  logger.warn(fields, "request completed with client error");
1274
1816
  return;
1275
1817
  }
@@ -1290,6 +1832,8 @@ function canonicalApiPath(path) {
1290
1832
  return "/v1/completions";
1291
1833
  case "/responses":
1292
1834
  return "/v1/responses";
1835
+ case "/usage":
1836
+ return "/v1/usage";
1293
1837
  default:
1294
1838
  return withoutTrailingSlash;
1295
1839
  }
@@ -1301,6 +1845,12 @@ function routeFor(method, path) {
1301
1845
  if (method === "GET" && (path === "/" || path === "/healthz")) {
1302
1846
  return "health";
1303
1847
  }
1848
+ if (method === "GET" && path === "/metrics") {
1849
+ return "metrics";
1850
+ }
1851
+ if (method === "GET" && path === "/v1/usage") {
1852
+ return "usage";
1853
+ }
1304
1854
  if (method === "GET" && path === "/v1/models") {
1305
1855
  return "models";
1306
1856
  }
@@ -1331,25 +1881,85 @@ function logUpstreamSuccess(logger, upstreamPath, status) {
1331
1881
  "copilot upstream request completed"
1332
1882
  );
1333
1883
  }
1884
+ function metricsResponse(metrics) {
1885
+ return new Response(metrics.renderPrometheus(), {
1886
+ headers: {
1887
+ ...corsHeaders(),
1888
+ "content-type": PROMETHEUS_CONTENT_TYPE
1889
+ },
1890
+ status: 200
1891
+ });
1892
+ }
1893
+ async function handleUsage(metrics, readUsage, signal) {
1894
+ const proxy = metrics.snapshot();
1895
+ const { copilot, error } = await readUsage(signal);
1896
+ const body = { copilot: copilot ?? null, object: "usage", proxy };
1897
+ if (error) {
1898
+ body.copilot_error = error;
1899
+ }
1900
+ return jsonResponse(body);
1901
+ }
1902
+ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_TTL_MS) {
1903
+ const usagePath = "/copilot_internal/user";
1904
+ let cache;
1905
+ return async (signal) => {
1906
+ if (cache && now() - cache.atMs < ttlMs) {
1907
+ return { copilot: cache.value };
1908
+ }
1909
+ try {
1910
+ const upstream = await client.usage(signal);
1911
+ metrics.recordUpstream(usagePath, upstream.ok);
1912
+ if (!upstream.ok) {
1913
+ return { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
1914
+ }
1915
+ const value = normalizeCopilotUsage(await upstream.json().catch(() => ({})));
1916
+ cache = { atMs: now(), value };
1917
+ metrics.recordCopilotQuota(value);
1918
+ return { copilot: value };
1919
+ } catch (error) {
1920
+ metrics.recordUpstream(usagePath, false);
1921
+ if (error instanceof CopilotAuthError) {
1922
+ return { error: error.message };
1923
+ }
1924
+ return { error: errorMessage(error) };
1925
+ }
1926
+ };
1927
+ }
1928
+ function safeParseJson(text) {
1929
+ try {
1930
+ return JSON.parse(text);
1931
+ } catch {
1932
+ return void 0;
1933
+ }
1934
+ }
1334
1935
  export {
1936
+ COPILOT_USAGE_API_VERSION,
1335
1937
  CopilotAuth,
1336
1938
  CopilotAuthError,
1337
1939
  CopilotClient,
1940
+ DEFAULT_GITHUB_API_BASE_URL,
1338
1941
  DEFAULT_LOG_FORMAT,
1339
1942
  DEFAULT_LOG_LEVEL,
1340
1943
  DEFAULT_MODEL,
1944
+ MetricsRegistry,
1945
+ PROMETHEUS_CONTENT_TYPE,
1946
+ applyCopilotHeaders,
1947
+ applyGithubApiHeaders,
1341
1948
  authStorePath,
1342
1949
  chatCompletionToCompletion,
1343
1950
  chatCompletionToResponse,
1344
1951
  completionsRequestToChatCompletion,
1345
1952
  createHoopilotHandler,
1346
1953
  createHoopilotLogger,
1954
+ extractTokenUsage,
1347
1955
  fallbackModels,
1348
1956
  githubCopilotDeviceLogin,
1349
1957
  noopLogger,
1350
1958
  normalizeChatCompletionRequest,
1959
+ normalizeCopilotUsage,
1351
1960
  normalizeModelsResponse,
1352
1961
  normalizeRequestedModel,
1962
+ observeResponseUsage,
1353
1963
  parseLogFormat,
1354
1964
  parseLogLevel,
1355
1965
  readStoredCopilotAuth,