@dianshuv/copilot-api 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/main.mjs +1349 -1393
- package/package.json +1 -1
package/dist/main.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { getProxyForUrl } from "proxy-from-env";
|
|
8
8
|
import { Agent, ProxyAgent, setGlobalDispatcher } from "undici";
|
|
9
9
|
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
10
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
11
|
import { serve } from "srvx";
|
|
11
12
|
import { PostHog } from "posthog-node";
|
|
12
13
|
import pc from "picocolors";
|
|
@@ -474,6 +475,56 @@ async function getGitHubUser() {
|
|
|
474
475
|
return await response.json();
|
|
475
476
|
}
|
|
476
477
|
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region src/lib/tui/request-timings.ts
|
|
480
|
+
/** Canonical phase keys — the Map keys double as TrackedRequest field names. */
|
|
481
|
+
const TIMING = {
|
|
482
|
+
TOKENIZE: "tokenizeMs",
|
|
483
|
+
TOKENIZE_COLD: "tokenizeColdMs",
|
|
484
|
+
UPSTREAM_TTFB: "upstreamTtfbMs",
|
|
485
|
+
LIMITER_RETRIES: "limiterRetries"
|
|
486
|
+
};
|
|
487
|
+
const storage = new AsyncLocalStorage();
|
|
488
|
+
/** Run `fn` with a fresh per-request timings store available ambiently. */
|
|
489
|
+
function runWithTimings(fn) {
|
|
490
|
+
const timings = /* @__PURE__ */ new Map();
|
|
491
|
+
return storage.run(timings, () => fn(timings));
|
|
492
|
+
}
|
|
493
|
+
/** The current request's timings store, if running inside `runWithTimings`. */
|
|
494
|
+
function getTimings() {
|
|
495
|
+
return storage.getStore();
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Add `ms` to the named phase on the current (or given) timings store.
|
|
499
|
+
* Accumulates: the same phase recorded multiple times sums (e.g. tokenize
|
|
500
|
+
* running several passes within one request). No-op when no store is active.
|
|
501
|
+
*/
|
|
502
|
+
function addTiming(phase, ms, store = storage.getStore()) {
|
|
503
|
+
if (!store) return;
|
|
504
|
+
store.set(phase, (store.get(phase) ?? 0) + ms);
|
|
505
|
+
}
|
|
506
|
+
/** Time a sync fn and accumulate its duration under `phase`; returns its result. */
|
|
507
|
+
function timeSync(phase, fn) {
|
|
508
|
+
const start = performance.now();
|
|
509
|
+
try {
|
|
510
|
+
return fn();
|
|
511
|
+
} finally {
|
|
512
|
+
addTiming(phase, performance.now() - start);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Project a collected timings Map onto the PhaseTimings shape so it can be
|
|
517
|
+
* spread straight onto a tracker RequestUpdate. Values are rounded to whole ms
|
|
518
|
+
* (limiterRetries is already integer, so rounding is a no-op there).
|
|
519
|
+
*/
|
|
520
|
+
function timingsToUpdate(timings) {
|
|
521
|
+
const out = {};
|
|
522
|
+
if (!timings) return out;
|
|
523
|
+
const writable = out;
|
|
524
|
+
for (const [key, value] of timings) writable[key] = Math.round(value);
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
|
|
477
528
|
//#endregion
|
|
478
529
|
//#region src/lib/fetch-retry.ts
|
|
479
530
|
const RETRYABLE_CAUSE_CODES = new Set([
|
|
@@ -517,7 +568,9 @@ async function fetchWithRetry(input, init, options) {
|
|
|
517
568
|
let networkAttempts = 0;
|
|
518
569
|
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) try {
|
|
519
570
|
networkAttempts++;
|
|
571
|
+
const fetchStart = performance.now();
|
|
520
572
|
const response = await fetch(input, currentInit);
|
|
573
|
+
const ttfbMs = performance.now() - fetchStart;
|
|
521
574
|
if (response.status === 401 && !authRefreshed && options?.onUnauthorized) {
|
|
522
575
|
const refreshed = await tryRefreshAuth(options.onUnauthorized);
|
|
523
576
|
if (refreshed) {
|
|
@@ -529,6 +582,7 @@ async function fetchWithRetry(input, init, options) {
|
|
|
529
582
|
continue;
|
|
530
583
|
}
|
|
531
584
|
}
|
|
585
|
+
addTiming(TIMING.UPSTREAM_TTFB, ttfbMs);
|
|
532
586
|
annotateRetryMeta(response, networkAttempts, authRefreshed);
|
|
533
587
|
return response;
|
|
534
588
|
} catch (error) {
|
|
@@ -993,1183 +1047,329 @@ const logout = defineCommand({
|
|
|
993
1047
|
|
|
994
1048
|
//#endregion
|
|
995
1049
|
//#region package.json
|
|
996
|
-
var version = "0.
|
|
1050
|
+
var version = "0.13.1";
|
|
997
1051
|
|
|
998
1052
|
//#endregion
|
|
999
|
-
//#region src/lib/
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
/**
|
|
1034
|
-
* Execute a request with adaptive rate limiting.
|
|
1035
|
-
* Returns a promise that resolves when the request succeeds.
|
|
1036
|
-
* The request will be retried automatically on 429 errors.
|
|
1037
|
-
*/
|
|
1038
|
-
async execute(fn) {
|
|
1039
|
-
if (this.mode === "normal") return this.executeInNormalMode(fn);
|
|
1040
|
-
if (this.mode === "recovering") return this.executeInRecoveringMode(fn);
|
|
1041
|
-
return this.enqueue(fn);
|
|
1042
|
-
}
|
|
1043
|
-
/**
|
|
1044
|
-
* Check if an error is a rate limit error (429) and extract Retry-After if available
|
|
1045
|
-
*/
|
|
1046
|
-
isRateLimitError(error) {
|
|
1047
|
-
if (error && typeof error === "object") {
|
|
1048
|
-
if ("status" in error && error.status === 429) return {
|
|
1049
|
-
isRateLimit: true,
|
|
1050
|
-
retryAfter: this.extractRetryAfter(error)
|
|
1051
|
-
};
|
|
1052
|
-
if ("responseText" in error && typeof error.responseText === "string") try {
|
|
1053
|
-
const parsed = JSON.parse(error.responseText);
|
|
1054
|
-
if (parsed && typeof parsed === "object" && "error" in parsed && parsed.error && typeof parsed.error === "object" && "code" in parsed.error && parsed.error.code === "rate_limited") return { isRateLimit: true };
|
|
1055
|
-
} catch {}
|
|
1056
|
-
}
|
|
1057
|
-
return { isRateLimit: false };
|
|
1058
|
-
}
|
|
1059
|
-
/**
|
|
1060
|
-
* Extract Retry-After value from error response
|
|
1061
|
-
*/
|
|
1062
|
-
extractRetryAfter(error) {
|
|
1063
|
-
if (!error || typeof error !== "object") return void 0;
|
|
1064
|
-
if ("responseText" in error && typeof error.responseText === "string") try {
|
|
1065
|
-
const parsed = JSON.parse(error.responseText);
|
|
1066
|
-
if (parsed && typeof parsed === "object" && "retry_after" in parsed && typeof parsed.retry_after === "number") return parsed.retry_after;
|
|
1067
|
-
if (parsed && typeof parsed === "object" && "error" in parsed && parsed.error && typeof parsed.error === "object" && "retry_after" in parsed.error && typeof parsed.error.retry_after === "number") return parsed.error.retry_after;
|
|
1068
|
-
} catch {}
|
|
1069
|
-
}
|
|
1070
|
-
/**
|
|
1071
|
-
* Execute in normal mode - full speed
|
|
1072
|
-
*/
|
|
1073
|
-
async executeInNormalMode(fn) {
|
|
1074
|
-
try {
|
|
1075
|
-
return {
|
|
1076
|
-
result: await fn(),
|
|
1077
|
-
queueWaitMs: 0
|
|
1078
|
-
};
|
|
1079
|
-
} catch (error) {
|
|
1080
|
-
const { isRateLimit, retryAfter } = this.isRateLimitError(error);
|
|
1081
|
-
if (isRateLimit) {
|
|
1082
|
-
this.enterRateLimitedMode();
|
|
1083
|
-
return this.enqueue(fn, retryAfter);
|
|
1084
|
-
}
|
|
1085
|
-
throw error;
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
/**
|
|
1089
|
-
* Execute in recovering mode - gradual speedup
|
|
1090
|
-
*/
|
|
1091
|
-
async executeInRecoveringMode(fn) {
|
|
1092
|
-
const startTime = Date.now();
|
|
1093
|
-
const currentInterval = this.config.gradualRecoverySteps[this.recoveryStepIndex] ?? 0;
|
|
1094
|
-
if (currentInterval > 0) {
|
|
1095
|
-
const elapsedMs = Date.now() - this.lastRequestTime;
|
|
1096
|
-
const requiredMs = currentInterval * 1e3;
|
|
1097
|
-
if (this.lastRequestTime > 0 && elapsedMs < requiredMs) {
|
|
1098
|
-
const waitMs = requiredMs - elapsedMs;
|
|
1099
|
-
await this.sleep(waitMs);
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
this.lastRequestTime = Date.now();
|
|
1103
|
-
try {
|
|
1104
|
-
const result = await fn();
|
|
1105
|
-
this.recoveryStepIndex++;
|
|
1106
|
-
if (this.recoveryStepIndex >= this.config.gradualRecoverySteps.length) this.completeRecovery();
|
|
1107
|
-
else {
|
|
1108
|
-
const nextInterval = this.config.gradualRecoverySteps[this.recoveryStepIndex] ?? 0;
|
|
1109
|
-
consola.info(`[RateLimiter] Recovery step ${this.recoveryStepIndex}/${this.config.gradualRecoverySteps.length} (next interval: ${nextInterval}s)`);
|
|
1110
|
-
}
|
|
1111
|
-
return {
|
|
1112
|
-
result,
|
|
1113
|
-
queueWaitMs: Date.now() - startTime
|
|
1114
|
-
};
|
|
1115
|
-
} catch (error) {
|
|
1116
|
-
const { isRateLimit, retryAfter } = this.isRateLimitError(error);
|
|
1117
|
-
if (isRateLimit) {
|
|
1118
|
-
consola.warn("[RateLimiter] Hit rate limit during recovery, returning to rate-limited mode");
|
|
1119
|
-
this.enterRateLimitedMode();
|
|
1120
|
-
return this.enqueue(fn, retryAfter);
|
|
1121
|
-
}
|
|
1122
|
-
throw error;
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
/**
|
|
1126
|
-
* Enter rate-limited mode
|
|
1127
|
-
*/
|
|
1128
|
-
enterRateLimitedMode() {
|
|
1129
|
-
if (this.mode === "rate-limited") return;
|
|
1130
|
-
this.mode = "rate-limited";
|
|
1131
|
-
this.rateLimitedAt = Date.now();
|
|
1132
|
-
this.consecutiveSuccesses = 0;
|
|
1133
|
-
consola.warn(`[RateLimiter] Entering rate-limited mode. Requests will be queued with exponential backoff (base: ${this.config.baseRetryIntervalSeconds}s).`);
|
|
1134
|
-
}
|
|
1135
|
-
/**
|
|
1136
|
-
* Check if we should try to recover to normal mode
|
|
1137
|
-
*/
|
|
1138
|
-
shouldAttemptRecovery() {
|
|
1139
|
-
if (this.consecutiveSuccesses >= this.config.consecutiveSuccessesForRecovery) {
|
|
1140
|
-
consola.info(`[RateLimiter] ${this.consecutiveSuccesses} consecutive successes. Starting gradual recovery.`);
|
|
1141
|
-
return true;
|
|
1142
|
-
}
|
|
1143
|
-
if (this.rateLimitedAt) {
|
|
1144
|
-
if (Date.now() - this.rateLimitedAt >= this.config.recoveryTimeoutMinutes * 60 * 1e3) {
|
|
1145
|
-
consola.info(`[RateLimiter] ${this.config.recoveryTimeoutMinutes} minutes elapsed. Starting gradual recovery.`);
|
|
1146
|
-
return true;
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
return false;
|
|
1150
|
-
}
|
|
1151
|
-
/**
|
|
1152
|
-
* Start gradual recovery mode
|
|
1153
|
-
*/
|
|
1154
|
-
startGradualRecovery() {
|
|
1155
|
-
this.mode = "recovering";
|
|
1156
|
-
this.recoveryStepIndex = 0;
|
|
1157
|
-
this.rateLimitedAt = null;
|
|
1158
|
-
this.consecutiveSuccesses = 0;
|
|
1159
|
-
const firstInterval = this.config.gradualRecoverySteps[0] ?? 0;
|
|
1160
|
-
consola.info(`[RateLimiter] Starting gradual recovery (${this.config.gradualRecoverySteps.length} steps, first interval: ${firstInterval}s)`);
|
|
1161
|
-
}
|
|
1162
|
-
/**
|
|
1163
|
-
* Complete recovery to normal mode
|
|
1164
|
-
*/
|
|
1165
|
-
completeRecovery() {
|
|
1166
|
-
this.mode = "normal";
|
|
1167
|
-
this.recoveryStepIndex = 0;
|
|
1168
|
-
consola.success("[RateLimiter] Recovery complete. Full speed enabled.");
|
|
1169
|
-
}
|
|
1170
|
-
/**
|
|
1171
|
-
* Enqueue a request for later execution
|
|
1172
|
-
*/
|
|
1173
|
-
enqueue(fn, retryAfterSeconds) {
|
|
1174
|
-
return new Promise((resolve, reject) => {
|
|
1175
|
-
const request = {
|
|
1176
|
-
execute: fn,
|
|
1177
|
-
resolve,
|
|
1178
|
-
reject,
|
|
1179
|
-
retryCount: 0,
|
|
1180
|
-
retryAfterSeconds,
|
|
1181
|
-
enqueuedAt: Date.now()
|
|
1182
|
-
};
|
|
1183
|
-
this.queue.push(request);
|
|
1184
|
-
if (this.queue.length > 1) {
|
|
1185
|
-
const position = this.queue.length;
|
|
1186
|
-
const estimatedWait = (position - 1) * this.config.requestIntervalSeconds;
|
|
1187
|
-
consola.info(`[RateLimiter] Request queued (position ${position}, ~${estimatedWait}s wait)`);
|
|
1188
|
-
}
|
|
1189
|
-
this.processQueue();
|
|
1190
|
-
});
|
|
1191
|
-
}
|
|
1192
|
-
/**
|
|
1193
|
-
* Calculate retry interval with exponential backoff
|
|
1194
|
-
*/
|
|
1195
|
-
calculateRetryInterval(request) {
|
|
1196
|
-
if (request.retryAfterSeconds !== void 0 && request.retryAfterSeconds > 0) return request.retryAfterSeconds;
|
|
1197
|
-
const backoff = this.config.baseRetryIntervalSeconds * Math.pow(2, request.retryCount);
|
|
1198
|
-
return Math.min(backoff, this.config.maxRetryIntervalSeconds);
|
|
1199
|
-
}
|
|
1200
|
-
/**
|
|
1201
|
-
* Process the queue
|
|
1202
|
-
*/
|
|
1203
|
-
async processQueue() {
|
|
1204
|
-
if (this.processing) return;
|
|
1205
|
-
this.processing = true;
|
|
1206
|
-
while (this.queue.length > 0) {
|
|
1207
|
-
const request = this.queue[0];
|
|
1208
|
-
if (this.shouldAttemptRecovery()) this.startGradualRecovery();
|
|
1209
|
-
const elapsedMs = Date.now() - this.lastRequestTime;
|
|
1210
|
-
const requiredMs = (request.retryCount > 0 ? this.calculateRetryInterval(request) : this.config.requestIntervalSeconds) * 1e3;
|
|
1211
|
-
if (this.lastRequestTime > 0 && elapsedMs < requiredMs) {
|
|
1212
|
-
const waitMs = requiredMs - elapsedMs;
|
|
1213
|
-
const waitSec = Math.ceil(waitMs / 1e3);
|
|
1214
|
-
consola.info(`[RateLimiter] Waiting ${waitSec}s before next request...`);
|
|
1215
|
-
await this.sleep(waitMs);
|
|
1216
|
-
}
|
|
1217
|
-
this.lastRequestTime = Date.now();
|
|
1218
|
-
try {
|
|
1219
|
-
const result = await request.execute();
|
|
1220
|
-
this.queue.shift();
|
|
1221
|
-
this.consecutiveSuccesses++;
|
|
1222
|
-
request.retryAfterSeconds = void 0;
|
|
1223
|
-
const queueWaitMs = Date.now() - request.enqueuedAt;
|
|
1224
|
-
request.resolve({
|
|
1225
|
-
result,
|
|
1226
|
-
queueWaitMs
|
|
1227
|
-
});
|
|
1228
|
-
if (this.mode === "rate-limited") consola.info(`[RateLimiter] Request succeeded (${this.consecutiveSuccesses}/${this.config.consecutiveSuccessesForRecovery} for recovery)`);
|
|
1229
|
-
} catch (error) {
|
|
1230
|
-
const { isRateLimit, retryAfter } = this.isRateLimitError(error);
|
|
1231
|
-
if (isRateLimit) {
|
|
1232
|
-
request.retryCount++;
|
|
1233
|
-
request.retryAfterSeconds = retryAfter;
|
|
1234
|
-
this.consecutiveSuccesses = 0;
|
|
1235
|
-
this.rateLimitedAt = Date.now();
|
|
1236
|
-
const nextInterval = this.calculateRetryInterval(request);
|
|
1237
|
-
const source = retryAfter ? "server Retry-After" : "exponential backoff";
|
|
1238
|
-
consola.warn(`[RateLimiter] Request failed with 429 (retry #${request.retryCount}). Retrying in ${nextInterval}s (${source})...`);
|
|
1239
|
-
} else {
|
|
1240
|
-
this.queue.shift();
|
|
1241
|
-
request.reject(error);
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
this.processing = false;
|
|
1246
|
-
}
|
|
1247
|
-
sleep(ms) {
|
|
1248
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1249
|
-
}
|
|
1250
|
-
/**
|
|
1251
|
-
* Reject all currently queued requests during shutdown.
|
|
1252
|
-
* Returns the number of requests that were rejected.
|
|
1253
|
-
*/
|
|
1254
|
-
rejectQueued() {
|
|
1255
|
-
const count = this.queue.length;
|
|
1256
|
-
for (const request of this.queue) request.reject(/* @__PURE__ */ new Error("Server is shutting down"));
|
|
1257
|
-
this.queue = [];
|
|
1258
|
-
return count;
|
|
1259
|
-
}
|
|
1260
|
-
/**
|
|
1261
|
-
* Get current status for debugging/monitoring
|
|
1262
|
-
*/
|
|
1263
|
-
getStatus() {
|
|
1264
|
-
return {
|
|
1265
|
-
mode: this.mode,
|
|
1266
|
-
queueLength: this.queue.length,
|
|
1267
|
-
consecutiveSuccesses: this.consecutiveSuccesses,
|
|
1268
|
-
rateLimitedAt: this.rateLimitedAt
|
|
1269
|
-
};
|
|
1053
|
+
//#region src/lib/event-loop-lag.ts
|
|
1054
|
+
const PROBE_INTERVAL_MS = 500;
|
|
1055
|
+
const REPORT_EVERY = 20;
|
|
1056
|
+
const WARN_LAG_MS = 50;
|
|
1057
|
+
let timer$1 = null;
|
|
1058
|
+
let expectedNext = 0;
|
|
1059
|
+
let maxLagMs = 0;
|
|
1060
|
+
let sumLagMs = 0;
|
|
1061
|
+
let samples = 0;
|
|
1062
|
+
function startEventLoopLagMonitor() {
|
|
1063
|
+
if (timer$1) return;
|
|
1064
|
+
expectedNext = performance.now() + PROBE_INTERVAL_MS;
|
|
1065
|
+
timer$1 = setInterval(() => {
|
|
1066
|
+
const now = performance.now();
|
|
1067
|
+
const lag = Math.max(0, now - expectedNext);
|
|
1068
|
+
expectedNext = now + PROBE_INTERVAL_MS;
|
|
1069
|
+
maxLagMs = Math.max(maxLagMs, lag);
|
|
1070
|
+
sumLagMs += lag;
|
|
1071
|
+
samples++;
|
|
1072
|
+
if (samples < REPORT_EVERY) return;
|
|
1073
|
+
const avg = sumLagMs / samples;
|
|
1074
|
+
const max = maxLagMs;
|
|
1075
|
+
if (max >= WARN_LAG_MS) consola.warn(`[event-loop] lag avg=${avg.toFixed(1)}ms max=${max.toFixed(1)}ms (blocked thread)`);
|
|
1076
|
+
else consola.debug(`[event-loop] lag avg=${avg.toFixed(1)}ms max=${max.toFixed(1)}ms`);
|
|
1077
|
+
maxLagMs = 0;
|
|
1078
|
+
sumLagMs = 0;
|
|
1079
|
+
samples = 0;
|
|
1080
|
+
}, PROBE_INTERVAL_MS);
|
|
1081
|
+
if (typeof timer$1.unref === "function") timer$1.unref();
|
|
1082
|
+
}
|
|
1083
|
+
function stopEventLoopLagMonitor() {
|
|
1084
|
+
if (timer$1) {
|
|
1085
|
+
clearInterval(timer$1);
|
|
1086
|
+
timer$1 = null;
|
|
1270
1087
|
}
|
|
1271
|
-
};
|
|
1272
|
-
let rateLimiterInstance = null;
|
|
1273
|
-
/**
|
|
1274
|
-
* Initialize the adaptive rate limiter with configuration
|
|
1275
|
-
*/
|
|
1276
|
-
function initAdaptiveRateLimiter(config = {}) {
|
|
1277
|
-
rateLimiterInstance = new AdaptiveRateLimiter(config);
|
|
1278
|
-
const baseRetry = config.baseRetryIntervalSeconds ?? DEFAULT_CONFIG$1.baseRetryIntervalSeconds;
|
|
1279
|
-
const maxRetry = config.maxRetryIntervalSeconds ?? DEFAULT_CONFIG$1.maxRetryIntervalSeconds;
|
|
1280
|
-
const interval = config.requestIntervalSeconds ?? DEFAULT_CONFIG$1.requestIntervalSeconds;
|
|
1281
|
-
const recovery = config.recoveryTimeoutMinutes ?? DEFAULT_CONFIG$1.recoveryTimeoutMinutes;
|
|
1282
|
-
const successes = config.consecutiveSuccessesForRecovery ?? DEFAULT_CONFIG$1.consecutiveSuccessesForRecovery;
|
|
1283
|
-
const steps = config.gradualRecoverySteps ?? DEFAULT_CONFIG$1.gradualRecoverySteps;
|
|
1284
|
-
consola.info(`[RateLimiter] Initialized (backoff: ${baseRetry}s-${maxRetry}s, interval: ${interval}s, recovery: ${recovery}min or ${successes} successes, gradual: [${steps.join("s, ")}s])`);
|
|
1285
|
-
}
|
|
1286
|
-
/**
|
|
1287
|
-
* Get the rate limiter instance
|
|
1288
|
-
*/
|
|
1289
|
-
function getAdaptiveRateLimiter() {
|
|
1290
|
-
return rateLimiterInstance;
|
|
1291
|
-
}
|
|
1292
|
-
/**
|
|
1293
|
-
* Execute a request with adaptive rate limiting.
|
|
1294
|
-
* If rate limiter is not initialized, executes immediately.
|
|
1295
|
-
* Returns the result along with queue wait time.
|
|
1296
|
-
*/
|
|
1297
|
-
async function executeWithAdaptiveRateLimit(fn) {
|
|
1298
|
-
if (!rateLimiterInstance) return {
|
|
1299
|
-
result: await fn(),
|
|
1300
|
-
queueWaitMs: 0
|
|
1301
|
-
};
|
|
1302
|
-
return rateLimiterInstance.execute(fn);
|
|
1303
1088
|
}
|
|
1304
1089
|
|
|
1305
1090
|
//#endregion
|
|
1306
|
-
//#region src/lib/
|
|
1307
|
-
/**
|
|
1308
|
-
* Auth gate — the inbound authentication decision point for the proxy.
|
|
1309
|
-
*
|
|
1310
|
-
* Protects this proxy's *inbound* surface with a configured **Proxy API key**
|
|
1311
|
-
* (NOT the outbound GitHub OAuth token or Copilot token). The decision logic
|
|
1312
|
-
* is expressed as pure functions so it can be unit-tested without booting the
|
|
1313
|
-
* server or reaching upstream.
|
|
1314
|
-
*/
|
|
1091
|
+
//#region src/lib/history-ws.ts
|
|
1315
1092
|
/**
|
|
1316
|
-
*
|
|
1317
|
-
*
|
|
1318
|
-
* Two header shapes are read, and **both** contribute candidates when present
|
|
1319
|
-
* (compare-all-present) so neither is silently ignored in favor of the other —
|
|
1320
|
-
* a later any-match over the candidates decides acceptance:
|
|
1321
|
-
* - `Authorization`: the scheme prefix is stripped case-insensitively
|
|
1322
|
-
* (`Bearer ` / `bearer ` …) because the scheme is case-insensitive per
|
|
1323
|
-
* RFC 7235, while the secret itself is case-sensitive. A bare value with no
|
|
1324
|
-
* scheme prefix is tolerated and returned verbatim.
|
|
1325
|
-
* - `x-api-key` (Issue 02): the Anthropic-native header. Taken verbatim — no
|
|
1326
|
-
* scheme stripping (a value that happens to start with `Bearer ` is kept
|
|
1327
|
-
* as-is).
|
|
1328
|
-
*
|
|
1329
|
-
* Order is `[Authorization, x-api-key]` for any present header; absent headers
|
|
1330
|
-
* contribute nothing.
|
|
1093
|
+
* WebSocket support for History API.
|
|
1094
|
+
* Enables real-time updates when new requests are recorded.
|
|
1331
1095
|
*/
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1096
|
+
const clients = /* @__PURE__ */ new Set();
|
|
1097
|
+
function addClient(ws) {
|
|
1098
|
+
clients.add(ws);
|
|
1099
|
+
const msg = {
|
|
1100
|
+
type: "connected",
|
|
1101
|
+
data: { clientCount: clients.size },
|
|
1102
|
+
timestamp: Date.now()
|
|
1103
|
+
};
|
|
1104
|
+
ws.send(JSON.stringify(msg));
|
|
1339
1105
|
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
* endpoints are reachable without a key so container orchestration probes are
|
|
1343
|
-
* never blocked. Everything else is protected (fail-closed) — unknown / future
|
|
1344
|
-
* routes default to protected.
|
|
1345
|
-
*
|
|
1346
|
-
* Matching is by **exact path**, with a trailing slash tolerated (so `/health/`
|
|
1347
|
-
* is exempt too) and `/` itself handled explicitly. Prefix matching is
|
|
1348
|
-
* deliberately avoided: `/healthz` or `/health/extra` must NOT be exempt. The
|
|
1349
|
-
* server registers a matching `/health/` route, so an exempt `/health/` request
|
|
1350
|
-
* resolves to the readiness handler rather than 404ing.
|
|
1351
|
-
*/
|
|
1352
|
-
function isExemptPath(path) {
|
|
1353
|
-
if (path === "/") return true;
|
|
1354
|
-
return (path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path) === "/health";
|
|
1106
|
+
function removeClient(ws) {
|
|
1107
|
+
clients.delete(ws);
|
|
1355
1108
|
}
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
* The configured key is trimmed before hashing (config-side trim).
|
|
1359
|
-
*/
|
|
1360
|
-
function digestConfiguredKey(configuredKey) {
|
|
1361
|
-
return createHash("sha256").update(configuredKey.trim()).digest();
|
|
1109
|
+
function getClientCount() {
|
|
1110
|
+
return clients.size;
|
|
1362
1111
|
}
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1112
|
+
function closeAllClients() {
|
|
1113
|
+
for (const client of clients) try {
|
|
1114
|
+
client.close(1001, "Server shutting down");
|
|
1115
|
+
} catch {}
|
|
1116
|
+
clients.clear();
|
|
1117
|
+
}
|
|
1118
|
+
function broadcast(message) {
|
|
1119
|
+
const data = JSON.stringify(message);
|
|
1120
|
+
for (const client of clients) try {
|
|
1121
|
+
if (client.readyState === WebSocket.OPEN) client.send(data);
|
|
1122
|
+
else clients.delete(client);
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
consola.debug("WebSocket send failed, removing client:", error);
|
|
1125
|
+
clients.delete(client);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
function notifyEntryAdded(summary) {
|
|
1129
|
+
if (clients.size === 0) return;
|
|
1130
|
+
broadcast({
|
|
1131
|
+
type: "entry_added",
|
|
1132
|
+
data: summary,
|
|
1133
|
+
timestamp: Date.now()
|
|
1375
1134
|
});
|
|
1376
1135
|
}
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
* - When both provide a non-empty value, the **flag wins** (env ignored).
|
|
1385
|
-
* - When only one provides a non-empty value, that one is used.
|
|
1386
|
-
* - When neither does, `key` is `undefined` and `source` is `"none"` → auth
|
|
1387
|
-
* stays disabled (same as the no-`--api-key` default).
|
|
1388
|
-
*
|
|
1389
|
-
* Pure: it reads nothing from `process.env` itself (the caller passes the env
|
|
1390
|
-
* value in), so it is fully unit-testable and the precedence logic is decoupled
|
|
1391
|
-
* from how the values are sourced.
|
|
1392
|
-
*/
|
|
1393
|
-
function resolveProxyApiKey(sources) {
|
|
1394
|
-
const flag = sources.flag?.trim() ?? "";
|
|
1395
|
-
if (flag !== "") return {
|
|
1396
|
-
key: flag,
|
|
1397
|
-
source: "flag"
|
|
1398
|
-
};
|
|
1399
|
-
const env = sources.env?.trim() ?? "";
|
|
1400
|
-
if (env !== "") return {
|
|
1401
|
-
key: env,
|
|
1402
|
-
source: "env"
|
|
1403
|
-
};
|
|
1404
|
-
return {
|
|
1405
|
-
key: void 0,
|
|
1406
|
-
source: "none"
|
|
1407
|
-
};
|
|
1408
|
-
}
|
|
1409
|
-
/**
|
|
1410
|
-
* Resolve the hostname the server will *actually* bind to, decided at the CLI
|
|
1411
|
-
* edge with flag-over-env precedence and a **safe loopback default** — the same
|
|
1412
|
-
* flag-over-env shape this codebase already uses to reconcile a CLI flag with
|
|
1413
|
-
* its env twin (`--api-key`/`COPILOT_API_KEY`, `--github-token`/`GH_TOKEN`).
|
|
1414
|
-
*
|
|
1415
|
-
* - `--host` flag wins when present; otherwise the `HOST` env; otherwise the
|
|
1416
|
-
* default `127.0.0.1`.
|
|
1417
|
-
* - The default is **loopback, not all-interfaces**: an unconfigured instance
|
|
1418
|
-
* must not expose `/token` (which echoes the plaintext Copilot token) and the
|
|
1419
|
-
* otherwise-unauthenticated API to the whole network. Binding every interface
|
|
1420
|
-
* is now an explicit opt-in — pass `--host 0.0.0.0` (or `HOST=0.0.0.0`).
|
|
1421
|
-
* - **flag vs env asymmetry on a blank value** (the security-critical part): an
|
|
1422
|
-
* explicit `--host` flag is the operator's deliberate choice, so a blank flag
|
|
1423
|
-
* (`--host ""` / whitespace) is taken as the wildcard-bind escape hatch and
|
|
1424
|
-
* canonicalized to an explicit `0.0.0.0` (rather than left as `""` to lean on
|
|
1425
|
-
* srvx's undocumented empty-string handling). But a *set-but-blank* `HOST` env
|
|
1426
|
-
* (`HOST=`, or `HOST=$UNSET` in a shell / compose where the var is unset →
|
|
1427
|
-
* empty — NOT a deliberate keystroke) is accidental plumbing, so it is treated
|
|
1428
|
-
* as **not provided** and falls through to the loopback default. This mirrors
|
|
1429
|
-
* `resolveProxyApiKey` trimming `""` to not-provided, so an empty `HOST` can't
|
|
1430
|
-
* silently reopen the all-interfaces-unauthenticated exposure the loopback
|
|
1431
|
-
* default exists to prevent.
|
|
1432
|
-
* - **Both sources are trimmed**: a padded `--host " 10.0.0.5 "` or
|
|
1433
|
-
* `HOST=" 10.0.0.5 "` would otherwise reach the socket bind verbatim and fail
|
|
1434
|
-
* with `ENOTFOUND`. Trimming also decides blank-ness for the rules above.
|
|
1435
|
-
*
|
|
1436
|
-
* `env` is passed in (not read here) to keep the function pure and unit-testable.
|
|
1437
|
-
*/
|
|
1438
|
-
function resolveBindHost(flag, env) {
|
|
1439
|
-
if (flag !== void 0) {
|
|
1440
|
-
const trimmed = flag.trim();
|
|
1441
|
-
return trimmed === "" ? "0.0.0.0" : trimmed;
|
|
1442
|
-
}
|
|
1443
|
-
const envTrimmed = env?.trim() ?? "";
|
|
1444
|
-
if (envTrimmed !== "") return envTrimmed;
|
|
1445
|
-
return "127.0.0.1";
|
|
1446
|
-
}
|
|
1447
|
-
/**
|
|
1448
|
-
* Resolve the address the server will *actually* bind to for the startup banner
|
|
1449
|
-
* (Issue 04).
|
|
1450
|
-
*
|
|
1451
|
-
* Mirrors srvx's own host resolution EXACTLY so the banner reports the TRUE bind
|
|
1452
|
-
* rather than a guess that could diverge from what srvx passes to the runtime.
|
|
1453
|
-
* srvx computes `hostname = opts.hostname ?? process.env.HOST` (a raw nullish
|
|
1454
|
-
* coalesce — no trimming, no empty-string special-casing), and start.ts passes
|
|
1455
|
-
* `hostname: options.host`. So:
|
|
1456
|
-
* - an explicit `--host` (even `""` / whitespace) is what srvx uses verbatim —
|
|
1457
|
-
* it does NOT fall back to HOST once `opts.hostname` is a non-null string;
|
|
1458
|
-
* - only an absent (`undefined`) `--host` lets srvx fall back to `HOST`;
|
|
1459
|
-
* - when the coalesced value is `undefined` or empty, the runtime binds all
|
|
1460
|
-
* interfaces, which we report as the explicit `0.0.0.0` so a wide-open bind
|
|
1461
|
-
* is unmistakable (srvx renders the same bind as "localhost (all
|
|
1462
|
-
* interfaces)").
|
|
1463
|
-
*
|
|
1464
|
-
* Critically, the resolved non-empty value is returned VERBATIM (not trimmed):
|
|
1465
|
-
* srvx hands the runtime exactly that string, so the banner must report exactly
|
|
1466
|
-
* that string — trimming here would make the banner claim a different address
|
|
1467
|
-
* than the one actually bound. `env` is passed in (not read) to keep the
|
|
1468
|
-
* function pure and unit-testable.
|
|
1469
|
-
*/
|
|
1470
|
-
function resolveBindAddress(host, env) {
|
|
1471
|
-
const resolved = host ?? env;
|
|
1472
|
-
if (resolved === void 0 || resolved === "") return "0.0.0.0";
|
|
1473
|
-
return resolved;
|
|
1136
|
+
function notifyEntryUpdated(summary) {
|
|
1137
|
+
if (clients.size === 0) return;
|
|
1138
|
+
broadcast({
|
|
1139
|
+
type: "entry_updated",
|
|
1140
|
+
data: summary,
|
|
1141
|
+
timestamp: Date.now()
|
|
1142
|
+
});
|
|
1474
1143
|
}
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
* - All-interfaces rendering: a wildcard bind (`0.0.0.0` / `::` / `[::]` /
|
|
1483
|
-
* empty) is not a connectable target, so it maps to `localhost` for URLs a
|
|
1484
|
-
* client will actually dial (matching srvx's "localhost (all interfaces)"
|
|
1485
|
-
* presentation). A narrowed bind (e.g. `127.0.0.1`, `192.168.1.10`, an IPv6
|
|
1486
|
-
* address) is kept so generated links point at the real interface — fixing
|
|
1487
|
-
* the prior bug where setting `HOST` (with `--host` omitted) yielded
|
|
1488
|
-
* `http://localhost:<port>` links the narrowed bind wasn't listening on.
|
|
1489
|
-
* - IPv6 bracketing: a literal IPv6 host (contains `:`) is wrapped in `[...]`,
|
|
1490
|
-
* exactly as srvx's own `fmtURL` does, so `http://[2001:db8::1]:<port>` is a
|
|
1491
|
-
* valid authority rather than the unparseable `http://2001:db8::1:<port>`.
|
|
1492
|
-
*
|
|
1493
|
-
* Returns a host token ready to drop into `http://<token>:<port>`.
|
|
1494
|
-
*/
|
|
1495
|
-
function resolveClientHost(host, env) {
|
|
1496
|
-
const bind = resolveBindAddress(host, env);
|
|
1497
|
-
if (bind === "0.0.0.0" || bind === "::" || bind === "[::]") return "localhost";
|
|
1498
|
-
if (bind.includes(":") && !bind.startsWith("[")) return `[${bind}]`;
|
|
1499
|
-
return bind;
|
|
1144
|
+
function notifyStatsUpdated(stats) {
|
|
1145
|
+
if (clients.size === 0) return;
|
|
1146
|
+
broadcast({
|
|
1147
|
+
type: "stats_updated",
|
|
1148
|
+
data: stats,
|
|
1149
|
+
timestamp: Date.now()
|
|
1150
|
+
});
|
|
1500
1151
|
}
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
* - auth OFF → `认证关闭`, plus the real bind address (so a careless all-
|
|
1509
|
-
* interfaces bind without auth is visible).
|
|
1510
|
-
*
|
|
1511
|
-
* The configured key value is **never** an input here, so it can never leak into
|
|
1512
|
-
* the banner — the function only knows the *source* tag, not the secret. Pure
|
|
1513
|
-
* (string in → strings out) so the banner copy is pinned by unit tests.
|
|
1514
|
-
*/
|
|
1515
|
-
function buildStartupAuthLines(params) {
|
|
1516
|
-
const { source, bindAddress } = params;
|
|
1517
|
-
return [source === "none" ? `Inbound auth: 认证关闭 (no proxy API key configured)` : `Inbound auth: 认证开启 (source: ${source})`, `Binding to: ${bindAddress}`];
|
|
1152
|
+
function notifyHistoryCleared() {
|
|
1153
|
+
if (clients.size === 0) return;
|
|
1154
|
+
broadcast({
|
|
1155
|
+
type: "history_cleared",
|
|
1156
|
+
data: null,
|
|
1157
|
+
timestamp: Date.now()
|
|
1158
|
+
});
|
|
1518
1159
|
}
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
* The `--api-key` flag and `COPILOT_API_KEY` env source are reconciled upstream
|
|
1527
|
-
* by `resolveProxyApiKey` (flag-over-env precedence, Issue 03); this function
|
|
1528
|
-
* receives only the already-resolved value.
|
|
1529
|
-
*/
|
|
1530
|
-
function configureProxyApiKey(rawKey) {
|
|
1531
|
-
const trimmed = rawKey?.trim() ?? "";
|
|
1532
|
-
if (trimmed === "") {
|
|
1533
|
-
state.proxyApiKeyDigest = void 0;
|
|
1534
|
-
return false;
|
|
1535
|
-
}
|
|
1536
|
-
state.proxyApiKeyDigest = digestConfiguredKey(trimmed);
|
|
1537
|
-
return true;
|
|
1160
|
+
function notifySessionDeleted(sessionId) {
|
|
1161
|
+
if (clients.size === 0) return;
|
|
1162
|
+
broadcast({
|
|
1163
|
+
type: "session_deleted",
|
|
1164
|
+
data: { sessionId },
|
|
1165
|
+
timestamp: Date.now()
|
|
1166
|
+
});
|
|
1538
1167
|
}
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
* the `authentication_error` body. Everything else — including every other
|
|
1545
|
-
* `/v1/…` endpoint and any unknown / future route — defaults to the OpenAI
|
|
1546
|
-
* family.
|
|
1547
|
-
*
|
|
1548
|
-
* Matching is by **exact path** (a trailing slash tolerated), deliberately not
|
|
1549
|
-
* a prefix test: the shared `/v1/` prefix must not sweep OpenAI-style endpoints
|
|
1550
|
-
* into the Anthropic family, and `/v1/messages-extra` or a deeper unexpected
|
|
1551
|
-
* subpath must not be misclassified either. This mirrors `isExemptPath`'s
|
|
1552
|
-
* exact-with-trailing-slash convention.
|
|
1553
|
-
*/
|
|
1554
|
-
function selectFamily(path) {
|
|
1555
|
-
const normalized = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
1556
|
-
if (normalized === "/v1/messages" || normalized === "/v1/messages/count_tokens") return "anthropic";
|
|
1557
|
-
return "openai";
|
|
1168
|
+
|
|
1169
|
+
//#endregion
|
|
1170
|
+
//#region src/lib/history.ts
|
|
1171
|
+
function generateId$1() {
|
|
1172
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 9);
|
|
1558
1173
|
}
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1174
|
+
const historyState = {
|
|
1175
|
+
enabled: false,
|
|
1176
|
+
entries: [],
|
|
1177
|
+
sessions: /* @__PURE__ */ new Map(),
|
|
1178
|
+
currentSessionId: "",
|
|
1179
|
+
maxEntries: 1e3,
|
|
1180
|
+
sessionTimeoutMs: 1800 * 1e3
|
|
1181
|
+
};
|
|
1182
|
+
const entryIndex = /* @__PURE__ */ new Map();
|
|
1183
|
+
function initHistory(enabled, maxEntries) {
|
|
1184
|
+
historyState.enabled = enabled;
|
|
1185
|
+
historyState.maxEntries = maxEntries;
|
|
1186
|
+
historyState.entries = [];
|
|
1187
|
+
historyState.sessions = /* @__PURE__ */ new Map();
|
|
1188
|
+
historyState.currentSessionId = enabled ? generateId$1() : "";
|
|
1189
|
+
entryIndex.clear();
|
|
1571
1190
|
}
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
* SDKs (and Claude Code via `/v1/messages`) recognize the failure as an auth
|
|
1575
|
-
* error: a top-level `{type:"error", error:{type:"authentication_error",
|
|
1576
|
-
* message}}`. As with the OpenAI body, missing and wrong credentials return the
|
|
1577
|
-
* identical body (no oracle).
|
|
1578
|
-
*/
|
|
1579
|
-
function unauthorizedAnthropicBody() {
|
|
1580
|
-
return {
|
|
1581
|
-
type: "error",
|
|
1582
|
-
error: {
|
|
1583
|
-
type: "authentication_error",
|
|
1584
|
-
message: "Invalid API key provided."
|
|
1585
|
-
}
|
|
1586
|
-
};
|
|
1191
|
+
function isHistoryEnabled() {
|
|
1192
|
+
return historyState.enabled;
|
|
1587
1193
|
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
* - CORS preflight `OPTIONS` on a protected path → pass through so browser
|
|
1596
|
-
* preflight isn't mistaken for a 401 (blocking it surfaces as an opaque CORS
|
|
1597
|
-
* error, very hard to diagnose). Scoped to *actual* preflights — an
|
|
1598
|
-
* `OPTIONS` carrying `Access-Control-Request-Method` — rather than any
|
|
1599
|
-
* `OPTIONS`, so the bypass surface can't silently widen. Preflights carry no
|
|
1600
|
-
* protected payload, so this doesn't weaken fail-closed.
|
|
1601
|
-
* - Otherwise require a valid Proxy API key; on failure return 401 with a
|
|
1602
|
-
* `WWW-Authenticate: Bearer` header and a **family-appropriate** body —
|
|
1603
|
-
* Anthropic-family (`/v1/messages*`) gets the `authentication_error` shape,
|
|
1604
|
-
* everything else the OpenAI `invalid_api_key` shape (Issue 02). The family
|
|
1605
|
-
* only selects the body shape; it does not change what is protected. Missing
|
|
1606
|
-
* and wrong credentials return the field-identical body for that family.
|
|
1607
|
-
*/
|
|
1608
|
-
function authGate() {
|
|
1609
|
-
return async (c, next) => {
|
|
1610
|
-
const configuredDigest = state.proxyApiKeyDigest;
|
|
1611
|
-
if (!configuredDigest) return next();
|
|
1612
|
-
if (isExemptPath(c.req.path)) return next();
|
|
1613
|
-
if (c.req.method === "OPTIONS" && c.req.raw.headers.get("access-control-request-method") !== null) return next();
|
|
1614
|
-
if (matchesConfiguredKey(configuredDigest, extractCredentials(c.req.raw.headers))) return next();
|
|
1615
|
-
c.header("WWW-Authenticate", "Bearer");
|
|
1616
|
-
if (selectFamily(c.req.path) === "anthropic") return c.json(unauthorizedAnthropicBody(), 401);
|
|
1617
|
-
return c.json(unauthorizedOpenAIBody(), 401);
|
|
1618
|
-
};
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
//#endregion
|
|
1622
|
-
//#region src/lib/context/request.ts
|
|
1623
|
-
let idCounter = 0;
|
|
1624
|
-
function createRequestContext(opts) {
|
|
1625
|
-
const id = `req_${Date.now()}_${++idCounter}`;
|
|
1626
|
-
const startTime = Date.now();
|
|
1627
|
-
const onEvent = opts.onEvent;
|
|
1628
|
-
let _state = "pending";
|
|
1629
|
-
let _originalRequest = null;
|
|
1630
|
-
let _response = null;
|
|
1631
|
-
let settled = false;
|
|
1632
|
-
function emit(event) {
|
|
1633
|
-
try {
|
|
1634
|
-
onEvent(event);
|
|
1635
|
-
} catch {}
|
|
1636
|
-
}
|
|
1637
|
-
const ctx = {
|
|
1638
|
-
id,
|
|
1639
|
-
tuiLogId: opts.tuiLogId,
|
|
1640
|
-
startTime,
|
|
1641
|
-
endpoint: opts.endpoint,
|
|
1642
|
-
get state() {
|
|
1643
|
-
return _state;
|
|
1644
|
-
},
|
|
1645
|
-
get durationMs() {
|
|
1646
|
-
return Date.now() - startTime;
|
|
1647
|
-
},
|
|
1648
|
-
get settled() {
|
|
1649
|
-
return settled;
|
|
1650
|
-
},
|
|
1651
|
-
get originalRequest() {
|
|
1652
|
-
return _originalRequest;
|
|
1653
|
-
},
|
|
1654
|
-
get response() {
|
|
1655
|
-
return _response;
|
|
1656
|
-
},
|
|
1657
|
-
setOriginalRequest(req) {
|
|
1658
|
-
_originalRequest = req;
|
|
1659
|
-
emit({
|
|
1660
|
-
type: "updated",
|
|
1661
|
-
context: ctx,
|
|
1662
|
-
field: "originalRequest"
|
|
1663
|
-
});
|
|
1664
|
-
},
|
|
1665
|
-
transition(newState) {
|
|
1666
|
-
const previousState = _state;
|
|
1667
|
-
_state = newState;
|
|
1668
|
-
emit({
|
|
1669
|
-
type: "state_changed",
|
|
1670
|
-
context: ctx,
|
|
1671
|
-
previousState
|
|
1672
|
-
});
|
|
1673
|
-
},
|
|
1674
|
-
complete(response) {
|
|
1675
|
-
if (settled) return;
|
|
1676
|
-
settled = true;
|
|
1677
|
-
_response = response;
|
|
1678
|
-
_state = "completed";
|
|
1679
|
-
emit({
|
|
1680
|
-
type: "completed",
|
|
1681
|
-
context: ctx,
|
|
1682
|
-
entry: ctx.toHistoryEntry()
|
|
1683
|
-
});
|
|
1684
|
-
},
|
|
1685
|
-
fail(model, error) {
|
|
1686
|
-
if (settled) return;
|
|
1687
|
-
settled = true;
|
|
1688
|
-
_response = {
|
|
1689
|
-
success: false,
|
|
1690
|
-
model,
|
|
1691
|
-
usage: {
|
|
1692
|
-
input_tokens: 0,
|
|
1693
|
-
output_tokens: 0
|
|
1694
|
-
},
|
|
1695
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1696
|
-
content: null
|
|
1697
|
-
};
|
|
1698
|
-
_state = "failed";
|
|
1699
|
-
emit({
|
|
1700
|
-
type: "failed",
|
|
1701
|
-
context: ctx,
|
|
1702
|
-
entry: ctx.toHistoryEntry()
|
|
1703
|
-
});
|
|
1704
|
-
},
|
|
1705
|
-
toHistoryEntry() {
|
|
1706
|
-
const entry = {
|
|
1707
|
-
id,
|
|
1708
|
-
endpoint: opts.endpoint,
|
|
1709
|
-
timestamp: startTime,
|
|
1710
|
-
durationMs: Date.now() - startTime,
|
|
1711
|
-
request: {
|
|
1712
|
-
model: _originalRequest?.model,
|
|
1713
|
-
messages: _originalRequest?.messages,
|
|
1714
|
-
stream: _originalRequest?.stream,
|
|
1715
|
-
tools: _originalRequest?.tools,
|
|
1716
|
-
system: _originalRequest?.system
|
|
1717
|
-
}
|
|
1718
|
-
};
|
|
1719
|
-
if (_response) entry.response = _response;
|
|
1720
|
-
return entry;
|
|
1194
|
+
function getCurrentSession(endpoint) {
|
|
1195
|
+
const now = Date.now();
|
|
1196
|
+
if (historyState.currentSessionId) {
|
|
1197
|
+
const session = historyState.sessions.get(historyState.currentSessionId);
|
|
1198
|
+
if (session && now - session.lastActivity < historyState.sessionTimeoutMs) {
|
|
1199
|
+
session.lastActivity = now;
|
|
1200
|
+
return historyState.currentSessionId;
|
|
1721
1201
|
}
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
_manager = createRequestContextManager(staleMaxAgeSec);
|
|
1737
|
-
return _manager;
|
|
1202
|
+
}
|
|
1203
|
+
const sessionId = generateId$1();
|
|
1204
|
+
historyState.currentSessionId = sessionId;
|
|
1205
|
+
historyState.sessions.set(sessionId, {
|
|
1206
|
+
id: sessionId,
|
|
1207
|
+
startTime: now,
|
|
1208
|
+
lastActivity: now,
|
|
1209
|
+
requestCount: 0,
|
|
1210
|
+
totalInputTokens: 0,
|
|
1211
|
+
totalOutputTokens: 0,
|
|
1212
|
+
models: [],
|
|
1213
|
+
endpoint
|
|
1214
|
+
});
|
|
1215
|
+
return sessionId;
|
|
1738
1216
|
}
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
const
|
|
1743
|
-
|
|
1744
|
-
const
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1217
|
+
function recordRequest(endpoint, request) {
|
|
1218
|
+
if (!historyState.enabled) return "";
|
|
1219
|
+
const sessionId = getCurrentSession(endpoint);
|
|
1220
|
+
const session = historyState.sessions.get(sessionId);
|
|
1221
|
+
if (!session) return "";
|
|
1222
|
+
const entry = {
|
|
1223
|
+
id: generateId$1(),
|
|
1224
|
+
sessionId,
|
|
1225
|
+
timestamp: Date.now(),
|
|
1226
|
+
endpoint,
|
|
1227
|
+
request: {
|
|
1228
|
+
model: request.model,
|
|
1229
|
+
messages: request.messages,
|
|
1230
|
+
stream: request.stream,
|
|
1231
|
+
tools: request.tools,
|
|
1232
|
+
max_tokens: request.max_tokens,
|
|
1233
|
+
temperature: request.temperature,
|
|
1234
|
+
system: request.system
|
|
1752
1235
|
}
|
|
1236
|
+
};
|
|
1237
|
+
historyState.entries.push(entry);
|
|
1238
|
+
entryIndex.set(entry.id, entry);
|
|
1239
|
+
session.requestCount++;
|
|
1240
|
+
if (!session.models.includes(request.model)) session.models.push(request.model);
|
|
1241
|
+
if (request.tools && request.tools.length > 0) {
|
|
1242
|
+
if (!session.toolsUsed) session.toolsUsed = [];
|
|
1243
|
+
for (const tool of request.tools) if (!session.toolsUsed.includes(tool.name)) session.toolsUsed.push(tool.name);
|
|
1753
1244
|
}
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1245
|
+
while (historyState.maxEntries > 0 && historyState.entries.length > historyState.maxEntries) {
|
|
1246
|
+
const removed = historyState.entries.shift();
|
|
1247
|
+
if (removed) {
|
|
1248
|
+
entryIndex.delete(removed.id);
|
|
1249
|
+
if (historyState.entries.filter((e) => e.sessionId === removed.sessionId).length === 0) historyState.sessions.delete(removed.sessionId);
|
|
1250
|
+
}
|
|
1757
1251
|
}
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1252
|
+
notifyEntryAdded({
|
|
1253
|
+
id: entry.id,
|
|
1254
|
+
endpoint,
|
|
1255
|
+
model: request.model,
|
|
1256
|
+
stream: request.stream,
|
|
1257
|
+
timestamp: entry.timestamp
|
|
1258
|
+
});
|
|
1259
|
+
return entry.id;
|
|
1260
|
+
}
|
|
1261
|
+
function recordResponse(id, response, durationMs) {
|
|
1262
|
+
if (!historyState.enabled || !id) return;
|
|
1263
|
+
const entry = entryIndex.get(id);
|
|
1264
|
+
if (entry) {
|
|
1265
|
+
entry.response = response;
|
|
1266
|
+
entry.durationMs = durationMs;
|
|
1267
|
+
const session = historyState.sessions.get(entry.sessionId);
|
|
1268
|
+
if (session) {
|
|
1269
|
+
session.totalInputTokens += response.usage.input_tokens;
|
|
1270
|
+
session.totalOutputTokens += response.usage.output_tokens;
|
|
1271
|
+
session.lastActivity = Date.now();
|
|
1762
1272
|
}
|
|
1273
|
+
notifyEntryUpdated({
|
|
1274
|
+
id: entry.id,
|
|
1275
|
+
endpoint: entry.endpoint,
|
|
1276
|
+
model: response.model,
|
|
1277
|
+
success: response.success,
|
|
1278
|
+
durationMs,
|
|
1279
|
+
inputTokens: response.usage.input_tokens,
|
|
1280
|
+
outputTokens: response.usage.output_tokens
|
|
1281
|
+
});
|
|
1282
|
+
notifyStatsUpdated({
|
|
1283
|
+
totalRequests: historyState.entries.length,
|
|
1284
|
+
totalInputTokens: session?.totalInputTokens ?? 0,
|
|
1285
|
+
totalOutputTokens: session?.totalOutputTokens ?? 0
|
|
1286
|
+
});
|
|
1763
1287
|
}
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1288
|
+
}
|
|
1289
|
+
function getHistory(options = {}) {
|
|
1290
|
+
const { page = 1, limit = 50, model, endpoint, status, from, to, search, sessionId } = options;
|
|
1291
|
+
let filtered = [...historyState.entries];
|
|
1292
|
+
if (sessionId) filtered = filtered.filter((e) => e.sessionId === sessionId);
|
|
1293
|
+
if (model) {
|
|
1294
|
+
const modelLower = model.toLowerCase();
|
|
1295
|
+
filtered = filtered.filter((e) => e.request.model.toLowerCase().includes(modelLower) || e.response?.model.toLowerCase().includes(modelLower));
|
|
1768
1296
|
}
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
});
|
|
1785
|
-
break;
|
|
1786
|
-
case "completed":
|
|
1787
|
-
if (rawEvent.entry) emit({
|
|
1788
|
-
type: "completed",
|
|
1789
|
-
context,
|
|
1790
|
-
entry: rawEvent.entry
|
|
1791
|
-
});
|
|
1792
|
-
activeContexts.delete(context.id);
|
|
1793
|
-
break;
|
|
1794
|
-
case "failed":
|
|
1795
|
-
if (rawEvent.entry) emit({
|
|
1796
|
-
type: "failed",
|
|
1797
|
-
context,
|
|
1798
|
-
entry: rawEvent.entry
|
|
1799
|
-
});
|
|
1800
|
-
activeContexts.delete(context.id);
|
|
1801
|
-
break;
|
|
1802
|
-
default: break;
|
|
1803
|
-
}
|
|
1297
|
+
if (endpoint) filtered = filtered.filter((e) => e.endpoint === endpoint);
|
|
1298
|
+
let effectiveStatus = status;
|
|
1299
|
+
const legacySuccess = options.success;
|
|
1300
|
+
if (!effectiveStatus && legacySuccess !== void 0) effectiveStatus = legacySuccess ? "success" : "error";
|
|
1301
|
+
switch (effectiveStatus) {
|
|
1302
|
+
case "success":
|
|
1303
|
+
filtered = filtered.filter((e) => e.response?.success === true);
|
|
1304
|
+
break;
|
|
1305
|
+
case "error":
|
|
1306
|
+
filtered = filtered.filter((e) => e.response !== void 0 && !e.response.success);
|
|
1307
|
+
break;
|
|
1308
|
+
case "pending":
|
|
1309
|
+
filtered = filtered.filter((e) => !e.response);
|
|
1310
|
+
break;
|
|
1311
|
+
default: break;
|
|
1804
1312
|
}
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
type: "created",
|
|
1815
|
-
context: ctx
|
|
1313
|
+
if (from) filtered = filtered.filter((e) => e.timestamp >= from);
|
|
1314
|
+
if (to) filtered = filtered.filter((e) => e.timestamp <= to);
|
|
1315
|
+
if (search) {
|
|
1316
|
+
const searchLower = search.toLowerCase();
|
|
1317
|
+
filtered = filtered.filter((e) => {
|
|
1318
|
+
const msgMatch = e.request.messages.some((m) => {
|
|
1319
|
+
if (typeof m.content === "string") return m.content.toLowerCase().includes(searchLower);
|
|
1320
|
+
if (Array.isArray(m.content)) return m.content.some((c) => c.text && c.text.toLowerCase().includes(searchLower));
|
|
1321
|
+
return false;
|
|
1816
1322
|
});
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
return
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
},
|
|
1834
|
-
startReaper,
|
|
1835
|
-
stopReaper,
|
|
1836
|
-
_runReaperOnce: runReaperOnce
|
|
1323
|
+
const respMatch = e.response?.content && typeof e.response.content.content === "string" && e.response.content.content.toLowerCase().includes(searchLower);
|
|
1324
|
+
const toolMatch = e.response?.toolCalls?.some((t) => t.name.toLowerCase().includes(searchLower));
|
|
1325
|
+
const sysMatch = e.request.system?.toLowerCase().includes(searchLower);
|
|
1326
|
+
return msgMatch || respMatch || toolMatch || sysMatch;
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
filtered.sort((a, b) => b.timestamp - a.timestamp);
|
|
1330
|
+
const total = filtered.length;
|
|
1331
|
+
const totalPages = Math.ceil(total / limit);
|
|
1332
|
+
const start = (page - 1) * limit;
|
|
1333
|
+
return {
|
|
1334
|
+
entries: filtered.slice(start, start + limit),
|
|
1335
|
+
total,
|
|
1336
|
+
page,
|
|
1337
|
+
limit,
|
|
1338
|
+
totalPages
|
|
1837
1339
|
};
|
|
1838
1340
|
}
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
//#region src/lib/hidden-models.ts
|
|
1842
|
-
/**
|
|
1843
|
-
* Hardcoded list of GitHub Copilot model ids that are hidden from listing
|
|
1844
|
-
* endpoints (the /v1/models response, the startup ASCII banner, and the
|
|
1845
|
-
* --claude-code interactive prompts), unless `--show-all-models` is passed.
|
|
1846
|
-
*
|
|
1847
|
-
* Note: this is a DISPLAY filter only. Explicit POSTs to handler endpoints
|
|
1848
|
-
* with a hidden id are NOT rejected — they pass through to upstream verbatim.
|
|
1849
|
-
*
|
|
1850
|
-
* Bumping the list requires a code change + release. No env var, no config
|
|
1851
|
-
* file, no CLI append interface.
|
|
1852
|
-
*/
|
|
1853
|
-
const HIDDEN_MODEL_IDS = new Set([
|
|
1854
|
-
"gpt-3.5-turbo",
|
|
1855
|
-
"gpt-3.5-turbo-0613",
|
|
1856
|
-
"gpt-4",
|
|
1857
|
-
"gpt-4-0613",
|
|
1858
|
-
"gpt-4-0125-preview",
|
|
1859
|
-
"gpt-4o",
|
|
1860
|
-
"gpt-4o-mini",
|
|
1861
|
-
"gpt-4-o-preview",
|
|
1862
|
-
"gpt-4o-2024-05-13",
|
|
1863
|
-
"gpt-4o-2024-08-06",
|
|
1864
|
-
"gpt-4o-2024-11-20",
|
|
1865
|
-
"gpt-4o-mini-2024-07-18",
|
|
1866
|
-
"gpt-4.1",
|
|
1867
|
-
"gpt-4.1-2025-04-14",
|
|
1868
|
-
"gpt-41-copilot",
|
|
1869
|
-
"gpt-5-mini",
|
|
1870
|
-
"gpt-5.3-codex",
|
|
1871
|
-
"gpt-5.4",
|
|
1872
|
-
"text-embedding-ada-002",
|
|
1873
|
-
"text-embedding-3-small",
|
|
1874
|
-
"text-embedding-3-small-inference",
|
|
1875
|
-
"gemini-2.5-pro",
|
|
1876
|
-
"gemini-3-flash-preview",
|
|
1877
|
-
"claude-opus-4.5",
|
|
1878
|
-
"claude-opus-4.6",
|
|
1879
|
-
"claude-opus-4.7-high",
|
|
1880
|
-
"claude-opus-4.7-xhigh",
|
|
1881
|
-
"claude-sonnet-4.5",
|
|
1882
|
-
"mai-code-1-flash-internal",
|
|
1883
|
-
"trajectory-compaction"
|
|
1884
|
-
]);
|
|
1885
|
-
function isHiddenModel(id, showAll) {
|
|
1886
|
-
if (showAll) return false;
|
|
1887
|
-
return HIDDEN_MODEL_IDS.has(id);
|
|
1341
|
+
function getEntry(id) {
|
|
1342
|
+
return entryIndex.get(id);
|
|
1888
1343
|
}
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
* Enables real-time updates when new requests are recorded.
|
|
1895
|
-
*/
|
|
1896
|
-
const clients = /* @__PURE__ */ new Set();
|
|
1897
|
-
function addClient(ws) {
|
|
1898
|
-
clients.add(ws);
|
|
1899
|
-
const msg = {
|
|
1900
|
-
type: "connected",
|
|
1901
|
-
data: { clientCount: clients.size },
|
|
1902
|
-
timestamp: Date.now()
|
|
1344
|
+
function getSessions() {
|
|
1345
|
+
const sessions = Array.from(historyState.sessions.values()).sort((a, b) => b.lastActivity - a.lastActivity);
|
|
1346
|
+
return {
|
|
1347
|
+
sessions,
|
|
1348
|
+
total: sessions.length
|
|
1903
1349
|
};
|
|
1904
|
-
ws.send(JSON.stringify(msg));
|
|
1905
|
-
}
|
|
1906
|
-
function removeClient(ws) {
|
|
1907
|
-
clients.delete(ws);
|
|
1908
|
-
}
|
|
1909
|
-
function getClientCount() {
|
|
1910
|
-
return clients.size;
|
|
1911
|
-
}
|
|
1912
|
-
function closeAllClients() {
|
|
1913
|
-
for (const client of clients) try {
|
|
1914
|
-
client.close(1001, "Server shutting down");
|
|
1915
|
-
} catch {}
|
|
1916
|
-
clients.clear();
|
|
1917
|
-
}
|
|
1918
|
-
function broadcast(message) {
|
|
1919
|
-
const data = JSON.stringify(message);
|
|
1920
|
-
for (const client of clients) try {
|
|
1921
|
-
if (client.readyState === WebSocket.OPEN) client.send(data);
|
|
1922
|
-
else clients.delete(client);
|
|
1923
|
-
} catch (error) {
|
|
1924
|
-
consola.debug("WebSocket send failed, removing client:", error);
|
|
1925
|
-
clients.delete(client);
|
|
1926
|
-
}
|
|
1927
1350
|
}
|
|
1928
|
-
function
|
|
1929
|
-
|
|
1930
|
-
broadcast({
|
|
1931
|
-
type: "entry_added",
|
|
1932
|
-
data: summary,
|
|
1933
|
-
timestamp: Date.now()
|
|
1934
|
-
});
|
|
1351
|
+
function getSession(id) {
|
|
1352
|
+
return historyState.sessions.get(id);
|
|
1935
1353
|
}
|
|
1936
|
-
function
|
|
1937
|
-
|
|
1938
|
-
broadcast({
|
|
1939
|
-
type: "entry_updated",
|
|
1940
|
-
data: summary,
|
|
1941
|
-
timestamp: Date.now()
|
|
1942
|
-
});
|
|
1354
|
+
function getSessionEntries(sessionId) {
|
|
1355
|
+
return historyState.entries.filter((e) => e.sessionId === sessionId).sort((a, b) => a.timestamp - b.timestamp);
|
|
1943
1356
|
}
|
|
1944
|
-
function
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
});
|
|
1357
|
+
function clearHistory() {
|
|
1358
|
+
historyState.entries = [];
|
|
1359
|
+
historyState.sessions = /* @__PURE__ */ new Map();
|
|
1360
|
+
historyState.currentSessionId = generateId$1();
|
|
1361
|
+
entryIndex.clear();
|
|
1362
|
+
notifyHistoryCleared();
|
|
1951
1363
|
}
|
|
1952
|
-
function
|
|
1953
|
-
if (
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
if (clients.size === 0) return;
|
|
1962
|
-
broadcast({
|
|
1963
|
-
type: "session_deleted",
|
|
1964
|
-
data: { sessionId },
|
|
1965
|
-
timestamp: Date.now()
|
|
1966
|
-
});
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
//#endregion
|
|
1970
|
-
//#region src/lib/history.ts
|
|
1971
|
-
function generateId$1() {
|
|
1972
|
-
return Date.now().toString(36) + Math.random().toString(36).slice(2, 9);
|
|
1973
|
-
}
|
|
1974
|
-
const historyState = {
|
|
1975
|
-
enabled: false,
|
|
1976
|
-
entries: [],
|
|
1977
|
-
sessions: /* @__PURE__ */ new Map(),
|
|
1978
|
-
currentSessionId: "",
|
|
1979
|
-
maxEntries: 1e3,
|
|
1980
|
-
sessionTimeoutMs: 1800 * 1e3
|
|
1981
|
-
};
|
|
1982
|
-
const entryIndex = /* @__PURE__ */ new Map();
|
|
1983
|
-
function initHistory(enabled, maxEntries) {
|
|
1984
|
-
historyState.enabled = enabled;
|
|
1985
|
-
historyState.maxEntries = maxEntries;
|
|
1986
|
-
historyState.entries = [];
|
|
1987
|
-
historyState.sessions = /* @__PURE__ */ new Map();
|
|
1988
|
-
historyState.currentSessionId = enabled ? generateId$1() : "";
|
|
1989
|
-
entryIndex.clear();
|
|
1990
|
-
}
|
|
1991
|
-
function isHistoryEnabled() {
|
|
1992
|
-
return historyState.enabled;
|
|
1993
|
-
}
|
|
1994
|
-
function getCurrentSession(endpoint) {
|
|
1995
|
-
const now = Date.now();
|
|
1996
|
-
if (historyState.currentSessionId) {
|
|
1997
|
-
const session = historyState.sessions.get(historyState.currentSessionId);
|
|
1998
|
-
if (session && now - session.lastActivity < historyState.sessionTimeoutMs) {
|
|
1999
|
-
session.lastActivity = now;
|
|
2000
|
-
return historyState.currentSessionId;
|
|
2001
|
-
}
|
|
2002
|
-
}
|
|
2003
|
-
const sessionId = generateId$1();
|
|
2004
|
-
historyState.currentSessionId = sessionId;
|
|
2005
|
-
historyState.sessions.set(sessionId, {
|
|
2006
|
-
id: sessionId,
|
|
2007
|
-
startTime: now,
|
|
2008
|
-
lastActivity: now,
|
|
2009
|
-
requestCount: 0,
|
|
2010
|
-
totalInputTokens: 0,
|
|
2011
|
-
totalOutputTokens: 0,
|
|
2012
|
-
models: [],
|
|
2013
|
-
endpoint
|
|
2014
|
-
});
|
|
2015
|
-
return sessionId;
|
|
2016
|
-
}
|
|
2017
|
-
function recordRequest(endpoint, request) {
|
|
2018
|
-
if (!historyState.enabled) return "";
|
|
2019
|
-
const sessionId = getCurrentSession(endpoint);
|
|
2020
|
-
const session = historyState.sessions.get(sessionId);
|
|
2021
|
-
if (!session) return "";
|
|
2022
|
-
const entry = {
|
|
2023
|
-
id: generateId$1(),
|
|
2024
|
-
sessionId,
|
|
2025
|
-
timestamp: Date.now(),
|
|
2026
|
-
endpoint,
|
|
2027
|
-
request: {
|
|
2028
|
-
model: request.model,
|
|
2029
|
-
messages: request.messages,
|
|
2030
|
-
stream: request.stream,
|
|
2031
|
-
tools: request.tools,
|
|
2032
|
-
max_tokens: request.max_tokens,
|
|
2033
|
-
temperature: request.temperature,
|
|
2034
|
-
system: request.system
|
|
2035
|
-
}
|
|
2036
|
-
};
|
|
2037
|
-
historyState.entries.push(entry);
|
|
2038
|
-
entryIndex.set(entry.id, entry);
|
|
2039
|
-
session.requestCount++;
|
|
2040
|
-
if (!session.models.includes(request.model)) session.models.push(request.model);
|
|
2041
|
-
if (request.tools && request.tools.length > 0) {
|
|
2042
|
-
if (!session.toolsUsed) session.toolsUsed = [];
|
|
2043
|
-
for (const tool of request.tools) if (!session.toolsUsed.includes(tool.name)) session.toolsUsed.push(tool.name);
|
|
2044
|
-
}
|
|
2045
|
-
while (historyState.maxEntries > 0 && historyState.entries.length > historyState.maxEntries) {
|
|
2046
|
-
const removed = historyState.entries.shift();
|
|
2047
|
-
if (removed) {
|
|
2048
|
-
entryIndex.delete(removed.id);
|
|
2049
|
-
if (historyState.entries.filter((e) => e.sessionId === removed.sessionId).length === 0) historyState.sessions.delete(removed.sessionId);
|
|
2050
|
-
}
|
|
2051
|
-
}
|
|
2052
|
-
notifyEntryAdded({
|
|
2053
|
-
id: entry.id,
|
|
2054
|
-
endpoint,
|
|
2055
|
-
model: request.model,
|
|
2056
|
-
stream: request.stream,
|
|
2057
|
-
timestamp: entry.timestamp
|
|
2058
|
-
});
|
|
2059
|
-
return entry.id;
|
|
2060
|
-
}
|
|
2061
|
-
function recordResponse(id, response, durationMs) {
|
|
2062
|
-
if (!historyState.enabled || !id) return;
|
|
2063
|
-
const entry = entryIndex.get(id);
|
|
2064
|
-
if (entry) {
|
|
2065
|
-
entry.response = response;
|
|
2066
|
-
entry.durationMs = durationMs;
|
|
2067
|
-
const session = historyState.sessions.get(entry.sessionId);
|
|
2068
|
-
if (session) {
|
|
2069
|
-
session.totalInputTokens += response.usage.input_tokens;
|
|
2070
|
-
session.totalOutputTokens += response.usage.output_tokens;
|
|
2071
|
-
session.lastActivity = Date.now();
|
|
2072
|
-
}
|
|
2073
|
-
notifyEntryUpdated({
|
|
2074
|
-
id: entry.id,
|
|
2075
|
-
endpoint: entry.endpoint,
|
|
2076
|
-
model: response.model,
|
|
2077
|
-
success: response.success,
|
|
2078
|
-
durationMs,
|
|
2079
|
-
inputTokens: response.usage.input_tokens,
|
|
2080
|
-
outputTokens: response.usage.output_tokens
|
|
2081
|
-
});
|
|
2082
|
-
notifyStatsUpdated({
|
|
2083
|
-
totalRequests: historyState.entries.length,
|
|
2084
|
-
totalInputTokens: session?.totalInputTokens ?? 0,
|
|
2085
|
-
totalOutputTokens: session?.totalOutputTokens ?? 0
|
|
2086
|
-
});
|
|
2087
|
-
}
|
|
2088
|
-
}
|
|
2089
|
-
function getHistory(options = {}) {
|
|
2090
|
-
const { page = 1, limit = 50, model, endpoint, status, from, to, search, sessionId } = options;
|
|
2091
|
-
let filtered = [...historyState.entries];
|
|
2092
|
-
if (sessionId) filtered = filtered.filter((e) => e.sessionId === sessionId);
|
|
2093
|
-
if (model) {
|
|
2094
|
-
const modelLower = model.toLowerCase();
|
|
2095
|
-
filtered = filtered.filter((e) => e.request.model.toLowerCase().includes(modelLower) || e.response?.model.toLowerCase().includes(modelLower));
|
|
2096
|
-
}
|
|
2097
|
-
if (endpoint) filtered = filtered.filter((e) => e.endpoint === endpoint);
|
|
2098
|
-
let effectiveStatus = status;
|
|
2099
|
-
const legacySuccess = options.success;
|
|
2100
|
-
if (!effectiveStatus && legacySuccess !== void 0) effectiveStatus = legacySuccess ? "success" : "error";
|
|
2101
|
-
switch (effectiveStatus) {
|
|
2102
|
-
case "success":
|
|
2103
|
-
filtered = filtered.filter((e) => e.response?.success === true);
|
|
2104
|
-
break;
|
|
2105
|
-
case "error":
|
|
2106
|
-
filtered = filtered.filter((e) => e.response !== void 0 && !e.response.success);
|
|
2107
|
-
break;
|
|
2108
|
-
case "pending":
|
|
2109
|
-
filtered = filtered.filter((e) => !e.response);
|
|
2110
|
-
break;
|
|
2111
|
-
default: break;
|
|
2112
|
-
}
|
|
2113
|
-
if (from) filtered = filtered.filter((e) => e.timestamp >= from);
|
|
2114
|
-
if (to) filtered = filtered.filter((e) => e.timestamp <= to);
|
|
2115
|
-
if (search) {
|
|
2116
|
-
const searchLower = search.toLowerCase();
|
|
2117
|
-
filtered = filtered.filter((e) => {
|
|
2118
|
-
const msgMatch = e.request.messages.some((m) => {
|
|
2119
|
-
if (typeof m.content === "string") return m.content.toLowerCase().includes(searchLower);
|
|
2120
|
-
if (Array.isArray(m.content)) return m.content.some((c) => c.text && c.text.toLowerCase().includes(searchLower));
|
|
2121
|
-
return false;
|
|
2122
|
-
});
|
|
2123
|
-
const respMatch = e.response?.content && typeof e.response.content.content === "string" && e.response.content.content.toLowerCase().includes(searchLower);
|
|
2124
|
-
const toolMatch = e.response?.toolCalls?.some((t) => t.name.toLowerCase().includes(searchLower));
|
|
2125
|
-
const sysMatch = e.request.system?.toLowerCase().includes(searchLower);
|
|
2126
|
-
return msgMatch || respMatch || toolMatch || sysMatch;
|
|
2127
|
-
});
|
|
2128
|
-
}
|
|
2129
|
-
filtered.sort((a, b) => b.timestamp - a.timestamp);
|
|
2130
|
-
const total = filtered.length;
|
|
2131
|
-
const totalPages = Math.ceil(total / limit);
|
|
2132
|
-
const start = (page - 1) * limit;
|
|
2133
|
-
return {
|
|
2134
|
-
entries: filtered.slice(start, start + limit),
|
|
2135
|
-
total,
|
|
2136
|
-
page,
|
|
2137
|
-
limit,
|
|
2138
|
-
totalPages
|
|
2139
|
-
};
|
|
2140
|
-
}
|
|
2141
|
-
function getEntry(id) {
|
|
2142
|
-
return entryIndex.get(id);
|
|
2143
|
-
}
|
|
2144
|
-
function getSessions() {
|
|
2145
|
-
const sessions = Array.from(historyState.sessions.values()).sort((a, b) => b.lastActivity - a.lastActivity);
|
|
2146
|
-
return {
|
|
2147
|
-
sessions,
|
|
2148
|
-
total: sessions.length
|
|
2149
|
-
};
|
|
2150
|
-
}
|
|
2151
|
-
function getSession(id) {
|
|
2152
|
-
return historyState.sessions.get(id);
|
|
2153
|
-
}
|
|
2154
|
-
function getSessionEntries(sessionId) {
|
|
2155
|
-
return historyState.entries.filter((e) => e.sessionId === sessionId).sort((a, b) => a.timestamp - b.timestamp);
|
|
2156
|
-
}
|
|
2157
|
-
function clearHistory() {
|
|
2158
|
-
historyState.entries = [];
|
|
2159
|
-
historyState.sessions = /* @__PURE__ */ new Map();
|
|
2160
|
-
historyState.currentSessionId = generateId$1();
|
|
2161
|
-
entryIndex.clear();
|
|
2162
|
-
notifyHistoryCleared();
|
|
2163
|
-
}
|
|
2164
|
-
function deleteSession(sessionId) {
|
|
2165
|
-
if (!historyState.sessions.has(sessionId)) return false;
|
|
2166
|
-
const removedEntries = historyState.entries.filter((e) => e.sessionId === sessionId);
|
|
2167
|
-
historyState.entries = historyState.entries.filter((e) => e.sessionId !== sessionId);
|
|
2168
|
-
for (const e of removedEntries) entryIndex.delete(e.id);
|
|
2169
|
-
historyState.sessions.delete(sessionId);
|
|
2170
|
-
if (historyState.currentSessionId === sessionId) historyState.currentSessionId = generateId$1();
|
|
2171
|
-
notifySessionDeleted(sessionId);
|
|
2172
|
-
return true;
|
|
1364
|
+
function deleteSession(sessionId) {
|
|
1365
|
+
if (!historyState.sessions.has(sessionId)) return false;
|
|
1366
|
+
const removedEntries = historyState.entries.filter((e) => e.sessionId === sessionId);
|
|
1367
|
+
historyState.entries = historyState.entries.filter((e) => e.sessionId !== sessionId);
|
|
1368
|
+
for (const e of removedEntries) entryIndex.delete(e.id);
|
|
1369
|
+
historyState.sessions.delete(sessionId);
|
|
1370
|
+
if (historyState.currentSessionId === sessionId) historyState.currentSessionId = generateId$1();
|
|
1371
|
+
notifySessionDeleted(sessionId);
|
|
1372
|
+
return true;
|
|
2173
1373
|
}
|
|
2174
1374
|
function getStats() {
|
|
2175
1375
|
const entries = historyState.entries;
|
|
@@ -2516,6 +1716,7 @@ async function gracefulShutdown(signal, deps) {
|
|
|
2516
1716
|
deps?.contextManager?.stopReaper();
|
|
2517
1717
|
} catch {}
|
|
2518
1718
|
stopMemoryPressureMonitor();
|
|
1719
|
+
stopEventLoopLagMonitor();
|
|
2519
1720
|
stopRefresh();
|
|
2520
1721
|
const wsClients = getWsCount();
|
|
2521
1722
|
if (wsClients > 0) {
|
|
@@ -2594,6 +1795,944 @@ function setupShutdownHandlers() {
|
|
|
2594
1795
|
process.on("SIGTERM", () => handler("SIGTERM"));
|
|
2595
1796
|
}
|
|
2596
1797
|
|
|
1798
|
+
//#endregion
|
|
1799
|
+
//#region src/lib/adaptive-rate-limiter.ts
|
|
1800
|
+
const DEFAULT_CONFIG$1 = {
|
|
1801
|
+
baseRetryIntervalSeconds: 1,
|
|
1802
|
+
maxRetryIntervalSeconds: 60,
|
|
1803
|
+
maxRetries: 8
|
|
1804
|
+
};
|
|
1805
|
+
/**
|
|
1806
|
+
* Per-request adaptive rate limiter. Retries the calling request on 429 with
|
|
1807
|
+
* exponential backoff without blocking any other in-flight request.
|
|
1808
|
+
*/
|
|
1809
|
+
var AdaptiveRateLimiter = class {
|
|
1810
|
+
config;
|
|
1811
|
+
constructor(config = {}) {
|
|
1812
|
+
this.config = {
|
|
1813
|
+
...DEFAULT_CONFIG$1,
|
|
1814
|
+
...config
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Execute a request, retrying ONLY this request on 429 with exponential
|
|
1819
|
+
* backoff. Never blocks other concurrent requests.
|
|
1820
|
+
*/
|
|
1821
|
+
async execute(fn) {
|
|
1822
|
+
let attempt = 0;
|
|
1823
|
+
let backoffMs = 0;
|
|
1824
|
+
for (;;) try {
|
|
1825
|
+
const result = await fn();
|
|
1826
|
+
if (attempt > 0) addTiming(TIMING.LIMITER_RETRIES, attempt);
|
|
1827
|
+
return {
|
|
1828
|
+
result,
|
|
1829
|
+
queueWaitMs: backoffMs
|
|
1830
|
+
};
|
|
1831
|
+
} catch (error) {
|
|
1832
|
+
const { isRateLimit, retryAfter } = this.isRateLimitError(error);
|
|
1833
|
+
if (!isRateLimit || attempt >= this.config.maxRetries || getIsShuttingDown()) throw error;
|
|
1834
|
+
attempt++;
|
|
1835
|
+
const delayMs = retryAfter !== void 0 && retryAfter > 0 ? retryAfter * 1e3 : this.withJitter(this.backoffSeconds(attempt) * 1e3);
|
|
1836
|
+
backoffMs += delayMs;
|
|
1837
|
+
await this.sleep(delayMs);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Check if an error is a rate limit error (429) and extract Retry-After if available.
|
|
1842
|
+
*/
|
|
1843
|
+
isRateLimitError(error) {
|
|
1844
|
+
if (error && typeof error === "object") {
|
|
1845
|
+
if ("status" in error && error.status === 429) return {
|
|
1846
|
+
isRateLimit: true,
|
|
1847
|
+
retryAfter: this.extractRetryAfter(error)
|
|
1848
|
+
};
|
|
1849
|
+
if ("responseText" in error && typeof error.responseText === "string") try {
|
|
1850
|
+
const parsed = JSON.parse(error.responseText);
|
|
1851
|
+
if (parsed && typeof parsed === "object" && "error" in parsed && parsed.error && typeof parsed.error === "object" && "code" in parsed.error && parsed.error.code === "rate_limited") return { isRateLimit: true };
|
|
1852
|
+
} catch {}
|
|
1853
|
+
}
|
|
1854
|
+
return { isRateLimit: false };
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1857
|
+
* Extract Retry-After value from error response.
|
|
1858
|
+
*/
|
|
1859
|
+
extractRetryAfter(error) {
|
|
1860
|
+
if (!error || typeof error !== "object") return void 0;
|
|
1861
|
+
if ("responseText" in error && typeof error.responseText === "string") try {
|
|
1862
|
+
const parsed = JSON.parse(error.responseText);
|
|
1863
|
+
if (parsed && typeof parsed === "object" && "retry_after" in parsed && typeof parsed.retry_after === "number") return parsed.retry_after;
|
|
1864
|
+
if (parsed && typeof parsed === "object" && "error" in parsed && parsed.error && typeof parsed.error === "object" && "retry_after" in parsed.error && typeof parsed.error.retry_after === "number") return parsed.error.retry_after;
|
|
1865
|
+
} catch {}
|
|
1866
|
+
}
|
|
1867
|
+
/** Exponential backoff (seconds) for the given retry attempt, capped. */
|
|
1868
|
+
backoffSeconds(attempt) {
|
|
1869
|
+
const backoff = this.config.baseRetryIntervalSeconds * 2 ** (attempt - 1);
|
|
1870
|
+
return Math.min(backoff, this.config.maxRetryIntervalSeconds);
|
|
1871
|
+
}
|
|
1872
|
+
/** Apply ±20% jitter so simultaneous retries don't hammer upstream in lockstep. */
|
|
1873
|
+
withJitter(ms) {
|
|
1874
|
+
const factor = .8 + Math.random() * .4;
|
|
1875
|
+
return Math.round(ms * factor);
|
|
1876
|
+
}
|
|
1877
|
+
sleep(ms) {
|
|
1878
|
+
return new Promise((resolve) => {
|
|
1879
|
+
const timer = setTimeout(resolve, ms);
|
|
1880
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* No global queue in per-request mode, so there is nothing to reject. Retained
|
|
1885
|
+
* for the shutdown call site (returns 0 = nothing drained).
|
|
1886
|
+
*/
|
|
1887
|
+
rejectQueued() {
|
|
1888
|
+
return 0;
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
let rateLimiterInstance = null;
|
|
1892
|
+
/**
|
|
1893
|
+
* Initialize the adaptive rate limiter with configuration.
|
|
1894
|
+
*/
|
|
1895
|
+
function initAdaptiveRateLimiter(config = {}) {
|
|
1896
|
+
rateLimiterInstance = new AdaptiveRateLimiter(config);
|
|
1897
|
+
const resolved = {
|
|
1898
|
+
...DEFAULT_CONFIG$1,
|
|
1899
|
+
...config
|
|
1900
|
+
};
|
|
1901
|
+
consola.info(`[RateLimiter] Initialized (per-request backoff: ${resolved.baseRetryIntervalSeconds}s-${resolved.maxRetryIntervalSeconds}s, max ${resolved.maxRetries} retries)`);
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Get the rate limiter instance.
|
|
1905
|
+
*/
|
|
1906
|
+
function getAdaptiveRateLimiter() {
|
|
1907
|
+
return rateLimiterInstance;
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Execute a request with adaptive rate limiting. If the limiter is not
|
|
1911
|
+
* initialized, executes immediately. Returns the result along with backoff wait.
|
|
1912
|
+
*/
|
|
1913
|
+
async function executeWithAdaptiveRateLimit(fn) {
|
|
1914
|
+
if (!rateLimiterInstance) return {
|
|
1915
|
+
result: await fn(),
|
|
1916
|
+
queueWaitMs: 0
|
|
1917
|
+
};
|
|
1918
|
+
return rateLimiterInstance.execute(fn);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
//#endregion
|
|
1922
|
+
//#region src/lib/auth-gate.ts
|
|
1923
|
+
/**
|
|
1924
|
+
* Auth gate — the inbound authentication decision point for the proxy.
|
|
1925
|
+
*
|
|
1926
|
+
* Protects this proxy's *inbound* surface with a configured **Proxy API key**
|
|
1927
|
+
* (NOT the outbound GitHub OAuth token or Copilot token). The decision logic
|
|
1928
|
+
* is expressed as pure functions so it can be unit-tested without booting the
|
|
1929
|
+
* server or reaching upstream.
|
|
1930
|
+
*/
|
|
1931
|
+
/**
|
|
1932
|
+
* Extract candidate presented credential values from request headers.
|
|
1933
|
+
*
|
|
1934
|
+
* Two header shapes are read, and **both** contribute candidates when present
|
|
1935
|
+
* (compare-all-present) so neither is silently ignored in favor of the other —
|
|
1936
|
+
* a later any-match over the candidates decides acceptance:
|
|
1937
|
+
* - `Authorization`: the scheme prefix is stripped case-insensitively
|
|
1938
|
+
* (`Bearer ` / `bearer ` …) because the scheme is case-insensitive per
|
|
1939
|
+
* RFC 7235, while the secret itself is case-sensitive. A bare value with no
|
|
1940
|
+
* scheme prefix is tolerated and returned verbatim.
|
|
1941
|
+
* - `x-api-key` (Issue 02): the Anthropic-native header. Taken verbatim — no
|
|
1942
|
+
* scheme stripping (a value that happens to start with `Bearer ` is kept
|
|
1943
|
+
* as-is).
|
|
1944
|
+
*
|
|
1945
|
+
* Order is `[Authorization, x-api-key]` for any present header; absent headers
|
|
1946
|
+
* contribute nothing.
|
|
1947
|
+
*/
|
|
1948
|
+
function extractCredentials(headers) {
|
|
1949
|
+
const candidates = [];
|
|
1950
|
+
const authorization = headers.get("authorization");
|
|
1951
|
+
if (authorization !== null) candidates.push(authorization.replace(/^Bearer\s+/i, ""));
|
|
1952
|
+
const apiKey = headers.get("x-api-key");
|
|
1953
|
+
if (apiKey !== null) candidates.push(apiKey);
|
|
1954
|
+
return candidates;
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Hard-coded exemption set: the liveness (`/`) and readiness (`/health`)
|
|
1958
|
+
* endpoints are reachable without a key so container orchestration probes are
|
|
1959
|
+
* never blocked. Everything else is protected (fail-closed) — unknown / future
|
|
1960
|
+
* routes default to protected.
|
|
1961
|
+
*
|
|
1962
|
+
* Matching is by **exact path**, with a trailing slash tolerated (so `/health/`
|
|
1963
|
+
* is exempt too) and `/` itself handled explicitly. Prefix matching is
|
|
1964
|
+
* deliberately avoided: `/healthz` or `/health/extra` must NOT be exempt. The
|
|
1965
|
+
* server registers a matching `/health/` route, so an exempt `/health/` request
|
|
1966
|
+
* resolves to the readiness handler rather than 404ing.
|
|
1967
|
+
*/
|
|
1968
|
+
function isExemptPath(path) {
|
|
1969
|
+
if (path === "/") return true;
|
|
1970
|
+
return (path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path) === "/health";
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Compute the fixed-length sha256 digest (32 bytes) of the configured key.
|
|
1974
|
+
* The configured key is trimmed before hashing (config-side trim).
|
|
1975
|
+
*/
|
|
1976
|
+
function digestConfiguredKey(configuredKey) {
|
|
1977
|
+
return createHash("sha256").update(configuredKey.trim()).digest();
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Constant-time membership test: does any presented candidate match the
|
|
1981
|
+
* configured key?
|
|
1982
|
+
*
|
|
1983
|
+
* Each candidate is sha256'd to a fixed 32-byte digest and compared against the
|
|
1984
|
+
* configured digest. Hashing to a fixed length sidesteps the `RangeError` that
|
|
1985
|
+
* `crypto.timingSafeEqual` throws on length-mismatched buffers, so a
|
|
1986
|
+
* wrong-length presented value yields `false` rather than throwing.
|
|
1987
|
+
*/
|
|
1988
|
+
function matchesConfiguredKey(configuredDigest, candidates) {
|
|
1989
|
+
return candidates.some((candidate) => {
|
|
1990
|
+
return timingSafeEqual(createHash("sha256").update(candidate).digest(), configuredDigest);
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Resolve the inbound Proxy API key from its two operator-facing sources,
|
|
1995
|
+
* applying the precedence + normalization contract (Issue 03):
|
|
1996
|
+
*
|
|
1997
|
+
* - `--api-key` flag (`flag`) and `COPILOT_API_KEY` env (`env`) are each
|
|
1998
|
+
* **trimmed first**; a trimmed-empty source (`""`, whitespace, or
|
|
1999
|
+
* `undefined`) counts as **not provided**.
|
|
2000
|
+
* - When both provide a non-empty value, the **flag wins** (env ignored).
|
|
2001
|
+
* - When only one provides a non-empty value, that one is used.
|
|
2002
|
+
* - When neither does, `key` is `undefined` and `source` is `"none"` → auth
|
|
2003
|
+
* stays disabled (same as the no-`--api-key` default).
|
|
2004
|
+
*
|
|
2005
|
+
* Pure: it reads nothing from `process.env` itself (the caller passes the env
|
|
2006
|
+
* value in), so it is fully unit-testable and the precedence logic is decoupled
|
|
2007
|
+
* from how the values are sourced.
|
|
2008
|
+
*/
|
|
2009
|
+
function resolveProxyApiKey(sources) {
|
|
2010
|
+
const flag = sources.flag?.trim() ?? "";
|
|
2011
|
+
if (flag !== "") return {
|
|
2012
|
+
key: flag,
|
|
2013
|
+
source: "flag"
|
|
2014
|
+
};
|
|
2015
|
+
const env = sources.env?.trim() ?? "";
|
|
2016
|
+
if (env !== "") return {
|
|
2017
|
+
key: env,
|
|
2018
|
+
source: "env"
|
|
2019
|
+
};
|
|
2020
|
+
return {
|
|
2021
|
+
key: void 0,
|
|
2022
|
+
source: "none"
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
/**
|
|
2026
|
+
* Resolve the hostname the server will *actually* bind to, decided at the CLI
|
|
2027
|
+
* edge with flag-over-env precedence and a **safe loopback default** — the same
|
|
2028
|
+
* flag-over-env shape this codebase already uses to reconcile a CLI flag with
|
|
2029
|
+
* its env twin (`--api-key`/`COPILOT_API_KEY`, `--github-token`/`GH_TOKEN`).
|
|
2030
|
+
*
|
|
2031
|
+
* - `--host` flag wins when present; otherwise the `HOST` env; otherwise the
|
|
2032
|
+
* default `127.0.0.1`.
|
|
2033
|
+
* - The default is **loopback, not all-interfaces**: an unconfigured instance
|
|
2034
|
+
* must not expose `/token` (which echoes the plaintext Copilot token) and the
|
|
2035
|
+
* otherwise-unauthenticated API to the whole network. Binding every interface
|
|
2036
|
+
* is now an explicit opt-in — pass `--host 0.0.0.0` (or `HOST=0.0.0.0`).
|
|
2037
|
+
* - **flag vs env asymmetry on a blank value** (the security-critical part): an
|
|
2038
|
+
* explicit `--host` flag is the operator's deliberate choice, so a blank flag
|
|
2039
|
+
* (`--host ""` / whitespace) is taken as the wildcard-bind escape hatch and
|
|
2040
|
+
* canonicalized to an explicit `0.0.0.0` (rather than left as `""` to lean on
|
|
2041
|
+
* srvx's undocumented empty-string handling). But a *set-but-blank* `HOST` env
|
|
2042
|
+
* (`HOST=`, or `HOST=$UNSET` in a shell / compose where the var is unset →
|
|
2043
|
+
* empty — NOT a deliberate keystroke) is accidental plumbing, so it is treated
|
|
2044
|
+
* as **not provided** and falls through to the loopback default. This mirrors
|
|
2045
|
+
* `resolveProxyApiKey` trimming `""` to not-provided, so an empty `HOST` can't
|
|
2046
|
+
* silently reopen the all-interfaces-unauthenticated exposure the loopback
|
|
2047
|
+
* default exists to prevent.
|
|
2048
|
+
* - **Both sources are trimmed**: a padded `--host " 10.0.0.5 "` or
|
|
2049
|
+
* `HOST=" 10.0.0.5 "` would otherwise reach the socket bind verbatim and fail
|
|
2050
|
+
* with `ENOTFOUND`. Trimming also decides blank-ness for the rules above.
|
|
2051
|
+
*
|
|
2052
|
+
* `env` is passed in (not read here) to keep the function pure and unit-testable.
|
|
2053
|
+
*/
|
|
2054
|
+
function resolveBindHost(flag, env) {
|
|
2055
|
+
if (flag !== void 0) {
|
|
2056
|
+
const trimmed = flag.trim();
|
|
2057
|
+
return trimmed === "" ? "0.0.0.0" : trimmed;
|
|
2058
|
+
}
|
|
2059
|
+
const envTrimmed = env?.trim() ?? "";
|
|
2060
|
+
if (envTrimmed !== "") return envTrimmed;
|
|
2061
|
+
return "127.0.0.1";
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Resolve the address the server will *actually* bind to for the startup banner
|
|
2065
|
+
* (Issue 04).
|
|
2066
|
+
*
|
|
2067
|
+
* Mirrors srvx's own host resolution EXACTLY so the banner reports the TRUE bind
|
|
2068
|
+
* rather than a guess that could diverge from what srvx passes to the runtime.
|
|
2069
|
+
* srvx computes `hostname = opts.hostname ?? process.env.HOST` (a raw nullish
|
|
2070
|
+
* coalesce — no trimming, no empty-string special-casing), and start.ts passes
|
|
2071
|
+
* `hostname: options.host`. So:
|
|
2072
|
+
* - an explicit `--host` (even `""` / whitespace) is what srvx uses verbatim —
|
|
2073
|
+
* it does NOT fall back to HOST once `opts.hostname` is a non-null string;
|
|
2074
|
+
* - only an absent (`undefined`) `--host` lets srvx fall back to `HOST`;
|
|
2075
|
+
* - when the coalesced value is `undefined` or empty, the runtime binds all
|
|
2076
|
+
* interfaces, which we report as the explicit `0.0.0.0` so a wide-open bind
|
|
2077
|
+
* is unmistakable (srvx renders the same bind as "localhost (all
|
|
2078
|
+
* interfaces)").
|
|
2079
|
+
*
|
|
2080
|
+
* Critically, the resolved non-empty value is returned VERBATIM (not trimmed):
|
|
2081
|
+
* srvx hands the runtime exactly that string, so the banner must report exactly
|
|
2082
|
+
* that string — trimming here would make the banner claim a different address
|
|
2083
|
+
* than the one actually bound. `env` is passed in (not read) to keep the
|
|
2084
|
+
* function pure and unit-testable.
|
|
2085
|
+
*/
|
|
2086
|
+
function resolveBindAddress(host, env) {
|
|
2087
|
+
const resolved = host ?? env;
|
|
2088
|
+
if (resolved === void 0 || resolved === "") return "0.0.0.0";
|
|
2089
|
+
return resolved;
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Resolve the CLIENT-FACING host for generated configs and viewer links
|
|
2093
|
+
* (Issue 04), derived from the SAME srvx host resolution as the banner so the
|
|
2094
|
+
* two never disagree about what was bound — and formatted as a valid URL
|
|
2095
|
+
* authority so the links actually parse.
|
|
2096
|
+
*
|
|
2097
|
+
* Two differences from {@link resolveBindAddress}:
|
|
2098
|
+
* - All-interfaces rendering: a wildcard bind (`0.0.0.0` / `::` / `[::]` /
|
|
2099
|
+
* empty) is not a connectable target, so it maps to `localhost` for URLs a
|
|
2100
|
+
* client will actually dial (matching srvx's "localhost (all interfaces)"
|
|
2101
|
+
* presentation). A narrowed bind (e.g. `127.0.0.1`, `192.168.1.10`, an IPv6
|
|
2102
|
+
* address) is kept so generated links point at the real interface — fixing
|
|
2103
|
+
* the prior bug where setting `HOST` (with `--host` omitted) yielded
|
|
2104
|
+
* `http://localhost:<port>` links the narrowed bind wasn't listening on.
|
|
2105
|
+
* - IPv6 bracketing: a literal IPv6 host (contains `:`) is wrapped in `[...]`,
|
|
2106
|
+
* exactly as srvx's own `fmtURL` does, so `http://[2001:db8::1]:<port>` is a
|
|
2107
|
+
* valid authority rather than the unparseable `http://2001:db8::1:<port>`.
|
|
2108
|
+
*
|
|
2109
|
+
* Returns a host token ready to drop into `http://<token>:<port>`.
|
|
2110
|
+
*/
|
|
2111
|
+
function resolveClientHost(host, env) {
|
|
2112
|
+
const bind = resolveBindAddress(host, env);
|
|
2113
|
+
if (bind === "0.0.0.0" || bind === "::" || bind === "[::]") return "localhost";
|
|
2114
|
+
if (bind.includes(":") && !bind.startsWith("[")) return `[${bind}]`;
|
|
2115
|
+
return bind;
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Build the inbound-auth startup banner lines (Issue 04).
|
|
2119
|
+
*
|
|
2120
|
+
* Returns the human-readable lines the proxy prints at boot so operators can see,
|
|
2121
|
+
* at a glance, the security posture of *this* instance:
|
|
2122
|
+
* - auth ON → `认证开启`, plus the key's origin (`flag` / `env`), plus the real
|
|
2123
|
+
* bind address.
|
|
2124
|
+
* - auth OFF → `认证关闭`, plus the real bind address (so a careless all-
|
|
2125
|
+
* interfaces bind without auth is visible).
|
|
2126
|
+
*
|
|
2127
|
+
* The configured key value is **never** an input here, so it can never leak into
|
|
2128
|
+
* the banner — the function only knows the *source* tag, not the secret. Pure
|
|
2129
|
+
* (string in → strings out) so the banner copy is pinned by unit tests.
|
|
2130
|
+
*/
|
|
2131
|
+
function buildStartupAuthLines(params) {
|
|
2132
|
+
const { source, bindAddress } = params;
|
|
2133
|
+
return [source === "none" ? `Inbound auth: 认证关闭 (no proxy API key configured)` : `Inbound auth: 认证开启 (source: ${source})`, `Binding to: ${bindAddress}`];
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* Configure the proxy API key on global state from a raw configured value.
|
|
2137
|
+
*
|
|
2138
|
+
* The value is trimmed; a trimmed-empty value (or `undefined`) is treated as
|
|
2139
|
+
* "not provided" → auth stays disabled. Otherwise the precomputed digest is
|
|
2140
|
+
* stored on state (presence === enabled). Returns whether auth is enabled.
|
|
2141
|
+
*
|
|
2142
|
+
* The `--api-key` flag and `COPILOT_API_KEY` env source are reconciled upstream
|
|
2143
|
+
* by `resolveProxyApiKey` (flag-over-env precedence, Issue 03); this function
|
|
2144
|
+
* receives only the already-resolved value.
|
|
2145
|
+
*/
|
|
2146
|
+
function configureProxyApiKey(rawKey) {
|
|
2147
|
+
const trimmed = rawKey?.trim() ?? "";
|
|
2148
|
+
if (trimmed === "") {
|
|
2149
|
+
state.proxyApiKeyDigest = void 0;
|
|
2150
|
+
return false;
|
|
2151
|
+
}
|
|
2152
|
+
state.proxyApiKeyDigest = digestConfiguredKey(trimmed);
|
|
2153
|
+
return true;
|
|
2154
|
+
}
|
|
2155
|
+
/**
|
|
2156
|
+
* Path → auth-family selector (Issue 02).
|
|
2157
|
+
*
|
|
2158
|
+
* The Anthropic-native surface is `/v1/messages` and its `count_tokens`
|
|
2159
|
+
* subpath; both map to the Anthropic family so a native Anthropic client gets
|
|
2160
|
+
* the `authentication_error` body. Everything else — including every other
|
|
2161
|
+
* `/v1/…` endpoint and any unknown / future route — defaults to the OpenAI
|
|
2162
|
+
* family.
|
|
2163
|
+
*
|
|
2164
|
+
* Matching is by **exact path** (a trailing slash tolerated), deliberately not
|
|
2165
|
+
* a prefix test: the shared `/v1/` prefix must not sweep OpenAI-style endpoints
|
|
2166
|
+
* into the Anthropic family, and `/v1/messages-extra` or a deeper unexpected
|
|
2167
|
+
* subpath must not be misclassified either. This mirrors `isExemptPath`'s
|
|
2168
|
+
* exact-with-trailing-slash convention.
|
|
2169
|
+
*/
|
|
2170
|
+
function selectFamily(path) {
|
|
2171
|
+
const normalized = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
2172
|
+
if (normalized === "/v1/messages" || normalized === "/v1/messages/count_tokens") return "anthropic";
|
|
2173
|
+
return "openai";
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* OpenAI-family 401 response body. The literal field values are pinned by the
|
|
2177
|
+
* ADR so OpenAI-compatible SDKs recognize the failure as an auth error. The
|
|
2178
|
+
* same body is returned whether credentials were missing or wrong (no oracle).
|
|
2179
|
+
*/
|
|
2180
|
+
function unauthorizedOpenAIBody() {
|
|
2181
|
+
return { error: {
|
|
2182
|
+
message: "Invalid API key provided.",
|
|
2183
|
+
type: "invalid_request_error",
|
|
2184
|
+
code: "invalid_api_key",
|
|
2185
|
+
param: null
|
|
2186
|
+
} };
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Anthropic-family 401 response body (Issue 02). Shape is pinned so Anthropic
|
|
2190
|
+
* SDKs (and Claude Code via `/v1/messages`) recognize the failure as an auth
|
|
2191
|
+
* error: a top-level `{type:"error", error:{type:"authentication_error",
|
|
2192
|
+
* message}}`. As with the OpenAI body, missing and wrong credentials return the
|
|
2193
|
+
* identical body (no oracle).
|
|
2194
|
+
*/
|
|
2195
|
+
function unauthorizedAnthropicBody() {
|
|
2196
|
+
return {
|
|
2197
|
+
type: "error",
|
|
2198
|
+
error: {
|
|
2199
|
+
type: "authentication_error",
|
|
2200
|
+
message: "Invalid API key provided."
|
|
2201
|
+
}
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
/**
|
|
2205
|
+
* Global fail-closed authentication middleware.
|
|
2206
|
+
*
|
|
2207
|
+
* Registered after the request logger and CORS but before route dispatch.
|
|
2208
|
+
* Behavior:
|
|
2209
|
+
* - Disabled (no configured digest) → pass through unchanged (default).
|
|
2210
|
+
* - Exempt path (`/`, `/health`) → pass through.
|
|
2211
|
+
* - CORS preflight `OPTIONS` on a protected path → pass through so browser
|
|
2212
|
+
* preflight isn't mistaken for a 401 (blocking it surfaces as an opaque CORS
|
|
2213
|
+
* error, very hard to diagnose). Scoped to *actual* preflights — an
|
|
2214
|
+
* `OPTIONS` carrying `Access-Control-Request-Method` — rather than any
|
|
2215
|
+
* `OPTIONS`, so the bypass surface can't silently widen. Preflights carry no
|
|
2216
|
+
* protected payload, so this doesn't weaken fail-closed.
|
|
2217
|
+
* - Otherwise require a valid Proxy API key; on failure return 401 with a
|
|
2218
|
+
* `WWW-Authenticate: Bearer` header and a **family-appropriate** body —
|
|
2219
|
+
* Anthropic-family (`/v1/messages*`) gets the `authentication_error` shape,
|
|
2220
|
+
* everything else the OpenAI `invalid_api_key` shape (Issue 02). The family
|
|
2221
|
+
* only selects the body shape; it does not change what is protected. Missing
|
|
2222
|
+
* and wrong credentials return the field-identical body for that family.
|
|
2223
|
+
*/
|
|
2224
|
+
function authGate() {
|
|
2225
|
+
return async (c, next) => {
|
|
2226
|
+
const configuredDigest = state.proxyApiKeyDigest;
|
|
2227
|
+
if (!configuredDigest) return next();
|
|
2228
|
+
if (isExemptPath(c.req.path)) return next();
|
|
2229
|
+
if (c.req.method === "OPTIONS" && c.req.raw.headers.get("access-control-request-method") !== null) return next();
|
|
2230
|
+
if (matchesConfiguredKey(configuredDigest, extractCredentials(c.req.raw.headers))) return next();
|
|
2231
|
+
c.header("WWW-Authenticate", "Bearer");
|
|
2232
|
+
if (selectFamily(c.req.path) === "anthropic") return c.json(unauthorizedAnthropicBody(), 401);
|
|
2233
|
+
return c.json(unauthorizedOpenAIBody(), 401);
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
//#endregion
|
|
2238
|
+
//#region src/lib/context/request.ts
|
|
2239
|
+
let idCounter = 0;
|
|
2240
|
+
function createRequestContext(opts) {
|
|
2241
|
+
const id = `req_${Date.now()}_${++idCounter}`;
|
|
2242
|
+
const startTime = Date.now();
|
|
2243
|
+
const onEvent = opts.onEvent;
|
|
2244
|
+
let _state = "pending";
|
|
2245
|
+
let _originalRequest = null;
|
|
2246
|
+
let _response = null;
|
|
2247
|
+
let settled = false;
|
|
2248
|
+
function emit(event) {
|
|
2249
|
+
try {
|
|
2250
|
+
onEvent(event);
|
|
2251
|
+
} catch {}
|
|
2252
|
+
}
|
|
2253
|
+
const ctx = {
|
|
2254
|
+
id,
|
|
2255
|
+
tuiLogId: opts.tuiLogId,
|
|
2256
|
+
startTime,
|
|
2257
|
+
endpoint: opts.endpoint,
|
|
2258
|
+
get state() {
|
|
2259
|
+
return _state;
|
|
2260
|
+
},
|
|
2261
|
+
get durationMs() {
|
|
2262
|
+
return Date.now() - startTime;
|
|
2263
|
+
},
|
|
2264
|
+
get settled() {
|
|
2265
|
+
return settled;
|
|
2266
|
+
},
|
|
2267
|
+
get originalRequest() {
|
|
2268
|
+
return _originalRequest;
|
|
2269
|
+
},
|
|
2270
|
+
get response() {
|
|
2271
|
+
return _response;
|
|
2272
|
+
},
|
|
2273
|
+
setOriginalRequest(req) {
|
|
2274
|
+
_originalRequest = req;
|
|
2275
|
+
emit({
|
|
2276
|
+
type: "updated",
|
|
2277
|
+
context: ctx,
|
|
2278
|
+
field: "originalRequest"
|
|
2279
|
+
});
|
|
2280
|
+
},
|
|
2281
|
+
transition(newState) {
|
|
2282
|
+
const previousState = _state;
|
|
2283
|
+
_state = newState;
|
|
2284
|
+
emit({
|
|
2285
|
+
type: "state_changed",
|
|
2286
|
+
context: ctx,
|
|
2287
|
+
previousState
|
|
2288
|
+
});
|
|
2289
|
+
},
|
|
2290
|
+
complete(response) {
|
|
2291
|
+
if (settled) return;
|
|
2292
|
+
settled = true;
|
|
2293
|
+
_response = response;
|
|
2294
|
+
_state = "completed";
|
|
2295
|
+
emit({
|
|
2296
|
+
type: "completed",
|
|
2297
|
+
context: ctx,
|
|
2298
|
+
entry: ctx.toHistoryEntry()
|
|
2299
|
+
});
|
|
2300
|
+
},
|
|
2301
|
+
fail(model, error) {
|
|
2302
|
+
if (settled) return;
|
|
2303
|
+
settled = true;
|
|
2304
|
+
_response = {
|
|
2305
|
+
success: false,
|
|
2306
|
+
model,
|
|
2307
|
+
usage: {
|
|
2308
|
+
input_tokens: 0,
|
|
2309
|
+
output_tokens: 0
|
|
2310
|
+
},
|
|
2311
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2312
|
+
content: null
|
|
2313
|
+
};
|
|
2314
|
+
_state = "failed";
|
|
2315
|
+
emit({
|
|
2316
|
+
type: "failed",
|
|
2317
|
+
context: ctx,
|
|
2318
|
+
entry: ctx.toHistoryEntry()
|
|
2319
|
+
});
|
|
2320
|
+
},
|
|
2321
|
+
toHistoryEntry() {
|
|
2322
|
+
const entry = {
|
|
2323
|
+
id,
|
|
2324
|
+
endpoint: opts.endpoint,
|
|
2325
|
+
timestamp: startTime,
|
|
2326
|
+
durationMs: Date.now() - startTime,
|
|
2327
|
+
request: {
|
|
2328
|
+
model: _originalRequest?.model,
|
|
2329
|
+
messages: _originalRequest?.messages,
|
|
2330
|
+
stream: _originalRequest?.stream,
|
|
2331
|
+
tools: _originalRequest?.tools,
|
|
2332
|
+
system: _originalRequest?.system
|
|
2333
|
+
}
|
|
2334
|
+
};
|
|
2335
|
+
if (_response) entry.response = _response;
|
|
2336
|
+
return entry;
|
|
2337
|
+
}
|
|
2338
|
+
};
|
|
2339
|
+
return ctx;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
//#endregion
|
|
2343
|
+
//#region src/lib/context/manager.ts
|
|
2344
|
+
/**
|
|
2345
|
+
* RequestContextManager — Active request management
|
|
2346
|
+
*
|
|
2347
|
+
* Manages all in-flight RequestContext instances. Publishes events for
|
|
2348
|
+
* WebSocket push and history persistence.
|
|
2349
|
+
*/
|
|
2350
|
+
let _manager = null;
|
|
2351
|
+
function initRequestContextManager(staleMaxAgeSec) {
|
|
2352
|
+
_manager = createRequestContextManager(staleMaxAgeSec);
|
|
2353
|
+
return _manager;
|
|
2354
|
+
}
|
|
2355
|
+
const REAPER_INTERVAL_MS = 6e4;
|
|
2356
|
+
const DEFAULT_STALE_MAX_AGE_SEC = 600;
|
|
2357
|
+
function createRequestContextManager(staleMaxAgeSec) {
|
|
2358
|
+
const maxAgeSec = staleMaxAgeSec ?? DEFAULT_STALE_MAX_AGE_SEC;
|
|
2359
|
+
const activeContexts = /* @__PURE__ */ new Map();
|
|
2360
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2361
|
+
let reaperTimer = null;
|
|
2362
|
+
function runReaperOnce() {
|
|
2363
|
+
if (maxAgeSec <= 0) return;
|
|
2364
|
+
const maxAgeMs = maxAgeSec * 1e3;
|
|
2365
|
+
for (const [id, ctx] of activeContexts) if (ctx.durationMs > maxAgeMs) {
|
|
2366
|
+
consola.warn(`[context] Force-failing stale request ${id} (endpoint: ${ctx.endpoint}, model: ${ctx.originalRequest?.model ?? "unknown"}, state: ${ctx.state}, age: ${Math.round(ctx.durationMs / 1e3)}s, max: ${maxAgeSec}s)`);
|
|
2367
|
+
ctx.fail(ctx.originalRequest?.model ?? "unknown", /* @__PURE__ */ new Error(`Request exceeded maximum age of ${maxAgeSec}s (stale context reaper)`));
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
function startReaper() {
|
|
2371
|
+
if (reaperTimer) return;
|
|
2372
|
+
reaperTimer = setInterval(runReaperOnce, REAPER_INTERVAL_MS);
|
|
2373
|
+
}
|
|
2374
|
+
function stopReaper() {
|
|
2375
|
+
if (reaperTimer) {
|
|
2376
|
+
clearInterval(reaperTimer);
|
|
2377
|
+
reaperTimer = null;
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
function emit(event) {
|
|
2381
|
+
for (const listener of listeners) try {
|
|
2382
|
+
listener(event);
|
|
2383
|
+
} catch {}
|
|
2384
|
+
}
|
|
2385
|
+
function handleContextEvent(rawEvent) {
|
|
2386
|
+
const { type, context } = rawEvent;
|
|
2387
|
+
switch (type) {
|
|
2388
|
+
case "state_changed":
|
|
2389
|
+
if (rawEvent.previousState) emit({
|
|
2390
|
+
type: "state_changed",
|
|
2391
|
+
context,
|
|
2392
|
+
previousState: rawEvent.previousState
|
|
2393
|
+
});
|
|
2394
|
+
break;
|
|
2395
|
+
case "updated":
|
|
2396
|
+
if (rawEvent.field) emit({
|
|
2397
|
+
type: "updated",
|
|
2398
|
+
context,
|
|
2399
|
+
field: rawEvent.field
|
|
2400
|
+
});
|
|
2401
|
+
break;
|
|
2402
|
+
case "completed":
|
|
2403
|
+
if (rawEvent.entry) emit({
|
|
2404
|
+
type: "completed",
|
|
2405
|
+
context,
|
|
2406
|
+
entry: rawEvent.entry
|
|
2407
|
+
});
|
|
2408
|
+
activeContexts.delete(context.id);
|
|
2409
|
+
break;
|
|
2410
|
+
case "failed":
|
|
2411
|
+
if (rawEvent.entry) emit({
|
|
2412
|
+
type: "failed",
|
|
2413
|
+
context,
|
|
2414
|
+
entry: rawEvent.entry
|
|
2415
|
+
});
|
|
2416
|
+
activeContexts.delete(context.id);
|
|
2417
|
+
break;
|
|
2418
|
+
default: break;
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
return {
|
|
2422
|
+
create(opts) {
|
|
2423
|
+
const ctx = createRequestContext({
|
|
2424
|
+
endpoint: opts.endpoint,
|
|
2425
|
+
tuiLogId: opts.tuiLogId,
|
|
2426
|
+
onEvent: handleContextEvent
|
|
2427
|
+
});
|
|
2428
|
+
activeContexts.set(ctx.id, ctx);
|
|
2429
|
+
emit({
|
|
2430
|
+
type: "created",
|
|
2431
|
+
context: ctx
|
|
2432
|
+
});
|
|
2433
|
+
return ctx;
|
|
2434
|
+
},
|
|
2435
|
+
get(id) {
|
|
2436
|
+
return activeContexts.get(id);
|
|
2437
|
+
},
|
|
2438
|
+
getAll() {
|
|
2439
|
+
return Array.from(activeContexts.values());
|
|
2440
|
+
},
|
|
2441
|
+
get activeCount() {
|
|
2442
|
+
return activeContexts.size;
|
|
2443
|
+
},
|
|
2444
|
+
on(_event, listener) {
|
|
2445
|
+
listeners.add(listener);
|
|
2446
|
+
},
|
|
2447
|
+
off(_event, listener) {
|
|
2448
|
+
listeners.delete(listener);
|
|
2449
|
+
},
|
|
2450
|
+
startReaper,
|
|
2451
|
+
stopReaper,
|
|
2452
|
+
_runReaperOnce: runReaperOnce
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
//#endregion
|
|
2457
|
+
//#region src/lib/hidden-models.ts
|
|
2458
|
+
/**
|
|
2459
|
+
* Hardcoded list of GitHub Copilot model ids that are hidden from listing
|
|
2460
|
+
* endpoints (the /v1/models response, the startup ASCII banner, and the
|
|
2461
|
+
* --claude-code interactive prompts), unless `--show-all-models` is passed.
|
|
2462
|
+
*
|
|
2463
|
+
* Note: this is a DISPLAY filter only. Explicit POSTs to handler endpoints
|
|
2464
|
+
* with a hidden id are NOT rejected — they pass through to upstream verbatim.
|
|
2465
|
+
*
|
|
2466
|
+
* Bumping the list requires a code change + release. No env var, no config
|
|
2467
|
+
* file, no CLI append interface.
|
|
2468
|
+
*/
|
|
2469
|
+
const HIDDEN_MODEL_IDS = new Set([
|
|
2470
|
+
"gpt-3.5-turbo",
|
|
2471
|
+
"gpt-3.5-turbo-0613",
|
|
2472
|
+
"gpt-4",
|
|
2473
|
+
"gpt-4-0613",
|
|
2474
|
+
"gpt-4-0125-preview",
|
|
2475
|
+
"gpt-4o",
|
|
2476
|
+
"gpt-4o-mini",
|
|
2477
|
+
"gpt-4-o-preview",
|
|
2478
|
+
"gpt-4o-2024-05-13",
|
|
2479
|
+
"gpt-4o-2024-08-06",
|
|
2480
|
+
"gpt-4o-2024-11-20",
|
|
2481
|
+
"gpt-4o-mini-2024-07-18",
|
|
2482
|
+
"gpt-4.1",
|
|
2483
|
+
"gpt-4.1-2025-04-14",
|
|
2484
|
+
"gpt-41-copilot",
|
|
2485
|
+
"gpt-5-mini",
|
|
2486
|
+
"gpt-5.3-codex",
|
|
2487
|
+
"text-embedding-ada-002",
|
|
2488
|
+
"text-embedding-3-small",
|
|
2489
|
+
"text-embedding-3-small-inference",
|
|
2490
|
+
"gemini-2.5-pro",
|
|
2491
|
+
"gemini-3-flash-preview",
|
|
2492
|
+
"claude-opus-4.5",
|
|
2493
|
+
"claude-opus-4.6",
|
|
2494
|
+
"claude-opus-4.7-high",
|
|
2495
|
+
"claude-opus-4.7-xhigh",
|
|
2496
|
+
"claude-sonnet-4.5",
|
|
2497
|
+
"mai-code-1-flash-internal",
|
|
2498
|
+
"trajectory-compaction"
|
|
2499
|
+
]);
|
|
2500
|
+
function isHiddenModel(id, showAll) {
|
|
2501
|
+
if (showAll) return false;
|
|
2502
|
+
return HIDDEN_MODEL_IDS.has(id);
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
//#endregion
|
|
2506
|
+
//#region src/lib/tokenizer.ts
|
|
2507
|
+
const ENCODING_MAP = {
|
|
2508
|
+
o200k_base: () => import("gpt-tokenizer/encoding/o200k_base"),
|
|
2509
|
+
cl100k_base: () => import("gpt-tokenizer/encoding/cl100k_base"),
|
|
2510
|
+
p50k_base: () => import("gpt-tokenizer/encoding/p50k_base"),
|
|
2511
|
+
p50k_edit: () => import("gpt-tokenizer/encoding/p50k_edit"),
|
|
2512
|
+
r50k_base: () => import("gpt-tokenizer/encoding/r50k_base")
|
|
2513
|
+
};
|
|
2514
|
+
const encodingCache = /* @__PURE__ */ new Map();
|
|
2515
|
+
const encodingInflight = /* @__PURE__ */ new Map();
|
|
2516
|
+
/**
|
|
2517
|
+
* Calculate tokens for tool calls
|
|
2518
|
+
*/
|
|
2519
|
+
const calculateToolCallsTokens = (toolCalls, encoder, constants) => {
|
|
2520
|
+
let tokens = 0;
|
|
2521
|
+
for (const toolCall of toolCalls) {
|
|
2522
|
+
tokens += constants.funcInit;
|
|
2523
|
+
tokens += encoder.encode(JSON.stringify(toolCall)).length;
|
|
2524
|
+
}
|
|
2525
|
+
tokens += constants.funcEnd;
|
|
2526
|
+
return tokens;
|
|
2527
|
+
};
|
|
2528
|
+
/**
|
|
2529
|
+
* Calculate tokens for content parts
|
|
2530
|
+
*/
|
|
2531
|
+
const calculateContentPartsTokens = (contentParts, encoder) => {
|
|
2532
|
+
let tokens = 0;
|
|
2533
|
+
for (const part of contentParts) if (part.type === "image_url") tokens += encoder.encode(part.image_url.url).length + 85;
|
|
2534
|
+
else if (part.text) tokens += encoder.encode(part.text).length;
|
|
2535
|
+
return tokens;
|
|
2536
|
+
};
|
|
2537
|
+
/**
|
|
2538
|
+
* Calculate tokens for a single message
|
|
2539
|
+
*/
|
|
2540
|
+
const calculateMessageTokens = (message, encoder, constants) => {
|
|
2541
|
+
const tokensPerMessage = 3;
|
|
2542
|
+
const tokensPerName = 1;
|
|
2543
|
+
let tokens = tokensPerMessage;
|
|
2544
|
+
for (const [key, value] of Object.entries(message)) {
|
|
2545
|
+
if (typeof value === "string") tokens += encoder.encode(value).length;
|
|
2546
|
+
if (key === "name") tokens += tokensPerName;
|
|
2547
|
+
if (key === "tool_calls") tokens += calculateToolCallsTokens(value, encoder, constants);
|
|
2548
|
+
if (key === "content" && Array.isArray(value)) tokens += calculateContentPartsTokens(value, encoder);
|
|
2549
|
+
}
|
|
2550
|
+
return tokens;
|
|
2551
|
+
};
|
|
2552
|
+
/**
|
|
2553
|
+
* Calculate tokens using custom algorithm
|
|
2554
|
+
*/
|
|
2555
|
+
const calculateTokens = (messages, encoder, constants) => {
|
|
2556
|
+
if (messages.length === 0) return 0;
|
|
2557
|
+
let numTokens = 0;
|
|
2558
|
+
for (const message of messages) numTokens += calculateMessageTokens(message, encoder, constants);
|
|
2559
|
+
numTokens += 3;
|
|
2560
|
+
return numTokens;
|
|
2561
|
+
};
|
|
2562
|
+
/**
|
|
2563
|
+
* Get the corresponding encoder module based on encoding type. Resolved
|
|
2564
|
+
* encoders are cached; concurrent first-loads are de-duplicated via the
|
|
2565
|
+
* in-flight map; and the cold BPE-table import is timed under TOKENIZE_COLD so
|
|
2566
|
+
* a first-request stall is distinguishable from steady-state encode cost.
|
|
2567
|
+
*/
|
|
2568
|
+
const getEncodeChatFunction = async (encoding) => {
|
|
2569
|
+
const cached = encodingCache.get(encoding);
|
|
2570
|
+
if (cached) return cached;
|
|
2571
|
+
const inflight = encodingInflight.get(encoding);
|
|
2572
|
+
if (inflight) return inflight;
|
|
2573
|
+
const loader = encoding in ENCODING_MAP ? ENCODING_MAP[encoding] : ENCODING_MAP.o200k_base;
|
|
2574
|
+
const loadPromise = (async () => {
|
|
2575
|
+
const start = performance.now();
|
|
2576
|
+
const mod = await loader();
|
|
2577
|
+
addTiming(TIMING.TOKENIZE_COLD, performance.now() - start);
|
|
2578
|
+
encodingCache.set(encoding, mod);
|
|
2579
|
+
return mod;
|
|
2580
|
+
})();
|
|
2581
|
+
encodingInflight.set(encoding, loadPromise);
|
|
2582
|
+
try {
|
|
2583
|
+
return await loadPromise;
|
|
2584
|
+
} finally {
|
|
2585
|
+
encodingInflight.delete(encoding);
|
|
2586
|
+
}
|
|
2587
|
+
};
|
|
2588
|
+
/**
|
|
2589
|
+
* Pre-load the default encoder at startup so the first burst of concurrent
|
|
2590
|
+
* requests doesn't pay the cold BPE-table import on the shared event loop.
|
|
2591
|
+
*/
|
|
2592
|
+
async function warmupTokenizer() {
|
|
2593
|
+
await getEncodeChatFunction("o200k_base");
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Get tokenizer type from model information
|
|
2597
|
+
*/
|
|
2598
|
+
const getTokenizerFromModel = (model) => {
|
|
2599
|
+
return model.capabilities?.tokenizer || "o200k_base";
|
|
2600
|
+
};
|
|
2601
|
+
/**
|
|
2602
|
+
* Count tokens in a text string using the model's tokenizer.
|
|
2603
|
+
* This is a simple wrapper for counting tokens in plain text.
|
|
2604
|
+
*/
|
|
2605
|
+
const countTextTokens = async (text, model) => {
|
|
2606
|
+
const encoder = await getEncodeChatFunction(getTokenizerFromModel(model));
|
|
2607
|
+
return timeSync(TIMING.TOKENIZE, () => encoder.encode(text).length);
|
|
2608
|
+
};
|
|
2609
|
+
/**
|
|
2610
|
+
* Get model-specific constants for token calculation.
|
|
2611
|
+
* These values are empirically determined based on OpenAI's function calling token overhead.
|
|
2612
|
+
* - funcInit: Tokens for initializing a function definition
|
|
2613
|
+
* - propInit: Tokens for initializing the properties section
|
|
2614
|
+
* - propKey: Tokens per property key
|
|
2615
|
+
* - enumInit: Token adjustment when enum is present (negative because type info is replaced)
|
|
2616
|
+
* - enumItem: Tokens per enum value
|
|
2617
|
+
* - funcEnd: Tokens for closing the function definition
|
|
2618
|
+
*/
|
|
2619
|
+
const getModelConstants = (model) => {
|
|
2620
|
+
return model.id === "gpt-3.5-turbo" || model.id === "gpt-4" ? {
|
|
2621
|
+
funcInit: 10,
|
|
2622
|
+
propInit: 3,
|
|
2623
|
+
propKey: 3,
|
|
2624
|
+
enumInit: -3,
|
|
2625
|
+
enumItem: 3,
|
|
2626
|
+
funcEnd: 12
|
|
2627
|
+
} : {
|
|
2628
|
+
funcInit: 7,
|
|
2629
|
+
propInit: 3,
|
|
2630
|
+
propKey: 3,
|
|
2631
|
+
enumInit: -3,
|
|
2632
|
+
enumItem: 3,
|
|
2633
|
+
funcEnd: 12
|
|
2634
|
+
};
|
|
2635
|
+
};
|
|
2636
|
+
/**
|
|
2637
|
+
* Calculate tokens for a single parameter
|
|
2638
|
+
*/
|
|
2639
|
+
const calculateParameterTokens = (key, prop, context) => {
|
|
2640
|
+
const { encoder, constants } = context;
|
|
2641
|
+
let tokens = constants.propKey;
|
|
2642
|
+
if (typeof prop !== "object" || prop === null) return tokens;
|
|
2643
|
+
const param = prop;
|
|
2644
|
+
const paramName = key;
|
|
2645
|
+
const paramType = param.type || "string";
|
|
2646
|
+
let paramDesc = param.description || "";
|
|
2647
|
+
if (param.enum && Array.isArray(param.enum)) {
|
|
2648
|
+
tokens += constants.enumInit;
|
|
2649
|
+
for (const item of param.enum) {
|
|
2650
|
+
tokens += constants.enumItem;
|
|
2651
|
+
tokens += encoder.encode(String(item)).length;
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
if (paramDesc.endsWith(".")) paramDesc = paramDesc.slice(0, -1);
|
|
2655
|
+
const line = `${paramName}:${paramType}:${paramDesc}`;
|
|
2656
|
+
tokens += encoder.encode(line).length;
|
|
2657
|
+
const excludedKeys = new Set([
|
|
2658
|
+
"type",
|
|
2659
|
+
"description",
|
|
2660
|
+
"enum"
|
|
2661
|
+
]);
|
|
2662
|
+
for (const propertyName of Object.keys(param)) if (!excludedKeys.has(propertyName)) {
|
|
2663
|
+
const propertyValue = param[propertyName];
|
|
2664
|
+
const propertyText = typeof propertyValue === "string" ? propertyValue : JSON.stringify(propertyValue);
|
|
2665
|
+
tokens += encoder.encode(`${propertyName}:${propertyText}`).length;
|
|
2666
|
+
}
|
|
2667
|
+
return tokens;
|
|
2668
|
+
};
|
|
2669
|
+
/**
|
|
2670
|
+
* Calculate tokens for function parameters
|
|
2671
|
+
*/
|
|
2672
|
+
const calculateParametersTokens = (parameters, encoder, constants) => {
|
|
2673
|
+
if (!parameters || typeof parameters !== "object") return 0;
|
|
2674
|
+
const params = parameters;
|
|
2675
|
+
let tokens = 0;
|
|
2676
|
+
for (const [key, value] of Object.entries(params)) if (key === "properties") {
|
|
2677
|
+
const properties = value;
|
|
2678
|
+
if (Object.keys(properties).length > 0) {
|
|
2679
|
+
tokens += constants.propInit;
|
|
2680
|
+
for (const propKey of Object.keys(properties)) tokens += calculateParameterTokens(propKey, properties[propKey], {
|
|
2681
|
+
encoder,
|
|
2682
|
+
constants
|
|
2683
|
+
});
|
|
2684
|
+
}
|
|
2685
|
+
} else {
|
|
2686
|
+
const paramText = typeof value === "string" ? value : JSON.stringify(value);
|
|
2687
|
+
tokens += encoder.encode(`${key}:${paramText}`).length;
|
|
2688
|
+
}
|
|
2689
|
+
return tokens;
|
|
2690
|
+
};
|
|
2691
|
+
/**
|
|
2692
|
+
* Calculate tokens for a single tool
|
|
2693
|
+
*/
|
|
2694
|
+
const calculateToolTokens = (tool, encoder, constants) => {
|
|
2695
|
+
let tokens = constants.funcInit;
|
|
2696
|
+
const func = tool.function;
|
|
2697
|
+
const fName = func.name;
|
|
2698
|
+
let fDesc = func.description || "";
|
|
2699
|
+
if (fDesc.endsWith(".")) fDesc = fDesc.slice(0, -1);
|
|
2700
|
+
const line = fName + ":" + fDesc;
|
|
2701
|
+
tokens += encoder.encode(line).length;
|
|
2702
|
+
if (typeof func.parameters === "object" && func.parameters !== null) tokens += calculateParametersTokens(func.parameters, encoder, constants);
|
|
2703
|
+
return tokens;
|
|
2704
|
+
};
|
|
2705
|
+
/**
|
|
2706
|
+
* Calculate token count for tools based on model
|
|
2707
|
+
*/
|
|
2708
|
+
const numTokensForTools = (tools, encoder, constants) => {
|
|
2709
|
+
let funcTokenCount = 0;
|
|
2710
|
+
for (const tool of tools) funcTokenCount += calculateToolTokens(tool, encoder, constants);
|
|
2711
|
+
funcTokenCount += constants.funcEnd;
|
|
2712
|
+
return funcTokenCount;
|
|
2713
|
+
};
|
|
2714
|
+
/**
|
|
2715
|
+
* Calculate the token count of messages.
|
|
2716
|
+
* Uses the tokenizer specified by the GitHub Copilot API model info.
|
|
2717
|
+
* All models (including Claude) use GPT tokenizers (o200k_base or cl100k_base).
|
|
2718
|
+
*/
|
|
2719
|
+
const getTokenCount = async (payload, model) => {
|
|
2720
|
+
const encoder = await getEncodeChatFunction(getTokenizerFromModel(model));
|
|
2721
|
+
return timeSync(TIMING.TOKENIZE, () => {
|
|
2722
|
+
const simplifiedMessages = payload.messages;
|
|
2723
|
+
const inputMessages = simplifiedMessages.filter((msg) => msg.role !== "assistant");
|
|
2724
|
+
const outputMessages = simplifiedMessages.filter((msg) => msg.role === "assistant");
|
|
2725
|
+
const constants = getModelConstants(model);
|
|
2726
|
+
let inputTokens = calculateTokens(inputMessages, encoder, constants);
|
|
2727
|
+
if (payload.tools && payload.tools.length > 0) inputTokens += numTokensForTools(payload.tools, encoder, constants);
|
|
2728
|
+
const outputTokens = calculateTokens(outputMessages, encoder, constants);
|
|
2729
|
+
return {
|
|
2730
|
+
input: inputTokens,
|
|
2731
|
+
output: outputTokens
|
|
2732
|
+
};
|
|
2733
|
+
});
|
|
2734
|
+
};
|
|
2735
|
+
|
|
2597
2736
|
//#endregion
|
|
2598
2737
|
//#region src/lib/tui/console-renderer.ts
|
|
2599
2738
|
const CLEAR_LINE = "\x1B[2K\r";
|
|
@@ -2716,7 +2855,7 @@ var ConsoleRenderer = class {
|
|
|
2716
2855
|
* Format a complete log line with colored parts
|
|
2717
2856
|
*/
|
|
2718
2857
|
formatLogLine(parts) {
|
|
2719
|
-
const { prefix, time, method, path, model, status, duration, tokens, queueWait, extra, isError, isDim } = parts;
|
|
2858
|
+
const { prefix, time, method, path, model, status, duration, tokens, queueWait, phases, extra, isError, isDim } = parts;
|
|
2720
2859
|
if (isDim) {
|
|
2721
2860
|
const modelPart = model ? ` ${model}` : "";
|
|
2722
2861
|
const extraPart = extra ? ` ${extra}` : "";
|
|
@@ -2730,6 +2869,7 @@ var ConsoleRenderer = class {
|
|
|
2730
2869
|
if (duration) result += ` ${pc.yellow(duration)}`;
|
|
2731
2870
|
if (queueWait) result += ` ${pc.dim(`(queued ${queueWait})`)}`;
|
|
2732
2871
|
if (tokens) result += ` ${pc.blue(tokens)}`;
|
|
2872
|
+
if (phases) result += ` ${pc.dim(phases)}`;
|
|
2733
2873
|
if (extra) result += isError ? pc.red(extra) : extra;
|
|
2734
2874
|
return result;
|
|
2735
2875
|
}
|
|
@@ -2750,6 +2890,19 @@ var ConsoleRenderer = class {
|
|
|
2750
2890
|
if (request.resolvedModel) return `${request.model} -> ${request.resolvedModel}`;
|
|
2751
2891
|
return request.model;
|
|
2752
2892
|
}
|
|
2893
|
+
/**
|
|
2894
|
+
* Compact per-phase timing breakdown for the complete line, e.g.
|
|
2895
|
+
* "tok=86ms ttfb=120ms". Only non-zero phases are shown, so a request that
|
|
2896
|
+
* skipped a phase stays terse.
|
|
2897
|
+
*/
|
|
2898
|
+
formatPhases(request) {
|
|
2899
|
+
const parts = [];
|
|
2900
|
+
if (request.tokenizeMs) parts.push(`tok=${formatDuration(Math.round(request.tokenizeMs))}`);
|
|
2901
|
+
if (request.tokenizeColdMs) parts.push(`tok+=${formatDuration(Math.round(request.tokenizeColdMs))}`);
|
|
2902
|
+
if (request.upstreamTtfbMs) parts.push(`ttfb=${formatDuration(Math.round(request.upstreamTtfbMs))}`);
|
|
2903
|
+
if (request.limiterRetries) parts.push(`retries=${request.limiterRetries}`);
|
|
2904
|
+
return parts.length > 0 ? parts.join(" ") : void 0;
|
|
2905
|
+
}
|
|
2753
2906
|
onRequestStart(request) {
|
|
2754
2907
|
this.activeRequests.set(request.id, request);
|
|
2755
2908
|
if (this.showActive && consola.level >= 5) {
|
|
@@ -2798,6 +2951,7 @@ var ConsoleRenderer = class {
|
|
|
2798
2951
|
duration: formatDuration(request.durationMs ?? 0),
|
|
2799
2952
|
queueWait,
|
|
2800
2953
|
tokens,
|
|
2954
|
+
phases: this.formatPhases(request),
|
|
2801
2955
|
extra: isError && request.error ? `: ${request.error}` : void 0,
|
|
2802
2956
|
isError,
|
|
2803
2957
|
isDim: request.isHistoryAccess
|
|
@@ -2817,7 +2971,7 @@ var ConsoleRenderer = class {
|
|
|
2817
2971
|
//#endregion
|
|
2818
2972
|
//#region src/lib/tui/tracker.ts
|
|
2819
2973
|
function generateId() {
|
|
2820
|
-
return
|
|
2974
|
+
return randomUUID();
|
|
2821
2975
|
}
|
|
2822
2976
|
var RequestTracker = class {
|
|
2823
2977
|
requests = /* @__PURE__ */ new Map();
|
|
@@ -2848,6 +3002,7 @@ var RequestTracker = class {
|
|
|
2848
3002
|
status: "executing",
|
|
2849
3003
|
isHistoryAccess: options.isHistoryAccess
|
|
2850
3004
|
};
|
|
3005
|
+
if (this.requests.has(id)) consola.warn(`[tracker] request id collision, overwriting in-flight: ${id}`);
|
|
2851
3006
|
this.requests.set(id, request);
|
|
2852
3007
|
this.renderer?.onRequestStart(request);
|
|
2853
3008
|
return id;
|
|
@@ -2867,6 +3022,10 @@ var RequestTracker = class {
|
|
|
2867
3022
|
if (update.error !== void 0) request.error = update.error;
|
|
2868
3023
|
if (update.queuePosition !== void 0) request.queuePosition = update.queuePosition;
|
|
2869
3024
|
if (update.queueWaitMs !== void 0) request.queueWaitMs = update.queueWaitMs;
|
|
3025
|
+
for (const key of Object.values(TIMING)) {
|
|
3026
|
+
const value = update[key];
|
|
3027
|
+
if (value !== void 0) request[key] = value;
|
|
3028
|
+
}
|
|
2870
3029
|
this.renderer?.onRequestUpdate(id, update);
|
|
2871
3030
|
}
|
|
2872
3031
|
/**
|
|
@@ -2959,35 +3118,39 @@ const requestTracker = new RequestTracker();
|
|
|
2959
3118
|
function tuiLogger() {
|
|
2960
3119
|
return async (c, next) => {
|
|
2961
3120
|
if (getIsShuttingDown()) return c.json({ error: "Server is shutting down" }, 503);
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
3121
|
+
return runWithTimings(async () => {
|
|
3122
|
+
const method = c.req.method;
|
|
3123
|
+
const path = c.req.path;
|
|
3124
|
+
const isHistoryAccess = path.startsWith("/history");
|
|
3125
|
+
const trackingId = requestTracker.startRequest({
|
|
3126
|
+
method,
|
|
3127
|
+
path,
|
|
3128
|
+
model: "",
|
|
3129
|
+
isHistoryAccess
|
|
3130
|
+
});
|
|
3131
|
+
c.set("trackingId", trackingId);
|
|
3132
|
+
try {
|
|
3133
|
+
await next();
|
|
3134
|
+
if ((c.res.headers.get("content-type") ?? "").includes("text/event-stream")) return;
|
|
3135
|
+
const status = c.res.status;
|
|
3136
|
+
const inputTokens = c.res.headers.get("x-input-tokens");
|
|
3137
|
+
const outputTokens = c.res.headers.get("x-output-tokens");
|
|
3138
|
+
const model = c.res.headers.get("x-model");
|
|
3139
|
+
if (model) {
|
|
3140
|
+
const request = requestTracker.getRequest(trackingId);
|
|
3141
|
+
if (request) request.model = model;
|
|
3142
|
+
}
|
|
3143
|
+
requestTracker.updateRequest(trackingId, timingsToUpdate(getTimings()));
|
|
3144
|
+
requestTracker.completeRequest(trackingId, status, inputTokens && outputTokens ? {
|
|
3145
|
+
inputTokens: Number.parseInt(inputTokens, 10),
|
|
3146
|
+
outputTokens: Number.parseInt(outputTokens, 10)
|
|
3147
|
+
} : void 0);
|
|
3148
|
+
} catch (error) {
|
|
3149
|
+
requestTracker.updateRequest(trackingId, timingsToUpdate(getTimings()));
|
|
3150
|
+
requestTracker.failRequest(trackingId, error instanceof Error ? error.message : "Unknown error");
|
|
3151
|
+
throw error;
|
|
2982
3152
|
}
|
|
2983
|
-
|
|
2984
|
-
inputTokens: Number.parseInt(inputTokens, 10),
|
|
2985
|
-
outputTokens: Number.parseInt(outputTokens, 10)
|
|
2986
|
-
} : void 0);
|
|
2987
|
-
} catch (error) {
|
|
2988
|
-
requestTracker.failRequest(trackingId, error instanceof Error ? error.message : "Unknown error");
|
|
2989
|
-
throw error;
|
|
2990
|
-
}
|
|
3153
|
+
});
|
|
2991
3154
|
};
|
|
2992
3155
|
}
|
|
2993
3156
|
|
|
@@ -3033,218 +3196,6 @@ function removeSystemReminderTags(text) {
|
|
|
3033
3196
|
return result;
|
|
3034
3197
|
}
|
|
3035
3198
|
|
|
3036
|
-
//#endregion
|
|
3037
|
-
//#region src/lib/tokenizer.ts
|
|
3038
|
-
const ENCODING_MAP = {
|
|
3039
|
-
o200k_base: () => import("gpt-tokenizer/encoding/o200k_base"),
|
|
3040
|
-
cl100k_base: () => import("gpt-tokenizer/encoding/cl100k_base"),
|
|
3041
|
-
p50k_base: () => import("gpt-tokenizer/encoding/p50k_base"),
|
|
3042
|
-
p50k_edit: () => import("gpt-tokenizer/encoding/p50k_edit"),
|
|
3043
|
-
r50k_base: () => import("gpt-tokenizer/encoding/r50k_base")
|
|
3044
|
-
};
|
|
3045
|
-
const encodingCache = /* @__PURE__ */ new Map();
|
|
3046
|
-
/**
|
|
3047
|
-
* Calculate tokens for tool calls
|
|
3048
|
-
*/
|
|
3049
|
-
const calculateToolCallsTokens = (toolCalls, encoder, constants) => {
|
|
3050
|
-
let tokens = 0;
|
|
3051
|
-
for (const toolCall of toolCalls) {
|
|
3052
|
-
tokens += constants.funcInit;
|
|
3053
|
-
tokens += encoder.encode(JSON.stringify(toolCall)).length;
|
|
3054
|
-
}
|
|
3055
|
-
tokens += constants.funcEnd;
|
|
3056
|
-
return tokens;
|
|
3057
|
-
};
|
|
3058
|
-
/**
|
|
3059
|
-
* Calculate tokens for content parts
|
|
3060
|
-
*/
|
|
3061
|
-
const calculateContentPartsTokens = (contentParts, encoder) => {
|
|
3062
|
-
let tokens = 0;
|
|
3063
|
-
for (const part of contentParts) if (part.type === "image_url") tokens += encoder.encode(part.image_url.url).length + 85;
|
|
3064
|
-
else if (part.text) tokens += encoder.encode(part.text).length;
|
|
3065
|
-
return tokens;
|
|
3066
|
-
};
|
|
3067
|
-
/**
|
|
3068
|
-
* Calculate tokens for a single message
|
|
3069
|
-
*/
|
|
3070
|
-
const calculateMessageTokens = (message, encoder, constants) => {
|
|
3071
|
-
const tokensPerMessage = 3;
|
|
3072
|
-
const tokensPerName = 1;
|
|
3073
|
-
let tokens = tokensPerMessage;
|
|
3074
|
-
for (const [key, value] of Object.entries(message)) {
|
|
3075
|
-
if (typeof value === "string") tokens += encoder.encode(value).length;
|
|
3076
|
-
if (key === "name") tokens += tokensPerName;
|
|
3077
|
-
if (key === "tool_calls") tokens += calculateToolCallsTokens(value, encoder, constants);
|
|
3078
|
-
if (key === "content" && Array.isArray(value)) tokens += calculateContentPartsTokens(value, encoder);
|
|
3079
|
-
}
|
|
3080
|
-
return tokens;
|
|
3081
|
-
};
|
|
3082
|
-
/**
|
|
3083
|
-
* Calculate tokens using custom algorithm
|
|
3084
|
-
*/
|
|
3085
|
-
const calculateTokens = (messages, encoder, constants) => {
|
|
3086
|
-
if (messages.length === 0) return 0;
|
|
3087
|
-
let numTokens = 0;
|
|
3088
|
-
for (const message of messages) numTokens += calculateMessageTokens(message, encoder, constants);
|
|
3089
|
-
numTokens += 3;
|
|
3090
|
-
return numTokens;
|
|
3091
|
-
};
|
|
3092
|
-
/**
|
|
3093
|
-
* Get the corresponding encoder module based on encoding type
|
|
3094
|
-
*/
|
|
3095
|
-
const getEncodeChatFunction = async (encoding) => {
|
|
3096
|
-
if (encodingCache.has(encoding)) {
|
|
3097
|
-
const cached = encodingCache.get(encoding);
|
|
3098
|
-
if (cached) return cached;
|
|
3099
|
-
}
|
|
3100
|
-
const supportedEncoding = encoding;
|
|
3101
|
-
if (!(supportedEncoding in ENCODING_MAP)) {
|
|
3102
|
-
const fallbackModule = await ENCODING_MAP.o200k_base();
|
|
3103
|
-
encodingCache.set(encoding, fallbackModule);
|
|
3104
|
-
return fallbackModule;
|
|
3105
|
-
}
|
|
3106
|
-
const encodingModule = await ENCODING_MAP[supportedEncoding]();
|
|
3107
|
-
encodingCache.set(encoding, encodingModule);
|
|
3108
|
-
return encodingModule;
|
|
3109
|
-
};
|
|
3110
|
-
/**
|
|
3111
|
-
* Get tokenizer type from model information
|
|
3112
|
-
*/
|
|
3113
|
-
const getTokenizerFromModel = (model) => {
|
|
3114
|
-
return model.capabilities?.tokenizer || "o200k_base";
|
|
3115
|
-
};
|
|
3116
|
-
/**
|
|
3117
|
-
* Count tokens in a text string using the model's tokenizer.
|
|
3118
|
-
* This is a simple wrapper for counting tokens in plain text.
|
|
3119
|
-
*/
|
|
3120
|
-
const countTextTokens = async (text, model) => {
|
|
3121
|
-
return (await getEncodeChatFunction(getTokenizerFromModel(model))).encode(text).length;
|
|
3122
|
-
};
|
|
3123
|
-
/**
|
|
3124
|
-
* Get model-specific constants for token calculation.
|
|
3125
|
-
* These values are empirically determined based on OpenAI's function calling token overhead.
|
|
3126
|
-
* - funcInit: Tokens for initializing a function definition
|
|
3127
|
-
* - propInit: Tokens for initializing the properties section
|
|
3128
|
-
* - propKey: Tokens per property key
|
|
3129
|
-
* - enumInit: Token adjustment when enum is present (negative because type info is replaced)
|
|
3130
|
-
* - enumItem: Tokens per enum value
|
|
3131
|
-
* - funcEnd: Tokens for closing the function definition
|
|
3132
|
-
*/
|
|
3133
|
-
const getModelConstants = (model) => {
|
|
3134
|
-
return model.id === "gpt-3.5-turbo" || model.id === "gpt-4" ? {
|
|
3135
|
-
funcInit: 10,
|
|
3136
|
-
propInit: 3,
|
|
3137
|
-
propKey: 3,
|
|
3138
|
-
enumInit: -3,
|
|
3139
|
-
enumItem: 3,
|
|
3140
|
-
funcEnd: 12
|
|
3141
|
-
} : {
|
|
3142
|
-
funcInit: 7,
|
|
3143
|
-
propInit: 3,
|
|
3144
|
-
propKey: 3,
|
|
3145
|
-
enumInit: -3,
|
|
3146
|
-
enumItem: 3,
|
|
3147
|
-
funcEnd: 12
|
|
3148
|
-
};
|
|
3149
|
-
};
|
|
3150
|
-
/**
|
|
3151
|
-
* Calculate tokens for a single parameter
|
|
3152
|
-
*/
|
|
3153
|
-
const calculateParameterTokens = (key, prop, context) => {
|
|
3154
|
-
const { encoder, constants } = context;
|
|
3155
|
-
let tokens = constants.propKey;
|
|
3156
|
-
if (typeof prop !== "object" || prop === null) return tokens;
|
|
3157
|
-
const param = prop;
|
|
3158
|
-
const paramName = key;
|
|
3159
|
-
const paramType = param.type || "string";
|
|
3160
|
-
let paramDesc = param.description || "";
|
|
3161
|
-
if (param.enum && Array.isArray(param.enum)) {
|
|
3162
|
-
tokens += constants.enumInit;
|
|
3163
|
-
for (const item of param.enum) {
|
|
3164
|
-
tokens += constants.enumItem;
|
|
3165
|
-
tokens += encoder.encode(String(item)).length;
|
|
3166
|
-
}
|
|
3167
|
-
}
|
|
3168
|
-
if (paramDesc.endsWith(".")) paramDesc = paramDesc.slice(0, -1);
|
|
3169
|
-
const line = `${paramName}:${paramType}:${paramDesc}`;
|
|
3170
|
-
tokens += encoder.encode(line).length;
|
|
3171
|
-
const excludedKeys = new Set([
|
|
3172
|
-
"type",
|
|
3173
|
-
"description",
|
|
3174
|
-
"enum"
|
|
3175
|
-
]);
|
|
3176
|
-
for (const propertyName of Object.keys(param)) if (!excludedKeys.has(propertyName)) {
|
|
3177
|
-
const propertyValue = param[propertyName];
|
|
3178
|
-
const propertyText = typeof propertyValue === "string" ? propertyValue : JSON.stringify(propertyValue);
|
|
3179
|
-
tokens += encoder.encode(`${propertyName}:${propertyText}`).length;
|
|
3180
|
-
}
|
|
3181
|
-
return tokens;
|
|
3182
|
-
};
|
|
3183
|
-
/**
|
|
3184
|
-
* Calculate tokens for function parameters
|
|
3185
|
-
*/
|
|
3186
|
-
const calculateParametersTokens = (parameters, encoder, constants) => {
|
|
3187
|
-
if (!parameters || typeof parameters !== "object") return 0;
|
|
3188
|
-
const params = parameters;
|
|
3189
|
-
let tokens = 0;
|
|
3190
|
-
for (const [key, value] of Object.entries(params)) if (key === "properties") {
|
|
3191
|
-
const properties = value;
|
|
3192
|
-
if (Object.keys(properties).length > 0) {
|
|
3193
|
-
tokens += constants.propInit;
|
|
3194
|
-
for (const propKey of Object.keys(properties)) tokens += calculateParameterTokens(propKey, properties[propKey], {
|
|
3195
|
-
encoder,
|
|
3196
|
-
constants
|
|
3197
|
-
});
|
|
3198
|
-
}
|
|
3199
|
-
} else {
|
|
3200
|
-
const paramText = typeof value === "string" ? value : JSON.stringify(value);
|
|
3201
|
-
tokens += encoder.encode(`${key}:${paramText}`).length;
|
|
3202
|
-
}
|
|
3203
|
-
return tokens;
|
|
3204
|
-
};
|
|
3205
|
-
/**
|
|
3206
|
-
* Calculate tokens for a single tool
|
|
3207
|
-
*/
|
|
3208
|
-
const calculateToolTokens = (tool, encoder, constants) => {
|
|
3209
|
-
let tokens = constants.funcInit;
|
|
3210
|
-
const func = tool.function;
|
|
3211
|
-
const fName = func.name;
|
|
3212
|
-
let fDesc = func.description || "";
|
|
3213
|
-
if (fDesc.endsWith(".")) fDesc = fDesc.slice(0, -1);
|
|
3214
|
-
const line = fName + ":" + fDesc;
|
|
3215
|
-
tokens += encoder.encode(line).length;
|
|
3216
|
-
if (typeof func.parameters === "object" && func.parameters !== null) tokens += calculateParametersTokens(func.parameters, encoder, constants);
|
|
3217
|
-
return tokens;
|
|
3218
|
-
};
|
|
3219
|
-
/**
|
|
3220
|
-
* Calculate token count for tools based on model
|
|
3221
|
-
*/
|
|
3222
|
-
const numTokensForTools = (tools, encoder, constants) => {
|
|
3223
|
-
let funcTokenCount = 0;
|
|
3224
|
-
for (const tool of tools) funcTokenCount += calculateToolTokens(tool, encoder, constants);
|
|
3225
|
-
funcTokenCount += constants.funcEnd;
|
|
3226
|
-
return funcTokenCount;
|
|
3227
|
-
};
|
|
3228
|
-
/**
|
|
3229
|
-
* Calculate the token count of messages.
|
|
3230
|
-
* Uses the tokenizer specified by the GitHub Copilot API model info.
|
|
3231
|
-
* All models (including Claude) use GPT tokenizers (o200k_base or cl100k_base).
|
|
3232
|
-
*/
|
|
3233
|
-
const getTokenCount = async (payload, model) => {
|
|
3234
|
-
const encoder = await getEncodeChatFunction(getTokenizerFromModel(model));
|
|
3235
|
-
const simplifiedMessages = payload.messages;
|
|
3236
|
-
const inputMessages = simplifiedMessages.filter((msg) => msg.role !== "assistant");
|
|
3237
|
-
const outputMessages = simplifiedMessages.filter((msg) => msg.role === "assistant");
|
|
3238
|
-
const constants = getModelConstants(model);
|
|
3239
|
-
let inputTokens = calculateTokens(inputMessages, encoder, constants);
|
|
3240
|
-
if (payload.tools && payload.tools.length > 0) inputTokens += numTokensForTools(payload.tools, encoder, constants);
|
|
3241
|
-
const outputTokens = calculateTokens(outputMessages, encoder, constants);
|
|
3242
|
-
return {
|
|
3243
|
-
input: inputTokens,
|
|
3244
|
-
output: outputTokens
|
|
3245
|
-
};
|
|
3246
|
-
};
|
|
3247
|
-
|
|
3248
3199
|
//#endregion
|
|
3249
3200
|
//#region src/lib/auto-truncate-openai.ts
|
|
3250
3201
|
/**
|
|
@@ -4198,13 +4149,14 @@ function updateTrackerStatus(trackingId, status) {
|
|
|
4198
4149
|
requestTracker.updateRequest(trackingId, { status });
|
|
4199
4150
|
}
|
|
4200
4151
|
/** Complete TUI tracking and send PostHog analytics */
|
|
4201
|
-
function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, reasoningTokens, analytics) {
|
|
4152
|
+
function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, reasoningTokens, analytics, timings) {
|
|
4202
4153
|
if (!trackingId) return;
|
|
4203
4154
|
requestTracker.updateRequest(trackingId, {
|
|
4204
4155
|
inputTokens,
|
|
4205
4156
|
outputTokens,
|
|
4206
4157
|
queueWaitMs,
|
|
4207
|
-
reasoningTokens
|
|
4158
|
+
reasoningTokens,
|
|
4159
|
+
...timingsToUpdate(timings)
|
|
4208
4160
|
});
|
|
4209
4161
|
requestTracker.completeRequest(trackingId, 200, {
|
|
4210
4162
|
inputTokens,
|
|
@@ -4250,7 +4202,8 @@ function createEntryContext(args) {
|
|
|
4250
4202
|
historyId: recordRequest(args.endpoint, args.buildHistoryRequest(payload)),
|
|
4251
4203
|
trackingId,
|
|
4252
4204
|
startTime,
|
|
4253
|
-
requestedModel
|
|
4205
|
+
requestedModel,
|
|
4206
|
+
timings: getTimings()
|
|
4254
4207
|
}
|
|
4255
4208
|
};
|
|
4256
4209
|
}
|
|
@@ -4506,7 +4459,7 @@ async function handleStreamingResponse$1(opts) {
|
|
|
4506
4459
|
durationMs: Date.now() - ctx.startTime,
|
|
4507
4460
|
stopReason: acc.finishReason || void 0,
|
|
4508
4461
|
toolCount: payload.tools?.length ?? 0
|
|
4509
|
-
});
|
|
4462
|
+
}, ctx.timings);
|
|
4510
4463
|
} catch (error) {
|
|
4511
4464
|
recordStreamError({
|
|
4512
4465
|
acc,
|
|
@@ -6961,8 +6914,9 @@ const SSE_PING = ": ping\n\n";
|
|
|
6961
6914
|
/**
|
|
6962
6915
|
* Grace period before opening a keepalive stream. Normal upstream responses
|
|
6963
6916
|
* resolve sub-second (response headers arrive immediately, the body is not
|
|
6964
|
-
* buffered); only a request
|
|
6965
|
-
* cleanly separates the two — a request still pending
|
|
6917
|
+
* buffered); only a request retrying behind the rate limiter's backoff stays
|
|
6918
|
+
* pending for seconds. 3s cleanly separates the two — a request still pending
|
|
6919
|
+
* after 3s is backing off.
|
|
6966
6920
|
*/
|
|
6967
6921
|
const RATE_LIMIT_GRACE_MS = 3e3;
|
|
6968
6922
|
/**
|
|
@@ -8449,7 +8403,7 @@ async function handleDirectAnthropicStreamingResponse(opts) {
|
|
|
8449
8403
|
durationMs: Date.now() - ctx.startTime,
|
|
8450
8404
|
stopReason: acc.stopReason || void 0,
|
|
8451
8405
|
toolCount: anthropicPayload.tools?.length ?? 0
|
|
8452
|
-
});
|
|
8406
|
+
}, ctx.timings);
|
|
8453
8407
|
} catch (error) {
|
|
8454
8408
|
consola.error("Direct Anthropic stream error:", formatError(error));
|
|
8455
8409
|
recordStreamError({
|
|
@@ -8694,7 +8648,7 @@ async function handleStreamingResponse(opts) {
|
|
|
8694
8648
|
durationMs: Date.now() - ctx.startTime,
|
|
8695
8649
|
stopReason: acc.stopReason || void 0,
|
|
8696
8650
|
toolCount: anthropicPayload.tools?.length ?? 0
|
|
8697
|
-
});
|
|
8651
|
+
}, ctx.timings);
|
|
8698
8652
|
} catch (error) {
|
|
8699
8653
|
consola.error("Stream error:", formatError(error));
|
|
8700
8654
|
recordStreamError({
|
|
@@ -9286,7 +9240,7 @@ const handleResponses = async (c) => {
|
|
|
9286
9240
|
stream: true,
|
|
9287
9241
|
durationMs: Date.now() - startTime,
|
|
9288
9242
|
toolCount: tools.length
|
|
9289
|
-
});
|
|
9243
|
+
}, ctx.timings);
|
|
9290
9244
|
} else if (streamErrorMessage) {
|
|
9291
9245
|
recordResponse(historyId, {
|
|
9292
9246
|
success: false,
|
|
@@ -9298,8 +9252,8 @@ const handleResponses = async (c) => {
|
|
|
9298
9252
|
error: streamErrorMessage,
|
|
9299
9253
|
content: null
|
|
9300
9254
|
}, Date.now() - startTime);
|
|
9301
|
-
completeTracking(trackingId, 0, 0, queueWaitMs);
|
|
9302
|
-
} else completeTracking(trackingId, 0, 0, queueWaitMs);
|
|
9255
|
+
completeTracking(trackingId, 0, 0, queueWaitMs, void 0, void 0, ctx.timings);
|
|
9256
|
+
} else completeTracking(trackingId, 0, 0, queueWaitMs, void 0, void 0, ctx.timings);
|
|
9303
9257
|
} catch (error) {
|
|
9304
9258
|
recordStreamError({
|
|
9305
9259
|
acc: { model: finalResult?.model || model },
|
|
@@ -9332,7 +9286,7 @@ const handleResponses = async (c) => {
|
|
|
9332
9286
|
stream: false,
|
|
9333
9287
|
durationMs: Date.now() - startTime,
|
|
9334
9288
|
toolCount: tools.length
|
|
9335
|
-
});
|
|
9289
|
+
}, ctx.timings);
|
|
9336
9290
|
consola.debug("Forwarding native Responses result:", JSON.stringify(result).slice(-400));
|
|
9337
9291
|
return c.json(echoResponseBody(result, ctx));
|
|
9338
9292
|
} catch (error) {
|
|
@@ -9505,6 +9459,7 @@ async function runServer(options) {
|
|
|
9505
9459
|
initHistory(true, 1e3);
|
|
9506
9460
|
consola.info("History recording enabled (max 1000 entries)");
|
|
9507
9461
|
startMemoryPressureMonitor();
|
|
9462
|
+
startEventLoopLagMonitor();
|
|
9508
9463
|
if (options.posthogKey) {
|
|
9509
9464
|
initPostHog(options.posthogKey);
|
|
9510
9465
|
if (isPostHogEnabled()) consola.info("PostHog analytics enabled");
|
|
@@ -9525,6 +9480,7 @@ async function runServer(options) {
|
|
|
9525
9480
|
consola.error(error instanceof Error ? error.message : String(error));
|
|
9526
9481
|
process.exit(1);
|
|
9527
9482
|
}
|
|
9483
|
+
await warmupTokenizer();
|
|
9528
9484
|
const allModels = state.models?.data ?? [];
|
|
9529
9485
|
if (allModels.length === 0) {
|
|
9530
9486
|
consola.error(`Upstream returned zero models for account type "${state.accountType}". Verify the account type matches your Copilot plan and that upstream is reachable.`);
|