@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/cli.js
CHANGED
|
@@ -2,18 +2,27 @@
|
|
|
2
2
|
import {
|
|
3
3
|
asRecord,
|
|
4
4
|
envValue,
|
|
5
|
+
errorMessage,
|
|
6
|
+
firstNumber,
|
|
7
|
+
isLoopbackHostname,
|
|
5
8
|
isTrustedTokenBaseUrl,
|
|
6
9
|
main,
|
|
10
|
+
modelIdsFromResponse,
|
|
11
|
+
parseJsonObject,
|
|
12
|
+
parseStreamingProxyMode,
|
|
13
|
+
randomId,
|
|
14
|
+
removeUndefined,
|
|
15
|
+
safeJsonParse,
|
|
7
16
|
trimTrailingSlash,
|
|
8
17
|
truncatedResponseText
|
|
9
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-6ALEIJJM.js";
|
|
10
19
|
|
|
11
20
|
// src/cli.ts
|
|
12
21
|
import { spawn } from "child_process";
|
|
13
22
|
import { readFileSync as readFileSync2 } from "fs";
|
|
14
23
|
|
|
15
24
|
// src/auth-store.ts
|
|
16
|
-
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
25
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
17
26
|
import { dirname, join } from "path";
|
|
18
27
|
var StoredCopilotAuthError = class extends Error {
|
|
19
28
|
constructor(message) {
|
|
@@ -64,7 +73,7 @@ function readStoredCopilotAuth(path = authStorePath()) {
|
|
|
64
73
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
65
74
|
throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} must contain a JSON object.`);
|
|
66
75
|
}
|
|
67
|
-
const record = parsed;
|
|
76
|
+
const record = asRecord(parsed);
|
|
68
77
|
const token = typeof record.token === "string" ? record.token.trim() : "";
|
|
69
78
|
if (!token) {
|
|
70
79
|
throw new StoredCopilotAuthError(`Hoopilot auth file at ${path} does not contain a token.`);
|
|
@@ -90,7 +99,15 @@ function writeStoredCopilotAuth(auth, path = authStorePath()) {
|
|
|
90
99
|
`;
|
|
91
100
|
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
92
101
|
writeFileSync(tmpPath, data, { mode: 384 });
|
|
93
|
-
|
|
102
|
+
try {
|
|
103
|
+
renameSync(tmpPath, path);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
try {
|
|
106
|
+
rmSync(tmpPath, { force: true });
|
|
107
|
+
} catch {
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
94
111
|
try {
|
|
95
112
|
chmodSync(path, 384);
|
|
96
113
|
} catch {
|
|
@@ -135,23 +152,20 @@ var CopilotAuth = class {
|
|
|
135
152
|
throw error;
|
|
136
153
|
}
|
|
137
154
|
if (stored) {
|
|
138
|
-
|
|
155
|
+
this.#cachedAccess = {
|
|
139
156
|
apiBaseUrl: trimTrailingSlash(
|
|
140
157
|
this.#hasCopilotApiBaseUrlOverride ? this.#copilotApiBaseUrl : stored.apiBaseUrl ?? this.#copilotApiBaseUrl
|
|
141
158
|
),
|
|
142
159
|
expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
|
|
143
160
|
source: "github-copilot-oauth",
|
|
144
161
|
token: stored.token
|
|
145
|
-
}
|
|
162
|
+
};
|
|
163
|
+
return this.#cachedAccess;
|
|
146
164
|
}
|
|
147
165
|
throw new CopilotAuthError(
|
|
148
166
|
"No GitHub Copilot OAuth credential found. Run `hoopilot login` to sign in through your browser."
|
|
149
167
|
);
|
|
150
168
|
}
|
|
151
|
-
#cacheAccess(access) {
|
|
152
|
-
this.#cachedAccess = access;
|
|
153
|
-
return access;
|
|
154
|
-
}
|
|
155
169
|
};
|
|
156
170
|
|
|
157
171
|
// src/copilot.ts
|
|
@@ -159,23 +173,26 @@ var DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
|
|
|
159
173
|
var ALLOWED_COPILOT_API_HOSTS = ["api.githubcopilot.com"];
|
|
160
174
|
var ALLOWED_GITHUB_API_HOSTS = ["api.github.com"];
|
|
161
175
|
var COPILOT_USAGE_API_VERSION = "2025-04-01";
|
|
176
|
+
var EDITOR_PLUGIN_VERSION = "hoopilot/0.1.0";
|
|
177
|
+
var EDITOR_VERSION = "Hoopilot/0.1.0";
|
|
178
|
+
var HOOPILOT_USER_AGENT = "hoopilot/0.1.0";
|
|
162
179
|
function applyCopilotHeaders(headers, token) {
|
|
163
180
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
164
181
|
headers.set("authorization", `Bearer ${token}`);
|
|
165
182
|
headers.set("copilot-integration-id", "vscode-chat");
|
|
166
|
-
headers.set("editor-plugin-version",
|
|
167
|
-
headers.set("editor-version",
|
|
183
|
+
headers.set("editor-plugin-version", EDITOR_PLUGIN_VERSION);
|
|
184
|
+
headers.set("editor-version", EDITOR_VERSION);
|
|
168
185
|
headers.set("openai-intent", "conversation-panel");
|
|
169
|
-
headers.set("user-agent",
|
|
186
|
+
headers.set("user-agent", HOOPILOT_USER_AGENT);
|
|
170
187
|
headers.set("x-github-api-version", "2026-06-01");
|
|
171
188
|
return headers;
|
|
172
189
|
}
|
|
173
190
|
function applyGithubApiHeaders(headers, token) {
|
|
174
191
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
175
192
|
headers.set("authorization", `token ${token}`);
|
|
176
|
-
headers.set("editor-plugin-version",
|
|
177
|
-
headers.set("editor-version",
|
|
178
|
-
headers.set("user-agent",
|
|
193
|
+
headers.set("editor-plugin-version", EDITOR_PLUGIN_VERSION);
|
|
194
|
+
headers.set("editor-version", EDITOR_VERSION);
|
|
195
|
+
headers.set("user-agent", HOOPILOT_USER_AGENT);
|
|
179
196
|
headers.set("x-github-api-version", COPILOT_USAGE_API_VERSION);
|
|
180
197
|
return headers;
|
|
181
198
|
}
|
|
@@ -188,7 +205,7 @@ function parseRateLimitHeaders(headers, nowMs = Date.now()) {
|
|
|
188
205
|
if (limit === void 0 && remaining === void 0 && used === void 0 && resetEpochSeconds === void 0 && retryAfterSeconds === void 0) {
|
|
189
206
|
return void 0;
|
|
190
207
|
}
|
|
191
|
-
return
|
|
208
|
+
return removeUndefined({
|
|
192
209
|
limit,
|
|
193
210
|
observedAtMs: nowMs,
|
|
194
211
|
remaining,
|
|
@@ -206,11 +223,6 @@ function headerInt(headers, name) {
|
|
|
206
223
|
const value = Number.parseInt(raw.trim(), 10);
|
|
207
224
|
return Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
208
225
|
}
|
|
209
|
-
function removeUndefinedRateLimit(rateLimit) {
|
|
210
|
-
return Object.fromEntries(
|
|
211
|
-
Object.entries(rateLimit).filter(([, value]) => value !== void 0)
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
226
|
var CopilotClient = class {
|
|
215
227
|
#auth;
|
|
216
228
|
#allowUnsafeUpstream;
|
|
@@ -307,7 +319,7 @@ function normalizeCopilotUsage(body) {
|
|
|
307
319
|
for (const category of /* @__PURE__ */ new Set([...Object.keys(remaining), ...Object.keys(monthly)])) {
|
|
308
320
|
const entitlement = numberOrUndefined(monthly[category]);
|
|
309
321
|
const left = numberOrUndefined(remaining[category]);
|
|
310
|
-
quotas[category] =
|
|
322
|
+
quotas[category] = removeUndefined({
|
|
311
323
|
entitlement,
|
|
312
324
|
percentRemaining: entitlement !== void 0 && entitlement > 0 && left !== void 0 ? left / entitlement * 100 : void 0,
|
|
313
325
|
remaining: left,
|
|
@@ -315,7 +327,7 @@ function normalizeCopilotUsage(body) {
|
|
|
315
327
|
});
|
|
316
328
|
}
|
|
317
329
|
}
|
|
318
|
-
return
|
|
330
|
+
return removeUndefined({
|
|
319
331
|
accessTypeSku: stringOrUndefined(record.access_type_sku),
|
|
320
332
|
chatEnabled: typeof record.chat_enabled === "boolean" ? record.chat_enabled : void 0,
|
|
321
333
|
plan: stringOrUndefined(record.copilot_plan),
|
|
@@ -327,7 +339,7 @@ function normalizeQuotaDetail(detail) {
|
|
|
327
339
|
const entitlement = numberOrUndefined(detail.entitlement);
|
|
328
340
|
const overageCount = numberOrUndefined(detail.overage_count);
|
|
329
341
|
const remaining = numberOrUndefined(detail.remaining) ?? numberOrUndefined(detail.quota_remaining);
|
|
330
|
-
return
|
|
342
|
+
return removeUndefined({
|
|
331
343
|
entitlement,
|
|
332
344
|
hasQuota: typeof detail.has_quota === "boolean" ? detail.has_quota : void 0,
|
|
333
345
|
overageCount,
|
|
@@ -351,21 +363,10 @@ function usedFrom(entitlement, remaining, overageCount) {
|
|
|
351
363
|
const overage = remaining === 0 ? overageCount ?? 0 : 0;
|
|
352
364
|
return Math.max(0, base + overage);
|
|
353
365
|
}
|
|
354
|
-
|
|
355
|
-
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
356
|
-
}
|
|
366
|
+
var numberOrUndefined = firstNumber;
|
|
357
367
|
function stringOrUndefined(value) {
|
|
358
368
|
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
359
369
|
}
|
|
360
|
-
function removeUndefinedQuota(quota) {
|
|
361
|
-
return Object.fromEntries(
|
|
362
|
-
Object.entries(quota).filter(([, value]) => value !== void 0)
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
|
-
function removeUndefinedUsage(usage) {
|
|
366
|
-
const entries = Object.entries(usage).filter(([, value]) => value !== void 0);
|
|
367
|
-
return Object.fromEntries(entries);
|
|
368
|
-
}
|
|
369
370
|
|
|
370
371
|
// src/github-device.ts
|
|
371
372
|
import { setTimeout as sleep } from "timers/promises";
|
|
@@ -497,11 +498,16 @@ function positiveSeconds(value, fallback) {
|
|
|
497
498
|
}
|
|
498
499
|
async function parseJsonResponse(response, context) {
|
|
499
500
|
const text = await response.text();
|
|
501
|
+
let value;
|
|
500
502
|
try {
|
|
501
|
-
|
|
503
|
+
value = JSON.parse(text);
|
|
502
504
|
} catch {
|
|
503
505
|
throw new Error(`${context}: ${text.slice(0, 500)}`);
|
|
504
506
|
}
|
|
507
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
508
|
+
throw new Error(`${context}: ${text.slice(0, 500)}`);
|
|
509
|
+
}
|
|
510
|
+
return value;
|
|
505
511
|
}
|
|
506
512
|
|
|
507
513
|
// src/logger.ts
|
|
@@ -565,21 +571,29 @@ function createHoopilotLogger(options = {}) {
|
|
|
565
571
|
timestamp: pino.stdTimeFunctions.isoTime
|
|
566
572
|
};
|
|
567
573
|
if (format === "pretty") {
|
|
568
|
-
return
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
574
|
+
return asHoopilotLogger(
|
|
575
|
+
pino(
|
|
576
|
+
pinoOptions,
|
|
577
|
+
pretty({
|
|
578
|
+
// Probe the same sink we write to (stdout / fd 1), so colors are not
|
|
579
|
+
// emitted into a redirected file when only stderr is a TTY. A custom
|
|
580
|
+
// stream's TTY-ness is unknown, so default to no color there.
|
|
581
|
+
colorize: options.colorize ?? (options.stream ? false : process.stdout.isTTY),
|
|
582
|
+
destination: options.stream ?? 1,
|
|
583
|
+
ignore: "pid,hostname",
|
|
584
|
+
singleLine: true,
|
|
585
|
+
translateTime: "SYS:standard"
|
|
586
|
+
})
|
|
587
|
+
)
|
|
577
588
|
);
|
|
578
589
|
}
|
|
579
590
|
if (options.stream) {
|
|
580
|
-
return pino(pinoOptions, options.stream);
|
|
591
|
+
return asHoopilotLogger(pino(pinoOptions, options.stream));
|
|
581
592
|
}
|
|
582
|
-
return pino(pinoOptions);
|
|
593
|
+
return asHoopilotLogger(pino(pinoOptions));
|
|
594
|
+
}
|
|
595
|
+
function asHoopilotLogger(logger) {
|
|
596
|
+
return logger;
|
|
583
597
|
}
|
|
584
598
|
function parseLogFormat(value) {
|
|
585
599
|
if (!value) {
|
|
@@ -621,6 +635,10 @@ function isLogLevel(value) {
|
|
|
621
635
|
return LOG_LEVELS.includes(value);
|
|
622
636
|
}
|
|
623
637
|
|
|
638
|
+
// src/server.ts
|
|
639
|
+
import { createHash, timingSafeEqual } from "crypto";
|
|
640
|
+
import { Elysia } from "elysia";
|
|
641
|
+
|
|
624
642
|
// src/openai.ts
|
|
625
643
|
var DEFAULT_MODEL = "gpt-4.1";
|
|
626
644
|
var OpenAICompatibilityError = class extends Error {
|
|
@@ -693,13 +711,6 @@ function compactionOutputFromResponsesSse(text) {
|
|
|
693
711
|
}
|
|
694
712
|
return deltas ? [messageOutputItem(deltas)] : [];
|
|
695
713
|
}
|
|
696
|
-
function safeJsonParse(text) {
|
|
697
|
-
try {
|
|
698
|
-
return JSON.parse(text);
|
|
699
|
-
} catch {
|
|
700
|
-
return void 0;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
714
|
function chatCompletionToCompletion(completion) {
|
|
704
715
|
return removeUndefined({
|
|
705
716
|
choices: completionChoices(completion).map((choice, index) => {
|
|
@@ -895,21 +906,18 @@ function extractTokenUsage(usage) {
|
|
|
895
906
|
asRecord(record.prompt_tokens_details).cached_tokens,
|
|
896
907
|
asRecord(record.input_tokens_details).cached_tokens
|
|
897
908
|
);
|
|
898
|
-
|
|
899
|
-
cachedTokens: cached,
|
|
909
|
+
const result = {
|
|
900
910
|
completionTokens,
|
|
901
911
|
promptTokens,
|
|
902
|
-
reasoningTokens: reasoning,
|
|
903
912
|
totalTokens: total ?? promptTokens + completionTokens
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
for (const value of values) {
|
|
908
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
909
|
-
return value;
|
|
910
|
-
}
|
|
913
|
+
};
|
|
914
|
+
if (cached !== void 0) {
|
|
915
|
+
result.cachedTokens = cached;
|
|
911
916
|
}
|
|
912
|
-
|
|
917
|
+
if (reasoning !== void 0) {
|
|
918
|
+
result.reasoningTokens = reasoning;
|
|
919
|
+
}
|
|
920
|
+
return result;
|
|
913
921
|
}
|
|
914
922
|
function completionChoices(completion) {
|
|
915
923
|
const choices = Array.isArray(completion.choices) ? completion.choices : [];
|
|
@@ -935,7 +943,7 @@ function processCompletionSseBlock(block, enqueue, markTerminal) {
|
|
|
935
943
|
enqueue("[DONE]");
|
|
936
944
|
return;
|
|
937
945
|
}
|
|
938
|
-
const parsed =
|
|
946
|
+
const parsed = parseJsonObject(data);
|
|
939
947
|
if (!parsed) {
|
|
940
948
|
return;
|
|
941
949
|
}
|
|
@@ -999,19 +1007,6 @@ function encodeDataSse(data) {
|
|
|
999
1007
|
|
|
1000
1008
|
`;
|
|
1001
1009
|
}
|
|
1002
|
-
function parseJson(data) {
|
|
1003
|
-
try {
|
|
1004
|
-
return asRecord(JSON.parse(data));
|
|
1005
|
-
} catch {
|
|
1006
|
-
return void 0;
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
function removeUndefined(record) {
|
|
1010
|
-
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
|
|
1011
|
-
}
|
|
1012
|
-
function randomId() {
|
|
1013
|
-
return crypto.randomUUID().replaceAll("-", "");
|
|
1014
|
-
}
|
|
1015
1010
|
function epochSeconds() {
|
|
1016
1011
|
return Math.floor(Date.now() / 1e3);
|
|
1017
1012
|
}
|
|
@@ -1024,13 +1019,13 @@ var AnthropicCompatibilityError = class extends Error {
|
|
|
1024
1019
|
}
|
|
1025
1020
|
};
|
|
1026
1021
|
function anthropicMessagesToResponsesRequest(request) {
|
|
1027
|
-
return
|
|
1022
|
+
return removeUndefined({
|
|
1028
1023
|
input: anthropicMessagesToResponsesInput(request.messages),
|
|
1029
1024
|
instructions: anthropicSystemToInstructions(request.system),
|
|
1030
1025
|
max_output_tokens: typeof request.max_tokens === "number" && Number.isFinite(request.max_tokens) ? request.max_tokens : void 0,
|
|
1031
1026
|
metadata: request.metadata,
|
|
1032
1027
|
model: normalizeRequestedModel(request.model),
|
|
1033
|
-
parallel_tool_calls: true,
|
|
1028
|
+
parallel_tool_calls: asRecord(request.tool_choice).disable_parallel_tool_use !== true,
|
|
1034
1029
|
reasoning: anthropicThinkingToReasoning(request.thinking),
|
|
1035
1030
|
stop: anthropicStopSequences(request.stop_sequences),
|
|
1036
1031
|
stream: request.stream === true,
|
|
@@ -1045,7 +1040,7 @@ function responsesResponseToAnthropicMessage(response, fallbackModel) {
|
|
|
1045
1040
|
const usage = anthropicUsage(response.usage);
|
|
1046
1041
|
return {
|
|
1047
1042
|
content,
|
|
1048
|
-
id: textValue(response.id) || `msg_${
|
|
1043
|
+
id: textValue(response.id) || `msg_${randomId()}`,
|
|
1049
1044
|
model: textValue(response.model) || fallbackModel,
|
|
1050
1045
|
role: "assistant",
|
|
1051
1046
|
stop_reason: anthropicStopReason(response, content),
|
|
@@ -1122,7 +1117,7 @@ function createAnthropicStreamState(options) {
|
|
|
1122
1117
|
return {
|
|
1123
1118
|
blocks: /* @__PURE__ */ new Map(),
|
|
1124
1119
|
completed: false,
|
|
1125
|
-
messageId: options.messageId ?? `msg_${
|
|
1120
|
+
messageId: options.messageId ?? `msg_${randomId()}`,
|
|
1126
1121
|
model: options.model,
|
|
1127
1122
|
nextBlockIndex: 0,
|
|
1128
1123
|
sawToolUse: false,
|
|
@@ -1176,7 +1171,7 @@ function anthropicMessagesToResponsesInput(messages) {
|
|
|
1176
1171
|
flushMessage();
|
|
1177
1172
|
input.push({
|
|
1178
1173
|
arguments: JSON.stringify(asRecord(part.input)),
|
|
1179
|
-
call_id: textValue(part.id) || `call_${
|
|
1174
|
+
call_id: textValue(part.id) || `call_${randomId()}`,
|
|
1180
1175
|
name: textValue(part.name),
|
|
1181
1176
|
type: "function_call"
|
|
1182
1177
|
});
|
|
@@ -1287,7 +1282,7 @@ function anthropicTools(tools) {
|
|
|
1287
1282
|
}
|
|
1288
1283
|
const converted = tools.map((tool) => {
|
|
1289
1284
|
const record = asRecord(tool);
|
|
1290
|
-
return
|
|
1285
|
+
return removeUndefined({
|
|
1291
1286
|
description: record.description,
|
|
1292
1287
|
name: record.name,
|
|
1293
1288
|
parameters: record.input_schema,
|
|
@@ -1358,7 +1353,7 @@ function anthropicContentFromResponsesOutput(response) {
|
|
|
1358
1353
|
}
|
|
1359
1354
|
if (type === "function_call") {
|
|
1360
1355
|
content.push({
|
|
1361
|
-
id: textValue(record.call_id) || textValue(record.id) || `call_${
|
|
1356
|
+
id: textValue(record.call_id) || textValue(record.id) || `call_${randomId()}`,
|
|
1362
1357
|
input: parseToolInput(textValue(record.arguments)),
|
|
1363
1358
|
name: textValue(record.name),
|
|
1364
1359
|
type: "tool_use"
|
|
@@ -1385,12 +1380,12 @@ function anthropicStopReason(response, content) {
|
|
|
1385
1380
|
}
|
|
1386
1381
|
function anthropicUsage(usage) {
|
|
1387
1382
|
const record = asRecord(usage);
|
|
1388
|
-
const inputTokens =
|
|
1389
|
-
const outputTokens =
|
|
1383
|
+
const inputTokens = firstNumber(record.input_tokens, record.prompt_tokens) ?? 0;
|
|
1384
|
+
const outputTokens = firstNumber(record.output_tokens, record.completion_tokens) ?? 0;
|
|
1390
1385
|
const details = asRecord(record.input_tokens_details);
|
|
1391
|
-
return
|
|
1392
|
-
cache_creation_input_tokens:
|
|
1393
|
-
cache_read_input_tokens:
|
|
1386
|
+
return removeUndefined({
|
|
1387
|
+
cache_creation_input_tokens: firstNumber(record.cache_creation_input_tokens),
|
|
1388
|
+
cache_read_input_tokens: firstNumber(record.cache_read_input_tokens, details.cached_tokens) ?? void 0,
|
|
1394
1389
|
input_tokens: inputTokens,
|
|
1395
1390
|
output_tokens: outputTokens
|
|
1396
1391
|
});
|
|
@@ -1572,7 +1567,7 @@ function ensureToolBlock(state, payload, item, enqueue) {
|
|
|
1572
1567
|
state.blocks.set(key, block);
|
|
1573
1568
|
enqueue("content_block_start", {
|
|
1574
1569
|
content_block: {
|
|
1575
|
-
id: textValue(item.call_id) || textValue(item.id) || `call_${
|
|
1570
|
+
id: textValue(item.call_id) || textValue(item.id) || `call_${randomId()}`,
|
|
1576
1571
|
input: {},
|
|
1577
1572
|
name: textValue(item.name),
|
|
1578
1573
|
type: "tool_use"
|
|
@@ -1606,13 +1601,6 @@ function parseSseBlock(block) {
|
|
|
1606
1601
|
}
|
|
1607
1602
|
return { data: data.join("\n"), event };
|
|
1608
1603
|
}
|
|
1609
|
-
function parseJsonObject(text) {
|
|
1610
|
-
try {
|
|
1611
|
-
return asRecord(JSON.parse(text));
|
|
1612
|
-
} catch {
|
|
1613
|
-
return void 0;
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
1604
|
function parseToolInput(argumentsText) {
|
|
1617
1605
|
const parsed = parseJsonObject(argumentsText);
|
|
1618
1606
|
return parsed ?? {};
|
|
@@ -1644,29 +1632,824 @@ function textValue(value) {
|
|
|
1644
1632
|
}
|
|
1645
1633
|
return "";
|
|
1646
1634
|
}
|
|
1647
|
-
function firstNumber2(...values) {
|
|
1648
|
-
for (const value of values) {
|
|
1649
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1650
|
-
return value;
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
return void 0;
|
|
1654
|
-
}
|
|
1655
1635
|
function indexValue(value) {
|
|
1656
1636
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
1657
1637
|
}
|
|
1658
|
-
function removeUndefined2(record) {
|
|
1659
|
-
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
|
|
1660
|
-
}
|
|
1661
1638
|
function encodeSse(event, data) {
|
|
1662
1639
|
return `event: ${event}
|
|
1663
1640
|
data: ${JSON.stringify(data)}
|
|
1664
1641
|
|
|
1665
1642
|
`;
|
|
1666
1643
|
}
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1644
|
+
|
|
1645
|
+
// src/dashboard.ts
|
|
1646
|
+
var DASHBOARD_HTML = `<!doctype html>
|
|
1647
|
+
<html lang="en">
|
|
1648
|
+
<head>
|
|
1649
|
+
<meta charset="utf-8" />
|
|
1650
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1651
|
+
<meta name="color-scheme" content="dark light" />
|
|
1652
|
+
<title>hoopilot · dashboard</title>
|
|
1653
|
+
<style>
|
|
1654
|
+
:root {
|
|
1655
|
+
--bg-0:#0b0e14; --bg-1:#11151c; --bg-2:#171c25; --bg-3:#1f2630;
|
|
1656
|
+
--border:#262d38; --border-strong:#37404d;
|
|
1657
|
+
--text-0:#e6edf3; --text-1:#9aa7b4; --text-2:#5e6b78; --text-dim:#3a434e; --text-inv:#0b0e14;
|
|
1658
|
+
--accent:#4ea1ff; --accent-2:#56d4dd; --accent-soft:rgba(78,161,255,.14);
|
|
1659
|
+
--amber:#f5b042;
|
|
1660
|
+
--ok:#3fb950; --warn:#d8a13a; --danger:#f0556a; --info:#a371f7; --cache:#7c8cff;
|
|
1661
|
+
--spark:#4ea1ff; --spark-fill:color-mix(in srgb, var(--accent) 14%, transparent);
|
|
1662
|
+
--grid-line:rgba(255,255,255,.05);
|
|
1663
|
+
--flash:color-mix(in srgb, var(--accent) 22%, transparent);
|
|
1664
|
+
--flash-up:color-mix(in srgb, var(--ok) 22%, transparent);
|
|
1665
|
+
--flash-down:color-mix(in srgb, var(--danger) 22%, transparent);
|
|
1666
|
+
--c1:#4ea1ff; --c2:#3fb950; --c3:#d8a13a; --c4:#a371f7; --c5:#56d4dd; --c6:#f0556a;
|
|
1667
|
+
--mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
|
1668
|
+
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, system-ui, sans-serif;
|
|
1669
|
+
}
|
|
1670
|
+
@media (prefers-color-scheme: light) {
|
|
1671
|
+
:root:not([data-theme="dark"]) {
|
|
1672
|
+
--bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
|
|
1673
|
+
--border:#d0d7de; --border-strong:#b6bec8;
|
|
1674
|
+
--text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
|
|
1675
|
+
--accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
|
|
1676
|
+
--amber:#b5730a;
|
|
1677
|
+
--ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
|
|
1678
|
+
--spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
|
|
1679
|
+
--grid-line:rgba(0,0,0,.06);
|
|
1680
|
+
--flash:color-mix(in srgb, var(--accent) 16%, transparent);
|
|
1681
|
+
--flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
|
|
1682
|
+
--flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
|
|
1683
|
+
--c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
[data-theme="light"] {
|
|
1687
|
+
--bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
|
|
1688
|
+
--border:#d0d7de; --border-strong:#b6bec8;
|
|
1689
|
+
--text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
|
|
1690
|
+
--accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
|
|
1691
|
+
--amber:#b5730a;
|
|
1692
|
+
--ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
|
|
1693
|
+
--spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
|
|
1694
|
+
--grid-line:rgba(0,0,0,.06);
|
|
1695
|
+
--flash:color-mix(in srgb, var(--accent) 16%, transparent);
|
|
1696
|
+
--flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
|
|
1697
|
+
--flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
|
|
1698
|
+
--c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
|
|
1699
|
+
}
|
|
1700
|
+
* { box-sizing: border-box; }
|
|
1701
|
+
html, body { margin:0; padding:0; }
|
|
1702
|
+
body {
|
|
1703
|
+
background: var(--bg-0); color: var(--text-0); font-family: var(--sans);
|
|
1704
|
+
font-size: 13px; line-height: 1.4; -webkit-font-smoothing: antialiased;
|
|
1705
|
+
}
|
|
1706
|
+
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
|
|
1707
|
+
.num { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
|
|
1708
|
+
.shell { max-width: 1280px; margin: 0 auto; padding: 0 24px 28px; }
|
|
1709
|
+
@media (min-width: 1080px) { .shell { border-left:1px solid var(--border); border-right:1px solid var(--border); } }
|
|
1710
|
+
@media (max-width: 680px) { .shell { padding: 0 12px 24px; } }
|
|
1711
|
+
|
|
1712
|
+
/* header */
|
|
1713
|
+
header.bar {
|
|
1714
|
+
position: sticky; top: 0; z-index: 20; background: var(--bg-1);
|
|
1715
|
+
border-bottom: 1px solid var(--border); height: 48px;
|
|
1716
|
+
}
|
|
1717
|
+
.bar-in { max-width:1280px; margin:0 auto; height:48px; padding:0 24px; display:flex; align-items:center; gap:12px; }
|
|
1718
|
+
@media (max-width:680px){ .bar-in{ padding:0 12px; gap:8px; } }
|
|
1719
|
+
.wordmark { font-family: var(--mono); font-weight:700; font-size:14px; color:var(--text-0); letter-spacing:-.01em; }
|
|
1720
|
+
.caret { display:inline-block; width:7px; height:15px; background:var(--amber); margin-left:3px; vertical-align:-2px; animation: blink 1.1s steps(1) infinite; }
|
|
1721
|
+
.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; }
|
|
1722
|
+
.chip.plan-pro { background:var(--accent-soft); color:var(--accent); }
|
|
1723
|
+
.chip.plan-business { background:color-mix(in srgb, var(--info) 16%, transparent); color:var(--info); }
|
|
1724
|
+
.chip.plan-free, .chip.plan-offline { background:var(--bg-3); color:var(--text-2); }
|
|
1725
|
+
.spacer { flex:1; }
|
|
1726
|
+
.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); }
|
|
1727
|
+
.dot { width:7px; height:7px; border-radius:50%; background:var(--text-2); flex:none; }
|
|
1728
|
+
.pill.live .dot { background:var(--ok); }
|
|
1729
|
+
.pill.paused .dot { background:var(--text-2); }
|
|
1730
|
+
.pill.reconnect { color:var(--warn); } .pill.reconnect .dot { background:var(--warn); }
|
|
1731
|
+
.pill.authkey { color:var(--warn); } .pill.authkey .dot { background:var(--warn); }
|
|
1732
|
+
.heartbeat { animation: hb .5s ease-out; }
|
|
1733
|
+
.updated { font-family:var(--mono); font-size:11px; color:var(--text-2); white-space:nowrap; }
|
|
1734
|
+
.updated.warn { color:var(--warn); } .updated.danger { color:var(--danger); }
|
|
1735
|
+
.seg { display:inline-flex; border:1px solid var(--border); border-radius:6px; overflow:hidden; }
|
|
1736
|
+
.seg button { background:transparent; color:var(--text-1); border:0; font-family:var(--mono); font-size:11px; padding:3px 8px; cursor:pointer; }
|
|
1737
|
+
.seg button + button { border-left:1px solid var(--border); }
|
|
1738
|
+
.seg button.active { background:var(--accent); color:var(--text-inv); }
|
|
1739
|
+
.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; }
|
|
1740
|
+
.iconbtn:hover { background:var(--bg-3); }
|
|
1741
|
+
button:focus-visible, input:focus-visible, .seg button:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
|
|
1742
|
+
#scanbar { position:absolute; left:0; bottom:-1px; height:1px; width:100%; overflow:hidden; }
|
|
1743
|
+
#scanbar::after { content:""; position:absolute; left:0; top:0; height:1px; width:40%;
|
|
1744
|
+
background:linear-gradient(90deg, transparent, var(--accent), transparent);
|
|
1745
|
+
animation: scan var(--scan-ms, 4000ms) linear infinite; }
|
|
1746
|
+
header.bar.paused #scanbar::after, header.bar.frozen #scanbar::after { animation-play-state:paused; opacity:.35; }
|
|
1747
|
+
|
|
1748
|
+
/* disconnect banner */
|
|
1749
|
+
#banner { display:none; margin-top:10px; padding:7px 12px; border-radius:5px; font-family:var(--mono); font-size:12px;
|
|
1750
|
+
background:color-mix(in srgb, var(--danger) 16%, transparent); color:var(--danger); border:1px solid color-mix(in srgb, var(--danger) 40%, transparent); }
|
|
1751
|
+
#banner.ok { background:color-mix(in srgb, var(--ok) 16%, transparent); color:var(--ok); border-color:color-mix(in srgb, var(--ok) 40%, transparent); }
|
|
1752
|
+
#banner.show { display:block; }
|
|
1753
|
+
|
|
1754
|
+
/* hero strip */
|
|
1755
|
+
.hero { display:grid; grid-template-columns:repeat(4,1fr); margin:18px 0 16px; }
|
|
1756
|
+
.vital { padding:6px 18px; }
|
|
1757
|
+
.vital + .vital { border-left:1px solid var(--border); }
|
|
1758
|
+
.vital .eyebrow { font-size:10px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-1); }
|
|
1759
|
+
.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); }
|
|
1760
|
+
.vital .vsub { font-size:11px; color:var(--text-2); min-height:14px; }
|
|
1761
|
+
.vital .vspark { display:block; width:100%; height:24px; margin-top:4px; }
|
|
1762
|
+
.vital.active { }
|
|
1763
|
+
.vital.active .eyebrow { color:var(--accent); }
|
|
1764
|
+
@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; } }
|
|
1765
|
+
@media (max-width:600px){ .hero{ grid-template-columns:1fr; } .vital + .vital{ border-left:0; border-top:1px solid var(--border); } }
|
|
1766
|
+
|
|
1767
|
+
/* grid + panels */
|
|
1768
|
+
.grid { display:grid; grid-template-columns:repeat(12,1fr); gap:12px; }
|
|
1769
|
+
.panel { position:relative; background:var(--bg-1); border:1px solid var(--border); border-radius:4px; padding:16px 12px 12px; min-width:0; }
|
|
1770
|
+
.panel > .ptitle { position:absolute; top:-8px; left:10px; padding:0 6px; background:var(--bg-1);
|
|
1771
|
+
font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
|
|
1772
|
+
.span5{ grid-column:span 5; } .span3{ grid-column:span 3; } .span4{ grid-column:span 4; }
|
|
1773
|
+
.span7{ grid-column:span 7; } .span8{ grid-column:span 8; }
|
|
1774
|
+
@media (max-width:1079px){ .grid{ grid-template-columns:repeat(6,1fr); }
|
|
1775
|
+
.span5,.span7,.span8{ grid-column:span 6; } .span3{ grid-column:span 3; } .span4{ grid-column:span 6; } }
|
|
1776
|
+
@media (max-width:680px){ .grid{ grid-template-columns:1fr; }
|
|
1777
|
+
.span3,.span4,.span5,.span7,.span8{ grid-column:span 1; } }
|
|
1778
|
+
|
|
1779
|
+
.headline { font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-weight:600; font-size:22px; line-height:1.1; }
|
|
1780
|
+
.cap { font-size:11px; color:var(--text-2); }
|
|
1781
|
+
.stack-bar { display:flex; height:8px; border-radius:4px; overflow:hidden; background:var(--bg-3); margin:8px 0; }
|
|
1782
|
+
.stack-bar i { display:block; height:100%; }
|
|
1783
|
+
.stack-bar.empty { outline:1px dashed var(--border); background:transparent; }
|
|
1784
|
+
|
|
1785
|
+
table.tbl { width:100%; border-collapse:collapse; font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-size:12px; }
|
|
1786
|
+
.scrollx { overflow-x:auto; }
|
|
1787
|
+
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; }
|
|
1788
|
+
table.tbl th.l { text-align:left; }
|
|
1789
|
+
table.tbl td { padding:3px 6px; text-align:right; white-space:nowrap; border-bottom:1px solid color-mix(in srgb, var(--border) 55%, transparent); }
|
|
1790
|
+
table.tbl td.l { text-align:left; max-width:160px; overflow:hidden; text-overflow:ellipsis; }
|
|
1791
|
+
table.tbl tr:hover td { background:var(--bg-2); }
|
|
1792
|
+
table.tbl tr.total td { border-top:1px solid var(--border-strong); border-bottom:0; font-weight:600; color:var(--text-0); }
|
|
1793
|
+
.minibar { display:inline-block; height:6px; border-radius:3px; background:var(--accent); vertical-align:middle; min-width:1px; }
|
|
1794
|
+
.ghost td { color:var(--text-2); text-align:center; }
|
|
1795
|
+
.reasoning { color:var(--info); } .cached { color:var(--cache); }
|
|
1796
|
+
|
|
1797
|
+
.legend { display:flex; flex-wrap:wrap; gap:4px 14px; margin-top:8px; }
|
|
1798
|
+
.legend .li { display:flex; align-items:center; gap:6px; font-family:var(--mono); font-size:11px; color:var(--text-1); }
|
|
1799
|
+
.legend .sw { width:8px; height:8px; border-radius:2px; flex:none; }
|
|
1800
|
+
|
|
1801
|
+
.lat-trio { display:flex; gap:18px; align-items:baseline; }
|
|
1802
|
+
.lat-trio .b { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
|
|
1803
|
+
.lat-trio .b small { display:block; font-size:10px; font-weight:600; text-transform:uppercase; color:var(--text-2); letter-spacing:.05em; }
|
|
1804
|
+
.lat-p95 { color:var(--info); }
|
|
1805
|
+
.lat-track { position:relative; height:22px; margin-top:10px; }
|
|
1806
|
+
.lat-track .line { position:absolute; top:11px; left:0; right:0; height:1px; background:var(--border); }
|
|
1807
|
+
.lat-track .tick { position:absolute; top:5px; width:2px; height:12px; border-radius:1px; }
|
|
1808
|
+
.lat-track .tick.p50 { background:var(--accent); } .lat-track .tick.p95 { background:var(--info); }
|
|
1809
|
+
.lat-track .tlab { position:absolute; top:-2px; font-family:var(--mono); font-size:9px; color:var(--text-2); transform:translateX(-50%); }
|
|
1810
|
+
details.routes { margin-top:10px; } details.routes summary { cursor:pointer; font-size:11px; color:var(--text-2); font-family:var(--mono); }
|
|
1811
|
+
|
|
1812
|
+
.qrow { margin:10px 0; } .qrow .qhead { display:flex; justify-content:space-between; align-items:baseline; font-size:12px; }
|
|
1813
|
+
.qrow .qname { color:var(--text-1); } .qrow .qval { font-family:var(--mono); font-variant-numeric:tabular-nums; color:var(--text-0); }
|
|
1814
|
+
.qbar { position:relative; height:8px; border-radius:4px; background:var(--bg-3); margin-top:5px; overflow:hidden; }
|
|
1815
|
+
.qbar i { position:absolute; left:0; top:0; height:100%; border-radius:4px; }
|
|
1816
|
+
.qbar.over i.ext { background:repeating-linear-gradient(45deg, var(--danger), var(--danger) 3px, transparent 3px, transparent 6px); }
|
|
1817
|
+
.inf { font-family:var(--mono); font-size:12px; color:var(--ok); }
|
|
1818
|
+
.emptybox { border:1px solid var(--border); border-radius:5px; padding:14px; text-align:center; color:var(--text-2); }
|
|
1819
|
+
.emptybox .keyglyph { font-size:20px; color:var(--text-1); }
|
|
1820
|
+
.emptybox h4 { margin:8px 0 4px; font-family:var(--sans); font-size:13px; color:var(--text-1); font-weight:600; }
|
|
1821
|
+
.emptybox .errline { font-family:var(--mono); font-size:11px; color:var(--text-2); word-break:break-word; margin:4px 0; }
|
|
1822
|
+
.prompt { font-family:var(--mono); font-size:12px; color:var(--text-1); }
|
|
1823
|
+
|
|
1824
|
+
.upblocks { display:flex; gap:18px; }
|
|
1825
|
+
.upblk { } .upblk .v { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
|
|
1826
|
+
.upblk .k { font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-2); }
|
|
1827
|
+
.upblk.err.hot { color:var(--danger); }
|
|
1828
|
+
.rate { font-family:var(--mono); font-size:12px; } .rate.warn{ color:var(--warn);} .rate.danger{ color:var(--danger);} .rate.ok{ color:var(--ok); }
|
|
1829
|
+
#up-spark, #thru-svg { display:block; width:100%; }
|
|
1830
|
+
#up-spark { height:30px; margin-top:8px; }
|
|
1831
|
+
#thru-svg { height:88px; margin-top:6px; }
|
|
1832
|
+
.flag { font-family:var(--mono); font-size:10px; color:var(--text-2); }
|
|
1833
|
+
|
|
1834
|
+
footer.foot { margin-top:14px; padding-top:10px; border-top:1px solid var(--border); display:flex; flex-wrap:wrap; gap:4px 14px;
|
|
1835
|
+
font-family:var(--mono); font-size:11px; color:var(--text-2); }
|
|
1836
|
+
footer.foot .end { margin-left:auto; }
|
|
1837
|
+
@media (max-width:680px){ footer.foot .end{ margin-left:0; } }
|
|
1838
|
+
|
|
1839
|
+
.skel { color:var(--text-dim); }
|
|
1840
|
+
.flash { animation: flash .6s ease-out; } .flash-up { animation: flashup .6s ease-out; } .flash-down { animation: flashdown .6s ease-out; }
|
|
1841
|
+
|
|
1842
|
+
/* auth takeover */
|
|
1843
|
+
#auth { display:none; }
|
|
1844
|
+
#auth.show { display:flex; justify-content:center; padding:64px 16px; }
|
|
1845
|
+
.authcard { width:100%; max-width:420px; background:var(--bg-1); border:1px solid var(--border); border-radius:6px; padding:22px 18px; position:relative; }
|
|
1846
|
+
.authcard h3 { margin:0 0 10px; font-family:var(--mono); font-size:12px; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
|
|
1847
|
+
.authcard p { font-size:12px; color:var(--text-2); margin:0 0 14px; }
|
|
1848
|
+
.authcard .row { display:flex; gap:8px; }
|
|
1849
|
+
.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; }
|
|
1850
|
+
.authcard input.bad { border-color:var(--danger); }
|
|
1851
|
+
.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; }
|
|
1852
|
+
.authcard .err { color:var(--danger); font-family:var(--mono); font-size:11px; min-height:14px; margin-top:8px; }
|
|
1853
|
+
.authcard .clear { position:absolute; top:14px; right:16px; font-size:11px; color:var(--text-2); cursor:pointer; }
|
|
1854
|
+
.dim { opacity:.45; filter:grayscale(.4); transition:opacity .2s, filter .2s; }
|
|
1855
|
+
|
|
1856
|
+
@keyframes blink { 50% { opacity:0; } }
|
|
1857
|
+
@keyframes scan { 0%{ transform:translateX(-100%);} 100%{ transform:translateX(350%);} }
|
|
1858
|
+
@keyframes hb { 0%{ transform:scale(1);} 35%{ transform:scale(1.7);} 100%{ transform:scale(1);} }
|
|
1859
|
+
@keyframes flash { from{ background:var(--flash);} to{ background:transparent;} }
|
|
1860
|
+
@keyframes flashup { from{ background:var(--flash-up);} to{ background:transparent;} }
|
|
1861
|
+
@keyframes flashdown { from{ background:var(--flash-down);} to{ background:transparent;} }
|
|
1862
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1863
|
+
.caret { animation:none; } #scanbar::after { animation:none; opacity:.3; }
|
|
1864
|
+
.heartbeat { animation:none; }
|
|
1865
|
+
.flash, .flash-up, .flash-down { animation:none; box-shadow: inset 2px 0 0 var(--accent); }
|
|
1866
|
+
}
|
|
1867
|
+
</style>
|
|
1868
|
+
</head>
|
|
1869
|
+
<body>
|
|
1870
|
+
<header class="bar" id="bar">
|
|
1871
|
+
<div class="bar-in">
|
|
1872
|
+
<span class="wordmark">hoopilot<span class="caret" aria-hidden="true"></span></span>
|
|
1873
|
+
<span class="chip" id="version-chip">v···</span>
|
|
1874
|
+
<span class="chip plan-offline" id="plan-chip">— offline</span>
|
|
1875
|
+
<span class="spacer"></span>
|
|
1876
|
+
<span class="pill" id="conn-pill" aria-live="polite"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting</span></span>
|
|
1877
|
+
<span class="updated" id="updated"></span>
|
|
1878
|
+
<span class="seg" id="seg" role="group" aria-label="Refresh interval">
|
|
1879
|
+
<button data-ms="2000">2s</button><button data-ms="4000" class="active">4s</button><button data-ms="10000">10s</button>
|
|
1880
|
+
</span>
|
|
1881
|
+
<button class="iconbtn" id="btn-pause" title="Pause / resume" aria-label="Pause or resume">❚❚</button>
|
|
1882
|
+
<button class="iconbtn" id="btn-theme" title="Theme: auto / dark / light" aria-label="Cycle theme">A</button>
|
|
1883
|
+
</div>
|
|
1884
|
+
<div id="scanbar" aria-hidden="true"></div>
|
|
1885
|
+
</header>
|
|
1886
|
+
|
|
1887
|
+
<div class="shell">
|
|
1888
|
+
<div id="banner" role="status" aria-live="polite"></div>
|
|
1889
|
+
|
|
1890
|
+
<section id="content">
|
|
1891
|
+
<section class="hero" aria-label="Vitals">
|
|
1892
|
+
<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>
|
|
1893
|
+
<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>
|
|
1894
|
+
<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>
|
|
1895
|
+
<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>
|
|
1896
|
+
</section>
|
|
1897
|
+
|
|
1898
|
+
<section class="grid">
|
|
1899
|
+
<div class="panel span5"><span class="ptitle">┤ Proxy · requests ┠</span>
|
|
1900
|
+
<div class="headline"><span id="req-total" class="skel">···</span> <span class="cap">requests</span></div>
|
|
1901
|
+
<div class="stack-bar empty" id="route-sharebar"></div>
|
|
1902
|
+
<div class="stack-bar empty" id="status-healthbar"></div>
|
|
1903
|
+
<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>
|
|
1904
|
+
</div>
|
|
1905
|
+
|
|
1906
|
+
<div class="panel span3"><span class="ptitle">┤ Status ┠</span>
|
|
1907
|
+
<div class="headline"><span id="error-rate" class="skel">···</span> <span class="cap">err rate</span></div>
|
|
1908
|
+
<div class="stack-bar empty" id="status-bar"></div>
|
|
1909
|
+
<div class="legend" id="status-legend"></div>
|
|
1910
|
+
</div>
|
|
1911
|
+
|
|
1912
|
+
<div class="panel span4"><span class="ptitle">┤ Latency · ms ┠</span>
|
|
1913
|
+
<div class="lat-trio">
|
|
1914
|
+
<div class="b"><small>p50</small><span id="lat-p50" class="skel">·</span></div>
|
|
1915
|
+
<div class="b lat-p95"><small>p95</small><span id="lat-p95" class="skel">·</span></div>
|
|
1916
|
+
<div class="b"><small>avg</small><span id="lat-avg" class="skel">·</span></div>
|
|
1917
|
+
<div class="b"><small>obs</small><span id="lat-count" class="skel">·</span></div>
|
|
1918
|
+
</div>
|
|
1919
|
+
<div class="lat-track" id="lat-track"><div class="line"></div></div>
|
|
1920
|
+
<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>
|
|
1921
|
+
</div>
|
|
1922
|
+
|
|
1923
|
+
<div class="panel span7"><span class="ptitle">┤ Tokens · by model ┠</span>
|
|
1924
|
+
<div class="headline"><span id="tok-total" class="skel">···</span> <span class="cap">tokens · <span id="tok-cache">cache ·%</span></span></div>
|
|
1925
|
+
<div class="stack-bar empty" id="tok-mixbar"></div>
|
|
1926
|
+
<div class="legend" id="tok-legend"></div>
|
|
1927
|
+
<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>
|
|
1928
|
+
</div>
|
|
1929
|
+
|
|
1930
|
+
<div class="panel span5"><span class="ptitle">┤ Copilot · quota ┠</span>
|
|
1931
|
+
<div id="copilot-body"><div class="emptybox skel">loading…</div></div>
|
|
1932
|
+
</div>
|
|
1933
|
+
|
|
1934
|
+
<div class="panel span4"><span class="ptitle">┤ Upstream · copilot edge ┠</span>
|
|
1935
|
+
<div class="upblocks">
|
|
1936
|
+
<div class="upblk"><div class="v" id="up-total">·</div><div class="k">calls</div></div>
|
|
1937
|
+
<div class="upblk err" id="up-errblk"><div class="v" id="up-errors">·</div><div class="k">errors</div></div>
|
|
1938
|
+
<div class="upblk"><div class="v rate" id="up-rate">·</div><div class="k">err rate</div></div>
|
|
1939
|
+
</div>
|
|
1940
|
+
<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>
|
|
1941
|
+
<div class="flag" id="up-flag"></div>
|
|
1942
|
+
</div>
|
|
1943
|
+
|
|
1944
|
+
<div class="panel span8"><span class="ptitle">┤ Throughput ┠</span>
|
|
1945
|
+
<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>
|
|
1946
|
+
<svg id="thru-svg" viewBox="0 0 320 88" preserveAspectRatio="none" aria-hidden="true">
|
|
1947
|
+
<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>
|
|
1948
|
+
<line class="grid" x1="0" y1="22" x2="320" y2="22" stroke="var(--grid-line)"/>
|
|
1949
|
+
<line class="grid" x1="0" y1="44" x2="320" y2="44" stroke="var(--grid-line)"/>
|
|
1950
|
+
<line class="grid" x1="0" y1="66" x2="320" y2="66" stroke="var(--grid-line)"/>
|
|
1951
|
+
<path id="thru-tok-area" fill="url(#thrugrad)" stroke="none"/>
|
|
1952
|
+
<path id="thru-tok-line" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>
|
|
1953
|
+
<path id="thru-req-line" fill="none" stroke="var(--accent-2)" stroke-width="1.2" vector-effect="non-scaling-stroke" opacity="0.9"/>
|
|
1954
|
+
</svg>
|
|
1955
|
+
</div>
|
|
1956
|
+
</section>
|
|
1957
|
+
</section>
|
|
1958
|
+
|
|
1959
|
+
<section id="auth" aria-live="polite">
|
|
1960
|
+
<div class="authcard">
|
|
1961
|
+
<span class="clear" id="auth-clear" style="display:none">clear key</span>
|
|
1962
|
+
<h3>┤ Auth required ┠</h3>
|
|
1963
|
+
<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>
|
|
1964
|
+
<div class="row"><input id="auth-input" type="password" placeholder="x-api-key" autocomplete="off" spellcheck="false" /><button id="auth-connect">connect</button></div>
|
|
1965
|
+
<div class="err" id="auth-err"></div>
|
|
1966
|
+
</div>
|
|
1967
|
+
</section>
|
|
1968
|
+
|
|
1969
|
+
<footer class="foot">
|
|
1970
|
+
<span id="foot-started">started ·</span>
|
|
1971
|
+
<span id="foot-uptime">uptime ·</span>
|
|
1972
|
+
<span id="foot-total">· req</span>
|
|
1973
|
+
<span id="foot-tokens">· tokens</span>
|
|
1974
|
+
<span id="foot-upstream">upstream ·</span>
|
|
1975
|
+
<span class="end" id="foot-cadence"></span>
|
|
1976
|
+
</footer>
|
|
1977
|
+
</div>
|
|
1978
|
+
|
|
1979
|
+
<script>
|
|
1980
|
+
(function(){
|
|
1981
|
+
"use strict";
|
|
1982
|
+
var byId = function(id){ return document.getElementById(id); };
|
|
1983
|
+
var CAP = 60;
|
|
1984
|
+
|
|
1985
|
+
// ---- persistent state ----
|
|
1986
|
+
var LS = window.localStorage;
|
|
1987
|
+
var apiKey = "";
|
|
1988
|
+
try { apiKey = LS.getItem("hoopilot.apiKey") || ""; } catch (e) { apiKey = ""; }
|
|
1989
|
+
var theme = "auto";
|
|
1990
|
+
try { theme = LS.getItem("hoopilot.theme") || "auto"; } catch (e) { theme = "auto"; }
|
|
1991
|
+
var intervalMs = 4000;
|
|
1992
|
+
try { var sv = parseInt(LS.getItem("hoopilot.intervalMs") || "", 10); if (sv === 2000 || sv === 4000 || sv === 10000) intervalMs = sv; } catch (e) {}
|
|
1993
|
+
|
|
1994
|
+
// ---- runtime state ----
|
|
1995
|
+
var paused = false;
|
|
1996
|
+
var timer = null;
|
|
1997
|
+
var inflightFetch = null;
|
|
1998
|
+
var lastSuccessAt = 0;
|
|
1999
|
+
var prevSample = null; // { t, reqTotal, tokTotal, upTotal, startedAt }
|
|
2000
|
+
var lastRender = {}; // for change-flash
|
|
2001
|
+
var backoffMs = 0;
|
|
2002
|
+
var lastUptime = null; // seconds; ticked locally between polls
|
|
2003
|
+
var hist = { req:[], tok:[], inflight:[], up:[] };
|
|
2004
|
+
|
|
2005
|
+
// ---- formatting helpers ----
|
|
2006
|
+
function humanInt(n){
|
|
2007
|
+
if (n === null || n === undefined || !isFinite(n)) return "0";
|
|
2008
|
+
var a = Math.abs(n);
|
|
2009
|
+
if (a >= 1000000) return (n/1000000).toFixed(a >= 10000000 ? 0 : 1) + "M";
|
|
2010
|
+
if (a >= 1000) return (n/1000).toFixed(a >= 10000 ? 0 : 1) + "k";
|
|
2011
|
+
return String(Math.round(n));
|
|
2012
|
+
}
|
|
2013
|
+
function rate(n){
|
|
2014
|
+
if (n === null || n === undefined || !isFinite(n)) return "0";
|
|
2015
|
+
if (n >= 100) return String(Math.round(n));
|
|
2016
|
+
if (n >= 10) return n.toFixed(1);
|
|
2017
|
+
return n.toFixed(2);
|
|
2018
|
+
}
|
|
2019
|
+
function pct(n){ if (!isFinite(n)) return "0%"; return (n >= 10 ? Math.round(n) : Math.round(n*10)/10) + "%"; }
|
|
2020
|
+
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 + ""; }
|
|
2021
|
+
function pad2(n){ return (n < 10 ? "0" : "") + n; }
|
|
2022
|
+
function fmtUptime(sec){
|
|
2023
|
+
sec = Math.max(0, Math.floor(sec));
|
|
2024
|
+
var d = Math.floor(sec/86400); sec -= d*86400;
|
|
2025
|
+
var h = Math.floor(sec/3600); sec -= h*3600;
|
|
2026
|
+
var m = Math.floor(sec/60); var s = sec - m*60;
|
|
2027
|
+
if (d > 0) return d + "d " + pad2(h) + ":" + pad2(m);
|
|
2028
|
+
if (h > 0) return h + ":" + pad2(m) + ":" + pad2(s);
|
|
2029
|
+
return m + ":" + pad2(s);
|
|
2030
|
+
}
|
|
2031
|
+
function titleize(key){
|
|
2032
|
+
var map = { premium_interactions:"Premium requests", chat:"Chat", completions:"Completions", code_review:"Code review" };
|
|
2033
|
+
if (map[key]) return map[key];
|
|
2034
|
+
return key.split("_").map(function(w){ return w ? w.charAt(0).toUpperCase() + w.slice(1) : w; }).join(" ");
|
|
2035
|
+
}
|
|
2036
|
+
function relTime(iso){
|
|
2037
|
+
var t = Date.parse(iso); if (!isFinite(t)) return iso || "";
|
|
2038
|
+
var s = Math.max(0, Math.round((Date.now() - t)/1000));
|
|
2039
|
+
return fmtUptime(s) + " ago";
|
|
2040
|
+
}
|
|
2041
|
+
function clearEl(el){ while (el && el.firstChild) el.removeChild(el.firstChild); }
|
|
2042
|
+
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; }
|
|
2043
|
+
|
|
2044
|
+
// Set numeric text and flash on discrete change.
|
|
2045
|
+
function setNum(id, value, kind, num){
|
|
2046
|
+
var el = byId(id); if (!el) return;
|
|
2047
|
+
el.classList.remove("skel");
|
|
2048
|
+
var s = String(value);
|
|
2049
|
+
if (el.textContent !== s){
|
|
2050
|
+
el.textContent = s;
|
|
2051
|
+
// Compare on the raw number (num) when provided, so directional flash works
|
|
2052
|
+
// even when value is a pre-formatted display string.
|
|
2053
|
+
var n = (num !== undefined) ? num : value;
|
|
2054
|
+
var prev = lastRender[id];
|
|
2055
|
+
if (prev !== undefined){
|
|
2056
|
+
var cls = "flash";
|
|
2057
|
+
if (kind === "delta" && typeof n === "number" && typeof prev === "number"){
|
|
2058
|
+
cls = n > prev ? "flash-up" : (n < prev ? "flash-down" : null);
|
|
2059
|
+
}
|
|
2060
|
+
if (cls){ el.classList.remove("flash","flash-up","flash-down"); void el.offsetWidth; el.classList.add(cls); }
|
|
2061
|
+
}
|
|
2062
|
+
lastRender[id] = n;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
function setText(id, s){ var el = byId(id); if (el){ el.classList.remove("skel"); el.textContent = s; } }
|
|
2066
|
+
|
|
2067
|
+
// ---- sparkline rendering ----
|
|
2068
|
+
function pushHist(arr, v){ arr.push(v); if (arr.length > CAP) arr.shift(); }
|
|
2069
|
+
function buildSpark(values, w, h){
|
|
2070
|
+
var pts = []; for (var i=0;i<values.length;i++){ if (isFinite(values[i])) pts.push({ i:i, v:values[i] }); }
|
|
2071
|
+
if (pts.length < 2) return null;
|
|
2072
|
+
var min = Infinity, max = -Infinity;
|
|
2073
|
+
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; } }
|
|
2074
|
+
var flat = (max - min) <= 0;
|
|
2075
|
+
var pad = flat ? 1 : (max - min) * 0.05; var lo = min - pad, hi = max + pad; var span = hi - lo; if (span <= 0) span = 1;
|
|
2076
|
+
var n = values.length;
|
|
2077
|
+
var line = "", lastX = 0, lastY = 0, started = false;
|
|
2078
|
+
for (var k=0;k<n;k++){
|
|
2079
|
+
var val = values[k]; if (!isFinite(val)) continue;
|
|
2080
|
+
var x = (n === 1) ? w : (k * (w/(n-1)));
|
|
2081
|
+
var norm = flat ? 0.5 : (val - lo)/span;
|
|
2082
|
+
var y = h - norm*(h-2) - 1;
|
|
2083
|
+
line += (started ? " L" : "M") + x.toFixed(2) + "," + y.toFixed(2);
|
|
2084
|
+
lastX = x; lastY = y; started = true;
|
|
2085
|
+
}
|
|
2086
|
+
var area = line + " L" + lastX.toFixed(2) + "," + h + " L0," + h + " Z";
|
|
2087
|
+
return { line:line, area:area, lastX:lastX, lastY:lastY };
|
|
2088
|
+
}
|
|
2089
|
+
function drawSpark(svgId, values){
|
|
2090
|
+
var svg = byId(svgId); if (!svg) return;
|
|
2091
|
+
var vb = svg.viewBox.baseVal; var w = vb.width || 200, h = vb.height || 24;
|
|
2092
|
+
var sp = buildSpark(values, w, h);
|
|
2093
|
+
var line = svg.querySelector(".line"), area = svg.querySelector(".area"), dot = svg.querySelector("circle");
|
|
2094
|
+
if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); if (dot) dot.style.display = "none"; return; }
|
|
2095
|
+
if (line) line.setAttribute("d", sp.line);
|
|
2096
|
+
if (area) area.setAttribute("d", sp.area);
|
|
2097
|
+
if (dot){ dot.setAttribute("cx", sp.lastX.toFixed(2)); dot.setAttribute("cy", sp.lastY.toFixed(2)); dot.style.display = ""; }
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// ---- theme ----
|
|
2101
|
+
function applyTheme(){
|
|
2102
|
+
var root = document.documentElement;
|
|
2103
|
+
if (theme === "dark") root.setAttribute("data-theme","dark");
|
|
2104
|
+
else if (theme === "light") root.setAttribute("data-theme","light");
|
|
2105
|
+
else root.removeAttribute("data-theme");
|
|
2106
|
+
byId("btn-theme").textContent = theme === "dark" ? "D" : (theme === "light" ? "L" : "A");
|
|
2107
|
+
}
|
|
2108
|
+
byId("btn-theme").addEventListener("click", function(){
|
|
2109
|
+
theme = theme === "auto" ? "dark" : (theme === "dark" ? "light" : "auto");
|
|
2110
|
+
try { LS.setItem("hoopilot.theme", theme); } catch (e) {}
|
|
2111
|
+
applyTheme();
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
// ---- interval + pause ----
|
|
2115
|
+
function setActiveSeg(){
|
|
2116
|
+
var btns = byId("seg").querySelectorAll("button");
|
|
2117
|
+
for (var i=0;i<btns.length;i++){ btns[i].classList.toggle("active", parseInt(btns[i].getAttribute("data-ms"),10) === intervalMs); }
|
|
2118
|
+
document.documentElement.style.setProperty("--scan-ms", intervalMs + "ms");
|
|
2119
|
+
}
|
|
2120
|
+
byId("seg").addEventListener("click", function(ev){
|
|
2121
|
+
var b = ev.target.closest ? ev.target.closest("button") : null; if (!b) return;
|
|
2122
|
+
intervalMs = parseInt(b.getAttribute("data-ms"),10) || 4000;
|
|
2123
|
+
try { LS.setItem("hoopilot.intervalMs", String(intervalMs)); } catch (e) {}
|
|
2124
|
+
setActiveSeg();
|
|
2125
|
+
if (!paused){ schedule(0); }
|
|
2126
|
+
});
|
|
2127
|
+
byId("btn-pause").addEventListener("click", function(){
|
|
2128
|
+
paused = !paused;
|
|
2129
|
+
byId("btn-pause").innerHTML = paused ? "▶" : "❚❚";
|
|
2130
|
+
byId("bar").classList.toggle("paused", paused);
|
|
2131
|
+
if (paused){ if (timer){ clearTimeout(timer); timer = null; } setPill("paused","PAUSED",false); }
|
|
2132
|
+
else { setPill("live","LIVE",false); schedule(0); }
|
|
2133
|
+
});
|
|
2134
|
+
|
|
2135
|
+
// ---- connection pill / banner ----
|
|
2136
|
+
function setPill(kind, text, beat){
|
|
2137
|
+
var pill = byId("conn-pill"); var dot = byId("conn-dot");
|
|
2138
|
+
pill.className = "pill " + kind;
|
|
2139
|
+
byId("conn-text").textContent = text;
|
|
2140
|
+
if (beat && dot){ dot.classList.remove("heartbeat"); void dot.offsetWidth; dot.classList.add("heartbeat"); }
|
|
2141
|
+
}
|
|
2142
|
+
function showBanner(text, ok){
|
|
2143
|
+
var b = byId("banner"); b.textContent = text; b.className = "show" + (ok ? " ok" : "");
|
|
2144
|
+
if (ok){ setTimeout(function(){ b.classList.remove("show"); }, 2000); }
|
|
2145
|
+
}
|
|
2146
|
+
function hideBanner(){ byId("banner").classList.remove("show"); }
|
|
2147
|
+
function setDimmed(on){ byId("content").classList.toggle("dim", on); }
|
|
2148
|
+
|
|
2149
|
+
// ---- auth takeover ----
|
|
2150
|
+
function showAuth(rejected){
|
|
2151
|
+
byId("content").style.display = "none";
|
|
2152
|
+
byId("auth").classList.add("show");
|
|
2153
|
+
setPill("authkey","API KEY",false);
|
|
2154
|
+
byId("auth-err").textContent = rejected ? "key rejected" : "";
|
|
2155
|
+
byId("auth-input").classList.toggle("bad", !!rejected);
|
|
2156
|
+
byId("auth-clear").style.display = apiKey ? "" : "none";
|
|
2157
|
+
byId("auth-input").focus();
|
|
2158
|
+
}
|
|
2159
|
+
function hideAuth(){ byId("auth").classList.remove("show"); byId("content").style.display = ""; }
|
|
2160
|
+
byId("auth-connect").addEventListener("click", function(){
|
|
2161
|
+
var v = byId("auth-input").value.trim(); if (!v) return;
|
|
2162
|
+
apiKey = v; try { LS.setItem("hoopilot.apiKey", apiKey); } catch (e) {}
|
|
2163
|
+
hideAuth(); schedule(0);
|
|
2164
|
+
});
|
|
2165
|
+
byId("auth-input").addEventListener("keydown", function(ev){ if (ev.key === "Enter") byId("auth-connect").click(); });
|
|
2166
|
+
byId("auth-clear").addEventListener("click", function(){
|
|
2167
|
+
apiKey = ""; try { LS.removeItem("hoopilot.apiKey"); } catch (e) {}
|
|
2168
|
+
byId("auth-input").value = ""; byId("auth-clear").style.display = "none"; byId("auth-input").focus();
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
// ---- the poll loop (setTimeout-chained, never setInterval) ----
|
|
2172
|
+
var pollGen = 0;
|
|
2173
|
+
function schedule(delay){
|
|
2174
|
+
if (timer){ clearTimeout(timer); }
|
|
2175
|
+
if (paused) return;
|
|
2176
|
+
timer = setTimeout(poll, delay === undefined ? intervalMs : delay);
|
|
2177
|
+
}
|
|
2178
|
+
function poll(){
|
|
2179
|
+
if (paused) return;
|
|
2180
|
+
// A new poll supersedes any in-flight one. Bump the generation so the old
|
|
2181
|
+
// request's settled handlers (including its abort rejection) become no-ops
|
|
2182
|
+
// and never flash a false "disconnected".
|
|
2183
|
+
pollGen += 1; var myGen = pollGen;
|
|
2184
|
+
if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
|
|
2185
|
+
var ctrl = new AbortController(); inflightFetch = ctrl;
|
|
2186
|
+
var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, 3000);
|
|
2187
|
+
var headers = { "accept":"application/json" };
|
|
2188
|
+
if (apiKey) headers["x-api-key"] = apiKey;
|
|
2189
|
+
fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
|
|
2190
|
+
clearTimeout(to);
|
|
2191
|
+
if (myGen !== pollGen) return null;
|
|
2192
|
+
if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
|
|
2193
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
2194
|
+
return res.json();
|
|
2195
|
+
}).then(function(data){
|
|
2196
|
+
if (myGen !== pollGen || data === null || paused) return;
|
|
2197
|
+
inflightFetch = null;
|
|
2198
|
+
onData(data);
|
|
2199
|
+
backoffMs = 0; lastSuccessAt = Date.now();
|
|
2200
|
+
hideAuth(); setDimmed(false); hideBanner();
|
|
2201
|
+
setPill("live","LIVE",true);
|
|
2202
|
+
byId("bar").classList.remove("frozen");
|
|
2203
|
+
schedule(intervalMs);
|
|
2204
|
+
}).catch(function(err){
|
|
2205
|
+
clearTimeout(to);
|
|
2206
|
+
if (myGen !== pollGen || paused) return;
|
|
2207
|
+
inflightFetch = null;
|
|
2208
|
+
onDisconnect(err);
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
function onDisconnect(err){
|
|
2212
|
+
setPill("reconnect","RECONNECTING",false);
|
|
2213
|
+
setDimmed(true);
|
|
2214
|
+
byId("bar").classList.add("frozen");
|
|
2215
|
+
backoffMs = backoffMs ? Math.min(Math.round(backoffMs * 1.5), 30000) : intervalMs;
|
|
2216
|
+
showBanner("Disconnected (" + (err && err.message ? err.message : "no response") + ") \\u2014 retrying in " + Math.round(backoffMs/1000) + "s", false);
|
|
2217
|
+
schedule(backoffMs);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// ---- main render ----
|
|
2221
|
+
function onData(usage){
|
|
2222
|
+
var proxy = usage.proxy || {};
|
|
2223
|
+
var now = Date.now();
|
|
2224
|
+
|
|
2225
|
+
setText("version-chip", "v" + (usage.version || "?"));
|
|
2226
|
+
|
|
2227
|
+
// rates
|
|
2228
|
+
var reqTotal = (proxy.requests && proxy.requests.total) || 0;
|
|
2229
|
+
var tokTotal = (proxy.tokens && proxy.tokens.total) || 0;
|
|
2230
|
+
var upTotal = (proxy.upstream && proxy.upstream.total) || 0;
|
|
2231
|
+
var startedAt = proxy.startedAt || "";
|
|
2232
|
+
var reqPerSec = NaN, tokPerSec = NaN, upDelta = 0, restarted = false;
|
|
2233
|
+
if (prevSample){
|
|
2234
|
+
var dt = (now - prevSample.t)/1000;
|
|
2235
|
+
if (prevSample.startedAt && startedAt && prevSample.startedAt !== startedAt) restarted = true;
|
|
2236
|
+
if (reqTotal < prevSample.reqTotal || tokTotal < prevSample.tokTotal) restarted = true;
|
|
2237
|
+
if (restarted){ reqPerSec = 0; tokPerSec = 0; upDelta = 0; }
|
|
2238
|
+
else if (dt > 0 && isFinite(dt)){
|
|
2239
|
+
reqPerSec = Math.max(0, (reqTotal - prevSample.reqTotal)/dt);
|
|
2240
|
+
tokPerSec = Math.max(0, (tokTotal - prevSample.tokTotal)/dt);
|
|
2241
|
+
upDelta = Math.max(0, upTotal - prevSample.upTotal);
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
prevSample = { t:now, reqTotal:reqTotal, tokTotal:tokTotal, upTotal:upTotal, startedAt:startedAt };
|
|
2245
|
+
|
|
2246
|
+
// hero vitals
|
|
2247
|
+
if (isFinite(reqPerSec)){ pushHist(hist.req, reqPerSec); setNum("req-num", rate(reqPerSec)); } else setText("req-num","\\u2014");
|
|
2248
|
+
if (isFinite(tokPerSec)){ pushHist(hist.tok, tokPerSec); setNum("tok-num", humanInt(tokPerSec)); } else setText("tok-num","\\u2014");
|
|
2249
|
+
var inflight = proxy.inFlight || 0;
|
|
2250
|
+
pushHist(hist.inflight, inflight); setNum("inflight-num", String(inflight), "delta", inflight);
|
|
2251
|
+
byId("v-inflight").classList.toggle("active", inflight > 0);
|
|
2252
|
+
setText("uptime-num", fmtUptime(proxy.uptimeSeconds || 0));
|
|
2253
|
+
|
|
2254
|
+
setText("req-sub", hist.req.length ? ("avg " + rate(avg(hist.req)) + "/s") : "warming up");
|
|
2255
|
+
setText("tok-sub", hist.tok.length ? ("peak " + humanInt(Math.max.apply(null, hist.tok)) + "/s") : "warming up");
|
|
2256
|
+
setText("inflight-sub", inflight + " now");
|
|
2257
|
+
setText("uptime-sub", startedAt ? ("since " + relTime(startedAt)) : "");
|
|
2258
|
+
|
|
2259
|
+
drawSpark("req-spark", hist.req);
|
|
2260
|
+
drawSpark("tok-spark", hist.tok);
|
|
2261
|
+
drawSpark("inflight-spark", hist.inflight);
|
|
2262
|
+
|
|
2263
|
+
renderRequests(proxy);
|
|
2264
|
+
renderStatus(proxy);
|
|
2265
|
+
renderLatency(proxy.latency || {});
|
|
2266
|
+
renderTokens(proxy.tokens || {});
|
|
2267
|
+
renderCopilot(usage);
|
|
2268
|
+
renderUpstream(proxy.upstream || {}, upDelta, restarted);
|
|
2269
|
+
renderThroughput();
|
|
2270
|
+
renderFooter(usage, proxy);
|
|
2271
|
+
|
|
2272
|
+
setNum("req-total", humanInt(reqTotal));
|
|
2273
|
+
setNum("tok-total", humanInt(tokTotal));
|
|
2274
|
+
lastUptime = proxy.uptimeSeconds || 0;
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
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; }
|
|
2278
|
+
|
|
2279
|
+
var ROUTE_COLORS = ["var(--c1)","var(--c2)","var(--c3)","var(--c4)","var(--c5)","var(--c6)"];
|
|
2280
|
+
function renderRequests(proxy){
|
|
2281
|
+
var byRoute = (proxy.requests && proxy.requests.byRoute) || {};
|
|
2282
|
+
var total = (proxy.requests && proxy.requests.total) || 0;
|
|
2283
|
+
var rows = Object.keys(byRoute).map(function(k){ return { k:k, v:byRoute[k] }; }).sort(function(a,b){ return b.v - a.v; });
|
|
2284
|
+
var share = byId("route-sharebar"); clearEl(share); share.className = "stack-bar" + (total ? "" : " empty");
|
|
2285
|
+
var body = byId("routes-body"); clearEl(body);
|
|
2286
|
+
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; }
|
|
2287
|
+
rows.forEach(function(r, idx){
|
|
2288
|
+
var p = total ? (r.v/total*100) : 0;
|
|
2289
|
+
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);
|
|
2290
|
+
var tr = mk("tr");
|
|
2291
|
+
var name = mk("td","l", r.k); name.title = r.k; tr.appendChild(name);
|
|
2292
|
+
tr.appendChild(mk("td",null, humanInt(r.v)));
|
|
2293
|
+
tr.appendChild(mk("td",null, pct(p)));
|
|
2294
|
+
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);
|
|
2295
|
+
body.appendChild(tr);
|
|
2296
|
+
});
|
|
2297
|
+
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);
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
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"; }
|
|
2301
|
+
function statusColor(cls){ return cls === "ok" ? "var(--ok)" : cls === "info" ? "var(--info)" : cls === "warn" ? "var(--warn)" : cls === "danger" ? "var(--danger)" : "var(--text-2)"; }
|
|
2302
|
+
function renderStatus(proxy){
|
|
2303
|
+
var byStatus = (proxy.requests && proxy.requests.byStatus) || {};
|
|
2304
|
+
var total = 0, errs = 0; var groups = { ok:0, info:0, warn:0, danger:0, muted:0 };
|
|
2305
|
+
var codes = Object.keys(byStatus).map(function(k){ return { k:k, v:byStatus[k] }; }).sort(function(a,b){ return b.v - a.v; });
|
|
2306
|
+
codes.forEach(function(c){ total += c.v; var cls = statusClass(c.k); groups[cls] += c.v; if (cls === "warn" || cls === "danger") errs += c.v; });
|
|
2307
|
+
var bar = byId("status-bar"); clearEl(bar); bar.className = "stack-bar" + (total ? "" : " empty");
|
|
2308
|
+
["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); } });
|
|
2309
|
+
var leg = byId("status-legend"); clearEl(leg);
|
|
2310
|
+
if (!codes.length){ leg.appendChild(mk("span","li","no requests yet")); }
|
|
2311
|
+
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); });
|
|
2312
|
+
var er = total ? (errs/total*100) : 0;
|
|
2313
|
+
setNum("error-rate", pct(er));
|
|
2314
|
+
var el = byId("error-rate"); el.style.color = er > 5 ? "var(--danger)" : er > 1 ? "var(--warn)" : "var(--ok)";
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
function renderLatency(lat){
|
|
2318
|
+
setText("lat-p50", fmtMs(lat.p50Ms)); setText("lat-avg", fmtMs(lat.avgMs)); setText("lat-count", humanInt(lat.count || 0));
|
|
2319
|
+
var p95 = byId("lat-p95"); p95.classList.remove("skel"); p95.textContent = fmtMs(lat.p95Ms);
|
|
2320
|
+
p95.style.color = (lat.p50Ms > 0 && lat.p95Ms > 2*lat.p50Ms) ? "var(--warn)" : "var(--info)";
|
|
2321
|
+
// track: position p50 and p95 across 0..(p95*1.15)
|
|
2322
|
+
var track = byId("lat-track"); var old = track.querySelectorAll(".tick,.tlab"); for (var i=0;i<old.length;i++) old[i].remove();
|
|
2323
|
+
var maxv = Math.max(lat.p95Ms || 0, lat.avgMs || 0, 1) * 1.15;
|
|
2324
|
+
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); }
|
|
2325
|
+
place(lat.p50Ms, "p50"); place(lat.p95Ms, "p95");
|
|
2326
|
+
var lr = byId("lat-routes"); clearEl(lr);
|
|
2327
|
+
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); });
|
|
2328
|
+
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); });
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
function renderTokens(tok){
|
|
2332
|
+
var prompt = tok.prompt||0, completion = tok.completion||0, reasoning = tok.reasoning||0, cached = tok.cached||0;
|
|
2333
|
+
var sum = prompt + completion + reasoning;
|
|
2334
|
+
var bar = byId("tok-mixbar"); clearEl(bar); bar.className = "stack-bar" + (sum ? "" : " empty");
|
|
2335
|
+
var parts = [ ["prompt", prompt, "var(--text-1)"], ["completion", completion, "var(--accent)"], ["reasoning", reasoning, "var(--info)"] ];
|
|
2336
|
+
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); } });
|
|
2337
|
+
var leg = byId("tok-legend"); clearEl(leg);
|
|
2338
|
+
var legParts = parts.concat([["cached", cached, "var(--cache)"]]);
|
|
2339
|
+
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); });
|
|
2340
|
+
var cacheRate = prompt ? (cached/prompt*100) : 0; setText("tok-cache", "cache " + pct(cacheRate));
|
|
2341
|
+
var body = byId("tok-body"); clearEl(body);
|
|
2342
|
+
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); });
|
|
2343
|
+
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; }
|
|
2344
|
+
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);
|
|
2345
|
+
tr.appendChild(mk("td",null, humanInt(m.prompt||0))); tr.appendChild(mk("td",null, humanInt(m.completion||0)));
|
|
2346
|
+
tr.appendChild(mk("td","reasoning", humanInt(m.reasoning||0))); tr.appendChild(mk("td","cached", humanInt(m.cached||0)));
|
|
2347
|
+
tr.appendChild(mk("td",null, humanInt(m.total||0))); tr.appendChild(mk("td",null, humanInt(m.requests||0))); body.appendChild(tr); });
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
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"; }
|
|
2351
|
+
function renderCopilot(usage){
|
|
2352
|
+
var box = byId("copilot-body"); clearEl(box);
|
|
2353
|
+
var cp = usage.copilot; var planChip = byId("plan-chip");
|
|
2354
|
+
if (!cp){
|
|
2355
|
+
planChip.className = "chip plan-offline"; planChip.textContent = "\\u2014 offline";
|
|
2356
|
+
var eb = mk("div","emptybox"); eb.appendChild(mk("div","keyglyph","\\u26bf"));
|
|
2357
|
+
eb.appendChild(mk("h4",null,"Copilot not connected"));
|
|
2358
|
+
if (usage.copilot_error) eb.appendChild(mk("div","errline", usage.copilot_error));
|
|
2359
|
+
eb.appendChild(mk("div","prompt","$ hoopilot login"));
|
|
2360
|
+
box.appendChild(eb); return;
|
|
2361
|
+
}
|
|
2362
|
+
planChip.className = "chip " + planClass(cp.plan); planChip.textContent = cp.plan || "copilot";
|
|
2363
|
+
var head = mk("div","cap");
|
|
2364
|
+
var bits = [];
|
|
2365
|
+
if (cp.accessTypeSku) bits.push(cp.accessTypeSku);
|
|
2366
|
+
if (cp.chatEnabled !== undefined) bits.push(cp.chatEnabled ? "chat on" : "chat off");
|
|
2367
|
+
if (cp.quotaResetDate) bits.push("resets " + cp.quotaResetDate);
|
|
2368
|
+
head.textContent = bits.join(" \\u00b7 "); box.appendChild(head);
|
|
2369
|
+
var quotas = cp.quotas || {}; var keys = Object.keys(quotas);
|
|
2370
|
+
if (!keys.length){ box.appendChild(mk("div","cap","No metered quotas reported.")); return; }
|
|
2371
|
+
var order = { premium_interactions:0, chat:1, completions:2 };
|
|
2372
|
+
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); });
|
|
2373
|
+
keys.forEach(function(k){
|
|
2374
|
+
var q = quotas[k]; var row = mk("div","qrow");
|
|
2375
|
+
var hd = mk("div","qhead"); hd.appendChild(mk("span","qname", titleize(k)));
|
|
2376
|
+
if (q.unlimited){ hd.appendChild(mk("span","inf","\\u221e unlimited")); row.appendChild(hd); box.appendChild(row); return; }
|
|
2377
|
+
var ent = q.entitlement, rem = q.remaining, used = q.used;
|
|
2378
|
+
var usedPct = (q.percentRemaining !== undefined) ? (100 - q.percentRemaining) : ((ent && used !== undefined) ? (used/ent*100) : 0);
|
|
2379
|
+
usedPct = Math.max(0, Math.min(100, usedPct));
|
|
2380
|
+
var valTxt = (used !== undefined && ent !== undefined) ? (humanInt(used) + " / " + humanInt(ent)) : (rem !== undefined ? (humanInt(rem) + " left") : pct(100-usedPct) + " left");
|
|
2381
|
+
hd.appendChild(mk("span","qval", valTxt)); row.appendChild(hd);
|
|
2382
|
+
var bar = mk("div","qbar"); var fill = mk("i"); fill.style.width = usedPct + "%";
|
|
2383
|
+
fill.style.background = usedPct > 85 ? "var(--danger)" : usedPct > 60 ? "var(--warn)" : "var(--ok)"; bar.appendChild(fill);
|
|
2384
|
+
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); }
|
|
2385
|
+
row.appendChild(bar);
|
|
2386
|
+
if (q.overageCount){ var ov = mk("div","flag", humanInt(q.overageCount) + " overage" + (q.tokenBasedBilling ? " \\u00b7 token billing" : "")); row.appendChild(ov); }
|
|
2387
|
+
box.appendChild(row);
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
function renderUpstream(up, delta, restarted){
|
|
2392
|
+
setNum("up-total", humanInt(up.total||0));
|
|
2393
|
+
setNum("up-errors", humanInt(up.errors||0), "delta", up.errors||0);
|
|
2394
|
+
var er = up.total ? (up.errors/up.total*100) : 0;
|
|
2395
|
+
var rt = byId("up-rate"); rt.textContent = pct(er); rt.className = "v rate " + (er > 5 ? "danger" : er > 1 ? "warn" : "ok");
|
|
2396
|
+
byId("up-errblk").classList.toggle("hot", (up.errors||0) > 0);
|
|
2397
|
+
pushHist(hist.up, delta||0); drawSpark("up-spark", hist.up);
|
|
2398
|
+
byId("up-flag").textContent = restarted ? "\\u21bb restarted" : "";
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
function renderThroughput(){
|
|
2402
|
+
drawDual("thru-tok-line","thru-tok-area", hist.tok, true);
|
|
2403
|
+
drawDual("thru-req-line", null, hist.req, false);
|
|
2404
|
+
setText("thru-tok", hist.tok.length ? rate(hist.tok[hist.tok.length-1]) : "\\u2014");
|
|
2405
|
+
setText("thru-req", hist.req.length ? rate(hist.req[hist.req.length-1]) : "\\u2014");
|
|
2406
|
+
var peakTok = hist.tok.length ? Math.max.apply(null, hist.tok) : 0;
|
|
2407
|
+
setText("thru-peak", "peak " + humanInt(peakTok) + " tok/s");
|
|
2408
|
+
}
|
|
2409
|
+
function drawDual(lineId, areaId, values, withArea){
|
|
2410
|
+
var svg = byId("thru-svg"); var vb = svg.viewBox.baseVal; var w = vb.width, h = vb.height;
|
|
2411
|
+
var sp = buildSpark(values, w, h);
|
|
2412
|
+
var line = byId(lineId); var area = areaId ? byId(areaId) : null;
|
|
2413
|
+
if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); return; }
|
|
2414
|
+
if (line) line.setAttribute("d", sp.line);
|
|
2415
|
+
if (area && withArea) area.setAttribute("d", sp.area);
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
function renderFooter(usage, proxy){
|
|
2419
|
+
setText("foot-started", proxy.startedAt ? ("started " + new Date(proxy.startedAt).toLocaleString()) : "started \\u2014");
|
|
2420
|
+
setText("foot-uptime", "uptime " + fmtUptime(proxy.uptimeSeconds||0));
|
|
2421
|
+
setText("foot-total", humanInt((proxy.requests && proxy.requests.total)||0) + " req");
|
|
2422
|
+
setText("foot-tokens", humanInt((proxy.tokens && proxy.tokens.total)||0) + " tokens");
|
|
2423
|
+
var up = proxy.upstream || {}; setText("foot-upstream", "upstream " + humanInt(up.total||0) + " / " + humanInt(up.errors||0) + " err");
|
|
2424
|
+
setText("foot-cadence", "polling /v1/usage every " + Math.round(intervalMs/1000) + "s \\u00b7 GET /dashboard");
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
// ---- 1s freshness + uptime ticker (independent of the poll loop) ----
|
|
2428
|
+
setInterval(function(){
|
|
2429
|
+
if (lastSuccessAt){
|
|
2430
|
+
var ago = Math.round((Date.now() - lastSuccessAt)/1000);
|
|
2431
|
+
var u = byId("updated"); u.textContent = "updated " + ago + "s ago";
|
|
2432
|
+
// Staleness only matters while polling; a deliberate pause is not "stale".
|
|
2433
|
+
u.className = "updated" + (paused ? "" : ago > intervalMs/1000*4 ? " danger" : ago > intervalMs/1000*2 ? " warn" : "");
|
|
2434
|
+
}
|
|
2435
|
+
// Tick uptime locally between polls so the seconds advance smoothly; each
|
|
2436
|
+
// successful poll re-seeds lastUptime from the authoritative server value.
|
|
2437
|
+
if (!paused && lastUptime !== null){
|
|
2438
|
+
lastUptime += 1;
|
|
2439
|
+
byId("uptime-num").textContent = fmtUptime(lastUptime);
|
|
2440
|
+
var fu = byId("foot-uptime"); if (fu) fu.textContent = "uptime " + fmtUptime(lastUptime);
|
|
2441
|
+
}
|
|
2442
|
+
}, 1000);
|
|
2443
|
+
|
|
2444
|
+
// ---- boot ----
|
|
2445
|
+
applyTheme(); setActiveSeg();
|
|
2446
|
+
setPill("","CONNECTING",false);
|
|
2447
|
+
poll();
|
|
2448
|
+
})();
|
|
2449
|
+
</script>
|
|
2450
|
+
</body>
|
|
2451
|
+
</html>
|
|
2452
|
+
`;
|
|
1670
2453
|
|
|
1671
2454
|
// src/metrics.ts
|
|
1672
2455
|
var PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
|
|
@@ -1752,26 +2535,23 @@ var MetricsRegistry = class {
|
|
|
1752
2535
|
const resource = this.#rateLimitResource(rateLimit.resource);
|
|
1753
2536
|
this.#githubRateLimit.set(resource, { ...rateLimit, resource });
|
|
1754
2537
|
}
|
|
1755
|
-
//
|
|
1756
|
-
//
|
|
1757
|
-
//
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
if (!this.#tokens.has(cleaned) && this.#tokens.size >= MAX_TRACKED_MODELS) {
|
|
2538
|
+
// Clean a raw value into a bounded exposition-format label: cap its length,
|
|
2539
|
+
// strip characters that would corrupt the format, and fold overflow past the
|
|
2540
|
+
// cardinality limit into UNKNOWN_MODEL so the series count stays bounded.
|
|
2541
|
+
#boundedLabel(value, tracked, maxEntries) {
|
|
2542
|
+
const cleaned = cleanLabel(value).slice(0, MAX_MODEL_LABEL_LENGTH) || UNKNOWN_MODEL;
|
|
2543
|
+
if (!tracked.has(cleaned) && tracked.size >= maxEntries) {
|
|
1762
2544
|
return UNKNOWN_MODEL;
|
|
1763
2545
|
}
|
|
1764
2546
|
return cleaned;
|
|
1765
2547
|
}
|
|
1766
|
-
// The
|
|
1767
|
-
|
|
1768
|
-
|
|
2548
|
+
// The model can originate from a (possibly hostile) client request.
|
|
2549
|
+
#modelLabel(model) {
|
|
2550
|
+
return this.#boundedLabel(model, this.#tokens, MAX_TRACKED_MODELS);
|
|
2551
|
+
}
|
|
2552
|
+
// The resource comes from a trusted upstream header, but is bounded the same way.
|
|
1769
2553
|
#rateLimitResource(resource) {
|
|
1770
|
-
|
|
1771
|
-
if (!this.#githubRateLimit.has(cleaned) && this.#githubRateLimit.size >= MAX_TRACKED_RATELIMIT_RESOURCES) {
|
|
1772
|
-
return UNKNOWN_MODEL;
|
|
1773
|
-
}
|
|
1774
|
-
return cleaned;
|
|
2554
|
+
return this.#boundedLabel(resource, this.#githubRateLimit, MAX_TRACKED_RATELIMIT_RESOURCES);
|
|
1775
2555
|
}
|
|
1776
2556
|
#observeDuration(route, seconds) {
|
|
1777
2557
|
const value = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0;
|
|
@@ -1824,6 +2604,7 @@ var MetricsRegistry = class {
|
|
|
1824
2604
|
return {
|
|
1825
2605
|
githubRateLimit,
|
|
1826
2606
|
inFlight: this.#inFlight,
|
|
2607
|
+
latency: this.#latencySnapshot(),
|
|
1827
2608
|
requests: { byRoute, byStatus, total: requestsTotal },
|
|
1828
2609
|
startedAt: new Date(this.#startedAtMs).toISOString(),
|
|
1829
2610
|
tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
|
|
@@ -1831,6 +2612,37 @@ var MetricsRegistry = class {
|
|
|
1831
2612
|
uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
|
|
1832
2613
|
};
|
|
1833
2614
|
}
|
|
2615
|
+
// Summarize the duration histogram into a JSON latency view: per-route count and
|
|
2616
|
+
// exact average, plus overall average and estimated p50/p95. The percentiles come
|
|
2617
|
+
// from the buckets aggregated across routes, so they share /metrics' resolution.
|
|
2618
|
+
#latencySnapshot() {
|
|
2619
|
+
const byRoute = {};
|
|
2620
|
+
const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
|
|
2621
|
+
let totalCount = 0;
|
|
2622
|
+
let totalSum = 0;
|
|
2623
|
+
for (const [route, entry] of this.#durations) {
|
|
2624
|
+
byRoute[route] = {
|
|
2625
|
+
avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
|
|
2626
|
+
count: entry.count
|
|
2627
|
+
};
|
|
2628
|
+
totalCount += entry.count;
|
|
2629
|
+
totalSum += entry.sum;
|
|
2630
|
+
for (let i = 0; i < aggregateBuckets.length; i += 1) {
|
|
2631
|
+
aggregateBuckets[i] = (aggregateBuckets[i] ?? 0) + (entry.buckets[i] ?? 0);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
return {
|
|
2635
|
+
avgMs: totalCount > 0 ? round2(totalSum / totalCount * 1e3) : 0,
|
|
2636
|
+
byRoute,
|
|
2637
|
+
count: totalCount,
|
|
2638
|
+
p50Ms: round2(
|
|
2639
|
+
quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.5) * 1e3
|
|
2640
|
+
),
|
|
2641
|
+
p95Ms: round2(
|
|
2642
|
+
quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.95) * 1e3
|
|
2643
|
+
)
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
1834
2646
|
/** Render the Prometheus text exposition format (version 0.0.4). */
|
|
1835
2647
|
renderPrometheus(now = Date.now) {
|
|
1836
2648
|
const lines = [];
|
|
@@ -2065,7 +2877,7 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome)
|
|
|
2065
2877
|
considerSseLine(line, accumulator.consider);
|
|
2066
2878
|
}
|
|
2067
2879
|
} else {
|
|
2068
|
-
const parsed =
|
|
2880
|
+
const parsed = safeJsonParse(text);
|
|
2069
2881
|
if (parsed !== void 0) {
|
|
2070
2882
|
accumulator.consider(parsed);
|
|
2071
2883
|
}
|
|
@@ -2127,7 +2939,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOut
|
|
|
2127
2939
|
considerSseLine(finalBuffer, accumulator.consider);
|
|
2128
2940
|
}
|
|
2129
2941
|
} else if (!overflowed && finalBuffer) {
|
|
2130
|
-
const parsed =
|
|
2942
|
+
const parsed = safeJsonParse(finalBuffer);
|
|
2131
2943
|
if (parsed !== void 0) {
|
|
2132
2944
|
accumulator.consider(parsed);
|
|
2133
2945
|
}
|
|
@@ -2170,24 +2982,37 @@ function considerSseLine(line, consider) {
|
|
|
2170
2982
|
if (!data || data === "[DONE]") {
|
|
2171
2983
|
return;
|
|
2172
2984
|
}
|
|
2173
|
-
const parsed =
|
|
2985
|
+
const parsed = safeJsonParse(data);
|
|
2174
2986
|
if (parsed !== void 0) {
|
|
2175
2987
|
consider(parsed);
|
|
2176
2988
|
}
|
|
2177
2989
|
}
|
|
2178
|
-
function safeParse(text) {
|
|
2179
|
-
try {
|
|
2180
|
-
return JSON.parse(text);
|
|
2181
|
-
} catch {
|
|
2182
|
-
return void 0;
|
|
2183
|
-
}
|
|
2184
|
-
}
|
|
2185
2990
|
function modelText(value) {
|
|
2186
2991
|
return typeof value === "string" ? value.trim() : "";
|
|
2187
2992
|
}
|
|
2188
2993
|
function nonNegative(value) {
|
|
2189
2994
|
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
2190
2995
|
}
|
|
2996
|
+
function round2(value) {
|
|
2997
|
+
return Math.round(value * 100) / 100;
|
|
2998
|
+
}
|
|
2999
|
+
function quantileFromBuckets(bucketCounts, bounds, count, q) {
|
|
3000
|
+
if (count <= 0) {
|
|
3001
|
+
return 0;
|
|
3002
|
+
}
|
|
3003
|
+
const rank = q * count;
|
|
3004
|
+
let cumulative = 0;
|
|
3005
|
+
for (let i = 0; i < bounds.length; i += 1) {
|
|
3006
|
+
const inBucket = bucketCounts[i] ?? 0;
|
|
3007
|
+
if (inBucket > 0 && cumulative + inBucket >= rank) {
|
|
3008
|
+
const lower = i === 0 ? 0 : bounds[i - 1] ?? 0;
|
|
3009
|
+
const upper = bounds[i] ?? lower;
|
|
3010
|
+
return lower + (upper - lower) * ((rank - cumulative) / inBucket);
|
|
3011
|
+
}
|
|
3012
|
+
cumulative += inBucket;
|
|
3013
|
+
}
|
|
3014
|
+
return bounds[bounds.length - 1] ?? 0;
|
|
3015
|
+
}
|
|
2191
3016
|
function cleanLabel(value) {
|
|
2192
3017
|
let result = "";
|
|
2193
3018
|
for (const char of value) {
|
|
@@ -2251,8 +3076,9 @@ async function getVersion() {
|
|
|
2251
3076
|
resolved = BAKED_VERSION;
|
|
2252
3077
|
} else {
|
|
2253
3078
|
try {
|
|
2254
|
-
const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json();
|
|
2255
|
-
|
|
3079
|
+
const manifest = asRecord(await Bun.file(new URL("../package.json", import.meta.url)).json());
|
|
3080
|
+
const version = manifest.version;
|
|
3081
|
+
resolved = typeof version === "string" ? version : "0.0.0";
|
|
2256
3082
|
} catch {
|
|
2257
3083
|
resolved = "0.0.0";
|
|
2258
3084
|
}
|
|
@@ -2278,6 +3104,18 @@ var RequestBodyTooLargeError = class extends Error {
|
|
|
2278
3104
|
this.name = "RequestBodyTooLargeError";
|
|
2279
3105
|
}
|
|
2280
3106
|
};
|
|
3107
|
+
var InvalidJsonError = class extends Error {
|
|
3108
|
+
constructor() {
|
|
3109
|
+
super(INVALID_JSON_MESSAGE);
|
|
3110
|
+
this.name = "InvalidJsonError";
|
|
3111
|
+
}
|
|
3112
|
+
};
|
|
3113
|
+
var JsonNotObjectError = class extends Error {
|
|
3114
|
+
constructor() {
|
|
3115
|
+
super(JSON_OBJECT_MESSAGE);
|
|
3116
|
+
this.name = "JsonNotObjectError";
|
|
3117
|
+
}
|
|
3118
|
+
};
|
|
2281
3119
|
function createHoopilotHandler(options = {}) {
|
|
2282
3120
|
const client = new CopilotClient(options);
|
|
2283
3121
|
const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
|
|
@@ -2287,8 +3125,19 @@ function createHoopilotHandler(options = {}) {
|
|
|
2287
3125
|
const readUsage = createUsageReader(client, metrics);
|
|
2288
3126
|
const recordTokens = (model, usage) => metrics.recordTokens(model, usage);
|
|
2289
3127
|
const recordExtraction = (extracted) => metrics.recordTokenExtraction(extracted);
|
|
2290
|
-
const
|
|
2291
|
-
const
|
|
3128
|
+
const bufferProxyBodies = shouldBufferProxyBodies(resolveStreamingProxyMode(options));
|
|
3129
|
+
const requestContext = /* @__PURE__ */ new WeakMap();
|
|
3130
|
+
const app = buildApp({
|
|
3131
|
+
apiKey,
|
|
3132
|
+
allowedOrigins,
|
|
3133
|
+
bufferProxyBodies,
|
|
3134
|
+
client,
|
|
3135
|
+
metrics,
|
|
3136
|
+
readUsage,
|
|
3137
|
+
recordExtraction,
|
|
3138
|
+
recordTokens,
|
|
3139
|
+
requestContext
|
|
3140
|
+
});
|
|
2292
3141
|
return async (request) => {
|
|
2293
3142
|
const startedAt = performance.now();
|
|
2294
3143
|
const url = new URL(request.url);
|
|
@@ -2304,7 +3153,24 @@ function createHoopilotHandler(options = {}) {
|
|
|
2304
3153
|
metrics.startRequest();
|
|
2305
3154
|
const origin = request.headers.get("origin")?.trim() || void 0;
|
|
2306
3155
|
const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
|
|
2307
|
-
const
|
|
3156
|
+
const inner = normalizeInnerRequest(request, apiPath, url);
|
|
3157
|
+
requestContext.set(inner, {
|
|
3158
|
+
apiPath,
|
|
3159
|
+
logger: requestLogger,
|
|
3160
|
+
origin,
|
|
3161
|
+
originalPath: url.pathname
|
|
3162
|
+
});
|
|
3163
|
+
let response;
|
|
3164
|
+
try {
|
|
3165
|
+
response = await app.handle(inner);
|
|
3166
|
+
} catch (error) {
|
|
3167
|
+
requestLogger.error(
|
|
3168
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
3169
|
+
"request failed"
|
|
3170
|
+
);
|
|
3171
|
+
response = jsonError(500, "internal_error", errorMessage(error));
|
|
3172
|
+
}
|
|
3173
|
+
return finishResponse(response, {
|
|
2308
3174
|
corsOrigin,
|
|
2309
3175
|
logger: requestLogger,
|
|
2310
3176
|
method: request.method,
|
|
@@ -2315,141 +3181,175 @@ function createHoopilotHandler(options = {}) {
|
|
|
2315
3181
|
closeConnection: bufferProxyBodies,
|
|
2316
3182
|
trackStreamingBody: !bufferProxyBodies
|
|
2317
3183
|
});
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
function buildApp(deps) {
|
|
3187
|
+
const {
|
|
3188
|
+
apiKey,
|
|
3189
|
+
allowedOrigins,
|
|
3190
|
+
bufferProxyBodies,
|
|
3191
|
+
client,
|
|
3192
|
+
metrics,
|
|
3193
|
+
readUsage,
|
|
3194
|
+
recordExtraction,
|
|
3195
|
+
recordTokens,
|
|
3196
|
+
requestContext
|
|
3197
|
+
} = deps;
|
|
3198
|
+
const contextFor = (request) => {
|
|
3199
|
+
const stored = requestContext.get(request);
|
|
3200
|
+
if (stored) {
|
|
3201
|
+
return stored;
|
|
3202
|
+
}
|
|
3203
|
+
const originalPath = new URL(request.url).pathname;
|
|
3204
|
+
return {
|
|
3205
|
+
apiPath: canonicalApiPath(originalPath),
|
|
3206
|
+
logger: noopLogger,
|
|
3207
|
+
origin: request.headers.get("origin")?.trim() || void 0,
|
|
3208
|
+
originalPath
|
|
3209
|
+
};
|
|
3210
|
+
};
|
|
3211
|
+
const loggerFor = (request) => contextFor(request).logger;
|
|
3212
|
+
const noBody = { parse: "none" };
|
|
3213
|
+
return new Elysia().onRequest(({ request }) => {
|
|
3214
|
+
const { apiPath, logger, origin } = contextFor(request);
|
|
2318
3215
|
const browserOrigin = forbiddenBrowserOrigin(origin, request, allowedOrigins);
|
|
2319
3216
|
if (browserOrigin) {
|
|
2320
|
-
|
|
3217
|
+
logger.warn(
|
|
2321
3218
|
{ event: "http.request.forbidden_origin", origin: browserOrigin },
|
|
2322
3219
|
"blocked cross-origin browser request"
|
|
2323
3220
|
);
|
|
2324
|
-
return
|
|
3221
|
+
return jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE);
|
|
2325
3222
|
}
|
|
2326
3223
|
if (request.method === "OPTIONS") {
|
|
2327
|
-
return
|
|
3224
|
+
return new Response(null, { headers: corsHeaders() });
|
|
3225
|
+
}
|
|
3226
|
+
if (request.method === "GET" && apiPath === "/dashboard") {
|
|
3227
|
+
return dashboardResponse();
|
|
2328
3228
|
}
|
|
2329
3229
|
if (!isAuthorized(request, apiKey)) {
|
|
2330
|
-
|
|
2331
|
-
return
|
|
3230
|
+
logger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
|
|
3231
|
+
return jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key.");
|
|
2332
3232
|
}
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
}
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
client,
|
|
2353
|
-
metrics,
|
|
2354
|
-
recordTokens,
|
|
2355
|
-
recordExtraction,
|
|
2356
|
-
request,
|
|
2357
|
-
requestLogger,
|
|
2358
|
-
bufferProxyBodies
|
|
2359
|
-
)
|
|
2360
|
-
);
|
|
2361
|
-
}
|
|
2362
|
-
if (request.method === "POST" && apiPath === "/v1/messages/count_tokens") {
|
|
2363
|
-
return finish(handleAnthropicCountTokens(await readJson(request)));
|
|
2364
|
-
}
|
|
2365
|
-
if (request.method === "POST" && apiPath === "/v1/chat/completions") {
|
|
2366
|
-
return finish(
|
|
2367
|
-
await handleChatCompletions(
|
|
2368
|
-
client,
|
|
2369
|
-
metrics,
|
|
2370
|
-
recordTokens,
|
|
2371
|
-
recordExtraction,
|
|
2372
|
-
request,
|
|
2373
|
-
requestLogger,
|
|
2374
|
-
bufferProxyBodies
|
|
2375
|
-
)
|
|
2376
|
-
);
|
|
2377
|
-
}
|
|
2378
|
-
if (request.method === "POST" && apiPath === "/v1/completions") {
|
|
2379
|
-
return finish(
|
|
2380
|
-
await handleCompletions(
|
|
2381
|
-
client,
|
|
2382
|
-
metrics,
|
|
2383
|
-
recordTokens,
|
|
2384
|
-
recordExtraction,
|
|
2385
|
-
request,
|
|
2386
|
-
requestLogger,
|
|
2387
|
-
bufferProxyBodies
|
|
2388
|
-
)
|
|
2389
|
-
);
|
|
2390
|
-
}
|
|
2391
|
-
if (request.method === "POST" && apiPath === "/v1/responses/compact") {
|
|
2392
|
-
return finish(
|
|
2393
|
-
await handleResponsesCompact(
|
|
2394
|
-
client,
|
|
2395
|
-
metrics,
|
|
2396
|
-
recordTokens,
|
|
2397
|
-
recordExtraction,
|
|
2398
|
-
request,
|
|
2399
|
-
requestLogger
|
|
2400
|
-
)
|
|
2401
|
-
);
|
|
2402
|
-
}
|
|
2403
|
-
if (request.method === "POST" && apiPath === "/v1/responses") {
|
|
2404
|
-
return finish(
|
|
2405
|
-
await handleResponses(
|
|
2406
|
-
client,
|
|
2407
|
-
metrics,
|
|
2408
|
-
recordTokens,
|
|
2409
|
-
recordExtraction,
|
|
2410
|
-
request,
|
|
2411
|
-
requestLogger,
|
|
2412
|
-
bufferProxyBodies
|
|
2413
|
-
)
|
|
2414
|
-
);
|
|
2415
|
-
}
|
|
2416
|
-
return finish(jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`));
|
|
2417
|
-
} catch (error) {
|
|
2418
|
-
if (error instanceof CopilotAuthError) {
|
|
2419
|
-
requestLogger.warn(
|
|
2420
|
-
{ err: errorDetails(error), event: "copilot.auth.missing" },
|
|
2421
|
-
"copilot auth failed"
|
|
2422
|
-
);
|
|
2423
|
-
return finish(jsonError(401, "copilot_auth_error", error.message));
|
|
2424
|
-
}
|
|
2425
|
-
const message = errorMessage(error);
|
|
2426
|
-
if (message === INVALID_JSON_MESSAGE || message === JSON_OBJECT_MESSAGE) {
|
|
2427
|
-
requestLogger.warn(
|
|
2428
|
-
{ err: errorDetails(error), event: "http.request.failed" },
|
|
2429
|
-
"request body was not usable json"
|
|
2430
|
-
);
|
|
2431
|
-
return finish(jsonError(400, "invalid_request_error", message));
|
|
2432
|
-
} else if (error instanceof OpenAICompatibilityError || error instanceof AnthropicCompatibilityError) {
|
|
2433
|
-
requestLogger.warn(
|
|
2434
|
-
{ err: errorDetails(error), event: "http.request.failed" },
|
|
2435
|
-
"request body used unsupported compatibility fields"
|
|
2436
|
-
);
|
|
2437
|
-
return finish(jsonError(400, "invalid_request_error", message));
|
|
2438
|
-
} else if (error instanceof RequestBodyTooLargeError) {
|
|
2439
|
-
requestLogger.warn(
|
|
2440
|
-
{ err: errorDetails(error), event: "http.request.failed" },
|
|
2441
|
-
"request body exceeded size limit"
|
|
2442
|
-
);
|
|
2443
|
-
return finish(jsonError(413, "request_too_large", message));
|
|
2444
|
-
} else {
|
|
2445
|
-
requestLogger.error(
|
|
2446
|
-
{ err: errorDetails(error), event: "http.request.failed" },
|
|
2447
|
-
"request failed"
|
|
2448
|
-
);
|
|
2449
|
-
}
|
|
2450
|
-
return finish(jsonError(500, "internal_error", message));
|
|
3233
|
+
}).onError(({ code, error, request }) => {
|
|
3234
|
+
const { logger, originalPath } = contextFor(request);
|
|
3235
|
+
if (code === "NOT_FOUND") {
|
|
3236
|
+
return jsonError(404, "not_found", `No route for ${request.method} ${originalPath}.`);
|
|
3237
|
+
}
|
|
3238
|
+
if (error instanceof CopilotAuthError) {
|
|
3239
|
+
logger.warn(
|
|
3240
|
+
{ err: errorDetails(error), event: "copilot.auth.missing" },
|
|
3241
|
+
"copilot auth failed"
|
|
3242
|
+
);
|
|
3243
|
+
return jsonError(401, "copilot_auth_error", error.message);
|
|
3244
|
+
}
|
|
3245
|
+
const message = errorMessage(error);
|
|
3246
|
+
if (error instanceof InvalidJsonError || error instanceof JsonNotObjectError) {
|
|
3247
|
+
logger.warn(
|
|
3248
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
3249
|
+
"request body was not usable json"
|
|
3250
|
+
);
|
|
3251
|
+
return jsonError(400, "invalid_request_error", message);
|
|
2451
3252
|
}
|
|
3253
|
+
if (error instanceof OpenAICompatibilityError || error instanceof AnthropicCompatibilityError) {
|
|
3254
|
+
logger.warn(
|
|
3255
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
3256
|
+
"request body used unsupported compatibility fields"
|
|
3257
|
+
);
|
|
3258
|
+
return jsonError(400, "invalid_request_error", message);
|
|
3259
|
+
}
|
|
3260
|
+
if (error instanceof RequestBodyTooLargeError) {
|
|
3261
|
+
logger.warn(
|
|
3262
|
+
{ err: errorDetails(error), event: "http.request.failed" },
|
|
3263
|
+
"request body exceeded size limit"
|
|
3264
|
+
);
|
|
3265
|
+
return jsonError(413, "request_too_large", message);
|
|
3266
|
+
}
|
|
3267
|
+
logger.error({ err: errorDetails(error), event: "http.request.failed" }, "request failed");
|
|
3268
|
+
return jsonError(500, "internal_error", message);
|
|
3269
|
+
}).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(
|
|
3270
|
+
"/v1/models",
|
|
3271
|
+
({ request }) => handleModels(client, metrics, request.signal, loggerFor(request))
|
|
3272
|
+
).get("/v1/responses", () => websocketUnsupportedResponse()).post(
|
|
3273
|
+
"/v1/messages",
|
|
3274
|
+
({ request }) => handleAnthropicMessages(
|
|
3275
|
+
client,
|
|
3276
|
+
metrics,
|
|
3277
|
+
recordTokens,
|
|
3278
|
+
recordExtraction,
|
|
3279
|
+
request,
|
|
3280
|
+
loggerFor(request),
|
|
3281
|
+
bufferProxyBodies
|
|
3282
|
+
),
|
|
3283
|
+
noBody
|
|
3284
|
+
).post(
|
|
3285
|
+
"/v1/messages/count_tokens",
|
|
3286
|
+
({ request }) => handleAnthropicCountTokens(request),
|
|
3287
|
+
noBody
|
|
3288
|
+
).post(
|
|
3289
|
+
"/v1/chat/completions",
|
|
3290
|
+
({ request }) => handleChatCompletions(
|
|
3291
|
+
client,
|
|
3292
|
+
metrics,
|
|
3293
|
+
recordTokens,
|
|
3294
|
+
recordExtraction,
|
|
3295
|
+
request,
|
|
3296
|
+
loggerFor(request),
|
|
3297
|
+
bufferProxyBodies
|
|
3298
|
+
),
|
|
3299
|
+
noBody
|
|
3300
|
+
).post(
|
|
3301
|
+
"/v1/completions",
|
|
3302
|
+
({ request }) => handleCompletions(
|
|
3303
|
+
client,
|
|
3304
|
+
metrics,
|
|
3305
|
+
recordTokens,
|
|
3306
|
+
recordExtraction,
|
|
3307
|
+
request,
|
|
3308
|
+
loggerFor(request),
|
|
3309
|
+
bufferProxyBodies
|
|
3310
|
+
),
|
|
3311
|
+
noBody
|
|
3312
|
+
).post(
|
|
3313
|
+
"/v1/responses/compact",
|
|
3314
|
+
({ request }) => handleResponsesCompact(
|
|
3315
|
+
client,
|
|
3316
|
+
metrics,
|
|
3317
|
+
recordTokens,
|
|
3318
|
+
recordExtraction,
|
|
3319
|
+
request,
|
|
3320
|
+
loggerFor(request)
|
|
3321
|
+
),
|
|
3322
|
+
noBody
|
|
3323
|
+
).post(
|
|
3324
|
+
"/v1/responses",
|
|
3325
|
+
({ request }) => handleResponses(
|
|
3326
|
+
client,
|
|
3327
|
+
metrics,
|
|
3328
|
+
recordTokens,
|
|
3329
|
+
recordExtraction,
|
|
3330
|
+
request,
|
|
3331
|
+
loggerFor(request),
|
|
3332
|
+
bufferProxyBodies
|
|
3333
|
+
),
|
|
3334
|
+
noBody
|
|
3335
|
+
);
|
|
3336
|
+
}
|
|
3337
|
+
function normalizeInnerRequest(request, canonicalPath, url) {
|
|
3338
|
+
if (canonicalPath === url.pathname) {
|
|
3339
|
+
return request;
|
|
3340
|
+
}
|
|
3341
|
+
const target = new URL(url);
|
|
3342
|
+
target.pathname = canonicalPath;
|
|
3343
|
+
const init = {
|
|
3344
|
+
headers: request.headers,
|
|
3345
|
+
method: request.method,
|
|
3346
|
+
signal: request.signal
|
|
2452
3347
|
};
|
|
3348
|
+
if (request.body) {
|
|
3349
|
+
init.body = request.body;
|
|
3350
|
+
init.duplex = "half";
|
|
3351
|
+
}
|
|
3352
|
+
return new Request(target, init);
|
|
2453
3353
|
}
|
|
2454
3354
|
function startHoopilotServer(options = {}) {
|
|
2455
3355
|
const host = options.host ?? envValue(options.env?.HOST) ?? DEFAULT_HOST;
|
|
@@ -2528,7 +3428,8 @@ async function handleAnthropicMessages(client, metrics, recordTokens, recordExtr
|
|
|
2528
3428
|
recordExtraction(usage !== void 0);
|
|
2529
3429
|
return jsonResponse(responsesResponseToAnthropicMessage(body, model));
|
|
2530
3430
|
}
|
|
2531
|
-
function handleAnthropicCountTokens(
|
|
3431
|
+
async function handleAnthropicCountTokens(request) {
|
|
3432
|
+
const body = await readJson(request);
|
|
2532
3433
|
return jsonResponse(estimateAnthropicMessageTokens(body));
|
|
2533
3434
|
}
|
|
2534
3435
|
async function handleModels(client, metrics, signal, logger) {
|
|
@@ -2614,14 +3515,14 @@ async function handleCompletions(client, metrics, recordTokens, recordExtraction
|
|
|
2614
3515
|
return jsonResponse(chatCompletionToCompletion(completion));
|
|
2615
3516
|
}
|
|
2616
3517
|
async function handleResponses(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
|
|
2617
|
-
const body = await readJsonText(request);
|
|
3518
|
+
const { json, text: body } = await readJsonText(request);
|
|
2618
3519
|
const upstream = await client.responses(body, request.signal);
|
|
2619
3520
|
metrics.recordUpstream("/responses", upstream.ok);
|
|
2620
3521
|
if (!upstream.ok) {
|
|
2621
3522
|
return proxyError(upstream, logger);
|
|
2622
3523
|
}
|
|
2623
3524
|
logUpstreamSuccess(logger, "/responses", upstream.status);
|
|
2624
|
-
const model = normalizeRequestedModel(
|
|
3525
|
+
const model = normalizeRequestedModel(json.model);
|
|
2625
3526
|
return proxyResponse(
|
|
2626
3527
|
await responseWithObservedUsage(
|
|
2627
3528
|
upstream,
|
|
@@ -2709,17 +3610,16 @@ function parseJsonObject2(text) {
|
|
|
2709
3610
|
try {
|
|
2710
3611
|
parsed = JSON.parse(text);
|
|
2711
3612
|
} catch {
|
|
2712
|
-
throw new
|
|
3613
|
+
throw new InvalidJsonError();
|
|
2713
3614
|
}
|
|
2714
3615
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2715
|
-
throw new
|
|
3616
|
+
throw new JsonNotObjectError();
|
|
2716
3617
|
}
|
|
2717
3618
|
return parsed;
|
|
2718
3619
|
}
|
|
2719
3620
|
async function readJsonText(request) {
|
|
2720
3621
|
const text = await readRequestText(request);
|
|
2721
|
-
parseJsonObject2(text);
|
|
2722
|
-
return text;
|
|
3622
|
+
return { json: parseJsonObject2(text), text };
|
|
2723
3623
|
}
|
|
2724
3624
|
async function readRequestText(request) {
|
|
2725
3625
|
const contentLength = request.headers.get("content-length");
|
|
@@ -2777,7 +3677,7 @@ function jsonError(status, code, message) {
|
|
|
2777
3677
|
);
|
|
2778
3678
|
}
|
|
2779
3679
|
function upstreamErrorResponse(status, text) {
|
|
2780
|
-
const parsedError = asRecord(asRecord(
|
|
3680
|
+
const parsedError = asRecord(asRecord(safeJsonParse(text)).error);
|
|
2781
3681
|
if (Object.keys(parsedError).length > 0) {
|
|
2782
3682
|
return jsonResponse({ error: parsedError }, status);
|
|
2783
3683
|
}
|
|
@@ -2799,13 +3699,18 @@ function corsHeaders() {
|
|
|
2799
3699
|
"access-control-expose-headers": "x-request-id"
|
|
2800
3700
|
};
|
|
2801
3701
|
}
|
|
3702
|
+
function secretEquals(candidate, secret) {
|
|
3703
|
+
const a = createHash("sha256").update(candidate).digest();
|
|
3704
|
+
const b = createHash("sha256").update(secret).digest();
|
|
3705
|
+
return timingSafeEqual(a, b);
|
|
3706
|
+
}
|
|
2802
3707
|
function isAuthorized(request, apiKey) {
|
|
2803
3708
|
if (!apiKey) {
|
|
2804
3709
|
return true;
|
|
2805
3710
|
}
|
|
2806
3711
|
const authorization = request.headers.get("authorization") ?? "";
|
|
2807
3712
|
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
2808
|
-
return bearer
|
|
3713
|
+
return bearer !== void 0 && secretEquals(bearer, apiKey) || secretEquals(request.headers.get("x-api-key") ?? "", apiKey);
|
|
2809
3714
|
}
|
|
2810
3715
|
function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
|
|
2811
3716
|
if (origin) {
|
|
@@ -2842,7 +3747,7 @@ function upstreamAuthMessage(message) {
|
|
|
2842
3747
|
return `GitHub Copilot rejected the credential or account access: ${message}`;
|
|
2843
3748
|
}
|
|
2844
3749
|
function isLoopbackHost(host) {
|
|
2845
|
-
return host
|
|
3750
|
+
return isLoopbackHostname(host);
|
|
2846
3751
|
}
|
|
2847
3752
|
function urlHost(host) {
|
|
2848
3753
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
@@ -2861,9 +3766,6 @@ function normalizeServerPort(value) {
|
|
|
2861
3766
|
}
|
|
2862
3767
|
return port;
|
|
2863
3768
|
}
|
|
2864
|
-
function errorMessage(error) {
|
|
2865
|
-
return error instanceof Error ? error.message : String(error);
|
|
2866
|
-
}
|
|
2867
3769
|
function serverLogger(options) {
|
|
2868
3770
|
if (options.logger) {
|
|
2869
3771
|
return options.logger.child({ component: "server" });
|
|
@@ -2879,10 +3781,7 @@ function serverLogger(options) {
|
|
|
2879
3781
|
}
|
|
2880
3782
|
function resolveStreamingProxyMode(options) {
|
|
2881
3783
|
const value = options.streamingProxyMode ?? envValue(options.env?.HOOPILOT_STREAM_MODE) ?? envValue(options.env?.HOOPILOT_STREAMING_PROXY_MODE) ?? "auto";
|
|
2882
|
-
|
|
2883
|
-
return value;
|
|
2884
|
-
}
|
|
2885
|
-
throw new Error(`Invalid stream mode: ${value}. Expected auto, live, or buffer.`);
|
|
3784
|
+
return parseStreamingProxyMode(value);
|
|
2886
3785
|
}
|
|
2887
3786
|
function shouldBufferProxyBodies(mode) {
|
|
2888
3787
|
if (mode === "buffer") {
|
|
@@ -2940,11 +3839,13 @@ function responseWithRequestId(response, requestId, closeConnection, corsOrigin)
|
|
|
2940
3839
|
function trackStreamCompletion(body, onComplete) {
|
|
2941
3840
|
const reader = body.getReader();
|
|
2942
3841
|
let fired = false;
|
|
2943
|
-
const
|
|
2944
|
-
if (
|
|
2945
|
-
|
|
2946
|
-
onComplete();
|
|
3842
|
+
const release = () => {
|
|
3843
|
+
if (fired) {
|
|
3844
|
+
return;
|
|
2947
3845
|
}
|
|
3846
|
+
fired = true;
|
|
3847
|
+
onComplete();
|
|
3848
|
+
reader.releaseLock();
|
|
2948
3849
|
};
|
|
2949
3850
|
return new ReadableStream({
|
|
2950
3851
|
async pull(controller) {
|
|
@@ -2952,18 +3853,25 @@ function trackStreamCompletion(body, onComplete) {
|
|
|
2952
3853
|
const { done, value } = await reader.read();
|
|
2953
3854
|
if (done) {
|
|
2954
3855
|
controller.close();
|
|
2955
|
-
|
|
3856
|
+
release();
|
|
2956
3857
|
return;
|
|
2957
3858
|
}
|
|
2958
3859
|
controller.enqueue(value);
|
|
2959
3860
|
} catch (error) {
|
|
2960
|
-
|
|
3861
|
+
release();
|
|
2961
3862
|
controller.error(error);
|
|
2962
3863
|
}
|
|
2963
3864
|
},
|
|
2964
|
-
cancel(reason) {
|
|
2965
|
-
|
|
2966
|
-
|
|
3865
|
+
async cancel(reason) {
|
|
3866
|
+
if (!fired) {
|
|
3867
|
+
fired = true;
|
|
3868
|
+
onComplete();
|
|
3869
|
+
}
|
|
3870
|
+
try {
|
|
3871
|
+
await reader.cancel(reason);
|
|
3872
|
+
} finally {
|
|
3873
|
+
reader.releaseLock();
|
|
3874
|
+
}
|
|
2967
3875
|
}
|
|
2968
3876
|
});
|
|
2969
3877
|
}
|
|
@@ -3011,44 +3919,26 @@ function canonicalApiPath(path) {
|
|
|
3011
3919
|
return withoutTrailingSlash;
|
|
3012
3920
|
}
|
|
3013
3921
|
}
|
|
3922
|
+
var API_ROUTES = [
|
|
3923
|
+
{ method: "GET", path: "/", name: "health" },
|
|
3924
|
+
{ method: "GET", path: "/healthz", name: "health" },
|
|
3925
|
+
{ method: "GET", path: "/dashboard", name: "dashboard" },
|
|
3926
|
+
{ method: "GET", path: "/metrics", name: "metrics" },
|
|
3927
|
+
{ method: "GET", path: "/v1/usage", name: "usage" },
|
|
3928
|
+
{ method: "GET", path: "/v1/models", name: "models" },
|
|
3929
|
+
{ method: "GET", path: "/v1/responses", name: "responses_websocket" },
|
|
3930
|
+
{ method: "POST", path: "/v1/messages", name: "anthropic_messages" },
|
|
3931
|
+
{ method: "POST", path: "/v1/messages/count_tokens", name: "anthropic_count_tokens" },
|
|
3932
|
+
{ method: "POST", path: "/v1/chat/completions", name: "chat_completions" },
|
|
3933
|
+
{ method: "POST", path: "/v1/completions", name: "completions" },
|
|
3934
|
+
{ method: "POST", path: "/v1/responses/compact", name: "responses_compact" },
|
|
3935
|
+
{ method: "POST", path: "/v1/responses", name: "responses" }
|
|
3936
|
+
];
|
|
3014
3937
|
function routeFor(method, path) {
|
|
3015
3938
|
if (method === "OPTIONS") {
|
|
3016
3939
|
return "cors.preflight";
|
|
3017
3940
|
}
|
|
3018
|
-
|
|
3019
|
-
return "health";
|
|
3020
|
-
}
|
|
3021
|
-
if (method === "GET" && path === "/metrics") {
|
|
3022
|
-
return "metrics";
|
|
3023
|
-
}
|
|
3024
|
-
if (method === "GET" && path === "/v1/usage") {
|
|
3025
|
-
return "usage";
|
|
3026
|
-
}
|
|
3027
|
-
if (method === "GET" && path === "/v1/models") {
|
|
3028
|
-
return "models";
|
|
3029
|
-
}
|
|
3030
|
-
if (method === "POST" && path === "/v1/messages") {
|
|
3031
|
-
return "anthropic_messages";
|
|
3032
|
-
}
|
|
3033
|
-
if (method === "POST" && path === "/v1/messages/count_tokens") {
|
|
3034
|
-
return "anthropic_count_tokens";
|
|
3035
|
-
}
|
|
3036
|
-
if (method === "POST" && path === "/v1/chat/completions") {
|
|
3037
|
-
return "chat_completions";
|
|
3038
|
-
}
|
|
3039
|
-
if (method === "POST" && path === "/v1/completions") {
|
|
3040
|
-
return "completions";
|
|
3041
|
-
}
|
|
3042
|
-
if (method === "POST" && path === "/v1/responses/compact") {
|
|
3043
|
-
return "responses_compact";
|
|
3044
|
-
}
|
|
3045
|
-
if (method === "POST" && path === "/v1/responses") {
|
|
3046
|
-
return "responses";
|
|
3047
|
-
}
|
|
3048
|
-
if (method === "GET" && path === "/v1/responses") {
|
|
3049
|
-
return "responses_websocket";
|
|
3050
|
-
}
|
|
3051
|
-
return "not_found";
|
|
3941
|
+
return API_ROUTES.find((entry) => entry.method === method && entry.path === path)?.name ?? "not_found";
|
|
3052
3942
|
}
|
|
3053
3943
|
function isStreamingResponse(response) {
|
|
3054
3944
|
return response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
@@ -3072,10 +3962,28 @@ function metricsResponse(metrics) {
|
|
|
3072
3962
|
status: 200
|
|
3073
3963
|
});
|
|
3074
3964
|
}
|
|
3965
|
+
function dashboardResponse() {
|
|
3966
|
+
return new Response(DASHBOARD_HTML, {
|
|
3967
|
+
headers: {
|
|
3968
|
+
...corsHeaders(),
|
|
3969
|
+
"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'",
|
|
3970
|
+
"content-type": "text/html; charset=utf-8",
|
|
3971
|
+
"referrer-policy": "no-referrer",
|
|
3972
|
+
"x-content-type-options": "nosniff",
|
|
3973
|
+
"x-frame-options": "DENY"
|
|
3974
|
+
},
|
|
3975
|
+
status: 200
|
|
3976
|
+
});
|
|
3977
|
+
}
|
|
3075
3978
|
async function handleUsage(metrics, readUsage, signal) {
|
|
3076
3979
|
const { copilot, error } = await readUsage(signal);
|
|
3077
3980
|
const proxy = metrics.snapshot();
|
|
3078
|
-
const body = {
|
|
3981
|
+
const body = {
|
|
3982
|
+
copilot: copilot ?? null,
|
|
3983
|
+
object: "usage",
|
|
3984
|
+
proxy,
|
|
3985
|
+
version: await getVersion()
|
|
3986
|
+
};
|
|
3079
3987
|
if (error) {
|
|
3080
3988
|
body.copilot_error = error;
|
|
3081
3989
|
}
|
|
@@ -3108,17 +4016,10 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
|
|
|
3108
4016
|
}
|
|
3109
4017
|
};
|
|
3110
4018
|
}
|
|
3111
|
-
function safeParseJson(text) {
|
|
3112
|
-
try {
|
|
3113
|
-
return JSON.parse(text);
|
|
3114
|
-
} catch {
|
|
3115
|
-
return void 0;
|
|
3116
|
-
}
|
|
3117
|
-
}
|
|
3118
4019
|
|
|
3119
4020
|
// src/update.ts
|
|
3120
4021
|
import { execFileSync } from "child_process";
|
|
3121
|
-
import { createHash } from "crypto";
|
|
4022
|
+
import { createHash as createHash2 } from "crypto";
|
|
3122
4023
|
import {
|
|
3123
4024
|
chmodSync as chmodSync2,
|
|
3124
4025
|
copyFileSync,
|
|
@@ -3126,7 +4027,7 @@ import {
|
|
|
3126
4027
|
mkdirSync as mkdirSync2,
|
|
3127
4028
|
realpathSync,
|
|
3128
4029
|
renameSync as renameSync2,
|
|
3129
|
-
rmSync,
|
|
4030
|
+
rmSync as rmSync2,
|
|
3130
4031
|
writeFileSync as writeFileSync2
|
|
3131
4032
|
} from "fs";
|
|
3132
4033
|
import { readFile, writeFile } from "fs/promises";
|
|
@@ -3300,10 +4201,11 @@ Run: ${upgradeCommandFor(kind)}
|
|
|
3300
4201
|
function parseState(text) {
|
|
3301
4202
|
try {
|
|
3302
4203
|
const data = JSON.parse(text);
|
|
4204
|
+
const record = data && typeof data === "object" ? data : {};
|
|
3303
4205
|
return {
|
|
3304
|
-
lastCheck: typeof
|
|
3305
|
-
latestVersion: typeof
|
|
3306
|
-
etag: typeof
|
|
4206
|
+
lastCheck: typeof record.lastCheck === "number" ? record.lastCheck : 0,
|
|
4207
|
+
latestVersion: typeof record.latestVersion === "string" ? record.latestVersion : null,
|
|
4208
|
+
etag: typeof record.etag === "string" ? record.etag : null
|
|
3307
4209
|
};
|
|
3308
4210
|
} catch {
|
|
3309
4211
|
return { lastCheck: 0, latestVersion: null, etag: null };
|
|
@@ -3357,6 +4259,7 @@ function latestReleaseApiUrl() {
|
|
|
3357
4259
|
|
|
3358
4260
|
// src/update.ts
|
|
3359
4261
|
var REQUEST_TIMEOUT_MS2 = 8e3;
|
|
4262
|
+
var DOWNLOAD_TIMEOUT_MS = REQUEST_TIMEOUT_MS2 * 10;
|
|
3360
4263
|
var SHA256SUMS = "SHA256SUMS";
|
|
3361
4264
|
function userAgent(version) {
|
|
3362
4265
|
return `hoopilot/${version}`;
|
|
@@ -3494,15 +4397,15 @@ async function downloadToFile(url, dest, version) {
|
|
|
3494
4397
|
const response = await fetch(url, {
|
|
3495
4398
|
headers: { "User-Agent": userAgent(version) },
|
|
3496
4399
|
redirect: "follow",
|
|
3497
|
-
signal: AbortSignal.timeout(
|
|
4400
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
|
|
3498
4401
|
});
|
|
3499
4402
|
if (!response.ok || !response.body) {
|
|
3500
4403
|
throw new Error(`Download failed (${response.status}) for ${url}`);
|
|
3501
4404
|
}
|
|
3502
|
-
await
|
|
4405
|
+
await Bun.write(dest, response);
|
|
3503
4406
|
}
|
|
3504
4407
|
async function sha256File(path) {
|
|
3505
|
-
return
|
|
4408
|
+
return createHash2("sha256").update(await readFile(path)).digest("hex");
|
|
3506
4409
|
}
|
|
3507
4410
|
async function verifyChecksum(release, assetName, file, version) {
|
|
3508
4411
|
const sums = release.assets.find((asset) => asset.name === SHA256SUMS);
|
|
@@ -3514,7 +4417,7 @@ async function verifyChecksum(release, assetName, file, version) {
|
|
|
3514
4417
|
const response = await fetch(sums.url, {
|
|
3515
4418
|
headers: { "User-Agent": userAgent(version) },
|
|
3516
4419
|
redirect: "follow",
|
|
3517
|
-
signal: AbortSignal.timeout(
|
|
4420
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
|
|
3518
4421
|
});
|
|
3519
4422
|
if (!response.ok) {
|
|
3520
4423
|
throw new Error(`Could not download ${SHA256SUMS} (${response.status}).`);
|
|
@@ -3532,7 +4435,7 @@ function swapBinary(tmpFile, exePath) {
|
|
|
3532
4435
|
if (process.platform === "win32") {
|
|
3533
4436
|
const oldExe = `${exePath}.old`;
|
|
3534
4437
|
try {
|
|
3535
|
-
|
|
4438
|
+
rmSync2(oldExe, { force: true });
|
|
3536
4439
|
} catch {
|
|
3537
4440
|
}
|
|
3538
4441
|
renameSync2(exePath, oldExe);
|
|
@@ -3589,7 +4492,7 @@ function refreshCodexxShim(dir, logger) {
|
|
|
3589
4492
|
{ err: errorDetails(error), event: "update.codexx_shim_failed" },
|
|
3590
4493
|
"could not refresh codexx shim"
|
|
3591
4494
|
);
|
|
3592
|
-
console.warn(`Updated hoopilot, but could not refresh the codexx shim: ${
|
|
4495
|
+
console.warn(`Updated hoopilot, but could not refresh the codexx shim: ${errorMessage(error)}`);
|
|
3593
4496
|
}
|
|
3594
4497
|
}
|
|
3595
4498
|
function cleanupOldBinary() {
|
|
@@ -3597,7 +4500,7 @@ function cleanupOldBinary() {
|
|
|
3597
4500
|
return;
|
|
3598
4501
|
}
|
|
3599
4502
|
try {
|
|
3600
|
-
|
|
4503
|
+
rmSync2(`${realpathSync(process.execPath)}.old`, { force: true });
|
|
3601
4504
|
} catch {
|
|
3602
4505
|
}
|
|
3603
4506
|
}
|
|
@@ -3661,7 +4564,7 @@ async function runUpdate(currentVersion, logger) {
|
|
|
3661
4564
|
throw error;
|
|
3662
4565
|
} finally {
|
|
3663
4566
|
try {
|
|
3664
|
-
|
|
4567
|
+
rmSync2(tmpFile, { force: true });
|
|
3665
4568
|
} catch {
|
|
3666
4569
|
}
|
|
3667
4570
|
}
|
|
@@ -3674,12 +4577,8 @@ async function runUpdate(currentVersion, logger) {
|
|
|
3674
4577
|
console.log("Restart hoopilot to run the new version.");
|
|
3675
4578
|
}
|
|
3676
4579
|
}
|
|
3677
|
-
function errorMessage2(error) {
|
|
3678
|
-
return error instanceof Error ? error.message : String(error);
|
|
3679
|
-
}
|
|
3680
4580
|
|
|
3681
4581
|
// src/cli.ts
|
|
3682
|
-
var ALLOWED_COPILOT_API_HOSTS2 = ["api.githubcopilot.com"];
|
|
3683
4582
|
async function main2(argv = Bun.argv.slice(2)) {
|
|
3684
4583
|
cleanupOldBinary();
|
|
3685
4584
|
const command = argv[0];
|
|
@@ -3812,7 +4711,7 @@ function parseArgs(argv) {
|
|
|
3812
4711
|
args.logLevel = parseLogLevel(optionValue(name, inlineValue, rest));
|
|
3813
4712
|
break;
|
|
3814
4713
|
case "--stream-mode":
|
|
3815
|
-
args.streamingProxyMode =
|
|
4714
|
+
args.streamingProxyMode = parseStreamingProxyMode(optionValue(name, inlineValue, rest));
|
|
3816
4715
|
break;
|
|
3817
4716
|
case "--host":
|
|
3818
4717
|
args.host = optionValue(name, inlineValue, rest);
|
|
@@ -3832,12 +4731,6 @@ function parseArgs(argv) {
|
|
|
3832
4731
|
}
|
|
3833
4732
|
return args;
|
|
3834
4733
|
}
|
|
3835
|
-
function parseStreamMode(value) {
|
|
3836
|
-
if (value === "auto" || value === "buffer" || value === "live") {
|
|
3837
|
-
return value;
|
|
3838
|
-
}
|
|
3839
|
-
throw new Error(`Invalid stream mode: ${value}. Expected auto, live, or buffer.`);
|
|
3840
|
-
}
|
|
3841
4734
|
function optionValue(name, inlineValue, rest) {
|
|
3842
4735
|
const value = inlineValue ?? rest.shift();
|
|
3843
4736
|
if (!value) {
|
|
@@ -3898,11 +4791,7 @@ async function runModels(options = {}) {
|
|
|
3898
4791
|
logger.debug({ event: "models.list.started" }, "fetching github copilot models");
|
|
3899
4792
|
const response = await new CopilotClient(options).models();
|
|
3900
4793
|
if (!response.ok) {
|
|
3901
|
-
|
|
3902
|
-
if (response.status === 401 || response.status === 403) {
|
|
3903
|
-
throw new CopilotAuthError(message);
|
|
3904
|
-
}
|
|
3905
|
-
throw new Error(message);
|
|
4794
|
+
await throwForCopilotResponse(response, "GitHub Copilot API model list");
|
|
3906
4795
|
}
|
|
3907
4796
|
const ids = modelIdsFromResponse(await response.json().catch(() => void 0));
|
|
3908
4797
|
if (ids.length === 0) {
|
|
@@ -3922,11 +4811,7 @@ async function runUsage(options = {}) {
|
|
|
3922
4811
|
logger.debug({ event: "usage.fetch.started" }, "fetching github copilot quota");
|
|
3923
4812
|
const response = await new CopilotClient(options).usage();
|
|
3924
4813
|
if (!response.ok) {
|
|
3925
|
-
|
|
3926
|
-
if (response.status === 401 || response.status === 403) {
|
|
3927
|
-
throw new CopilotAuthError(message);
|
|
3928
|
-
}
|
|
3929
|
-
throw new Error(message);
|
|
4814
|
+
await throwForCopilotResponse(response, "GitHub Copilot usage request");
|
|
3930
4815
|
}
|
|
3931
4816
|
const rateLimit = parseRateLimitHeaders(response.headers);
|
|
3932
4817
|
const usage = normalizeCopilotUsage(await response.json().catch(() => ({})));
|
|
@@ -4026,7 +4911,7 @@ async function verifyCopilotOAuthToken(token, options = {}) {
|
|
|
4026
4911
|
options.copilotApiBaseUrl ?? envValue(options.env?.COPILOT_API_BASE_URL) ?? DEFAULT_COPILOT_API_BASE_URL
|
|
4027
4912
|
);
|
|
4028
4913
|
const allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
|
|
4029
|
-
if (!isTrustedTokenBaseUrl(apiBaseUrl,
|
|
4914
|
+
if (!isTrustedTokenBaseUrl(apiBaseUrl, ALLOWED_COPILOT_API_HOSTS, allowUnsafeUpstream)) {
|
|
4030
4915
|
throw new Error(
|
|
4031
4916
|
`Refusing to send the GitHub OAuth token to an untrusted Copilot API host: ${apiBaseUrl}`
|
|
4032
4917
|
);
|
|
@@ -4037,19 +4922,22 @@ async function verifyCopilotOAuthToken(token, options = {}) {
|
|
|
4037
4922
|
method: "GET"
|
|
4038
4923
|
});
|
|
4039
4924
|
if (!response.ok) {
|
|
4040
|
-
|
|
4041
|
-
if (response.status === 401 || response.status === 403) {
|
|
4042
|
-
throw new CopilotAuthError(message);
|
|
4043
|
-
}
|
|
4044
|
-
throw new Error(message);
|
|
4925
|
+
await throwForCopilotResponse(response, "GitHub Copilot API verification");
|
|
4045
4926
|
}
|
|
4046
4927
|
return {
|
|
4047
4928
|
apiBaseUrl,
|
|
4048
|
-
expiresAtMs: Date.now() +
|
|
4929
|
+
expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
|
|
4049
4930
|
source: "github-copilot-oauth",
|
|
4050
4931
|
token
|
|
4051
4932
|
};
|
|
4052
4933
|
}
|
|
4934
|
+
async function throwForCopilotResponse(response, label) {
|
|
4935
|
+
const message = `${label} failed with ${response.status}: ${await truncatedResponseText(response)}`;
|
|
4936
|
+
if (response.status === 401 || response.status === 403) {
|
|
4937
|
+
throw new CopilotAuthError(message);
|
|
4938
|
+
}
|
|
4939
|
+
throw new Error(message);
|
|
4940
|
+
}
|
|
4053
4941
|
function openBrowserBestEffort(url, spawnOpener = spawn) {
|
|
4054
4942
|
const platform = process.platform;
|
|
4055
4943
|
const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
|
|
@@ -4065,21 +4953,6 @@ function openBrowserBestEffort(url, spawnOpener = spawn) {
|
|
|
4065
4953
|
} catch {
|
|
4066
4954
|
}
|
|
4067
4955
|
}
|
|
4068
|
-
function modelIdsFromResponse(body) {
|
|
4069
|
-
const record = asRecord(body);
|
|
4070
|
-
const data = Array.isArray(record.data) ? record.data : Array.isArray(body) ? body : [];
|
|
4071
|
-
const seen = /* @__PURE__ */ new Set();
|
|
4072
|
-
const ids = [];
|
|
4073
|
-
for (const model of data) {
|
|
4074
|
-
const id = asRecord(model).id;
|
|
4075
|
-
if (typeof id !== "string" || id.length === 0 || seen.has(id)) {
|
|
4076
|
-
continue;
|
|
4077
|
-
}
|
|
4078
|
-
seen.add(id);
|
|
4079
|
-
ids.push(id);
|
|
4080
|
-
}
|
|
4081
|
-
return ids;
|
|
4082
|
-
}
|
|
4083
4956
|
function withRuntimeEnv(args) {
|
|
4084
4957
|
return { ...args, env: process.env };
|
|
4085
4958
|
}
|
|
@@ -4129,7 +5002,7 @@ Commands:
|
|
|
4129
5002
|
|
|
4130
5003
|
While the server runs, GET /metrics exposes Prometheus metrics (request counts,
|
|
4131
5004
|
token usage, latency) and GET /v1/usage returns those metrics plus live Copilot
|
|
4132
|
-
quota as JSON.
|
|
5005
|
+
quota as JSON. Open GET /dashboard in a browser for a live usage and status view.
|
|
4133
5006
|
|
|
4134
5007
|
Options:
|
|
4135
5008
|
-p, --port <port> Port to listen on. Default: 4141
|
|
@@ -4163,7 +5036,7 @@ Environment:
|
|
|
4163
5036
|
}
|
|
4164
5037
|
if (import.meta.main) {
|
|
4165
5038
|
main2().catch((error) => {
|
|
4166
|
-
console.error(
|
|
5039
|
+
console.error(errorMessage(error));
|
|
4167
5040
|
process.exit(1);
|
|
4168
5041
|
});
|
|
4169
5042
|
}
|