@openhoo/hoopilot 1.3.0 → 2.1.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/dist/index.cjs DELETED
@@ -1,4653 +0,0 @@
1
- "use strict";
2
- var __create = Object.create;
3
- var __defProp = Object.defineProperty;
4
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
- var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __getProtoOf = Object.getPrototypeOf;
7
- var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __export = (target, all) => {
9
- for (var name in all)
10
- __defProp(target, name, { get: all[name], enumerable: true });
11
- };
12
- var __copyProps = (to, from, except, desc) => {
13
- if (from && typeof from === "object" || typeof from === "function") {
14
- for (let key of __getOwnPropNames(from))
15
- if (!__hasOwnProp.call(to, key) && key !== except)
16
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
- }
18
- return to;
19
- };
20
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
- // If the importer is in node compatibility mode or this is not an ESM
22
- // file that has been converted to a CommonJS file using a Babel-
23
- // compatible transform (i.e. "__esModule" has not been set), then set
24
- // "default" to the CommonJS "module.exports" for node compatibility.
25
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
- mod
27
- ));
28
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
-
30
- // src/index.ts
31
- var index_exports = {};
32
- __export(index_exports, {
33
- AnthropicCompatibilityError: () => AnthropicCompatibilityError,
34
- COPILOT_USAGE_API_VERSION: () => COPILOT_USAGE_API_VERSION,
35
- CopilotAuth: () => CopilotAuth,
36
- CopilotAuthError: () => CopilotAuthError,
37
- CopilotClient: () => CopilotClient,
38
- DEFAULT_GITHUB_API_BASE_URL: () => DEFAULT_GITHUB_API_BASE_URL,
39
- DEFAULT_LOG_FORMAT: () => DEFAULT_LOG_FORMAT,
40
- DEFAULT_LOG_LEVEL: () => DEFAULT_LOG_LEVEL,
41
- DEFAULT_MODEL: () => DEFAULT_MODEL,
42
- MetricsRegistry: () => MetricsRegistry,
43
- PROMETHEUS_CONTENT_TYPE: () => PROMETHEUS_CONTENT_TYPE,
44
- anthropicMessagesToResponsesRequest: () => anthropicMessagesToResponsesRequest,
45
- applyCopilotHeaders: () => applyCopilotHeaders,
46
- applyGithubApiHeaders: () => applyGithubApiHeaders,
47
- authStorePath: () => authStorePath,
48
- chatCompletionToCompletion: () => chatCompletionToCompletion,
49
- chatCompletionToResponse: () => chatCompletionToResponse,
50
- completionStreamFromChatStream: () => completionStreamFromChatStream,
51
- completionsRequestToChatCompletion: () => completionsRequestToChatCompletion,
52
- createHoopilotHandler: () => createHoopilotHandler,
53
- createHoopilotLogger: () => createHoopilotLogger,
54
- estimateAnthropicMessageTokens: () => estimateAnthropicMessageTokens,
55
- extractTokenUsage: () => extractTokenUsage,
56
- fallbackModels: () => fallbackModels,
57
- githubCopilotDeviceLogin: () => githubCopilotDeviceLogin,
58
- noopLogger: () => noopLogger,
59
- normalizeChatCompletionRequest: () => normalizeChatCompletionRequest,
60
- normalizeCopilotUsage: () => normalizeCopilotUsage,
61
- normalizeModelsResponse: () => normalizeModelsResponse,
62
- normalizeRequestedModel: () => normalizeRequestedModel,
63
- observeResponseUsage: () => observeResponseUsage,
64
- parseLogFormat: () => parseLogFormat,
65
- parseLogLevel: () => parseLogLevel,
66
- parseRateLimitHeaders: () => parseRateLimitHeaders,
67
- readStoredCopilotAuth: () => readStoredCopilotAuth,
68
- responsesCompactionResult: () => responsesCompactionResult,
69
- responsesRequestToChatCompletion: () => responsesRequestToChatCompletion,
70
- responsesResponseToAnthropicMessage: () => responsesResponseToAnthropicMessage,
71
- responsesStreamFromChatStream: () => responsesStreamFromChatStream,
72
- responsesStreamToAnthropicStream: () => responsesStreamToAnthropicStream,
73
- startHoopilotServer: () => startHoopilotServer,
74
- writeStoredCopilotAuth: () => writeStoredCopilotAuth
75
- });
76
- module.exports = __toCommonJS(index_exports);
77
-
78
- // src/util.ts
79
- function trimTrailingSlash(value) {
80
- return value.replace(/\/+$/, "");
81
- }
82
- function envValue(value) {
83
- const trimmed = value?.trim();
84
- return trimmed ? trimmed : void 0;
85
- }
86
- function isTrustedTokenBaseUrl(rawUrl, allowedHttpsHosts, allowUnsafeHttps = false) {
87
- const url = parseUrl(rawUrl);
88
- if (!url) {
89
- return false;
90
- }
91
- if (url.username || url.password || url.search || url.hash) {
92
- return false;
93
- }
94
- if (url.pathname !== "" && url.pathname !== "/") {
95
- return false;
96
- }
97
- if (isLoopbackHttpUrl(url)) {
98
- return true;
99
- }
100
- if (url.protocol !== "https:") {
101
- return false;
102
- }
103
- const host = url.hostname.toLowerCase();
104
- return allowedHttpsHosts.includes(host) || allowUnsafeHttps;
105
- }
106
- function parseUrl(rawUrl) {
107
- let url;
108
- try {
109
- url = new URL(rawUrl);
110
- } catch {
111
- return void 0;
112
- }
113
- return url;
114
- }
115
- function isLoopbackHttpUrl(url) {
116
- return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
117
- }
118
- async function truncatedResponseText(response, max = 500) {
119
- const text = await response.text();
120
- return text.slice(0, max);
121
- }
122
- function asRecord(value) {
123
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
124
- }
125
-
126
- // src/openai.ts
127
- var DEFAULT_MODEL = "gpt-4.1";
128
- var OpenAICompatibilityError = class extends Error {
129
- constructor(message) {
130
- super(message);
131
- this.name = "OpenAICompatibilityError";
132
- }
133
- };
134
- function responsesRequestToChatCompletion(request) {
135
- const messages = [];
136
- const instructions = contentToText(request.instructions);
137
- if (instructions) {
138
- messages.push({ content: instructions, role: "system" });
139
- }
140
- for (const message of inputToMessages(request.input)) {
141
- messages.push(message);
142
- }
143
- return removeUndefined({
144
- frequency_penalty: request.frequency_penalty,
145
- max_tokens: request.max_output_tokens ?? request.max_tokens,
146
- messages,
147
- metadata: request.metadata,
148
- model: normalizeRequestedModel(request.model),
149
- presence_penalty: request.presence_penalty,
150
- reasoning_effort: asRecord(request.reasoning).effort,
151
- response_format: asRecord(request.text).format,
152
- seed: request.seed,
153
- stream: request.stream === true,
154
- temperature: request.temperature,
155
- tool_choice: chatToolChoice(request.tool_choice),
156
- tools: chatTools(request.tools),
157
- top_p: request.top_p
158
- });
159
- }
160
- function normalizeChatCompletionRequest(request) {
161
- return removeUndefined({
162
- ...request,
163
- model: normalizeRequestedModel(request.model)
164
- });
165
- }
166
- function completionsRequestToChatCompletion(request) {
167
- assertSupportedLegacyCompletionRequest(request);
168
- return removeUndefined({
169
- frequency_penalty: request.frequency_penalty,
170
- logit_bias: request.logit_bias,
171
- max_tokens: request.max_tokens,
172
- messages: [{ content: legacyPromptToText(request.prompt), role: "user" }],
173
- model: normalizeRequestedModel(request.model),
174
- n: request.n,
175
- presence_penalty: request.presence_penalty,
176
- seed: request.seed,
177
- stop: request.stop,
178
- stream: request.stream === true,
179
- stream_options: request.stream_options,
180
- temperature: request.temperature,
181
- top_p: request.top_p,
182
- user: request.user
183
- });
184
- }
185
- function normalizeRequestedModel(model) {
186
- const requested = contentToText(model).trim();
187
- return requested || DEFAULT_MODEL;
188
- }
189
- function chatCompletionToResponse(completion, responseId) {
190
- const id = responseId ?? `resp_${randomId()}`;
191
- const choice = firstChoice(completion);
192
- const message = asRecord(choice.message);
193
- const model = contentToText(completion.model) || DEFAULT_MODEL;
194
- const output = outputItemsFromMessage(message);
195
- const usage = responseUsage(completion.usage);
196
- return removeUndefined({
197
- created_at: epochSeconds(),
198
- error: null,
199
- id,
200
- incomplete_details: null,
201
- instructions: null,
202
- max_output_tokens: null,
203
- metadata: {},
204
- model,
205
- object: "response",
206
- output,
207
- output_text: outputText(output),
208
- parallel_tool_calls: true,
209
- status: "completed",
210
- temperature: null,
211
- tool_choice: "auto",
212
- tools: [],
213
- top_p: null,
214
- usage
215
- });
216
- }
217
- function responsesCompactionResult(upstreamText, isSse) {
218
- const output = isSse ? compactionOutputFromResponsesSse(upstreamText) : compactionOutputFromResponse(asRecord(safeJsonParse(upstreamText)));
219
- return { output };
220
- }
221
- function compactionOutputFromResponse(response) {
222
- if (Array.isArray(response.output) && response.output.length > 0) {
223
- return response.output;
224
- }
225
- const text = contentToText(response.output_text);
226
- return text ? [messageOutputItem(text)] : [];
227
- }
228
- function compactionOutputFromResponsesSse(text) {
229
- let deltas = "";
230
- let completedOutput;
231
- for (const block of text.split(/\r?\n\r?\n/)) {
232
- const data = block.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("");
233
- if (!data || data === "[DONE]") {
234
- continue;
235
- }
236
- const record = asRecord(safeJsonParse(data));
237
- const type = contentToText(record.type);
238
- if (type === "response.output_text.delta") {
239
- deltas += contentToText(record.delta);
240
- } else if (type === "response.completed" || type === "response.incomplete") {
241
- const response = asRecord(record.response);
242
- if (Array.isArray(response.output)) {
243
- completedOutput = response.output;
244
- }
245
- }
246
- }
247
- if (completedOutput && completedOutput.length > 0) {
248
- return completedOutput;
249
- }
250
- return deltas ? [messageOutputItem(deltas)] : [];
251
- }
252
- function safeJsonParse(text) {
253
- try {
254
- return JSON.parse(text);
255
- } catch {
256
- return void 0;
257
- }
258
- }
259
- function chatCompletionToCompletion(completion) {
260
- return removeUndefined({
261
- choices: completionChoices(completion).map((choice, index) => {
262
- const message = asRecord(choice.message);
263
- return {
264
- finish_reason: choice.finish_reason ?? "stop",
265
- index: typeof choice.index === "number" ? choice.index : index,
266
- logprobs: choice.logprobs ?? null,
267
- text: contentToText(choice.text) || contentToText(message.content)
268
- };
269
- }),
270
- created: completion.created ?? epochSeconds(),
271
- id: completion.id ?? `cmpl_${randomId()}`,
272
- model: completion.model ?? DEFAULT_MODEL,
273
- object: "text_completion",
274
- system_fingerprint: completion.system_fingerprint,
275
- usage: completion.usage
276
- });
277
- }
278
- function completionStreamFromChatStream(chatStream) {
279
- const encoder = new TextEncoder();
280
- const decoder = new TextDecoder();
281
- let buffer = "";
282
- let sawTerminalEvent = false;
283
- return new ReadableStream({
284
- async start(controller) {
285
- const enqueue = (data) => {
286
- controller.enqueue(encoder.encode(encodeDataSse(data)));
287
- };
288
- const markTerminal = () => {
289
- sawTerminalEvent = true;
290
- };
291
- const reader = chatStream.getReader();
292
- try {
293
- while (true) {
294
- const result = await reader.read();
295
- if (result.done) {
296
- break;
297
- }
298
- buffer += decoder.decode(result.value, { stream: true });
299
- const blocks = buffer.split(/\r?\n\r?\n/);
300
- buffer = blocks.pop() ?? "";
301
- for (const block of blocks) {
302
- processCompletionSseBlock(block, enqueue, markTerminal);
303
- }
304
- }
305
- const tail = `${buffer}${decoder.decode()}`;
306
- if (tail.trim()) {
307
- processCompletionSseBlock(tail, enqueue, markTerminal);
308
- }
309
- if (!sawTerminalEvent) {
310
- enqueue("[DONE]");
311
- }
312
- controller.close();
313
- } catch (error) {
314
- await reader.cancel(error).catch(() => {
315
- });
316
- controller.error(error);
317
- } finally {
318
- reader.releaseLock();
319
- }
320
- }
321
- });
322
- }
323
- function completionSseTextFromChatSseText(text) {
324
- const chunks = [];
325
- let sawTerminalEvent = false;
326
- const enqueue = (data) => {
327
- chunks.push(encodeDataSse(data));
328
- };
329
- const markTerminal = () => {
330
- sawTerminalEvent = true;
331
- };
332
- for (const block of text.split(/\r?\n\r?\n/)) {
333
- if (block.trim()) {
334
- processCompletionSseBlock(block, enqueue, markTerminal);
335
- }
336
- }
337
- if (!sawTerminalEvent) {
338
- enqueue("[DONE]");
339
- }
340
- return chunks.join("");
341
- }
342
- function normalizeModelsResponse(upstream) {
343
- const record = asRecord(upstream);
344
- const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
345
- const models = data.map((model) => asRecord(model)).filter((model) => typeof model.id === "string").map((model) => ({
346
- created: model.created ?? 0,
347
- id: model.id,
348
- object: "model",
349
- owned_by: model.owned_by ?? "github-copilot"
350
- }));
351
- return {
352
- data: models.length > 0 ? models : fallbackModels(),
353
- object: "list"
354
- };
355
- }
356
- function fallbackModels() {
357
- return [
358
- {
359
- created: 0,
360
- id: DEFAULT_MODEL,
361
- object: "model",
362
- owned_by: "github-copilot"
363
- }
364
- ];
365
- }
366
- function responsesStreamFromChatStream(chatStream, options) {
367
- const encoder = new TextEncoder();
368
- const decoder = new TextDecoder();
369
- const responseId = options.responseId ?? `resp_${randomId()}`;
370
- const messageId = `msg_${randomId()}`;
371
- const createdAt = epochSeconds();
372
- let buffer = "";
373
- let text = "";
374
- let messageOutputIndex;
375
- let nextOutputIndex = 0;
376
- let sequenceNumber = 0;
377
- const tools = /* @__PURE__ */ new Map();
378
- return new ReadableStream({
379
- async start(controller) {
380
- const enqueue = (event, data) => {
381
- controller.enqueue(
382
- encoder.encode(
383
- encodeSse(
384
- event,
385
- data === "[DONE]" ? data : { ...data, sequence_number: sequenceNumber++ }
386
- )
387
- )
388
- );
389
- };
390
- enqueue("response.created", {
391
- response: baseStreamResponse(responseId, options.model, createdAt, "in_progress", []),
392
- type: "response.created"
393
- });
394
- const ensureMessageStarted = () => {
395
- if (messageOutputIndex !== void 0) {
396
- return;
397
- }
398
- messageOutputIndex = nextOutputIndex++;
399
- enqueue("response.output_item.added", {
400
- item: {
401
- content: [],
402
- id: messageId,
403
- role: "assistant",
404
- status: "in_progress",
405
- type: "message"
406
- },
407
- output_index: messageOutputIndex,
408
- type: "response.output_item.added"
409
- });
410
- enqueue("response.content_part.added", {
411
- content_index: 0,
412
- item_id: messageId,
413
- output_index: messageOutputIndex,
414
- part: {
415
- annotations: [],
416
- text: "",
417
- type: "output_text"
418
- },
419
- type: "response.content_part.added"
420
- });
421
- };
422
- const appendText = (delta) => {
423
- ensureMessageStarted();
424
- text += delta;
425
- enqueue("response.output_text.delta", {
426
- content_index: 0,
427
- delta,
428
- item_id: messageId,
429
- output_index: messageOutputIndex ?? 0,
430
- type: "response.output_text.delta"
431
- });
432
- };
433
- const appendToolCall = (toolCall) => {
434
- const fn = asRecord(toolCall.function);
435
- const index = typeof toolCall.index === "number" ? toolCall.index : tools.size;
436
- let existing = tools.get(index);
437
- const isNew = !existing;
438
- existing ??= {
439
- arguments: "",
440
- id: contentToText(toolCall.id) || `call_${randomId()}`,
441
- index,
442
- itemId: `fc_${randomId()}`,
443
- name: "",
444
- outputIndex: nextOutputIndex++
445
- };
446
- existing.id = contentToText(toolCall.id) || existing.id;
447
- existing.name += contentToText(fn.name);
448
- tools.set(index, existing);
449
- if (isNew) {
450
- enqueue("response.output_item.added", {
451
- item: functionCallItem(existing, "in_progress"),
452
- output_index: existing.outputIndex ?? 0,
453
- type: "response.output_item.added"
454
- });
455
- }
456
- const argumentDelta = contentToText(fn.arguments);
457
- if (argumentDelta) {
458
- existing.arguments += argumentDelta;
459
- enqueue("response.function_call_arguments.delta", {
460
- delta: argumentDelta,
461
- item_id: existing.itemId,
462
- output_index: existing.outputIndex ?? 0,
463
- type: "response.function_call_arguments.delta"
464
- });
465
- }
466
- };
467
- const reader = chatStream.getReader();
468
- try {
469
- while (true) {
470
- const result = await reader.read();
471
- if (result.done) {
472
- break;
473
- }
474
- buffer += decoder.decode(result.value, { stream: true });
475
- const lines = buffer.split(/\r?\n/);
476
- buffer = lines.pop() ?? "";
477
- for (const line of lines) {
478
- processChatSseLine(line, { appendText, appendToolCall });
479
- }
480
- }
481
- if (buffer) {
482
- processChatSseLine(buffer, { appendText, appendToolCall });
483
- }
484
- const outputEntries = [];
485
- if (messageOutputIndex !== void 0) {
486
- const item = messageOutputItem(text, messageId);
487
- outputEntries.push([messageOutputIndex, item]);
488
- enqueue("response.output_text.done", {
489
- content_index: 0,
490
- item_id: messageId,
491
- output_index: messageOutputIndex,
492
- text,
493
- type: "response.output_text.done"
494
- });
495
- enqueue("response.content_part.done", {
496
- content_index: 0,
497
- item_id: messageId,
498
- output_index: messageOutputIndex,
499
- part: {
500
- annotations: [],
501
- text,
502
- type: "output_text"
503
- },
504
- type: "response.content_part.done"
505
- });
506
- enqueue("response.output_item.done", {
507
- item,
508
- output_index: messageOutputIndex,
509
- type: "response.output_item.done"
510
- });
511
- }
512
- for (const tool of [...tools.values()].sort(
513
- (a, b) => (a.outputIndex ?? 0) - (b.outputIndex ?? 0)
514
- )) {
515
- const item = functionCallItem(tool);
516
- const outputIndex = tool.outputIndex ?? 0;
517
- outputEntries.push([outputIndex, item]);
518
- enqueue("response.function_call_arguments.done", {
519
- arguments: tool.arguments,
520
- item_id: item.id,
521
- output_index: outputIndex,
522
- type: "response.function_call_arguments.done"
523
- });
524
- enqueue("response.output_item.done", {
525
- item,
526
- output_index: outputIndex,
527
- type: "response.output_item.done"
528
- });
529
- }
530
- const output = outputEntries.sort(([left], [right]) => left - right).map(([, item]) => item);
531
- enqueue("response.completed", {
532
- response: baseStreamResponse(responseId, options.model, createdAt, "completed", output),
533
- type: "response.completed"
534
- });
535
- enqueue("done", "[DONE]");
536
- controller.close();
537
- } catch (error) {
538
- await reader.cancel(error).catch(() => {
539
- });
540
- controller.error(error);
541
- } finally {
542
- reader.releaseLock();
543
- }
544
- }
545
- });
546
- }
547
- function inputToMessages(input) {
548
- if (typeof input === "string") {
549
- return [{ content: input, role: "user" }];
550
- }
551
- if (!Array.isArray(input)) {
552
- return [];
553
- }
554
- const messages = [];
555
- for (const item of input) {
556
- const record = asRecord(item);
557
- const type = contentToText(record.type);
558
- if (type === "function_call_output") {
559
- messages.push({
560
- content: contentToText(record.output),
561
- role: "tool",
562
- tool_call_id: contentToText(record.call_id)
563
- });
564
- continue;
565
- }
566
- if (type === "function_call") {
567
- messages.push({
568
- role: "assistant",
569
- tool_calls: [
570
- {
571
- function: {
572
- arguments: contentToText(record.arguments),
573
- name: contentToText(record.name)
574
- },
575
- id: contentToText(record.call_id) || contentToText(record.id),
576
- type: "function"
577
- }
578
- ]
579
- });
580
- continue;
581
- }
582
- if (type && type !== "message") {
583
- unsupportedResponsesFeature(`input item type "${type}"`);
584
- }
585
- const role = responsesRoleToChatRole(contentToText(record.role));
586
- const content = chatMessageContent(record.content);
587
- if (role && content !== void 0) {
588
- messages.push({ content, role });
589
- }
590
- }
591
- return messages;
592
- }
593
- function chatMessageContent(content) {
594
- if (typeof content === "string") {
595
- return content;
596
- }
597
- if (!Array.isArray(content)) {
598
- if (content === void 0 || content === null) {
599
- return void 0;
600
- }
601
- unsupportedResponsesFeature("non-array message content objects");
602
- }
603
- const parts = [];
604
- for (const part of content) {
605
- const record = asRecord(part);
606
- const type = contentToText(record.type);
607
- if (type === "input_text" || type === "output_text" || type === "text") {
608
- parts.push({ text: contentToText(record.text), type: "text" });
609
- continue;
610
- }
611
- if (type === "input_image") {
612
- if (contentToText(record.file_id)) {
613
- unsupportedResponsesFeature("input_image file_id parts");
614
- }
615
- const imageUrl = contentToText(record.image_url);
616
- if (!imageUrl) {
617
- unsupportedResponsesFeature("input_image parts without image_url");
618
- }
619
- const image = { url: imageUrl };
620
- const detail = contentToText(record.detail);
621
- if (detail) {
622
- image.detail = detail;
623
- }
624
- parts.push({ image_url: image, type: "image_url" });
625
- continue;
626
- }
627
- if (type === "input_file") {
628
- unsupportedResponsesFeature("input_file parts");
629
- }
630
- if (type === "input_audio") {
631
- unsupportedResponsesFeature("input_audio parts");
632
- }
633
- unsupportedResponsesFeature(`content part type "${type || "unknown"}"`);
634
- }
635
- if (parts.length === 0) {
636
- return void 0;
637
- }
638
- if (parts.every((part) => part.type === "text")) {
639
- return parts.map((part) => contentToText(part.text)).join("\n");
640
- }
641
- return parts;
642
- }
643
- function legacyPromptToText(prompt) {
644
- if (typeof prompt === "string") {
645
- return prompt;
646
- }
647
- if (Array.isArray(prompt) && prompt.length === 1 && typeof prompt[0] === "string") {
648
- return prompt[0];
649
- }
650
- throw new OpenAICompatibilityError(
651
- "Hoopilot legacy completions compatibility supports exactly one string prompt per request."
652
- );
653
- }
654
- function assertSupportedLegacyCompletionRequest(request) {
655
- if (request.echo === true) {
656
- throw new OpenAICompatibilityError(
657
- "Hoopilot legacy completions compatibility does not support echo=true."
658
- );
659
- }
660
- if (typeof request.best_of === "number" && request.best_of > 1) {
661
- throw new OpenAICompatibilityError(
662
- "Hoopilot legacy completions compatibility does not support best_of greater than 1."
663
- );
664
- }
665
- if (typeof request.logprobs === "number" && request.logprobs > 0) {
666
- throw new OpenAICompatibilityError(
667
- "Hoopilot legacy completions compatibility does not support legacy logprobs."
668
- );
669
- }
670
- if (contentToText(request.suffix)) {
671
- throw new OpenAICompatibilityError(
672
- "Hoopilot legacy completions compatibility does not support suffix."
673
- );
674
- }
675
- }
676
- function contentToText(content) {
677
- if (typeof content === "string") {
678
- return content;
679
- }
680
- if (typeof content === "number" || typeof content === "boolean") {
681
- return String(content);
682
- }
683
- if (Array.isArray(content)) {
684
- return content.map((item) => contentToText(item)).filter(Boolean).join("\n");
685
- }
686
- if (content && typeof content === "object") {
687
- const record = content;
688
- if (typeof record.text === "string") {
689
- return record.text;
690
- }
691
- if (typeof record.output_text === "string") {
692
- return record.output_text;
693
- }
694
- return JSON.stringify(content);
695
- }
696
- return "";
697
- }
698
- function responsesRoleToChatRole(role) {
699
- if (!role) {
700
- return "user";
701
- }
702
- if (role === "assistant" || role === "developer" || role === "system" || role === "tool" || role === "user") {
703
- return role === "developer" ? "system" : role;
704
- }
705
- unsupportedResponsesFeature(`message role "${role}"`);
706
- }
707
- function chatTools(tools) {
708
- if (!Array.isArray(tools)) {
709
- return void 0;
710
- }
711
- const converted = tools.map((tool) => {
712
- const record = asRecord(tool);
713
- const type = contentToText(record.type);
714
- if (type !== "function") {
715
- unsupportedResponsesFeature(`tool type "${type || "unknown"}"`);
716
- }
717
- return {
718
- function: removeUndefined({
719
- description: record.description,
720
- name: record.name,
721
- parameters: record.parameters,
722
- strict: record.strict
723
- }),
724
- type: "function"
725
- };
726
- });
727
- return converted.length > 0 ? converted : void 0;
728
- }
729
- function chatToolChoice(toolChoice) {
730
- if (typeof toolChoice === "string" || toolChoice === void 0) {
731
- return toolChoice;
732
- }
733
- const record = asRecord(toolChoice);
734
- const type = contentToText(record.type);
735
- if (type === "function" && typeof record.name === "string") {
736
- return { function: { name: record.name }, type: "function" };
737
- }
738
- unsupportedResponsesFeature(`tool_choice type "${type || "unknown"}"`);
739
- }
740
- function unsupportedResponsesFeature(feature) {
741
- throw new OpenAICompatibilityError(
742
- `Hoopilot Responses-to-chat compatibility does not support ${feature}.`
743
- );
744
- }
745
- function outputItemsFromMessage(message) {
746
- const output = [];
747
- const text = contentToText(message.content);
748
- if (text) {
749
- output.push(messageOutputItem(text));
750
- }
751
- const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
752
- for (const toolCall of toolCalls) {
753
- const record = asRecord(toolCall);
754
- const fn = asRecord(record.function);
755
- output.push(
756
- functionCallItem({
757
- arguments: contentToText(fn.arguments),
758
- id: contentToText(record.id) || `call_${randomId()}`,
759
- index: output.length,
760
- name: contentToText(fn.name)
761
- })
762
- );
763
- }
764
- return output;
765
- }
766
- function messageOutputItem(text, id = `msg_${randomId()}`) {
767
- return {
768
- content: [
769
- {
770
- annotations: [],
771
- text,
772
- type: "output_text"
773
- }
774
- ],
775
- id,
776
- role: "assistant",
777
- status: "completed",
778
- type: "message"
779
- };
780
- }
781
- function functionCallItem(tool, status = "completed") {
782
- return {
783
- arguments: tool.arguments,
784
- call_id: tool.id,
785
- id: tool.itemId ?? `fc_${randomId()}`,
786
- name: tool.name,
787
- status,
788
- type: "function_call"
789
- };
790
- }
791
- function outputText(output) {
792
- return output.flatMap((item) => {
793
- const content = item.content;
794
- return Array.isArray(content) ? content : [];
795
- }).map((part) => contentToText(asRecord(part).text)).filter(Boolean).join("");
796
- }
797
- function responseUsage(usage) {
798
- const record = asRecord(usage);
799
- if (Object.keys(record).length === 0) {
800
- return null;
801
- }
802
- const inputTokens = record.prompt_tokens;
803
- const outputTokens = record.completion_tokens;
804
- return removeUndefined({
805
- input_tokens: inputTokens,
806
- input_tokens_details: responseUsageDetails(record.prompt_tokens_details, inputTokens, {
807
- cached_tokens: 0
808
- }),
809
- output_tokens: outputTokens,
810
- output_tokens_details: responseUsageDetails(record.completion_tokens_details, outputTokens, {
811
- reasoning_tokens: 0
812
- }),
813
- total_tokens: record.total_tokens
814
- });
815
- }
816
- function responseUsageDetails(value, tokenCount, fallback) {
817
- const record = asRecord(value);
818
- if (Object.keys(record).length > 0) {
819
- return record;
820
- }
821
- return typeof tokenCount === "number" && Number.isFinite(tokenCount) ? fallback : void 0;
822
- }
823
- function extractTokenUsage(usage) {
824
- const record = asRecord(usage);
825
- const prompt = firstNumber(record.prompt_tokens, record.input_tokens);
826
- const completion = firstNumber(record.completion_tokens, record.output_tokens);
827
- const total = firstNumber(record.total_tokens);
828
- if (prompt === void 0 && completion === void 0 && total === void 0) {
829
- return void 0;
830
- }
831
- const promptTokens = prompt ?? 0;
832
- const completionTokens = completion ?? 0;
833
- const reasoning = firstNumber(
834
- asRecord(record.completion_tokens_details).reasoning_tokens,
835
- asRecord(record.output_tokens_details).reasoning_tokens
836
- );
837
- const cached = firstNumber(
838
- asRecord(record.prompt_tokens_details).cached_tokens,
839
- asRecord(record.input_tokens_details).cached_tokens
840
- );
841
- return removeUndefined({
842
- cachedTokens: cached,
843
- completionTokens,
844
- promptTokens,
845
- reasoningTokens: reasoning,
846
- totalTokens: total ?? promptTokens + completionTokens
847
- });
848
- }
849
- function firstNumber(...values) {
850
- for (const value of values) {
851
- if (typeof value === "number" && Number.isFinite(value)) {
852
- return value;
853
- }
854
- }
855
- return void 0;
856
- }
857
- function firstChoice(completion) {
858
- return completionChoices(completion)[0] ?? {};
859
- }
860
- function completionChoices(completion) {
861
- const choices = Array.isArray(completion.choices) ? completion.choices : [];
862
- return choices.map((choice) => asRecord(choice));
863
- }
864
- function processCompletionSseBlock(block, enqueue, markTerminal) {
865
- let event = "message";
866
- const dataLines = [];
867
- for (const line of block.split(/\r?\n/)) {
868
- const trimmed = line.trim();
869
- if (trimmed.startsWith("event:")) {
870
- event = trimmed.slice("event:".length).trim() || event;
871
- } else if (trimmed.startsWith("data:")) {
872
- dataLines.push(trimmed.slice("data:".length).trim());
873
- }
874
- }
875
- const data = dataLines.join("\n");
876
- if (!data) {
877
- return;
878
- }
879
- if (data === "[DONE]") {
880
- markTerminal();
881
- enqueue("[DONE]");
882
- return;
883
- }
884
- const parsed = parseJson(data);
885
- if (!parsed) {
886
- return;
887
- }
888
- const error = completionStreamError(event, parsed);
889
- if (error) {
890
- markTerminal();
891
- enqueue({ error });
892
- return;
893
- }
894
- const choices = completionChoices(parsed).map((choice, index) => {
895
- const delta = asRecord(choice.delta);
896
- const text = contentToText(delta.content);
897
- const finishReason = choice.finish_reason ?? null;
898
- if (!text && finishReason === null) {
899
- return void 0;
900
- }
901
- return {
902
- finish_reason: finishReason,
903
- index: typeof choice.index === "number" ? choice.index : index,
904
- logprobs: choice.logprobs ?? null,
905
- text
906
- };
907
- }).filter((choice) => choice !== void 0);
908
- const usage = asRecord(parsed.usage);
909
- const hasUsage = Object.keys(usage).length > 0;
910
- if (choices.length === 0 && !hasUsage) {
911
- return;
912
- }
913
- enqueue(
914
- removeUndefined({
915
- choices,
916
- created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
917
- id: contentToText(parsed.id) || `cmpl_${randomId()}`,
918
- model: contentToText(parsed.model) || DEFAULT_MODEL,
919
- object: "text_completion",
920
- usage: hasUsage ? usage : void 0
921
- })
922
- );
923
- }
924
- function completionStreamError(event, parsed) {
925
- const responseError = asRecord(asRecord(parsed.response).error);
926
- const directError = asRecord(parsed.error);
927
- const error = Object.keys(directError).length > 0 ? directError : Object.keys(responseError).length > 0 ? responseError : void 0;
928
- if (error) {
929
- return error;
930
- }
931
- if (event === "error" || parsed.type === "response.failed") {
932
- return removeUndefined({
933
- code: contentToText(parsed.code) || void 0,
934
- message: contentToText(parsed.message) || "Upstream streaming request failed.",
935
- type: contentToText(parsed.type) || "upstream_stream_error"
936
- });
937
- }
938
- return void 0;
939
- }
940
- function processChatSseLine(line, handlers) {
941
- const trimmed = line.trim();
942
- if (!trimmed.startsWith("data:")) {
943
- return;
944
- }
945
- const data = trimmed.slice("data:".length).trim();
946
- if (!data || data === "[DONE]") {
947
- return;
948
- }
949
- const parsed = parseJson(data);
950
- if (!parsed) {
951
- return;
952
- }
953
- const choice = firstChoice(parsed);
954
- const delta = asRecord(choice.delta);
955
- const content = contentToText(delta.content);
956
- if (content) {
957
- handlers.appendText(content);
958
- }
959
- const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
960
- for (const toolCall of toolCalls) {
961
- handlers.appendToolCall(asRecord(toolCall));
962
- }
963
- }
964
- function baseStreamResponse(id, model, createdAt, status, output) {
965
- return {
966
- created_at: createdAt,
967
- error: null,
968
- id,
969
- incomplete_details: null,
970
- instructions: null,
971
- max_output_tokens: null,
972
- metadata: {},
973
- model,
974
- object: "response",
975
- output,
976
- parallel_tool_calls: true,
977
- status,
978
- temperature: null,
979
- tool_choice: "auto",
980
- tools: [],
981
- top_p: null
982
- };
983
- }
984
- function encodeSse(event, data) {
985
- if (data === "[DONE]") {
986
- return "data: [DONE]\n\n";
987
- }
988
- return `event: ${event}
989
- data: ${JSON.stringify(data)}
990
-
991
- `;
992
- }
993
- function encodeDataSse(data) {
994
- if (data === "[DONE]") {
995
- return "data: [DONE]\n\n";
996
- }
997
- return `data: ${JSON.stringify(data)}
998
-
999
- `;
1000
- }
1001
- function parseJson(data) {
1002
- try {
1003
- return asRecord(JSON.parse(data));
1004
- } catch {
1005
- return void 0;
1006
- }
1007
- }
1008
- function removeUndefined(record) {
1009
- return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
1010
- }
1011
- function randomId() {
1012
- return crypto.randomUUID().replaceAll("-", "");
1013
- }
1014
- function epochSeconds() {
1015
- return Math.floor(Date.now() / 1e3);
1016
- }
1017
-
1018
- // src/anthropic.ts
1019
- var AnthropicCompatibilityError = class extends Error {
1020
- constructor(message) {
1021
- super(message);
1022
- this.name = "AnthropicCompatibilityError";
1023
- }
1024
- };
1025
- function anthropicMessagesToResponsesRequest(request) {
1026
- return removeUndefined2({
1027
- input: anthropicMessagesToResponsesInput(request.messages),
1028
- instructions: anthropicSystemToInstructions(request.system),
1029
- max_output_tokens: typeof request.max_tokens === "number" && Number.isFinite(request.max_tokens) ? request.max_tokens : void 0,
1030
- metadata: request.metadata,
1031
- model: normalizeRequestedModel(request.model),
1032
- parallel_tool_calls: true,
1033
- reasoning: anthropicThinkingToReasoning(request.thinking),
1034
- stop: anthropicStopSequences(request.stop_sequences),
1035
- stream: request.stream === true,
1036
- temperature: request.temperature,
1037
- tool_choice: anthropicToolChoice(request.tool_choice),
1038
- tools: anthropicTools(request.tools),
1039
- top_p: request.top_p
1040
- });
1041
- }
1042
- function responsesResponseToAnthropicMessage(response, fallbackModel) {
1043
- const content = anthropicContentFromResponsesOutput(response);
1044
- const usage = anthropicUsage(response.usage);
1045
- return {
1046
- content,
1047
- id: textValue(response.id) || `msg_${randomId2()}`,
1048
- model: textValue(response.model) || fallbackModel,
1049
- role: "assistant",
1050
- stop_reason: anthropicStopReason(response, content),
1051
- stop_sequence: null,
1052
- type: "message",
1053
- usage
1054
- };
1055
- }
1056
- function responsesStreamToAnthropicStream(stream, options) {
1057
- const decoder = new TextDecoder();
1058
- const encoder = new TextEncoder();
1059
- let buffer = "";
1060
- const state = createAnthropicStreamState(options);
1061
- return new ReadableStream({
1062
- async start(controller) {
1063
- const enqueue = (event, data) => {
1064
- controller.enqueue(encoder.encode(encodeSse2(event, data)));
1065
- };
1066
- const reader = stream.getReader();
1067
- try {
1068
- while (true) {
1069
- const result = await reader.read();
1070
- if (result.done) {
1071
- break;
1072
- }
1073
- buffer += decoder.decode(result.value, { stream: true });
1074
- const blocks = buffer.split(/\r?\n\r?\n/);
1075
- buffer = blocks.pop() ?? "";
1076
- for (const block of blocks) {
1077
- processResponsesSseBlock(block, state, enqueue);
1078
- }
1079
- }
1080
- const tail = `${buffer}${decoder.decode()}`;
1081
- if (tail.trim()) {
1082
- processResponsesSseBlock(tail, state, enqueue);
1083
- }
1084
- finishAnthropicStream(state, enqueue);
1085
- controller.close();
1086
- } catch (error) {
1087
- await reader.cancel(error).catch(() => {
1088
- });
1089
- controller.error(error);
1090
- } finally {
1091
- reader.releaseLock();
1092
- }
1093
- }
1094
- });
1095
- }
1096
- function responsesSseTextToAnthropicSseText(text, options) {
1097
- const chunks = [];
1098
- const state = createAnthropicStreamState(options);
1099
- const enqueue = (event, data) => {
1100
- chunks.push(encodeSse2(event, data));
1101
- };
1102
- for (const block of text.split(/\r?\n\r?\n/)) {
1103
- if (block.trim()) {
1104
- processResponsesSseBlock(block, state, enqueue);
1105
- }
1106
- }
1107
- finishAnthropicStream(state, enqueue);
1108
- return chunks.join("");
1109
- }
1110
- function estimateAnthropicMessageTokens(request) {
1111
- const chars = estimatedTextSize(request.system) + estimatedTextSize(request.messages) + estimatedTextSize(request.tools) + estimatedTextSize(request.tool_choice) + estimatedTextSize(request.thinking);
1112
- const messageCount = Array.isArray(request.messages) ? request.messages.length : 1;
1113
- const toolCount = Array.isArray(request.tools) ? request.tools.length : 0;
1114
- const inputTokens = Math.max(1, Math.ceil(chars / 4) + messageCount * 4 + toolCount * 16);
1115
- return {
1116
- input_tokens: inputTokens,
1117
- total_tokens: inputTokens
1118
- };
1119
- }
1120
- function createAnthropicStreamState(options) {
1121
- return {
1122
- blocks: /* @__PURE__ */ new Map(),
1123
- completed: false,
1124
- messageId: options.messageId ?? `msg_${randomId2()}`,
1125
- model: options.model,
1126
- nextBlockIndex: 0,
1127
- sawToolUse: false,
1128
- started: false,
1129
- usage: anthropicUsage(void 0)
1130
- };
1131
- }
1132
- function anthropicMessagesToResponsesInput(messages) {
1133
- if (!Array.isArray(messages)) {
1134
- throw new AnthropicCompatibilityError("Anthropic Messages requests require messages[].");
1135
- }
1136
- const input = [];
1137
- for (const message of messages) {
1138
- const record = asRecord(message);
1139
- const role = anthropicRole(record.role);
1140
- const parts = anthropicContentParts(record.content);
1141
- const messageParts = [];
1142
- const flushMessage = () => {
1143
- if (messageParts.length === 0) {
1144
- return;
1145
- }
1146
- input.push({
1147
- content: [...messageParts],
1148
- role,
1149
- type: "message"
1150
- });
1151
- messageParts.length = 0;
1152
- };
1153
- for (const part of parts) {
1154
- const type = textValue(part.type) || "text";
1155
- if (type === "text") {
1156
- const text = textValue(part.text);
1157
- if (text) {
1158
- messageParts.push({
1159
- text,
1160
- type: role === "assistant" ? "output_text" : "input_text"
1161
- });
1162
- }
1163
- continue;
1164
- }
1165
- if (type === "image") {
1166
- if (role !== "user") {
1167
- throw new AnthropicCompatibilityError(
1168
- "Anthropic image content is only supported for user messages."
1169
- );
1170
- }
1171
- messageParts.push(anthropicImageToResponsesPart(part));
1172
- continue;
1173
- }
1174
- if (type === "tool_use") {
1175
- flushMessage();
1176
- input.push({
1177
- arguments: JSON.stringify(asRecord(part.input)),
1178
- call_id: textValue(part.id) || `call_${randomId2()}`,
1179
- name: textValue(part.name),
1180
- type: "function_call"
1181
- });
1182
- continue;
1183
- }
1184
- if (type === "tool_result") {
1185
- flushMessage();
1186
- input.push({
1187
- call_id: textValue(part.tool_use_id),
1188
- output: anthropicToolResultOutput(part.content),
1189
- type: "function_call_output"
1190
- });
1191
- continue;
1192
- }
1193
- if (type === "thinking" || type === "redacted_thinking") {
1194
- continue;
1195
- }
1196
- throw new AnthropicCompatibilityError(
1197
- `Anthropic content block type "${type}" is not supported.`
1198
- );
1199
- }
1200
- flushMessage();
1201
- }
1202
- return input;
1203
- }
1204
- function anthropicRole(value) {
1205
- const role = textValue(value);
1206
- if (role === "assistant" || role === "user") {
1207
- return role;
1208
- }
1209
- if (!role) {
1210
- return "user";
1211
- }
1212
- throw new AnthropicCompatibilityError(`Anthropic message role "${role}" is not supported.`);
1213
- }
1214
- function anthropicContentParts(content) {
1215
- if (typeof content === "string") {
1216
- return [{ text: content, type: "text" }];
1217
- }
1218
- if (Array.isArray(content)) {
1219
- return content.map(
1220
- (part) => typeof part === "string" ? { text: part, type: "text" } : asRecord(part)
1221
- );
1222
- }
1223
- if (content === void 0 || content === null) {
1224
- return [];
1225
- }
1226
- return [asRecord(content)];
1227
- }
1228
- function anthropicImageToResponsesPart(part) {
1229
- const source = asRecord(part.source);
1230
- const sourceType = textValue(source.type);
1231
- if (sourceType === "base64") {
1232
- const mediaType = textValue(source.media_type) || "image/png";
1233
- const data = textValue(source.data);
1234
- if (!data) {
1235
- throw new AnthropicCompatibilityError("Anthropic base64 image content requires source.data.");
1236
- }
1237
- return {
1238
- detail: "auto",
1239
- image_url: `data:${mediaType};base64,${data}`,
1240
- type: "input_image"
1241
- };
1242
- }
1243
- if (sourceType === "url") {
1244
- const url = textValue(source.url);
1245
- if (!url) {
1246
- throw new AnthropicCompatibilityError("Anthropic URL image content requires source.url.");
1247
- }
1248
- return {
1249
- detail: "auto",
1250
- image_url: url,
1251
- type: "input_image"
1252
- };
1253
- }
1254
- throw new AnthropicCompatibilityError(
1255
- `Anthropic image source type "${sourceType || "unknown"}" is not supported.`
1256
- );
1257
- }
1258
- function anthropicToolResultOutput(content) {
1259
- if (typeof content === "string") {
1260
- return content;
1261
- }
1262
- if (Array.isArray(content)) {
1263
- return content.map((part) => {
1264
- const record = asRecord(part);
1265
- return textValue(record.text) || textValue(record.content) || JSON.stringify(part);
1266
- }).filter(Boolean).join("\n");
1267
- }
1268
- if (content === void 0 || content === null) {
1269
- return "";
1270
- }
1271
- return typeof content === "object" ? JSON.stringify(content) : String(content);
1272
- }
1273
- function anthropicSystemToInstructions(system) {
1274
- if (typeof system === "string") {
1275
- return system || void 0;
1276
- }
1277
- if (!Array.isArray(system)) {
1278
- return void 0;
1279
- }
1280
- const text = system.map((part) => textValue(asRecord(part).text) || textValue(part)).filter(Boolean).join("\n");
1281
- return text || void 0;
1282
- }
1283
- function anthropicTools(tools) {
1284
- if (!Array.isArray(tools)) {
1285
- return void 0;
1286
- }
1287
- const converted = tools.map((tool) => {
1288
- const record = asRecord(tool);
1289
- return removeUndefined2({
1290
- description: record.description,
1291
- name: record.name,
1292
- parameters: record.input_schema,
1293
- strict: record.strict,
1294
- type: "function"
1295
- });
1296
- });
1297
- return converted.length > 0 ? converted : void 0;
1298
- }
1299
- function anthropicToolChoice(toolChoice) {
1300
- if (toolChoice === void 0 || toolChoice === null) {
1301
- return void 0;
1302
- }
1303
- const record = asRecord(toolChoice);
1304
- const type = textValue(record.type);
1305
- if (type === "auto") {
1306
- return "auto";
1307
- }
1308
- if (type === "any") {
1309
- return "required";
1310
- }
1311
- if (type === "none") {
1312
- return "none";
1313
- }
1314
- if (type === "tool") {
1315
- return { name: textValue(record.name), type: "function" };
1316
- }
1317
- throw new AnthropicCompatibilityError(
1318
- `Anthropic tool_choice type "${type || "unknown"}" is not supported.`
1319
- );
1320
- }
1321
- function anthropicThinkingToReasoning(thinking) {
1322
- const record = asRecord(thinking);
1323
- if (Object.keys(record).length === 0) {
1324
- return void 0;
1325
- }
1326
- const type = textValue(record.type);
1327
- if (type && type !== "enabled") {
1328
- return void 0;
1329
- }
1330
- const budget = typeof record.budget_tokens === "number" ? record.budget_tokens : 0;
1331
- return {
1332
- effort: budget >= 16e3 ? "high" : budget >= 4e3 ? "medium" : "low"
1333
- };
1334
- }
1335
- function anthropicStopSequences(stopSequences) {
1336
- if (!Array.isArray(stopSequences) || stopSequences.length === 0) {
1337
- return void 0;
1338
- }
1339
- return stopSequences.map((sequence) => textValue(sequence)).filter(Boolean);
1340
- }
1341
- function anthropicContentFromResponsesOutput(response) {
1342
- const content = [];
1343
- const output = Array.isArray(response.output) ? response.output : [];
1344
- for (const item of output) {
1345
- const record = asRecord(item);
1346
- const type = textValue(record.type);
1347
- if (type === "message") {
1348
- const parts = Array.isArray(record.content) ? record.content : [];
1349
- for (const part of parts) {
1350
- const partRecord = asRecord(part);
1351
- const text = textValue(partRecord.text) || textValue(partRecord.output_text);
1352
- if (text) {
1353
- content.push({ text, type: "text" });
1354
- }
1355
- }
1356
- continue;
1357
- }
1358
- if (type === "function_call") {
1359
- content.push({
1360
- id: textValue(record.call_id) || textValue(record.id) || `call_${randomId2()}`,
1361
- input: parseToolInput(textValue(record.arguments)),
1362
- name: textValue(record.name),
1363
- type: "tool_use"
1364
- });
1365
- }
1366
- }
1367
- if (content.length === 0) {
1368
- const outputText2 = textValue(response.output_text);
1369
- if (outputText2) {
1370
- content.push({ text: outputText2, type: "text" });
1371
- }
1372
- }
1373
- return content;
1374
- }
1375
- function anthropicStopReason(response, content) {
1376
- if (content.some((part) => part.type === "tool_use")) {
1377
- return "tool_use";
1378
- }
1379
- const incompleteReason = textValue(asRecord(response.incomplete_details).reason);
1380
- if (textValue(response.status) === "incomplete" || incompleteReason === "max_output_tokens") {
1381
- return "max_tokens";
1382
- }
1383
- return "end_turn";
1384
- }
1385
- function anthropicUsage(usage) {
1386
- const record = asRecord(usage);
1387
- const inputTokens = firstNumber2(record.input_tokens, record.prompt_tokens) ?? 0;
1388
- const outputTokens = firstNumber2(record.output_tokens, record.completion_tokens) ?? 0;
1389
- const details = asRecord(record.input_tokens_details);
1390
- return removeUndefined2({
1391
- cache_creation_input_tokens: firstNumber2(record.cache_creation_input_tokens),
1392
- cache_read_input_tokens: firstNumber2(record.cache_read_input_tokens, details.cached_tokens) ?? void 0,
1393
- input_tokens: inputTokens,
1394
- output_tokens: outputTokens
1395
- });
1396
- }
1397
- function processResponsesSseBlock(block, state, enqueue) {
1398
- const { data, event } = parseSseBlock(block);
1399
- if (!data || data === "[DONE]") {
1400
- return;
1401
- }
1402
- const parsed = parseJsonObject(data);
1403
- if (!parsed) {
1404
- return;
1405
- }
1406
- const type = textValue(parsed.type) || event;
1407
- if (type === "response.created") {
1408
- const response = asRecord(parsed.response);
1409
- state.messageId = textValue(response.id) || state.messageId;
1410
- state.model = textValue(response.model) || state.model;
1411
- startAnthropicMessage(state, enqueue);
1412
- return;
1413
- }
1414
- if (type === "response.output_item.added") {
1415
- const item = asRecord(parsed.item);
1416
- if (textValue(item.type) === "function_call") {
1417
- ensureToolBlock(state, parsed, item, enqueue);
1418
- }
1419
- return;
1420
- }
1421
- if (type === "response.output_text.delta") {
1422
- const blockState = ensureTextBlock(state, parsed, enqueue);
1423
- const delta = textValue(parsed.delta);
1424
- if (delta) {
1425
- blockState.sentText += delta;
1426
- enqueue("content_block_delta", {
1427
- delta: { text: delta, type: "text_delta" },
1428
- index: blockState.index,
1429
- type: "content_block_delta"
1430
- });
1431
- }
1432
- return;
1433
- }
1434
- if (type === "response.output_text.done" || type === "response.content_part.done") {
1435
- const blockState = ensureTextBlock(state, parsed, enqueue);
1436
- const text = textValue(parsed.text) || textValue(asRecord(parsed.part).text);
1437
- if (text && !blockState.sentText) {
1438
- blockState.sentText = text;
1439
- enqueue("content_block_delta", {
1440
- delta: { text, type: "text_delta" },
1441
- index: blockState.index,
1442
- type: "content_block_delta"
1443
- });
1444
- }
1445
- stopBlock(blockState, enqueue);
1446
- return;
1447
- }
1448
- if (type === "response.function_call_arguments.delta") {
1449
- const blockState = ensureToolBlock(state, parsed, {}, enqueue);
1450
- const delta = textValue(parsed.delta);
1451
- if (delta) {
1452
- blockState.sentText += delta;
1453
- enqueue("content_block_delta", {
1454
- delta: { partial_json: delta, type: "input_json_delta" },
1455
- index: blockState.index,
1456
- type: "content_block_delta"
1457
- });
1458
- }
1459
- return;
1460
- }
1461
- if (type === "response.function_call_arguments.done") {
1462
- const blockState = ensureToolBlock(state, parsed, {}, enqueue);
1463
- const args = textValue(parsed.arguments);
1464
- if (args && !blockState.sentText) {
1465
- blockState.sentText = args;
1466
- enqueue("content_block_delta", {
1467
- delta: { partial_json: args, type: "input_json_delta" },
1468
- index: blockState.index,
1469
- type: "content_block_delta"
1470
- });
1471
- }
1472
- stopBlock(blockState, enqueue);
1473
- return;
1474
- }
1475
- if (type === "response.output_item.done") {
1476
- const item = asRecord(parsed.item);
1477
- if (textValue(item.type) === "function_call") {
1478
- const blockState = ensureToolBlock(state, parsed, item, enqueue);
1479
- const args = textValue(item.arguments);
1480
- if (args && !blockState.sentText) {
1481
- blockState.sentText = args;
1482
- enqueue("content_block_delta", {
1483
- delta: { partial_json: args, type: "input_json_delta" },
1484
- index: blockState.index,
1485
- type: "content_block_delta"
1486
- });
1487
- }
1488
- stopBlock(blockState, enqueue);
1489
- }
1490
- return;
1491
- }
1492
- if (type === "response.completed") {
1493
- const response = asRecord(parsed.response);
1494
- state.model = textValue(response.model) || state.model;
1495
- state.usage = anthropicUsage(response.usage);
1496
- finishAnthropicStream(state, enqueue);
1497
- return;
1498
- }
1499
- if (type === "response.failed" || event === "error") {
1500
- const error = asRecord(asRecord(parsed.response).error);
1501
- enqueue("error", {
1502
- error: {
1503
- message: textValue(error.message) || textValue(parsed.message) || "Upstream stream failed.",
1504
- type: textValue(error.type) || "api_error"
1505
- },
1506
- type: "error"
1507
- });
1508
- state.completed = true;
1509
- }
1510
- }
1511
- function startAnthropicMessage(state, enqueue) {
1512
- if (state.started) {
1513
- return;
1514
- }
1515
- state.started = true;
1516
- enqueue("message_start", {
1517
- message: {
1518
- content: [],
1519
- id: state.messageId,
1520
- model: state.model,
1521
- role: "assistant",
1522
- stop_reason: null,
1523
- stop_sequence: null,
1524
- type: "message",
1525
- usage: anthropicUsage(void 0)
1526
- },
1527
- type: "message_start"
1528
- });
1529
- }
1530
- function finishAnthropicStream(state, enqueue) {
1531
- if (state.completed) {
1532
- return;
1533
- }
1534
- startAnthropicMessage(state, enqueue);
1535
- for (const block of [...state.blocks.values()].sort((left, right) => left.index - right.index)) {
1536
- stopBlock(block, enqueue);
1537
- }
1538
- enqueue("message_delta", {
1539
- delta: {
1540
- stop_reason: state.sawToolUse ? "tool_use" : "end_turn",
1541
- stop_sequence: null
1542
- },
1543
- type: "message_delta",
1544
- usage: state.usage
1545
- });
1546
- enqueue("message_stop", { type: "message_stop" });
1547
- state.completed = true;
1548
- }
1549
- function ensureTextBlock(state, payload, enqueue) {
1550
- startAnthropicMessage(state, enqueue);
1551
- const key = `text:${indexValue(payload.output_index)}:${indexValue(payload.content_index)}`;
1552
- let block = state.blocks.get(key);
1553
- if (!block) {
1554
- block = { index: state.nextBlockIndex++, sentText: "", stopped: false, type: "text" };
1555
- state.blocks.set(key, block);
1556
- enqueue("content_block_start", {
1557
- content_block: { text: "", type: "text" },
1558
- index: block.index,
1559
- type: "content_block_start"
1560
- });
1561
- }
1562
- return block;
1563
- }
1564
- function ensureToolBlock(state, payload, item, enqueue) {
1565
- startAnthropicMessage(state, enqueue);
1566
- state.sawToolUse = true;
1567
- const key = `tool:${indexValue(payload.output_index)}`;
1568
- let block = state.blocks.get(key);
1569
- if (!block) {
1570
- block = { index: state.nextBlockIndex++, sentText: "", stopped: false, type: "tool_use" };
1571
- state.blocks.set(key, block);
1572
- enqueue("content_block_start", {
1573
- content_block: {
1574
- id: textValue(item.call_id) || textValue(item.id) || `call_${randomId2()}`,
1575
- input: {},
1576
- name: textValue(item.name),
1577
- type: "tool_use"
1578
- },
1579
- index: block.index,
1580
- type: "content_block_start"
1581
- });
1582
- }
1583
- return block;
1584
- }
1585
- function stopBlock(block, enqueue) {
1586
- if (block.stopped) {
1587
- return;
1588
- }
1589
- block.stopped = true;
1590
- enqueue("content_block_stop", {
1591
- index: block.index,
1592
- type: "content_block_stop"
1593
- });
1594
- }
1595
- function parseSseBlock(block) {
1596
- let event = "message";
1597
- const data = [];
1598
- for (const line of block.split(/\r?\n/)) {
1599
- const trimmed = line.trim();
1600
- if (trimmed.startsWith("event:")) {
1601
- event = trimmed.slice("event:".length).trim() || event;
1602
- } else if (trimmed.startsWith("data:")) {
1603
- data.push(trimmed.slice("data:".length).trim());
1604
- }
1605
- }
1606
- return { data: data.join("\n"), event };
1607
- }
1608
- function parseJsonObject(text) {
1609
- try {
1610
- return asRecord(JSON.parse(text));
1611
- } catch {
1612
- return void 0;
1613
- }
1614
- }
1615
- function parseToolInput(argumentsText) {
1616
- const parsed = parseJsonObject(argumentsText);
1617
- return parsed ?? {};
1618
- }
1619
- function estimatedTextSize(value) {
1620
- if (value === void 0 || value === null) {
1621
- return 0;
1622
- }
1623
- if (typeof value === "string") {
1624
- return value.length;
1625
- }
1626
- if (typeof value === "number" || typeof value === "boolean") {
1627
- return String(value).length;
1628
- }
1629
- if (Array.isArray(value)) {
1630
- return value.reduce((sum, item) => sum + estimatedTextSize(item), 0);
1631
- }
1632
- if (typeof value === "object") {
1633
- return Object.values(value).reduce((sum, item) => sum + estimatedTextSize(item), 0);
1634
- }
1635
- return 0;
1636
- }
1637
- function textValue(value) {
1638
- if (typeof value === "string") {
1639
- return value;
1640
- }
1641
- if (typeof value === "number" || typeof value === "boolean") {
1642
- return String(value);
1643
- }
1644
- return "";
1645
- }
1646
- function firstNumber2(...values) {
1647
- for (const value of values) {
1648
- if (typeof value === "number" && Number.isFinite(value)) {
1649
- return value;
1650
- }
1651
- }
1652
- return void 0;
1653
- }
1654
- function indexValue(value) {
1655
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
1656
- }
1657
- function removeUndefined2(record) {
1658
- return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
1659
- }
1660
- function encodeSse2(event, data) {
1661
- return `event: ${event}
1662
- data: ${JSON.stringify(data)}
1663
-
1664
- `;
1665
- }
1666
- function randomId2() {
1667
- return crypto.randomUUID().replaceAll("-", "");
1668
- }
1669
-
1670
- // src/auth-store.ts
1671
- var import_node_fs = require("fs");
1672
- var import_node_path = require("path");
1673
- var StoredCopilotAuthError = class extends Error {
1674
- constructor(message) {
1675
- super(message);
1676
- this.name = "StoredCopilotAuthError";
1677
- }
1678
- };
1679
- function authStorePath(env = process.env) {
1680
- const explicit = envValue(env.HOOPILOT_AUTH_FILE);
1681
- if (explicit) {
1682
- return explicit;
1683
- }
1684
- const xdg = envValue(env.XDG_CONFIG_HOME);
1685
- if (xdg) {
1686
- return (0, import_node_path.join)(xdg, "hoopilot", "auth.json");
1687
- }
1688
- const appdata = envValue(env.APPDATA);
1689
- if (appdata) {
1690
- return (0, import_node_path.join)(appdata, "hoopilot", "auth.json");
1691
- }
1692
- const home = envValue(env.HOME);
1693
- if (!home) {
1694
- throw new StoredCopilotAuthError(
1695
- "Cannot resolve Hoopilot auth file path without HOOPILOT_AUTH_FILE, XDG_CONFIG_HOME, APPDATA, or HOME."
1696
- );
1697
- }
1698
- const base = (0, import_node_path.join)(home, ".config");
1699
- return (0, import_node_path.join)(base, "hoopilot", "auth.json");
1700
- }
1701
- function readStoredCopilotAuth(path = authStorePath()) {
1702
- let text;
1703
- try {
1704
- text = (0, import_node_fs.readFileSync)(path, "utf8");
1705
- } catch (error) {
1706
- if (error.code === "ENOENT") {
1707
- return void 0;
1708
- }
1709
- throw new StoredCopilotAuthError(`Could not read Hoopilot auth file at ${path}.`);
1710
- }
1711
- let parsed;
1712
- try {
1713
- parsed = JSON.parse(text);
1714
- } catch {
1715
- throw new StoredCopilotAuthError(
1716
- `Hoopilot auth file at ${path} is not valid JSON. Run \`hoopilot login\` to replace it.`
1717
- );
1718
- }
1719
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1720
- throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
1721
- }
1722
- const record = parsed;
1723
- const token = typeof record.token === "string" ? record.token.trim() : "";
1724
- if (!token) {
1725
- throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
1726
- }
1727
- return {
1728
- apiBaseUrl: typeof record.apiBaseUrl === "string" ? record.apiBaseUrl : void 0,
1729
- createdAt: typeof record.createdAt === "string" ? record.createdAt : void 0,
1730
- githubDomain: typeof record.githubDomain === "string" ? record.githubDomain : void 0,
1731
- source: typeof record.source === "string" ? record.source : void 0,
1732
- token
1733
- };
1734
- }
1735
- function writeStoredCopilotAuth(auth, path = authStorePath()) {
1736
- (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
1737
- const data = `${JSON.stringify(
1738
- {
1739
- ...auth,
1740
- createdAt: auth.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
1741
- },
1742
- null,
1743
- 2
1744
- )}
1745
- `;
1746
- const tmpPath = `${path}.${process.pid}.tmp`;
1747
- (0, import_node_fs.writeFileSync)(tmpPath, data, { mode: 384 });
1748
- (0, import_node_fs.renameSync)(tmpPath, path);
1749
- try {
1750
- (0, import_node_fs.chmodSync)(path, 384);
1751
- } catch {
1752
- }
1753
- }
1754
-
1755
- // src/auth.ts
1756
- var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
1757
- var REFRESH_SKEW_MS = 6e4;
1758
- var STORED_TOKEN_TTL_MS = 10 * 6e4;
1759
- var CopilotAuthError = class extends Error {
1760
- constructor(message) {
1761
- super(message);
1762
- this.name = "CopilotAuthError";
1763
- }
1764
- };
1765
- var CopilotAuth = class {
1766
- #authStorePath;
1767
- #copilotApiBaseUrl;
1768
- #hasCopilotApiBaseUrlOverride;
1769
- #cachedAccess;
1770
- constructor(options = {}) {
1771
- const envAuthStorePath = envValue(options.env?.HOOPILOT_AUTH_FILE);
1772
- const envCopilotApiBaseUrl = envValue(options.env?.COPILOT_API_BASE_URL);
1773
- this.#authStorePath = options.authStorePath ?? envAuthStorePath;
1774
- this.#hasCopilotApiBaseUrlOverride = Boolean(options.copilotApiBaseUrl ?? envCopilotApiBaseUrl);
1775
- this.#copilotApiBaseUrl = trimTrailingSlash(
1776
- options.copilotApiBaseUrl ?? envCopilotApiBaseUrl ?? DEFAULT_COPILOT_API_BASE_URL
1777
- );
1778
- }
1779
- async getAccess() {
1780
- if (this.#cachedAccess && this.#cachedAccess.expiresAtMs - REFRESH_SKEW_MS > Date.now()) {
1781
- return this.#cachedAccess;
1782
- }
1783
- let stored;
1784
- try {
1785
- stored = readStoredCopilotAuth(this.#authStorePath);
1786
- } catch (error) {
1787
- if (error instanceof StoredCopilotAuthError) {
1788
- throw new CopilotAuthError(error.message);
1789
- }
1790
- throw error;
1791
- }
1792
- if (stored) {
1793
- return this.#cacheAccess({
1794
- apiBaseUrl: trimTrailingSlash(
1795
- this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
1796
- ),
1797
- expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
1798
- source: "github-copilot-oauth",
1799
- token: stored.token
1800
- });
1801
- }
1802
- throw new CopilotAuthError(
1803
- "No GitHub Copilot OAuth credential found. Run `hoopilot login` to sign in through your browser."
1804
- );
1805
- }
1806
- #cacheAccess(access) {
1807
- this.#cachedAccess = access;
1808
- return access;
1809
- }
1810
- };
1811
-
1812
- // src/copilot.ts
1813
- var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
1814
- var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
1815
- var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
1816
- var COPILOT_USAGE_API_VERSION = "2025-04-01";
1817
- function applyCopilotHeaders(headers, token) {
1818
- headers.set("accept", headers.get("accept") ?? "application/json");
1819
- headers.set("authorization", `Bearer ${token}`);
1820
- headers.set("copilot-integration-id", "vscode-chat");
1821
- headers.set("editor-plugin-version", "hoopilot/0.1.0");
1822
- headers.set("editor-version", "Hoopilot/0.1.0");
1823
- headers.set("openai-intent", "conversation-panel");
1824
- headers.set("user-agent", "hoopilot/0.1.0");
1825
- headers.set("x-github-api-version", "2026-06-01");
1826
- return headers;
1827
- }
1828
- function applyGithubApiHeaders(headers, token) {
1829
- headers.set("accept", headers.get("accept") ?? "application/json");
1830
- headers.set("authorization", `token ${token}`);
1831
- headers.set("editor-plugin-version", "hoopilot/0.1.0");
1832
- headers.set("editor-version", "Hoopilot/0.1.0");
1833
- headers.set("user-agent", "hoopilot/0.1.0");
1834
- headers.set("x-github-api-version", COPILOT_USAGE_API_VERSION);
1835
- return headers;
1836
- }
1837
- function parseRateLimitHeaders(headers, nowMs = Date.now()) {
1838
- const limit = headerInt(headers, "x-ratelimit-limit");
1839
- const remaining = headerInt(headers, "x-ratelimit-remaining");
1840
- const used = headerInt(headers, "x-ratelimit-used");
1841
- const resetEpochSeconds = headerInt(headers, "x-ratelimit-reset");
1842
- const retryAfterSeconds = headerInt(headers, "retry-after");
1843
- if (limit === void 0 && remaining === void 0 && used === void 0 && resetEpochSeconds === void 0 && retryAfterSeconds === void 0) {
1844
- return void 0;
1845
- }
1846
- return removeUndefinedRateLimit({
1847
- limit,
1848
- observedAtMs: nowMs,
1849
- remaining,
1850
- resetEpochSeconds,
1851
- resource: headers.get("x-ratelimit-resource")?.trim() || "unknown",
1852
- retryAfterSeconds,
1853
- used
1854
- });
1855
- }
1856
- function headerInt(headers, name) {
1857
- const raw = headers.get(name);
1858
- if (raw === null) {
1859
- return void 0;
1860
- }
1861
- const value = Number.parseInt(raw.trim(), 10);
1862
- return Number.isFinite(value) && value >= 0 ? value : void 0;
1863
- }
1864
- function removeUndefinedRateLimit(rateLimit) {
1865
- return Object.fromEntries(
1866
- Object.entries(rateLimit).filter(([, value]) => value !== void 0)
1867
- );
1868
- }
1869
- var CopilotClient = class {
1870
- #auth;
1871
- #allowUnsafeUpstream;
1872
- #fetch;
1873
- #githubApiBaseUrl;
1874
- constructor(options = {}) {
1875
- this.#auth = new CopilotAuth(options);
1876
- this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
1877
- this.#fetch = options.fetch ?? fetch;
1878
- this.#githubApiBaseUrl = trimTrailingSlash(
1879
- options.githubApiBaseUrl ?? envValue(options.env?.HOOPILOT_GITHUB_API_BASE_URL) ?? DEFAULT_GITHUB_API_BASE_URL
1880
- );
1881
- }
1882
- /**
1883
- * Fetch the Copilot account's quota / premium-request usage from the GitHub
1884
- * REST `copilot_internal/user` endpoint. The stored device-flow OAuth token is
1885
- * accepted directly here — no Copilot token exchange is required to read quota.
1886
- */
1887
- async usage(signal) {
1888
- if (!isTrustedTokenBaseUrl(
1889
- this.#githubApiBaseUrl,
1890
- ALLOWED_GITHUB_API_HOSTS,
1891
- this.#allowUnsafeUpstream
1892
- )) {
1893
- throw new Error(
1894
- `Refusing to send the GitHub OAuth token to an untrusted GitHub API host: ${this.#githubApiBaseUrl}`
1895
- );
1896
- }
1897
- const access = await this.#auth.getAccess();
1898
- const headers = applyGithubApiHeaders(new Headers(), access.token);
1899
- return this.#fetch(`${this.#githubApiBaseUrl}/copilot_internal/user`, {
1900
- headers,
1901
- method: "GET",
1902
- signal
1903
- });
1904
- }
1905
- async chatCompletions(body, signal) {
1906
- return this.fetchCopilot("/chat/completions", {
1907
- body: JSON.stringify(body),
1908
- headers: {
1909
- "content-type": "application/json"
1910
- },
1911
- method: "POST",
1912
- signal
1913
- });
1914
- }
1915
- async responses(body, signal) {
1916
- return this.fetchCopilot("/responses", {
1917
- body,
1918
- headers: {
1919
- "content-type": "application/json"
1920
- },
1921
- method: "POST",
1922
- signal
1923
- });
1924
- }
1925
- async models(signal) {
1926
- return this.fetchCopilot("/models", {
1927
- headers: {
1928
- accept: "application/json"
1929
- },
1930
- method: "GET",
1931
- signal
1932
- });
1933
- }
1934
- async fetchCopilot(path, init) {
1935
- const access = await this.#auth.getAccess();
1936
- if (!isTrustedTokenBaseUrl(
1937
- access.apiBaseUrl,
1938
- ALLOWED_COPILOT_API_HOSTS,
1939
- this.#allowUnsafeUpstream
1940
- )) {
1941
- throw new Error(
1942
- `Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${access.apiBaseUrl}`
1943
- );
1944
- }
1945
- const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
1946
- return this.#fetch(`${access.apiBaseUrl}${path}`, {
1947
- ...init,
1948
- headers
1949
- });
1950
- }
1951
- };
1952
- function normalizeCopilotUsage(body) {
1953
- const record = asRecord(body);
1954
- const quotas = {};
1955
- const snapshots = asRecord(record.quota_snapshots);
1956
- for (const [category, detail] of Object.entries(snapshots)) {
1957
- quotas[category] = normalizeQuotaDetail(asRecord(detail));
1958
- }
1959
- if (Object.keys(quotas).length === 0) {
1960
- const remaining = asRecord(record.limited_user_quotas);
1961
- const monthly = asRecord(record.monthly_quotas);
1962
- for (const category of /* @__PURE__ */ new Set([...Object.keys(remaining), ...Object.keys(monthly)])) {
1963
- const entitlement = numberOrUndefined(monthly[category]);
1964
- const left = numberOrUndefined(remaining[category]);
1965
- quotas[category] = removeUndefinedQuota({
1966
- entitlement,
1967
- percentRemaining: entitlement !== void 0 && entitlement > 0 && left !== void 0 ? left / entitlement * 100 : void 0,
1968
- remaining: left,
1969
- used: usedFrom(entitlement, left)
1970
- });
1971
- }
1972
- }
1973
- return removeUndefinedUsage({
1974
- accessTypeSku: stringOrUndefined(record.access_type_sku),
1975
- chatEnabled: typeof record.chat_enabled === "boolean" ? record.chat_enabled : void 0,
1976
- plan: stringOrUndefined(record.copilot_plan),
1977
- quotaResetDate: stringOrUndefined(record.quota_reset_date) ?? stringOrUndefined(record.quota_reset_date_utc) ?? stringOrUndefined(record.limited_user_reset_date),
1978
- quotas
1979
- });
1980
- }
1981
- function normalizeQuotaDetail(detail) {
1982
- const entitlement = numberOrUndefined(detail.entitlement);
1983
- const overageCount = numberOrUndefined(detail.overage_count);
1984
- const remaining = numberOrUndefined(detail.remaining) ?? numberOrUndefined(detail.quota_remaining);
1985
- return removeUndefinedQuota({
1986
- entitlement,
1987
- hasQuota: typeof detail.has_quota === "boolean" ? detail.has_quota : void 0,
1988
- overageCount,
1989
- overageEntitlement: numberOrUndefined(detail.overage_entitlement),
1990
- overagePermitted: typeof detail.overage_permitted === "boolean" ? detail.overage_permitted : void 0,
1991
- percentRemaining: numberOrUndefined(detail.percent_remaining),
1992
- quotaId: stringOrUndefined(detail.quota_id),
1993
- quotaResetAt: stringOrUndefined(detail.quota_reset_at),
1994
- remaining,
1995
- timestampUtc: stringOrUndefined(detail.timestamp_utc),
1996
- tokenBasedBilling: typeof detail.token_based_billing === "boolean" ? detail.token_based_billing : void 0,
1997
- unlimited: typeof detail.unlimited === "boolean" ? detail.unlimited : void 0,
1998
- used: usedFrom(entitlement, remaining, overageCount)
1999
- });
2000
- }
2001
- function usedFrom(entitlement, remaining, overageCount) {
2002
- if (entitlement === void 0 || remaining === void 0) {
2003
- return void 0;
2004
- }
2005
- const base = entitlement - remaining;
2006
- const overage = remaining === 0 ? overageCount ?? 0 : 0;
2007
- return Math.max(0, base + overage);
2008
- }
2009
- function numberOrUndefined(value) {
2010
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
2011
- }
2012
- function stringOrUndefined(value) {
2013
- return typeof value === "string" && value.length > 0 ? value : void 0;
2014
- }
2015
- function removeUndefinedQuota(quota) {
2016
- return Object.fromEntries(
2017
- Object.entries(quota).filter(([, value]) => value !== void 0)
2018
- );
2019
- }
2020
- function removeUndefinedUsage(usage) {
2021
- const entries = Object.entries(usage).filter(([, value]) => value !== void 0);
2022
- return Object.fromEntries(entries);
2023
- }
2024
-
2025
- // src/github-device.ts
2026
- var import_promises = require("timers/promises");
2027
- var DEFAULT_GITHUB_COPILOT_CLIENT_ID = "Ov23li8tweQw6odWQebz";
2028
- var DEFAULT_GITHUB_DOMAIN = "github.com";
2029
- var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
2030
- var POLLING_SAFETY_MARGIN_MS = 3e3;
2031
- var REQUEST_TIMEOUT_MS = 15e3;
2032
- async function githubCopilotDeviceLogin(options = {}) {
2033
- const env = options.env ?? process.env;
2034
- const fetcher = options.fetch ?? fetch;
2035
- const sleeper = options.sleep ?? import_promises.setTimeout;
2036
- const domain = normalizeDomain(
2037
- options.domain ?? envValue(env.HOOPILOT_GITHUB_DOMAIN) ?? DEFAULT_GITHUB_DOMAIN
2038
- );
2039
- const clientId = options.clientId ?? envValue(env.HOOPILOT_GITHUB_CLIENT_ID) ?? envValue(env.COPILOT_GITHUB_CLIENT_ID) ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
2040
- const device = await requestDeviceCode(fetcher, domain, clientId);
2041
- const verificationUrl = device.verification_uri;
2042
- const userCode = device.user_code;
2043
- const deviceCode = device.device_code;
2044
- if (!verificationUrl || !userCode || !deviceCode) {
2045
- throw new Error("GitHub device authorization response is missing required fields.");
2046
- }
2047
- options.logger?.info(`First copy your one-time code: ${userCode}`);
2048
- options.logger?.info(`Open ${verificationUrl} in your browser to authorize Hoopilot.`);
2049
- await options.openBrowser?.(verificationUrl);
2050
- return {
2051
- domain,
2052
- token: await pollForAccessToken(fetcher, sleeper, domain, clientId, {
2053
- deviceCode,
2054
- expiresIn: positiveSeconds(device.expires_in, 900),
2055
- interval: positiveSeconds(device.interval, 5)
2056
- })
2057
- };
2058
- }
2059
- async function requestDeviceCode(fetcher, domain, clientId) {
2060
- const response = await fetcher(`https://${domain}/login/device/code`, {
2061
- body: JSON.stringify({
2062
- client_id: clientId,
2063
- scope: "read:user"
2064
- }),
2065
- headers: oauthHeaders(),
2066
- method: "POST",
2067
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
2068
- });
2069
- if (!response.ok) {
2070
- throw new Error(
2071
- `GitHub device authorization failed with ${response.status}: ${await truncatedResponseText(
2072
- response
2073
- )}`
2074
- );
2075
- }
2076
- return parseJsonResponse(
2077
- response,
2078
- "GitHub device authorization response was not valid JSON"
2079
- );
2080
- }
2081
- async function pollForAccessToken(fetcher, sleeper, domain, clientId, device) {
2082
- let intervalMs = device.interval * 1e3 + POLLING_SAFETY_MARGIN_MS;
2083
- const deadline = Date.now() + device.expiresIn * 1e3;
2084
- while (Date.now() < deadline) {
2085
- await sleeper(intervalMs);
2086
- const response = await fetcher(`https://${domain}/login/oauth/access_token`, {
2087
- body: JSON.stringify({
2088
- client_id: clientId,
2089
- device_code: device.deviceCode,
2090
- grant_type: DEVICE_GRANT_TYPE
2091
- }),
2092
- headers: oauthHeaders(),
2093
- method: "POST",
2094
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
2095
- });
2096
- if (!response.ok) {
2097
- throw new Error(
2098
- `GitHub device token exchange failed with ${response.status}: ${await truncatedResponseText(
2099
- response
2100
- )}`
2101
- );
2102
- }
2103
- const data = await parseJsonResponse(
2104
- response,
2105
- "GitHub device token response was not valid JSON"
2106
- );
2107
- if (data.access_token) {
2108
- return data.access_token;
2109
- }
2110
- if (data.error === "authorization_pending") {
2111
- continue;
2112
- }
2113
- if (data.error === "slow_down") {
2114
- intervalMs = positiveSeconds(data.interval, device.interval + 5) * 1e3 + POLLING_SAFETY_MARGIN_MS;
2115
- continue;
2116
- }
2117
- if (data.error === "expired_token") {
2118
- throw new Error("GitHub device login expired. Run `hoopilot login` again.");
2119
- }
2120
- if (data.error === "access_denied") {
2121
- throw new Error("GitHub device login was cancelled.");
2122
- }
2123
- if (data.error) {
2124
- throw new Error(data.error_description || `GitHub device login failed: ${data.error}`);
2125
- }
2126
- }
2127
- throw new Error("GitHub device login timed out. Run `hoopilot login` again.");
2128
- }
2129
- function oauthHeaders() {
2130
- const headers = new Headers();
2131
- headers.set("accept", "application/json");
2132
- headers.set("content-type", "application/json");
2133
- headers.set("user-agent", "hoopilot");
2134
- return headers;
2135
- }
2136
- function normalizeDomain(value) {
2137
- const raw = value.trim();
2138
- const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
2139
- let url;
2140
- try {
2141
- url = new URL(withScheme);
2142
- } catch {
2143
- throw new Error(`Invalid GitHub domain: ${value}.`);
2144
- }
2145
- if (url.protocol !== "https:" && url.protocol !== "http:" || url.username || url.password || !url.hostname || url.pathname !== "" && url.pathname !== "/" || url.search || url.hash) {
2146
- throw new Error(`Invalid GitHub domain: ${value}. Provide only a hostname.`);
2147
- }
2148
- return url.host;
2149
- }
2150
- function positiveSeconds(value, fallback) {
2151
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
2152
- }
2153
- async function parseJsonResponse(response, context) {
2154
- const text = await response.text();
2155
- try {
2156
- return JSON.parse(text);
2157
- } catch {
2158
- throw new Error(`${context}: ${text.slice(0, 500)}`);
2159
- }
2160
- }
2161
-
2162
- // src/logger.ts
2163
- var import_pino = __toESM(require("pino"), 1);
2164
- var import_pino_pretty = __toESM(require("pino-pretty"), 1);
2165
- var DEFAULT_LOG_FORMAT = "pretty";
2166
- var DEFAULT_LOG_LEVEL = "info";
2167
- var LOG_FORMATS = ["json", "pretty"];
2168
- var LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal", "silent"];
2169
- var REDACT_PATHS = [
2170
- "apiKey",
2171
- "authorization",
2172
- "cookie",
2173
- "headers.authorization",
2174
- "headers.Authorization",
2175
- "headers.cookie",
2176
- "headers.Cookie",
2177
- "headers.x-api-key",
2178
- "headers.X-Api-Key",
2179
- "token",
2180
- "*.apiKey",
2181
- "*.authorization",
2182
- "*.cookie",
2183
- "*.token",
2184
- "*.headers.authorization",
2185
- "*.headers.Authorization",
2186
- "*.headers.cookie",
2187
- "*.headers.Cookie",
2188
- "*.headers.x-api-key",
2189
- "*.headers.X-Api-Key"
2190
- ];
2191
- var noopLogger = {
2192
- child: () => noopLogger,
2193
- debug: () => {
2194
- },
2195
- error: () => {
2196
- },
2197
- fatal: () => {
2198
- },
2199
- info: () => {
2200
- },
2201
- trace: () => {
2202
- },
2203
- warn: () => {
2204
- }
2205
- };
2206
- function createHoopilotLogger(options = {}) {
2207
- const env = options.env ?? process.env;
2208
- const level = parseLogLevel(options.level ?? envValue(env.HOOPILOT_LOG_LEVEL));
2209
- const format = parseLogFormat(options.format ?? envValue(env.HOOPILOT_LOG_FORMAT));
2210
- const pinoOptions = {
2211
- base: {
2212
- service: "hoopilot",
2213
- ...options.base
2214
- },
2215
- level,
2216
- redact: {
2217
- censor: "[Redacted]",
2218
- paths: REDACT_PATHS
2219
- },
2220
- timestamp: import_pino.default.stdTimeFunctions.isoTime
2221
- };
2222
- if (format === "pretty") {
2223
- return (0, import_pino.default)(
2224
- pinoOptions,
2225
- (0, import_pino_pretty.default)({
2226
- colorize: options.colorize ?? process.stderr.isTTY,
2227
- destination: options.stream ?? 1,
2228
- ignore: "pid,hostname",
2229
- singleLine: true,
2230
- translateTime: "SYS:standard"
2231
- })
2232
- );
2233
- }
2234
- if (options.stream) {
2235
- return (0, import_pino.default)(pinoOptions, options.stream);
2236
- }
2237
- return (0, import_pino.default)(pinoOptions);
2238
- }
2239
- function parseLogFormat(value) {
2240
- if (!value) {
2241
- return DEFAULT_LOG_FORMAT;
2242
- }
2243
- if (isLogFormat(value)) {
2244
- return value;
2245
- }
2246
- throw new Error(`Invalid log format: ${value}. Expected one of: ${LOG_FORMATS.join(", ")}.`);
2247
- }
2248
- function parseLogLevel(value) {
2249
- if (!value) {
2250
- return DEFAULT_LOG_LEVEL;
2251
- }
2252
- if (isLogLevel(value)) {
2253
- return value;
2254
- }
2255
- throw new Error(`Invalid log level: ${value}. Expected one of: ${LOG_LEVELS.join(", ")}.`);
2256
- }
2257
- function shouldCreateLogger(options) {
2258
- return Boolean(
2259
- options.logger || options.logFormat || options.logLevel || envValue(options.env?.HOOPILOT_LOG_FORMAT) || envValue(options.env?.HOOPILOT_LOG_LEVEL)
2260
- );
2261
- }
2262
- function errorDetails(error) {
2263
- if (error instanceof Error) {
2264
- return {
2265
- message: error.message,
2266
- name: error.name,
2267
- stack: error.stack
2268
- };
2269
- }
2270
- return { message: String(error) };
2271
- }
2272
- function isLogFormat(value) {
2273
- return LOG_FORMATS.includes(value);
2274
- }
2275
- function isLogLevel(value) {
2276
- return LOG_LEVELS.includes(value);
2277
- }
2278
-
2279
- // src/metrics.ts
2280
- var PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
2281
- var DURATION_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
2282
- var USAGE_BUFFER_LIMIT_BYTES = 16 * 1024 * 1024;
2283
- var MAX_TRACKED_MODELS = 200;
2284
- var MAX_MODEL_LABEL_LENGTH = 200;
2285
- var MAX_TRACKED_RATELIMIT_RESOURCES = 32;
2286
- var LABEL_SEPARATOR = "";
2287
- var UNKNOWN_MODEL = "unknown";
2288
- function emptyModelTotals() {
2289
- return { cached: 0, completion: 0, prompt: 0, reasoning: 0, requests: 0, total: 0 };
2290
- }
2291
- var MetricsRegistry = class {
2292
- #startedAtMs;
2293
- #inFlight = 0;
2294
- #requests = /* @__PURE__ */ new Map();
2295
- #durations = /* @__PURE__ */ new Map();
2296
- #tokens = /* @__PURE__ */ new Map();
2297
- #upstream = /* @__PURE__ */ new Map();
2298
- #copilotQuota;
2299
- #githubRateLimit = /* @__PURE__ */ new Map();
2300
- #extraction = { extracted: 0, missing: 0 };
2301
- constructor(options = {}) {
2302
- this.#startedAtMs = (options.now ?? Date.now)();
2303
- }
2304
- /** Mark a request as started; pair with exactly one {@link observe}. */
2305
- startRequest() {
2306
- this.#inFlight += 1;
2307
- }
2308
- /** Record a completed request and clear its in-flight slot. */
2309
- observe(observation) {
2310
- if (this.#inFlight > 0) {
2311
- this.#inFlight -= 1;
2312
- }
2313
- const key = labelKey(observation.route, observation.method, String(observation.status));
2314
- this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
2315
- this.#observeDuration(observation.route, observation.durationMs / 1e3);
2316
- }
2317
- /**
2318
- * Record whether one upstream completion reported token usage. `missing`
2319
- * counts responses that carried no usage object — most often streamed Chat
2320
- * Completions sent without `stream_options: {"include_usage": true}` — so a
2321
- * rising miss rate flags clients whose token usage is going unaccounted.
2322
- */
2323
- recordTokenExtraction(extracted) {
2324
- if (extracted) {
2325
- this.#extraction.extracted += 1;
2326
- } else {
2327
- this.#extraction.missing += 1;
2328
- }
2329
- }
2330
- /** Accumulate token counts for a model from one upstream completion. */
2331
- recordTokens(model, usage) {
2332
- const name = this.#modelLabel(model);
2333
- const totals = this.#tokens.get(name) ?? emptyModelTotals();
2334
- totals.requests += 1;
2335
- totals.prompt += nonNegative(usage.promptTokens);
2336
- totals.completion += nonNegative(usage.completionTokens);
2337
- totals.total += nonNegative(usage.totalTokens);
2338
- totals.reasoning += nonNegative(usage.reasoningTokens ?? 0);
2339
- totals.cached += nonNegative(usage.cachedTokens ?? 0);
2340
- this.#tokens.set(name, totals);
2341
- }
2342
- /** Record one upstream Copilot call and whether it succeeded. */
2343
- recordUpstream(path, ok) {
2344
- const key = labelKey(path, ok ? "ok" : "error");
2345
- this.#upstream.set(key, (this.#upstream.get(key) ?? 0) + 1);
2346
- }
2347
- /** Store the latest Copilot quota so /metrics can expose it as gauges. */
2348
- recordCopilotQuota(usage) {
2349
- this.#copilotQuota = usage;
2350
- }
2351
- /**
2352
- * Store the latest GitHub REST rate-limit budget, keyed by its resource bucket.
2353
- * A no-op when `rateLimit` is undefined (the response carried no rate-limit
2354
- * headers) so callers can pass {@link parseRateLimitHeaders} output directly.
2355
- */
2356
- recordGithubRateLimit(rateLimit) {
2357
- if (!rateLimit) {
2358
- return;
2359
- }
2360
- const resource = this.#rateLimitResource(rateLimit.resource);
2361
- this.#githubRateLimit.set(resource, { ...rateLimit, resource });
2362
- }
2363
- // Sanitize the model into a bounded label. The model can originate from a
2364
- // client request, so cap its length, strip characters that would corrupt the
2365
- // exposition format, and fold overflow past the cardinality limit into
2366
- // UNKNOWN_MODEL to keep the series count bounded.
2367
- #modelLabel(model) {
2368
- const cleaned = cleanLabel(model).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2369
- if (!this.#tokens.has(cleaned) && this.#tokens.size >= MAX_TRACKED_MODELS) {
2370
- return UNKNOWN_MODEL;
2371
- }
2372
- return cleaned;
2373
- }
2374
- // The resource comes from a trusted upstream header, but clean and bound it
2375
- // with the same discipline as model labels: strip control characters that
2376
- // would corrupt the exposition format and fold overflow into "unknown".
2377
- #rateLimitResource(resource) {
2378
- const cleaned = cleanLabel(resource).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
2379
- if (!this.#githubRateLimit.has(cleaned) && this.#githubRateLimit.size >= MAX_TRACKED_RATELIMIT_RESOURCES) {
2380
- return UNKNOWN_MODEL;
2381
- }
2382
- return cleaned;
2383
- }
2384
- #observeDuration(route, seconds) {
2385
- const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
2386
- const entry = this.#durations.get(route) ?? {
2387
- buckets: new Array(DURATION_BUCKETS_SECONDS.length).fill(0),
2388
- count: 0,
2389
- sum: 0
2390
- };
2391
- entry.count += 1;
2392
- entry.sum += value;
2393
- const index = DURATION_BUCKETS_SECONDS.findIndex((bound) => value <= bound);
2394
- if (index !== -1) {
2395
- entry.buckets[index] = (entry.buckets[index] ?? 0) + 1;
2396
- }
2397
- this.#durations.set(route, entry);
2398
- }
2399
- /** A JSON-friendly view of the current counters. */
2400
- snapshot(now = Date.now) {
2401
- const byRoute = {};
2402
- const byStatus = {};
2403
- let requestsTotal = 0;
2404
- for (const [key, count] of this.#requests) {
2405
- const [route = "", , status = ""] = key.split(LABEL_SEPARATOR);
2406
- byRoute[route] = (byRoute[route] ?? 0) + count;
2407
- byStatus[status] = (byStatus[status] ?? 0) + count;
2408
- requestsTotal += count;
2409
- }
2410
- const byModel = {};
2411
- const tokenTotals = { cached: 0, completion: 0, prompt: 0, reasoning: 0, total: 0 };
2412
- for (const [model, totals] of this.#tokens) {
2413
- byModel[model] = { ...totals };
2414
- tokenTotals.prompt += totals.prompt;
2415
- tokenTotals.completion += totals.completion;
2416
- tokenTotals.total += totals.total;
2417
- tokenTotals.reasoning += totals.reasoning;
2418
- tokenTotals.cached += totals.cached;
2419
- }
2420
- let upstreamTotal = 0;
2421
- let upstreamErrors = 0;
2422
- for (const [key, count] of this.#upstream) {
2423
- upstreamTotal += count;
2424
- if (key.endsWith(`${LABEL_SEPARATOR}error`)) {
2425
- upstreamErrors += count;
2426
- }
2427
- }
2428
- const githubRateLimit = {};
2429
- for (const [resource, rateLimit] of this.#githubRateLimit) {
2430
- githubRateLimit[resource] = toRateLimitSnapshot(rateLimit);
2431
- }
2432
- return {
2433
- githubRateLimit,
2434
- inFlight: this.#inFlight,
2435
- latency: this.#latencySnapshot(),
2436
- requests: { byRoute, byStatus, total: requestsTotal },
2437
- startedAt: new Date(this.#startedAtMs).toISOString(),
2438
- tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
2439
- upstream: { errors: upstreamErrors, total: upstreamTotal },
2440
- uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
2441
- };
2442
- }
2443
- // Summarize the duration histogram into a JSON latency view: per-route count and
2444
- // exact average, plus overall average and estimated p50/p95. The percentiles come
2445
- // from the buckets aggregated across routes, so they share /metrics' resolution.
2446
- #latencySnapshot() {
2447
- const byRoute = {};
2448
- const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
2449
- let totalCount = 0;
2450
- let totalSum = 0;
2451
- for (const [route, entry] of this.#durations) {
2452
- byRoute[route] = {
2453
- avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
2454
- count: entry.count
2455
- };
2456
- totalCount += entry.count;
2457
- totalSum += entry.sum;
2458
- for (let i = 0; i < aggregateBuckets.length; i += 1) {
2459
- aggregateBuckets[i] = (aggregateBuckets[i] ?? 0) + (entry.buckets[i] ?? 0);
2460
- }
2461
- }
2462
- return {
2463
- avgMs: totalCount > 0 ? round2(totalSum / totalCount * 1e3) : 0,
2464
- byRoute,
2465
- count: totalCount,
2466
- p50Ms: round2(
2467
- quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.5) * 1e3
2468
- ),
2469
- p95Ms: round2(
2470
- quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.95) * 1e3
2471
- )
2472
- };
2473
- }
2474
- /** Render the Prometheus text exposition format (version 0.0.4). */
2475
- renderPrometheus(now = Date.now) {
2476
- const lines = [];
2477
- lines.push("# HELP hoopilot_process_start_time_seconds Unix epoch when the proxy started.");
2478
- lines.push("# TYPE hoopilot_process_start_time_seconds gauge");
2479
- lines.push(`hoopilot_process_start_time_seconds ${this.#startedAtMs / 1e3}`);
2480
- lines.push("# HELP hoopilot_uptime_seconds Seconds since the proxy started.");
2481
- lines.push("# TYPE hoopilot_uptime_seconds gauge");
2482
- lines.push(`hoopilot_uptime_seconds ${Math.max(0, (now() - this.#startedAtMs) / 1e3)}`);
2483
- lines.push("# HELP hoopilot_requests_in_flight Requests currently being served.");
2484
- lines.push("# TYPE hoopilot_requests_in_flight gauge");
2485
- lines.push(`hoopilot_requests_in_flight ${this.#inFlight}`);
2486
- lines.push("# HELP hoopilot_requests_total Completed requests by route, method, and status.");
2487
- lines.push("# TYPE hoopilot_requests_total counter");
2488
- for (const [key, count] of this.#requests) {
2489
- const [route = "", method = "", status = ""] = key.split(LABEL_SEPARATOR);
2490
- lines.push(`hoopilot_requests_total${labels({ method, route, status })} ${count}`);
2491
- }
2492
- lines.push(
2493
- "# HELP hoopilot_upstream_requests_total Copilot upstream calls by path and outcome."
2494
- );
2495
- lines.push("# TYPE hoopilot_upstream_requests_total counter");
2496
- for (const [key, count] of this.#upstream) {
2497
- const [path = "", outcome = ""] = key.split(LABEL_SEPARATOR);
2498
- lines.push(`hoopilot_upstream_requests_total${labels({ outcome, path })} ${count}`);
2499
- }
2500
- lines.push(
2501
- "# HELP hoopilot_tokens_total Tokens reported by upstream usage, by model and type."
2502
- );
2503
- lines.push("# TYPE hoopilot_tokens_total counter");
2504
- for (const [model, totals] of this.#tokens) {
2505
- lines.push(`hoopilot_tokens_total${labels({ model, type: "prompt" })} ${totals.prompt}`);
2506
- lines.push(
2507
- `hoopilot_tokens_total${labels({ model, type: "completion" })} ${totals.completion}`
2508
- );
2509
- lines.push(
2510
- `hoopilot_tokens_total${labels({ model, type: "reasoning" })} ${totals.reasoning}`
2511
- );
2512
- lines.push(`hoopilot_tokens_total${labels({ model, type: "cached" })} ${totals.cached}`);
2513
- }
2514
- lines.push("# HELP hoopilot_model_requests_total Completions with usage observed, by model.");
2515
- lines.push("# TYPE hoopilot_model_requests_total counter");
2516
- for (const [model, totals] of this.#tokens) {
2517
- lines.push(`hoopilot_model_requests_total${labels({ model })} ${totals.requests}`);
2518
- }
2519
- lines.push(
2520
- "# HELP hoopilot_token_extraction_total Completions by whether upstream reported token usage."
2521
- );
2522
- lines.push("# TYPE hoopilot_token_extraction_total counter");
2523
- lines.push(
2524
- `hoopilot_token_extraction_total${labels({ outcome: "extracted" })} ${this.#extraction.extracted}`
2525
- );
2526
- lines.push(
2527
- `hoopilot_token_extraction_total${labels({ outcome: "missing" })} ${this.#extraction.missing}`
2528
- );
2529
- lines.push("# HELP hoopilot_request_duration_seconds Request duration by route.");
2530
- lines.push("# TYPE hoopilot_request_duration_seconds histogram");
2531
- for (const [route, entry] of this.#durations) {
2532
- let cumulative = 0;
2533
- for (let i = 0; i < DURATION_BUCKETS_SECONDS.length; i += 1) {
2534
- cumulative += entry.buckets[i] ?? 0;
2535
- const le = formatNumber(DURATION_BUCKETS_SECONDS[i] ?? 0);
2536
- lines.push(
2537
- `hoopilot_request_duration_seconds_bucket${labels({ le, route })} ${cumulative}`
2538
- );
2539
- }
2540
- lines.push(
2541
- `hoopilot_request_duration_seconds_bucket${labels({ le: "+Inf", route })} ${entry.count}`
2542
- );
2543
- lines.push(`hoopilot_request_duration_seconds_sum${labels({ route })} ${entry.sum}`);
2544
- lines.push(`hoopilot_request_duration_seconds_count${labels({ route })} ${entry.count}`);
2545
- }
2546
- this.#renderGithubRateLimit(lines);
2547
- this.#renderCopilotQuota(lines);
2548
- return `${lines.join("\n")}
2549
- `;
2550
- }
2551
- #renderGithubRateLimit(lines) {
2552
- const entries = [...this.#githubRateLimit.values()];
2553
- if (entries.length === 0) {
2554
- return;
2555
- }
2556
- const gauge = (suffix, help, pick) => {
2557
- const present = entries.filter((rateLimit) => pick(rateLimit) !== void 0);
2558
- if (present.length === 0) {
2559
- return;
2560
- }
2561
- lines.push(`# HELP hoopilot_github_ratelimit_${suffix} ${help}`);
2562
- lines.push(`# TYPE hoopilot_github_ratelimit_${suffix} gauge`);
2563
- for (const rateLimit of present) {
2564
- lines.push(
2565
- `hoopilot_github_ratelimit_${suffix}${labels({ resource: rateLimit.resource })} ${pick(rateLimit)}`
2566
- );
2567
- }
2568
- };
2569
- gauge("limit", "GitHub REST API request ceiling for the resource window.", (r) => r.limit);
2570
- gauge("remaining", "Requests remaining in the GitHub REST API window.", (r) => r.remaining);
2571
- gauge("used", "Requests used in the GitHub REST API window.", (r) => r.used);
2572
- gauge(
2573
- "reset_timestamp_seconds",
2574
- "Unix epoch when the GitHub REST API window resets.",
2575
- (r) => r.resetEpochSeconds
2576
- );
2577
- gauge(
2578
- "retry_after_seconds",
2579
- "Seconds to wait after a GitHub secondary-limit response.",
2580
- (r) => r.retryAfterSeconds
2581
- );
2582
- }
2583
- #renderCopilotQuota(lines) {
2584
- const usage = this.#copilotQuota;
2585
- if (!usage) {
2586
- return;
2587
- }
2588
- const categories = Object.entries(usage.quotas);
2589
- const gauge = (suffix, help, pick) => {
2590
- const present = categories.filter(([, quota]) => pick(quota) !== void 0);
2591
- if (present.length === 0) {
2592
- return;
2593
- }
2594
- lines.push(`# HELP hoopilot_copilot_quota_${suffix} ${help}`);
2595
- lines.push(`# TYPE hoopilot_copilot_quota_${suffix} gauge`);
2596
- for (const [category, quota] of present) {
2597
- lines.push(`hoopilot_copilot_quota_${suffix}${labels({ category })} ${pick(quota)}`);
2598
- }
2599
- };
2600
- gauge("remaining", "Remaining quota for the Copilot category.", (q) => q.remaining);
2601
- gauge("entitlement", "Quota entitlement for the Copilot category.", (q) => q.entitlement);
2602
- gauge("used", "Used quota (entitlement minus remaining) for the category.", (q) => q.used);
2603
- gauge("overage_count", "Overage count for the Copilot category.", (q) => q.overageCount);
2604
- gauge(
2605
- "overage_entitlement",
2606
- "Overage entitlement for the Copilot category.",
2607
- (q) => q.overageEntitlement
2608
- );
2609
- gauge(
2610
- "percent_remaining",
2611
- "Percent of quota remaining for the Copilot category.",
2612
- (q) => q.percentRemaining
2613
- );
2614
- booleanGauge(
2615
- "unlimited",
2616
- "Whether the Copilot quota category is unlimited.",
2617
- (q) => q.unlimited
2618
- );
2619
- booleanGauge(
2620
- "overage_permitted",
2621
- "Whether overage is permitted for the Copilot category.",
2622
- (q) => q.overagePermitted
2623
- );
2624
- booleanGauge("has_quota", "Whether the Copilot quota category has a quota.", (q) => q.hasQuota);
2625
- booleanGauge(
2626
- "token_based_billing",
2627
- "Whether the Copilot quota category uses token-based billing.",
2628
- (q) => q.tokenBasedBilling
2629
- );
2630
- dateGauge(
2631
- "category_reset_timestamp_seconds",
2632
- "Unix epoch of the Copilot category-specific quota reset.",
2633
- (q) => q.quotaResetAt
2634
- );
2635
- dateGauge(
2636
- "category_snapshot_timestamp_seconds",
2637
- "Unix epoch of the Copilot category quota snapshot.",
2638
- (q) => q.timestampUtc
2639
- );
2640
- const resetMs = usage.quotaResetDate ? Date.parse(usage.quotaResetDate) : Number.NaN;
2641
- if (Number.isFinite(resetMs)) {
2642
- lines.push(
2643
- "# HELP hoopilot_copilot_quota_reset_timestamp_seconds Unix epoch of the next reset."
2644
- );
2645
- lines.push("# TYPE hoopilot_copilot_quota_reset_timestamp_seconds gauge");
2646
- lines.push(`hoopilot_copilot_quota_reset_timestamp_seconds ${resetMs / 1e3}`);
2647
- }
2648
- if (usage.plan || usage.accessTypeSku) {
2649
- lines.push("# HELP hoopilot_copilot_info Copilot plan metadata as a constant-1 info gauge.");
2650
- lines.push("# TYPE hoopilot_copilot_info gauge");
2651
- lines.push(
2652
- `hoopilot_copilot_info${labels({
2653
- access_type_sku: usage.accessTypeSku ?? "",
2654
- plan: usage.plan ?? ""
2655
- })} 1`
2656
- );
2657
- }
2658
- function booleanGauge(suffix, help, pick) {
2659
- const present = categories.filter(([, quota]) => pick(quota) !== void 0);
2660
- if (present.length === 0) {
2661
- return;
2662
- }
2663
- lines.push(`# HELP hoopilot_copilot_quota_${suffix} ${help}`);
2664
- lines.push(`# TYPE hoopilot_copilot_quota_${suffix} gauge`);
2665
- for (const [category, quota] of present) {
2666
- lines.push(
2667
- `hoopilot_copilot_quota_${suffix}${labels({ category })} ${pick(quota) ? 1 : 0}`
2668
- );
2669
- }
2670
- }
2671
- function dateGauge(suffix, help, pick) {
2672
- const present = categories.map(([category, quota]) => [category, Date.parse(pick(quota) ?? "")]).filter(([, timestamp]) => Number.isFinite(timestamp));
2673
- if (present.length === 0) {
2674
- return;
2675
- }
2676
- lines.push(`# HELP hoopilot_copilot_quota_${suffix} ${help}`);
2677
- lines.push(`# TYPE hoopilot_copilot_quota_${suffix} gauge`);
2678
- for (const [category, timestamp] of present) {
2679
- lines.push(`hoopilot_copilot_quota_${suffix}${labels({ category })} ${timestamp / 1e3}`);
2680
- }
2681
- }
2682
- }
2683
- };
2684
- function observeResponseUsage(response, fallbackModel, onUsage, signal, onOutcome) {
2685
- const body = response.body;
2686
- if (!body) {
2687
- return response;
2688
- }
2689
- const [clientBranch, observerBranch] = body.tee();
2690
- const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
2691
- void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal, onOutcome).catch(
2692
- () => {
2693
- }
2694
- );
2695
- return new Response(clientBranch, {
2696
- headers: response.headers,
2697
- status: response.status,
2698
- statusText: response.statusText
2699
- });
2700
- }
2701
- function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome) {
2702
- const accumulator = createUsageAccumulator(fallbackModel, onUsage, onOutcome);
2703
- if (isSse) {
2704
- for (const line of text.split(/\r?\n/)) {
2705
- considerSseLine(line, accumulator.consider);
2706
- }
2707
- } else {
2708
- const parsed = safeParse(text);
2709
- if (parsed !== void 0) {
2710
- accumulator.consider(parsed);
2711
- }
2712
- }
2713
- accumulator.finish();
2714
- }
2715
- async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
2716
- const reader = stream.getReader();
2717
- const onAbort = () => {
2718
- reader.cancel().catch(() => {
2719
- });
2720
- };
2721
- if (signal?.aborted) {
2722
- reader.cancel().catch(() => {
2723
- });
2724
- } else {
2725
- signal?.addEventListener("abort", onAbort, { once: true });
2726
- }
2727
- const decoder = new TextDecoder();
2728
- const guardedOutcome = onOutcome ? (extracted) => {
2729
- if (!signal?.aborted) {
2730
- onOutcome(extracted);
2731
- }
2732
- } : void 0;
2733
- const accumulator = createUsageAccumulator(fallbackModel, onUsage, guardedOutcome);
2734
- let buffer = "";
2735
- let bufferedBytes = 0;
2736
- let overflowed = false;
2737
- try {
2738
- while (true) {
2739
- const result = await reader.read();
2740
- if (result.done) {
2741
- break;
2742
- }
2743
- const chunk = decoder.decode(result.value, { stream: true });
2744
- if (isSse) {
2745
- buffer += chunk;
2746
- const lines = buffer.split(/\r?\n/);
2747
- buffer = lines.pop() ?? "";
2748
- for (const line of lines) {
2749
- considerSseLine(line, accumulator.consider);
2750
- }
2751
- if (buffer.length > USAGE_BUFFER_LIMIT_BYTES) {
2752
- buffer = "";
2753
- }
2754
- } else if (!overflowed) {
2755
- bufferedBytes += result.value.byteLength;
2756
- if (bufferedBytes > USAGE_BUFFER_LIMIT_BYTES) {
2757
- overflowed = true;
2758
- buffer = "";
2759
- } else {
2760
- buffer += chunk;
2761
- }
2762
- }
2763
- }
2764
- const finalBuffer = buffer + decoder.decode();
2765
- if (isSse) {
2766
- if (finalBuffer) {
2767
- considerSseLine(finalBuffer, accumulator.consider);
2768
- }
2769
- } else if (!overflowed && finalBuffer) {
2770
- const parsed = safeParse(finalBuffer);
2771
- if (parsed !== void 0) {
2772
- accumulator.consider(parsed);
2773
- }
2774
- }
2775
- } finally {
2776
- signal?.removeEventListener("abort", onAbort);
2777
- reader.releaseLock();
2778
- }
2779
- accumulator.finish();
2780
- }
2781
- function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
2782
- let model = fallbackModel;
2783
- let usage;
2784
- return {
2785
- consider(payload) {
2786
- const record = asRecord(payload);
2787
- const found = extractTokenUsage(record.usage) ?? extractTokenUsage(asRecord(record.response).usage);
2788
- if (found) {
2789
- usage = found;
2790
- }
2791
- const candidateModel = modelText(record.model) || modelText(asRecord(record.response).model);
2792
- if (candidateModel) {
2793
- model = candidateModel;
2794
- }
2795
- },
2796
- finish() {
2797
- if (usage) {
2798
- onUsage(model, usage);
2799
- }
2800
- onOutcome?.(usage !== void 0);
2801
- }
2802
- };
2803
- }
2804
- function considerSseLine(line, consider) {
2805
- const trimmed = line.trim();
2806
- if (!trimmed.startsWith("data:")) {
2807
- return;
2808
- }
2809
- const data = trimmed.slice("data:".length).trim();
2810
- if (!data || data === "[DONE]") {
2811
- return;
2812
- }
2813
- const parsed = safeParse(data);
2814
- if (parsed !== void 0) {
2815
- consider(parsed);
2816
- }
2817
- }
2818
- function safeParse(text) {
2819
- try {
2820
- return JSON.parse(text);
2821
- } catch {
2822
- return void 0;
2823
- }
2824
- }
2825
- function modelText(value) {
2826
- return typeof value === "string" ? value.trim() : "";
2827
- }
2828
- function nonNegative(value) {
2829
- return Number.isFinite(value) && value > 0 ? value : 0;
2830
- }
2831
- function round2(value) {
2832
- return Math.round(value * 100) / 100;
2833
- }
2834
- function quantileFromBuckets(bucketCounts, bounds, count, q) {
2835
- if (count <= 0) {
2836
- return 0;
2837
- }
2838
- const rank = q * count;
2839
- let cumulative = 0;
2840
- for (let i = 0; i < bounds.length; i += 1) {
2841
- const inBucket = bucketCounts[i] ?? 0;
2842
- if (inBucket > 0 && cumulative + inBucket >= rank) {
2843
- const lower = i === 0 ? 0 : bounds[i - 1] ?? 0;
2844
- const upper = bounds[i] ?? lower;
2845
- return lower + (upper - lower) * ((rank - cumulative) / inBucket);
2846
- }
2847
- cumulative += inBucket;
2848
- }
2849
- return bounds[bounds.length - 1] ?? 0;
2850
- }
2851
- function cleanLabel(value) {
2852
- let result = "";
2853
- for (const char of value) {
2854
- const code = char.charCodeAt(0);
2855
- if (code > 31 && code !== 127) {
2856
- result += char;
2857
- }
2858
- }
2859
- return result.trim();
2860
- }
2861
- function toRateLimitSnapshot(rateLimit) {
2862
- const snapshot = {
2863
- observedAt: new Date(rateLimit.observedAtMs).toISOString()
2864
- };
2865
- if (rateLimit.limit !== void 0) {
2866
- snapshot.limit = rateLimit.limit;
2867
- }
2868
- if (rateLimit.remaining !== void 0) {
2869
- snapshot.remaining = rateLimit.remaining;
2870
- }
2871
- if (rateLimit.used !== void 0) {
2872
- snapshot.used = rateLimit.used;
2873
- }
2874
- if (rateLimit.resetEpochSeconds !== void 0) {
2875
- snapshot.resetAt = new Date(rateLimit.resetEpochSeconds * 1e3).toISOString();
2876
- }
2877
- if (rateLimit.retryAfterSeconds !== void 0) {
2878
- snapshot.retryAfterSeconds = rateLimit.retryAfterSeconds;
2879
- }
2880
- return snapshot;
2881
- }
2882
- function labelKey(...parts) {
2883
- return parts.join(LABEL_SEPARATOR);
2884
- }
2885
- function labels(pairs) {
2886
- const entries = Object.entries(pairs);
2887
- if (entries.length === 0) {
2888
- return "";
2889
- }
2890
- const rendered = entries.map(([name, value]) => `${name}="${escapeLabelValue(value)}"`);
2891
- return `{${rendered.join(",")}}`;
2892
- }
2893
- function escapeLabelValue(value) {
2894
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r");
2895
- }
2896
- function formatNumber(value) {
2897
- return Number.isInteger(value) ? value.toString() : String(value);
2898
- }
2899
-
2900
- // src/dashboard.ts
2901
- var DASHBOARD_HTML = `<!doctype html>
2902
- <html lang="en">
2903
- <head>
2904
- <meta charset="utf-8" />
2905
- <meta name="viewport" content="width=device-width, initial-scale=1" />
2906
- <meta name="color-scheme" content="dark light" />
2907
- <title>hoopilot &middot; dashboard</title>
2908
- <style>
2909
- :root {
2910
- --bg-0:#0b0e14; --bg-1:#11151c; --bg-2:#171c25; --bg-3:#1f2630;
2911
- --border:#262d38; --border-strong:#37404d;
2912
- --text-0:#e6edf3; --text-1:#9aa7b4; --text-2:#5e6b78; --text-dim:#3a434e; --text-inv:#0b0e14;
2913
- --accent:#4ea1ff; --accent-2:#56d4dd; --accent-soft:rgba(78,161,255,.14);
2914
- --amber:#f5b042;
2915
- --ok:#3fb950; --warn:#d8a13a; --danger:#f0556a; --info:#a371f7; --cache:#7c8cff;
2916
- --spark:#4ea1ff; --spark-fill:color-mix(in srgb, var(--accent) 14%, transparent);
2917
- --grid-line:rgba(255,255,255,.05);
2918
- --flash:color-mix(in srgb, var(--accent) 22%, transparent);
2919
- --flash-up:color-mix(in srgb, var(--ok) 22%, transparent);
2920
- --flash-down:color-mix(in srgb, var(--danger) 22%, transparent);
2921
- --c1:#4ea1ff; --c2:#3fb950; --c3:#d8a13a; --c4:#a371f7; --c5:#56d4dd; --c6:#f0556a;
2922
- --mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
2923
- --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, system-ui, sans-serif;
2924
- }
2925
- @media (prefers-color-scheme: light) {
2926
- :root:not([data-theme="dark"]) {
2927
- --bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
2928
- --border:#d0d7de; --border-strong:#b6bec8;
2929
- --text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
2930
- --accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
2931
- --amber:#b5730a;
2932
- --ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
2933
- --spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
2934
- --grid-line:rgba(0,0,0,.06);
2935
- --flash:color-mix(in srgb, var(--accent) 16%, transparent);
2936
- --flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
2937
- --flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
2938
- --c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
2939
- }
2940
- }
2941
- [data-theme="light"] {
2942
- --bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
2943
- --border:#d0d7de; --border-strong:#b6bec8;
2944
- --text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
2945
- --accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
2946
- --amber:#b5730a;
2947
- --ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
2948
- --spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
2949
- --grid-line:rgba(0,0,0,.06);
2950
- --flash:color-mix(in srgb, var(--accent) 16%, transparent);
2951
- --flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
2952
- --flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
2953
- --c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
2954
- }
2955
- * { box-sizing: border-box; }
2956
- html, body { margin:0; padding:0; }
2957
- body {
2958
- background: var(--bg-0); color: var(--text-0); font-family: var(--sans);
2959
- font-size: 13px; line-height: 1.4; -webkit-font-smoothing: antialiased;
2960
- }
2961
- .mono { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
2962
- .num { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
2963
- .shell { max-width: 1280px; margin: 0 auto; padding: 0 24px 28px; }
2964
- @media (min-width: 1080px) { .shell { border-left:1px solid var(--border); border-right:1px solid var(--border); } }
2965
- @media (max-width: 680px) { .shell { padding: 0 12px 24px; } }
2966
-
2967
- /* header */
2968
- header.bar {
2969
- position: sticky; top: 0; z-index: 20; background: var(--bg-1);
2970
- border-bottom: 1px solid var(--border); height: 48px;
2971
- }
2972
- .bar-in { max-width:1280px; margin:0 auto; height:48px; padding:0 24px; display:flex; align-items:center; gap:12px; }
2973
- @media (max-width:680px){ .bar-in{ padding:0 12px; gap:8px; } }
2974
- .wordmark { font-family: var(--mono); font-weight:700; font-size:14px; color:var(--text-0); letter-spacing:-.01em; }
2975
- .caret { display:inline-block; width:7px; height:15px; background:var(--amber); margin-left:3px; vertical-align:-2px; animation: blink 1.1s steps(1) infinite; }
2976
- .chip { font-family: var(--mono); font-size:11px; padding:2px 7px; border-radius:10px; background:var(--bg-3); color:var(--text-1); white-space:nowrap; }
2977
- .chip.plan-pro { background:var(--accent-soft); color:var(--accent); }
2978
- .chip.plan-business { background:color-mix(in srgb, var(--info) 16%, transparent); color:var(--info); }
2979
- .chip.plan-free, .chip.plan-offline { background:var(--bg-3); color:var(--text-2); }
2980
- .spacer { flex:1; }
2981
- .pill { display:inline-flex; align-items:center; gap:6px; font-size:11px; font-family:var(--mono); padding:3px 9px; border-radius:11px; background:var(--bg-3); color:var(--text-1); }
2982
- .dot { width:7px; height:7px; border-radius:50%; background:var(--text-2); flex:none; }
2983
- .pill.live .dot { background:var(--ok); }
2984
- .pill.paused .dot { background:var(--text-2); }
2985
- .pill.reconnect { color:var(--warn); } .pill.reconnect .dot { background:var(--warn); }
2986
- .pill.authkey { color:var(--warn); } .pill.authkey .dot { background:var(--warn); }
2987
- .heartbeat { animation: hb .5s ease-out; }
2988
- .updated { font-family:var(--mono); font-size:11px; color:var(--text-2); white-space:nowrap; }
2989
- .updated.warn { color:var(--warn); } .updated.danger { color:var(--danger); }
2990
- .seg { display:inline-flex; border:1px solid var(--border); border-radius:6px; overflow:hidden; }
2991
- .seg button { background:transparent; color:var(--text-1); border:0; font-family:var(--mono); font-size:11px; padding:3px 8px; cursor:pointer; }
2992
- .seg button + button { border-left:1px solid var(--border); }
2993
- .seg button.active { background:var(--accent); color:var(--text-inv); }
2994
- .iconbtn { background:transparent; border:1px solid var(--border); border-radius:6px; color:var(--text-1); cursor:pointer; font-size:13px; line-height:1; padding:4px 7px; min-width:30px; }
2995
- .iconbtn:hover { background:var(--bg-3); }
2996
- button:focus-visible, input:focus-visible, .seg button:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
2997
- #scanbar { position:absolute; left:0; bottom:-1px; height:1px; width:100%; overflow:hidden; }
2998
- #scanbar::after { content:""; position:absolute; left:0; top:0; height:1px; width:40%;
2999
- background:linear-gradient(90deg, transparent, var(--accent), transparent);
3000
- animation: scan var(--scan-ms, 4000ms) linear infinite; }
3001
- header.bar.paused #scanbar::after, header.bar.frozen #scanbar::after { animation-play-state:paused; opacity:.35; }
3002
-
3003
- /* disconnect banner */
3004
- #banner { display:none; margin-top:10px; padding:7px 12px; border-radius:5px; font-family:var(--mono); font-size:12px;
3005
- background:color-mix(in srgb, var(--danger) 16%, transparent); color:var(--danger); border:1px solid color-mix(in srgb, var(--danger) 40%, transparent); }
3006
- #banner.ok { background:color-mix(in srgb, var(--ok) 16%, transparent); color:var(--ok); border-color:color-mix(in srgb, var(--ok) 40%, transparent); }
3007
- #banner.show { display:block; }
3008
-
3009
- /* hero strip */
3010
- .hero { display:grid; grid-template-columns:repeat(4,1fr); margin:18px 0 16px; }
3011
- .vital { padding:6px 18px; }
3012
- .vital + .vital { border-left:1px solid var(--border); }
3013
- .vital .eyebrow { font-size:10px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-1); }
3014
- .vital .vnum { font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-weight:600; font-size:clamp(2rem,5vw,3.25rem); line-height:1.02; letter-spacing:-.02em; color:var(--text-0); }
3015
- .vital .vsub { font-size:11px; color:var(--text-2); min-height:14px; }
3016
- .vital .vspark { display:block; width:100%; height:24px; margin-top:4px; }
3017
- .vital.active { }
3018
- .vital.active .eyebrow { color:var(--accent); }
3019
- @media (max-width:1079px){ .hero{ grid-template-columns:repeat(2,1fr); } .vital:nth-child(3){ border-left:0; } .vital:nth-child(n+3){ border-top:1px solid var(--border); padding-top:12px; } }
3020
- @media (max-width:600px){ .hero{ grid-template-columns:1fr; } .vital + .vital{ border-left:0; border-top:1px solid var(--border); } }
3021
-
3022
- /* grid + panels */
3023
- .grid { display:grid; grid-template-columns:repeat(12,1fr); gap:12px; }
3024
- .panel { position:relative; background:var(--bg-1); border:1px solid var(--border); border-radius:4px; padding:16px 12px 12px; min-width:0; }
3025
- .panel > .ptitle { position:absolute; top:-8px; left:10px; padding:0 6px; background:var(--bg-1);
3026
- font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
3027
- .span5{ grid-column:span 5; } .span3{ grid-column:span 3; } .span4{ grid-column:span 4; }
3028
- .span7{ grid-column:span 7; } .span8{ grid-column:span 8; }
3029
- @media (max-width:1079px){ .grid{ grid-template-columns:repeat(6,1fr); }
3030
- .span5,.span7,.span8{ grid-column:span 6; } .span3{ grid-column:span 3; } .span4{ grid-column:span 6; } }
3031
- @media (max-width:680px){ .grid{ grid-template-columns:1fr; }
3032
- .span3,.span4,.span5,.span7,.span8{ grid-column:span 1; } }
3033
-
3034
- .headline { font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-weight:600; font-size:22px; line-height:1.1; }
3035
- .cap { font-size:11px; color:var(--text-2); }
3036
- .stack-bar { display:flex; height:8px; border-radius:4px; overflow:hidden; background:var(--bg-3); margin:8px 0; }
3037
- .stack-bar i { display:block; height:100%; }
3038
- .stack-bar.empty { outline:1px dashed var(--border); background:transparent; }
3039
-
3040
- table.tbl { width:100%; border-collapse:collapse; font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-size:12px; }
3041
- .scrollx { overflow-x:auto; }
3042
- table.tbl th { font-size:10px; font-weight:600; text-transform:uppercase; color:var(--text-2); text-align:right; padding:4px 6px; border-bottom:1px solid var(--border); white-space:nowrap; }
3043
- table.tbl th.l { text-align:left; }
3044
- table.tbl td { padding:3px 6px; text-align:right; white-space:nowrap; border-bottom:1px solid color-mix(in srgb, var(--border) 55%, transparent); }
3045
- table.tbl td.l { text-align:left; max-width:160px; overflow:hidden; text-overflow:ellipsis; }
3046
- table.tbl tr:hover td { background:var(--bg-2); }
3047
- table.tbl tr.total td { border-top:1px solid var(--border-strong); border-bottom:0; font-weight:600; color:var(--text-0); }
3048
- .minibar { display:inline-block; height:6px; border-radius:3px; background:var(--accent); vertical-align:middle; min-width:1px; }
3049
- .ghost td { color:var(--text-2); text-align:center; }
3050
- .reasoning { color:var(--info); } .cached { color:var(--cache); }
3051
-
3052
- .legend { display:flex; flex-wrap:wrap; gap:4px 14px; margin-top:8px; }
3053
- .legend .li { display:flex; align-items:center; gap:6px; font-family:var(--mono); font-size:11px; color:var(--text-1); }
3054
- .legend .sw { width:8px; height:8px; border-radius:2px; flex:none; }
3055
-
3056
- .lat-trio { display:flex; gap:18px; align-items:baseline; }
3057
- .lat-trio .b { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
3058
- .lat-trio .b small { display:block; font-size:10px; font-weight:600; text-transform:uppercase; color:var(--text-2); letter-spacing:.05em; }
3059
- .lat-p95 { color:var(--info); }
3060
- .lat-track { position:relative; height:22px; margin-top:10px; }
3061
- .lat-track .line { position:absolute; top:11px; left:0; right:0; height:1px; background:var(--border); }
3062
- .lat-track .tick { position:absolute; top:5px; width:2px; height:12px; border-radius:1px; }
3063
- .lat-track .tick.p50 { background:var(--accent); } .lat-track .tick.p95 { background:var(--info); }
3064
- .lat-track .tlab { position:absolute; top:-2px; font-family:var(--mono); font-size:9px; color:var(--text-2); transform:translateX(-50%); }
3065
- details.routes { margin-top:10px; } details.routes summary { cursor:pointer; font-size:11px; color:var(--text-2); font-family:var(--mono); }
3066
-
3067
- .qrow { margin:10px 0; } .qrow .qhead { display:flex; justify-content:space-between; align-items:baseline; font-size:12px; }
3068
- .qrow .qname { color:var(--text-1); } .qrow .qval { font-family:var(--mono); font-variant-numeric:tabular-nums; color:var(--text-0); }
3069
- .qbar { position:relative; height:8px; border-radius:4px; background:var(--bg-3); margin-top:5px; overflow:hidden; }
3070
- .qbar i { position:absolute; left:0; top:0; height:100%; border-radius:4px; }
3071
- .qbar.over i.ext { background:repeating-linear-gradient(45deg, var(--danger), var(--danger) 3px, transparent 3px, transparent 6px); }
3072
- .inf { font-family:var(--mono); font-size:12px; color:var(--ok); }
3073
- .emptybox { border:1px solid var(--border); border-radius:5px; padding:14px; text-align:center; color:var(--text-2); }
3074
- .emptybox .keyglyph { font-size:20px; color:var(--text-1); }
3075
- .emptybox h4 { margin:8px 0 4px; font-family:var(--sans); font-size:13px; color:var(--text-1); font-weight:600; }
3076
- .emptybox .errline { font-family:var(--mono); font-size:11px; color:var(--text-2); word-break:break-word; margin:4px 0; }
3077
- .prompt { font-family:var(--mono); font-size:12px; color:var(--text-1); }
3078
-
3079
- .upblocks { display:flex; gap:18px; }
3080
- .upblk { } .upblk .v { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
3081
- .upblk .k { font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-2); }
3082
- .upblk.err.hot { color:var(--danger); }
3083
- .rate { font-family:var(--mono); font-size:12px; } .rate.warn{ color:var(--warn);} .rate.danger{ color:var(--danger);} .rate.ok{ color:var(--ok); }
3084
- #up-spark, #thru-svg { display:block; width:100%; }
3085
- #up-spark { height:30px; margin-top:8px; }
3086
- #thru-svg { height:88px; margin-top:6px; }
3087
- .flag { font-family:var(--mono); font-size:10px; color:var(--text-2); }
3088
-
3089
- footer.foot { margin-top:14px; padding-top:10px; border-top:1px solid var(--border); display:flex; flex-wrap:wrap; gap:4px 14px;
3090
- font-family:var(--mono); font-size:11px; color:var(--text-2); }
3091
- footer.foot .end { margin-left:auto; }
3092
- @media (max-width:680px){ footer.foot .end{ margin-left:0; } }
3093
-
3094
- .skel { color:var(--text-dim); }
3095
- .flash { animation: flash .6s ease-out; } .flash-up { animation: flashup .6s ease-out; } .flash-down { animation: flashdown .6s ease-out; }
3096
-
3097
- /* auth takeover */
3098
- #auth { display:none; }
3099
- #auth.show { display:flex; justify-content:center; padding:64px 16px; }
3100
- .authcard { width:100%; max-width:420px; background:var(--bg-1); border:1px solid var(--border); border-radius:6px; padding:22px 18px; position:relative; }
3101
- .authcard h3 { margin:0 0 10px; font-family:var(--mono); font-size:12px; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
3102
- .authcard p { font-size:12px; color:var(--text-2); margin:0 0 14px; }
3103
- .authcard .row { display:flex; gap:8px; }
3104
- .authcard input { flex:1; background:var(--bg-0); border:1px solid var(--border); border-radius:5px; color:var(--text-0); font-family:var(--mono); font-size:13px; padding:8px 10px; }
3105
- .authcard input.bad { border-color:var(--danger); }
3106
- .authcard button { background:var(--accent); color:var(--text-inv); border:0; border-radius:5px; font-family:var(--mono); font-size:12px; padding:0 14px; cursor:pointer; }
3107
- .authcard .err { color:var(--danger); font-family:var(--mono); font-size:11px; min-height:14px; margin-top:8px; }
3108
- .authcard .clear { position:absolute; top:14px; right:16px; font-size:11px; color:var(--text-2); cursor:pointer; }
3109
- .dim { opacity:.45; filter:grayscale(.4); transition:opacity .2s, filter .2s; }
3110
-
3111
- @keyframes blink { 50% { opacity:0; } }
3112
- @keyframes scan { 0%{ transform:translateX(-100%);} 100%{ transform:translateX(350%);} }
3113
- @keyframes hb { 0%{ transform:scale(1);} 35%{ transform:scale(1.7);} 100%{ transform:scale(1);} }
3114
- @keyframes flash { from{ background:var(--flash);} to{ background:transparent;} }
3115
- @keyframes flashup { from{ background:var(--flash-up);} to{ background:transparent;} }
3116
- @keyframes flashdown { from{ background:var(--flash-down);} to{ background:transparent;} }
3117
- @media (prefers-reduced-motion: reduce) {
3118
- .caret { animation:none; } #scanbar::after { animation:none; opacity:.3; }
3119
- .heartbeat { animation:none; }
3120
- .flash, .flash-up, .flash-down { animation:none; box-shadow: inset 2px 0 0 var(--accent); }
3121
- }
3122
- </style>
3123
- </head>
3124
- <body>
3125
- <header class="bar" id="bar">
3126
- <div class="bar-in">
3127
- <span class="wordmark">hoopilot<span class="caret" aria-hidden="true"></span></span>
3128
- <span class="chip" id="version-chip">v&middot;&middot;&middot;</span>
3129
- <span class="chip plan-offline" id="plan-chip">&mdash; offline</span>
3130
- <span class="spacer"></span>
3131
- <span class="pill" id="conn-pill" aria-live="polite"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting</span></span>
3132
- <span class="updated" id="updated"></span>
3133
- <span class="seg" id="seg" role="group" aria-label="Refresh interval">
3134
- <button data-ms="2000">2s</button><button data-ms="4000" class="active">4s</button><button data-ms="10000">10s</button>
3135
- </span>
3136
- <button class="iconbtn" id="btn-pause" title="Pause / resume" aria-label="Pause or resume">&#10074;&#10074;</button>
3137
- <button class="iconbtn" id="btn-theme" title="Theme: auto / dark / light" aria-label="Cycle theme">A</button>
3138
- </div>
3139
- <div id="scanbar" aria-hidden="true"></div>
3140
- </header>
3141
-
3142
- <div class="shell">
3143
- <div id="banner" role="status" aria-live="polite"></div>
3144
-
3145
- <section id="content">
3146
- <section class="hero" aria-label="Vitals">
3147
- <div class="vital" id="v-req"><div class="eyebrow">Req / s</div><div class="vnum skel" id="req-num">&middot;&middot;&middot;</div><div class="vsub" id="req-sub"></div><svg class="vspark" id="req-spark" viewBox="0 0 200 24" preserveAspectRatio="none" aria-hidden="true"><path class="area" fill="var(--spark-fill)" stroke="none"/><path class="line" fill="none" stroke="var(--ok)" stroke-width="1.5" vector-effect="non-scaling-stroke"/><circle r="1.6" fill="var(--ok)" style="display:none"/></svg></div>
3148
- <div class="vital" id="v-tok"><div class="eyebrow">Tokens / s</div><div class="vnum skel" id="tok-num">&middot;&middot;&middot;</div><div class="vsub" id="tok-sub"></div><svg class="vspark" id="tok-spark" viewBox="0 0 200 24" preserveAspectRatio="none" aria-hidden="true"><path class="area" fill="var(--spark-fill)" stroke="none"/><path class="line" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/><circle r="1.6" fill="var(--accent)" style="display:none"/></svg></div>
3149
- <div class="vital" id="v-inflight"><div class="eyebrow">In&#8209;flight</div><div class="vnum skel" id="inflight-num">&middot;&middot;&middot;</div><div class="vsub" id="inflight-sub"></div><svg class="vspark" id="inflight-spark" viewBox="0 0 200 24" preserveAspectRatio="none" aria-hidden="true"><path class="area" fill="var(--spark-fill)" stroke="none"/><path class="line" fill="none" stroke="var(--accent-2)" stroke-width="1.5" vector-effect="non-scaling-stroke"/><circle r="1.6" fill="var(--accent-2)" style="display:none"/></svg></div>
3150
- <div class="vital" id="v-uptime"><div class="eyebrow">Uptime</div><div class="vnum skel" id="uptime-num">&middot;&middot;&middot;</div><div class="vsub" id="uptime-sub"></div></div>
3151
- </section>
3152
-
3153
- <section class="grid">
3154
- <div class="panel span5"><span class="ptitle">&#9508; Proxy &middot; requests &#9504;</span>
3155
- <div class="headline"><span id="req-total" class="skel">&middot;&middot;&middot;</span> <span class="cap">requests</span></div>
3156
- <div class="stack-bar empty" id="route-sharebar"></div>
3157
- <div class="stack-bar empty" id="status-healthbar"></div>
3158
- <div class="scrollx"><table class="tbl"><thead><tr><th class="l">Route</th><th>Count</th><th>%</th><th style="width:60px">&nbsp;</th></tr></thead><tbody id="routes-body"><tr class="ghost"><td colspan="4">loading&hellip;</td></tr></tbody></table></div>
3159
- </div>
3160
-
3161
- <div class="panel span3"><span class="ptitle">&#9508; Status &#9504;</span>
3162
- <div class="headline"><span id="error-rate" class="skel">&middot;&middot;&middot;</span> <span class="cap">err rate</span></div>
3163
- <div class="stack-bar empty" id="status-bar"></div>
3164
- <div class="legend" id="status-legend"></div>
3165
- </div>
3166
-
3167
- <div class="panel span4"><span class="ptitle">&#9508; Latency &middot; ms &#9504;</span>
3168
- <div class="lat-trio">
3169
- <div class="b"><small>p50</small><span id="lat-p50" class="skel">&middot;</span></div>
3170
- <div class="b lat-p95"><small>p95</small><span id="lat-p95" class="skel">&middot;</span></div>
3171
- <div class="b"><small>avg</small><span id="lat-avg" class="skel">&middot;</span></div>
3172
- <div class="b"><small>obs</small><span id="lat-count" class="skel">&middot;</span></div>
3173
- </div>
3174
- <div class="lat-track" id="lat-track"><div class="line"></div></div>
3175
- <details class="routes"><summary>by route</summary><div class="scrollx"><table class="tbl"><thead><tr><th class="l">Route</th><th>avg ms</th><th>count</th></tr></thead><tbody id="lat-routes"></tbody></table></div></details>
3176
- </div>
3177
-
3178
- <div class="panel span7"><span class="ptitle">&#9508; Tokens &middot; by model &#9504;</span>
3179
- <div class="headline"><span id="tok-total" class="skel">&middot;&middot;&middot;</span> <span class="cap">tokens &middot; <span id="tok-cache">cache &middot;%</span></span></div>
3180
- <div class="stack-bar empty" id="tok-mixbar"></div>
3181
- <div class="legend" id="tok-legend"></div>
3182
- <div class="scrollx" style="margin-top:8px"><table class="tbl"><thead><tr><th class="l">Model</th><th>prompt</th><th>compl</th><th>reason</th><th>cached</th><th>total</th><th>reqs</th></tr></thead><tbody id="tok-body"><tr class="ghost"><td colspan="7">no token usage yet</td></tr></tbody></table></div>
3183
- </div>
3184
-
3185
- <div class="panel span5"><span class="ptitle">&#9508; Copilot &middot; quota &#9504;</span>
3186
- <div id="copilot-body"><div class="emptybox skel">loading&hellip;</div></div>
3187
- </div>
3188
-
3189
- <div class="panel span4"><span class="ptitle">&#9508; Upstream &middot; copilot edge &#9504;</span>
3190
- <div class="upblocks">
3191
- <div class="upblk"><div class="v" id="up-total">&middot;</div><div class="k">calls</div></div>
3192
- <div class="upblk err" id="up-errblk"><div class="v" id="up-errors">&middot;</div><div class="k">errors</div></div>
3193
- <div class="upblk"><div class="v rate" id="up-rate">&middot;</div><div class="k">err rate</div></div>
3194
- </div>
3195
- <svg id="up-spark" viewBox="0 0 320 30" preserveAspectRatio="none" aria-hidden="true"><path class="area" fill="var(--spark-fill)" stroke="none"/><path class="line" fill="none" stroke="var(--danger)" stroke-width="1.5" vector-effect="non-scaling-stroke"/></svg>
3196
- <div class="flag" id="up-flag"></div>
3197
- </div>
3198
-
3199
- <div class="panel span8"><span class="ptitle">&#9508; Throughput &#9504;</span>
3200
- <div class="cap"><span style="color:var(--accent)">&#9632;</span> tokens/s <span id="thru-tok" class="num"></span> &nbsp; <span style="color:var(--accent-2)">&#9632;</span> req/s <span id="thru-req" class="num"></span> <span class="end" id="thru-peak" style="float:right"></span></div>
3201
- <svg id="thru-svg" viewBox="0 0 320 88" preserveAspectRatio="none" aria-hidden="true">
3202
- <defs><linearGradient id="thrugrad" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="var(--accent)" stop-opacity="0.28"/><stop offset="100%" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs>
3203
- <line class="grid" x1="0" y1="22" x2="320" y2="22" stroke="var(--grid-line)"/>
3204
- <line class="grid" x1="0" y1="44" x2="320" y2="44" stroke="var(--grid-line)"/>
3205
- <line class="grid" x1="0" y1="66" x2="320" y2="66" stroke="var(--grid-line)"/>
3206
- <path id="thru-tok-area" fill="url(#thrugrad)" stroke="none"/>
3207
- <path id="thru-tok-line" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>
3208
- <path id="thru-req-line" fill="none" stroke="var(--accent-2)" stroke-width="1.2" vector-effect="non-scaling-stroke" opacity="0.9"/>
3209
- </svg>
3210
- </div>
3211
- </section>
3212
- </section>
3213
-
3214
- <section id="auth" aria-live="polite">
3215
- <div class="authcard">
3216
- <span class="clear" id="auth-clear" style="display:none">clear key</span>
3217
- <h3>&#9508; Auth required &#9504;</h3>
3218
- <p>This hoopilot proxy requires an API key. It is stored locally in your browser and sent as <span class="mono">x-api-key</span>.</p>
3219
- <div class="row"><input id="auth-input" type="password" placeholder="x-api-key" autocomplete="off" spellcheck="false" /><button id="auth-connect">connect</button></div>
3220
- <div class="err" id="auth-err"></div>
3221
- </div>
3222
- </section>
3223
-
3224
- <footer class="foot">
3225
- <span id="foot-started">started &middot;</span>
3226
- <span id="foot-uptime">uptime &middot;</span>
3227
- <span id="foot-total">&middot; req</span>
3228
- <span id="foot-tokens">&middot; tokens</span>
3229
- <span id="foot-upstream">upstream &middot;</span>
3230
- <span class="end" id="foot-cadence"></span>
3231
- </footer>
3232
- </div>
3233
-
3234
- <script>
3235
- (function(){
3236
- "use strict";
3237
- var byId = function(id){ return document.getElementById(id); };
3238
- var CAP = 60;
3239
-
3240
- // ---- persistent state ----
3241
- var LS = window.localStorage;
3242
- var apiKey = "";
3243
- try { apiKey = LS.getItem("hoopilot.apiKey") || ""; } catch (e) { apiKey = ""; }
3244
- var theme = "auto";
3245
- try { theme = LS.getItem("hoopilot.theme") || "auto"; } catch (e) { theme = "auto"; }
3246
- var intervalMs = 4000;
3247
- try { var sv = parseInt(LS.getItem("hoopilot.intervalMs") || "", 10); if (sv === 2000 || sv === 4000 || sv === 10000) intervalMs = sv; } catch (e) {}
3248
-
3249
- // ---- runtime state ----
3250
- var paused = false;
3251
- var timer = null;
3252
- var inflightFetch = null;
3253
- var lastSuccessAt = 0;
3254
- var prevSample = null; // { t, reqTotal, tokTotal, upTotal, startedAt }
3255
- var lastRender = {}; // for change-flash
3256
- var backoffMs = 0;
3257
- var lastUptime = null; // seconds; ticked locally between polls
3258
- var hist = { req:[], tok:[], inflight:[], up:[] };
3259
-
3260
- // ---- formatting helpers ----
3261
- function humanInt(n){
3262
- if (n === null || n === undefined || !isFinite(n)) return "0";
3263
- var a = Math.abs(n);
3264
- if (a >= 1000000) return (n/1000000).toFixed(a >= 10000000 ? 0 : 1) + "M";
3265
- if (a >= 1000) return (n/1000).toFixed(a >= 10000 ? 0 : 1) + "k";
3266
- return String(Math.round(n));
3267
- }
3268
- function rate(n){
3269
- if (n === null || n === undefined || !isFinite(n)) return "0";
3270
- if (n >= 100) return String(Math.round(n));
3271
- if (n >= 10) return n.toFixed(1);
3272
- return n.toFixed(2);
3273
- }
3274
- function pct(n){ if (!isFinite(n)) return "0%"; return (n >= 10 ? Math.round(n) : Math.round(n*10)/10) + "%"; }
3275
- function fmtMs(n){ if (n === null || n === undefined || !isFinite(n) || n <= 0) return "0"; if (n >= 1000) return (n/1000).toFixed(2) + "s"; if (n >= 100) return String(Math.round(n)); return Math.round(n*10)/10 + ""; }
3276
- function pad2(n){ return (n < 10 ? "0" : "") + n; }
3277
- function fmtUptime(sec){
3278
- sec = Math.max(0, Math.floor(sec));
3279
- var d = Math.floor(sec/86400); sec -= d*86400;
3280
- var h = Math.floor(sec/3600); sec -= h*3600;
3281
- var m = Math.floor(sec/60); var s = sec - m*60;
3282
- if (d > 0) return d + "d " + pad2(h) + ":" + pad2(m);
3283
- if (h > 0) return h + ":" + pad2(m) + ":" + pad2(s);
3284
- return m + ":" + pad2(s);
3285
- }
3286
- function titleize(key){
3287
- var map = { premium_interactions:"Premium requests", chat:"Chat", completions:"Completions", code_review:"Code review" };
3288
- if (map[key]) return map[key];
3289
- return key.split("_").map(function(w){ return w ? w.charAt(0).toUpperCase() + w.slice(1) : w; }).join(" ");
3290
- }
3291
- function relTime(iso){
3292
- var t = Date.parse(iso); if (!isFinite(t)) return iso || "";
3293
- var s = Math.max(0, Math.round((Date.now() - t)/1000));
3294
- return fmtUptime(s) + " ago";
3295
- }
3296
- function clearEl(el){ while (el && el.firstChild) el.removeChild(el.firstChild); }
3297
- function mk(tag, cls, txt){ var e = document.createElement(tag); if (cls) e.className = cls; if (txt !== undefined && txt !== null) e.textContent = txt; return e; }
3298
-
3299
- // Set numeric text and flash on discrete change.
3300
- function setNum(id, value, kind){
3301
- var el = byId(id); if (!el) return;
3302
- el.classList.remove("skel");
3303
- var s = String(value);
3304
- if (el.textContent !== s){
3305
- el.textContent = s;
3306
- var prev = lastRender[id];
3307
- if (prev !== undefined){
3308
- var cls = "flash";
3309
- if (kind === "delta" && typeof value === "number" && typeof prev === "number"){
3310
- cls = value > prev ? "flash-up" : (value < prev ? "flash-down" : null);
3311
- }
3312
- if (cls){ el.classList.remove("flash","flash-up","flash-down"); void el.offsetWidth; el.classList.add(cls); }
3313
- }
3314
- lastRender[id] = value;
3315
- }
3316
- }
3317
- function setText(id, s){ var el = byId(id); if (el){ el.classList.remove("skel"); el.textContent = s; } }
3318
-
3319
- // ---- sparkline rendering ----
3320
- function pushHist(arr, v){ arr.push(v); if (arr.length > CAP) arr.shift(); }
3321
- function buildSpark(values, w, h){
3322
- var pts = []; for (var i=0;i<values.length;i++){ if (isFinite(values[i])) pts.push({ i:i, v:values[i] }); }
3323
- if (pts.length < 2) return null;
3324
- var min = Infinity, max = -Infinity;
3325
- for (var j=0;j<values.length;j++){ var v = values[j]; if (isFinite(v)){ if (v<min) min=v; if (v>max) max=v; } }
3326
- var flat = (max - min) <= 0;
3327
- var pad = flat ? 1 : (max - min) * 0.05; var lo = min - pad, hi = max + pad; var span = hi - lo; if (span <= 0) span = 1;
3328
- var n = values.length;
3329
- var line = "", lastX = 0, lastY = 0, started = false;
3330
- for (var k=0;k<n;k++){
3331
- var val = values[k]; if (!isFinite(val)) continue;
3332
- var x = (n === 1) ? w : (k * (w/(n-1)));
3333
- var norm = flat ? 0.5 : (val - lo)/span;
3334
- var y = h - norm*(h-2) - 1;
3335
- line += (started ? " L" : "M") + x.toFixed(2) + "," + y.toFixed(2);
3336
- lastX = x; lastY = y; started = true;
3337
- }
3338
- var area = line + " L" + lastX.toFixed(2) + "," + h + " L0," + h + " Z";
3339
- return { line:line, area:area, lastX:lastX, lastY:lastY };
3340
- }
3341
- function drawSpark(svgId, values){
3342
- var svg = byId(svgId); if (!svg) return;
3343
- var vb = svg.viewBox.baseVal; var w = vb.width || 200, h = vb.height || 24;
3344
- var sp = buildSpark(values, w, h);
3345
- var line = svg.querySelector(".line"), area = svg.querySelector(".area"), dot = svg.querySelector("circle");
3346
- if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); if (dot) dot.style.display = "none"; return; }
3347
- if (line) line.setAttribute("d", sp.line);
3348
- if (area) area.setAttribute("d", sp.area);
3349
- if (dot){ dot.setAttribute("cx", sp.lastX.toFixed(2)); dot.setAttribute("cy", sp.lastY.toFixed(2)); dot.style.display = ""; }
3350
- }
3351
-
3352
- // ---- theme ----
3353
- function applyTheme(){
3354
- var root = document.documentElement;
3355
- if (theme === "dark") root.setAttribute("data-theme","dark");
3356
- else if (theme === "light") root.setAttribute("data-theme","light");
3357
- else root.removeAttribute("data-theme");
3358
- byId("btn-theme").textContent = theme === "dark" ? "D" : (theme === "light" ? "L" : "A");
3359
- }
3360
- byId("btn-theme").addEventListener("click", function(){
3361
- theme = theme === "auto" ? "dark" : (theme === "dark" ? "light" : "auto");
3362
- try { LS.setItem("hoopilot.theme", theme); } catch (e) {}
3363
- applyTheme();
3364
- });
3365
-
3366
- // ---- interval + pause ----
3367
- function setActiveSeg(){
3368
- var btns = byId("seg").querySelectorAll("button");
3369
- for (var i=0;i<btns.length;i++){ btns[i].classList.toggle("active", parseInt(btns[i].getAttribute("data-ms"),10) === intervalMs); }
3370
- document.documentElement.style.setProperty("--scan-ms", intervalMs + "ms");
3371
- }
3372
- byId("seg").addEventListener("click", function(ev){
3373
- var b = ev.target.closest ? ev.target.closest("button") : null; if (!b) return;
3374
- intervalMs = parseInt(b.getAttribute("data-ms"),10) || 4000;
3375
- try { LS.setItem("hoopilot.intervalMs", String(intervalMs)); } catch (e) {}
3376
- setActiveSeg();
3377
- if (!paused){ schedule(0); }
3378
- });
3379
- byId("btn-pause").addEventListener("click", function(){
3380
- paused = !paused;
3381
- byId("btn-pause").innerHTML = paused ? "&#9654;" : "&#10074;&#10074;";
3382
- byId("bar").classList.toggle("paused", paused);
3383
- if (paused){ if (timer){ clearTimeout(timer); timer = null; } setPill("paused","PAUSED",false); }
3384
- else { setPill("live","LIVE",false); schedule(0); }
3385
- });
3386
-
3387
- // ---- connection pill / banner ----
3388
- function setPill(kind, text, beat){
3389
- var pill = byId("conn-pill"); var dot = byId("conn-dot");
3390
- pill.className = "pill " + kind;
3391
- byId("conn-text").textContent = text;
3392
- if (beat && dot){ dot.classList.remove("heartbeat"); void dot.offsetWidth; dot.classList.add("heartbeat"); }
3393
- }
3394
- function showBanner(text, ok){
3395
- var b = byId("banner"); b.textContent = text; b.className = "banner show" + (ok ? " ok" : ""); b.classList.add("show");
3396
- if (ok){ setTimeout(function(){ b.classList.remove("show"); }, 2000); }
3397
- }
3398
- function hideBanner(){ byId("banner").classList.remove("show"); }
3399
- function setDimmed(on){ byId("content").classList.toggle("dim", on); }
3400
-
3401
- // ---- auth takeover ----
3402
- function showAuth(rejected){
3403
- byId("content").style.display = "none";
3404
- byId("auth").classList.add("show");
3405
- setPill("authkey","API KEY",false);
3406
- byId("auth-err").textContent = rejected ? "key rejected" : "";
3407
- byId("auth-input").classList.toggle("bad", !!rejected);
3408
- byId("auth-clear").style.display = apiKey ? "" : "none";
3409
- byId("auth-input").focus();
3410
- }
3411
- function hideAuth(){ byId("auth").classList.remove("show"); byId("content").style.display = ""; }
3412
- byId("auth-connect").addEventListener("click", function(){
3413
- var v = byId("auth-input").value.trim(); if (!v) return;
3414
- apiKey = v; try { LS.setItem("hoopilot.apiKey", apiKey); } catch (e) {}
3415
- hideAuth(); schedule(0);
3416
- });
3417
- byId("auth-input").addEventListener("keydown", function(ev){ if (ev.key === "Enter") byId("auth-connect").click(); });
3418
- byId("auth-clear").addEventListener("click", function(){
3419
- apiKey = ""; try { LS.removeItem("hoopilot.apiKey"); } catch (e) {}
3420
- byId("auth-input").value = ""; byId("auth-clear").style.display = "none"; byId("auth-input").focus();
3421
- });
3422
-
3423
- // ---- the poll loop (setTimeout-chained, never setInterval) ----
3424
- var pollGen = 0;
3425
- function schedule(delay){
3426
- if (timer){ clearTimeout(timer); }
3427
- if (paused) return;
3428
- timer = setTimeout(poll, delay === undefined ? intervalMs : delay);
3429
- }
3430
- function poll(){
3431
- if (paused) return;
3432
- // A new poll supersedes any in-flight one. Bump the generation so the old
3433
- // request's settled handlers (including its abort rejection) become no-ops
3434
- // and never flash a false "disconnected".
3435
- pollGen += 1; var myGen = pollGen;
3436
- if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
3437
- var ctrl = new AbortController(); inflightFetch = ctrl;
3438
- var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, 3000);
3439
- var headers = { "accept":"application/json" };
3440
- if (apiKey) headers["x-api-key"] = apiKey;
3441
- fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
3442
- clearTimeout(to);
3443
- if (myGen !== pollGen) return null;
3444
- if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
3445
- if (!res.ok) throw new Error("HTTP " + res.status);
3446
- return res.json();
3447
- }).then(function(data){
3448
- if (myGen !== pollGen || data === null || paused) return;
3449
- inflightFetch = null;
3450
- onData(data);
3451
- backoffMs = 0; lastSuccessAt = Date.now();
3452
- hideAuth(); setDimmed(false); hideBanner();
3453
- setPill("live","LIVE",true);
3454
- byId("bar").classList.remove("frozen");
3455
- schedule(intervalMs);
3456
- }).catch(function(err){
3457
- clearTimeout(to);
3458
- if (myGen !== pollGen || paused) return;
3459
- inflightFetch = null;
3460
- onDisconnect(err);
3461
- });
3462
- }
3463
- function onDisconnect(err){
3464
- setPill("reconnect","RECONNECTING",false);
3465
- setDimmed(true);
3466
- byId("bar").classList.add("frozen");
3467
- backoffMs = backoffMs ? Math.min(Math.round(backoffMs * 1.5), 30000) : intervalMs;
3468
- showBanner("Disconnected (" + (err && err.message ? err.message : "no response") + ") \\u2014 retrying in " + Math.round(backoffMs/1000) + "s", false);
3469
- schedule(backoffMs);
3470
- }
3471
-
3472
- // ---- main render ----
3473
- function onData(usage){
3474
- var proxy = usage.proxy || {};
3475
- var now = Date.now();
3476
-
3477
- setText("version-chip", "v" + (usage.version || "?"));
3478
-
3479
- // rates
3480
- var reqTotal = (proxy.requests && proxy.requests.total) || 0;
3481
- var tokTotal = (proxy.tokens && proxy.tokens.total) || 0;
3482
- var upTotal = (proxy.upstream && proxy.upstream.total) || 0;
3483
- var startedAt = proxy.startedAt || "";
3484
- var reqPerSec = NaN, tokPerSec = NaN, upDelta = 0, restarted = false;
3485
- if (prevSample){
3486
- var dt = (now - prevSample.t)/1000;
3487
- if (prevSample.startedAt && startedAt && prevSample.startedAt !== startedAt) restarted = true;
3488
- if (reqTotal < prevSample.reqTotal || tokTotal < prevSample.tokTotal) restarted = true;
3489
- if (restarted){ reqPerSec = 0; tokPerSec = 0; upDelta = 0; }
3490
- else if (dt > 0 && isFinite(dt)){
3491
- reqPerSec = Math.max(0, (reqTotal - prevSample.reqTotal)/dt);
3492
- tokPerSec = Math.max(0, (tokTotal - prevSample.tokTotal)/dt);
3493
- upDelta = Math.max(0, upTotal - prevSample.upTotal);
3494
- }
3495
- }
3496
- prevSample = { t:now, reqTotal:reqTotal, tokTotal:tokTotal, upTotal:upTotal, startedAt:startedAt };
3497
-
3498
- // hero vitals
3499
- if (isFinite(reqPerSec)){ pushHist(hist.req, reqPerSec); setNum("req-num", rate(reqPerSec)); } else setText("req-num","\\u2014");
3500
- if (isFinite(tokPerSec)){ pushHist(hist.tok, tokPerSec); setNum("tok-num", humanInt(tokPerSec)); } else setText("tok-num","\\u2014");
3501
- var inflight = proxy.inFlight || 0;
3502
- pushHist(hist.inflight, inflight); setNum("inflight-num", String(inflight), "delta");
3503
- byId("v-inflight").classList.toggle("active", inflight > 0);
3504
- setText("uptime-num", fmtUptime(proxy.uptimeSeconds || 0));
3505
-
3506
- setText("req-sub", hist.req.length ? ("avg " + rate(avg(hist.req)) + "/s") : "warming up");
3507
- setText("tok-sub", hist.tok.length ? ("peak " + humanInt(Math.max.apply(null, hist.tok)) + "/s") : "warming up");
3508
- setText("inflight-sub", inflight + " now");
3509
- setText("uptime-sub", startedAt ? ("since " + relTime(startedAt)) : "");
3510
-
3511
- drawSpark("req-spark", hist.req);
3512
- drawSpark("tok-spark", hist.tok);
3513
- drawSpark("inflight-spark", hist.inflight);
3514
-
3515
- renderRequests(proxy);
3516
- renderStatus(proxy);
3517
- renderLatency(proxy.latency || {});
3518
- renderTokens(proxy.tokens || {});
3519
- renderCopilot(usage);
3520
- renderUpstream(proxy.upstream || {}, upDelta, restarted);
3521
- renderThroughput();
3522
- renderFooter(usage, proxy);
3523
-
3524
- setNum("req-total", humanInt(reqTotal));
3525
- setNum("tok-total", humanInt(tokTotal));
3526
- lastUptime = proxy.uptimeSeconds || 0;
3527
- }
3528
-
3529
- function avg(arr){ if (!arr.length) return 0; var s = 0; for (var i=0;i<arr.length;i++) s += arr[i]; return s/arr.length; }
3530
-
3531
- var ROUTE_COLORS = ["var(--c1)","var(--c2)","var(--c3)","var(--c4)","var(--c5)","var(--c6)"];
3532
- function renderRequests(proxy){
3533
- var byRoute = (proxy.requests && proxy.requests.byRoute) || {};
3534
- var total = (proxy.requests && proxy.requests.total) || 0;
3535
- var rows = Object.keys(byRoute).map(function(k){ return { k:k, v:byRoute[k] }; }).sort(function(a,b){ return b.v - a.v; });
3536
- var share = byId("route-sharebar"); clearEl(share); share.className = "stack-bar" + (total ? "" : " empty");
3537
- var body = byId("routes-body"); clearEl(body);
3538
- if (!rows.length){ var tr = mk("tr","ghost"); var td = mk("td",null,"no requests yet"); td.colSpan = 4; tr.appendChild(td); body.appendChild(tr); return; }
3539
- rows.forEach(function(r, idx){
3540
- var p = total ? (r.v/total*100) : 0;
3541
- var seg = mk("i"); seg.style.width = p + "%"; seg.style.background = ROUTE_COLORS[idx % ROUTE_COLORS.length]; seg.title = r.k + " " + pct(p); share.appendChild(seg);
3542
- var tr = mk("tr");
3543
- var name = mk("td","l", r.k); name.title = r.k; tr.appendChild(name);
3544
- tr.appendChild(mk("td",null, humanInt(r.v)));
3545
- tr.appendChild(mk("td",null, pct(p)));
3546
- var btd = mk("td"); var bar = mk("span","minibar"); bar.style.width = Math.max(2, p) + "%"; bar.style.background = ROUTE_COLORS[idx % ROUTE_COLORS.length]; btd.appendChild(bar); tr.appendChild(btd);
3547
- body.appendChild(tr);
3548
- });
3549
- var tot = mk("tr","total"); tot.appendChild(mk("td","l","total")); tot.appendChild(mk("td",null, humanInt(total))); tot.appendChild(mk("td",null,"100%")); tot.appendChild(mk("td")); body.appendChild(tot);
3550
- }
3551
-
3552
- function statusClass(code){ var c = String(code).charAt(0); if (c === "2") return "ok"; if (c === "3") return "info"; if (c === "4") return "warn"; if (c === "5") return "danger"; return "muted"; }
3553
- function statusColor(cls){ return cls === "ok" ? "var(--ok)" : cls === "info" ? "var(--info)" : cls === "warn" ? "var(--warn)" : cls === "danger" ? "var(--danger)" : "var(--text-2)"; }
3554
- function renderStatus(proxy){
3555
- var byStatus = (proxy.requests && proxy.requests.byStatus) || {};
3556
- var total = 0, errs = 0; var groups = { ok:0, info:0, warn:0, danger:0, muted:0 };
3557
- var codes = Object.keys(byStatus).map(function(k){ return { k:k, v:byStatus[k] }; }).sort(function(a,b){ return b.v - a.v; });
3558
- codes.forEach(function(c){ total += c.v; var cls = statusClass(c.k); groups[cls] += c.v; if (cls === "warn" || cls === "danger") errs += c.v; });
3559
- var bar = byId("status-bar"); clearEl(bar); bar.className = "stack-bar" + (total ? "" : " empty");
3560
- ["ok","info","warn","danger","muted"].forEach(function(cls){ if (groups[cls] > 0){ var seg = mk("i"); seg.style.width = (groups[cls]/total*100) + "%"; seg.style.background = statusColor(cls); bar.appendChild(seg); } });
3561
- var leg = byId("status-legend"); clearEl(leg);
3562
- if (!codes.length){ leg.appendChild(mk("span","li","no requests yet")); }
3563
- codes.forEach(function(c){ var li = mk("span","li"); var sw = mk("span","sw"); sw.style.background = statusColor(statusClass(c.k)); li.appendChild(sw); li.appendChild(mk("span",null, c.k + " " + humanInt(c.v))); leg.appendChild(li); });
3564
- var er = total ? (errs/total*100) : 0;
3565
- setNum("error-rate", pct(er));
3566
- var el = byId("error-rate"); el.style.color = er > 5 ? "var(--danger)" : er > 1 ? "var(--warn)" : "var(--ok)";
3567
- }
3568
-
3569
- function renderLatency(lat){
3570
- setText("lat-p50", fmtMs(lat.p50Ms)); setText("lat-avg", fmtMs(lat.avgMs)); setText("lat-count", humanInt(lat.count || 0));
3571
- var p95 = byId("lat-p95"); p95.classList.remove("skel"); p95.textContent = fmtMs(lat.p95Ms);
3572
- p95.style.color = (lat.p50Ms > 0 && lat.p95Ms > 2*lat.p50Ms) ? "var(--warn)" : "var(--info)";
3573
- // track: position p50 and p95 across 0..(p95*1.15)
3574
- var track = byId("lat-track"); var old = track.querySelectorAll(".tick,.tlab"); for (var i=0;i<old.length;i++) old[i].remove();
3575
- var maxv = Math.max(lat.p95Ms || 0, lat.avgMs || 0, 1) * 1.15;
3576
- function place(v, cls){ if (!isFinite(v) || v <= 0) return; var x = Math.min(100, v/maxv*100); var t = mk("div","tick " + cls); t.style.left = x + "%"; track.appendChild(t); var lab = mk("div","tlab", fmtMs(v)); lab.style.left = x + "%"; track.appendChild(lab); }
3577
- place(lat.p50Ms, "p50"); place(lat.p95Ms, "p95");
3578
- var lr = byId("lat-routes"); clearEl(lr);
3579
- var byRoute = lat.byRoute || {}; var rows = Object.keys(byRoute).map(function(k){ return { k:k, v:byRoute[k] }; }).sort(function(a,b){ return (b.v.avgMs||0) - (a.v.avgMs||0); });
3580
- rows.forEach(function(r){ var tr = mk("tr"); var n = mk("td","l", r.k); n.title = r.k; tr.appendChild(n); tr.appendChild(mk("td",null, fmtMs(r.v.avgMs))); tr.appendChild(mk("td",null, humanInt(r.v.count||0))); lr.appendChild(tr); });
3581
- }
3582
-
3583
- function renderTokens(tok){
3584
- var prompt = tok.prompt||0, completion = tok.completion||0, reasoning = tok.reasoning||0, cached = tok.cached||0;
3585
- var sum = prompt + completion + reasoning;
3586
- var bar = byId("tok-mixbar"); clearEl(bar); bar.className = "stack-bar" + (sum ? "" : " empty");
3587
- var parts = [ ["prompt", prompt, "var(--text-1)"], ["completion", completion, "var(--accent)"], ["reasoning", reasoning, "var(--info)"] ];
3588
- parts.forEach(function(p){ if (sum && p[1] > 0){ var seg = mk("i"); seg.style.width = (p[1]/sum*100) + "%"; seg.style.background = p[2]; seg.title = p[0]; bar.appendChild(seg); } });
3589
- var leg = byId("tok-legend"); clearEl(leg);
3590
- var legParts = parts.concat([["cached", cached, "var(--cache)"]]);
3591
- legParts.forEach(function(p){ var li = mk("span","li"); var sw = mk("span","sw"); sw.style.background = p[2]; li.appendChild(sw); var den = (p[0] === "cached") ? prompt : sum; var sh = den ? " " + pct(p[1]/den*100) : ""; li.appendChild(mk("span",null, p[0] + " " + humanInt(p[1]) + sh)); leg.appendChild(li); });
3592
- var cacheRate = prompt ? (cached/prompt*100) : 0; setText("tok-cache", "cache " + pct(cacheRate));
3593
- var body = byId("tok-body"); clearEl(body);
3594
- var byModel = tok.byModel || {}; var rows = Object.keys(byModel).map(function(k){ return { k:k, v:byModel[k] }; }).sort(function(a,b){ return (b.v.total||0) - (a.v.total||0); });
3595
- if (!rows.length){ var tr = mk("tr","ghost"); var td = mk("td",null,"no token usage yet"); td.colSpan = 7; tr.appendChild(td); body.appendChild(tr); return; }
3596
- rows.forEach(function(r){ var m = r.v; var tr = mk("tr"); var n = mk("td","l", r.k); n.title = r.k; tr.appendChild(n);
3597
- tr.appendChild(mk("td",null, humanInt(m.prompt||0))); tr.appendChild(mk("td",null, humanInt(m.completion||0)));
3598
- tr.appendChild(mk("td","reasoning", humanInt(m.reasoning||0))); tr.appendChild(mk("td","cached", humanInt(m.cached||0)));
3599
- tr.appendChild(mk("td",null, humanInt(m.total||0))); tr.appendChild(mk("td",null, humanInt(m.requests||0))); body.appendChild(tr); });
3600
- }
3601
-
3602
- function planClass(plan){ if (!plan) return "plan-offline"; if (plan.indexOf("pro") >= 0) return "plan-pro"; if (plan.indexOf("business") >= 0 || plan.indexOf("enterprise") >= 0) return "plan-business"; return "plan-free"; }
3603
- function renderCopilot(usage){
3604
- var box = byId("copilot-body"); clearEl(box);
3605
- var cp = usage.copilot; var planChip = byId("plan-chip");
3606
- if (!cp){
3607
- planChip.className = "chip plan-offline"; planChip.textContent = "\\u2014 offline";
3608
- var eb = mk("div","emptybox"); eb.appendChild(mk("div","keyglyph","\\u26bf"));
3609
- eb.appendChild(mk("h4",null,"Copilot not connected"));
3610
- if (usage.copilot_error) eb.appendChild(mk("div","errline", usage.copilot_error));
3611
- eb.appendChild(mk("div","prompt","$ hoopilot login"));
3612
- box.appendChild(eb); return;
3613
- }
3614
- planChip.className = "chip " + planClass(cp.plan); planChip.textContent = cp.plan || "copilot";
3615
- var head = mk("div","cap");
3616
- var bits = [];
3617
- if (cp.accessTypeSku) bits.push(cp.accessTypeSku);
3618
- if (cp.chatEnabled !== undefined) bits.push(cp.chatEnabled ? "chat on" : "chat off");
3619
- if (cp.quotaResetDate) bits.push("resets " + cp.quotaResetDate);
3620
- head.textContent = bits.join(" \\u00b7 "); box.appendChild(head);
3621
- var quotas = cp.quotas || {}; var keys = Object.keys(quotas);
3622
- if (!keys.length){ box.appendChild(mk("div","cap","No metered quotas reported.")); return; }
3623
- var order = { premium_interactions:0, chat:1, completions:2 };
3624
- keys.sort(function(a,b){ var ra = order[a]===undefined?9:order[a], rb = order[b]===undefined?9:order[b]; return ra-rb || a.localeCompare(b); });
3625
- keys.forEach(function(k){
3626
- var q = quotas[k]; var row = mk("div","qrow");
3627
- var hd = mk("div","qhead"); hd.appendChild(mk("span","qname", titleize(k)));
3628
- if (q.unlimited){ hd.appendChild(mk("span","inf","\\u221e unlimited")); row.appendChild(hd); box.appendChild(row); return; }
3629
- var ent = q.entitlement, rem = q.remaining, used = q.used;
3630
- var usedPct = (q.percentRemaining !== undefined) ? (100 - q.percentRemaining) : ((ent && used !== undefined) ? (used/ent*100) : 0);
3631
- usedPct = Math.max(0, Math.min(100, usedPct));
3632
- var valTxt = (used !== undefined && ent !== undefined) ? (humanInt(used) + " / " + humanInt(ent)) : (rem !== undefined ? (humanInt(rem) + " left") : pct(100-usedPct) + " left");
3633
- hd.appendChild(mk("span","qval", valTxt)); row.appendChild(hd);
3634
- var bar = mk("div","qbar"); var fill = mk("i"); fill.style.width = usedPct + "%";
3635
- fill.style.background = usedPct > 85 ? "var(--danger)" : usedPct > 60 ? "var(--warn)" : "var(--ok)"; bar.appendChild(fill);
3636
- if (q.overageCount && q.overagePermitted){ bar.classList.add("over"); var ext = mk("i","ext"); ext.style.left = "100%"; ext.style.width = "8%"; bar.appendChild(ext); }
3637
- row.appendChild(bar);
3638
- if (q.overageCount){ var ov = mk("div","flag", humanInt(q.overageCount) + " overage" + (q.tokenBasedBilling ? " \\u00b7 token billing" : "")); row.appendChild(ov); }
3639
- box.appendChild(row);
3640
- });
3641
- }
3642
-
3643
- function renderUpstream(up, delta, restarted){
3644
- setNum("up-total", humanInt(up.total||0));
3645
- setNum("up-errors", humanInt(up.errors||0), "delta");
3646
- var er = up.total ? (up.errors/up.total*100) : 0;
3647
- var rt = byId("up-rate"); rt.textContent = pct(er); rt.className = "v rate " + (er > 5 ? "danger" : er > 1 ? "warn" : "ok");
3648
- byId("up-errblk").classList.toggle("hot", (up.errors||0) > 0);
3649
- pushHist(hist.up, delta||0); drawSpark("up-spark", hist.up);
3650
- byId("up-flag").textContent = restarted ? "\\u21bb restarted" : "";
3651
- }
3652
-
3653
- function renderThroughput(){
3654
- drawDual("thru-tok-line","thru-tok-area", hist.tok, true);
3655
- drawDual("thru-req-line", null, hist.req, false);
3656
- setText("thru-tok", hist.tok.length ? rate(hist.tok[hist.tok.length-1]) : "\\u2014");
3657
- setText("thru-req", hist.req.length ? rate(hist.req[hist.req.length-1]) : "\\u2014");
3658
- var peakTok = hist.tok.length ? Math.max.apply(null, hist.tok) : 0;
3659
- setText("thru-peak", "peak " + humanInt(peakTok) + " tok/s");
3660
- }
3661
- function drawDual(lineId, areaId, values, withArea){
3662
- var svg = byId("thru-svg"); var vb = svg.viewBox.baseVal; var w = vb.width, h = vb.height;
3663
- var sp = buildSpark(values, w, h);
3664
- var line = byId(lineId); var area = areaId ? byId(areaId) : null;
3665
- if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); return; }
3666
- if (line) line.setAttribute("d", sp.line);
3667
- if (area && withArea) area.setAttribute("d", sp.area);
3668
- }
3669
-
3670
- function renderFooter(usage, proxy){
3671
- setText("foot-started", proxy.startedAt ? ("started " + new Date(proxy.startedAt).toLocaleString()) : "started \\u2014");
3672
- setText("foot-uptime", "uptime " + fmtUptime(proxy.uptimeSeconds||0));
3673
- setText("foot-total", humanInt((proxy.requests && proxy.requests.total)||0) + " req");
3674
- setText("foot-tokens", humanInt((proxy.tokens && proxy.tokens.total)||0) + " tokens");
3675
- var up = proxy.upstream || {}; setText("foot-upstream", "upstream " + humanInt(up.total||0) + " / " + humanInt(up.errors||0) + " err");
3676
- setText("foot-cadence", "polling /v1/usage every " + Math.round(intervalMs/1000) + "s \\u00b7 GET /dashboard");
3677
- }
3678
-
3679
- // ---- 1s freshness + uptime ticker (independent of the poll loop) ----
3680
- setInterval(function(){
3681
- if (lastSuccessAt){
3682
- var ago = Math.round((Date.now() - lastSuccessAt)/1000);
3683
- var u = byId("updated"); u.textContent = "updated " + ago + "s ago";
3684
- // Staleness only matters while polling; a deliberate pause is not "stale".
3685
- u.className = "updated" + (paused ? "" : ago > intervalMs/1000*4 ? " danger" : ago > intervalMs/1000*2 ? " warn" : "");
3686
- }
3687
- // Tick uptime locally between polls so the seconds advance smoothly; each
3688
- // successful poll re-seeds lastUptime from the authoritative server value.
3689
- if (!paused && lastUptime !== null){
3690
- lastUptime += 1;
3691
- byId("uptime-num").textContent = fmtUptime(lastUptime);
3692
- var fu = byId("foot-uptime"); if (fu) fu.textContent = "uptime " + fmtUptime(lastUptime);
3693
- }
3694
- }, 1000);
3695
-
3696
- // ---- boot ----
3697
- applyTheme(); setActiveSeg();
3698
- setPill("","CONNECTING",false);
3699
- poll();
3700
- })();
3701
- </script>
3702
- </body>
3703
- </html>
3704
- `;
3705
-
3706
- // src/version.ts
3707
- var import_meta = {};
3708
- var BAKED_VERSION = typeof HOOPILOT_VERSION !== "undefined" ? HOOPILOT_VERSION : void 0;
3709
- var IS_STANDALONE_BINARY = BAKED_VERSION !== void 0;
3710
- var cachedVersion;
3711
- async function getVersion() {
3712
- if (cachedVersion !== void 0) {
3713
- return cachedVersion;
3714
- }
3715
- let resolved;
3716
- if (BAKED_VERSION) {
3717
- resolved = BAKED_VERSION;
3718
- } else {
3719
- try {
3720
- const manifest = await Bun.file(new URL("../package.json", import_meta.url)).json();
3721
- resolved = typeof manifest.version === "string" ? manifest.version : "0.0.0";
3722
- } catch {
3723
- resolved = "0.0.0";
3724
- }
3725
- }
3726
- cachedVersion = resolved;
3727
- return resolved;
3728
- }
3729
-
3730
- // src/server.ts
3731
- var DEFAULT_HOST = "127.0.0.1";
3732
- var DEFAULT_PORT = 4141;
3733
- var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
3734
- var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set(["local-key"]);
3735
- var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
3736
- var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
3737
- var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
3738
- var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
3739
- var REQUEST_TOO_LARGE_MESSAGE = `Request body must be ${MAX_REQUEST_BODY_BYTES} bytes or smaller.`;
3740
- var USAGE_CACHE_TTL_MS = 6e4;
3741
- var RequestBodyTooLargeError = class extends Error {
3742
- constructor() {
3743
- super(REQUEST_TOO_LARGE_MESSAGE);
3744
- this.name = "RequestBodyTooLargeError";
3745
- }
3746
- };
3747
- function createHoopilotHandler(options = {}) {
3748
- const client = new CopilotClient(options);
3749
- const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
3750
- const allowedOrigins = parseAllowedOrigins(options.env);
3751
- const logger = serverLogger(options);
3752
- const metrics = options.metrics ?? new MetricsRegistry();
3753
- const readUsage = createUsageReader(client, metrics);
3754
- const recordTokens = (model, usage) => metrics.recordTokens(model, usage);
3755
- const recordExtraction = (extracted) => metrics.recordTokenExtraction(extracted);
3756
- const streamingProxyMode = resolveStreamingProxyMode(options);
3757
- const bufferProxyBodies = shouldBufferProxyBodies(streamingProxyMode);
3758
- return async (request) => {
3759
- const startedAt = performance.now();
3760
- const url = new URL(request.url);
3761
- const apiPath = canonicalApiPath(url.pathname);
3762
- const requestId = requestIdFor(request);
3763
- const route = routeFor(request.method, apiPath);
3764
- const requestLogger = logger.child({
3765
- method: request.method,
3766
- path: url.pathname,
3767
- requestId,
3768
- route
3769
- });
3770
- metrics.startRequest();
3771
- const origin = request.headers.get("origin")?.trim() || void 0;
3772
- const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
3773
- const finish = (response) => finishResponse(response, {
3774
- corsOrigin,
3775
- logger: requestLogger,
3776
- method: request.method,
3777
- metrics,
3778
- requestId,
3779
- route,
3780
- startedAt,
3781
- closeConnection: bufferProxyBodies,
3782
- trackStreamingBody: !bufferProxyBodies
3783
- });
3784
- const browserOrigin = forbiddenBrowserOrigin(origin, request, allowedOrigins);
3785
- if (browserOrigin) {
3786
- requestLogger.warn(
3787
- { event: "http.request.forbidden_origin", origin: browserOrigin },
3788
- "blocked cross-origin browser request"
3789
- );
3790
- return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
3791
- }
3792
- if (request.method === "OPTIONS") {
3793
- return finish(new Response(null, { headers: corsHeaders() }));
3794
- }
3795
- if (request.method === "GET" && apiPath === "/dashboard") {
3796
- return finish(dashboardResponse());
3797
- }
3798
- if (!isAuthorized(request, apiKey)) {
3799
- requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
3800
- return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
3801
- }
3802
- try {
3803
- if (request.method === "GET" && (apiPath === "/" || apiPath === "/healthz")) {
3804
- return finish(jsonResponse({ name: "hoopilot", object: "health", status: "ok" }));
3805
- }
3806
- if (request.method === "GET" && apiPath === "/metrics") {
3807
- return finish(metricsResponse(metrics));
3808
- }
3809
- if (request.method === "GET" && apiPath === "/v1/usage") {
3810
- return finish(await handleUsage(metrics, readUsage, request.signal));
3811
- }
3812
- if (request.method === "GET" && apiPath === "/v1/responses") {
3813
- return finish(websocketUnsupportedResponse());
3814
- }
3815
- if (request.method === "GET" && apiPath === "/v1/models") {
3816
- return finish(await handleModels(client, metrics, request.signal, requestLogger));
3817
- }
3818
- if (request.method === "POST" && apiPath === "/v1/messages") {
3819
- return finish(
3820
- await handleAnthropicMessages(
3821
- client,
3822
- metrics,
3823
- recordTokens,
3824
- recordExtraction,
3825
- request,
3826
- requestLogger,
3827
- bufferProxyBodies
3828
- )
3829
- );
3830
- }
3831
- if (request.method === "POST" && apiPath === "/v1/messages/count_tokens") {
3832
- return finish(handleAnthropicCountTokens(await readJson(request)));
3833
- }
3834
- if (request.method === "POST" && apiPath === "/v1/chat/completions") {
3835
- return finish(
3836
- await handleChatCompletions(
3837
- client,
3838
- metrics,
3839
- recordTokens,
3840
- recordExtraction,
3841
- request,
3842
- requestLogger,
3843
- bufferProxyBodies
3844
- )
3845
- );
3846
- }
3847
- if (request.method === "POST" && apiPath === "/v1/completions") {
3848
- return finish(
3849
- await handleCompletions(
3850
- client,
3851
- metrics,
3852
- recordTokens,
3853
- recordExtraction,
3854
- request,
3855
- requestLogger,
3856
- bufferProxyBodies
3857
- )
3858
- );
3859
- }
3860
- if (request.method === "POST" && apiPath === "/v1/responses/compact") {
3861
- return finish(
3862
- await handleResponsesCompact(
3863
- client,
3864
- metrics,
3865
- recordTokens,
3866
- recordExtraction,
3867
- request,
3868
- requestLogger
3869
- )
3870
- );
3871
- }
3872
- if (request.method === "POST" && apiPath === "/v1/responses") {
3873
- return finish(
3874
- await handleResponses(
3875
- client,
3876
- metrics,
3877
- recordTokens,
3878
- recordExtraction,
3879
- request,
3880
- requestLogger,
3881
- bufferProxyBodies
3882
- )
3883
- );
3884
- }
3885
- return finish(jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`));
3886
- } catch (error) {
3887
- if (error instanceof CopilotAuthError) {
3888
- requestLogger.warn(
3889
- { err: errorDetails(error), event: "copilot.auth.missing" },
3890
- "copilot auth failed"
3891
- );
3892
- return finish(jsonError(401, "copilot_auth_error", error.message));
3893
- }
3894
- const message = errorMessage(error);
3895
- if (message === INVALID_JSON_MESSAGE || message === JSON_OBJECT_MESSAGE) {
3896
- requestLogger.warn(
3897
- { err: errorDetails(error), event: "http.request.failed" },
3898
- "request body was not usable json"
3899
- );
3900
- return finish(jsonError(400, "invalid_request_error", message));
3901
- } else if (error instanceof OpenAICompatibilityError || error instanceof AnthropicCompatibilityError) {
3902
- requestLogger.warn(
3903
- { err: errorDetails(error), event: "http.request.failed" },
3904
- "request body used unsupported compatibility fields"
3905
- );
3906
- return finish(jsonError(400, "invalid_request_error", message));
3907
- } else if (error instanceof RequestBodyTooLargeError) {
3908
- requestLogger.warn(
3909
- { err: errorDetails(error), event: "http.request.failed" },
3910
- "request body exceeded size limit"
3911
- );
3912
- return finish(jsonError(413, "request_too_large", message));
3913
- } else {
3914
- requestLogger.error(
3915
- { err: errorDetails(error), event: "http.request.failed" },
3916
- "request failed"
3917
- );
3918
- }
3919
- return finish(jsonError(500, "internal_error", message));
3920
- }
3921
- };
3922
- }
3923
- function startHoopilotServer(options = {}) {
3924
- const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
3925
- const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
3926
- const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
3927
- const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
3928
- if (!isLoopbackHost(host)) {
3929
- if (!apiKey && !allowUnauthenticated) {
3930
- throw new Error(
3931
- "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
3932
- );
3933
- }
3934
- if (apiKey && isWellKnownDemoApiKey(apiKey)) {
3935
- throw new Error(
3936
- "Refusing to listen on a non-loopback host with a well-known demo HOOPILOT_API_KEY. Set a strong, unique API key."
3937
- );
3938
- }
3939
- }
3940
- const server = Bun.serve({
3941
- fetch: createHoopilotHandler({
3942
- ...options,
3943
- apiKey,
3944
- host,
3945
- port
3946
- }),
3947
- hostname: host,
3948
- port
3949
- });
3950
- return {
3951
- server,
3952
- url: `http://${urlHost(host)}:${server.port}`
3953
- };
3954
- }
3955
- async function handleAnthropicMessages(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
3956
- const anthropicRequest = await readJson(request);
3957
- const responsesRequest = anthropicMessagesToResponsesRequest(anthropicRequest);
3958
- const upstream = await client.responses(JSON.stringify(responsesRequest), request.signal);
3959
- metrics.recordUpstream("/responses", upstream.ok);
3960
- if (!upstream.ok) {
3961
- return proxyError(upstream, logger);
3962
- }
3963
- logUpstreamSuccess(logger, "/responses", upstream.status);
3964
- const model = normalizeRequestedModel(responsesRequest.model);
3965
- if (isStreamingResponse(upstream) && upstream.body) {
3966
- if (bufferProxyBodies) {
3967
- const text = await upstream.text();
3968
- recordResponseTextUsage(text, true, model, recordTokens, recordExtraction);
3969
- return proxyResponse(
3970
- responseFromText(upstream, responsesSseTextToAnthropicSseText(text, { model }))
3971
- );
3972
- }
3973
- const observed = observeResponseUsage(
3974
- upstream,
3975
- model,
3976
- recordTokens,
3977
- request.signal,
3978
- recordExtraction
3979
- );
3980
- if (!observed.body) {
3981
- return proxyResponse(observed);
3982
- }
3983
- return proxyResponse(
3984
- new Response(responsesStreamToAnthropicStream(observed.body, { model }), {
3985
- headers: observed.headers,
3986
- status: observed.status,
3987
- statusText: observed.statusText
3988
- })
3989
- );
3990
- }
3991
- const body = asRecord(await upstream.json());
3992
- const usage = extractTokenUsage(body.usage);
3993
- if (usage) {
3994
- const responseModel = typeof body.model === "string" ? body.model.trim() : "";
3995
- recordTokens(responseModel || model, usage);
3996
- }
3997
- recordExtraction(usage !== void 0);
3998
- return jsonResponse(responsesResponseToAnthropicMessage(body, model));
3999
- }
4000
- function handleAnthropicCountTokens(body) {
4001
- return jsonResponse(estimateAnthropicMessageTokens(body));
4002
- }
4003
- async function handleModels(client, metrics, signal, logger) {
4004
- const upstream = await client.models(signal);
4005
- metrics.recordUpstream("/models", upstream.ok);
4006
- if (!upstream.ok) {
4007
- if (isUpstreamAuthStatus(upstream.status)) {
4008
- return proxyError(upstream, logger);
4009
- }
4010
- logger.warn(
4011
- {
4012
- event: "copilot.models.fallback",
4013
- upstreamPath: "/models",
4014
- upstreamStatus: upstream.status
4015
- },
4016
- "falling back to built-in model list"
4017
- );
4018
- return jsonResponse({ data: fallbackModels(), object: "list" });
4019
- }
4020
- logUpstreamSuccess(logger, "/models", upstream.status);
4021
- return jsonResponse(normalizeModelsResponse(await upstream.json()));
4022
- }
4023
- async function handleChatCompletions(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
4024
- const chatRequest = normalizeChatCompletionRequest(await readJson(request));
4025
- const upstream = await client.chatCompletions(chatRequest, request.signal);
4026
- metrics.recordUpstream("/chat/completions", upstream.ok);
4027
- if (!upstream.ok) {
4028
- return proxyError(upstream, logger);
4029
- }
4030
- logUpstreamSuccess(logger, "/chat/completions", upstream.status);
4031
- const model = normalizeRequestedModel(chatRequest.model);
4032
- return proxyResponse(
4033
- await responseWithObservedUsage(
4034
- upstream,
4035
- model,
4036
- recordTokens,
4037
- request.signal,
4038
- bufferProxyBodies,
4039
- recordExtraction
4040
- )
4041
- );
4042
- }
4043
- async function handleCompletions(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
4044
- const body = await readJson(request);
4045
- const upstream = await client.chatCompletions(
4046
- completionsRequestToChatCompletion(body),
4047
- request.signal
4048
- );
4049
- metrics.recordUpstream("/chat/completions", upstream.ok);
4050
- if (!upstream.ok) {
4051
- return proxyError(upstream, logger);
4052
- }
4053
- logUpstreamSuccess(logger, "/chat/completions", upstream.status);
4054
- const model = normalizeRequestedModel(body.model);
4055
- if (isStreamingResponse(upstream) && upstream.body) {
4056
- if (bufferProxyBodies) {
4057
- const upstreamText = await upstream.text();
4058
- recordResponseTextUsage(upstreamText, true, model, recordTokens, recordExtraction);
4059
- const text = completionSseTextFromChatSseText(upstreamText);
4060
- return proxyResponse(responseFromText(upstream, text));
4061
- }
4062
- return proxyResponse(
4063
- observeResponseUsage(
4064
- new Response(completionStreamFromChatStream(upstream.body), {
4065
- headers: upstream.headers,
4066
- status: upstream.status,
4067
- statusText: upstream.statusText
4068
- }),
4069
- model,
4070
- recordTokens,
4071
- request.signal,
4072
- recordExtraction
4073
- )
4074
- );
4075
- }
4076
- const completion = asRecord(await upstream.json());
4077
- const usage = extractTokenUsage(completion.usage);
4078
- if (usage) {
4079
- const responseModel = typeof completion.model === "string" ? completion.model.trim() : "";
4080
- recordTokens(responseModel || model, usage);
4081
- }
4082
- recordExtraction(usage !== void 0);
4083
- return jsonResponse(chatCompletionToCompletion(completion));
4084
- }
4085
- async function handleResponses(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
4086
- const body = await readJsonText(request);
4087
- const upstream = await client.responses(body, request.signal);
4088
- metrics.recordUpstream("/responses", upstream.ok);
4089
- if (!upstream.ok) {
4090
- return proxyError(upstream, logger);
4091
- }
4092
- logUpstreamSuccess(logger, "/responses", upstream.status);
4093
- const model = normalizeRequestedModel(asRecord(safeParseJson(body)).model);
4094
- return proxyResponse(
4095
- await responseWithObservedUsage(
4096
- upstream,
4097
- model,
4098
- recordTokens,
4099
- request.signal,
4100
- bufferProxyBodies,
4101
- recordExtraction
4102
- )
4103
- );
4104
- }
4105
- async function handleResponsesCompact(client, metrics, recordTokens, recordExtraction, request, logger) {
4106
- const body = await readJson(request);
4107
- const upstream = await client.responses(
4108
- JSON.stringify({ ...body, stream: false }),
4109
- request.signal
4110
- );
4111
- metrics.recordUpstream("/responses", upstream.ok);
4112
- if (!upstream.ok) {
4113
- return proxyError(upstream, logger);
4114
- }
4115
- logUpstreamSuccess(logger, "/responses", upstream.status);
4116
- const isSse = isStreamingResponse(upstream);
4117
- const text = await upstream.text();
4118
- recordResponseTextUsage(
4119
- text,
4120
- isSse,
4121
- normalizeRequestedModel(body.model),
4122
- recordTokens,
4123
- recordExtraction
4124
- );
4125
- return jsonResponse(responsesCompactionResult(text, isSse));
4126
- }
4127
- async function responseWithObservedUsage(response, fallbackModel, recordTokens, signal, bufferBody, recordExtraction) {
4128
- const isSse = isStreamingResponse(response);
4129
- if (bufferBody && response.body) {
4130
- const text = await response.text();
4131
- recordResponseTextUsage(text, isSse, fallbackModel, recordTokens, recordExtraction);
4132
- return responseFromText(response, text);
4133
- }
4134
- return observeResponseUsage(response, fallbackModel, recordTokens, signal, recordExtraction);
4135
- }
4136
- function responseFromText(source, text) {
4137
- return new Response(text, {
4138
- headers: source.headers,
4139
- status: source.status,
4140
- statusText: source.statusText
4141
- });
4142
- }
4143
- async function proxyError(upstream, logger) {
4144
- const text = await upstream.text();
4145
- if (isUpstreamAuthStatus(upstream.status)) {
4146
- logger.warn(
4147
- { event: "copilot.auth.rejected", upstreamStatus: upstream.status },
4148
- "copilot rejected credential or account access"
4149
- );
4150
- return jsonError(401, "copilot_auth_error", upstreamAuthMessage(text || upstream.statusText));
4151
- }
4152
- logger.warn(
4153
- { event: "copilot.request.failed", upstreamStatus: upstream.status },
4154
- "copilot upstream request failed"
4155
- );
4156
- return upstreamErrorResponse(upstream.status, text || upstream.statusText);
4157
- }
4158
- function proxyResponse(upstream) {
4159
- const headers = new Headers(upstream.headers);
4160
- headers.delete("content-encoding");
4161
- headers.delete("content-length");
4162
- headers.delete("transfer-encoding");
4163
- for (const [key, value] of Object.entries(corsHeaders())) {
4164
- headers.set(key, value);
4165
- }
4166
- return new Response(upstream.body, {
4167
- headers,
4168
- status: upstream.status,
4169
- statusText: upstream.statusText
4170
- });
4171
- }
4172
- async function readJson(request) {
4173
- const text = await readRequestText(request);
4174
- return parseJsonObject2(text);
4175
- }
4176
- function parseJsonObject2(text) {
4177
- let parsed;
4178
- try {
4179
- parsed = JSON.parse(text);
4180
- } catch {
4181
- throw new Error(INVALID_JSON_MESSAGE);
4182
- }
4183
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4184
- throw new Error(JSON_OBJECT_MESSAGE);
4185
- }
4186
- return parsed;
4187
- }
4188
- async function readJsonText(request) {
4189
- const text = await readRequestText(request);
4190
- parseJsonObject2(text);
4191
- return text;
4192
- }
4193
- async function readRequestText(request) {
4194
- const contentLength = request.headers.get("content-length");
4195
- if (contentLength) {
4196
- const declaredBytes = Number(contentLength);
4197
- if (Number.isFinite(declaredBytes) && declaredBytes > MAX_REQUEST_BODY_BYTES) {
4198
- throw new RequestBodyTooLargeError();
4199
- }
4200
- }
4201
- const body = request.body;
4202
- if (!body) {
4203
- return "";
4204
- }
4205
- const reader = body.getReader();
4206
- const decoder = new TextDecoder();
4207
- let bytes = 0;
4208
- let text = "";
4209
- try {
4210
- while (true) {
4211
- const { done, value } = await reader.read();
4212
- if (done) {
4213
- return `${text}${decoder.decode()}`;
4214
- }
4215
- bytes += value.byteLength;
4216
- if (bytes > MAX_REQUEST_BODY_BYTES) {
4217
- await reader.cancel().catch(() => {
4218
- });
4219
- throw new RequestBodyTooLargeError();
4220
- }
4221
- text += decoder.decode(value, { stream: true });
4222
- }
4223
- } finally {
4224
- reader.releaseLock();
4225
- }
4226
- }
4227
- function jsonResponse(body, status = 200) {
4228
- return new Response(JSON.stringify(body), {
4229
- headers: {
4230
- ...corsHeaders(),
4231
- "content-type": "application/json; charset=utf-8"
4232
- },
4233
- status
4234
- });
4235
- }
4236
- function jsonError(status, code, message) {
4237
- return jsonResponse(
4238
- {
4239
- error: {
4240
- code,
4241
- message,
4242
- type: code
4243
- }
4244
- },
4245
- status
4246
- );
4247
- }
4248
- function upstreamErrorResponse(status, text) {
4249
- const parsedError = asRecord(asRecord(safeParseJson(text)).error);
4250
- if (Object.keys(parsedError).length > 0) {
4251
- return jsonResponse({ error: parsedError }, status);
4252
- }
4253
- return jsonError(status, "copilot_error", text);
4254
- }
4255
- function websocketUnsupportedResponse() {
4256
- const response = jsonError(
4257
- 426,
4258
- "websocket_not_supported",
4259
- "Hoopilot does not support Responses WebSocket transport; retry with HTTP Responses API."
4260
- );
4261
- response.headers.set("upgrade", "websocket");
4262
- return response;
4263
- }
4264
- function corsHeaders() {
4265
- return {
4266
- "access-control-allow-headers": "anthropic-beta, anthropic-dangerous-direct-browser-access, anthropic-version, authorization, content-type, x-api-key, x-request-id",
4267
- "access-control-allow-methods": "GET, POST, OPTIONS",
4268
- "access-control-expose-headers": "x-request-id"
4269
- };
4270
- }
4271
- function isAuthorized(request, apiKey) {
4272
- if (!apiKey) {
4273
- return true;
4274
- }
4275
- const authorization = request.headers.get("authorization") ?? "";
4276
- const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
4277
- return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
4278
- }
4279
- function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
4280
- if (origin) {
4281
- return isAllowedOrigin(origin, allowedOrigins) ? void 0 : origin;
4282
- }
4283
- const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
4284
- return fetchSite === "cross-site" ? "cross-site" : void 0;
4285
- }
4286
- function parseAllowedOrigins(env) {
4287
- const raw = envValue(env?.HOOPILOT_ALLOWED_ORIGINS);
4288
- if (!raw) {
4289
- return /* @__PURE__ */ new Set();
4290
- }
4291
- return new Set(
4292
- raw.split(",").map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)
4293
- );
4294
- }
4295
- function isAllowedOrigin(origin, allowedOrigins) {
4296
- return isLoopbackOrigin(origin) || allowedOrigins.has(origin.toLowerCase());
4297
- }
4298
- function resolveCorsAllowOrigin(origin, allowedOrigins) {
4299
- if (!origin) {
4300
- return "*";
4301
- }
4302
- return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
4303
- }
4304
- function isWellKnownDemoApiKey(apiKey) {
4305
- return WELL_KNOWN_DEMO_API_KEYS.has(apiKey.trim().toLowerCase());
4306
- }
4307
- function isUpstreamAuthStatus(status) {
4308
- return status === 401 || status === 403;
4309
- }
4310
- function upstreamAuthMessage(message) {
4311
- return `GitHub Copilot rejected the credential or account access: ${message}`;
4312
- }
4313
- function isLoopbackHost(host) {
4314
- return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
4315
- }
4316
- function urlHost(host) {
4317
- return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
4318
- }
4319
- function isLoopbackOrigin(origin) {
4320
- try {
4321
- return isLoopbackHost(new URL(origin).hostname.toLowerCase());
4322
- } catch {
4323
- return false;
4324
- }
4325
- }
4326
- function normalizeServerPort(value) {
4327
- const port = Number(value);
4328
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
4329
- throw new Error(`Invalid port: ${value}.`);
4330
- }
4331
- return port;
4332
- }
4333
- function errorMessage(error) {
4334
- return error instanceof Error ? error.message : String(error);
4335
- }
4336
- function serverLogger(options) {
4337
- if (options.logger) {
4338
- return options.logger.child({ component: "server" });
4339
- }
4340
- if (shouldCreateLogger(options)) {
4341
- return createHoopilotLogger({
4342
- env: options.env,
4343
- format: options.logFormat,
4344
- level: options.logLevel
4345
- }).child({ component: "server" });
4346
- }
4347
- return noopLogger;
4348
- }
4349
- function resolveStreamingProxyMode(options) {
4350
- const value = options.streamingProxyMode ?? envValue(options.env?.HOOPILOT_STREAM_MODE) ?? envValue(options.env?.HOOPILOT_STREAMING_PROXY_MODE) ?? "auto";
4351
- if (value === "auto" || value === "buffer" || value === "live") {
4352
- return value;
4353
- }
4354
- throw new Error(`Invalid stream mode: ${value}. Expected auto, live, or buffer.`);
4355
- }
4356
- function shouldBufferProxyBodies(mode) {
4357
- if (mode === "buffer") {
4358
- return true;
4359
- }
4360
- if (mode === "live") {
4361
- return false;
4362
- }
4363
- return process.platform === "win32" && IS_STANDALONE_BINARY;
4364
- }
4365
- function finishResponse(response, options) {
4366
- const withRequestId = responseWithRequestId(
4367
- response,
4368
- options.requestId,
4369
- options.closeConnection,
4370
- options.corsOrigin
4371
- );
4372
- const stream = isStreamingResponse(withRequestId);
4373
- const status = withRequestId.status;
4374
- const complete = () => {
4375
- const durationMs = Math.round((performance.now() - options.startedAt) * 100) / 100;
4376
- options.metrics.observe({ durationMs, method: options.method, route: options.route, status });
4377
- logRequestCompleted(options.logger, status, stream, durationMs);
4378
- };
4379
- if (stream && withRequestId.body && options.trackStreamingBody) {
4380
- return new Response(trackStreamCompletion(withRequestId.body, complete), {
4381
- headers: withRequestId.headers,
4382
- status,
4383
- statusText: withRequestId.statusText
4384
- });
4385
- }
4386
- complete();
4387
- return withRequestId;
4388
- }
4389
- function responseWithRequestId(response, requestId, closeConnection, corsOrigin) {
4390
- const headers = new Headers(response.headers);
4391
- headers.set("x-request-id", requestId);
4392
- if (corsOrigin) {
4393
- headers.set("access-control-allow-origin", corsOrigin);
4394
- if (corsOrigin !== "*") {
4395
- headers.append("vary", "Origin");
4396
- }
4397
- } else {
4398
- headers.delete("access-control-allow-origin");
4399
- }
4400
- if (closeConnection) {
4401
- headers.set("connection", "close");
4402
- }
4403
- return new Response(response.body, {
4404
- headers,
4405
- status: response.status,
4406
- statusText: response.statusText
4407
- });
4408
- }
4409
- function trackStreamCompletion(body, onComplete) {
4410
- const reader = body.getReader();
4411
- let fired = false;
4412
- const fire = () => {
4413
- if (!fired) {
4414
- fired = true;
4415
- onComplete();
4416
- }
4417
- };
4418
- return new ReadableStream({
4419
- async pull(controller) {
4420
- try {
4421
- const { done, value } = await reader.read();
4422
- if (done) {
4423
- controller.close();
4424
- fire();
4425
- return;
4426
- }
4427
- controller.enqueue(value);
4428
- } catch (error) {
4429
- fire();
4430
- controller.error(error);
4431
- }
4432
- },
4433
- cancel(reason) {
4434
- fire();
4435
- return reader.cancel(reason);
4436
- }
4437
- });
4438
- }
4439
- function logRequestCompleted(logger, status, stream, durationMs) {
4440
- const fields = {
4441
- durationMs,
4442
- event: "http.request.completed",
4443
- status,
4444
- stream
4445
- };
4446
- if (status >= 500) {
4447
- logger.error(fields, "request completed with server error");
4448
- return;
4449
- }
4450
- if (status >= 400) {
4451
- logger.warn(fields, "request completed with client error");
4452
- return;
4453
- }
4454
- logger.info(fields, "request completed");
4455
- }
4456
- function requestIdFor(request) {
4457
- const existing = request.headers.get("x-request-id")?.trim();
4458
- return existing && REQUEST_ID_PATTERN.test(existing) ? existing : crypto.randomUUID();
4459
- }
4460
- function canonicalApiPath(path) {
4461
- const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/, "") : path;
4462
- switch (withoutTrailingSlash) {
4463
- case "/models":
4464
- return "/v1/models";
4465
- case "/chat/completions":
4466
- return "/v1/chat/completions";
4467
- case "/completions":
4468
- return "/v1/completions";
4469
- case "/messages":
4470
- return "/v1/messages";
4471
- case "/messages/count_tokens":
4472
- return "/v1/messages/count_tokens";
4473
- case "/responses":
4474
- return "/v1/responses";
4475
- case "/responses/compact":
4476
- return "/v1/responses/compact";
4477
- case "/usage":
4478
- return "/v1/usage";
4479
- default:
4480
- return withoutTrailingSlash;
4481
- }
4482
- }
4483
- function routeFor(method, path) {
4484
- if (method === "OPTIONS") {
4485
- return "cors.preflight";
4486
- }
4487
- if (method === "GET" && (path === "/" || path === "/healthz")) {
4488
- return "health";
4489
- }
4490
- if (method === "GET" && path === "/dashboard") {
4491
- return "dashboard";
4492
- }
4493
- if (method === "GET" && path === "/metrics") {
4494
- return "metrics";
4495
- }
4496
- if (method === "GET" && path === "/v1/usage") {
4497
- return "usage";
4498
- }
4499
- if (method === "GET" && path === "/v1/models") {
4500
- return "models";
4501
- }
4502
- if (method === "POST" && path === "/v1/messages") {
4503
- return "anthropic_messages";
4504
- }
4505
- if (method === "POST" && path === "/v1/messages/count_tokens") {
4506
- return "anthropic_count_tokens";
4507
- }
4508
- if (method === "POST" && path === "/v1/chat/completions") {
4509
- return "chat_completions";
4510
- }
4511
- if (method === "POST" && path === "/v1/completions") {
4512
- return "completions";
4513
- }
4514
- if (method === "POST" && path === "/v1/responses/compact") {
4515
- return "responses_compact";
4516
- }
4517
- if (method === "POST" && path === "/v1/responses") {
4518
- return "responses";
4519
- }
4520
- if (method === "GET" && path === "/v1/responses") {
4521
- return "responses_websocket";
4522
- }
4523
- return "not_found";
4524
- }
4525
- function isStreamingResponse(response) {
4526
- return response.headers.get("content-type")?.includes("text/event-stream") ?? false;
4527
- }
4528
- function logUpstreamSuccess(logger, upstreamPath, status) {
4529
- logger.debug(
4530
- {
4531
- event: "copilot.request.completed",
4532
- upstreamPath,
4533
- upstreamStatus: status
4534
- },
4535
- "copilot upstream request completed"
4536
- );
4537
- }
4538
- function metricsResponse(metrics) {
4539
- return new Response(metrics.renderPrometheus(), {
4540
- headers: {
4541
- ...corsHeaders(),
4542
- "content-type": PROMETHEUS_CONTENT_TYPE
4543
- },
4544
- status: 200
4545
- });
4546
- }
4547
- function dashboardResponse() {
4548
- return new Response(DASHBOARD_HTML, {
4549
- headers: {
4550
- ...corsHeaders(),
4551
- "content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
4552
- "content-type": "text/html; charset=utf-8",
4553
- "referrer-policy": "no-referrer",
4554
- "x-content-type-options": "nosniff",
4555
- "x-frame-options": "DENY"
4556
- },
4557
- status: 200
4558
- });
4559
- }
4560
- async function handleUsage(metrics, readUsage, signal) {
4561
- const { copilot, error } = await readUsage(signal);
4562
- const proxy = metrics.snapshot();
4563
- const body = {
4564
- copilot: copilot ?? null,
4565
- object: "usage",
4566
- proxy,
4567
- version: await getVersion()
4568
- };
4569
- if (error) {
4570
- body.copilot_error = error;
4571
- }
4572
- return jsonResponse(body);
4573
- }
4574
- function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_TTL_MS) {
4575
- const usagePath = "/copilot_internal/user";
4576
- let cache;
4577
- return async (signal) => {
4578
- if (cache && now() - cache.atMs < ttlMs) {
4579
- return { copilot: cache.value };
4580
- }
4581
- try {
4582
- const upstream = await client.usage(signal);
4583
- metrics.recordUpstream(usagePath, upstream.ok);
4584
- metrics.recordGithubRateLimit(parseRateLimitHeaders(upstream.headers, now()));
4585
- if (!upstream.ok) {
4586
- return { error: `GitHub Copilot usage request failed with ${upstream.status}.` };
4587
- }
4588
- const value = normalizeCopilotUsage(await upstream.json().catch(() => ({})));
4589
- cache = { atMs: now(), value };
4590
- metrics.recordCopilotQuota(value);
4591
- return { copilot: value };
4592
- } catch (error) {
4593
- if (error instanceof CopilotAuthError) {
4594
- return { error: error.message };
4595
- }
4596
- metrics.recordUpstream(usagePath, false);
4597
- return { error: errorMessage(error) };
4598
- }
4599
- };
4600
- }
4601
- function safeParseJson(text) {
4602
- try {
4603
- return JSON.parse(text);
4604
- } catch {
4605
- return void 0;
4606
- }
4607
- }
4608
- // Annotate the CommonJS export names for ESM import in node:
4609
- 0 && (module.exports = {
4610
- AnthropicCompatibilityError,
4611
- COPILOT_USAGE_API_VERSION,
4612
- CopilotAuth,
4613
- CopilotAuthError,
4614
- CopilotClient,
4615
- DEFAULT_GITHUB_API_BASE_URL,
4616
- DEFAULT_LOG_FORMAT,
4617
- DEFAULT_LOG_LEVEL,
4618
- DEFAULT_MODEL,
4619
- MetricsRegistry,
4620
- PROMETHEUS_CONTENT_TYPE,
4621
- anthropicMessagesToResponsesRequest,
4622
- applyCopilotHeaders,
4623
- applyGithubApiHeaders,
4624
- authStorePath,
4625
- chatCompletionToCompletion,
4626
- chatCompletionToResponse,
4627
- completionStreamFromChatStream,
4628
- completionsRequestToChatCompletion,
4629
- createHoopilotHandler,
4630
- createHoopilotLogger,
4631
- estimateAnthropicMessageTokens,
4632
- extractTokenUsage,
4633
- fallbackModels,
4634
- githubCopilotDeviceLogin,
4635
- noopLogger,
4636
- normalizeChatCompletionRequest,
4637
- normalizeCopilotUsage,
4638
- normalizeModelsResponse,
4639
- normalizeRequestedModel,
4640
- observeResponseUsage,
4641
- parseLogFormat,
4642
- parseLogLevel,
4643
- parseRateLimitHeaders,
4644
- readStoredCopilotAuth,
4645
- responsesCompactionResult,
4646
- responsesRequestToChatCompletion,
4647
- responsesResponseToAnthropicMessage,
4648
- responsesStreamFromChatStream,
4649
- responsesStreamToAnthropicStream,
4650
- startHoopilotServer,
4651
- writeStoredCopilotAuth
4652
- });
4653
- //# sourceMappingURL=index.cjs.map