@openhoo/hoopilot 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -10
- package/dist/{chunk-JU6F5L34.js → chunk-6ALEIJJM.js} +82 -20
- package/dist/chunk-6ALEIJJM.js.map +1 -0
- package/dist/cli.js +1267 -394
- package/dist/cli.js.map +1 -1
- package/dist/codexx.js +1 -1
- package/dist/index.d.ts +38 -6
- package/dist/index.js +1299 -342
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/dist/chunk-JU6F5L34.js.map +0 -1
- package/dist/index.cjs +0 -3751
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -370
package/dist/index.js
CHANGED
|
@@ -35,8 +35,12 @@ function parseUrl(rawUrl) {
|
|
|
35
35
|
}
|
|
36
36
|
return url;
|
|
37
37
|
}
|
|
38
|
+
var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
39
|
+
function isLoopbackHostname(host) {
|
|
40
|
+
return LOOPBACK_HOSTNAMES.has(host);
|
|
41
|
+
}
|
|
38
42
|
function isLoopbackHttpUrl(url) {
|
|
39
|
-
return url.protocol === "http:" && (url.hostname
|
|
43
|
+
return url.protocol === "http:" && isLoopbackHostname(url.hostname);
|
|
40
44
|
}
|
|
41
45
|
async function truncatedResponseText(response, max = 500) {
|
|
42
46
|
const text = await response.text();
|
|
@@ -45,6 +49,48 @@ async function truncatedResponseText(response, max = 500) {
|
|
|
45
49
|
function asRecord(value) {
|
|
46
50
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
47
51
|
}
|
|
52
|
+
function errorMessage(error) {
|
|
53
|
+
return error instanceof Error ? error.message : String(error);
|
|
54
|
+
}
|
|
55
|
+
function firstNumber(...values) {
|
|
56
|
+
for (const value of values) {
|
|
57
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return void 0;
|
|
62
|
+
}
|
|
63
|
+
function randomId() {
|
|
64
|
+
return crypto.randomUUID().replaceAll("-", "");
|
|
65
|
+
}
|
|
66
|
+
function removeUndefined(value) {
|
|
67
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
|
|
68
|
+
}
|
|
69
|
+
function safeJsonParse(text) {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(text);
|
|
72
|
+
} catch {
|
|
73
|
+
return void 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function parseJsonObject(text) {
|
|
77
|
+
try {
|
|
78
|
+
return asRecord(JSON.parse(text));
|
|
79
|
+
} catch {
|
|
80
|
+
return void 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
var STREAMING_PROXY_MODES = [
|
|
84
|
+
"auto",
|
|
85
|
+
"buffer",
|
|
86
|
+
"live"
|
|
87
|
+
];
|
|
88
|
+
function parseStreamingProxyMode(value) {
|
|
89
|
+
if (STREAMING_PROXY_MODES.includes(value)) {
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`Invalid stream mode: ${value}. Expected ${STREAMING_PROXY_MODES.join(", ")}.`);
|
|
93
|
+
}
|
|
48
94
|
|
|
49
95
|
// src/openai.ts
|
|
50
96
|
var DEFAULT_MODEL = "gpt-4.1";
|
|
@@ -172,13 +218,6 @@ function compactionOutputFromResponsesSse(text) {
|
|
|
172
218
|
}
|
|
173
219
|
return deltas ? [messageOutputItem(deltas)] : [];
|
|
174
220
|
}
|
|
175
|
-
function safeJsonParse(text) {
|
|
176
|
-
try {
|
|
177
|
-
return JSON.parse(text);
|
|
178
|
-
} catch {
|
|
179
|
-
return void 0;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
221
|
function chatCompletionToCompletion(completion) {
|
|
183
222
|
return removeUndefined({
|
|
184
223
|
choices: completionChoices(completion).map((choice, index) => {
|
|
@@ -372,7 +411,7 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
372
411
|
if (isNew) {
|
|
373
412
|
enqueue("response.output_item.added", {
|
|
374
413
|
item: functionCallItem(existing, "in_progress"),
|
|
375
|
-
output_index: existing.outputIndex
|
|
414
|
+
output_index: existing.outputIndex,
|
|
376
415
|
type: "response.output_item.added"
|
|
377
416
|
});
|
|
378
417
|
}
|
|
@@ -382,7 +421,7 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
382
421
|
enqueue("response.function_call_arguments.delta", {
|
|
383
422
|
delta: argumentDelta,
|
|
384
423
|
item_id: existing.itemId,
|
|
385
|
-
output_index: existing.outputIndex
|
|
424
|
+
output_index: existing.outputIndex,
|
|
386
425
|
type: "response.function_call_arguments.delta"
|
|
387
426
|
});
|
|
388
427
|
}
|
|
@@ -432,11 +471,9 @@ function responsesStreamFromChatStream(chatStream, options) {
|
|
|
432
471
|
type: "response.output_item.done"
|
|
433
472
|
});
|
|
434
473
|
}
|
|
435
|
-
for (const tool of [...tools.values()].sort(
|
|
436
|
-
(a, b) => (a.outputIndex ?? 0) - (b.outputIndex ?? 0)
|
|
437
|
-
)) {
|
|
474
|
+
for (const tool of [...tools.values()].sort((a, b) => a.outputIndex - b.outputIndex)) {
|
|
438
475
|
const item = functionCallItem(tool);
|
|
439
|
-
const outputIndex = tool.outputIndex
|
|
476
|
+
const outputIndex = tool.outputIndex;
|
|
440
477
|
outputEntries.push([outputIndex, item]);
|
|
441
478
|
enqueue("response.function_call_arguments.done", {
|
|
442
479
|
arguments: tool.arguments,
|
|
@@ -679,7 +716,6 @@ function outputItemsFromMessage(message) {
|
|
|
679
716
|
functionCallItem({
|
|
680
717
|
arguments: contentToText(fn.arguments),
|
|
681
718
|
id: contentToText(record.id) || `call_${randomId()}`,
|
|
682
|
-
index: output.length,
|
|
683
719
|
name: contentToText(fn.name)
|
|
684
720
|
})
|
|
685
721
|
);
|
|
@@ -761,21 +797,18 @@ function extractTokenUsage(usage) {
|
|
|
761
797
|
asRecord(record.prompt_tokens_details).cached_tokens,
|
|
762
798
|
asRecord(record.input_tokens_details).cached_tokens
|
|
763
799
|
);
|
|
764
|
-
|
|
765
|
-
cachedTokens: cached,
|
|
800
|
+
const result = {
|
|
766
801
|
completionTokens,
|
|
767
802
|
promptTokens,
|
|
768
|
-
reasoningTokens: reasoning,
|
|
769
803
|
totalTokens: total ?? promptTokens + completionTokens
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
for (const value of values) {
|
|
774
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
775
|
-
return value;
|
|
776
|
-
}
|
|
804
|
+
};
|
|
805
|
+
if (cached !== void 0) {
|
|
806
|
+
result.cachedTokens = cached;
|
|
777
807
|
}
|
|
778
|
-
|
|
808
|
+
if (reasoning !== void 0) {
|
|
809
|
+
result.reasoningTokens = reasoning;
|
|
810
|
+
}
|
|
811
|
+
return result;
|
|
779
812
|
}
|
|
780
813
|
function firstChoice(completion) {
|
|
781
814
|
return completionChoices(completion)[0] ?? {};
|
|
@@ -804,7 +837,7 @@ function processCompletionSseBlock(block, enqueue, markTerminal) {
|
|
|
804
837
|
enqueue("[DONE]");
|
|
805
838
|
return;
|
|
806
839
|
}
|
|
807
|
-
const parsed =
|
|
840
|
+
const parsed = parseJsonObject(data);
|
|
808
841
|
if (!parsed) {
|
|
809
842
|
return;
|
|
810
843
|
}
|
|
@@ -869,7 +902,7 @@ function processChatSseLine(line, handlers) {
|
|
|
869
902
|
if (!data || data === "[DONE]") {
|
|
870
903
|
return;
|
|
871
904
|
}
|
|
872
|
-
const parsed =
|
|
905
|
+
const parsed = parseJsonObject(data);
|
|
873
906
|
if (!parsed) {
|
|
874
907
|
return;
|
|
875
908
|
}
|
|
@@ -921,19 +954,6 @@ function encodeDataSse(data) {
|
|
|
921
954
|
|
|
922
955
|
`;
|
|
923
956
|
}
|
|
924
|
-
function parseJson(data) {
|
|
925
|
-
try {
|
|
926
|
-
return asRecord(JSON.parse(data));
|
|
927
|
-
} catch {
|
|
928
|
-
return void 0;
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
function removeUndefined(record) {
|
|
932
|
-
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
|
|
933
|
-
}
|
|
934
|
-
function randomId() {
|
|
935
|
-
return crypto.randomUUID().replaceAll("-", "");
|
|
936
|
-
}
|
|
937
957
|
function epochSeconds() {
|
|
938
958
|
return Math.floor(Date.now() / 1e3);
|
|
939
959
|
}
|
|
@@ -946,13 +966,13 @@ var AnthropicCompatibilityError = class extends Error {
|
|
|
946
966
|
}
|
|
947
967
|
};
|
|
948
968
|
function anthropicMessagesToResponsesRequest(request) {
|
|
949
|
-
return
|
|
969
|
+
return removeUndefined({
|
|
950
970
|
input: anthropicMessagesToResponsesInput(request.messages),
|
|
951
971
|
instructions: anthropicSystemToInstructions(request.system),
|
|
952
972
|
max_output_tokens: typeof request.max_tokens === "number" && Number.isFinite(request.max_tokens) ? request.max_tokens : void 0,
|
|
953
973
|
metadata: request.metadata,
|
|
954
974
|
model: normalizeRequestedModel(request.model),
|
|
955
|
-
parallel_tool_calls: true,
|
|
975
|
+
parallel_tool_calls: asRecord(request.tool_choice).disable_parallel_tool_use !== true,
|
|
956
976
|
reasoning: anthropicThinkingToReasoning(request.thinking),
|
|
957
977
|
stop: anthropicStopSequences(request.stop_sequences),
|
|
958
978
|
stream: request.stream === true,
|
|
@@ -967,7 +987,7 @@ function responsesResponseToAnthropicMessage(response, fallbackModel) {
|
|
|
967
987
|
const usage = anthropicUsage(response.usage);
|
|
968
988
|
return {
|
|
969
989
|
content,
|
|
970
|
-
id: textValue(response.id) || `msg_${
|
|
990
|
+
id: textValue(response.id) || `msg_${randomId()}`,
|
|
971
991
|
model: textValue(response.model) || fallbackModel,
|
|
972
992
|
role: "assistant",
|
|
973
993
|
stop_reason: anthropicStopReason(response, content),
|
|
@@ -1044,7 +1064,7 @@ function createAnthropicStreamState(options) {
|
|
|
1044
1064
|
return {
|
|
1045
1065
|
blocks: /* @__PURE__ */ new Map(),
|
|
1046
1066
|
completed: false,
|
|
1047
|
-
messageId: options.messageId ?? `msg_${
|
|
1067
|
+
messageId: options.messageId ?? `msg_${randomId()}`,
|
|
1048
1068
|
model: options.model,
|
|
1049
1069
|
nextBlockIndex: 0,
|
|
1050
1070
|
sawToolUse: false,
|
|
@@ -1098,7 +1118,7 @@ function anthropicMessagesToResponsesInput(messages) {
|
|
|
1098
1118
|
flushMessage();
|
|
1099
1119
|
input.push({
|
|
1100
1120
|
arguments: JSON.stringify(asRecord(part.input)),
|
|
1101
|
-
call_id: textValue(part.id) || `call_${
|
|
1121
|
+
call_id: textValue(part.id) || `call_${randomId()}`,
|
|
1102
1122
|
name: textValue(part.name),
|
|
1103
1123
|
type: "function_call"
|
|
1104
1124
|
});
|
|
@@ -1209,7 +1229,7 @@ function anthropicTools(tools) {
|
|
|
1209
1229
|
}
|
|
1210
1230
|
const converted = tools.map((tool) => {
|
|
1211
1231
|
const record = asRecord(tool);
|
|
1212
|
-
return
|
|
1232
|
+
return removeUndefined({
|
|
1213
1233
|
description: record.description,
|
|
1214
1234
|
name: record.name,
|
|
1215
1235
|
parameters: record.input_schema,
|
|
@@ -1280,7 +1300,7 @@ function anthropicContentFromResponsesOutput(response) {
|
|
|
1280
1300
|
}
|
|
1281
1301
|
if (type === "function_call") {
|
|
1282
1302
|
content.push({
|
|
1283
|
-
id: textValue(record.call_id) || textValue(record.id) || `call_${
|
|
1303
|
+
id: textValue(record.call_id) || textValue(record.id) || `call_${randomId()}`,
|
|
1284
1304
|
input: parseToolInput(textValue(record.arguments)),
|
|
1285
1305
|
name: textValue(record.name),
|
|
1286
1306
|
type: "tool_use"
|
|
@@ -1307,12 +1327,12 @@ function anthropicStopReason(response, content) {
|
|
|
1307
1327
|
}
|
|
1308
1328
|
function anthropicUsage(usage) {
|
|
1309
1329
|
const record = asRecord(usage);
|
|
1310
|
-
const inputTokens =
|
|
1311
|
-
const outputTokens =
|
|
1330
|
+
const inputTokens = firstNumber(record.input_tokens, record.prompt_tokens) ?? 0;
|
|
1331
|
+
const outputTokens = firstNumber(record.output_tokens, record.completion_tokens) ?? 0;
|
|
1312
1332
|
const details = asRecord(record.input_tokens_details);
|
|
1313
|
-
return
|
|
1314
|
-
cache_creation_input_tokens:
|
|
1315
|
-
cache_read_input_tokens:
|
|
1333
|
+
return removeUndefined({
|
|
1334
|
+
cache_creation_input_tokens: firstNumber(record.cache_creation_input_tokens),
|
|
1335
|
+
cache_read_input_tokens: firstNumber(record.cache_read_input_tokens, details.cached_tokens) ?? void 0,
|
|
1316
1336
|
input_tokens: inputTokens,
|
|
1317
1337
|
output_tokens: outputTokens
|
|
1318
1338
|
});
|
|
@@ -1494,7 +1514,7 @@ function ensureToolBlock(state, payload, item, enqueue) {
|
|
|
1494
1514
|
state.blocks.set(key, block);
|
|
1495
1515
|
enqueue("content_block_start", {
|
|
1496
1516
|
content_block: {
|
|
1497
|
-
id: textValue(item.call_id) || textValue(item.id) || `call_${
|
|
1517
|
+
id: textValue(item.call_id) || textValue(item.id) || `call_${randomId()}`,
|
|
1498
1518
|
input: {},
|
|
1499
1519
|
name: textValue(item.name),
|
|
1500
1520
|
type: "tool_use"
|
|
@@ -1528,13 +1548,6 @@ function parseSseBlock(block) {
|
|
|
1528
1548
|
}
|
|
1529
1549
|
return { data: data.join("\n"), event };
|
|
1530
1550
|
}
|
|
1531
|
-
function parseJsonObject(text) {
|
|
1532
|
-
try {
|
|
1533
|
-
return asRecord(JSON.parse(text));
|
|
1534
|
-
} catch {
|
|
1535
|
-
return void 0;
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
1551
|
function parseToolInput(argumentsText) {
|
|
1539
1552
|
const parsed = parseJsonObject(argumentsText);
|
|
1540
1553
|
return parsed ?? {};
|
|
@@ -1566,32 +1579,18 @@ function textValue(value) {
|
|
|
1566
1579
|
}
|
|
1567
1580
|
return "";
|
|
1568
1581
|
}
|
|
1569
|
-
function firstNumber2(...values) {
|
|
1570
|
-
for (const value of values) {
|
|
1571
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1572
|
-
return value;
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
return void 0;
|
|
1576
|
-
}
|
|
1577
1582
|
function indexValue(value) {
|
|
1578
1583
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
1579
1584
|
}
|
|
1580
|
-
function removeUndefined2(record) {
|
|
1581
|
-
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
|
|
1582
|
-
}
|
|
1583
1585
|
function encodeSse2(event, data) {
|
|
1584
1586
|
return `event: ${event}
|
|
1585
1587
|
data: ${JSON.stringify(data)}
|
|
1586
1588
|
|
|
1587
1589
|
`;
|
|
1588
1590
|
}
|
|
1589
|
-
function randomId2() {
|
|
1590
|
-
return crypto.randomUUID().replaceAll("-", "");
|
|
1591
|
-
}
|
|
1592
1591
|
|
|
1593
1592
|
// src/auth-store.ts
|
|
1594
|
-
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
1593
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
1595
1594
|
import { dirname, join } from "path";
|
|
1596
1595
|
var StoredCopilotAuthError = class extends Error {
|
|
1597
1596
|
constructor(message) {
|
|
@@ -1642,7 +1641,7 @@ function readStoredCopilotAuth(path = authStorePath()) {
|
|
|
1642
1641
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1643
1642
|
throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
|
|
1644
1643
|
}
|
|
1645
|
-
const record = parsed;
|
|
1644
|
+
const record = asRecord(parsed);
|
|
1646
1645
|
const token = typeof record.token === "string" ? record.token.trim() : "";
|
|
1647
1646
|
if (!token) {
|
|
1648
1647
|
throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
|
|
@@ -1668,7 +1667,15 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
|
|
|
1668
1667
|
`;
|
|
1669
1668
|
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
1670
1669
|
writeFileSync(tmpPath, data, { mode: 384 });
|
|
1671
|
-
|
|
1670
|
+
try {
|
|
1671
|
+
renameSync(tmpPath, path);
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
try {
|
|
1674
|
+
rmSync(tmpPath, { force: true });
|
|
1675
|
+
} catch {
|
|
1676
|
+
}
|
|
1677
|
+
throw error;
|
|
1678
|
+
}
|
|
1672
1679
|
try {
|
|
1673
1680
|
chmodSync(path, 384);
|
|
1674
1681
|
} catch {
|
|
@@ -1713,23 +1720,20 @@ var CopilotAuth = class {
|
|
|
1713
1720
|
throw error;
|
|
1714
1721
|
}
|
|
1715
1722
|
if (stored) {
|
|
1716
|
-
|
|
1723
|
+
this.#cachedAccess = {
|
|
1717
1724
|
apiBaseUrl: trimTrailingSlash(
|
|
1718
1725
|
this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
|
|
1719
1726
|
),
|
|
1720
1727
|
expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
|
|
1721
1728
|
source: "github-copilot-oauth",
|
|
1722
1729
|
token: stored.token
|
|
1723
|
-
}
|
|
1730
|
+
};
|
|
1731
|
+
return this.#cachedAccess;
|
|
1724
1732
|
}
|
|
1725
1733
|
throw new CopilotAuthError(
|
|
1726
1734
|
"No GitHub Copilot OAuth credential found. Run `hoopilot login` to sign in through your browser."
|
|
1727
1735
|
);
|
|
1728
1736
|
}
|
|
1729
|
-
#cacheAccess(access) {
|
|
1730
|
-
this.#cachedAccess = access;
|
|
1731
|
-
return access;
|
|
1732
|
-
}
|
|
1733
1737
|
};
|
|
1734
1738
|
|
|
1735
1739
|
// src/copilot.ts
|
|
@@ -1737,23 +1741,26 @@ var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
|
|
|
1737
1741
|
var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
|
|
1738
1742
|
var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
|
|
1739
1743
|
var COPILOT_USAGE_API_VERSION = "2025-04-01";
|
|
1744
|
+
var EDITOR_PLUGIN_VERSION = "hoopilot/0.1.0";
|
|
1745
|
+
var EDITOR_VERSION = "Hoopilot/0.1.0";
|
|
1746
|
+
var HOOPILOT_USER_AGENT = "hoopilot/0.1.0";
|
|
1740
1747
|
function applyCopilotHeaders(headers, token) {
|
|
1741
1748
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
1742
1749
|
headers.set("authorization", `Bearer ${token}`);
|
|
1743
1750
|
headers.set("copilot-integration-id", "vscode-chat");
|
|
1744
|
-
headers.set("editor-plugin-version",
|
|
1745
|
-
headers.set("editor-version",
|
|
1751
|
+
headers.set("editor-plugin-version", EDITOR_PLUGIN_VERSION);
|
|
1752
|
+
headers.set("editor-version", EDITOR_VERSION);
|
|
1746
1753
|
headers.set("openai-intent", "conversation-panel");
|
|
1747
|
-
headers.set("user-agent",
|
|
1754
|
+
headers.set("user-agent", HOOPILOT_USER_AGENT);
|
|
1748
1755
|
headers.set("x-github-api-version", "2026-06-01");
|
|
1749
1756
|
return headers;
|
|
1750
1757
|
}
|
|
1751
1758
|
function applyGithubApiHeaders(headers, token) {
|
|
1752
1759
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
1753
1760
|
headers.set("authorization", `token ${token}`);
|
|
1754
|
-
headers.set("editor-plugin-version",
|
|
1755
|
-
headers.set("editor-version",
|
|
1756
|
-
headers.set("user-agent",
|
|
1761
|
+
headers.set("editor-plugin-version", EDITOR_PLUGIN_VERSION);
|
|
1762
|
+
headers.set("editor-version", EDITOR_VERSION);
|
|
1763
|
+
headers.set("user-agent", HOOPILOT_USER_AGENT);
|
|
1757
1764
|
headers.set("x-github-api-version", COPILOT_USAGE_API_VERSION);
|
|
1758
1765
|
return headers;
|
|
1759
1766
|
}
|
|
@@ -1766,7 +1773,7 @@ function parseRateLimitHeaders(headers, nowMs = Date.now()) {
|
|
|
1766
1773
|
if (limit === void 0 && remaining === void 0 && used === void 0 && resetEpochSeconds === void 0 && retryAfterSeconds === void 0) {
|
|
1767
1774
|
return void 0;
|
|
1768
1775
|
}
|
|
1769
|
-
return
|
|
1776
|
+
return removeUndefined({
|
|
1770
1777
|
limit,
|
|
1771
1778
|
observedAtMs: nowMs,
|
|
1772
1779
|
remaining,
|
|
@@ -1784,11 +1791,6 @@ function headerInt(headers, name) {
|
|
|
1784
1791
|
const value = Number.parseInt(raw.trim(), 10);
|
|
1785
1792
|
return Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
1786
1793
|
}
|
|
1787
|
-
function removeUndefinedRateLimit(rateLimit) {
|
|
1788
|
-
return Object.fromEntries(
|
|
1789
|
-
Object.entries(rateLimit).filter(([, value]) => value !== void 0)
|
|
1790
|
-
);
|
|
1791
|
-
}
|
|
1792
1794
|
var CopilotClient = class {
|
|
1793
1795
|
#auth;
|
|
1794
1796
|
#allowUnsafeUpstream;
|
|
@@ -1885,7 +1887,7 @@ function normalizeCopilotUsage(body) {
|
|
|
1885
1887
|
for (const category of /* @__PURE__ */ new Set([...Object.keys(remaining), ...Object.keys(monthly)])) {
|
|
1886
1888
|
const entitlement = numberOrUndefined(monthly[category]);
|
|
1887
1889
|
const left = numberOrUndefined(remaining[category]);
|
|
1888
|
-
quotas[category] =
|
|
1890
|
+
quotas[category] = removeUndefined({
|
|
1889
1891
|
entitlement,
|
|
1890
1892
|
percentRemaining: entitlement !== void 0 && entitlement > 0 && left !== void 0 ? left / entitlement * 100 : void 0,
|
|
1891
1893
|
remaining: left,
|
|
@@ -1893,7 +1895,7 @@ function normalizeCopilotUsage(body) {
|
|
|
1893
1895
|
});
|
|
1894
1896
|
}
|
|
1895
1897
|
}
|
|
1896
|
-
return
|
|
1898
|
+
return removeUndefined({
|
|
1897
1899
|
accessTypeSku: stringOrUndefined(record.access_type_sku),
|
|
1898
1900
|
chatEnabled: typeof record.chat_enabled === "boolean" ? record.chat_enabled : void 0,
|
|
1899
1901
|
plan: stringOrUndefined(record.copilot_plan),
|
|
@@ -1905,7 +1907,7 @@ function normalizeQuotaDetail(detail) {
|
|
|
1905
1907
|
const entitlement = numberOrUndefined(detail.entitlement);
|
|
1906
1908
|
const overageCount = numberOrUndefined(detail.overage_count);
|
|
1907
1909
|
const remaining = numberOrUndefined(detail.remaining) ?? numberOrUndefined(detail.quota_remaining);
|
|
1908
|
-
return
|
|
1910
|
+
return removeUndefined({
|
|
1909
1911
|
entitlement,
|
|
1910
1912
|
hasQuota: typeof detail.has_quota === "boolean" ? detail.has_quota : void 0,
|
|
1911
1913
|
overageCount,
|
|
@@ -1929,21 +1931,10 @@ function usedFrom(entitlement, remaining, overageCount) {
|
|
|
1929
1931
|
const overage = remaining === 0 ? overageCount ?? 0 : 0;
|
|
1930
1932
|
return Math.max(0, base + overage);
|
|
1931
1933
|
}
|
|
1932
|
-
|
|
1933
|
-
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1934
|
-
}
|
|
1934
|
+
var numberOrUndefined = firstNumber;
|
|
1935
1935
|
function stringOrUndefined(value) {
|
|
1936
1936
|
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1937
1937
|
}
|
|
1938
|
-
function removeUndefinedQuota(quota) {
|
|
1939
|
-
return Object.fromEntries(
|
|
1940
|
-
Object.entries(quota).filter(([, value]) => value !== void 0)
|
|
1941
|
-
);
|
|
1942
|
-
}
|
|
1943
|
-
function removeUndefinedUsage(usage) {
|
|
1944
|
-
const entries = Object.entries(usage).filter(([, value]) => value !== void 0);
|
|
1945
|
-
return Object.fromEntries(entries);
|
|
1946
|
-
}
|
|
1947
1938
|
|
|
1948
1939
|
// src/github-device.ts
|
|
1949
1940
|
import { setTimeout as sleep } from "timers/promises";
|
|
@@ -2075,11 +2066,16 @@ function positiveSeconds(value, fallback) {
|
|
|
2075
2066
|
}
|
|
2076
2067
|
async function parseJsonResponse(response, context) {
|
|
2077
2068
|
const text = await response.text();
|
|
2069
|
+
let value;
|
|
2078
2070
|
try {
|
|
2079
|
-
|
|
2071
|
+
value = JSON.parse(text);
|
|
2080
2072
|
} catch {
|
|
2081
2073
|
throw new Error(`${context}: ${text.slice(0, 500)}`);
|
|
2082
2074
|
}
|
|
2075
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2076
|
+
throw new Error(`${context}: ${text.slice(0, 500)}`);
|
|
2077
|
+
}
|
|
2078
|
+
return value;
|
|
2083
2079
|
}
|
|
2084
2080
|
|
|
2085
2081
|
// src/logger.ts
|
|
@@ -2143,21 +2139,29 @@ function createHoopilotLogger(options = {}) {
|
|
|
2143
2139
|
timestamp: pino.stdTimeFunctions.isoTime
|
|
2144
2140
|
};
|
|
2145
2141
|
if (format === "pretty") {
|
|
2146
|
-
return
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2142
|
+
return asHoopilotLogger(
|
|
2143
|
+
pino(
|
|
2144
|
+
pinoOptions,
|
|
2145
|
+
pretty({
|
|
2146
|
+
// Probe the same sink we write to (stdout / fd 1), so colors are not
|
|
2147
|
+
// emitted into a redirected file when only stderr is a TTY. A custom
|
|
2148
|
+
// stream's TTY-ness is unknown, so default to no color there.
|
|
2149
|
+
colorize: options.colorize ?? (options.stream ? false : process.stdout.isTTY),
|
|
2150
|
+
destination: options.stream ?? 1,
|
|
2151
|
+
ignore: "pid,hostname",
|
|
2152
|
+
singleLine: true,
|
|
2153
|
+
translateTime: "SYS:standard"
|
|
2154
|
+
})
|
|
2155
|
+
)
|
|
2155
2156
|
);
|
|
2156
2157
|
}
|
|
2157
2158
|
if (options.stream) {
|
|
2158
|
-
return pino(pinoOptions, options.stream);
|
|
2159
|
+
return asHoopilotLogger(pino(pinoOptions, options.stream));
|
|
2159
2160
|
}
|
|
2160
|
-
return pino(pinoOptions);
|
|
2161
|
+
return asHoopilotLogger(pino(pinoOptions));
|
|
2162
|
+
}
|
|
2163
|
+
function asHoopilotLogger(logger) {
|
|
2164
|
+
return logger;
|
|
2161
2165
|
}
|
|
2162
2166
|
function parseLogFormat(value) {
|
|
2163
2167
|
if (!value) {
|
|
@@ -2283,26 +2287,23 @@ var MetricsRegistry = class {
|
|
|
2283
2287
|
const resource = this.#rateLimitResource(rateLimit.resource);
|
|
2284
2288
|
this.#githubRateLimit.set(resource, { ...rateLimit, resource });
|
|
2285
2289
|
}
|
|
2286
|
-
//
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
if (!this.#tokens.has(cleaned) && this.#tokens.size >= MAX_TRACKED_MODELS) {
|
|
2290
|
+
// Clean a raw value into a bounded exposition-format label: cap its length,
|
|
2291
|
+
// strip characters that would corrupt the format, and fold overflow past the
|
|
2292
|
+
// cardinality limit into UNKNOWN_MODEL so the series count stays bounded.
|
|
2293
|
+
#boundedLabel(value, tracked, maxEntries) {
|
|
2294
|
+
const cleaned = cleanLabel(value).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
|
|
2295
|
+
if (!tracked.has(cleaned) && tracked.size >= maxEntries) {
|
|
2293
2296
|
return UNKNOWN_MODEL;
|
|
2294
2297
|
}
|
|
2295
2298
|
return cleaned;
|
|
2296
2299
|
}
|
|
2297
|
-
// The
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
+
// The model can originate from a (possibly hostile) client request.
|
|
2301
|
+
#modelLabel(model) {
|
|
2302
|
+
return this.#boundedLabel(model, this.#tokens, MAX_TRACKED_MODELS);
|
|
2303
|
+
}
|
|
2304
|
+
// The resource comes from a trusted upstream header, but is bounded the same way.
|
|
2300
2305
|
#rateLimitResource(resource) {
|
|
2301
|
-
|
|
2302
|
-
if (!this.#githubRateLimit.has(cleaned) && this.#githubRateLimit.size >= MAX_TRACKED_RATELIMIT_RESOURCES) {
|
|
2303
|
-
return UNKNOWN_MODEL;
|
|
2304
|
-
}
|
|
2305
|
-
return cleaned;
|
|
2306
|
+
return this.#boundedLabel(resource, this.#githubRateLimit, MAX_TRACKED_RATELIMIT_RESOURCES);
|
|
2306
2307
|
}
|
|
2307
2308
|
#observeDuration(route, seconds) {
|
|
2308
2309
|
const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
|
|
@@ -2355,6 +2356,7 @@ var MetricsRegistry = class {
|
|
|
2355
2356
|
return {
|
|
2356
2357
|
githubRateLimit,
|
|
2357
2358
|
inFlight: this.#inFlight,
|
|
2359
|
+
latency: this.#latencySnapshot(),
|
|
2358
2360
|
requests: { byRoute, byStatus, total: requestsTotal },
|
|
2359
2361
|
startedAt: new Date(this.#startedAtMs).toISOString(),
|
|
2360
2362
|
tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
|
|
@@ -2362,6 +2364,37 @@ var MetricsRegistry = class {
|
|
|
2362
2364
|
uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
|
|
2363
2365
|
};
|
|
2364
2366
|
}
|
|
2367
|
+
// Summarize the duration histogram into a JSON latency view: per-route count and
|
|
2368
|
+
// exact average, plus overall average and estimated p50/p95. The percentiles come
|
|
2369
|
+
// from the buckets aggregated across routes, so they share /metrics' resolution.
|
|
2370
|
+
#latencySnapshot() {
|
|
2371
|
+
const byRoute = {};
|
|
2372
|
+
const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
|
|
2373
|
+
let totalCount = 0;
|
|
2374
|
+
let totalSum = 0;
|
|
2375
|
+
for (const [route, entry] of this.#durations) {
|
|
2376
|
+
byRoute[route] = {
|
|
2377
|
+
avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
|
|
2378
|
+
count: entry.count
|
|
2379
|
+
};
|
|
2380
|
+
totalCount += entry.count;
|
|
2381
|
+
totalSum += entry.sum;
|
|
2382
|
+
for (let i = 0; i < aggregateBuckets.length; i += 1) {
|
|
2383
|
+
aggregateBuckets[i] = (aggregateBuckets[i] ?? 0) + (entry.buckets[i] ?? 0);
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
return {
|
|
2387
|
+
avgMs: totalCount > 0 ? round2(totalSum / totalCount * 1e3) : 0,
|
|
2388
|
+
byRoute,
|
|
2389
|
+
count: totalCount,
|
|
2390
|
+
p50Ms: round2(
|
|
2391
|
+
quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.5) * 1e3
|
|
2392
|
+
),
|
|
2393
|
+
p95Ms: round2(
|
|
2394
|
+
quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.95) * 1e3
|
|
2395
|
+
)
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2365
2398
|
/** Render the Prometheus text exposition format (version 0.0.4). */
|
|
2366
2399
|
renderPrometheus(now = Date.now) {
|
|
2367
2400
|
const lines = [];
|
|
@@ -2596,7 +2629,7 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome)
|
|
|
2596
2629
|
considerSseLine(line, accumulator.consider);
|
|
2597
2630
|
}
|
|
2598
2631
|
} else {
|
|
2599
|
-
const parsed =
|
|
2632
|
+
const parsed = safeJsonParse(text);
|
|
2600
2633
|
if (parsed !== void 0) {
|
|
2601
2634
|
accumulator.consider(parsed);
|
|
2602
2635
|
}
|
|
@@ -2658,7 +2691,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
2658
2691
|
considerSseLine(finalBuffer, accumulator.consider);
|
|
2659
2692
|
}
|
|
2660
2693
|
} else if (!overflowed && finalBuffer) {
|
|
2661
|
-
const parsed =
|
|
2694
|
+
const parsed = safeJsonParse(finalBuffer);
|
|
2662
2695
|
if (parsed !== void 0) {
|
|
2663
2696
|
accumulator.consider(parsed);
|
|
2664
2697
|
}
|
|
@@ -2701,24 +2734,37 @@ function considerSseLine(line, consider) {
|
|
|
2701
2734
|
if (!data || data === "[DONE]") {
|
|
2702
2735
|
return;
|
|
2703
2736
|
}
|
|
2704
|
-
const parsed =
|
|
2737
|
+
const parsed = safeJsonParse(data);
|
|
2705
2738
|
if (parsed !== void 0) {
|
|
2706
2739
|
consider(parsed);
|
|
2707
2740
|
}
|
|
2708
2741
|
}
|
|
2709
|
-
function safeParse(text) {
|
|
2710
|
-
try {
|
|
2711
|
-
return JSON.parse(text);
|
|
2712
|
-
} catch {
|
|
2713
|
-
return void 0;
|
|
2714
|
-
}
|
|
2715
|
-
}
|
|
2716
2742
|
function modelText(value) {
|
|
2717
2743
|
return typeof value === "string" ? value.trim() : "";
|
|
2718
2744
|
}
|
|
2719
2745
|
function nonNegative(value) {
|
|
2720
2746
|
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
2721
2747
|
}
|
|
2748
|
+
function round2(value) {
|
|
2749
|
+
return Math.round(value * 100) / 100;
|
|
2750
|
+
}
|
|
2751
|
+
function quantileFromBuckets(bucketCounts, bounds, count, q) {
|
|
2752
|
+
if (count <= 0) {
|
|
2753
|
+
return 0;
|
|
2754
|
+
}
|
|
2755
|
+
const rank = q * count;
|
|
2756
|
+
let cumulative = 0;
|
|
2757
|
+
for (let i = 0; i < bounds.length; i += 1) {
|
|
2758
|
+
const inBucket = bucketCounts[i] ?? 0;
|
|
2759
|
+
if (inBucket > 0 && cumulative + inBucket >= rank) {
|
|
2760
|
+
const lower = i === 0 ? 0 : bounds[i - 1] ?? 0;
|
|
2761
|
+
const upper = bounds[i] ?? lower;
|
|
2762
|
+
return lower + (upper - lower) * ((rank - cumulative) / inBucket);
|
|
2763
|
+
}
|
|
2764
|
+
cumulative += inBucket;
|
|
2765
|
+
}
|
|
2766
|
+
return bounds[bounds.length - 1] ?? 0;
|
|
2767
|
+
}
|
|
2722
2768
|
function cleanLabel(value) {
|
|
2723
2769
|
let result = "";
|
|
2724
2770
|
for (const char of value) {
|
|
@@ -2768,9 +2814,842 @@ function formatNumber(value) {
|
|
|
2768
2814
|
return Number.isInteger(value) ? value.toString() : String(value);
|
|
2769
2815
|
}
|
|
2770
2816
|
|
|
2817
|
+
// src/server.ts
|
|
2818
|
+
import { createHash, timingSafeEqual } from "crypto";
|
|
2819
|
+
import { Elysia } from "elysia";
|
|
2820
|
+
|
|
2821
|
+
// src/dashboard.ts
|
|
2822
|
+
var DASHBOARD_HTML = `<!doctype html>
|
|
2823
|
+
<html lang="en">
|
|
2824
|
+
<head>
|
|
2825
|
+
<meta charset="utf-8" />
|
|
2826
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2827
|
+
<meta name="color-scheme" content="dark light" />
|
|
2828
|
+
<title>hoopilot · dashboard</title>
|
|
2829
|
+
<style>
|
|
2830
|
+
:root {
|
|
2831
|
+
--bg-0:#0b0e14; --bg-1:#11151c; --bg-2:#171c25; --bg-3:#1f2630;
|
|
2832
|
+
--border:#262d38; --border-strong:#37404d;
|
|
2833
|
+
--text-0:#e6edf3; --text-1:#9aa7b4; --text-2:#5e6b78; --text-dim:#3a434e; --text-inv:#0b0e14;
|
|
2834
|
+
--accent:#4ea1ff; --accent-2:#56d4dd; --accent-soft:rgba(78,161,255,.14);
|
|
2835
|
+
--amber:#f5b042;
|
|
2836
|
+
--ok:#3fb950; --warn:#d8a13a; --danger:#f0556a; --info:#a371f7; --cache:#7c8cff;
|
|
2837
|
+
--spark:#4ea1ff; --spark-fill:color-mix(in srgb, var(--accent) 14%, transparent);
|
|
2838
|
+
--grid-line:rgba(255,255,255,.05);
|
|
2839
|
+
--flash:color-mix(in srgb, var(--accent) 22%, transparent);
|
|
2840
|
+
--flash-up:color-mix(in srgb, var(--ok) 22%, transparent);
|
|
2841
|
+
--flash-down:color-mix(in srgb, var(--danger) 22%, transparent);
|
|
2842
|
+
--c1:#4ea1ff; --c2:#3fb950; --c3:#d8a13a; --c4:#a371f7; --c5:#56d4dd; --c6:#f0556a;
|
|
2843
|
+
--mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
|
2844
|
+
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, system-ui, sans-serif;
|
|
2845
|
+
}
|
|
2846
|
+
@media (prefers-color-scheme: light) {
|
|
2847
|
+
:root:not([data-theme="dark"]) {
|
|
2848
|
+
--bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
|
|
2849
|
+
--border:#d0d7de; --border-strong:#b6bec8;
|
|
2850
|
+
--text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
|
|
2851
|
+
--accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
|
|
2852
|
+
--amber:#b5730a;
|
|
2853
|
+
--ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
|
|
2854
|
+
--spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
|
|
2855
|
+
--grid-line:rgba(0,0,0,.06);
|
|
2856
|
+
--flash:color-mix(in srgb, var(--accent) 16%, transparent);
|
|
2857
|
+
--flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
|
|
2858
|
+
--flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
|
|
2859
|
+
--c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
[data-theme="light"] {
|
|
2863
|
+
--bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
|
|
2864
|
+
--border:#d0d7de; --border-strong:#b6bec8;
|
|
2865
|
+
--text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
|
|
2866
|
+
--accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
|
|
2867
|
+
--amber:#b5730a;
|
|
2868
|
+
--ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
|
|
2869
|
+
--spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
|
|
2870
|
+
--grid-line:rgba(0,0,0,.06);
|
|
2871
|
+
--flash:color-mix(in srgb, var(--accent) 16%, transparent);
|
|
2872
|
+
--flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
|
|
2873
|
+
--flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
|
|
2874
|
+
--c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
|
|
2875
|
+
}
|
|
2876
|
+
* { box-sizing: border-box; }
|
|
2877
|
+
html, body { margin:0; padding:0; }
|
|
2878
|
+
body {
|
|
2879
|
+
background: var(--bg-0); color: var(--text-0); font-family: var(--sans);
|
|
2880
|
+
font-size: 13px; line-height: 1.4; -webkit-font-smoothing: antialiased;
|
|
2881
|
+
}
|
|
2882
|
+
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
|
|
2883
|
+
.num { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
|
|
2884
|
+
.shell { max-width: 1280px; margin: 0 auto; padding: 0 24px 28px; }
|
|
2885
|
+
@media (min-width: 1080px) { .shell { border-left:1px solid var(--border); border-right:1px solid var(--border); } }
|
|
2886
|
+
@media (max-width: 680px) { .shell { padding: 0 12px 24px; } }
|
|
2887
|
+
|
|
2888
|
+
/* header */
|
|
2889
|
+
header.bar {
|
|
2890
|
+
position: sticky; top: 0; z-index: 20; background: var(--bg-1);
|
|
2891
|
+
border-bottom: 1px solid var(--border); height: 48px;
|
|
2892
|
+
}
|
|
2893
|
+
.bar-in { max-width:1280px; margin:0 auto; height:48px; padding:0 24px; display:flex; align-items:center; gap:12px; }
|
|
2894
|
+
@media (max-width:680px){ .bar-in{ padding:0 12px; gap:8px; } }
|
|
2895
|
+
.wordmark { font-family: var(--mono); font-weight:700; font-size:14px; color:var(--text-0); letter-spacing:-.01em; }
|
|
2896
|
+
.caret { display:inline-block; width:7px; height:15px; background:var(--amber); margin-left:3px; vertical-align:-2px; animation: blink 1.1s steps(1) infinite; }
|
|
2897
|
+
.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; }
|
|
2898
|
+
.chip.plan-pro { background:var(--accent-soft); color:var(--accent); }
|
|
2899
|
+
.chip.plan-business { background:color-mix(in srgb, var(--info) 16%, transparent); color:var(--info); }
|
|
2900
|
+
.chip.plan-free, .chip.plan-offline { background:var(--bg-3); color:var(--text-2); }
|
|
2901
|
+
.spacer { flex:1; }
|
|
2902
|
+
.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); }
|
|
2903
|
+
.dot { width:7px; height:7px; border-radius:50%; background:var(--text-2); flex:none; }
|
|
2904
|
+
.pill.live .dot { background:var(--ok); }
|
|
2905
|
+
.pill.paused .dot { background:var(--text-2); }
|
|
2906
|
+
.pill.reconnect { color:var(--warn); } .pill.reconnect .dot { background:var(--warn); }
|
|
2907
|
+
.pill.authkey { color:var(--warn); } .pill.authkey .dot { background:var(--warn); }
|
|
2908
|
+
.heartbeat { animation: hb .5s ease-out; }
|
|
2909
|
+
.updated { font-family:var(--mono); font-size:11px; color:var(--text-2); white-space:nowrap; }
|
|
2910
|
+
.updated.warn { color:var(--warn); } .updated.danger { color:var(--danger); }
|
|
2911
|
+
.seg { display:inline-flex; border:1px solid var(--border); border-radius:6px; overflow:hidden; }
|
|
2912
|
+
.seg button { background:transparent; color:var(--text-1); border:0; font-family:var(--mono); font-size:11px; padding:3px 8px; cursor:pointer; }
|
|
2913
|
+
.seg button + button { border-left:1px solid var(--border); }
|
|
2914
|
+
.seg button.active { background:var(--accent); color:var(--text-inv); }
|
|
2915
|
+
.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; }
|
|
2916
|
+
.iconbtn:hover { background:var(--bg-3); }
|
|
2917
|
+
button:focus-visible, input:focus-visible, .seg button:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
|
|
2918
|
+
#scanbar { position:absolute; left:0; bottom:-1px; height:1px; width:100%; overflow:hidden; }
|
|
2919
|
+
#scanbar::after { content:""; position:absolute; left:0; top:0; height:1px; width:40%;
|
|
2920
|
+
background:linear-gradient(90deg, transparent, var(--accent), transparent);
|
|
2921
|
+
animation: scan var(--scan-ms, 4000ms) linear infinite; }
|
|
2922
|
+
header.bar.paused #scanbar::after, header.bar.frozen #scanbar::after { animation-play-state:paused; opacity:.35; }
|
|
2923
|
+
|
|
2924
|
+
/* disconnect banner */
|
|
2925
|
+
#banner { display:none; margin-top:10px; padding:7px 12px; border-radius:5px; font-family:var(--mono); font-size:12px;
|
|
2926
|
+
background:color-mix(in srgb, var(--danger) 16%, transparent); color:var(--danger); border:1px solid color-mix(in srgb, var(--danger) 40%, transparent); }
|
|
2927
|
+
#banner.ok { background:color-mix(in srgb, var(--ok) 16%, transparent); color:var(--ok); border-color:color-mix(in srgb, var(--ok) 40%, transparent); }
|
|
2928
|
+
#banner.show { display:block; }
|
|
2929
|
+
|
|
2930
|
+
/* hero strip */
|
|
2931
|
+
.hero { display:grid; grid-template-columns:repeat(4,1fr); margin:18px 0 16px; }
|
|
2932
|
+
.vital { padding:6px 18px; }
|
|
2933
|
+
.vital + .vital { border-left:1px solid var(--border); }
|
|
2934
|
+
.vital .eyebrow { font-size:10px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-1); }
|
|
2935
|
+
.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); }
|
|
2936
|
+
.vital .vsub { font-size:11px; color:var(--text-2); min-height:14px; }
|
|
2937
|
+
.vital .vspark { display:block; width:100%; height:24px; margin-top:4px; }
|
|
2938
|
+
.vital.active { }
|
|
2939
|
+
.vital.active .eyebrow { color:var(--accent); }
|
|
2940
|
+
@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; } }
|
|
2941
|
+
@media (max-width:600px){ .hero{ grid-template-columns:1fr; } .vital + .vital{ border-left:0; border-top:1px solid var(--border); } }
|
|
2942
|
+
|
|
2943
|
+
/* grid + panels */
|
|
2944
|
+
.grid { display:grid; grid-template-columns:repeat(12,1fr); gap:12px; }
|
|
2945
|
+
.panel { position:relative; background:var(--bg-1); border:1px solid var(--border); border-radius:4px; padding:16px 12px 12px; min-width:0; }
|
|
2946
|
+
.panel > .ptitle { position:absolute; top:-8px; left:10px; padding:0 6px; background:var(--bg-1);
|
|
2947
|
+
font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
|
|
2948
|
+
.span5{ grid-column:span 5; } .span3{ grid-column:span 3; } .span4{ grid-column:span 4; }
|
|
2949
|
+
.span7{ grid-column:span 7; } .span8{ grid-column:span 8; }
|
|
2950
|
+
@media (max-width:1079px){ .grid{ grid-template-columns:repeat(6,1fr); }
|
|
2951
|
+
.span5,.span7,.span8{ grid-column:span 6; } .span3{ grid-column:span 3; } .span4{ grid-column:span 6; } }
|
|
2952
|
+
@media (max-width:680px){ .grid{ grid-template-columns:1fr; }
|
|
2953
|
+
.span3,.span4,.span5,.span7,.span8{ grid-column:span 1; } }
|
|
2954
|
+
|
|
2955
|
+
.headline { font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-weight:600; font-size:22px; line-height:1.1; }
|
|
2956
|
+
.cap { font-size:11px; color:var(--text-2); }
|
|
2957
|
+
.stack-bar { display:flex; height:8px; border-radius:4px; overflow:hidden; background:var(--bg-3); margin:8px 0; }
|
|
2958
|
+
.stack-bar i { display:block; height:100%; }
|
|
2959
|
+
.stack-bar.empty { outline:1px dashed var(--border); background:transparent; }
|
|
2960
|
+
|
|
2961
|
+
table.tbl { width:100%; border-collapse:collapse; font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-size:12px; }
|
|
2962
|
+
.scrollx { overflow-x:auto; }
|
|
2963
|
+
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; }
|
|
2964
|
+
table.tbl th.l { text-align:left; }
|
|
2965
|
+
table.tbl td { padding:3px 6px; text-align:right; white-space:nowrap; border-bottom:1px solid color-mix(in srgb, var(--border) 55%, transparent); }
|
|
2966
|
+
table.tbl td.l { text-align:left; max-width:160px; overflow:hidden; text-overflow:ellipsis; }
|
|
2967
|
+
table.tbl tr:hover td { background:var(--bg-2); }
|
|
2968
|
+
table.tbl tr.total td { border-top:1px solid var(--border-strong); border-bottom:0; font-weight:600; color:var(--text-0); }
|
|
2969
|
+
.minibar { display:inline-block; height:6px; border-radius:3px; background:var(--accent); vertical-align:middle; min-width:1px; }
|
|
2970
|
+
.ghost td { color:var(--text-2); text-align:center; }
|
|
2971
|
+
.reasoning { color:var(--info); } .cached { color:var(--cache); }
|
|
2972
|
+
|
|
2973
|
+
.legend { display:flex; flex-wrap:wrap; gap:4px 14px; margin-top:8px; }
|
|
2974
|
+
.legend .li { display:flex; align-items:center; gap:6px; font-family:var(--mono); font-size:11px; color:var(--text-1); }
|
|
2975
|
+
.legend .sw { width:8px; height:8px; border-radius:2px; flex:none; }
|
|
2976
|
+
|
|
2977
|
+
.lat-trio { display:flex; gap:18px; align-items:baseline; }
|
|
2978
|
+
.lat-trio .b { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
|
|
2979
|
+
.lat-trio .b small { display:block; font-size:10px; font-weight:600; text-transform:uppercase; color:var(--text-2); letter-spacing:.05em; }
|
|
2980
|
+
.lat-p95 { color:var(--info); }
|
|
2981
|
+
.lat-track { position:relative; height:22px; margin-top:10px; }
|
|
2982
|
+
.lat-track .line { position:absolute; top:11px; left:0; right:0; height:1px; background:var(--border); }
|
|
2983
|
+
.lat-track .tick { position:absolute; top:5px; width:2px; height:12px; border-radius:1px; }
|
|
2984
|
+
.lat-track .tick.p50 { background:var(--accent); } .lat-track .tick.p95 { background:var(--info); }
|
|
2985
|
+
.lat-track .tlab { position:absolute; top:-2px; font-family:var(--mono); font-size:9px; color:var(--text-2); transform:translateX(-50%); }
|
|
2986
|
+
details.routes { margin-top:10px; } details.routes summary { cursor:pointer; font-size:11px; color:var(--text-2); font-family:var(--mono); }
|
|
2987
|
+
|
|
2988
|
+
.qrow { margin:10px 0; } .qrow .qhead { display:flex; justify-content:space-between; align-items:baseline; font-size:12px; }
|
|
2989
|
+
.qrow .qname { color:var(--text-1); } .qrow .qval { font-family:var(--mono); font-variant-numeric:tabular-nums; color:var(--text-0); }
|
|
2990
|
+
.qbar { position:relative; height:8px; border-radius:4px; background:var(--bg-3); margin-top:5px; overflow:hidden; }
|
|
2991
|
+
.qbar i { position:absolute; left:0; top:0; height:100%; border-radius:4px; }
|
|
2992
|
+
.qbar.over i.ext { background:repeating-linear-gradient(45deg, var(--danger), var(--danger) 3px, transparent 3px, transparent 6px); }
|
|
2993
|
+
.inf { font-family:var(--mono); font-size:12px; color:var(--ok); }
|
|
2994
|
+
.emptybox { border:1px solid var(--border); border-radius:5px; padding:14px; text-align:center; color:var(--text-2); }
|
|
2995
|
+
.emptybox .keyglyph { font-size:20px; color:var(--text-1); }
|
|
2996
|
+
.emptybox h4 { margin:8px 0 4px; font-family:var(--sans); font-size:13px; color:var(--text-1); font-weight:600; }
|
|
2997
|
+
.emptybox .errline { font-family:var(--mono); font-size:11px; color:var(--text-2); word-break:break-word; margin:4px 0; }
|
|
2998
|
+
.prompt { font-family:var(--mono); font-size:12px; color:var(--text-1); }
|
|
2999
|
+
|
|
3000
|
+
.upblocks { display:flex; gap:18px; }
|
|
3001
|
+
.upblk { } .upblk .v { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
|
|
3002
|
+
.upblk .k { font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-2); }
|
|
3003
|
+
.upblk.err.hot { color:var(--danger); }
|
|
3004
|
+
.rate { font-family:var(--mono); font-size:12px; } .rate.warn{ color:var(--warn);} .rate.danger{ color:var(--danger);} .rate.ok{ color:var(--ok); }
|
|
3005
|
+
#up-spark, #thru-svg { display:block; width:100%; }
|
|
3006
|
+
#up-spark { height:30px; margin-top:8px; }
|
|
3007
|
+
#thru-svg { height:88px; margin-top:6px; }
|
|
3008
|
+
.flag { font-family:var(--mono); font-size:10px; color:var(--text-2); }
|
|
3009
|
+
|
|
3010
|
+
footer.foot { margin-top:14px; padding-top:10px; border-top:1px solid var(--border); display:flex; flex-wrap:wrap; gap:4px 14px;
|
|
3011
|
+
font-family:var(--mono); font-size:11px; color:var(--text-2); }
|
|
3012
|
+
footer.foot .end { margin-left:auto; }
|
|
3013
|
+
@media (max-width:680px){ footer.foot .end{ margin-left:0; } }
|
|
3014
|
+
|
|
3015
|
+
.skel { color:var(--text-dim); }
|
|
3016
|
+
.flash { animation: flash .6s ease-out; } .flash-up { animation: flashup .6s ease-out; } .flash-down { animation: flashdown .6s ease-out; }
|
|
3017
|
+
|
|
3018
|
+
/* auth takeover */
|
|
3019
|
+
#auth { display:none; }
|
|
3020
|
+
#auth.show { display:flex; justify-content:center; padding:64px 16px; }
|
|
3021
|
+
.authcard { width:100%; max-width:420px; background:var(--bg-1); border:1px solid var(--border); border-radius:6px; padding:22px 18px; position:relative; }
|
|
3022
|
+
.authcard h3 { margin:0 0 10px; font-family:var(--mono); font-size:12px; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
|
|
3023
|
+
.authcard p { font-size:12px; color:var(--text-2); margin:0 0 14px; }
|
|
3024
|
+
.authcard .row { display:flex; gap:8px; }
|
|
3025
|
+
.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; }
|
|
3026
|
+
.authcard input.bad { border-color:var(--danger); }
|
|
3027
|
+
.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; }
|
|
3028
|
+
.authcard .err { color:var(--danger); font-family:var(--mono); font-size:11px; min-height:14px; margin-top:8px; }
|
|
3029
|
+
.authcard .clear { position:absolute; top:14px; right:16px; font-size:11px; color:var(--text-2); cursor:pointer; }
|
|
3030
|
+
.dim { opacity:.45; filter:grayscale(.4); transition:opacity .2s, filter .2s; }
|
|
3031
|
+
|
|
3032
|
+
@keyframes blink { 50% { opacity:0; } }
|
|
3033
|
+
@keyframes scan { 0%{ transform:translateX(-100%);} 100%{ transform:translateX(350%);} }
|
|
3034
|
+
@keyframes hb { 0%{ transform:scale(1);} 35%{ transform:scale(1.7);} 100%{ transform:scale(1);} }
|
|
3035
|
+
@keyframes flash { from{ background:var(--flash);} to{ background:transparent;} }
|
|
3036
|
+
@keyframes flashup { from{ background:var(--flash-up);} to{ background:transparent;} }
|
|
3037
|
+
@keyframes flashdown { from{ background:var(--flash-down);} to{ background:transparent;} }
|
|
3038
|
+
@media (prefers-reduced-motion: reduce) {
|
|
3039
|
+
.caret { animation:none; } #scanbar::after { animation:none; opacity:.3; }
|
|
3040
|
+
.heartbeat { animation:none; }
|
|
3041
|
+
.flash, .flash-up, .flash-down { animation:none; box-shadow: inset 2px 0 0 var(--accent); }
|
|
3042
|
+
}
|
|
3043
|
+
</style>
|
|
3044
|
+
</head>
|
|
3045
|
+
<body>
|
|
3046
|
+
<header class="bar" id="bar">
|
|
3047
|
+
<div class="bar-in">
|
|
3048
|
+
<span class="wordmark">hoopilot<span class="caret" aria-hidden="true"></span></span>
|
|
3049
|
+
<span class="chip" id="version-chip">v···</span>
|
|
3050
|
+
<span class="chip plan-offline" id="plan-chip">— offline</span>
|
|
3051
|
+
<span class="spacer"></span>
|
|
3052
|
+
<span class="pill" id="conn-pill" aria-live="polite"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting</span></span>
|
|
3053
|
+
<span class="updated" id="updated"></span>
|
|
3054
|
+
<span class="seg" id="seg" role="group" aria-label="Refresh interval">
|
|
3055
|
+
<button data-ms="2000">2s</button><button data-ms="4000" class="active">4s</button><button data-ms="10000">10s</button>
|
|
3056
|
+
</span>
|
|
3057
|
+
<button class="iconbtn" id="btn-pause" title="Pause / resume" aria-label="Pause or resume">❚❚</button>
|
|
3058
|
+
<button class="iconbtn" id="btn-theme" title="Theme: auto / dark / light" aria-label="Cycle theme">A</button>
|
|
3059
|
+
</div>
|
|
3060
|
+
<div id="scanbar" aria-hidden="true"></div>
|
|
3061
|
+
</header>
|
|
3062
|
+
|
|
3063
|
+
<div class="shell">
|
|
3064
|
+
<div id="banner" role="status" aria-live="polite"></div>
|
|
3065
|
+
|
|
3066
|
+
<section id="content">
|
|
3067
|
+
<section class="hero" aria-label="Vitals">
|
|
3068
|
+
<div class="vital" id="v-req"><div class="eyebrow">Req / s</div><div class="vnum skel" id="req-num">···</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>
|
|
3069
|
+
<div class="vital" id="v-tok"><div class="eyebrow">Tokens / s</div><div class="vnum skel" id="tok-num">···</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>
|
|
3070
|
+
<div class="vital" id="v-inflight"><div class="eyebrow">In‑flight</div><div class="vnum skel" id="inflight-num">···</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>
|
|
3071
|
+
<div class="vital" id="v-uptime"><div class="eyebrow">Uptime</div><div class="vnum skel" id="uptime-num">···</div><div class="vsub" id="uptime-sub"></div></div>
|
|
3072
|
+
</section>
|
|
3073
|
+
|
|
3074
|
+
<section class="grid">
|
|
3075
|
+
<div class="panel span5"><span class="ptitle">┤ Proxy · requests ┠</span>
|
|
3076
|
+
<div class="headline"><span id="req-total" class="skel">···</span> <span class="cap">requests</span></div>
|
|
3077
|
+
<div class="stack-bar empty" id="route-sharebar"></div>
|
|
3078
|
+
<div class="stack-bar empty" id="status-healthbar"></div>
|
|
3079
|
+
<div class="scrollx"><table class="tbl"><thead><tr><th class="l">Route</th><th>Count</th><th>%</th><th style="width:60px"> </th></tr></thead><tbody id="routes-body"><tr class="ghost"><td colspan="4">loading…</td></tr></tbody></table></div>
|
|
3080
|
+
</div>
|
|
3081
|
+
|
|
3082
|
+
<div class="panel span3"><span class="ptitle">┤ Status ┠</span>
|
|
3083
|
+
<div class="headline"><span id="error-rate" class="skel">···</span> <span class="cap">err rate</span></div>
|
|
3084
|
+
<div class="stack-bar empty" id="status-bar"></div>
|
|
3085
|
+
<div class="legend" id="status-legend"></div>
|
|
3086
|
+
</div>
|
|
3087
|
+
|
|
3088
|
+
<div class="panel span4"><span class="ptitle">┤ Latency · ms ┠</span>
|
|
3089
|
+
<div class="lat-trio">
|
|
3090
|
+
<div class="b"><small>p50</small><span id="lat-p50" class="skel">·</span></div>
|
|
3091
|
+
<div class="b lat-p95"><small>p95</small><span id="lat-p95" class="skel">·</span></div>
|
|
3092
|
+
<div class="b"><small>avg</small><span id="lat-avg" class="skel">·</span></div>
|
|
3093
|
+
<div class="b"><small>obs</small><span id="lat-count" class="skel">·</span></div>
|
|
3094
|
+
</div>
|
|
3095
|
+
<div class="lat-track" id="lat-track"><div class="line"></div></div>
|
|
3096
|
+
<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>
|
|
3097
|
+
</div>
|
|
3098
|
+
|
|
3099
|
+
<div class="panel span7"><span class="ptitle">┤ Tokens · by model ┠</span>
|
|
3100
|
+
<div class="headline"><span id="tok-total" class="skel">···</span> <span class="cap">tokens · <span id="tok-cache">cache ·%</span></span></div>
|
|
3101
|
+
<div class="stack-bar empty" id="tok-mixbar"></div>
|
|
3102
|
+
<div class="legend" id="tok-legend"></div>
|
|
3103
|
+
<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>
|
|
3104
|
+
</div>
|
|
3105
|
+
|
|
3106
|
+
<div class="panel span5"><span class="ptitle">┤ Copilot · quota ┠</span>
|
|
3107
|
+
<div id="copilot-body"><div class="emptybox skel">loading…</div></div>
|
|
3108
|
+
</div>
|
|
3109
|
+
|
|
3110
|
+
<div class="panel span4"><span class="ptitle">┤ Upstream · copilot edge ┠</span>
|
|
3111
|
+
<div class="upblocks">
|
|
3112
|
+
<div class="upblk"><div class="v" id="up-total">·</div><div class="k">calls</div></div>
|
|
3113
|
+
<div class="upblk err" id="up-errblk"><div class="v" id="up-errors">·</div><div class="k">errors</div></div>
|
|
3114
|
+
<div class="upblk"><div class="v rate" id="up-rate">·</div><div class="k">err rate</div></div>
|
|
3115
|
+
</div>
|
|
3116
|
+
<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>
|
|
3117
|
+
<div class="flag" id="up-flag"></div>
|
|
3118
|
+
</div>
|
|
3119
|
+
|
|
3120
|
+
<div class="panel span8"><span class="ptitle">┤ Throughput ┠</span>
|
|
3121
|
+
<div class="cap"><span style="color:var(--accent)">■</span> tokens/s <span id="thru-tok" class="num"></span> <span style="color:var(--accent-2)">■</span> req/s <span id="thru-req" class="num"></span> <span class="end" id="thru-peak" style="float:right"></span></div>
|
|
3122
|
+
<svg id="thru-svg" viewBox="0 0 320 88" preserveAspectRatio="none" aria-hidden="true">
|
|
3123
|
+
<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>
|
|
3124
|
+
<line class="grid" x1="0" y1="22" x2="320" y2="22" stroke="var(--grid-line)"/>
|
|
3125
|
+
<line class="grid" x1="0" y1="44" x2="320" y2="44" stroke="var(--grid-line)"/>
|
|
3126
|
+
<line class="grid" x1="0" y1="66" x2="320" y2="66" stroke="var(--grid-line)"/>
|
|
3127
|
+
<path id="thru-tok-area" fill="url(#thrugrad)" stroke="none"/>
|
|
3128
|
+
<path id="thru-tok-line" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>
|
|
3129
|
+
<path id="thru-req-line" fill="none" stroke="var(--accent-2)" stroke-width="1.2" vector-effect="non-scaling-stroke" opacity="0.9"/>
|
|
3130
|
+
</svg>
|
|
3131
|
+
</div>
|
|
3132
|
+
</section>
|
|
3133
|
+
</section>
|
|
3134
|
+
|
|
3135
|
+
<section id="auth" aria-live="polite">
|
|
3136
|
+
<div class="authcard">
|
|
3137
|
+
<span class="clear" id="auth-clear" style="display:none">clear key</span>
|
|
3138
|
+
<h3>┤ Auth required ┠</h3>
|
|
3139
|
+
<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>
|
|
3140
|
+
<div class="row"><input id="auth-input" type="password" placeholder="x-api-key" autocomplete="off" spellcheck="false" /><button id="auth-connect">connect</button></div>
|
|
3141
|
+
<div class="err" id="auth-err"></div>
|
|
3142
|
+
</div>
|
|
3143
|
+
</section>
|
|
3144
|
+
|
|
3145
|
+
<footer class="foot">
|
|
3146
|
+
<span id="foot-started">started ·</span>
|
|
3147
|
+
<span id="foot-uptime">uptime ·</span>
|
|
3148
|
+
<span id="foot-total">· req</span>
|
|
3149
|
+
<span id="foot-tokens">· tokens</span>
|
|
3150
|
+
<span id="foot-upstream">upstream ·</span>
|
|
3151
|
+
<span class="end" id="foot-cadence"></span>
|
|
3152
|
+
</footer>
|
|
3153
|
+
</div>
|
|
3154
|
+
|
|
3155
|
+
<script>
|
|
3156
|
+
(function(){
|
|
3157
|
+
"use strict";
|
|
3158
|
+
var byId = function(id){ return document.getElementById(id); };
|
|
3159
|
+
var CAP = 60;
|
|
3160
|
+
|
|
3161
|
+
// ---- persistent state ----
|
|
3162
|
+
var LS = window.localStorage;
|
|
3163
|
+
var apiKey = "";
|
|
3164
|
+
try { apiKey = LS.getItem("hoopilot.apiKey") || ""; } catch (e) { apiKey = ""; }
|
|
3165
|
+
var theme = "auto";
|
|
3166
|
+
try { theme = LS.getItem("hoopilot.theme") || "auto"; } catch (e) { theme = "auto"; }
|
|
3167
|
+
var intervalMs = 4000;
|
|
3168
|
+
try { var sv = parseInt(LS.getItem("hoopilot.intervalMs") || "", 10); if (sv === 2000 || sv === 4000 || sv === 10000) intervalMs = sv; } catch (e) {}
|
|
3169
|
+
|
|
3170
|
+
// ---- runtime state ----
|
|
3171
|
+
var paused = false;
|
|
3172
|
+
var timer = null;
|
|
3173
|
+
var inflightFetch = null;
|
|
3174
|
+
var lastSuccessAt = 0;
|
|
3175
|
+
var prevSample = null; // { t, reqTotal, tokTotal, upTotal, startedAt }
|
|
3176
|
+
var lastRender = {}; // for change-flash
|
|
3177
|
+
var backoffMs = 0;
|
|
3178
|
+
var lastUptime = null; // seconds; ticked locally between polls
|
|
3179
|
+
var hist = { req:[], tok:[], inflight:[], up:[] };
|
|
3180
|
+
|
|
3181
|
+
// ---- formatting helpers ----
|
|
3182
|
+
function humanInt(n){
|
|
3183
|
+
if (n === null || n === undefined || !isFinite(n)) return "0";
|
|
3184
|
+
var a = Math.abs(n);
|
|
3185
|
+
if (a >= 1000000) return (n/1000000).toFixed(a >= 10000000 ? 0 : 1) + "M";
|
|
3186
|
+
if (a >= 1000) return (n/1000).toFixed(a >= 10000 ? 0 : 1) + "k";
|
|
3187
|
+
return String(Math.round(n));
|
|
3188
|
+
}
|
|
3189
|
+
function rate(n){
|
|
3190
|
+
if (n === null || n === undefined || !isFinite(n)) return "0";
|
|
3191
|
+
if (n >= 100) return String(Math.round(n));
|
|
3192
|
+
if (n >= 10) return n.toFixed(1);
|
|
3193
|
+
return n.toFixed(2);
|
|
3194
|
+
}
|
|
3195
|
+
function pct(n){ if (!isFinite(n)) return "0%"; return (n >= 10 ? Math.round(n) : Math.round(n*10)/10) + "%"; }
|
|
3196
|
+
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 + ""; }
|
|
3197
|
+
function pad2(n){ return (n < 10 ? "0" : "") + n; }
|
|
3198
|
+
function fmtUptime(sec){
|
|
3199
|
+
sec = Math.max(0, Math.floor(sec));
|
|
3200
|
+
var d = Math.floor(sec/86400); sec -= d*86400;
|
|
3201
|
+
var h = Math.floor(sec/3600); sec -= h*3600;
|
|
3202
|
+
var m = Math.floor(sec/60); var s = sec - m*60;
|
|
3203
|
+
if (d > 0) return d + "d " + pad2(h) + ":" + pad2(m);
|
|
3204
|
+
if (h > 0) return h + ":" + pad2(m) + ":" + pad2(s);
|
|
3205
|
+
return m + ":" + pad2(s);
|
|
3206
|
+
}
|
|
3207
|
+
function titleize(key){
|
|
3208
|
+
var map = { premium_interactions:"Premium requests", chat:"Chat", completions:"Completions", code_review:"Code review" };
|
|
3209
|
+
if (map[key]) return map[key];
|
|
3210
|
+
return key.split("_").map(function(w){ return w ? w.charAt(0).toUpperCase() + w.slice(1) : w; }).join(" ");
|
|
3211
|
+
}
|
|
3212
|
+
function relTime(iso){
|
|
3213
|
+
var t = Date.parse(iso); if (!isFinite(t)) return iso || "";
|
|
3214
|
+
var s = Math.max(0, Math.round((Date.now() - t)/1000));
|
|
3215
|
+
return fmtUptime(s) + " ago";
|
|
3216
|
+
}
|
|
3217
|
+
function clearEl(el){ while (el && el.firstChild) el.removeChild(el.firstChild); }
|
|
3218
|
+
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; }
|
|
3219
|
+
|
|
3220
|
+
// Set numeric text and flash on discrete change.
|
|
3221
|
+
function setNum(id, value, kind, num){
|
|
3222
|
+
var el = byId(id); if (!el) return;
|
|
3223
|
+
el.classList.remove("skel");
|
|
3224
|
+
var s = String(value);
|
|
3225
|
+
if (el.textContent !== s){
|
|
3226
|
+
el.textContent = s;
|
|
3227
|
+
// Compare on the raw number (num) when provided, so directional flash works
|
|
3228
|
+
// even when value is a pre-formatted display string.
|
|
3229
|
+
var n = (num !== undefined) ? num : value;
|
|
3230
|
+
var prev = lastRender[id];
|
|
3231
|
+
if (prev !== undefined){
|
|
3232
|
+
var cls = "flash";
|
|
3233
|
+
if (kind === "delta" && typeof n === "number" && typeof prev === "number"){
|
|
3234
|
+
cls = n > prev ? "flash-up" : (n < prev ? "flash-down" : null);
|
|
3235
|
+
}
|
|
3236
|
+
if (cls){ el.classList.remove("flash","flash-up","flash-down"); void el.offsetWidth; el.classList.add(cls); }
|
|
3237
|
+
}
|
|
3238
|
+
lastRender[id] = n;
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
function setText(id, s){ var el = byId(id); if (el){ el.classList.remove("skel"); el.textContent = s; } }
|
|
3242
|
+
|
|
3243
|
+
// ---- sparkline rendering ----
|
|
3244
|
+
function pushHist(arr, v){ arr.push(v); if (arr.length > CAP) arr.shift(); }
|
|
3245
|
+
function buildSpark(values, w, h){
|
|
3246
|
+
var pts = []; for (var i=0;i<values.length;i++){ if (isFinite(values[i])) pts.push({ i:i, v:values[i] }); }
|
|
3247
|
+
if (pts.length < 2) return null;
|
|
3248
|
+
var min = Infinity, max = -Infinity;
|
|
3249
|
+
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; } }
|
|
3250
|
+
var flat = (max - min) <= 0;
|
|
3251
|
+
var pad = flat ? 1 : (max - min) * 0.05; var lo = min - pad, hi = max + pad; var span = hi - lo; if (span <= 0) span = 1;
|
|
3252
|
+
var n = values.length;
|
|
3253
|
+
var line = "", lastX = 0, lastY = 0, started = false;
|
|
3254
|
+
for (var k=0;k<n;k++){
|
|
3255
|
+
var val = values[k]; if (!isFinite(val)) continue;
|
|
3256
|
+
var x = (n === 1) ? w : (k * (w/(n-1)));
|
|
3257
|
+
var norm = flat ? 0.5 : (val - lo)/span;
|
|
3258
|
+
var y = h - norm*(h-2) - 1;
|
|
3259
|
+
line += (started ? " L" : "M") + x.toFixed(2) + "," + y.toFixed(2);
|
|
3260
|
+
lastX = x; lastY = y; started = true;
|
|
3261
|
+
}
|
|
3262
|
+
var area = line + " L" + lastX.toFixed(2) + "," + h + " L0," + h + " Z";
|
|
3263
|
+
return { line:line, area:area, lastX:lastX, lastY:lastY };
|
|
3264
|
+
}
|
|
3265
|
+
function drawSpark(svgId, values){
|
|
3266
|
+
var svg = byId(svgId); if (!svg) return;
|
|
3267
|
+
var vb = svg.viewBox.baseVal; var w = vb.width || 200, h = vb.height || 24;
|
|
3268
|
+
var sp = buildSpark(values, w, h);
|
|
3269
|
+
var line = svg.querySelector(".line"), area = svg.querySelector(".area"), dot = svg.querySelector("circle");
|
|
3270
|
+
if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); if (dot) dot.style.display = "none"; return; }
|
|
3271
|
+
if (line) line.setAttribute("d", sp.line);
|
|
3272
|
+
if (area) area.setAttribute("d", sp.area);
|
|
3273
|
+
if (dot){ dot.setAttribute("cx", sp.lastX.toFixed(2)); dot.setAttribute("cy", sp.lastY.toFixed(2)); dot.style.display = ""; }
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
// ---- theme ----
|
|
3277
|
+
function applyTheme(){
|
|
3278
|
+
var root = document.documentElement;
|
|
3279
|
+
if (theme === "dark") root.setAttribute("data-theme","dark");
|
|
3280
|
+
else if (theme === "light") root.setAttribute("data-theme","light");
|
|
3281
|
+
else root.removeAttribute("data-theme");
|
|
3282
|
+
byId("btn-theme").textContent = theme === "dark" ? "D" : (theme === "light" ? "L" : "A");
|
|
3283
|
+
}
|
|
3284
|
+
byId("btn-theme").addEventListener("click", function(){
|
|
3285
|
+
theme = theme === "auto" ? "dark" : (theme === "dark" ? "light" : "auto");
|
|
3286
|
+
try { LS.setItem("hoopilot.theme", theme); } catch (e) {}
|
|
3287
|
+
applyTheme();
|
|
3288
|
+
});
|
|
3289
|
+
|
|
3290
|
+
// ---- interval + pause ----
|
|
3291
|
+
function setActiveSeg(){
|
|
3292
|
+
var btns = byId("seg").querySelectorAll("button");
|
|
3293
|
+
for (var i=0;i<btns.length;i++){ btns[i].classList.toggle("active", parseInt(btns[i].getAttribute("data-ms"),10) === intervalMs); }
|
|
3294
|
+
document.documentElement.style.setProperty("--scan-ms", intervalMs + "ms");
|
|
3295
|
+
}
|
|
3296
|
+
byId("seg").addEventListener("click", function(ev){
|
|
3297
|
+
var b = ev.target.closest ? ev.target.closest("button") : null; if (!b) return;
|
|
3298
|
+
intervalMs = parseInt(b.getAttribute("data-ms"),10) || 4000;
|
|
3299
|
+
try { LS.setItem("hoopilot.intervalMs", String(intervalMs)); } catch (e) {}
|
|
3300
|
+
setActiveSeg();
|
|
3301
|
+
if (!paused){ schedule(0); }
|
|
3302
|
+
});
|
|
3303
|
+
byId("btn-pause").addEventListener("click", function(){
|
|
3304
|
+
paused = !paused;
|
|
3305
|
+
byId("btn-pause").innerHTML = paused ? "▶" : "❚❚";
|
|
3306
|
+
byId("bar").classList.toggle("paused", paused);
|
|
3307
|
+
if (paused){ if (timer){ clearTimeout(timer); timer = null; } setPill("paused","PAUSED",false); }
|
|
3308
|
+
else { setPill("live","LIVE",false); schedule(0); }
|
|
3309
|
+
});
|
|
3310
|
+
|
|
3311
|
+
// ---- connection pill / banner ----
|
|
3312
|
+
function setPill(kind, text, beat){
|
|
3313
|
+
var pill = byId("conn-pill"); var dot = byId("conn-dot");
|
|
3314
|
+
pill.className = "pill " + kind;
|
|
3315
|
+
byId("conn-text").textContent = text;
|
|
3316
|
+
if (beat && dot){ dot.classList.remove("heartbeat"); void dot.offsetWidth; dot.classList.add("heartbeat"); }
|
|
3317
|
+
}
|
|
3318
|
+
function showBanner(text, ok){
|
|
3319
|
+
var b = byId("banner"); b.textContent = text; b.className = "show" + (ok ? " ok" : "");
|
|
3320
|
+
if (ok){ setTimeout(function(){ b.classList.remove("show"); }, 2000); }
|
|
3321
|
+
}
|
|
3322
|
+
function hideBanner(){ byId("banner").classList.remove("show"); }
|
|
3323
|
+
function setDimmed(on){ byId("content").classList.toggle("dim", on); }
|
|
3324
|
+
|
|
3325
|
+
// ---- auth takeover ----
|
|
3326
|
+
function showAuth(rejected){
|
|
3327
|
+
byId("content").style.display = "none";
|
|
3328
|
+
byId("auth").classList.add("show");
|
|
3329
|
+
setPill("authkey","API KEY",false);
|
|
3330
|
+
byId("auth-err").textContent = rejected ? "key rejected" : "";
|
|
3331
|
+
byId("auth-input").classList.toggle("bad", !!rejected);
|
|
3332
|
+
byId("auth-clear").style.display = apiKey ? "" : "none";
|
|
3333
|
+
byId("auth-input").focus();
|
|
3334
|
+
}
|
|
3335
|
+
function hideAuth(){ byId("auth").classList.remove("show"); byId("content").style.display = ""; }
|
|
3336
|
+
byId("auth-connect").addEventListener("click", function(){
|
|
3337
|
+
var v = byId("auth-input").value.trim(); if (!v) return;
|
|
3338
|
+
apiKey = v; try { LS.setItem("hoopilot.apiKey", apiKey); } catch (e) {}
|
|
3339
|
+
hideAuth(); schedule(0);
|
|
3340
|
+
});
|
|
3341
|
+
byId("auth-input").addEventListener("keydown", function(ev){ if (ev.key === "Enter") byId("auth-connect").click(); });
|
|
3342
|
+
byId("auth-clear").addEventListener("click", function(){
|
|
3343
|
+
apiKey = ""; try { LS.removeItem("hoopilot.apiKey"); } catch (e) {}
|
|
3344
|
+
byId("auth-input").value = ""; byId("auth-clear").style.display = "none"; byId("auth-input").focus();
|
|
3345
|
+
});
|
|
3346
|
+
|
|
3347
|
+
// ---- the poll loop (setTimeout-chained, never setInterval) ----
|
|
3348
|
+
var pollGen = 0;
|
|
3349
|
+
function schedule(delay){
|
|
3350
|
+
if (timer){ clearTimeout(timer); }
|
|
3351
|
+
if (paused) return;
|
|
3352
|
+
timer = setTimeout(poll, delay === undefined ? intervalMs : delay);
|
|
3353
|
+
}
|
|
3354
|
+
function poll(){
|
|
3355
|
+
if (paused) return;
|
|
3356
|
+
// A new poll supersedes any in-flight one. Bump the generation so the old
|
|
3357
|
+
// request's settled handlers (including its abort rejection) become no-ops
|
|
3358
|
+
// and never flash a false "disconnected".
|
|
3359
|
+
pollGen += 1; var myGen = pollGen;
|
|
3360
|
+
if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
|
|
3361
|
+
var ctrl = new AbortController(); inflightFetch = ctrl;
|
|
3362
|
+
var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, 3000);
|
|
3363
|
+
var headers = { "accept":"application/json" };
|
|
3364
|
+
if (apiKey) headers["x-api-key"] = apiKey;
|
|
3365
|
+
fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
|
|
3366
|
+
clearTimeout(to);
|
|
3367
|
+
if (myGen !== pollGen) return null;
|
|
3368
|
+
if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
|
|
3369
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
3370
|
+
return res.json();
|
|
3371
|
+
}).then(function(data){
|
|
3372
|
+
if (myGen !== pollGen || data === null || paused) return;
|
|
3373
|
+
inflightFetch = null;
|
|
3374
|
+
onData(data);
|
|
3375
|
+
backoffMs = 0; lastSuccessAt = Date.now();
|
|
3376
|
+
hideAuth(); setDimmed(false); hideBanner();
|
|
3377
|
+
setPill("live","LIVE",true);
|
|
3378
|
+
byId("bar").classList.remove("frozen");
|
|
3379
|
+
schedule(intervalMs);
|
|
3380
|
+
}).catch(function(err){
|
|
3381
|
+
clearTimeout(to);
|
|
3382
|
+
if (myGen !== pollGen || paused) return;
|
|
3383
|
+
inflightFetch = null;
|
|
3384
|
+
onDisconnect(err);
|
|
3385
|
+
});
|
|
3386
|
+
}
|
|
3387
|
+
function onDisconnect(err){
|
|
3388
|
+
setPill("reconnect","RECONNECTING",false);
|
|
3389
|
+
setDimmed(true);
|
|
3390
|
+
byId("bar").classList.add("frozen");
|
|
3391
|
+
backoffMs = backoffMs ? Math.min(Math.round(backoffMs * 1.5), 30000) : intervalMs;
|
|
3392
|
+
showBanner("Disconnected (" + (err && err.message ? err.message : "no response") + ") \\u2014 retrying in " + Math.round(backoffMs/1000) + "s", false);
|
|
3393
|
+
schedule(backoffMs);
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
// ---- main render ----
|
|
3397
|
+
function onData(usage){
|
|
3398
|
+
var proxy = usage.proxy || {};
|
|
3399
|
+
var now = Date.now();
|
|
3400
|
+
|
|
3401
|
+
setText("version-chip", "v" + (usage.version || "?"));
|
|
3402
|
+
|
|
3403
|
+
// rates
|
|
3404
|
+
var reqTotal = (proxy.requests && proxy.requests.total) || 0;
|
|
3405
|
+
var tokTotal = (proxy.tokens && proxy.tokens.total) || 0;
|
|
3406
|
+
var upTotal = (proxy.upstream && proxy.upstream.total) || 0;
|
|
3407
|
+
var startedAt = proxy.startedAt || "";
|
|
3408
|
+
var reqPerSec = NaN, tokPerSec = NaN, upDelta = 0, restarted = false;
|
|
3409
|
+
if (prevSample){
|
|
3410
|
+
var dt = (now - prevSample.t)/1000;
|
|
3411
|
+
if (prevSample.startedAt && startedAt && prevSample.startedAt !== startedAt) restarted = true;
|
|
3412
|
+
if (reqTotal < prevSample.reqTotal || tokTotal < prevSample.tokTotal) restarted = true;
|
|
3413
|
+
if (restarted){ reqPerSec = 0; tokPerSec = 0; upDelta = 0; }
|
|
3414
|
+
else if (dt > 0 && isFinite(dt)){
|
|
3415
|
+
reqPerSec = Math.max(0, (reqTotal - prevSample.reqTotal)/dt);
|
|
3416
|
+
tokPerSec = Math.max(0, (tokTotal - prevSample.tokTotal)/dt);
|
|
3417
|
+
upDelta = Math.max(0, upTotal - prevSample.upTotal);
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
prevSample = { t:now, reqTotal:reqTotal, tokTotal:tokTotal, upTotal:upTotal, startedAt:startedAt };
|
|
3421
|
+
|
|
3422
|
+
// hero vitals
|
|
3423
|
+
if (isFinite(reqPerSec)){ pushHist(hist.req, reqPerSec); setNum("req-num", rate(reqPerSec)); } else setText("req-num","\\u2014");
|
|
3424
|
+
if (isFinite(tokPerSec)){ pushHist(hist.tok, tokPerSec); setNum("tok-num", humanInt(tokPerSec)); } else setText("tok-num","\\u2014");
|
|
3425
|
+
var inflight = proxy.inFlight || 0;
|
|
3426
|
+
pushHist(hist.inflight, inflight); setNum("inflight-num", String(inflight), "delta", inflight);
|
|
3427
|
+
byId("v-inflight").classList.toggle("active", inflight > 0);
|
|
3428
|
+
setText("uptime-num", fmtUptime(proxy.uptimeSeconds || 0));
|
|
3429
|
+
|
|
3430
|
+
setText("req-sub", hist.req.length ? ("avg " + rate(avg(hist.req)) + "/s") : "warming up");
|
|
3431
|
+
setText("tok-sub", hist.tok.length ? ("peak " + humanInt(Math.max.apply(null, hist.tok)) + "/s") : "warming up");
|
|
3432
|
+
setText("inflight-sub", inflight + " now");
|
|
3433
|
+
setText("uptime-sub", startedAt ? ("since " + relTime(startedAt)) : "");
|
|
3434
|
+
|
|
3435
|
+
drawSpark("req-spark", hist.req);
|
|
3436
|
+
drawSpark("tok-spark", hist.tok);
|
|
3437
|
+
drawSpark("inflight-spark", hist.inflight);
|
|
3438
|
+
|
|
3439
|
+
renderRequests(proxy);
|
|
3440
|
+
renderStatus(proxy);
|
|
3441
|
+
renderLatency(proxy.latency || {});
|
|
3442
|
+
renderTokens(proxy.tokens || {});
|
|
3443
|
+
renderCopilot(usage);
|
|
3444
|
+
renderUpstream(proxy.upstream || {}, upDelta, restarted);
|
|
3445
|
+
renderThroughput();
|
|
3446
|
+
renderFooter(usage, proxy);
|
|
3447
|
+
|
|
3448
|
+
setNum("req-total", humanInt(reqTotal));
|
|
3449
|
+
setNum("tok-total", humanInt(tokTotal));
|
|
3450
|
+
lastUptime = proxy.uptimeSeconds || 0;
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
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; }
|
|
3454
|
+
|
|
3455
|
+
var ROUTE_COLORS = ["var(--c1)","var(--c2)","var(--c3)","var(--c4)","var(--c5)","var(--c6)"];
|
|
3456
|
+
function renderRequests(proxy){
|
|
3457
|
+
var byRoute = (proxy.requests && proxy.requests.byRoute) || {};
|
|
3458
|
+
var total = (proxy.requests && proxy.requests.total) || 0;
|
|
3459
|
+
var rows = Object.keys(byRoute).map(function(k){ return { k:k, v:byRoute[k] }; }).sort(function(a,b){ return b.v - a.v; });
|
|
3460
|
+
var share = byId("route-sharebar"); clearEl(share); share.className = "stack-bar" + (total ? "" : " empty");
|
|
3461
|
+
var body = byId("routes-body"); clearEl(body);
|
|
3462
|
+
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; }
|
|
3463
|
+
rows.forEach(function(r, idx){
|
|
3464
|
+
var p = total ? (r.v/total*100) : 0;
|
|
3465
|
+
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);
|
|
3466
|
+
var tr = mk("tr");
|
|
3467
|
+
var name = mk("td","l", r.k); name.title = r.k; tr.appendChild(name);
|
|
3468
|
+
tr.appendChild(mk("td",null, humanInt(r.v)));
|
|
3469
|
+
tr.appendChild(mk("td",null, pct(p)));
|
|
3470
|
+
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);
|
|
3471
|
+
body.appendChild(tr);
|
|
3472
|
+
});
|
|
3473
|
+
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);
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
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"; }
|
|
3477
|
+
function statusColor(cls){ return cls === "ok" ? "var(--ok)" : cls === "info" ? "var(--info)" : cls === "warn" ? "var(--warn)" : cls === "danger" ? "var(--danger)" : "var(--text-2)"; }
|
|
3478
|
+
function renderStatus(proxy){
|
|
3479
|
+
var byStatus = (proxy.requests && proxy.requests.byStatus) || {};
|
|
3480
|
+
var total = 0, errs = 0; var groups = { ok:0, info:0, warn:0, danger:0, muted:0 };
|
|
3481
|
+
var codes = Object.keys(byStatus).map(function(k){ return { k:k, v:byStatus[k] }; }).sort(function(a,b){ return b.v - a.v; });
|
|
3482
|
+
codes.forEach(function(c){ total += c.v; var cls = statusClass(c.k); groups[cls] += c.v; if (cls === "warn" || cls === "danger") errs += c.v; });
|
|
3483
|
+
var bar = byId("status-bar"); clearEl(bar); bar.className = "stack-bar" + (total ? "" : " empty");
|
|
3484
|
+
["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); } });
|
|
3485
|
+
var leg = byId("status-legend"); clearEl(leg);
|
|
3486
|
+
if (!codes.length){ leg.appendChild(mk("span","li","no requests yet")); }
|
|
3487
|
+
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); });
|
|
3488
|
+
var er = total ? (errs/total*100) : 0;
|
|
3489
|
+
setNum("error-rate", pct(er));
|
|
3490
|
+
var el = byId("error-rate"); el.style.color = er > 5 ? "var(--danger)" : er > 1 ? "var(--warn)" : "var(--ok)";
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
function renderLatency(lat){
|
|
3494
|
+
setText("lat-p50", fmtMs(lat.p50Ms)); setText("lat-avg", fmtMs(lat.avgMs)); setText("lat-count", humanInt(lat.count || 0));
|
|
3495
|
+
var p95 = byId("lat-p95"); p95.classList.remove("skel"); p95.textContent = fmtMs(lat.p95Ms);
|
|
3496
|
+
p95.style.color = (lat.p50Ms > 0 && lat.p95Ms > 2*lat.p50Ms) ? "var(--warn)" : "var(--info)";
|
|
3497
|
+
// track: position p50 and p95 across 0..(p95*1.15)
|
|
3498
|
+
var track = byId("lat-track"); var old = track.querySelectorAll(".tick,.tlab"); for (var i=0;i<old.length;i++) old[i].remove();
|
|
3499
|
+
var maxv = Math.max(lat.p95Ms || 0, lat.avgMs || 0, 1) * 1.15;
|
|
3500
|
+
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); }
|
|
3501
|
+
place(lat.p50Ms, "p50"); place(lat.p95Ms, "p95");
|
|
3502
|
+
var lr = byId("lat-routes"); clearEl(lr);
|
|
3503
|
+
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); });
|
|
3504
|
+
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); });
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
function renderTokens(tok){
|
|
3508
|
+
var prompt = tok.prompt||0, completion = tok.completion||0, reasoning = tok.reasoning||0, cached = tok.cached||0;
|
|
3509
|
+
var sum = prompt + completion + reasoning;
|
|
3510
|
+
var bar = byId("tok-mixbar"); clearEl(bar); bar.className = "stack-bar" + (sum ? "" : " empty");
|
|
3511
|
+
var parts = [ ["prompt", prompt, "var(--text-1)"], ["completion", completion, "var(--accent)"], ["reasoning", reasoning, "var(--info)"] ];
|
|
3512
|
+
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); } });
|
|
3513
|
+
var leg = byId("tok-legend"); clearEl(leg);
|
|
3514
|
+
var legParts = parts.concat([["cached", cached, "var(--cache)"]]);
|
|
3515
|
+
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); });
|
|
3516
|
+
var cacheRate = prompt ? (cached/prompt*100) : 0; setText("tok-cache", "cache " + pct(cacheRate));
|
|
3517
|
+
var body = byId("tok-body"); clearEl(body);
|
|
3518
|
+
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); });
|
|
3519
|
+
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; }
|
|
3520
|
+
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);
|
|
3521
|
+
tr.appendChild(mk("td",null, humanInt(m.prompt||0))); tr.appendChild(mk("td",null, humanInt(m.completion||0)));
|
|
3522
|
+
tr.appendChild(mk("td","reasoning", humanInt(m.reasoning||0))); tr.appendChild(mk("td","cached", humanInt(m.cached||0)));
|
|
3523
|
+
tr.appendChild(mk("td",null, humanInt(m.total||0))); tr.appendChild(mk("td",null, humanInt(m.requests||0))); body.appendChild(tr); });
|
|
3524
|
+
}
|
|
3525
|
+
|
|
3526
|
+
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"; }
|
|
3527
|
+
function renderCopilot(usage){
|
|
3528
|
+
var box = byId("copilot-body"); clearEl(box);
|
|
3529
|
+
var cp = usage.copilot; var planChip = byId("plan-chip");
|
|
3530
|
+
if (!cp){
|
|
3531
|
+
planChip.className = "chip plan-offline"; planChip.textContent = "\\u2014 offline";
|
|
3532
|
+
var eb = mk("div","emptybox"); eb.appendChild(mk("div","keyglyph","\\u26bf"));
|
|
3533
|
+
eb.appendChild(mk("h4",null,"Copilot not connected"));
|
|
3534
|
+
if (usage.copilot_error) eb.appendChild(mk("div","errline", usage.copilot_error));
|
|
3535
|
+
eb.appendChild(mk("div","prompt","$ hoopilot login"));
|
|
3536
|
+
box.appendChild(eb); return;
|
|
3537
|
+
}
|
|
3538
|
+
planChip.className = "chip " + planClass(cp.plan); planChip.textContent = cp.plan || "copilot";
|
|
3539
|
+
var head = mk("div","cap");
|
|
3540
|
+
var bits = [];
|
|
3541
|
+
if (cp.accessTypeSku) bits.push(cp.accessTypeSku);
|
|
3542
|
+
if (cp.chatEnabled !== undefined) bits.push(cp.chatEnabled ? "chat on" : "chat off");
|
|
3543
|
+
if (cp.quotaResetDate) bits.push("resets " + cp.quotaResetDate);
|
|
3544
|
+
head.textContent = bits.join(" \\u00b7 "); box.appendChild(head);
|
|
3545
|
+
var quotas = cp.quotas || {}; var keys = Object.keys(quotas);
|
|
3546
|
+
if (!keys.length){ box.appendChild(mk("div","cap","No metered quotas reported.")); return; }
|
|
3547
|
+
var order = { premium_interactions:0, chat:1, completions:2 };
|
|
3548
|
+
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); });
|
|
3549
|
+
keys.forEach(function(k){
|
|
3550
|
+
var q = quotas[k]; var row = mk("div","qrow");
|
|
3551
|
+
var hd = mk("div","qhead"); hd.appendChild(mk("span","qname", titleize(k)));
|
|
3552
|
+
if (q.unlimited){ hd.appendChild(mk("span","inf","\\u221e unlimited")); row.appendChild(hd); box.appendChild(row); return; }
|
|
3553
|
+
var ent = q.entitlement, rem = q.remaining, used = q.used;
|
|
3554
|
+
var usedPct = (q.percentRemaining !== undefined) ? (100 - q.percentRemaining) : ((ent && used !== undefined) ? (used/ent*100) : 0);
|
|
3555
|
+
usedPct = Math.max(0, Math.min(100, usedPct));
|
|
3556
|
+
var valTxt = (used !== undefined && ent !== undefined) ? (humanInt(used) + " / " + humanInt(ent)) : (rem !== undefined ? (humanInt(rem) + " left") : pct(100-usedPct) + " left");
|
|
3557
|
+
hd.appendChild(mk("span","qval", valTxt)); row.appendChild(hd);
|
|
3558
|
+
var bar = mk("div","qbar"); var fill = mk("i"); fill.style.width = usedPct + "%";
|
|
3559
|
+
fill.style.background = usedPct > 85 ? "var(--danger)" : usedPct > 60 ? "var(--warn)" : "var(--ok)"; bar.appendChild(fill);
|
|
3560
|
+
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); }
|
|
3561
|
+
row.appendChild(bar);
|
|
3562
|
+
if (q.overageCount){ var ov = mk("div","flag", humanInt(q.overageCount) + " overage" + (q.tokenBasedBilling ? " \\u00b7 token billing" : "")); row.appendChild(ov); }
|
|
3563
|
+
box.appendChild(row);
|
|
3564
|
+
});
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
function renderUpstream(up, delta, restarted){
|
|
3568
|
+
setNum("up-total", humanInt(up.total||0));
|
|
3569
|
+
setNum("up-errors", humanInt(up.errors||0), "delta", up.errors||0);
|
|
3570
|
+
var er = up.total ? (up.errors/up.total*100) : 0;
|
|
3571
|
+
var rt = byId("up-rate"); rt.textContent = pct(er); rt.className = "v rate " + (er > 5 ? "danger" : er > 1 ? "warn" : "ok");
|
|
3572
|
+
byId("up-errblk").classList.toggle("hot", (up.errors||0) > 0);
|
|
3573
|
+
pushHist(hist.up, delta||0); drawSpark("up-spark", hist.up);
|
|
3574
|
+
byId("up-flag").textContent = restarted ? "\\u21bb restarted" : "";
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
function renderThroughput(){
|
|
3578
|
+
drawDual("thru-tok-line","thru-tok-area", hist.tok, true);
|
|
3579
|
+
drawDual("thru-req-line", null, hist.req, false);
|
|
3580
|
+
setText("thru-tok", hist.tok.length ? rate(hist.tok[hist.tok.length-1]) : "\\u2014");
|
|
3581
|
+
setText("thru-req", hist.req.length ? rate(hist.req[hist.req.length-1]) : "\\u2014");
|
|
3582
|
+
var peakTok = hist.tok.length ? Math.max.apply(null, hist.tok) : 0;
|
|
3583
|
+
setText("thru-peak", "peak " + humanInt(peakTok) + " tok/s");
|
|
3584
|
+
}
|
|
3585
|
+
function drawDual(lineId, areaId, values, withArea){
|
|
3586
|
+
var svg = byId("thru-svg"); var vb = svg.viewBox.baseVal; var w = vb.width, h = vb.height;
|
|
3587
|
+
var sp = buildSpark(values, w, h);
|
|
3588
|
+
var line = byId(lineId); var area = areaId ? byId(areaId) : null;
|
|
3589
|
+
if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); return; }
|
|
3590
|
+
if (line) line.setAttribute("d", sp.line);
|
|
3591
|
+
if (area && withArea) area.setAttribute("d", sp.area);
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3594
|
+
function renderFooter(usage, proxy){
|
|
3595
|
+
setText("foot-started", proxy.startedAt ? ("started " + new Date(proxy.startedAt).toLocaleString()) : "started \\u2014");
|
|
3596
|
+
setText("foot-uptime", "uptime " + fmtUptime(proxy.uptimeSeconds||0));
|
|
3597
|
+
setText("foot-total", humanInt((proxy.requests && proxy.requests.total)||0) + " req");
|
|
3598
|
+
setText("foot-tokens", humanInt((proxy.tokens && proxy.tokens.total)||0) + " tokens");
|
|
3599
|
+
var up = proxy.upstream || {}; setText("foot-upstream", "upstream " + humanInt(up.total||0) + " / " + humanInt(up.errors||0) + " err");
|
|
3600
|
+
setText("foot-cadence", "polling /v1/usage every " + Math.round(intervalMs/1000) + "s \\u00b7 GET /dashboard");
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3603
|
+
// ---- 1s freshness + uptime ticker (independent of the poll loop) ----
|
|
3604
|
+
setInterval(function(){
|
|
3605
|
+
if (lastSuccessAt){
|
|
3606
|
+
var ago = Math.round((Date.now() - lastSuccessAt)/1000);
|
|
3607
|
+
var u = byId("updated"); u.textContent = "updated " + ago + "s ago";
|
|
3608
|
+
// Staleness only matters while polling; a deliberate pause is not "stale".
|
|
3609
|
+
u.className = "updated" + (paused ? "" : ago > intervalMs/1000*4 ? " danger" : ago > intervalMs/1000*2 ? " warn" : "");
|
|
3610
|
+
}
|
|
3611
|
+
// Tick uptime locally between polls so the seconds advance smoothly; each
|
|
3612
|
+
// successful poll re-seeds lastUptime from the authoritative server value.
|
|
3613
|
+
if (!paused && lastUptime !== null){
|
|
3614
|
+
lastUptime += 1;
|
|
3615
|
+
byId("uptime-num").textContent = fmtUptime(lastUptime);
|
|
3616
|
+
var fu = byId("foot-uptime"); if (fu) fu.textContent = "uptime " + fmtUptime(lastUptime);
|
|
3617
|
+
}
|
|
3618
|
+
}, 1000);
|
|
3619
|
+
|
|
3620
|
+
// ---- boot ----
|
|
3621
|
+
applyTheme(); setActiveSeg();
|
|
3622
|
+
setPill("","CONNECTING",false);
|
|
3623
|
+
poll();
|
|
3624
|
+
})();
|
|
3625
|
+
</script>
|
|
3626
|
+
</body>
|
|
3627
|
+
</html>
|
|
3628
|
+
`;
|
|
3629
|
+
|
|
2771
3630
|
// src/version.ts
|
|
2772
3631
|
var BAKED_VERSION = typeof HOOPILOT_VERSION !== "undefined" ? HOOPILOT_VERSION : void 0;
|
|
2773
3632
|
var IS_STANDALONE_BINARY = BAKED_VERSION !== void 0;
|
|
3633
|
+
var cachedVersion;
|
|
3634
|
+
async function getVersion() {
|
|
3635
|
+
if (cachedVersion !== void 0) {
|
|
3636
|
+
return cachedVersion;
|
|
3637
|
+
}
|
|
3638
|
+
let resolved;
|
|
3639
|
+
if (BAKED_VERSION) {
|
|
3640
|
+
resolved = BAKED_VERSION;
|
|
3641
|
+
} else {
|
|
3642
|
+
try {
|
|
3643
|
+
const manifest = asRecord(await Bun.file(new URL("../package.json", import.meta.url)).json());
|
|
3644
|
+
const version = manifest.version;
|
|
3645
|
+
resolved = typeof version === "string" ? version : "0.0.0";
|
|
3646
|
+
} catch {
|
|
3647
|
+
resolved = "0.0.0";
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
cachedVersion = resolved;
|
|
3651
|
+
return resolved;
|
|
3652
|
+
}
|
|
2774
3653
|
|
|
2775
3654
|
// src/server.ts
|
|
2776
3655
|
var DEFAULT_HOST = "127.0.0.1";
|
|
@@ -2789,6 +3668,18 @@ var RequestBodyTooLargeError = class extends Error {
|
|
|
2789
3668
|
this.name = "RequestBodyTooLargeError";
|
|
2790
3669
|
}
|
|
2791
3670
|
};
|
|
3671
|
+
var InvalidJsonError = class extends Error {
|
|
3672
|
+
constructor() {
|
|
3673
|
+
super(INVALID_JSON_MESSAGE);
|
|
3674
|
+
this.name = "InvalidJsonError";
|
|
3675
|
+
}
|
|
3676
|
+
};
|
|
3677
|
+
var JsonNotObjectError = class extends Error {
|
|
3678
|
+
constructor() {
|
|
3679
|
+
super(JSON_OBJECT_MESSAGE);
|
|
3680
|
+
this.name = "JsonNotObjectError";
|
|
3681
|
+
}
|
|
3682
|
+
};
|
|
2792
3683
|
function createHoopilotHandler(options = {}) {
|
|
2793
3684
|
const client = new CopilotClient(options);
|
|
2794
3685
|
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
@@ -2798,8 +3689,19 @@ function createHoopilotHandler(options = {}) {
|
|
|
2798
3689
|
const readUsage = createUsageReader(client, metrics);
|
|
2799
3690
|
const recordTokens = (model, usage) => metrics.recordTokens(model, usage);
|
|
2800
3691
|
const recordExtraction = (extracted) => metrics.recordTokenExtraction(extracted);
|
|
2801
|
-
const
|
|
2802
|
-
const
|
|
3692
|
+
const bufferProxyBodies = shouldBufferProxyBodies(resolveStreamingProxyMode(options));
|
|
3693
|
+
const requestContext = /* @__PURE__ */ new WeakMap();
|
|
3694
|
+
const app = buildApp({
|
|
3695
|
+
apiKey,
|
|
3696
|
+
allowedOrigins,
|
|
3697
|
+
bufferProxyBodies,
|
|
3698
|
+
client,
|
|
3699
|
+
metrics,
|
|
3700
|
+
readUsage,
|
|
3701
|
+
recordExtraction,
|
|
3702
|
+
recordTokens,
|
|
3703
|
+
requestContext
|
|
3704
|
+
});
|
|
2803
3705
|
return async (request) => {
|
|
2804
3706
|
const startedAt = performance.now();
|
|
2805
3707
|
const url = new URL(request.url);
|
|
@@ -2815,7 +3717,24 @@ function createHoopilotHandler(options = {}) {
|
|
|
2815
3717
|
metrics.startRequest();
|
|
2816
3718
|
const origin = request.headers.get("origin")?.trim() || void 0;
|
|
2817
3719
|
const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
|
|
2818
|
-
const
|
|
3720
|
+
const inner = normalizeInnerRequest(request, apiPath, url);
|
|
3721
|
+
requestContext.set(inner, {
|
|
3722
|
+
apiPath,
|
|
3723
|
+
logger: requestLogger,
|
|
3724
|
+
origin,
|
|
3725
|
+
originalPath: url.pathname
|
|
3726
|
+
});
|
|
3727
|
+
let response;
|
|
3728
|
+
try {
|
|
3729
|
+
response = await app.handle(inner);
|
|
3730
|
+
} catch (error) {
|
|
3731
|
+
requestLogger.error(
|
|
3732
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
3733
|
+
"request failed"
|
|
3734
|
+
);
|
|
3735
|
+
response = jsonError(500, "internal_error", errorMessage(error));
|
|
3736
|
+
}
|
|
3737
|
+
return finishResponse(response, {
|
|
2819
3738
|
corsOrigin,
|
|
2820
3739
|
logger: requestLogger,
|
|
2821
3740
|
method: request.method,
|
|
@@ -2826,141 +3745,175 @@ function createHoopilotHandler(options = {}) {
|
|
|
2826
3745
|
closeConnection: bufferProxyBodies,
|
|
2827
3746
|
trackStreamingBody: !bufferProxyBodies
|
|
2828
3747
|
});
|
|
3748
|
+
};
|
|
3749
|
+
}
|
|
3750
|
+
function buildApp(deps) {
|
|
3751
|
+
const {
|
|
3752
|
+
apiKey,
|
|
3753
|
+
allowedOrigins,
|
|
3754
|
+
bufferProxyBodies,
|
|
3755
|
+
client,
|
|
3756
|
+
metrics,
|
|
3757
|
+
readUsage,
|
|
3758
|
+
recordExtraction,
|
|
3759
|
+
recordTokens,
|
|
3760
|
+
requestContext
|
|
3761
|
+
} = deps;
|
|
3762
|
+
const contextFor = (request) => {
|
|
3763
|
+
const stored = requestContext.get(request);
|
|
3764
|
+
if (stored) {
|
|
3765
|
+
return stored;
|
|
3766
|
+
}
|
|
3767
|
+
const originalPath = new URL(request.url).pathname;
|
|
3768
|
+
return {
|
|
3769
|
+
apiPath: canonicalApiPath(originalPath),
|
|
3770
|
+
logger: noopLogger,
|
|
3771
|
+
origin: request.headers.get("origin")?.trim() || void 0,
|
|
3772
|
+
originalPath
|
|
3773
|
+
};
|
|
3774
|
+
};
|
|
3775
|
+
const loggerFor = (request) => contextFor(request).logger;
|
|
3776
|
+
const noBody = { parse: "none" };
|
|
3777
|
+
return new Elysia().onRequest(({ request }) => {
|
|
3778
|
+
const { apiPath, logger, origin } = contextFor(request);
|
|
2829
3779
|
const browserOrigin = forbiddenBrowserOrigin(origin, request, allowedOrigins);
|
|
2830
3780
|
if (browserOrigin) {
|
|
2831
|
-
|
|
3781
|
+
logger.warn(
|
|
2832
3782
|
{ event: "http.request.forbidden_origin", origin: browserOrigin },
|
|
2833
3783
|
"blocked cross-origin browser request"
|
|
2834
3784
|
);
|
|
2835
|
-
return
|
|
3785
|
+
return jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE);
|
|
2836
3786
|
}
|
|
2837
3787
|
if (request.method === "OPTIONS") {
|
|
2838
|
-
return
|
|
3788
|
+
return new Response(null, { headers: corsHeaders() });
|
|
3789
|
+
}
|
|
3790
|
+
if (request.method === "GET" && apiPath === "/dashboard") {
|
|
3791
|
+
return dashboardResponse();
|
|
2839
3792
|
}
|
|
2840
3793
|
if (!isAuthorized(request, apiKey)) {
|
|
2841
|
-
|
|
2842
|
-
return
|
|
3794
|
+
logger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
|
|
3795
|
+
return jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key.");
|
|
2843
3796
|
}
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
}
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
return finish(
|
|
2878
|
-
await handleChatCompletions(
|
|
2879
|
-
client,
|
|
2880
|
-
metrics,
|
|
2881
|
-
recordTokens,
|
|
2882
|
-
recordExtraction,
|
|
2883
|
-
request,
|
|
2884
|
-
requestLogger,
|
|
2885
|
-
bufferProxyBodies
|
|
2886
|
-
)
|
|
2887
|
-
);
|
|
2888
|
-
}
|
|
2889
|
-
if (request.method === "POST" && apiPath === "/v1/completions") {
|
|
2890
|
-
return finish(
|
|
2891
|
-
await handleCompletions(
|
|
2892
|
-
client,
|
|
2893
|
-
metrics,
|
|
2894
|
-
recordTokens,
|
|
2895
|
-
recordExtraction,
|
|
2896
|
-
request,
|
|
2897
|
-
requestLogger,
|
|
2898
|
-
bufferProxyBodies
|
|
2899
|
-
)
|
|
2900
|
-
);
|
|
2901
|
-
}
|
|
2902
|
-
if (request.method === "POST" && apiPath === "/v1/responses/compact") {
|
|
2903
|
-
return finish(
|
|
2904
|
-
await handleResponsesCompact(
|
|
2905
|
-
client,
|
|
2906
|
-
metrics,
|
|
2907
|
-
recordTokens,
|
|
2908
|
-
recordExtraction,
|
|
2909
|
-
request,
|
|
2910
|
-
requestLogger
|
|
2911
|
-
)
|
|
2912
|
-
);
|
|
2913
|
-
}
|
|
2914
|
-
if (request.method === "POST" && apiPath === "/v1/responses") {
|
|
2915
|
-
return finish(
|
|
2916
|
-
await handleResponses(
|
|
2917
|
-
client,
|
|
2918
|
-
metrics,
|
|
2919
|
-
recordTokens,
|
|
2920
|
-
recordExtraction,
|
|
2921
|
-
request,
|
|
2922
|
-
requestLogger,
|
|
2923
|
-
bufferProxyBodies
|
|
2924
|
-
)
|
|
2925
|
-
);
|
|
2926
|
-
}
|
|
2927
|
-
return finish(jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`));
|
|
2928
|
-
} catch (error) {
|
|
2929
|
-
if (error instanceof CopilotAuthError) {
|
|
2930
|
-
requestLogger.warn(
|
|
2931
|
-
{ err: errorDetails(error), event: "copilot.auth.missing" },
|
|
2932
|
-
"copilot auth failed"
|
|
2933
|
-
);
|
|
2934
|
-
return finish(jsonError(401, "copilot_auth_error", error.message));
|
|
2935
|
-
}
|
|
2936
|
-
const message = errorMessage(error);
|
|
2937
|
-
if (message === INVALID_JSON_MESSAGE || message === JSON_OBJECT_MESSAGE) {
|
|
2938
|
-
requestLogger.warn(
|
|
2939
|
-
{ err: errorDetails(error), event: "http.request.failed" },
|
|
2940
|
-
"request body was not usable json"
|
|
2941
|
-
);
|
|
2942
|
-
return finish(jsonError(400, "invalid_request_error", message));
|
|
2943
|
-
} else if (error instanceof OpenAICompatibilityError || error instanceof AnthropicCompatibilityError) {
|
|
2944
|
-
requestLogger.warn(
|
|
2945
|
-
{ err: errorDetails(error), event: "http.request.failed" },
|
|
2946
|
-
"request body used unsupported compatibility fields"
|
|
2947
|
-
);
|
|
2948
|
-
return finish(jsonError(400, "invalid_request_error", message));
|
|
2949
|
-
} else if (error instanceof RequestBodyTooLargeError) {
|
|
2950
|
-
requestLogger.warn(
|
|
2951
|
-
{ err: errorDetails(error), event: "http.request.failed" },
|
|
2952
|
-
"request body exceeded size limit"
|
|
2953
|
-
);
|
|
2954
|
-
return finish(jsonError(413, "request_too_large", message));
|
|
2955
|
-
} else {
|
|
2956
|
-
requestLogger.error(
|
|
2957
|
-
{ err: errorDetails(error), event: "http.request.failed" },
|
|
2958
|
-
"request failed"
|
|
2959
|
-
);
|
|
2960
|
-
}
|
|
2961
|
-
return finish(jsonError(500, "internal_error", message));
|
|
3797
|
+
}).onError(({ code, error, request }) => {
|
|
3798
|
+
const { logger, originalPath } = contextFor(request);
|
|
3799
|
+
if (code === "NOT_FOUND") {
|
|
3800
|
+
return jsonError(404, "not_found", `No route for ${request.method} ${originalPath}.`);
|
|
3801
|
+
}
|
|
3802
|
+
if (error instanceof CopilotAuthError) {
|
|
3803
|
+
logger.warn(
|
|
3804
|
+
{ err: errorDetails(error), event: "copilot.auth.missing" },
|
|
3805
|
+
"copilot auth failed"
|
|
3806
|
+
);
|
|
3807
|
+
return jsonError(401, "copilot_auth_error", error.message);
|
|
3808
|
+
}
|
|
3809
|
+
const message = errorMessage(error);
|
|
3810
|
+
if (error instanceof InvalidJsonError || error instanceof JsonNotObjectError) {
|
|
3811
|
+
logger.warn(
|
|
3812
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
3813
|
+
"request body was not usable json"
|
|
3814
|
+
);
|
|
3815
|
+
return jsonError(400, "invalid_request_error", message);
|
|
3816
|
+
}
|
|
3817
|
+
if (error instanceof OpenAICompatibilityError || error instanceof AnthropicCompatibilityError) {
|
|
3818
|
+
logger.warn(
|
|
3819
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
3820
|
+
"request body used unsupported compatibility fields"
|
|
3821
|
+
);
|
|
3822
|
+
return jsonError(400, "invalid_request_error", message);
|
|
3823
|
+
}
|
|
3824
|
+
if (error instanceof RequestBodyTooLargeError) {
|
|
3825
|
+
logger.warn(
|
|
3826
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
3827
|
+
"request body exceeded size limit"
|
|
3828
|
+
);
|
|
3829
|
+
return jsonError(413, "request_too_large", message);
|
|
2962
3830
|
}
|
|
3831
|
+
logger.error({ err: errorDetails(error), event: "http.request.failed" }, "request failed");
|
|
3832
|
+
return jsonError(500, "internal_error", message);
|
|
3833
|
+
}).get("/", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/healthz", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/metrics", () => metricsResponse(metrics)).get("/v1/usage", ({ request }) => handleUsage(metrics, readUsage, request.signal)).get(
|
|
3834
|
+
"/v1/models",
|
|
3835
|
+
({ request }) => handleModels(client, metrics, request.signal, loggerFor(request))
|
|
3836
|
+
).get("/v1/responses", () => websocketUnsupportedResponse()).post(
|
|
3837
|
+
"/v1/messages",
|
|
3838
|
+
({ request }) => handleAnthropicMessages(
|
|
3839
|
+
client,
|
|
3840
|
+
metrics,
|
|
3841
|
+
recordTokens,
|
|
3842
|
+
recordExtraction,
|
|
3843
|
+
request,
|
|
3844
|
+
loggerFor(request),
|
|
3845
|
+
bufferProxyBodies
|
|
3846
|
+
),
|
|
3847
|
+
noBody
|
|
3848
|
+
).post(
|
|
3849
|
+
"/v1/messages/count_tokens",
|
|
3850
|
+
({ request }) => handleAnthropicCountTokens(request),
|
|
3851
|
+
noBody
|
|
3852
|
+
).post(
|
|
3853
|
+
"/v1/chat/completions",
|
|
3854
|
+
({ request }) => handleChatCompletions(
|
|
3855
|
+
client,
|
|
3856
|
+
metrics,
|
|
3857
|
+
recordTokens,
|
|
3858
|
+
recordExtraction,
|
|
3859
|
+
request,
|
|
3860
|
+
loggerFor(request),
|
|
3861
|
+
bufferProxyBodies
|
|
3862
|
+
),
|
|
3863
|
+
noBody
|
|
3864
|
+
).post(
|
|
3865
|
+
"/v1/completions",
|
|
3866
|
+
({ request }) => handleCompletions(
|
|
3867
|
+
client,
|
|
3868
|
+
metrics,
|
|
3869
|
+
recordTokens,
|
|
3870
|
+
recordExtraction,
|
|
3871
|
+
request,
|
|
3872
|
+
loggerFor(request),
|
|
3873
|
+
bufferProxyBodies
|
|
3874
|
+
),
|
|
3875
|
+
noBody
|
|
3876
|
+
).post(
|
|
3877
|
+
"/v1/responses/compact",
|
|
3878
|
+
({ request }) => handleResponsesCompact(
|
|
3879
|
+
client,
|
|
3880
|
+
metrics,
|
|
3881
|
+
recordTokens,
|
|
3882
|
+
recordExtraction,
|
|
3883
|
+
request,
|
|
3884
|
+
loggerFor(request)
|
|
3885
|
+
),
|
|
3886
|
+
noBody
|
|
3887
|
+
).post(
|
|
3888
|
+
"/v1/responses",
|
|
3889
|
+
({ request }) => handleResponses(
|
|
3890
|
+
client,
|
|
3891
|
+
metrics,
|
|
3892
|
+
recordTokens,
|
|
3893
|
+
recordExtraction,
|
|
3894
|
+
request,
|
|
3895
|
+
loggerFor(request),
|
|
3896
|
+
bufferProxyBodies
|
|
3897
|
+
),
|
|
3898
|
+
noBody
|
|
3899
|
+
);
|
|
3900
|
+
}
|
|
3901
|
+
function normalizeInnerRequest(request, canonicalPath, url) {
|
|
3902
|
+
if (canonicalPath === url.pathname) {
|
|
3903
|
+
return request;
|
|
3904
|
+
}
|
|
3905
|
+
const target = new URL(url);
|
|
3906
|
+
target.pathname = canonicalPath;
|
|
3907
|
+
const init = {
|
|
3908
|
+
headers: request.headers,
|
|
3909
|
+
method: request.method,
|
|
3910
|
+
signal: request.signal
|
|
2963
3911
|
};
|
|
3912
|
+
if (request.body) {
|
|
3913
|
+
init.body = request.body;
|
|
3914
|
+
init.duplex = "half";
|
|
3915
|
+
}
|
|
3916
|
+
return new Request(target, init);
|
|
2964
3917
|
}
|
|
2965
3918
|
function startHoopilotServer(options = {}) {
|
|
2966
3919
|
const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
|
|
@@ -3039,7 +3992,8 @@ async function handleAnthropicMessages(client, metrics, recordTokens, recordExtr
|
|
|
3039
3992
|
recordExtraction(usage !== void 0);
|
|
3040
3993
|
return jsonResponse(responsesResponseToAnthropicMessage(body, model));
|
|
3041
3994
|
}
|
|
3042
|
-
function handleAnthropicCountTokens(
|
|
3995
|
+
async function handleAnthropicCountTokens(request) {
|
|
3996
|
+
const body = await readJson(request);
|
|
3043
3997
|
return jsonResponse(estimateAnthropicMessageTokens(body));
|
|
3044
3998
|
}
|
|
3045
3999
|
async function handleModels(client, metrics, signal, logger) {
|
|
@@ -3125,14 +4079,14 @@ async function handleCompletions(client, metrics, recordTokens, recordExtraction
|
|
|
3125
4079
|
return jsonResponse(chatCompletionToCompletion(completion));
|
|
3126
4080
|
}
|
|
3127
4081
|
async function handleResponses(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
|
|
3128
|
-
const body = await readJsonText(request);
|
|
4082
|
+
const { json, text: body } = await readJsonText(request);
|
|
3129
4083
|
const upstream = await client.responses(body, request.signal);
|
|
3130
4084
|
metrics.recordUpstream("/responses", upstream.ok);
|
|
3131
4085
|
if (!upstream.ok) {
|
|
3132
4086
|
return proxyError(upstream, logger);
|
|
3133
4087
|
}
|
|
3134
4088
|
logUpstreamSuccess(logger, "/responses", upstream.status);
|
|
3135
|
-
const model = normalizeRequestedModel(
|
|
4089
|
+
const model = normalizeRequestedModel(json.model);
|
|
3136
4090
|
return proxyResponse(
|
|
3137
4091
|
await responseWithObservedUsage(
|
|
3138
4092
|
upstream,
|
|
@@ -3220,17 +4174,16 @@ function parseJsonObject2(text) {
|
|
|
3220
4174
|
try {
|
|
3221
4175
|
parsed = JSON.parse(text);
|
|
3222
4176
|
} catch {
|
|
3223
|
-
throw new
|
|
4177
|
+
throw new InvalidJsonError();
|
|
3224
4178
|
}
|
|
3225
4179
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
3226
|
-
throw new
|
|
4180
|
+
throw new JsonNotObjectError();
|
|
3227
4181
|
}
|
|
3228
4182
|
return parsed;
|
|
3229
4183
|
}
|
|
3230
4184
|
async function readJsonText(request) {
|
|
3231
4185
|
const text = await readRequestText(request);
|
|
3232
|
-
parseJsonObject2(text);
|
|
3233
|
-
return text;
|
|
4186
|
+
return { json: parseJsonObject2(text), text };
|
|
3234
4187
|
}
|
|
3235
4188
|
async function readRequestText(request) {
|
|
3236
4189
|
const contentLength = request.headers.get("content-length");
|
|
@@ -3288,7 +4241,7 @@ function jsonError(status, code, message) {
|
|
|
3288
4241
|
);
|
|
3289
4242
|
}
|
|
3290
4243
|
function upstreamErrorResponse(status, text) {
|
|
3291
|
-
const parsedError = asRecord(asRecord(
|
|
4244
|
+
const parsedError = asRecord(asRecord(safeJsonParse(text)).error);
|
|
3292
4245
|
if (Object.keys(parsedError).length > 0) {
|
|
3293
4246
|
return jsonResponse({ error: parsedError }, status);
|
|
3294
4247
|
}
|
|
@@ -3310,13 +4263,18 @@ function corsHeaders() {
|
|
|
3310
4263
|
"access-control-expose-headers": "x-request-id"
|
|
3311
4264
|
};
|
|
3312
4265
|
}
|
|
4266
|
+
function secretEquals(candidate, secret) {
|
|
4267
|
+
const a = createHash("sha256").update(candidate).digest();
|
|
4268
|
+
const b = createHash("sha256").update(secret).digest();
|
|
4269
|
+
return timingSafeEqual(a, b);
|
|
4270
|
+
}
|
|
3313
4271
|
function isAuthorized(request, apiKey) {
|
|
3314
4272
|
if (!apiKey) {
|
|
3315
4273
|
return true;
|
|
3316
4274
|
}
|
|
3317
4275
|
const authorization = request.headers.get("authorization") ?? "";
|
|
3318
4276
|
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
3319
|
-
return bearer
|
|
4277
|
+
return bearer !== void 0 && secretEquals(bearer, apiKey) || secretEquals(request.headers.get("x-api-key") ?? "", apiKey);
|
|
3320
4278
|
}
|
|
3321
4279
|
function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
|
|
3322
4280
|
if (origin) {
|
|
@@ -3353,7 +4311,7 @@ function upstreamAuthMessage(message) {
|
|
|
3353
4311
|
return `GitHub Copilot rejected the credential or account access: ${message}`;
|
|
3354
4312
|
}
|
|
3355
4313
|
function isLoopbackHost(host) {
|
|
3356
|
-
return host
|
|
4314
|
+
return isLoopbackHostname(host);
|
|
3357
4315
|
}
|
|
3358
4316
|
function urlHost(host) {
|
|
3359
4317
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
@@ -3372,9 +4330,6 @@ function normalizeServerPort(value) {
|
|
|
3372
4330
|
}
|
|
3373
4331
|
return port;
|
|
3374
4332
|
}
|
|
3375
|
-
function errorMessage(error) {
|
|
3376
|
-
return error instanceof Error ? error.message : String(error);
|
|
3377
|
-
}
|
|
3378
4333
|
function serverLogger(options) {
|
|
3379
4334
|
if (options.logger) {
|
|
3380
4335
|
return options.logger.child({ component: "server" });
|
|
@@ -3390,10 +4345,7 @@ function serverLogger(options) {
|
|
|
3390
4345
|
}
|
|
3391
4346
|
function resolveStreamingProxyMode(options) {
|
|
3392
4347
|
const value = options.streamingProxyMode ?? envValue(options.env?.HOOPILOT_STREAM_MODE) ?? envValue(options.env?.HOOPILOT_STREAMING_PROXY_MODE) ?? "auto";
|
|
3393
|
-
|
|
3394
|
-
return value;
|
|
3395
|
-
}
|
|
3396
|
-
throw new Error(`Invalid stream mode: ${value}. Expected auto, live, or buffer.`);
|
|
4348
|
+
return parseStreamingProxyMode(value);
|
|
3397
4349
|
}
|
|
3398
4350
|
function shouldBufferProxyBodies(mode) {
|
|
3399
4351
|
if (mode === "buffer") {
|
|
@@ -3451,11 +4403,13 @@ function responseWithRequestId(response, requestId, closeConnection, corsOrigin)
|
|
|
3451
4403
|
function trackStreamCompletion(body, onComplete) {
|
|
3452
4404
|
const reader = body.getReader();
|
|
3453
4405
|
let fired = false;
|
|
3454
|
-
const
|
|
3455
|
-
if (
|
|
3456
|
-
|
|
3457
|
-
onComplete();
|
|
4406
|
+
const release = () => {
|
|
4407
|
+
if (fired) {
|
|
4408
|
+
return;
|
|
3458
4409
|
}
|
|
4410
|
+
fired = true;
|
|
4411
|
+
onComplete();
|
|
4412
|
+
reader.releaseLock();
|
|
3459
4413
|
};
|
|
3460
4414
|
return new ReadableStream({
|
|
3461
4415
|
async pull(controller) {
|
|
@@ -3463,18 +4417,25 @@ function trackStreamCompletion(body, onComplete) {
|
|
|
3463
4417
|
const { done, value } = await reader.read();
|
|
3464
4418
|
if (done) {
|
|
3465
4419
|
controller.close();
|
|
3466
|
-
|
|
4420
|
+
release();
|
|
3467
4421
|
return;
|
|
3468
4422
|
}
|
|
3469
4423
|
controller.enqueue(value);
|
|
3470
4424
|
} catch (error) {
|
|
3471
|
-
|
|
4425
|
+
release();
|
|
3472
4426
|
controller.error(error);
|
|
3473
4427
|
}
|
|
3474
4428
|
},
|
|
3475
|
-
cancel(reason) {
|
|
3476
|
-
|
|
3477
|
-
|
|
4429
|
+
async cancel(reason) {
|
|
4430
|
+
if (!fired) {
|
|
4431
|
+
fired = true;
|
|
4432
|
+
onComplete();
|
|
4433
|
+
}
|
|
4434
|
+
try {
|
|
4435
|
+
await reader.cancel(reason);
|
|
4436
|
+
} finally {
|
|
4437
|
+
reader.releaseLock();
|
|
4438
|
+
}
|
|
3478
4439
|
}
|
|
3479
4440
|
});
|
|
3480
4441
|
}
|
|
@@ -3522,44 +4483,26 @@ function canonicalApiPath(path) {
|
|
|
3522
4483
|
return withoutTrailingSlash;
|
|
3523
4484
|
}
|
|
3524
4485
|
}
|
|
4486
|
+
var API_ROUTES = [
|
|
4487
|
+
{ method: "GET", path: "/", name: "health" },
|
|
4488
|
+
{ method: "GET", path: "/healthz", name: "health" },
|
|
4489
|
+
{ method: "GET", path: "/dashboard", name: "dashboard" },
|
|
4490
|
+
{ method: "GET", path: "/metrics", name: "metrics" },
|
|
4491
|
+
{ method: "GET", path: "/v1/usage", name: "usage" },
|
|
4492
|
+
{ method: "GET", path: "/v1/models", name: "models" },
|
|
4493
|
+
{ method: "GET", path: "/v1/responses", name: "responses_websocket" },
|
|
4494
|
+
{ method: "POST", path: "/v1/messages", name: "anthropic_messages" },
|
|
4495
|
+
{ method: "POST", path: "/v1/messages/count_tokens", name: "anthropic_count_tokens" },
|
|
4496
|
+
{ method: "POST", path: "/v1/chat/completions", name: "chat_completions" },
|
|
4497
|
+
{ method: "POST", path: "/v1/completions", name: "completions" },
|
|
4498
|
+
{ method: "POST", path: "/v1/responses/compact", name: "responses_compact" },
|
|
4499
|
+
{ method: "POST", path: "/v1/responses", name: "responses" }
|
|
4500
|
+
];
|
|
3525
4501
|
function routeFor(method, path) {
|
|
3526
4502
|
if (method === "OPTIONS") {
|
|
3527
4503
|
return "cors.preflight";
|
|
3528
4504
|
}
|
|
3529
|
-
|
|
3530
|
-
return "health";
|
|
3531
|
-
}
|
|
3532
|
-
if (method === "GET" && path === "/metrics") {
|
|
3533
|
-
return "metrics";
|
|
3534
|
-
}
|
|
3535
|
-
if (method === "GET" && path === "/v1/usage") {
|
|
3536
|
-
return "usage";
|
|
3537
|
-
}
|
|
3538
|
-
if (method === "GET" && path === "/v1/models") {
|
|
3539
|
-
return "models";
|
|
3540
|
-
}
|
|
3541
|
-
if (method === "POST" && path === "/v1/messages") {
|
|
3542
|
-
return "anthropic_messages";
|
|
3543
|
-
}
|
|
3544
|
-
if (method === "POST" && path === "/v1/messages/count_tokens") {
|
|
3545
|
-
return "anthropic_count_tokens";
|
|
3546
|
-
}
|
|
3547
|
-
if (method === "POST" && path === "/v1/chat/completions") {
|
|
3548
|
-
return "chat_completions";
|
|
3549
|
-
}
|
|
3550
|
-
if (method === "POST" && path === "/v1/completions") {
|
|
3551
|
-
return "completions";
|
|
3552
|
-
}
|
|
3553
|
-
if (method === "POST" && path === "/v1/responses/compact") {
|
|
3554
|
-
return "responses_compact";
|
|
3555
|
-
}
|
|
3556
|
-
if (method === "POST" && path === "/v1/responses") {
|
|
3557
|
-
return "responses";
|
|
3558
|
-
}
|
|
3559
|
-
if (method === "GET" && path === "/v1/responses") {
|
|
3560
|
-
return "responses_websocket";
|
|
3561
|
-
}
|
|
3562
|
-
return "not_found";
|
|
4505
|
+
return API_ROUTES.find((entry) => entry.method === method && entry.path === path)?.name ?? "not_found";
|
|
3563
4506
|
}
|
|
3564
4507
|
function isStreamingResponse(response) {
|
|
3565
4508
|
return response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
@@ -3583,10 +4526,28 @@ function metricsResponse(metrics) {
|
|
|
3583
4526
|
status: 200
|
|
3584
4527
|
});
|
|
3585
4528
|
}
|
|
4529
|
+
function dashboardResponse() {
|
|
4530
|
+
return new Response(DASHBOARD_HTML, {
|
|
4531
|
+
headers: {
|
|
4532
|
+
...corsHeaders(),
|
|
4533
|
+
"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'",
|
|
4534
|
+
"content-type": "text/html; charset=utf-8",
|
|
4535
|
+
"referrer-policy": "no-referrer",
|
|
4536
|
+
"x-content-type-options": "nosniff",
|
|
4537
|
+
"x-frame-options": "DENY"
|
|
4538
|
+
},
|
|
4539
|
+
status: 200
|
|
4540
|
+
});
|
|
4541
|
+
}
|
|
3586
4542
|
async function handleUsage(metrics, readUsage, signal) {
|
|
3587
4543
|
const { copilot, error } = await readUsage(signal);
|
|
3588
4544
|
const proxy = metrics.snapshot();
|
|
3589
|
-
const body = {
|
|
4545
|
+
const body = {
|
|
4546
|
+
copilot: copilot ?? null,
|
|
4547
|
+
object: "usage",
|
|
4548
|
+
proxy,
|
|
4549
|
+
version: await getVersion()
|
|
4550
|
+
};
|
|
3590
4551
|
if (error) {
|
|
3591
4552
|
body.copilot_error = error;
|
|
3592
4553
|
}
|
|
@@ -3619,24 +4580,19 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
|
|
|
3619
4580
|
}
|
|
3620
4581
|
};
|
|
3621
4582
|
}
|
|
3622
|
-
function safeParseJson(text) {
|
|
3623
|
-
try {
|
|
3624
|
-
return JSON.parse(text);
|
|
3625
|
-
} catch {
|
|
3626
|
-
return void 0;
|
|
3627
|
-
}
|
|
3628
|
-
}
|
|
3629
4583
|
export {
|
|
3630
4584
|
AnthropicCompatibilityError,
|
|
3631
4585
|
COPILOT_USAGE_API_VERSION,
|
|
3632
4586
|
CopilotAuth,
|
|
3633
4587
|
CopilotAuthError,
|
|
3634
4588
|
CopilotClient,
|
|
4589
|
+
DEFAULT_COPILOT_API_BASE_URL,
|
|
3635
4590
|
DEFAULT_GITHUB_API_BASE_URL,
|
|
3636
4591
|
DEFAULT_LOG_FORMAT,
|
|
3637
4592
|
DEFAULT_LOG_LEVEL,
|
|
3638
4593
|
DEFAULT_MODEL,
|
|
3639
4594
|
MetricsRegistry,
|
|
4595
|
+
OpenAICompatibilityError,
|
|
3640
4596
|
PROMETHEUS_CONTENT_TYPE,
|
|
3641
4597
|
anthropicMessagesToResponsesRequest,
|
|
3642
4598
|
applyCopilotHeaders,
|
|
@@ -3662,6 +4618,7 @@ export {
|
|
|
3662
4618
|
parseLogLevel,
|
|
3663
4619
|
parseRateLimitHeaders,
|
|
3664
4620
|
readStoredCopilotAuth,
|
|
4621
|
+
recordResponseTextUsage,
|
|
3665
4622
|
responsesCompactionResult,
|
|
3666
4623
|
responsesRequestToChatCompletion,
|
|
3667
4624
|
responsesResponseToAnthropicMessage,
|