@openhoo/hoopilot 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -17
- package/dist/cli.js +874 -194
- package/dist/cli.js.map +1 -1
- package/dist/codexx.js +1 -1
- package/dist/codexx.js.map +1 -1
- package/dist/index.cjs +772 -132
- 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 +764 -133
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/auth-store.ts
|
|
2
|
-
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
3
3
|
import { dirname, join } from "path";
|
|
4
4
|
function authStorePath(env = process.env) {
|
|
5
5
|
if (env.HOOPILOT_AUTH_FILE) {
|
|
@@ -31,25 +31,36 @@ function readStoredCopilotAuth(path = authStorePath()) {
|
|
|
31
31
|
}
|
|
32
32
|
function writeStoredCopilotAuth(auth, path = authStorePath()) {
|
|
33
33
|
mkdirSync(dirname(path), { recursive: true });
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
);
|
|
34
|
+
const data = `${JSON.stringify(
|
|
35
|
+
{
|
|
36
|
+
...auth,
|
|
37
|
+
createdAt: auth.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
38
|
+
},
|
|
39
|
+
null,
|
|
40
|
+
2
|
|
41
|
+
)}
|
|
42
|
+
`;
|
|
43
|
+
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
44
|
+
writeFileSync(tmpPath, data, { mode: 384 });
|
|
45
|
+
renameSync(tmpPath, path);
|
|
47
46
|
try {
|
|
48
47
|
chmodSync(path, 384);
|
|
49
48
|
} catch {
|
|
50
49
|
}
|
|
51
50
|
}
|
|
52
51
|
|
|
52
|
+
// src/util.ts
|
|
53
|
+
function trimTrailingSlash(value) {
|
|
54
|
+
return value.replace(/\/+$/, "");
|
|
55
|
+
}
|
|
56
|
+
async function truncatedResponseText(response, max = 500) {
|
|
57
|
+
const text = await response.text();
|
|
58
|
+
return text.slice(0, max);
|
|
59
|
+
}
|
|
60
|
+
function asRecord(value) {
|
|
61
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
// src/auth.ts
|
|
54
65
|
var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
|
|
55
66
|
var REFRESH_SKEW_MS = 6e4;
|
|
@@ -92,17 +103,59 @@ var CopilotAuth = class {
|
|
|
92
103
|
return access;
|
|
93
104
|
}
|
|
94
105
|
};
|
|
95
|
-
function trimTrailingSlash(value) {
|
|
96
|
-
return value.replace(/\/+$/, "");
|
|
97
|
-
}
|
|
98
106
|
|
|
99
107
|
// src/copilot.ts
|
|
108
|
+
var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
|
|
109
|
+
var COPILOT_USAGE_API_VERSION = "2025-04-01";
|
|
110
|
+
function applyCopilotHeaders(headers, token) {
|
|
111
|
+
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
112
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
113
|
+
headers.set("copilot-integration-id", "vscode-chat");
|
|
114
|
+
headers.set("editor-plugin-version", "hoopilot/0.1.0");
|
|
115
|
+
headers.set("editor-version", "Hoopilot/0.1.0");
|
|
116
|
+
headers.set("openai-intent", "conversation-panel");
|
|
117
|
+
headers.set("user-agent", "hoopilot/0.1.0");
|
|
118
|
+
headers.set("x-github-api-version", "2026-06-01");
|
|
119
|
+
return headers;
|
|
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
|
+
}
|
|
100
130
|
var CopilotClient = class {
|
|
101
131
|
#auth;
|
|
102
132
|
#fetch;
|
|
133
|
+
#githubApiBaseUrl;
|
|
103
134
|
constructor(options = {}) {
|
|
104
135
|
this.#auth = new CopilotAuth(options);
|
|
105
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
|
+
});
|
|
106
159
|
}
|
|
107
160
|
async chatCompletions(body, signal) {
|
|
108
161
|
return this.fetchCopilot("/chat/completions", {
|
|
@@ -135,21 +188,88 @@ var CopilotClient = class {
|
|
|
135
188
|
}
|
|
136
189
|
async fetchCopilot(path, init) {
|
|
137
190
|
const access = await this.#auth.getAccess();
|
|
138
|
-
const headers = new Headers(init.headers);
|
|
139
|
-
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
140
|
-
headers.set("authorization", `Bearer ${access.token}`);
|
|
141
|
-
headers.set("copilot-integration-id", "vscode-chat");
|
|
142
|
-
headers.set("editor-plugin-version", "hoopilot/0.1.0");
|
|
143
|
-
headers.set("editor-version", "Hoopilot/0.1.0");
|
|
144
|
-
headers.set("openai-intent", "conversation-panel");
|
|
145
|
-
headers.set("user-agent", "hoopilot/0.1.0");
|
|
146
|
-
headers.set("x-github-api-version", "2026-06-01");
|
|
191
|
+
const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
|
|
147
192
|
return this.#fetch(`${access.apiBaseUrl}${path}`, {
|
|
148
193
|
...init,
|
|
149
194
|
headers
|
|
150
195
|
});
|
|
151
196
|
}
|
|
152
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
|
+
}
|
|
153
273
|
|
|
154
274
|
// src/github-device.ts
|
|
155
275
|
import { setTimeout as sleep } from "timers/promises";
|
|
@@ -157,6 +277,7 @@ var DEFAULT_GITHUB_COPILOT_CLIENT_ID = "Ov23li8tweQw6odWQebz";
|
|
|
157
277
|
var DEFAULT_GITHUB_DOMAIN = "github.com";
|
|
158
278
|
var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
159
279
|
var POLLING_SAFETY_MARGIN_MS = 3e3;
|
|
280
|
+
var REQUEST_TIMEOUT_MS = 15e3;
|
|
160
281
|
async function githubCopilotDeviceLogin(options = {}) {
|
|
161
282
|
const env = options.env ?? process.env;
|
|
162
283
|
const fetcher = options.fetch ?? fetch;
|
|
@@ -191,16 +312,20 @@ async function requestDeviceCode(fetcher, domain, clientId) {
|
|
|
191
312
|
scope: "read:user"
|
|
192
313
|
}),
|
|
193
314
|
headers: oauthHeaders(),
|
|
194
|
-
method: "POST"
|
|
315
|
+
method: "POST",
|
|
316
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
195
317
|
});
|
|
196
318
|
if (!response.ok) {
|
|
197
319
|
throw new Error(
|
|
198
|
-
`GitHub device authorization failed with ${response.status}: ${await
|
|
320
|
+
`GitHub device authorization failed with ${response.status}: ${await truncatedResponseText(
|
|
199
321
|
response
|
|
200
322
|
)}`
|
|
201
323
|
);
|
|
202
324
|
}
|
|
203
|
-
return
|
|
325
|
+
return parseJsonResponse(
|
|
326
|
+
response,
|
|
327
|
+
"GitHub device authorization response was not valid JSON"
|
|
328
|
+
);
|
|
204
329
|
}
|
|
205
330
|
async function pollForAccessToken(fetcher, sleeper, domain, clientId, device) {
|
|
206
331
|
let intervalMs = device.interval * 1e3 + POLLING_SAFETY_MARGIN_MS;
|
|
@@ -214,16 +339,20 @@ async function pollForAccessToken(fetcher, sleeper, domain, clientId, device) {
|
|
|
214
339
|
grant_type: DEVICE_GRANT_TYPE
|
|
215
340
|
}),
|
|
216
341
|
headers: oauthHeaders(),
|
|
217
|
-
method: "POST"
|
|
342
|
+
method: "POST",
|
|
343
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
218
344
|
});
|
|
219
345
|
if (!response.ok) {
|
|
220
346
|
throw new Error(
|
|
221
|
-
`GitHub device token exchange failed with ${response.status}: ${await
|
|
347
|
+
`GitHub device token exchange failed with ${response.status}: ${await truncatedResponseText(
|
|
222
348
|
response
|
|
223
349
|
)}`
|
|
224
350
|
);
|
|
225
351
|
}
|
|
226
|
-
const data = await
|
|
352
|
+
const data = await parseJsonResponse(
|
|
353
|
+
response,
|
|
354
|
+
"GitHub device token response was not valid JSON"
|
|
355
|
+
);
|
|
227
356
|
if (data.access_token) {
|
|
228
357
|
return data.access_token;
|
|
229
358
|
}
|
|
@@ -259,9 +388,13 @@ function normalizeDomain(value) {
|
|
|
259
388
|
function positiveSeconds(value, fallback) {
|
|
260
389
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
261
390
|
}
|
|
262
|
-
async function
|
|
391
|
+
async function parseJsonResponse(response, context) {
|
|
263
392
|
const text = await response.text();
|
|
264
|
-
|
|
393
|
+
try {
|
|
394
|
+
return JSON.parse(text);
|
|
395
|
+
} catch {
|
|
396
|
+
throw new Error(`${context}: ${text.slice(0, 500)}`);
|
|
397
|
+
}
|
|
265
398
|
}
|
|
266
399
|
|
|
267
400
|
// src/logger.ts
|
|
@@ -364,6 +497,16 @@ function shouldCreateLogger(options) {
|
|
|
364
497
|
options.logger || options.logFormat || options.logLevel || options.env?.HOOPILOT_LOG_FORMAT || options.env?.HOOPILOT_LOG_LEVEL
|
|
365
498
|
);
|
|
366
499
|
}
|
|
500
|
+
function errorDetails(error) {
|
|
501
|
+
if (error instanceof Error) {
|
|
502
|
+
return {
|
|
503
|
+
message: error.message,
|
|
504
|
+
name: error.name,
|
|
505
|
+
stack: error.stack
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
return { message: String(error) };
|
|
509
|
+
}
|
|
367
510
|
function isLogFormat(value) {
|
|
368
511
|
return LOG_FORMATS.includes(value);
|
|
369
512
|
}
|
|
@@ -541,17 +684,18 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
541
684
|
const lines = buffer.split(/\r?\n/);
|
|
542
685
|
buffer = lines.pop() ?? "";
|
|
543
686
|
for (const line of lines) {
|
|
544
|
-
processChatSseLine(line, enqueue, tools, (delta) => {
|
|
687
|
+
processChatSseLine(messageId, line, enqueue, tools, (delta) => {
|
|
545
688
|
text += delta;
|
|
546
689
|
});
|
|
547
690
|
}
|
|
548
691
|
}
|
|
549
692
|
if (buffer) {
|
|
550
|
-
processChatSseLine(buffer, enqueue, tools, (delta) => {
|
|
693
|
+
processChatSseLine(messageId, buffer, enqueue, tools, (delta) => {
|
|
551
694
|
text += delta;
|
|
552
695
|
});
|
|
553
696
|
}
|
|
554
|
-
const
|
|
697
|
+
const toolItems = [...tools.values()].map(functionCallItem);
|
|
698
|
+
const output = [messageOutputItem(text, messageId), ...toolItems];
|
|
555
699
|
enqueue("response.output_text.done", {
|
|
556
700
|
content_index: 0,
|
|
557
701
|
item_id: messageId,
|
|
@@ -575,8 +719,7 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
575
719
|
output_index: 0,
|
|
576
720
|
type: "response.output_item.done"
|
|
577
721
|
});
|
|
578
|
-
|
|
579
|
-
const item = functionCallItem(tool);
|
|
722
|
+
toolItems.forEach((item, index) => {
|
|
580
723
|
const outputIndex = index + 1;
|
|
581
724
|
enqueue("response.output_item.added", {
|
|
582
725
|
item,
|
|
@@ -584,7 +727,7 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
584
727
|
type: "response.output_item.added"
|
|
585
728
|
});
|
|
586
729
|
enqueue("response.function_call_arguments.done", {
|
|
587
|
-
arguments:
|
|
730
|
+
arguments: item.arguments,
|
|
588
731
|
item_id: item.id,
|
|
589
732
|
output_index: outputIndex,
|
|
590
733
|
type: "response.function_call_arguments.done"
|
|
@@ -602,6 +745,8 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
602
745
|
enqueue("done", "[DONE]");
|
|
603
746
|
controller.close();
|
|
604
747
|
} catch (error) {
|
|
748
|
+
await reader.cancel(error).catch(() => {
|
|
749
|
+
});
|
|
605
750
|
controller.error(error);
|
|
606
751
|
} finally {
|
|
607
752
|
reader.releaseLock();
|
|
@@ -804,11 +949,45 @@ function responseUsage(usage) {
|
|
|
804
949
|
total_tokens: record.total_tokens
|
|
805
950
|
});
|
|
806
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
|
+
}
|
|
807
986
|
function firstChoice(completion) {
|
|
808
987
|
const choices = Array.isArray(completion.choices) ? completion.choices : [];
|
|
809
988
|
return asRecord(choices[0]);
|
|
810
989
|
}
|
|
811
|
-
function processChatSseLine(line, enqueue, tools, appendText) {
|
|
990
|
+
function processChatSseLine(messageId, line, enqueue, tools, appendText) {
|
|
812
991
|
const trimmed = line.trim();
|
|
813
992
|
if (!trimmed.startsWith("data:")) {
|
|
814
993
|
return;
|
|
@@ -829,7 +1008,7 @@ function processChatSseLine(line, enqueue, tools, appendText) {
|
|
|
829
1008
|
enqueue("response.output_text.delta", {
|
|
830
1009
|
content_index: 0,
|
|
831
1010
|
delta: content,
|
|
832
|
-
item_id:
|
|
1011
|
+
item_id: messageId,
|
|
833
1012
|
output_index: 0,
|
|
834
1013
|
type: "response.output_text.delta"
|
|
835
1014
|
});
|
|
@@ -851,9 +1030,6 @@ function processChatSseLine(line, enqueue, tools, appendText) {
|
|
|
851
1030
|
tools.set(index, existing);
|
|
852
1031
|
}
|
|
853
1032
|
}
|
|
854
|
-
function streamOutputItems(messageId, text, tools) {
|
|
855
|
-
return [messageOutputItem(text, messageId), ...tools.map((tool) => functionCallItem(tool))];
|
|
856
|
-
}
|
|
857
1033
|
function baseStreamResponse(id, model, createdAt, status, output) {
|
|
858
1034
|
return {
|
|
859
1035
|
created_at: createdAt,
|
|
@@ -893,9 +1069,6 @@ function parseJson(data) {
|
|
|
893
1069
|
function removeUndefined(record) {
|
|
894
1070
|
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
|
|
895
1071
|
}
|
|
896
|
-
function asRecord(value) {
|
|
897
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
898
|
-
}
|
|
899
1072
|
function randomId() {
|
|
900
1073
|
return crypto.randomUUID().replaceAll("-", "");
|
|
901
1074
|
}
|
|
@@ -903,104 +1076,449 @@ function epochSeconds() {
|
|
|
903
1076
|
return Math.floor(Date.now() / 1e3);
|
|
904
1077
|
}
|
|
905
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
|
+
|
|
906
1445
|
// src/server.ts
|
|
907
1446
|
var DEFAULT_HOST = "127.0.0.1";
|
|
908
1447
|
var DEFAULT_PORT = 4141;
|
|
909
1448
|
var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
|
|
1449
|
+
var USAGE_CACHE_TTL_MS = 6e4;
|
|
910
1450
|
function createHoopilotHandler(options = {}) {
|
|
911
1451
|
const client = new CopilotClient(options);
|
|
912
1452
|
const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
|
|
913
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);
|
|
914
1457
|
return async (request) => {
|
|
915
1458
|
const startedAt = performance.now();
|
|
916
1459
|
const url = new URL(request.url);
|
|
917
1460
|
const apiPath = canonicalApiPath(url.pathname);
|
|
918
1461
|
const requestId = requestIdFor(request);
|
|
1462
|
+
const route = routeFor(request.method, apiPath);
|
|
919
1463
|
const requestLogger = logger.child({
|
|
920
1464
|
method: request.method,
|
|
921
1465
|
path: url.pathname,
|
|
922
1466
|
requestId,
|
|
923
|
-
route
|
|
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
|
|
924
1477
|
});
|
|
925
1478
|
if (request.method === "OPTIONS") {
|
|
926
|
-
return
|
|
927
|
-
logger: requestLogger,
|
|
928
|
-
requestId,
|
|
929
|
-
startedAt
|
|
930
|
-
});
|
|
1479
|
+
return finish(new Response(null, { headers: corsHeaders() }));
|
|
931
1480
|
}
|
|
932
1481
|
if (!isAuthorized(request, apiKey)) {
|
|
933
1482
|
requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
|
|
934
|
-
return
|
|
935
|
-
jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."),
|
|
936
|
-
{
|
|
937
|
-
logger: requestLogger,
|
|
938
|
-
requestId,
|
|
939
|
-
startedAt
|
|
940
|
-
}
|
|
941
|
-
);
|
|
1483
|
+
return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
|
|
942
1484
|
}
|
|
943
1485
|
try {
|
|
944
1486
|
if (request.method === "GET" && (apiPath === "/" || apiPath === "/healthz")) {
|
|
945
|
-
return
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
);
|
|
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));
|
|
953
1494
|
}
|
|
954
1495
|
if (request.method === "GET" && apiPath === "/v1/responses") {
|
|
955
|
-
return
|
|
956
|
-
logger: requestLogger,
|
|
957
|
-
requestId,
|
|
958
|
-
startedAt
|
|
959
|
-
});
|
|
1496
|
+
return finish(websocketUnsupportedResponse());
|
|
960
1497
|
}
|
|
961
1498
|
if (request.method === "GET" && apiPath === "/v1/models") {
|
|
962
|
-
return
|
|
963
|
-
logger: requestLogger,
|
|
964
|
-
requestId,
|
|
965
|
-
startedAt
|
|
966
|
-
});
|
|
1499
|
+
return finish(await handleModels(client, metrics, request.signal, requestLogger));
|
|
967
1500
|
}
|
|
968
1501
|
if (request.method === "POST" && apiPath === "/v1/chat/completions") {
|
|
969
|
-
return
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
startedAt
|
|
973
|
-
});
|
|
1502
|
+
return finish(
|
|
1503
|
+
await handleChatCompletions(client, metrics, recordTokens, request, requestLogger)
|
|
1504
|
+
);
|
|
974
1505
|
}
|
|
975
1506
|
if (request.method === "POST" && apiPath === "/v1/completions") {
|
|
976
|
-
return
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
startedAt
|
|
980
|
-
});
|
|
1507
|
+
return finish(
|
|
1508
|
+
await handleCompletions(client, metrics, recordTokens, request, requestLogger)
|
|
1509
|
+
);
|
|
981
1510
|
}
|
|
982
1511
|
if (request.method === "POST" && apiPath === "/v1/responses") {
|
|
983
|
-
return
|
|
984
|
-
logger: requestLogger,
|
|
985
|
-
requestId,
|
|
986
|
-
startedAt
|
|
987
|
-
});
|
|
1512
|
+
return finish(await handleResponses(client, metrics, recordTokens, request, requestLogger));
|
|
988
1513
|
}
|
|
989
|
-
return
|
|
990
|
-
jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`),
|
|
991
|
-
{ logger: requestLogger, requestId, startedAt }
|
|
992
|
-
);
|
|
1514
|
+
return finish(jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`));
|
|
993
1515
|
} catch (error) {
|
|
994
1516
|
if (error instanceof CopilotAuthError) {
|
|
995
1517
|
requestLogger.warn(
|
|
996
1518
|
{ err: errorDetails(error), event: "copilot.auth.missing" },
|
|
997
1519
|
"copilot auth failed"
|
|
998
1520
|
);
|
|
999
|
-
return
|
|
1000
|
-
logger: requestLogger,
|
|
1001
|
-
requestId,
|
|
1002
|
-
startedAt
|
|
1003
|
-
});
|
|
1521
|
+
return finish(jsonError(401, "copilot_auth_error", error.message));
|
|
1004
1522
|
}
|
|
1005
1523
|
const message = errorMessage(error);
|
|
1006
1524
|
if (message === INVALID_JSON_MESSAGE) {
|
|
@@ -1014,11 +1532,7 @@ function createHoopilotHandler(options = {}) {
|
|
|
1014
1532
|
"request failed"
|
|
1015
1533
|
);
|
|
1016
1534
|
}
|
|
1017
|
-
return
|
|
1018
|
-
logger: requestLogger,
|
|
1019
|
-
requestId,
|
|
1020
|
-
startedAt
|
|
1021
|
-
});
|
|
1535
|
+
return finish(jsonError(500, "internal_error", message));
|
|
1022
1536
|
}
|
|
1023
1537
|
};
|
|
1024
1538
|
}
|
|
@@ -1047,8 +1561,9 @@ function startHoopilotServer(options = {}) {
|
|
|
1047
1561
|
url: `http://${host}:${server.port}`
|
|
1048
1562
|
};
|
|
1049
1563
|
}
|
|
1050
|
-
async function handleModels(client, signal, logger) {
|
|
1564
|
+
async function handleModels(client, metrics, signal, logger) {
|
|
1051
1565
|
const upstream = await client.models(signal);
|
|
1566
|
+
metrics.recordUpstream("/models", upstream.ok);
|
|
1052
1567
|
if (!upstream.ok) {
|
|
1053
1568
|
if (isUpstreamAuthStatus(upstream.status)) {
|
|
1054
1569
|
return proxyError(upstream, logger);
|
|
@@ -1066,35 +1581,50 @@ async function handleModels(client, signal, logger) {
|
|
|
1066
1581
|
logUpstreamSuccess(logger, "/models", upstream.status);
|
|
1067
1582
|
return jsonResponse(normalizeModelsResponse(await upstream.json()));
|
|
1068
1583
|
}
|
|
1069
|
-
async function handleChatCompletions(client, request, logger) {
|
|
1584
|
+
async function handleChatCompletions(client, metrics, recordTokens, request, logger) {
|
|
1070
1585
|
const chatRequest = normalizeChatCompletionRequest(await readJson(request));
|
|
1071
1586
|
const upstream = await client.chatCompletions(chatRequest, request.signal);
|
|
1587
|
+
metrics.recordUpstream("/chat/completions", upstream.ok);
|
|
1072
1588
|
if (!upstream.ok) {
|
|
1073
1589
|
return proxyError(upstream, logger);
|
|
1074
1590
|
}
|
|
1075
1591
|
logUpstreamSuccess(logger, "/chat/completions", upstream.status);
|
|
1076
|
-
|
|
1592
|
+
const model = normalizeRequestedModel(chatRequest.model);
|
|
1593
|
+
return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
|
|
1077
1594
|
}
|
|
1078
|
-
async function handleCompletions(client, request, logger) {
|
|
1595
|
+
async function handleCompletions(client, metrics, recordTokens, request, logger) {
|
|
1079
1596
|
const body = await readJson(request);
|
|
1080
1597
|
const upstream = await client.chatCompletions(
|
|
1081
1598
|
completionsRequestToChatCompletion(body),
|
|
1082
1599
|
request.signal
|
|
1083
1600
|
);
|
|
1601
|
+
metrics.recordUpstream("/chat/completions", upstream.ok);
|
|
1084
1602
|
if (!upstream.ok) {
|
|
1085
1603
|
return proxyError(upstream, logger);
|
|
1086
1604
|
}
|
|
1087
1605
|
logUpstreamSuccess(logger, "/chat/completions", upstream.status);
|
|
1088
|
-
|
|
1606
|
+
const model = normalizeRequestedModel(body.model);
|
|
1607
|
+
if (isStreamingResponse(upstream)) {
|
|
1608
|
+
return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
|
|
1609
|
+
}
|
|
1610
|
+
const completion = asRecord(await upstream.json());
|
|
1611
|
+
const usage = extractTokenUsage(completion.usage);
|
|
1612
|
+
if (usage) {
|
|
1613
|
+
const responseModel = typeof completion.model === "string" ? completion.model.trim() : "";
|
|
1614
|
+
recordTokens(responseModel || model, usage);
|
|
1615
|
+
}
|
|
1616
|
+
return jsonResponse(chatCompletionToCompletion(completion));
|
|
1089
1617
|
}
|
|
1090
|
-
async function handleResponses(client, request, logger) {
|
|
1618
|
+
async function handleResponses(client, metrics, recordTokens, request, logger) {
|
|
1091
1619
|
const body = await readJsonText(request);
|
|
1092
1620
|
const upstream = await client.responses(body, request.signal);
|
|
1621
|
+
metrics.recordUpstream("/responses", upstream.ok);
|
|
1093
1622
|
if (!upstream.ok) {
|
|
1094
1623
|
return proxyError(upstream, logger);
|
|
1095
1624
|
}
|
|
1096
1625
|
logUpstreamSuccess(logger, "/responses", upstream.status);
|
|
1097
|
-
|
|
1626
|
+
const model = normalizeRequestedModel(asRecord(safeParseJson(body)).model);
|
|
1627
|
+
return proxyResponse(observeResponseUsage(upstream, model, recordTokens, request.signal));
|
|
1098
1628
|
}
|
|
1099
1629
|
async function proxyError(upstream, logger) {
|
|
1100
1630
|
const text = await upstream.text();
|
|
@@ -1127,8 +1657,7 @@ function proxyResponse(upstream) {
|
|
|
1127
1657
|
}
|
|
1128
1658
|
async function readJson(request) {
|
|
1129
1659
|
try {
|
|
1130
|
-
|
|
1131
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1660
|
+
return asRecord(await request.json());
|
|
1132
1661
|
} catch {
|
|
1133
1662
|
throw new Error(INVALID_JSON_MESSAGE);
|
|
1134
1663
|
}
|
|
@@ -1214,7 +1743,21 @@ function serverLogger(options) {
|
|
|
1214
1743
|
}
|
|
1215
1744
|
function finishResponse(response, options) {
|
|
1216
1745
|
const withRequestId = responseWithRequestId(response, options.requestId);
|
|
1217
|
-
|
|
1746
|
+
const stream = isStreamingResponse(withRequestId);
|
|
1747
|
+
const status = withRequestId.status;
|
|
1748
|
+
const complete = () => {
|
|
1749
|
+
const durationMs = Math.round((performance.now() - options.startedAt) * 100) / 100;
|
|
1750
|
+
options.metrics.observe({ durationMs, method: options.method, route: options.route, status });
|
|
1751
|
+
logRequestCompleted(options.logger, status, stream, durationMs);
|
|
1752
|
+
};
|
|
1753
|
+
if (stream && withRequestId.body) {
|
|
1754
|
+
return new Response(trackStreamCompletion(withRequestId.body, complete), {
|
|
1755
|
+
headers: withRequestId.headers,
|
|
1756
|
+
status,
|
|
1757
|
+
statusText: withRequestId.statusText
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
complete();
|
|
1218
1761
|
return withRequestId;
|
|
1219
1762
|
}
|
|
1220
1763
|
function responseWithRequestId(response, requestId) {
|
|
@@ -1226,18 +1769,48 @@ function responseWithRequestId(response, requestId) {
|
|
|
1226
1769
|
statusText: response.statusText
|
|
1227
1770
|
});
|
|
1228
1771
|
}
|
|
1229
|
-
function
|
|
1772
|
+
function trackStreamCompletion(body, onComplete) {
|
|
1773
|
+
const reader = body.getReader();
|
|
1774
|
+
let fired = false;
|
|
1775
|
+
const fire = () => {
|
|
1776
|
+
if (!fired) {
|
|
1777
|
+
fired = true;
|
|
1778
|
+
onComplete();
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
return new ReadableStream({
|
|
1782
|
+
async pull(controller) {
|
|
1783
|
+
try {
|
|
1784
|
+
const { done, value } = await reader.read();
|
|
1785
|
+
if (done) {
|
|
1786
|
+
controller.close();
|
|
1787
|
+
fire();
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
controller.enqueue(value);
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
fire();
|
|
1793
|
+
controller.error(error);
|
|
1794
|
+
}
|
|
1795
|
+
},
|
|
1796
|
+
cancel(reason) {
|
|
1797
|
+
fire();
|
|
1798
|
+
return reader.cancel(reason);
|
|
1799
|
+
}
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
function logRequestCompleted(logger, status, stream, durationMs) {
|
|
1230
1803
|
const fields = {
|
|
1231
|
-
durationMs
|
|
1804
|
+
durationMs,
|
|
1232
1805
|
event: "http.request.completed",
|
|
1233
|
-
status
|
|
1234
|
-
stream
|
|
1806
|
+
status,
|
|
1807
|
+
stream
|
|
1235
1808
|
};
|
|
1236
|
-
if (
|
|
1809
|
+
if (status >= 500) {
|
|
1237
1810
|
logger.error(fields, "request completed with server error");
|
|
1238
1811
|
return;
|
|
1239
1812
|
}
|
|
1240
|
-
if (
|
|
1813
|
+
if (status >= 400) {
|
|
1241
1814
|
logger.warn(fields, "request completed with client error");
|
|
1242
1815
|
return;
|
|
1243
1816
|
}
|
|
@@ -1258,6 +1831,8 @@ function canonicalApiPath(path) {
|
|
|
1258
1831
|
return "/v1/completions";
|
|
1259
1832
|
case "/responses":
|
|
1260
1833
|
return "/v1/responses";
|
|
1834
|
+
case "/usage":
|
|
1835
|
+
return "/v1/usage";
|
|
1261
1836
|
default:
|
|
1262
1837
|
return withoutTrailingSlash;
|
|
1263
1838
|
}
|
|
@@ -1269,6 +1844,12 @@ function routeFor(method, path) {
|
|
|
1269
1844
|
if (method === "GET" && (path === "/" || path === "/healthz")) {
|
|
1270
1845
|
return "health";
|
|
1271
1846
|
}
|
|
1847
|
+
if (method === "GET" && path === "/metrics") {
|
|
1848
|
+
return "metrics";
|
|
1849
|
+
}
|
|
1850
|
+
if (method === "GET" && path === "/v1/usage") {
|
|
1851
|
+
return "usage";
|
|
1852
|
+
}
|
|
1272
1853
|
if (method === "GET" && path === "/v1/models") {
|
|
1273
1854
|
return "models";
|
|
1274
1855
|
}
|
|
@@ -1299,35 +1880,85 @@ function logUpstreamSuccess(logger, upstreamPath, status) {
|
|
|
1299
1880
|
"copilot upstream request completed"
|
|
1300
1881
|
);
|
|
1301
1882
|
}
|
|
1302
|
-
function
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1883
|
+
function metricsResponse(metrics) {
|
|
1884
|
+
return new Response(metrics.renderPrometheus(), {
|
|
1885
|
+
headers: {
|
|
1886
|
+
...corsHeaders(),
|
|
1887
|
+
"content-type": PROMETHEUS_CONTENT_TYPE
|
|
1888
|
+
},
|
|
1889
|
+
status: 200
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
async function handleUsage(metrics, readUsage, signal) {
|
|
1893
|
+
const proxy = metrics.snapshot();
|
|
1894
|
+
const { copilot, error } = await readUsage(signal);
|
|
1895
|
+
const body = { copilot: copilot ?? null, object: "usage", proxy };
|
|
1896
|
+
if (error) {
|
|
1897
|
+
body.copilot_error = error;
|
|
1898
|
+
}
|
|
1899
|
+
return jsonResponse(body);
|
|
1900
|
+
}
|
|
1901
|
+
function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_TTL_MS) {
|
|
1902
|
+
const usagePath = "/copilot_internal/user";
|
|
1903
|
+
let cache;
|
|
1904
|
+
return async (signal) => {
|
|
1905
|
+
if (cache && now() - cache.atMs < ttlMs) {
|
|
1906
|
+
return { copilot: cache.value };
|
|
1907
|
+
}
|
|
1908
|
+
try {
|
|
1909
|
+
const upstream = await client.usage(signal);
|
|
1910
|
+
metrics.recordUpstream(usagePath, upstream.ok);
|
|
1911
|
+
if (!upstream.ok) {
|
|
1912
|
+
return { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
|
|
1913
|
+
}
|
|
1914
|
+
const value = normalizeCopilotUsage(await upstream.json().catch(() => ({})));
|
|
1915
|
+
cache = { atMs: now(), value };
|
|
1916
|
+
metrics.recordCopilotQuota(value);
|
|
1917
|
+
return { copilot: value };
|
|
1918
|
+
} catch (error) {
|
|
1919
|
+
metrics.recordUpstream(usagePath, false);
|
|
1920
|
+
if (error instanceof CopilotAuthError) {
|
|
1921
|
+
return { error: error.message };
|
|
1922
|
+
}
|
|
1923
|
+
return { error: errorMessage(error) };
|
|
1924
|
+
}
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
function safeParseJson(text) {
|
|
1928
|
+
try {
|
|
1929
|
+
return JSON.parse(text);
|
|
1930
|
+
} catch {
|
|
1931
|
+
return void 0;
|
|
1309
1932
|
}
|
|
1310
|
-
return { message: String(error) };
|
|
1311
1933
|
}
|
|
1312
1934
|
export {
|
|
1935
|
+
COPILOT_USAGE_API_VERSION,
|
|
1313
1936
|
CopilotAuth,
|
|
1314
1937
|
CopilotAuthError,
|
|
1315
1938
|
CopilotClient,
|
|
1939
|
+
DEFAULT_GITHUB_API_BASE_URL,
|
|
1316
1940
|
DEFAULT_LOG_FORMAT,
|
|
1317
1941
|
DEFAULT_LOG_LEVEL,
|
|
1318
1942
|
DEFAULT_MODEL,
|
|
1943
|
+
MetricsRegistry,
|
|
1944
|
+
PROMETHEUS_CONTENT_TYPE,
|
|
1945
|
+
applyCopilotHeaders,
|
|
1946
|
+
applyGithubApiHeaders,
|
|
1319
1947
|
authStorePath,
|
|
1320
1948
|
chatCompletionToCompletion,
|
|
1321
1949
|
chatCompletionToResponse,
|
|
1322
1950
|
completionsRequestToChatCompletion,
|
|
1323
1951
|
createHoopilotHandler,
|
|
1324
1952
|
createHoopilotLogger,
|
|
1953
|
+
extractTokenUsage,
|
|
1325
1954
|
fallbackModels,
|
|
1326
1955
|
githubCopilotDeviceLogin,
|
|
1327
1956
|
noopLogger,
|
|
1328
1957
|
normalizeChatCompletionRequest,
|
|
1958
|
+
normalizeCopilotUsage,
|
|
1329
1959
|
normalizeModelsResponse,
|
|
1330
1960
|
normalizeRequestedModel,
|
|
1961
|
+
observeResponseUsage,
|
|
1331
1962
|
parseLogFormat,
|
|
1332
1963
|
parseLogLevel,
|
|
1333
1964
|
readStoredCopilotAuth,
|