@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/index.cjs
CHANGED
|
@@ -30,24 +30,33 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
COPILOT_USAGE_API_VERSION: () => COPILOT_USAGE_API_VERSION,
|
|
33
34
|
CopilotAuth: () => CopilotAuth,
|
|
34
35
|
CopilotAuthError: () => CopilotAuthError,
|
|
35
36
|
CopilotClient: () => CopilotClient,
|
|
37
|
+
DEFAULT_GITHUB_API_BASE_URL: () => DEFAULT_GITHUB_API_BASE_URL,
|
|
36
38
|
DEFAULT_LOG_FORMAT: () => DEFAULT_LOG_FORMAT,
|
|
37
39
|
DEFAULT_LOG_LEVEL: () => DEFAULT_LOG_LEVEL,
|
|
38
40
|
DEFAULT_MODEL: () => DEFAULT_MODEL,
|
|
41
|
+
MetricsRegistry: () => MetricsRegistry,
|
|
42
|
+
PROMETHEUS_CONTENT_TYPE: () => PROMETHEUS_CONTENT_TYPE,
|
|
43
|
+
applyCopilotHeaders: () => applyCopilotHeaders,
|
|
44
|
+
applyGithubApiHeaders: () => applyGithubApiHeaders,
|
|
39
45
|
authStorePath: () => authStorePath,
|
|
40
46
|
chatCompletionToCompletion: () => chatCompletionToCompletion,
|
|
41
47
|
chatCompletionToResponse: () => chatCompletionToResponse,
|
|
42
48
|
completionsRequestToChatCompletion: () => completionsRequestToChatCompletion,
|
|
43
49
|
createHoopilotHandler: () => createHoopilotHandler,
|
|
44
50
|
createHoopilotLogger: () => createHoopilotLogger,
|
|
51
|
+
extractTokenUsage: () => extractTokenUsage,
|
|
45
52
|
fallbackModels: () => fallbackModels,
|
|
46
53
|
githubCopilotDeviceLogin: () => githubCopilotDeviceLogin,
|
|
47
54
|
noopLogger: () => noopLogger,
|
|
48
55
|
normalizeChatCompletionRequest: () => normalizeChatCompletionRequest,
|
|
56
|
+
normalizeCopilotUsage: () => normalizeCopilotUsage,
|
|
49
57
|
normalizeModelsResponse: () => normalizeModelsResponse,
|
|
50
58
|
normalizeRequestedModel: () => normalizeRequestedModel,
|
|
59
|
+
observeResponseUsage: () => observeResponseUsage,
|
|
51
60
|
parseLogFormat: () => parseLogFormat,
|
|
52
61
|
parseLogLevel: () => parseLogLevel,
|
|
53
62
|
readStoredCopilotAuth: () => readStoredCopilotAuth,
|
|
@@ -165,6 +174,8 @@ var CopilotAuth = class {
|
|
|
165
174
|
};
|
|
166
175
|
|
|
167
176
|
// src/copilot.ts
|
|
177
|
+
var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
|
|
178
|
+
var COPILOT_USAGE_API_VERSION = "2025-04-01";
|
|
168
179
|
function applyCopilotHeaders(headers, token) {
|
|
169
180
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
170
181
|
headers.set("authorization", `Bearer ${token}`);
|
|
@@ -176,12 +187,44 @@ function applyCopilotHeaders(headers, token) {
|
|
|
176
187
|
headers.set("x-github-api-version", "2026-06-01");
|
|
177
188
|
return headers;
|
|
178
189
|
}
|
|
190
|
+
function applyGithubApiHeaders(headers, token) {
|
|
191
|
+
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
192
|
+
headers.set("authorization", `token ${token}`);
|
|
193
|
+
headers.set("editor-plugin-version", "hoopilot/0.1.0");
|
|
194
|
+
headers.set("editor-version", "Hoopilot/0.1.0");
|
|
195
|
+
headers.set("user-agent", "hoopilot/0.1.0");
|
|
196
|
+
headers.set("x-github-api-version", COPILOT_USAGE_API_VERSION);
|
|
197
|
+
return headers;
|
|
198
|
+
}
|
|
179
199
|
var CopilotClient = class {
|
|
180
200
|
#auth;
|
|
181
201
|
#fetch;
|
|
202
|
+
#githubApiBaseUrl;
|
|
182
203
|
constructor(options = {}) {
|
|
183
204
|
this.#auth = new CopilotAuth(options);
|
|
184
205
|
this.#fetch = options.fetch ?? fetch;
|
|
206
|
+
this.#githubApiBaseUrl = trimTrailingSlash(
|
|
207
|
+
options.githubApiBaseUrl ?? options.env?.HOOPILOT_GITHUB_API_BASE_URL ?? DEFAULT_GITHUB_API_BASE_URL
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Fetch the Copilot account's quota / premium-request usage from the GitHub
|
|
212
|
+
* REST `copilot_internal/user` endpoint. The stored device-flow OAuth token is
|
|
213
|
+
* accepted directly here — no Copilot token exchange is required to read quota.
|
|
214
|
+
*/
|
|
215
|
+
async usage(signal) {
|
|
216
|
+
if (!isHttpsOrLoopback(this.#githubApiBaseUrl)) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Refusing to send the GitHub OAuth token to a non-HTTPS host: ${this.#githubApiBaseUrl}`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const access = await this.#auth.getAccess();
|
|
222
|
+
const headers = applyGithubApiHeaders(new Headers(), access.token);
|
|
223
|
+
return this.#fetch(`${this.#githubApiBaseUrl}/copilot_internal/user`, {
|
|
224
|
+
headers,
|
|
225
|
+
method: "GET",
|
|
226
|
+
signal
|
|
227
|
+
});
|
|
185
228
|
}
|
|
186
229
|
async chatCompletions(body, signal) {
|
|
187
230
|
return this.fetchCopilot("/chat/completions", {
|
|
@@ -221,6 +264,81 @@ var CopilotClient = class {
|
|
|
221
264
|
});
|
|
222
265
|
}
|
|
223
266
|
};
|
|
267
|
+
function normalizeCopilotUsage(body) {
|
|
268
|
+
const record = asRecord(body);
|
|
269
|
+
const quotas = {};
|
|
270
|
+
const snapshots = asRecord(record.quota_snapshots);
|
|
271
|
+
for (const [category, detail] of Object.entries(snapshots)) {
|
|
272
|
+
quotas[category] = normalizeQuotaDetail(asRecord(detail));
|
|
273
|
+
}
|
|
274
|
+
if (Object.keys(quotas).length === 0) {
|
|
275
|
+
const remaining = asRecord(record.limited_user_quotas);
|
|
276
|
+
const monthly = asRecord(record.monthly_quotas);
|
|
277
|
+
for (const category of /* @__PURE__ */ new Set([...Object.keys(remaining), ...Object.keys(monthly)])) {
|
|
278
|
+
const entitlement = numberOrUndefined(monthly[category]);
|
|
279
|
+
const left = numberOrUndefined(remaining[category]);
|
|
280
|
+
quotas[category] = removeUndefinedQuota({
|
|
281
|
+
entitlement,
|
|
282
|
+
percentRemaining: entitlement !== void 0 && entitlement > 0 && left !== void 0 ? left / entitlement * 100 : void 0,
|
|
283
|
+
remaining: left,
|
|
284
|
+
used: usedFrom(entitlement, left)
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return removeUndefinedUsage({
|
|
289
|
+
accessTypeSku: stringOrUndefined(record.access_type_sku),
|
|
290
|
+
chatEnabled: typeof record.chat_enabled === "boolean" ? record.chat_enabled : void 0,
|
|
291
|
+
plan: stringOrUndefined(record.copilot_plan),
|
|
292
|
+
quotaResetDate: stringOrUndefined(record.quota_reset_date) ?? stringOrUndefined(record.quota_reset_date_utc) ?? stringOrUndefined(record.limited_user_reset_date),
|
|
293
|
+
quotas
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
function normalizeQuotaDetail(detail) {
|
|
297
|
+
const entitlement = numberOrUndefined(detail.entitlement);
|
|
298
|
+
const remaining = numberOrUndefined(detail.remaining) ?? numberOrUndefined(detail.quota_remaining);
|
|
299
|
+
return removeUndefinedQuota({
|
|
300
|
+
entitlement,
|
|
301
|
+
overageCount: numberOrUndefined(detail.overage_count),
|
|
302
|
+
overagePermitted: typeof detail.overage_permitted === "boolean" ? detail.overage_permitted : void 0,
|
|
303
|
+
percentRemaining: numberOrUndefined(detail.percent_remaining),
|
|
304
|
+
remaining,
|
|
305
|
+
unlimited: typeof detail.unlimited === "boolean" ? detail.unlimited : void 0,
|
|
306
|
+
used: usedFrom(entitlement, remaining)
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function usedFrom(entitlement, remaining) {
|
|
310
|
+
if (entitlement === void 0 || remaining === void 0) {
|
|
311
|
+
return void 0;
|
|
312
|
+
}
|
|
313
|
+
return Math.max(0, entitlement - remaining);
|
|
314
|
+
}
|
|
315
|
+
function isHttpsOrLoopback(rawUrl) {
|
|
316
|
+
let url;
|
|
317
|
+
try {
|
|
318
|
+
url = new URL(rawUrl);
|
|
319
|
+
} catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
if (url.protocol === "https:") {
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1");
|
|
326
|
+
}
|
|
327
|
+
function numberOrUndefined(value) {
|
|
328
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
329
|
+
}
|
|
330
|
+
function stringOrUndefined(value) {
|
|
331
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
332
|
+
}
|
|
333
|
+
function removeUndefinedQuota(quota) {
|
|
334
|
+
return Object.fromEntries(
|
|
335
|
+
Object.entries(quota).filter(([, value]) => value !== void 0)
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
function removeUndefinedUsage(usage) {
|
|
339
|
+
const entries = Object.entries(usage).filter(([, value]) => value !== void 0);
|
|
340
|
+
return Object.fromEntries(entries);
|
|
341
|
+
}
|
|
224
342
|
|
|
225
343
|
// src/github-device.ts
|
|
226
344
|
var import_promises = require("timers/promises");
|
|
@@ -900,6 +1018,40 @@ function responseUsage(usage) {
|
|
|
900
1018
|
total_tokens: record.total_tokens
|
|
901
1019
|
});
|
|
902
1020
|
}
|
|
1021
|
+
function extractTokenUsage(usage) {
|
|
1022
|
+
const record = asRecord(usage);
|
|
1023
|
+
const prompt = firstNumber(record.prompt_tokens, record.input_tokens);
|
|
1024
|
+
const completion = firstNumber(record.completion_tokens, record.output_tokens);
|
|
1025
|
+
const total = firstNumber(record.total_tokens);
|
|
1026
|
+
if (prompt === void 0 && completion === void 0 && total === void 0) {
|
|
1027
|
+
return void 0;
|
|
1028
|
+
}
|
|
1029
|
+
const promptTokens = prompt ?? 0;
|
|
1030
|
+
const completionTokens = completion ?? 0;
|
|
1031
|
+
const reasoning = firstNumber(
|
|
1032
|
+
asRecord(record.completion_tokens_details).reasoning_tokens,
|
|
1033
|
+
asRecord(record.output_tokens_details).reasoning_tokens
|
|
1034
|
+
);
|
|
1035
|
+
const cached = firstNumber(
|
|
1036
|
+
asRecord(record.prompt_tokens_details).cached_tokens,
|
|
1037
|
+
asRecord(record.input_tokens_details).cached_tokens
|
|
1038
|
+
);
|
|
1039
|
+
return removeUndefined({
|
|
1040
|
+
cachedTokens: cached,
|
|
1041
|
+
completionTokens,
|
|
1042
|
+
promptTokens,
|
|
1043
|
+
reasoningTokens: reasoning,
|
|
1044
|
+
totalTokens: total ?? promptTokens + completionTokens
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
function firstNumber(...values) {
|
|
1048
|
+
for (const value of values) {
|
|
1049
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1050
|
+
return value;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return void 0;
|
|
1054
|
+
}
|
|
903
1055
|
function firstChoice(completion) {
|
|
904
1056
|
const choices = Array.isArray(completion.choices) ? completion.choices : [];
|
|
905
1057
|
return asRecord(choices[0]);
|
|
@@ -993,104 +1145,449 @@ function epochSeconds() {
|
|
|
993
1145
|
return Math.floor(Date.now() / 1e3);
|
|
994
1146
|
}
|
|
995
1147
|
|
|
1148
|
+
// src/metrics.ts
|
|
1149
|
+
var PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
|
|
1150
|
+
var DURATION_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
|
|
1151
|
+
var USAGE_BUFFER_LIMIT_BYTES = 16 * 1024 * 1024;
|
|
1152
|
+
var MAX_TRACKED_MODELS = 200;
|
|
1153
|
+
var MAX_MODEL_LABEL_LENGTH = 200;
|
|
1154
|
+
var LABEL_SEPARATOR = "";
|
|
1155
|
+
var UNKNOWN_MODEL = "unknown";
|
|
1156
|
+
function emptyModelTotals() {
|
|
1157
|
+
return { cached: 0, completion: 0, prompt: 0, reasoning: 0, requests: 0, total: 0 };
|
|
1158
|
+
}
|
|
1159
|
+
var MetricsRegistry = class {
|
|
1160
|
+
#startedAtMs;
|
|
1161
|
+
#inFlight = 0;
|
|
1162
|
+
#requests = /* @__PURE__ */ new Map();
|
|
1163
|
+
#durations = /* @__PURE__ */ new Map();
|
|
1164
|
+
#tokens = /* @__PURE__ */ new Map();
|
|
1165
|
+
#upstream = /* @__PURE__ */ new Map();
|
|
1166
|
+
#copilotQuota;
|
|
1167
|
+
constructor(options = {}) {
|
|
1168
|
+
this.#startedAtMs = (options.now ?? Date.now)();
|
|
1169
|
+
}
|
|
1170
|
+
/** Mark a request as started; pair with exactly one {@link observe}. */
|
|
1171
|
+
startRequest() {
|
|
1172
|
+
this.#inFlight += 1;
|
|
1173
|
+
}
|
|
1174
|
+
/** Record a completed request and clear its in-flight slot. */
|
|
1175
|
+
observe(observation) {
|
|
1176
|
+
if (this.#inFlight > 0) {
|
|
1177
|
+
this.#inFlight -= 1;
|
|
1178
|
+
}
|
|
1179
|
+
const key = labelKey(observation.route, observation.method, String(observation.status));
|
|
1180
|
+
this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
|
|
1181
|
+
this.#observeDuration(observation.route, observation.durationMs / 1e3);
|
|
1182
|
+
}
|
|
1183
|
+
/** Accumulate token counts for a model from one upstream completion. */
|
|
1184
|
+
recordTokens(model, usage) {
|
|
1185
|
+
const name = this.#modelLabel(model);
|
|
1186
|
+
const totals = this.#tokens.get(name) ?? emptyModelTotals();
|
|
1187
|
+
totals.requests += 1;
|
|
1188
|
+
totals.prompt += nonNegative(usage.promptTokens);
|
|
1189
|
+
totals.completion += nonNegative(usage.completionTokens);
|
|
1190
|
+
totals.total += nonNegative(usage.totalTokens);
|
|
1191
|
+
totals.reasoning += nonNegative(usage.reasoningTokens ?? 0);
|
|
1192
|
+
totals.cached += nonNegative(usage.cachedTokens ?? 0);
|
|
1193
|
+
this.#tokens.set(name, totals);
|
|
1194
|
+
}
|
|
1195
|
+
/** Record one upstream Copilot call and whether it succeeded. */
|
|
1196
|
+
recordUpstream(path, ok) {
|
|
1197
|
+
const key = labelKey(path, ok ? "ok" : "error");
|
|
1198
|
+
this.#upstream.set(key, (this.#upstream.get(key) ?? 0) + 1);
|
|
1199
|
+
}
|
|
1200
|
+
/** Store the latest Copilot quota so /metrics can expose it as gauges. */
|
|
1201
|
+
recordCopilotQuota(usage) {
|
|
1202
|
+
this.#copilotQuota = usage;
|
|
1203
|
+
}
|
|
1204
|
+
// Sanitize the model into a bounded, control-char-free label. The model can
|
|
1205
|
+
// originate from a client request, so cap its length, strip characters that
|
|
1206
|
+
// would corrupt the exposition format, and fold overflow past the cardinality
|
|
1207
|
+
// limit into UNKNOWN_MODEL to keep the series count bounded.
|
|
1208
|
+
#modelLabel(model) {
|
|
1209
|
+
const cleaned = model.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
|
|
1210
|
+
if (!this.#tokens.has(cleaned) && this.#tokens.size >= MAX_TRACKED_MODELS) {
|
|
1211
|
+
return UNKNOWN_MODEL;
|
|
1212
|
+
}
|
|
1213
|
+
return cleaned;
|
|
1214
|
+
}
|
|
1215
|
+
#observeDuration(route, seconds) {
|
|
1216
|
+
const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
|
|
1217
|
+
const entry = this.#durations.get(route) ?? {
|
|
1218
|
+
buckets: new Array(DURATION_BUCKETS_SECONDS.length).fill(0),
|
|
1219
|
+
count: 0,
|
|
1220
|
+
sum: 0
|
|
1221
|
+
};
|
|
1222
|
+
entry.count += 1;
|
|
1223
|
+
entry.sum += value;
|
|
1224
|
+
const index = DURATION_BUCKETS_SECONDS.findIndex((bound) => value <= bound);
|
|
1225
|
+
if (index !== -1) {
|
|
1226
|
+
entry.buckets[index] = (entry.buckets[index] ?? 0) + 1;
|
|
1227
|
+
}
|
|
1228
|
+
this.#durations.set(route, entry);
|
|
1229
|
+
}
|
|
1230
|
+
/** A JSON-friendly view of the current counters. */
|
|
1231
|
+
snapshot(now = Date.now) {
|
|
1232
|
+
const byRoute = {};
|
|
1233
|
+
const byStatus = {};
|
|
1234
|
+
let requestsTotal = 0;
|
|
1235
|
+
for (const [key, count] of this.#requests) {
|
|
1236
|
+
const [route = "", , status = ""] = key.split(LABEL_SEPARATOR);
|
|
1237
|
+
byRoute[route] = (byRoute[route] ?? 0) + count;
|
|
1238
|
+
byStatus[status] = (byStatus[status] ?? 0) + count;
|
|
1239
|
+
requestsTotal += count;
|
|
1240
|
+
}
|
|
1241
|
+
const byModel = {};
|
|
1242
|
+
const tokenTotals = { cached: 0, completion: 0, prompt: 0, reasoning: 0, total: 0 };
|
|
1243
|
+
for (const [model, totals] of this.#tokens) {
|
|
1244
|
+
byModel[model] = { ...totals };
|
|
1245
|
+
tokenTotals.prompt += totals.prompt;
|
|
1246
|
+
tokenTotals.completion += totals.completion;
|
|
1247
|
+
tokenTotals.total += totals.total;
|
|
1248
|
+
tokenTotals.reasoning += totals.reasoning;
|
|
1249
|
+
tokenTotals.cached += totals.cached;
|
|
1250
|
+
}
|
|
1251
|
+
let upstreamTotal = 0;
|
|
1252
|
+
let upstreamErrors = 0;
|
|
1253
|
+
for (const [key, count] of this.#upstream) {
|
|
1254
|
+
upstreamTotal += count;
|
|
1255
|
+
if (key.endsWith(`${LABEL_SEPARATOR}error`)) {
|
|
1256
|
+
upstreamErrors += count;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return {
|
|
1260
|
+
inFlight: this.#inFlight,
|
|
1261
|
+
requests: { byRoute, byStatus, total: requestsTotal },
|
|
1262
|
+
startedAt: new Date(this.#startedAtMs).toISOString(),
|
|
1263
|
+
tokens: { byModel, ...tokenTotals },
|
|
1264
|
+
upstream: { errors: upstreamErrors, total: upstreamTotal },
|
|
1265
|
+
uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
/** Render the Prometheus text exposition format (version 0.0.4). */
|
|
1269
|
+
renderPrometheus(now = Date.now) {
|
|
1270
|
+
const lines = [];
|
|
1271
|
+
lines.push("# HELP hoopilot_process_start_time_seconds Unix epoch when the proxy started.");
|
|
1272
|
+
lines.push("# TYPE hoopilot_process_start_time_seconds gauge");
|
|
1273
|
+
lines.push(`hoopilot_process_start_time_seconds ${this.#startedAtMs / 1e3}`);
|
|
1274
|
+
lines.push("# HELP hoopilot_uptime_seconds Seconds since the proxy started.");
|
|
1275
|
+
lines.push("# TYPE hoopilot_uptime_seconds gauge");
|
|
1276
|
+
lines.push(`hoopilot_uptime_seconds ${Math.max(0, (now() - this.#startedAtMs) / 1e3)}`);
|
|
1277
|
+
lines.push("# HELP hoopilot_requests_in_flight Requests currently being served.");
|
|
1278
|
+
lines.push("# TYPE hoopilot_requests_in_flight gauge");
|
|
1279
|
+
lines.push(`hoopilot_requests_in_flight ${this.#inFlight}`);
|
|
1280
|
+
lines.push("# HELP hoopilot_requests_total Completed requests by route, method, and status.");
|
|
1281
|
+
lines.push("# TYPE hoopilot_requests_total counter");
|
|
1282
|
+
for (const [key, count] of this.#requests) {
|
|
1283
|
+
const [route = "", method = "", status = ""] = key.split(LABEL_SEPARATOR);
|
|
1284
|
+
lines.push(`hoopilot_requests_total${labels({ method, route, status })} ${count}`);
|
|
1285
|
+
}
|
|
1286
|
+
lines.push(
|
|
1287
|
+
"# HELP hoopilot_upstream_requests_total Copilot upstream calls by path and outcome."
|
|
1288
|
+
);
|
|
1289
|
+
lines.push("# TYPE hoopilot_upstream_requests_total counter");
|
|
1290
|
+
for (const [key, count] of this.#upstream) {
|
|
1291
|
+
const [path = "", outcome = ""] = key.split(LABEL_SEPARATOR);
|
|
1292
|
+
lines.push(`hoopilot_upstream_requests_total${labels({ outcome, path })} ${count}`);
|
|
1293
|
+
}
|
|
1294
|
+
lines.push(
|
|
1295
|
+
"# HELP hoopilot_tokens_total Tokens reported by upstream usage, by model and type."
|
|
1296
|
+
);
|
|
1297
|
+
lines.push("# TYPE hoopilot_tokens_total counter");
|
|
1298
|
+
for (const [model, totals] of this.#tokens) {
|
|
1299
|
+
lines.push(`hoopilot_tokens_total${labels({ model, type: "prompt" })} ${totals.prompt}`);
|
|
1300
|
+
lines.push(
|
|
1301
|
+
`hoopilot_tokens_total${labels({ model, type: "completion" })} ${totals.completion}`
|
|
1302
|
+
);
|
|
1303
|
+
lines.push(
|
|
1304
|
+
`hoopilot_tokens_total${labels({ model, type: "reasoning" })} ${totals.reasoning}`
|
|
1305
|
+
);
|
|
1306
|
+
lines.push(`hoopilot_tokens_total${labels({ model, type: "cached" })} ${totals.cached}`);
|
|
1307
|
+
}
|
|
1308
|
+
lines.push("# HELP hoopilot_model_requests_total Completions with usage observed, by model.");
|
|
1309
|
+
lines.push("# TYPE hoopilot_model_requests_total counter");
|
|
1310
|
+
for (const [model, totals] of this.#tokens) {
|
|
1311
|
+
lines.push(`hoopilot_model_requests_total${labels({ model })} ${totals.requests}`);
|
|
1312
|
+
}
|
|
1313
|
+
lines.push("# HELP hoopilot_request_duration_seconds Request duration by route.");
|
|
1314
|
+
lines.push("# TYPE hoopilot_request_duration_seconds histogram");
|
|
1315
|
+
for (const [route, entry] of this.#durations) {
|
|
1316
|
+
let cumulative = 0;
|
|
1317
|
+
for (let i = 0; i < DURATION_BUCKETS_SECONDS.length; i += 1) {
|
|
1318
|
+
cumulative += entry.buckets[i] ?? 0;
|
|
1319
|
+
const le = formatNumber(DURATION_BUCKETS_SECONDS[i] ?? 0);
|
|
1320
|
+
lines.push(
|
|
1321
|
+
`hoopilot_request_duration_seconds_bucket${labels({ le, route })} ${cumulative}`
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
lines.push(
|
|
1325
|
+
`hoopilot_request_duration_seconds_bucket${labels({ le: "+Inf", route })} ${entry.count}`
|
|
1326
|
+
);
|
|
1327
|
+
lines.push(`hoopilot_request_duration_seconds_sum${labels({ route })} ${entry.sum}`);
|
|
1328
|
+
lines.push(`hoopilot_request_duration_seconds_count${labels({ route })} ${entry.count}`);
|
|
1329
|
+
}
|
|
1330
|
+
this.#renderCopilotQuota(lines);
|
|
1331
|
+
return `${lines.join("\n")}
|
|
1332
|
+
`;
|
|
1333
|
+
}
|
|
1334
|
+
#renderCopilotQuota(lines) {
|
|
1335
|
+
const usage = this.#copilotQuota;
|
|
1336
|
+
if (!usage) {
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
const categories = Object.entries(usage.quotas);
|
|
1340
|
+
const gauge = (suffix, help, pick) => {
|
|
1341
|
+
const present = categories.filter(([, quota]) => pick(quota) !== void 0);
|
|
1342
|
+
if (present.length === 0) {
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
lines.push(`# HELP hoopilot_copilot_quota_${suffix} ${help}`);
|
|
1346
|
+
lines.push(`# TYPE hoopilot_copilot_quota_${suffix} gauge`);
|
|
1347
|
+
for (const [category, quota] of present) {
|
|
1348
|
+
lines.push(`hoopilot_copilot_quota_${suffix}${labels({ category })} ${pick(quota)}`);
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
gauge("remaining", "Remaining quota for the Copilot category.", (q) => q.remaining);
|
|
1352
|
+
gauge("entitlement", "Quota entitlement for the Copilot category.", (q) => q.entitlement);
|
|
1353
|
+
gauge("used", "Used quota (entitlement minus remaining) for the category.", (q) => q.used);
|
|
1354
|
+
gauge(
|
|
1355
|
+
"percent_remaining",
|
|
1356
|
+
"Percent of quota remaining for the Copilot category.",
|
|
1357
|
+
(q) => q.percentRemaining
|
|
1358
|
+
);
|
|
1359
|
+
const resetMs = usage.quotaResetDate ? Date.parse(usage.quotaResetDate) : Number.NaN;
|
|
1360
|
+
if (Number.isFinite(resetMs)) {
|
|
1361
|
+
lines.push(
|
|
1362
|
+
"# HELP hoopilot_copilot_quota_reset_timestamp_seconds Unix epoch of the next reset."
|
|
1363
|
+
);
|
|
1364
|
+
lines.push("# TYPE hoopilot_copilot_quota_reset_timestamp_seconds gauge");
|
|
1365
|
+
lines.push(`hoopilot_copilot_quota_reset_timestamp_seconds ${resetMs / 1e3}`);
|
|
1366
|
+
}
|
|
1367
|
+
if (usage.plan || usage.accessTypeSku) {
|
|
1368
|
+
lines.push("# HELP hoopilot_copilot_info Copilot plan metadata as a constant-1 info gauge.");
|
|
1369
|
+
lines.push("# TYPE hoopilot_copilot_info gauge");
|
|
1370
|
+
lines.push(
|
|
1371
|
+
`hoopilot_copilot_info${labels({
|
|
1372
|
+
access_type_sku: usage.accessTypeSku ?? "",
|
|
1373
|
+
plan: usage.plan ?? ""
|
|
1374
|
+
})} 1`
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
function observeResponseUsage(response, fallbackModel, onUsage, signal) {
|
|
1380
|
+
const body = response.body;
|
|
1381
|
+
if (!body) {
|
|
1382
|
+
return response;
|
|
1383
|
+
}
|
|
1384
|
+
const [clientBranch, observerBranch] = body.tee();
|
|
1385
|
+
const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
1386
|
+
void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal).catch(() => {
|
|
1387
|
+
});
|
|
1388
|
+
return new Response(clientBranch, {
|
|
1389
|
+
headers: response.headers,
|
|
1390
|
+
status: response.status,
|
|
1391
|
+
statusText: response.statusText
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal) {
|
|
1395
|
+
const reader = stream.getReader();
|
|
1396
|
+
const onAbort = () => {
|
|
1397
|
+
reader.cancel().catch(() => {
|
|
1398
|
+
});
|
|
1399
|
+
};
|
|
1400
|
+
if (signal?.aborted) {
|
|
1401
|
+
reader.cancel().catch(() => {
|
|
1402
|
+
});
|
|
1403
|
+
} else {
|
|
1404
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1405
|
+
}
|
|
1406
|
+
const decoder = new TextDecoder();
|
|
1407
|
+
let model = fallbackModel;
|
|
1408
|
+
let usage;
|
|
1409
|
+
let buffer = "";
|
|
1410
|
+
let bufferedBytes = 0;
|
|
1411
|
+
let overflowed = false;
|
|
1412
|
+
const consider = (payload) => {
|
|
1413
|
+
const record = asRecord(payload);
|
|
1414
|
+
const found = extractTokenUsage(record.usage) ?? extractTokenUsage(asRecord(record.response).usage);
|
|
1415
|
+
if (found) {
|
|
1416
|
+
usage = found;
|
|
1417
|
+
}
|
|
1418
|
+
const candidateModel = modelText(record.model) || modelText(asRecord(record.response).model);
|
|
1419
|
+
if (candidateModel) {
|
|
1420
|
+
model = candidateModel;
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1423
|
+
try {
|
|
1424
|
+
while (true) {
|
|
1425
|
+
const result = await reader.read();
|
|
1426
|
+
if (result.done) {
|
|
1427
|
+
break;
|
|
1428
|
+
}
|
|
1429
|
+
const chunk = decoder.decode(result.value, { stream: true });
|
|
1430
|
+
if (isSse) {
|
|
1431
|
+
buffer += chunk;
|
|
1432
|
+
const lines = buffer.split(/\r?\n/);
|
|
1433
|
+
buffer = lines.pop() ?? "";
|
|
1434
|
+
for (const line of lines) {
|
|
1435
|
+
considerSseLine(line, consider);
|
|
1436
|
+
}
|
|
1437
|
+
if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
|
|
1438
|
+
buffer = "";
|
|
1439
|
+
}
|
|
1440
|
+
} else if (!overflowed) {
|
|
1441
|
+
bufferedBytes += result.value.byteLength;
|
|
1442
|
+
if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
|
|
1443
|
+
overflowed = true;
|
|
1444
|
+
buffer = "";
|
|
1445
|
+
} else {
|
|
1446
|
+
buffer += chunk;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
const finalBuffer = buffer + decoder.decode();
|
|
1451
|
+
if (isSse) {
|
|
1452
|
+
if (finalBuffer) {
|
|
1453
|
+
considerSseLine(finalBuffer, consider);
|
|
1454
|
+
}
|
|
1455
|
+
} else if (!overflowed && finalBuffer) {
|
|
1456
|
+
const parsed = safeParse(finalBuffer);
|
|
1457
|
+
if (parsed !== void 0) {
|
|
1458
|
+
consider(parsed);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
} finally {
|
|
1462
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1463
|
+
reader.releaseLock();
|
|
1464
|
+
}
|
|
1465
|
+
if (usage) {
|
|
1466
|
+
onUsage(model, usage);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
function considerSseLine(line, consider) {
|
|
1470
|
+
const trimmed = line.trim();
|
|
1471
|
+
if (!trimmed.startsWith("data:")) {
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const data = trimmed.slice("data:".length).trim();
|
|
1475
|
+
if (!data || data === "[DONE]") {
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
const parsed = safeParse(data);
|
|
1479
|
+
if (parsed !== void 0) {
|
|
1480
|
+
consider(parsed);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
function safeParse(text) {
|
|
1484
|
+
try {
|
|
1485
|
+
return JSON.parse(text);
|
|
1486
|
+
} catch {
|
|
1487
|
+
return void 0;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
function modelText(value) {
|
|
1491
|
+
return typeof value === "string" ? value.trim() : "";
|
|
1492
|
+
}
|
|
1493
|
+
function nonNegative(value) {
|
|
1494
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
1495
|
+
}
|
|
1496
|
+
function labelKey(...parts) {
|
|
1497
|
+
return parts.join(LABEL_SEPARATOR);
|
|
1498
|
+
}
|
|
1499
|
+
function labels(pairs) {
|
|
1500
|
+
const entries = Object.entries(pairs);
|
|
1501
|
+
if (entries.length === 0) {
|
|
1502
|
+
return "";
|
|
1503
|
+
}
|
|
1504
|
+
const rendered = entries.map(([name, value]) => `${name}="${escapeLabelValue(value)}"`);
|
|
1505
|
+
return `{${rendered.join(",")}}`;
|
|
1506
|
+
}
|
|
1507
|
+
function escapeLabelValue(value) {
|
|
1508
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r");
|
|
1509
|
+
}
|
|
1510
|
+
function formatNumber(value) {
|
|
1511
|
+
return Number.isInteger(value) ? value.toString() : String(value);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
996
1514
|
// src/server.ts
|
|
997
1515
|
var DEFAULT_HOST = "127.0.0.1";
|
|
998
1516
|
var DEFAULT_PORT = 4141;
|
|
999
1517
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
1518
|
+
var USAGE_CACHE_TTL_MS = 6e4;
|
|
1000
1519
|
function createHoopilotHandler(options = {}) {
|
|
1001
1520
|
const client = new CopilotClient(options);
|
|
1002
1521
|
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
1003
1522
|
const logger = serverLogger(options);
|
|
1523
|
+
const metrics = options.metrics ?? new MetricsRegistry();
|
|
1524
|
+
const readUsage = createUsageReader(client, metrics);
|
|
1525
|
+
const recordTokens = (model, usage) => metrics.recordTokens(model, usage);
|
|
1004
1526
|
return async (request) => {
|
|
1005
1527
|
const startedAt = performance.now();
|
|
1006
1528
|
const url = new URL(request.url);
|
|
1007
1529
|
const apiPath = canonicalApiPath(url.pathname);
|
|
1008
1530
|
const requestId = requestIdFor(request);
|
|
1531
|
+
const route = routeFor(request.method, apiPath);
|
|
1009
1532
|
const requestLogger = logger.child({
|
|
1010
1533
|
method: request.method,
|
|
1011
1534
|
path: url.pathname,
|
|
1012
1535
|
requestId,
|
|
1013
|
-
route
|
|
1536
|
+
route
|
|
1537
|
+
});
|
|
1538
|
+
metrics.startRequest();
|
|
1539
|
+
const finish = (response) => finishResponse(response, {
|
|
1540
|
+
logger: requestLogger,
|
|
1541
|
+
method: request.method,
|
|
1542
|
+
metrics,
|
|
1543
|
+
requestId,
|
|
1544
|
+
route,
|
|
1545
|
+
startedAt
|
|
1014
1546
|
});
|
|
1015
1547
|
if (request.method === "OPTIONS") {
|
|
1016
|
-
return
|
|
1017
|
-
logger: requestLogger,
|
|
1018
|
-
requestId,
|
|
1019
|
-
startedAt
|
|
1020
|
-
});
|
|
1548
|
+
return finish(new Response(null, { headers: corsHeaders() }));
|
|
1021
1549
|
}
|
|
1022
1550
|
if (!isAuthorized(request, apiKey)) {
|
|
1023
1551
|
requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
|
|
1024
|
-
return
|
|
1025
|
-
jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."),
|
|
1026
|
-
{
|
|
1027
|
-
logger: requestLogger,
|
|
1028
|
-
requestId,
|
|
1029
|
-
startedAt
|
|
1030
|
-
}
|
|
1031
|
-
);
|
|
1552
|
+
return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
|
|
1032
1553
|
}
|
|
1033
1554
|
try {
|
|
1034
1555
|
if (request.method === "GET" && (apiPath === "/" || apiPath === "/healthz")) {
|
|
1035
|
-
return
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
);
|
|
1556
|
+
return finish(jsonResponse({ name: "hoopilot", object: "health", status: "ok" }));
|
|
1557
|
+
}
|
|
1558
|
+
if (request.method === "GET" && apiPath === "/metrics") {
|
|
1559
|
+
return finish(metricsResponse(metrics));
|
|
1560
|
+
}
|
|
1561
|
+
if (request.method === "GET" && apiPath === "/v1/usage") {
|
|
1562
|
+
return finish(await handleUsage(metrics, readUsage, request.signal));
|
|
1043
1563
|
}
|
|
1044
1564
|
if (request.method === "GET" && apiPath === "/v1/responses") {
|
|
1045
|
-
return
|
|
1046
|
-
logger: requestLogger,
|
|
1047
|
-
requestId,
|
|
1048
|
-
startedAt
|
|
1049
|
-
});
|
|
1565
|
+
return finish(websocketUnsupportedResponse());
|
|
1050
1566
|
}
|
|
1051
1567
|
if (request.method === "GET" && apiPath === "/v1/models") {
|
|
1052
|
-
return
|
|
1053
|
-
logger: requestLogger,
|
|
1054
|
-
requestId,
|
|
1055
|
-
startedAt
|
|
1056
|
-
});
|
|
1568
|
+
return finish(await handleModels(client, metrics, request.signal, requestLogger));
|
|
1057
1569
|
}
|
|
1058
1570
|
if (request.method === "POST" && apiPath === "/v1/chat/completions") {
|
|
1059
|
-
return
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
startedAt
|
|
1063
|
-
});
|
|
1571
|
+
return finish(
|
|
1572
|
+
await handleChatCompletions(client, metrics, recordTokens, request, requestLogger)
|
|
1573
|
+
);
|
|
1064
1574
|
}
|
|
1065
1575
|
if (request.method === "POST" && apiPath === "/v1/completions") {
|
|
1066
|
-
return
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
startedAt
|
|
1070
|
-
});
|
|
1576
|
+
return finish(
|
|
1577
|
+
await handleCompletions(client, metrics, recordTokens, request, requestLogger)
|
|
1578
|
+
);
|
|
1071
1579
|
}
|
|
1072
1580
|
if (request.method === "POST" && apiPath === "/v1/responses") {
|
|
1073
|
-
return
|
|
1074
|
-
logger: requestLogger,
|
|
1075
|
-
requestId,
|
|
1076
|
-
startedAt
|
|
1077
|
-
});
|
|
1581
|
+
return finish(await handleResponses(client, metrics, recordTokens, request, requestLogger));
|
|
1078
1582
|
}
|
|
1079
|
-
return
|
|
1080
|
-
jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`),
|
|
1081
|
-
{ logger: requestLogger, requestId, startedAt }
|
|
1082
|
-
);
|
|
1583
|
+
return finish(jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`));
|
|
1083
1584
|
} catch (error) {
|
|
1084
1585
|
if (error instanceof CopilotAuthError) {
|
|
1085
1586
|
requestLogger.warn(
|
|
1086
1587
|
{ err: errorDetails(error), event: "copilot.auth.missing" },
|
|
1087
1588
|
"copilot auth failed"
|
|
1088
1589
|
);
|
|
1089
|
-
return
|
|
1090
|
-
logger: requestLogger,
|
|
1091
|
-
requestId,
|
|
1092
|
-
startedAt
|
|
1093
|
-
});
|
|
1590
|
+
return finish(jsonError(401, "copilot_auth_error", error.message));
|
|
1094
1591
|
}
|
|
1095
1592
|
const message = errorMessage(error);
|
|
1096
1593
|
if (message === INVALID_JSON_MESSAGE) {
|
|
@@ -1098,17 +1595,14 @@ function createHoopilotHandler(options = {}) {
|
|
|
1098
1595
|
{ err: errorDetails(error), event: "http.request.failed" },
|
|
1099
1596
|
"request body was invalid json"
|
|
1100
1597
|
);
|
|
1598
|
+
return finish(jsonError(400, "invalid_request_error", message));
|
|
1101
1599
|
} else {
|
|
1102
1600
|
requestLogger.error(
|
|
1103
1601
|
{ err: errorDetails(error), event: "http.request.failed" },
|
|
1104
1602
|
"request failed"
|
|
1105
1603
|
);
|
|
1106
1604
|
}
|
|
1107
|
-
return
|
|
1108
|
-
logger: requestLogger,
|
|
1109
|
-
requestId,
|
|
1110
|
-
startedAt
|
|
1111
|
-
});
|
|
1605
|
+
return finish(jsonError(500, "internal_error", message));
|
|
1112
1606
|
}
|
|
1113
1607
|
};
|
|
1114
1608
|
}
|
|
@@ -1137,8 +1631,9 @@ function startHoopilotServer(options = {}) {
|
|
|
1137
1631
|
url: `http://${host}:${server.port}`
|
|
1138
1632
|
};
|
|
1139
1633
|
}
|
|
1140
|
-
async function handleModels(client, signal, logger) {
|
|
1634
|
+
async function handleModels(client, metrics, signal, logger) {
|
|
1141
1635
|
const upstream = await client.models(signal);
|
|
1636
|
+
metrics.recordUpstream("/models", upstream.ok);
|
|
1142
1637
|
if (!upstream.ok) {
|
|
1143
1638
|
if (isUpstreamAuthStatus(upstream.status)) {
|
|
1144
1639
|
return proxyError(upstream, logger);
|
|
@@ -1156,38 +1651,50 @@ async function handleModels(client, signal, logger) {
|
|
|
1156
1651
|
logUpstreamSuccess(logger, "/models", upstream.status);
|
|
1157
1652
|
return jsonResponse(normalizeModelsResponse(await upstream.json()));
|
|
1158
1653
|
}
|
|
1159
|
-
async function handleChatCompletions(client, request, logger) {
|
|
1654
|
+
async function handleChatCompletions(client, metrics, recordTokens, request, logger) {
|
|
1160
1655
|
const chatRequest = normalizeChatCompletionRequest(await readJson(request));
|
|
1161
1656
|
const upstream = await client.chatCompletions(chatRequest, request.signal);
|
|
1657
|
+
metrics.recordUpstream("/chat/completions", upstream.ok);
|
|
1162
1658
|
if (!upstream.ok) {
|
|
1163
1659
|
return proxyError(upstream, logger);
|
|
1164
1660
|
}
|
|
1165
1661
|
logUpstreamSuccess(logger, "/chat/completions", upstream.status);
|
|
1166
|
-
|
|
1662
|
+
const model = normalizeRequestedModel(chatRequest.model);
|
|
1663
|
+
return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
|
|
1167
1664
|
}
|
|
1168
|
-
async function handleCompletions(client, request, logger) {
|
|
1665
|
+
async function handleCompletions(client, metrics, recordTokens, request, logger) {
|
|
1169
1666
|
const body = await readJson(request);
|
|
1170
1667
|
const upstream = await client.chatCompletions(
|
|
1171
1668
|
completionsRequestToChatCompletion(body),
|
|
1172
1669
|
request.signal
|
|
1173
1670
|
);
|
|
1671
|
+
metrics.recordUpstream("/chat/completions", upstream.ok);
|
|
1174
1672
|
if (!upstream.ok) {
|
|
1175
1673
|
return proxyError(upstream, logger);
|
|
1176
1674
|
}
|
|
1177
1675
|
logUpstreamSuccess(logger, "/chat/completions", upstream.status);
|
|
1676
|
+
const model = normalizeRequestedModel(body.model);
|
|
1178
1677
|
if (isStreamingResponse(upstream)) {
|
|
1179
|
-
return proxyResponse(upstream);
|
|
1678
|
+
return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
|
|
1679
|
+
}
|
|
1680
|
+
const completion = asRecord(await upstream.json());
|
|
1681
|
+
const usage = extractTokenUsage(completion.usage);
|
|
1682
|
+
if (usage) {
|
|
1683
|
+
const responseModel = typeof completion.model === "string" ? completion.model.trim() : "";
|
|
1684
|
+
recordTokens(responseModel || model, usage);
|
|
1180
1685
|
}
|
|
1181
|
-
return jsonResponse(chatCompletionToCompletion(
|
|
1686
|
+
return jsonResponse(chatCompletionToCompletion(completion));
|
|
1182
1687
|
}
|
|
1183
|
-
async function handleResponses(client, request, logger) {
|
|
1688
|
+
async function handleResponses(client, metrics, recordTokens, request, logger) {
|
|
1184
1689
|
const body = await readJsonText(request);
|
|
1185
1690
|
const upstream = await client.responses(body, request.signal);
|
|
1691
|
+
metrics.recordUpstream("/responses", upstream.ok);
|
|
1186
1692
|
if (!upstream.ok) {
|
|
1187
1693
|
return proxyError(upstream, logger);
|
|
1188
1694
|
}
|
|
1189
1695
|
logUpstreamSuccess(logger, "/responses", upstream.status);
|
|
1190
|
-
|
|
1696
|
+
const model = normalizeRequestedModel(asRecord(safeParseJson(body)).model);
|
|
1697
|
+
return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
|
|
1191
1698
|
}
|
|
1192
1699
|
async function proxyError(upstream, logger) {
|
|
1193
1700
|
const text = await upstream.text();
|
|
@@ -1306,7 +1813,21 @@ function serverLogger(options) {
|
|
|
1306
1813
|
}
|
|
1307
1814
|
function finishResponse(response, options) {
|
|
1308
1815
|
const withRequestId = responseWithRequestId(response, options.requestId);
|
|
1309
|
-
|
|
1816
|
+
const stream = isStreamingResponse(withRequestId);
|
|
1817
|
+
const status = withRequestId.status;
|
|
1818
|
+
const complete = () => {
|
|
1819
|
+
const durationMs = Math.round((performance.now() - options.startedAt) * 100) / 100;
|
|
1820
|
+
options.metrics.observe({ durationMs, method: options.method, route: options.route, status });
|
|
1821
|
+
logRequestCompleted(options.logger, status, stream, durationMs);
|
|
1822
|
+
};
|
|
1823
|
+
if (stream && withRequestId.body) {
|
|
1824
|
+
return new Response(trackStreamCompletion(withRequestId.body, complete), {
|
|
1825
|
+
headers: withRequestId.headers,
|
|
1826
|
+
status,
|
|
1827
|
+
statusText: withRequestId.statusText
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
complete();
|
|
1310
1831
|
return withRequestId;
|
|
1311
1832
|
}
|
|
1312
1833
|
function responseWithRequestId(response, requestId) {
|
|
@@ -1318,18 +1839,48 @@ function responseWithRequestId(response, requestId) {
|
|
|
1318
1839
|
statusText: response.statusText
|
|
1319
1840
|
});
|
|
1320
1841
|
}
|
|
1321
|
-
function
|
|
1842
|
+
function trackStreamCompletion(body, onComplete) {
|
|
1843
|
+
const reader = body.getReader();
|
|
1844
|
+
let fired = false;
|
|
1845
|
+
const fire = () => {
|
|
1846
|
+
if (!fired) {
|
|
1847
|
+
fired = true;
|
|
1848
|
+
onComplete();
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
return new ReadableStream({
|
|
1852
|
+
async pull(controller) {
|
|
1853
|
+
try {
|
|
1854
|
+
const { done, value } = await reader.read();
|
|
1855
|
+
if (done) {
|
|
1856
|
+
controller.close();
|
|
1857
|
+
fire();
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
controller.enqueue(value);
|
|
1861
|
+
} catch (error) {
|
|
1862
|
+
fire();
|
|
1863
|
+
controller.error(error);
|
|
1864
|
+
}
|
|
1865
|
+
},
|
|
1866
|
+
cancel(reason) {
|
|
1867
|
+
fire();
|
|
1868
|
+
return reader.cancel(reason);
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
function logRequestCompleted(logger, status, stream, durationMs) {
|
|
1322
1873
|
const fields = {
|
|
1323
|
-
durationMs
|
|
1874
|
+
durationMs,
|
|
1324
1875
|
event: "http.request.completed",
|
|
1325
|
-
status
|
|
1326
|
-
stream
|
|
1876
|
+
status,
|
|
1877
|
+
stream
|
|
1327
1878
|
};
|
|
1328
|
-
if (
|
|
1879
|
+
if (status >= 500) {
|
|
1329
1880
|
logger.error(fields, "request completed with server error");
|
|
1330
1881
|
return;
|
|
1331
1882
|
}
|
|
1332
|
-
if (
|
|
1883
|
+
if (status >= 400) {
|
|
1333
1884
|
logger.warn(fields, "request completed with client error");
|
|
1334
1885
|
return;
|
|
1335
1886
|
}
|
|
@@ -1350,6 +1901,8 @@ function canonicalApiPath(path) {
|
|
|
1350
1901
|
return "/v1/completions";
|
|
1351
1902
|
case "/responses":
|
|
1352
1903
|
return "/v1/responses";
|
|
1904
|
+
case "/usage":
|
|
1905
|
+
return "/v1/usage";
|
|
1353
1906
|
default:
|
|
1354
1907
|
return withoutTrailingSlash;
|
|
1355
1908
|
}
|
|
@@ -1361,6 +1914,12 @@ function routeFor(method, path) {
|
|
|
1361
1914
|
if (method === "GET" && (path === "/" || path === "/healthz")) {
|
|
1362
1915
|
return "health";
|
|
1363
1916
|
}
|
|
1917
|
+
if (method === "GET" && path === "/metrics") {
|
|
1918
|
+
return "metrics";
|
|
1919
|
+
}
|
|
1920
|
+
if (method === "GET" && path === "/v1/usage") {
|
|
1921
|
+
return "usage";
|
|
1922
|
+
}
|
|
1364
1923
|
if (method === "GET" && path === "/v1/models") {
|
|
1365
1924
|
return "models";
|
|
1366
1925
|
}
|
|
@@ -1391,26 +1950,86 @@ function logUpstreamSuccess(logger, upstreamPath, status) {
|
|
|
1391
1950
|
"copilot upstream request completed"
|
|
1392
1951
|
);
|
|
1393
1952
|
}
|
|
1953
|
+
function metricsResponse(metrics) {
|
|
1954
|
+
return new Response(metrics.renderPrometheus(), {
|
|
1955
|
+
headers: {
|
|
1956
|
+
...corsHeaders(),
|
|
1957
|
+
"content-type": PROMETHEUS_CONTENT_TYPE
|
|
1958
|
+
},
|
|
1959
|
+
status: 200
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
async function handleUsage(metrics, readUsage, signal) {
|
|
1963
|
+
const proxy = metrics.snapshot();
|
|
1964
|
+
const { copilot, error } = await readUsage(signal);
|
|
1965
|
+
const body = { copilot: copilot ?? null, object: "usage", proxy };
|
|
1966
|
+
if (error) {
|
|
1967
|
+
body.copilot_error = error;
|
|
1968
|
+
}
|
|
1969
|
+
return jsonResponse(body);
|
|
1970
|
+
}
|
|
1971
|
+
function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_TTL_MS) {
|
|
1972
|
+
const usagePath = "/copilot_internal/user";
|
|
1973
|
+
let cache;
|
|
1974
|
+
return async (signal) => {
|
|
1975
|
+
if (cache && now() - cache.atMs < ttlMs) {
|
|
1976
|
+
return { copilot: cache.value };
|
|
1977
|
+
}
|
|
1978
|
+
try {
|
|
1979
|
+
const upstream = await client.usage(signal);
|
|
1980
|
+
metrics.recordUpstream(usagePath, upstream.ok);
|
|
1981
|
+
if (!upstream.ok) {
|
|
1982
|
+
return { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
|
|
1983
|
+
}
|
|
1984
|
+
const value = normalizeCopilotUsage(await upstream.json().catch(() => ({})));
|
|
1985
|
+
cache = { atMs: now(), value };
|
|
1986
|
+
metrics.recordCopilotQuota(value);
|
|
1987
|
+
return { copilot: value };
|
|
1988
|
+
} catch (error) {
|
|
1989
|
+
metrics.recordUpstream(usagePath, false);
|
|
1990
|
+
if (error instanceof CopilotAuthError) {
|
|
1991
|
+
return { error: error.message };
|
|
1992
|
+
}
|
|
1993
|
+
return { error: errorMessage(error) };
|
|
1994
|
+
}
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
function safeParseJson(text) {
|
|
1998
|
+
try {
|
|
1999
|
+
return JSON.parse(text);
|
|
2000
|
+
} catch {
|
|
2001
|
+
return void 0;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
1394
2004
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1395
2005
|
0 && (module.exports = {
|
|
2006
|
+
COPILOT_USAGE_API_VERSION,
|
|
1396
2007
|
CopilotAuth,
|
|
1397
2008
|
CopilotAuthError,
|
|
1398
2009
|
CopilotClient,
|
|
2010
|
+
DEFAULT_GITHUB_API_BASE_URL,
|
|
1399
2011
|
DEFAULT_LOG_FORMAT,
|
|
1400
2012
|
DEFAULT_LOG_LEVEL,
|
|
1401
2013
|
DEFAULT_MODEL,
|
|
2014
|
+
MetricsRegistry,
|
|
2015
|
+
PROMETHEUS_CONTENT_TYPE,
|
|
2016
|
+
applyCopilotHeaders,
|
|
2017
|
+
applyGithubApiHeaders,
|
|
1402
2018
|
authStorePath,
|
|
1403
2019
|
chatCompletionToCompletion,
|
|
1404
2020
|
chatCompletionToResponse,
|
|
1405
2021
|
completionsRequestToChatCompletion,
|
|
1406
2022
|
createHoopilotHandler,
|
|
1407
2023
|
createHoopilotLogger,
|
|
2024
|
+
extractTokenUsage,
|
|
1408
2025
|
fallbackModels,
|
|
1409
2026
|
githubCopilotDeviceLogin,
|
|
1410
2027
|
noopLogger,
|
|
1411
2028
|
normalizeChatCompletionRequest,
|
|
2029
|
+
normalizeCopilotUsage,
|
|
1412
2030
|
normalizeModelsResponse,
|
|
1413
2031
|
normalizeRequestedModel,
|
|
2032
|
+
observeResponseUsage,
|
|
1414
2033
|
parseLogFormat,
|
|
1415
2034
|
parseLogLevel,
|
|
1416
2035
|
readStoredCopilotAuth,
|