@dianshuv/copilot-api 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.mjs +1350 -1393
  2. 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.12.0";
1050
+ var version = "0.13.0";
997
1051
 
998
1052
  //#endregion
999
- //#region src/lib/adaptive-rate-limiter.ts
1000
- const DEFAULT_CONFIG$1 = {
1001
- baseRetryIntervalSeconds: 10,
1002
- maxRetryIntervalSeconds: 120,
1003
- requestIntervalSeconds: 10,
1004
- recoveryTimeoutMinutes: 10,
1005
- consecutiveSuccessesForRecovery: 5,
1006
- gradualRecoverySteps: [
1007
- 5,
1008
- 2,
1009
- 1,
1010
- 0
1011
- ]
1012
- };
1013
- /**
1014
- * Adaptive rate limiter that switches between normal, rate-limited, and recovering modes
1015
- * based on API responses.
1016
- */
1017
- var AdaptiveRateLimiter = class {
1018
- config;
1019
- mode = "normal";
1020
- queue = [];
1021
- processing = false;
1022
- rateLimitedAt = null;
1023
- consecutiveSuccesses = 0;
1024
- lastRequestTime = 0;
1025
- /** Current step in gradual recovery (index into gradualRecoverySteps) */
1026
- recoveryStepIndex = 0;
1027
- constructor(config = {}) {
1028
- this.config = {
1029
- ...DEFAULT_CONFIG$1,
1030
- ...config
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/auth-gate.ts
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
- * Extract candidate presented credential values from request headers.
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
- function extractCredentials(headers) {
1333
- const candidates = [];
1334
- const authorization = headers.get("authorization");
1335
- if (authorization !== null) candidates.push(authorization.replace(/^Bearer\s+/i, ""));
1336
- const apiKey = headers.get("x-api-key");
1337
- if (apiKey !== null) candidates.push(apiKey);
1338
- return candidates;
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
- * Hard-coded exemption set: the liveness (`/`) and readiness (`/health`)
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
- * Compute the fixed-length sha256 digest (32 bytes) of the configured key.
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
- * Constant-time membership test: does any presented candidate match the
1365
- * configured key?
1366
- *
1367
- * Each candidate is sha256'd to a fixed 32-byte digest and compared against the
1368
- * configured digest. Hashing to a fixed length sidesteps the `RangeError` that
1369
- * `crypto.timingSafeEqual` throws on length-mismatched buffers, so a
1370
- * wrong-length presented value yields `false` rather than throwing.
1371
- */
1372
- function matchesConfiguredKey(configuredDigest, candidates) {
1373
- return candidates.some((candidate) => {
1374
- return timingSafeEqual(createHash("sha256").update(candidate).digest(), configuredDigest);
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
- * Resolve the inbound Proxy API key from its two operator-facing sources,
1379
- * applying the precedence + normalization contract (Issue 03):
1380
- *
1381
- * - `--api-key` flag (`flag`) and `COPILOT_API_KEY` env (`env`) are each
1382
- * **trimmed first**; a trimmed-empty source (`""`, whitespace, or
1383
- * `undefined`) counts as **not provided**.
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
- * Resolve the CLIENT-FACING host for generated configs and viewer links
1477
- * (Issue 04), derived from the SAME srvx host resolution as the banner so the
1478
- * two never disagree about what was bound — and formatted as a valid URL
1479
- * authority so the links actually parse.
1480
- *
1481
- * Two differences from {@link resolveBindAddress}:
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
- * Build the inbound-auth startup banner lines (Issue 04).
1503
- *
1504
- * Returns the human-readable lines the proxy prints at boot so operators can see,
1505
- * at a glance, the security posture of *this* instance:
1506
- * - auth ON → `认证开启`, plus the key's origin (`flag` / `env`), plus the real
1507
- * bind address.
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
- * Configure the proxy API key on global state from a raw configured value.
1521
- *
1522
- * The value is trimmed; a trimmed-empty value (or `undefined`) is treated as
1523
- * "not provided" → auth stays disabled. Otherwise the precomputed digest is
1524
- * stored on state (presence === enabled). Returns whether auth is enabled.
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
- * Path → auth-family selector (Issue 02).
1541
- *
1542
- * The Anthropic-native surface is `/v1/messages` and its `count_tokens`
1543
- * subpath; both map to the Anthropic family so a native Anthropic client gets
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
- * OpenAI-family 401 response body. The literal field values are pinned by the
1561
- * ADR so OpenAI-compatible SDKs recognize the failure as an auth error. The
1562
- * same body is returned whether credentials were missing or wrong (no oracle).
1563
- */
1564
- function unauthorizedOpenAIBody() {
1565
- return { error: {
1566
- message: "Invalid API key provided.",
1567
- type: "invalid_request_error",
1568
- code: "invalid_api_key",
1569
- param: null
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
- * Anthropic-family 401 response body (Issue 02). Shape is pinned so Anthropic
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
- * Global fail-closed authentication middleware.
1590
- *
1591
- * Registered after the request logger and CORS but before route dispatch.
1592
- * Behavior:
1593
- * - Disabled (no configured digest) → pass through unchanged (default).
1594
- * - Exempt path (`/`, `/health`) → pass through.
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
- return ctx;
1724
- }
1725
-
1726
- //#endregion
1727
- //#region src/lib/context/manager.ts
1728
- /**
1729
- * RequestContextManager — Active request management
1730
- *
1731
- * Manages all in-flight RequestContext instances. Publishes events for
1732
- * WebSocket push and history persistence.
1733
- */
1734
- let _manager = null;
1735
- function initRequestContextManager(staleMaxAgeSec) {
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
- const REAPER_INTERVAL_MS = 6e4;
1740
- const DEFAULT_STALE_MAX_AGE_SEC = 600;
1741
- function createRequestContextManager(staleMaxAgeSec) {
1742
- const maxAgeSec = staleMaxAgeSec ?? DEFAULT_STALE_MAX_AGE_SEC;
1743
- const activeContexts = /* @__PURE__ */ new Map();
1744
- const listeners = /* @__PURE__ */ new Set();
1745
- let reaperTimer = null;
1746
- function runReaperOnce() {
1747
- if (maxAgeSec <= 0) return;
1748
- const maxAgeMs = maxAgeSec * 1e3;
1749
- for (const [id, ctx] of activeContexts) if (ctx.durationMs > maxAgeMs) {
1750
- 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)`);
1751
- ctx.fail(ctx.originalRequest?.model ?? "unknown", /* @__PURE__ */ new Error(`Request exceeded maximum age of ${maxAgeSec}s (stale context reaper)`));
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
- function startReaper() {
1755
- if (reaperTimer) return;
1756
- reaperTimer = setInterval(runReaperOnce, REAPER_INTERVAL_MS);
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
- function stopReaper() {
1759
- if (reaperTimer) {
1760
- clearInterval(reaperTimer);
1761
- reaperTimer = null;
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
- function emit(event) {
1765
- for (const listener of listeners) try {
1766
- listener(event);
1767
- } catch {}
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
- function handleContextEvent(rawEvent) {
1770
- const { type, context } = rawEvent;
1771
- switch (type) {
1772
- case "state_changed":
1773
- if (rawEvent.previousState) emit({
1774
- type: "state_changed",
1775
- context,
1776
- previousState: rawEvent.previousState
1777
- });
1778
- break;
1779
- case "updated":
1780
- if (rawEvent.field) emit({
1781
- type: "updated",
1782
- context,
1783
- field: rawEvent.field
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
- return {
1806
- create(opts) {
1807
- const ctx = createRequestContext({
1808
- endpoint: opts.endpoint,
1809
- tuiLogId: opts.tuiLogId,
1810
- onEvent: handleContextEvent
1811
- });
1812
- activeContexts.set(ctx.id, ctx);
1813
- emit({
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
- return ctx;
1818
- },
1819
- get(id) {
1820
- return activeContexts.get(id);
1821
- },
1822
- getAll() {
1823
- return Array.from(activeContexts.values());
1824
- },
1825
- get activeCount() {
1826
- return activeContexts.size;
1827
- },
1828
- on(_event, listener) {
1829
- listeners.add(listener);
1830
- },
1831
- off(_event, listener) {
1832
- listeners.delete(listener);
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
- //#endregion
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
- //#endregion
1891
- //#region src/lib/history-ws.ts
1892
- /**
1893
- * WebSocket support for History API.
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
1350
  }
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
- }
1928
- function notifyEntryAdded(summary) {
1929
- if (clients.size === 0) return;
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 notifyEntryUpdated(summary) {
1937
- if (clients.size === 0) return;
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 notifyStatsUpdated(stats) {
1945
- if (clients.size === 0) return;
1946
- broadcast({
1947
- type: "stats_updated",
1948
- data: stats,
1949
- timestamp: Date.now()
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 notifyHistoryCleared() {
1953
- if (clients.size === 0) return;
1954
- broadcast({
1955
- type: "history_cleared",
1956
- data: null,
1957
- timestamp: Date.now()
1958
- });
1959
- }
1960
- function notifySessionDeleted(sessionId) {
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,945 @@ 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
+ "gpt-5.4",
2488
+ "text-embedding-ada-002",
2489
+ "text-embedding-3-small",
2490
+ "text-embedding-3-small-inference",
2491
+ "gemini-2.5-pro",
2492
+ "gemini-3-flash-preview",
2493
+ "claude-opus-4.5",
2494
+ "claude-opus-4.6",
2495
+ "claude-opus-4.7-high",
2496
+ "claude-opus-4.7-xhigh",
2497
+ "claude-sonnet-4.5",
2498
+ "mai-code-1-flash-internal",
2499
+ "trajectory-compaction"
2500
+ ]);
2501
+ function isHiddenModel(id, showAll) {
2502
+ if (showAll) return false;
2503
+ return HIDDEN_MODEL_IDS.has(id);
2504
+ }
2505
+
2506
+ //#endregion
2507
+ //#region src/lib/tokenizer.ts
2508
+ const ENCODING_MAP = {
2509
+ o200k_base: () => import("gpt-tokenizer/encoding/o200k_base"),
2510
+ cl100k_base: () => import("gpt-tokenizer/encoding/cl100k_base"),
2511
+ p50k_base: () => import("gpt-tokenizer/encoding/p50k_base"),
2512
+ p50k_edit: () => import("gpt-tokenizer/encoding/p50k_edit"),
2513
+ r50k_base: () => import("gpt-tokenizer/encoding/r50k_base")
2514
+ };
2515
+ const encodingCache = /* @__PURE__ */ new Map();
2516
+ const encodingInflight = /* @__PURE__ */ new Map();
2517
+ /**
2518
+ * Calculate tokens for tool calls
2519
+ */
2520
+ const calculateToolCallsTokens = (toolCalls, encoder, constants) => {
2521
+ let tokens = 0;
2522
+ for (const toolCall of toolCalls) {
2523
+ tokens += constants.funcInit;
2524
+ tokens += encoder.encode(JSON.stringify(toolCall)).length;
2525
+ }
2526
+ tokens += constants.funcEnd;
2527
+ return tokens;
2528
+ };
2529
+ /**
2530
+ * Calculate tokens for content parts
2531
+ */
2532
+ const calculateContentPartsTokens = (contentParts, encoder) => {
2533
+ let tokens = 0;
2534
+ for (const part of contentParts) if (part.type === "image_url") tokens += encoder.encode(part.image_url.url).length + 85;
2535
+ else if (part.text) tokens += encoder.encode(part.text).length;
2536
+ return tokens;
2537
+ };
2538
+ /**
2539
+ * Calculate tokens for a single message
2540
+ */
2541
+ const calculateMessageTokens = (message, encoder, constants) => {
2542
+ const tokensPerMessage = 3;
2543
+ const tokensPerName = 1;
2544
+ let tokens = tokensPerMessage;
2545
+ for (const [key, value] of Object.entries(message)) {
2546
+ if (typeof value === "string") tokens += encoder.encode(value).length;
2547
+ if (key === "name") tokens += tokensPerName;
2548
+ if (key === "tool_calls") tokens += calculateToolCallsTokens(value, encoder, constants);
2549
+ if (key === "content" && Array.isArray(value)) tokens += calculateContentPartsTokens(value, encoder);
2550
+ }
2551
+ return tokens;
2552
+ };
2553
+ /**
2554
+ * Calculate tokens using custom algorithm
2555
+ */
2556
+ const calculateTokens = (messages, encoder, constants) => {
2557
+ if (messages.length === 0) return 0;
2558
+ let numTokens = 0;
2559
+ for (const message of messages) numTokens += calculateMessageTokens(message, encoder, constants);
2560
+ numTokens += 3;
2561
+ return numTokens;
2562
+ };
2563
+ /**
2564
+ * Get the corresponding encoder module based on encoding type. Resolved
2565
+ * encoders are cached; concurrent first-loads are de-duplicated via the
2566
+ * in-flight map; and the cold BPE-table import is timed under TOKENIZE_COLD so
2567
+ * a first-request stall is distinguishable from steady-state encode cost.
2568
+ */
2569
+ const getEncodeChatFunction = async (encoding) => {
2570
+ const cached = encodingCache.get(encoding);
2571
+ if (cached) return cached;
2572
+ const inflight = encodingInflight.get(encoding);
2573
+ if (inflight) return inflight;
2574
+ const loader = encoding in ENCODING_MAP ? ENCODING_MAP[encoding] : ENCODING_MAP.o200k_base;
2575
+ const loadPromise = (async () => {
2576
+ const start = performance.now();
2577
+ const mod = await loader();
2578
+ addTiming(TIMING.TOKENIZE_COLD, performance.now() - start);
2579
+ encodingCache.set(encoding, mod);
2580
+ return mod;
2581
+ })();
2582
+ encodingInflight.set(encoding, loadPromise);
2583
+ try {
2584
+ return await loadPromise;
2585
+ } finally {
2586
+ encodingInflight.delete(encoding);
2587
+ }
2588
+ };
2589
+ /**
2590
+ * Pre-load the default encoder at startup so the first burst of concurrent
2591
+ * requests doesn't pay the cold BPE-table import on the shared event loop.
2592
+ */
2593
+ async function warmupTokenizer() {
2594
+ await getEncodeChatFunction("o200k_base");
2595
+ }
2596
+ /**
2597
+ * Get tokenizer type from model information
2598
+ */
2599
+ const getTokenizerFromModel = (model) => {
2600
+ return model.capabilities?.tokenizer || "o200k_base";
2601
+ };
2602
+ /**
2603
+ * Count tokens in a text string using the model's tokenizer.
2604
+ * This is a simple wrapper for counting tokens in plain text.
2605
+ */
2606
+ const countTextTokens = async (text, model) => {
2607
+ const encoder = await getEncodeChatFunction(getTokenizerFromModel(model));
2608
+ return timeSync(TIMING.TOKENIZE, () => encoder.encode(text).length);
2609
+ };
2610
+ /**
2611
+ * Get model-specific constants for token calculation.
2612
+ * These values are empirically determined based on OpenAI's function calling token overhead.
2613
+ * - funcInit: Tokens for initializing a function definition
2614
+ * - propInit: Tokens for initializing the properties section
2615
+ * - propKey: Tokens per property key
2616
+ * - enumInit: Token adjustment when enum is present (negative because type info is replaced)
2617
+ * - enumItem: Tokens per enum value
2618
+ * - funcEnd: Tokens for closing the function definition
2619
+ */
2620
+ const getModelConstants = (model) => {
2621
+ return model.id === "gpt-3.5-turbo" || model.id === "gpt-4" ? {
2622
+ funcInit: 10,
2623
+ propInit: 3,
2624
+ propKey: 3,
2625
+ enumInit: -3,
2626
+ enumItem: 3,
2627
+ funcEnd: 12
2628
+ } : {
2629
+ funcInit: 7,
2630
+ propInit: 3,
2631
+ propKey: 3,
2632
+ enumInit: -3,
2633
+ enumItem: 3,
2634
+ funcEnd: 12
2635
+ };
2636
+ };
2637
+ /**
2638
+ * Calculate tokens for a single parameter
2639
+ */
2640
+ const calculateParameterTokens = (key, prop, context) => {
2641
+ const { encoder, constants } = context;
2642
+ let tokens = constants.propKey;
2643
+ if (typeof prop !== "object" || prop === null) return tokens;
2644
+ const param = prop;
2645
+ const paramName = key;
2646
+ const paramType = param.type || "string";
2647
+ let paramDesc = param.description || "";
2648
+ if (param.enum && Array.isArray(param.enum)) {
2649
+ tokens += constants.enumInit;
2650
+ for (const item of param.enum) {
2651
+ tokens += constants.enumItem;
2652
+ tokens += encoder.encode(String(item)).length;
2653
+ }
2654
+ }
2655
+ if (paramDesc.endsWith(".")) paramDesc = paramDesc.slice(0, -1);
2656
+ const line = `${paramName}:${paramType}:${paramDesc}`;
2657
+ tokens += encoder.encode(line).length;
2658
+ const excludedKeys = new Set([
2659
+ "type",
2660
+ "description",
2661
+ "enum"
2662
+ ]);
2663
+ for (const propertyName of Object.keys(param)) if (!excludedKeys.has(propertyName)) {
2664
+ const propertyValue = param[propertyName];
2665
+ const propertyText = typeof propertyValue === "string" ? propertyValue : JSON.stringify(propertyValue);
2666
+ tokens += encoder.encode(`${propertyName}:${propertyText}`).length;
2667
+ }
2668
+ return tokens;
2669
+ };
2670
+ /**
2671
+ * Calculate tokens for function parameters
2672
+ */
2673
+ const calculateParametersTokens = (parameters, encoder, constants) => {
2674
+ if (!parameters || typeof parameters !== "object") return 0;
2675
+ const params = parameters;
2676
+ let tokens = 0;
2677
+ for (const [key, value] of Object.entries(params)) if (key === "properties") {
2678
+ const properties = value;
2679
+ if (Object.keys(properties).length > 0) {
2680
+ tokens += constants.propInit;
2681
+ for (const propKey of Object.keys(properties)) tokens += calculateParameterTokens(propKey, properties[propKey], {
2682
+ encoder,
2683
+ constants
2684
+ });
2685
+ }
2686
+ } else {
2687
+ const paramText = typeof value === "string" ? value : JSON.stringify(value);
2688
+ tokens += encoder.encode(`${key}:${paramText}`).length;
2689
+ }
2690
+ return tokens;
2691
+ };
2692
+ /**
2693
+ * Calculate tokens for a single tool
2694
+ */
2695
+ const calculateToolTokens = (tool, encoder, constants) => {
2696
+ let tokens = constants.funcInit;
2697
+ const func = tool.function;
2698
+ const fName = func.name;
2699
+ let fDesc = func.description || "";
2700
+ if (fDesc.endsWith(".")) fDesc = fDesc.slice(0, -1);
2701
+ const line = fName + ":" + fDesc;
2702
+ tokens += encoder.encode(line).length;
2703
+ if (typeof func.parameters === "object" && func.parameters !== null) tokens += calculateParametersTokens(func.parameters, encoder, constants);
2704
+ return tokens;
2705
+ };
2706
+ /**
2707
+ * Calculate token count for tools based on model
2708
+ */
2709
+ const numTokensForTools = (tools, encoder, constants) => {
2710
+ let funcTokenCount = 0;
2711
+ for (const tool of tools) funcTokenCount += calculateToolTokens(tool, encoder, constants);
2712
+ funcTokenCount += constants.funcEnd;
2713
+ return funcTokenCount;
2714
+ };
2715
+ /**
2716
+ * Calculate the token count of messages.
2717
+ * Uses the tokenizer specified by the GitHub Copilot API model info.
2718
+ * All models (including Claude) use GPT tokenizers (o200k_base or cl100k_base).
2719
+ */
2720
+ const getTokenCount = async (payload, model) => {
2721
+ const encoder = await getEncodeChatFunction(getTokenizerFromModel(model));
2722
+ return timeSync(TIMING.TOKENIZE, () => {
2723
+ const simplifiedMessages = payload.messages;
2724
+ const inputMessages = simplifiedMessages.filter((msg) => msg.role !== "assistant");
2725
+ const outputMessages = simplifiedMessages.filter((msg) => msg.role === "assistant");
2726
+ const constants = getModelConstants(model);
2727
+ let inputTokens = calculateTokens(inputMessages, encoder, constants);
2728
+ if (payload.tools && payload.tools.length > 0) inputTokens += numTokensForTools(payload.tools, encoder, constants);
2729
+ const outputTokens = calculateTokens(outputMessages, encoder, constants);
2730
+ return {
2731
+ input: inputTokens,
2732
+ output: outputTokens
2733
+ };
2734
+ });
2735
+ };
2736
+
2597
2737
  //#endregion
2598
2738
  //#region src/lib/tui/console-renderer.ts
2599
2739
  const CLEAR_LINE = "\x1B[2K\r";
@@ -2716,7 +2856,7 @@ var ConsoleRenderer = class {
2716
2856
  * Format a complete log line with colored parts
2717
2857
  */
2718
2858
  formatLogLine(parts) {
2719
- const { prefix, time, method, path, model, status, duration, tokens, queueWait, extra, isError, isDim } = parts;
2859
+ const { prefix, time, method, path, model, status, duration, tokens, queueWait, phases, extra, isError, isDim } = parts;
2720
2860
  if (isDim) {
2721
2861
  const modelPart = model ? ` ${model}` : "";
2722
2862
  const extraPart = extra ? ` ${extra}` : "";
@@ -2730,6 +2870,7 @@ var ConsoleRenderer = class {
2730
2870
  if (duration) result += ` ${pc.yellow(duration)}`;
2731
2871
  if (queueWait) result += ` ${pc.dim(`(queued ${queueWait})`)}`;
2732
2872
  if (tokens) result += ` ${pc.blue(tokens)}`;
2873
+ if (phases) result += ` ${pc.dim(phases)}`;
2733
2874
  if (extra) result += isError ? pc.red(extra) : extra;
2734
2875
  return result;
2735
2876
  }
@@ -2750,6 +2891,19 @@ var ConsoleRenderer = class {
2750
2891
  if (request.resolvedModel) return `${request.model} -> ${request.resolvedModel}`;
2751
2892
  return request.model;
2752
2893
  }
2894
+ /**
2895
+ * Compact per-phase timing breakdown for the complete line, e.g.
2896
+ * "tok=86ms ttfb=120ms". Only non-zero phases are shown, so a request that
2897
+ * skipped a phase stays terse.
2898
+ */
2899
+ formatPhases(request) {
2900
+ const parts = [];
2901
+ if (request.tokenizeMs) parts.push(`tok=${formatDuration(Math.round(request.tokenizeMs))}`);
2902
+ if (request.tokenizeColdMs) parts.push(`tok+=${formatDuration(Math.round(request.tokenizeColdMs))}`);
2903
+ if (request.upstreamTtfbMs) parts.push(`ttfb=${formatDuration(Math.round(request.upstreamTtfbMs))}`);
2904
+ if (request.limiterRetries) parts.push(`retries=${request.limiterRetries}`);
2905
+ return parts.length > 0 ? parts.join(" ") : void 0;
2906
+ }
2753
2907
  onRequestStart(request) {
2754
2908
  this.activeRequests.set(request.id, request);
2755
2909
  if (this.showActive && consola.level >= 5) {
@@ -2798,6 +2952,7 @@ var ConsoleRenderer = class {
2798
2952
  duration: formatDuration(request.durationMs ?? 0),
2799
2953
  queueWait,
2800
2954
  tokens,
2955
+ phases: this.formatPhases(request),
2801
2956
  extra: isError && request.error ? `: ${request.error}` : void 0,
2802
2957
  isError,
2803
2958
  isDim: request.isHistoryAccess
@@ -2817,7 +2972,7 @@ var ConsoleRenderer = class {
2817
2972
  //#endregion
2818
2973
  //#region src/lib/tui/tracker.ts
2819
2974
  function generateId() {
2820
- return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
2975
+ return randomUUID();
2821
2976
  }
2822
2977
  var RequestTracker = class {
2823
2978
  requests = /* @__PURE__ */ new Map();
@@ -2848,6 +3003,7 @@ var RequestTracker = class {
2848
3003
  status: "executing",
2849
3004
  isHistoryAccess: options.isHistoryAccess
2850
3005
  };
3006
+ if (this.requests.has(id)) consola.warn(`[tracker] request id collision, overwriting in-flight: ${id}`);
2851
3007
  this.requests.set(id, request);
2852
3008
  this.renderer?.onRequestStart(request);
2853
3009
  return id;
@@ -2867,6 +3023,10 @@ var RequestTracker = class {
2867
3023
  if (update.error !== void 0) request.error = update.error;
2868
3024
  if (update.queuePosition !== void 0) request.queuePosition = update.queuePosition;
2869
3025
  if (update.queueWaitMs !== void 0) request.queueWaitMs = update.queueWaitMs;
3026
+ for (const key of Object.values(TIMING)) {
3027
+ const value = update[key];
3028
+ if (value !== void 0) request[key] = value;
3029
+ }
2870
3030
  this.renderer?.onRequestUpdate(id, update);
2871
3031
  }
2872
3032
  /**
@@ -2959,35 +3119,39 @@ const requestTracker = new RequestTracker();
2959
3119
  function tuiLogger() {
2960
3120
  return async (c, next) => {
2961
3121
  if (getIsShuttingDown()) return c.json({ error: "Server is shutting down" }, 503);
2962
- const method = c.req.method;
2963
- const path = c.req.path;
2964
- const isHistoryAccess = path.startsWith("/history");
2965
- const trackingId = requestTracker.startRequest({
2966
- method,
2967
- path,
2968
- model: "",
2969
- isHistoryAccess
2970
- });
2971
- c.set("trackingId", trackingId);
2972
- try {
2973
- await next();
2974
- if ((c.res.headers.get("content-type") ?? "").includes("text/event-stream")) return;
2975
- const status = c.res.status;
2976
- const inputTokens = c.res.headers.get("x-input-tokens");
2977
- const outputTokens = c.res.headers.get("x-output-tokens");
2978
- const model = c.res.headers.get("x-model");
2979
- if (model) {
2980
- const request = requestTracker.getRequest(trackingId);
2981
- if (request) request.model = model;
3122
+ return runWithTimings(async () => {
3123
+ const method = c.req.method;
3124
+ const path = c.req.path;
3125
+ const isHistoryAccess = path.startsWith("/history");
3126
+ const trackingId = requestTracker.startRequest({
3127
+ method,
3128
+ path,
3129
+ model: "",
3130
+ isHistoryAccess
3131
+ });
3132
+ c.set("trackingId", trackingId);
3133
+ try {
3134
+ await next();
3135
+ if ((c.res.headers.get("content-type") ?? "").includes("text/event-stream")) return;
3136
+ const status = c.res.status;
3137
+ const inputTokens = c.res.headers.get("x-input-tokens");
3138
+ const outputTokens = c.res.headers.get("x-output-tokens");
3139
+ const model = c.res.headers.get("x-model");
3140
+ if (model) {
3141
+ const request = requestTracker.getRequest(trackingId);
3142
+ if (request) request.model = model;
3143
+ }
3144
+ requestTracker.updateRequest(trackingId, timingsToUpdate(getTimings()));
3145
+ requestTracker.completeRequest(trackingId, status, inputTokens && outputTokens ? {
3146
+ inputTokens: Number.parseInt(inputTokens, 10),
3147
+ outputTokens: Number.parseInt(outputTokens, 10)
3148
+ } : void 0);
3149
+ } catch (error) {
3150
+ requestTracker.updateRequest(trackingId, timingsToUpdate(getTimings()));
3151
+ requestTracker.failRequest(trackingId, error instanceof Error ? error.message : "Unknown error");
3152
+ throw error;
2982
3153
  }
2983
- requestTracker.completeRequest(trackingId, status, inputTokens && outputTokens ? {
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
- }
3154
+ });
2991
3155
  };
2992
3156
  }
2993
3157
 
@@ -3033,218 +3197,6 @@ function removeSystemReminderTags(text) {
3033
3197
  return result;
3034
3198
  }
3035
3199
 
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
3200
  //#endregion
3249
3201
  //#region src/lib/auto-truncate-openai.ts
3250
3202
  /**
@@ -4198,13 +4150,14 @@ function updateTrackerStatus(trackingId, status) {
4198
4150
  requestTracker.updateRequest(trackingId, { status });
4199
4151
  }
4200
4152
  /** Complete TUI tracking and send PostHog analytics */
4201
- function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, reasoningTokens, analytics) {
4153
+ function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, reasoningTokens, analytics, timings) {
4202
4154
  if (!trackingId) return;
4203
4155
  requestTracker.updateRequest(trackingId, {
4204
4156
  inputTokens,
4205
4157
  outputTokens,
4206
4158
  queueWaitMs,
4207
- reasoningTokens
4159
+ reasoningTokens,
4160
+ ...timingsToUpdate(timings)
4208
4161
  });
4209
4162
  requestTracker.completeRequest(trackingId, 200, {
4210
4163
  inputTokens,
@@ -4250,7 +4203,8 @@ function createEntryContext(args) {
4250
4203
  historyId: recordRequest(args.endpoint, args.buildHistoryRequest(payload)),
4251
4204
  trackingId,
4252
4205
  startTime,
4253
- requestedModel
4206
+ requestedModel,
4207
+ timings: getTimings()
4254
4208
  }
4255
4209
  };
4256
4210
  }
@@ -4506,7 +4460,7 @@ async function handleStreamingResponse$1(opts) {
4506
4460
  durationMs: Date.now() - ctx.startTime,
4507
4461
  stopReason: acc.finishReason || void 0,
4508
4462
  toolCount: payload.tools?.length ?? 0
4509
- });
4463
+ }, ctx.timings);
4510
4464
  } catch (error) {
4511
4465
  recordStreamError({
4512
4466
  acc,
@@ -6961,8 +6915,9 @@ const SSE_PING = ": ping\n\n";
6961
6915
  /**
6962
6916
  * Grace period before opening a keepalive stream. Normal upstream responses
6963
6917
  * resolve sub-second (response headers arrive immediately, the body is not
6964
- * buffered); only a request queued behind the rate limiter takes >=10s. 3s
6965
- * cleanly separates the two — a request still pending after 3s is queued.
6918
+ * buffered); only a request retrying behind the rate limiter's backoff stays
6919
+ * pending for seconds. 3s cleanly separates the two — a request still pending
6920
+ * after 3s is backing off.
6966
6921
  */
6967
6922
  const RATE_LIMIT_GRACE_MS = 3e3;
6968
6923
  /**
@@ -8449,7 +8404,7 @@ async function handleDirectAnthropicStreamingResponse(opts) {
8449
8404
  durationMs: Date.now() - ctx.startTime,
8450
8405
  stopReason: acc.stopReason || void 0,
8451
8406
  toolCount: anthropicPayload.tools?.length ?? 0
8452
- });
8407
+ }, ctx.timings);
8453
8408
  } catch (error) {
8454
8409
  consola.error("Direct Anthropic stream error:", formatError(error));
8455
8410
  recordStreamError({
@@ -8694,7 +8649,7 @@ async function handleStreamingResponse(opts) {
8694
8649
  durationMs: Date.now() - ctx.startTime,
8695
8650
  stopReason: acc.stopReason || void 0,
8696
8651
  toolCount: anthropicPayload.tools?.length ?? 0
8697
- });
8652
+ }, ctx.timings);
8698
8653
  } catch (error) {
8699
8654
  consola.error("Stream error:", formatError(error));
8700
8655
  recordStreamError({
@@ -9286,7 +9241,7 @@ const handleResponses = async (c) => {
9286
9241
  stream: true,
9287
9242
  durationMs: Date.now() - startTime,
9288
9243
  toolCount: tools.length
9289
- });
9244
+ }, ctx.timings);
9290
9245
  } else if (streamErrorMessage) {
9291
9246
  recordResponse(historyId, {
9292
9247
  success: false,
@@ -9298,8 +9253,8 @@ const handleResponses = async (c) => {
9298
9253
  error: streamErrorMessage,
9299
9254
  content: null
9300
9255
  }, Date.now() - startTime);
9301
- completeTracking(trackingId, 0, 0, queueWaitMs);
9302
- } else completeTracking(trackingId, 0, 0, queueWaitMs);
9256
+ completeTracking(trackingId, 0, 0, queueWaitMs, void 0, void 0, ctx.timings);
9257
+ } else completeTracking(trackingId, 0, 0, queueWaitMs, void 0, void 0, ctx.timings);
9303
9258
  } catch (error) {
9304
9259
  recordStreamError({
9305
9260
  acc: { model: finalResult?.model || model },
@@ -9332,7 +9287,7 @@ const handleResponses = async (c) => {
9332
9287
  stream: false,
9333
9288
  durationMs: Date.now() - startTime,
9334
9289
  toolCount: tools.length
9335
- });
9290
+ }, ctx.timings);
9336
9291
  consola.debug("Forwarding native Responses result:", JSON.stringify(result).slice(-400));
9337
9292
  return c.json(echoResponseBody(result, ctx));
9338
9293
  } catch (error) {
@@ -9505,6 +9460,7 @@ async function runServer(options) {
9505
9460
  initHistory(true, 1e3);
9506
9461
  consola.info("History recording enabled (max 1000 entries)");
9507
9462
  startMemoryPressureMonitor();
9463
+ startEventLoopLagMonitor();
9508
9464
  if (options.posthogKey) {
9509
9465
  initPostHog(options.posthogKey);
9510
9466
  if (isPostHogEnabled()) consola.info("PostHog analytics enabled");
@@ -9525,6 +9481,7 @@ async function runServer(options) {
9525
9481
  consola.error(error instanceof Error ? error.message : String(error));
9526
9482
  process.exit(1);
9527
9483
  }
9484
+ await warmupTokenizer();
9528
9485
  const allModels = state.models?.data ?? [];
9529
9486
  if (allModels.length === 0) {
9530
9487
  consola.error(`Upstream returned zero models for account type "${state.accountType}". Verify the account type matches your Copilot plan and that upstream is reachable.`);