@dianshuv/copilot-api 0.11.4 → 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 (3) hide show
  1. package/README.md +9 -49
  2. package/dist/main.mjs +1724 -2383
  3. package/package.json +1 -2
package/dist/main.mjs CHANGED
@@ -3,16 +3,13 @@ import { defineCommand, runMain } from "citty";
3
3
  import consola from "consola";
4
4
  import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
- import path, { dirname, join } from "node:path";
6
+ 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 { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
11
- import clipboard from "clipboardy";
10
+ import { AsyncLocalStorage } from "node:async_hooks";
12
11
  import { serve } from "srvx";
13
12
  import { PostHog } from "posthog-node";
14
- import { execSync } from "node:child_process";
15
- import process$1 from "node:process";
16
13
  import pc from "picocolors";
17
14
  import { Hono } from "hono";
18
15
  import { cors } from "hono/cors";
@@ -478,6 +475,56 @@ async function getGitHubUser() {
478
475
  return await response.json();
479
476
  }
480
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
+
481
528
  //#endregion
482
529
  //#region src/lib/fetch-retry.ts
483
530
  const RETRYABLE_CAUSE_CODES = new Set([
@@ -521,7 +568,9 @@ async function fetchWithRetry(input, init, options) {
521
568
  let networkAttempts = 0;
522
569
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) try {
523
570
  networkAttempts++;
571
+ const fetchStart = performance.now();
524
572
  const response = await fetch(input, currentInit);
573
+ const ttfbMs = performance.now() - fetchStart;
525
574
  if (response.status === 401 && !authRefreshed && options?.onUnauthorized) {
526
575
  const refreshed = await tryRefreshAuth(options.onUnauthorized);
527
576
  if (refreshed) {
@@ -533,6 +582,7 @@ async function fetchWithRetry(input, init, options) {
533
582
  continue;
534
583
  }
535
584
  }
585
+ addTiming(TIMING.UPSTREAM_TTFB, ttfbMs);
536
586
  annotateRetryMeta(response, networkAttempts, authRefreshed);
537
587
  return response;
538
588
  } catch (error) {
@@ -782,45 +832,6 @@ async function logUser() {
782
832
  consola.info(`Logged in as ${user.login}`);
783
833
  }
784
834
 
785
- //#endregion
786
- //#region src/auth.ts
787
- async function runAuth(options) {
788
- if (options.verbose) {
789
- consola.level = 5;
790
- consola.info("Verbose logging enabled");
791
- }
792
- state.showToken = options.showToken;
793
- initProxyFromEnv();
794
- await ensurePaths();
795
- await setupGitHubToken({ force: true });
796
- consola.success("GitHub token written to", PATHS.GITHUB_TOKEN_PATH);
797
- }
798
- const auth = defineCommand({
799
- meta: {
800
- name: "auth",
801
- description: "Run GitHub auth flow without running the server"
802
- },
803
- args: {
804
- verbose: {
805
- alias: "v",
806
- type: "boolean",
807
- default: false,
808
- description: "Enable verbose logging"
809
- },
810
- "show-token": {
811
- type: "boolean",
812
- default: false,
813
- description: "Show GitHub token on auth"
814
- }
815
- },
816
- run({ args }) {
817
- return runAuth({
818
- verbose: args.verbose,
819
- showToken: args["show-token"]
820
- });
821
- }
822
- });
823
-
824
835
  //#endregion
825
836
  //#region src/services/github/get-copilot-usage.ts
826
837
  const getCopilotUsage = async () => {
@@ -829,43 +840,6 @@ const getCopilotUsage = async () => {
829
840
  return await response.json();
830
841
  };
831
842
 
832
- //#endregion
833
- //#region src/check-usage.ts
834
- const checkUsage = defineCommand({
835
- meta: {
836
- name: "check-usage",
837
- description: "Show current GitHub Copilot usage/quota information"
838
- },
839
- async run() {
840
- initProxyFromEnv();
841
- await ensurePaths();
842
- await setupGitHubToken();
843
- try {
844
- const usage = await getCopilotUsage();
845
- const premium = usage.quota_snapshots.premium_interactions;
846
- const premiumTotal = premium.entitlement;
847
- const premiumUsed = premiumTotal - premium.remaining;
848
- const premiumPercentUsed = premiumTotal > 0 ? premiumUsed / premiumTotal * 100 : 0;
849
- const premiumPercentRemaining = premium.percent_remaining;
850
- function summarizeQuota(name, snap) {
851
- if (!snap) return `${name}: N/A`;
852
- const total = snap.entitlement;
853
- const used = total - snap.remaining;
854
- const percentUsed = total > 0 ? used / total * 100 : 0;
855
- const percentRemaining = snap.percent_remaining;
856
- return `${name}: ${used}/${total} used (${percentUsed.toFixed(1)}% used, ${percentRemaining.toFixed(1)}% remaining)`;
857
- }
858
- const premiumLine = `Premium: ${premiumUsed}/${premiumTotal} used (${premiumPercentUsed.toFixed(1)}% used, ${premiumPercentRemaining.toFixed(1)}% remaining)`;
859
- const chatLine = summarizeQuota("Chat", usage.quota_snapshots.chat);
860
- const completionsLine = summarizeQuota("Completions", usage.quota_snapshots.completions);
861
- consola.box(`Copilot Usage (plan: ${usage.copilot_plan})\nQuota resets: ${usage.quota_reset_date}\n\nQuotas:\n ${premiumLine}\n ${chatLine}\n ${completionsLine}`);
862
- } catch (err) {
863
- consola.error("Failed to fetch Copilot usage:", err);
864
- process.exit(1);
865
- }
866
- }
867
- });
868
-
869
843
  //#endregion
870
844
  //#region src/debug.ts
871
845
  async function getPackageVersion() {
@@ -1008,6 +982,45 @@ const debug = defineCommand({
1008
982
  }
1009
983
  });
1010
984
 
985
+ //#endregion
986
+ //#region src/login.ts
987
+ async function runLogin(options) {
988
+ if (options.verbose) {
989
+ consola.level = 5;
990
+ consola.info("Verbose logging enabled");
991
+ }
992
+ state.showToken = options.showToken;
993
+ initProxyFromEnv();
994
+ await ensurePaths();
995
+ await setupGitHubToken({ force: true });
996
+ consola.success("GitHub token written to", PATHS.GITHUB_TOKEN_PATH);
997
+ }
998
+ const login = defineCommand({
999
+ meta: {
1000
+ name: "login",
1001
+ description: "Run GitHub auth flow without running the server"
1002
+ },
1003
+ args: {
1004
+ verbose: {
1005
+ alias: "v",
1006
+ type: "boolean",
1007
+ default: false,
1008
+ description: "Enable verbose logging"
1009
+ },
1010
+ "show-token": {
1011
+ type: "boolean",
1012
+ default: false,
1013
+ description: "Show GitHub token on auth"
1014
+ }
1015
+ },
1016
+ run({ args }) {
1017
+ return runLogin({
1018
+ verbose: args.verbose,
1019
+ showToken: args["show-token"]
1020
+ });
1021
+ }
1022
+ });
1023
+
1011
1024
  //#endregion
1012
1025
  //#region src/logout.ts
1013
1026
  async function runLogout() {
@@ -1033,2004 +1046,1693 @@ const logout = defineCommand({
1033
1046
  });
1034
1047
 
1035
1048
  //#endregion
1036
- //#region src/patch-claude-code.ts
1037
- const SUPPORTED_VERSIONS = {
1038
- v2a: {
1039
- min: "2.0.0",
1040
- max: "2.1.10"
1041
- },
1042
- v2b: { min: "2.1.11" }
1043
- };
1044
- const PATTERNS = {
1045
- funcOriginal: /function HR\(A\)\{if\(A\.includes\("\[1m\]"\)\)return 1e6;return 200000\}/,
1046
- funcPatched: /function HR\(A\)\{if\(A\.includes\("\[1m\]"\)\)return 1e6;return \d+\}/,
1047
- variable: /var ([A-Za-z_$]\w*)=(\d+)(?=,\w+=20000,)/
1048
- };
1049
- /**
1050
- * Parse semver version string to comparable parts
1051
- */
1052
- function parseVersion(version) {
1053
- return version.split(".").map((n) => Number.parseInt(n, 10) || 0);
1054
- }
1055
- /**
1056
- * Compare two semver versions
1057
- * Returns: -1 if a < b, 0 if a == b, 1 if a > b
1058
- */
1059
- function compareVersions(a, b) {
1060
- const partsA = parseVersion(a);
1061
- const partsB = parseVersion(b);
1062
- const len = Math.max(partsA.length, partsB.length);
1063
- for (let i = 0; i < len; i++) {
1064
- const numA = partsA[i] || 0;
1065
- const numB = partsB[i] || 0;
1066
- if (numA < numB) return -1;
1067
- if (numA > numB) return 1;
1049
+ //#region package.json
1050
+ var version = "0.13.0";
1051
+
1052
+ //#endregion
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;
1068
1087
  }
1069
- return 0;
1070
- }
1071
- function getPatternTypeForVersion(version) {
1072
- if (compareVersions(version, SUPPORTED_VERSIONS.v2a.min) >= 0 && compareVersions(version, SUPPORTED_VERSIONS.v2a.max) <= 0) return "func";
1073
- if (compareVersions(version, SUPPORTED_VERSIONS.v2b.min) >= 0) return "variable";
1074
- return null;
1075
1088
  }
1089
+
1090
+ //#endregion
1091
+ //#region src/lib/history-ws.ts
1076
1092
  /**
1077
- * Get supported version range string for error messages
1093
+ * WebSocket support for History API.
1094
+ * Enables real-time updates when new requests are recorded.
1078
1095
  */
1079
- function getSupportedRangeString() {
1080
- return `${SUPPORTED_VERSIONS.v2a.min}-${SUPPORTED_VERSIONS.v2a.max}, ${SUPPORTED_VERSIONS.v2b.min}+`;
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));
1081
1105
  }
1082
- /**
1083
- * Get Claude Code version from package.json
1084
- */
1085
- function getClaudeCodeVersion(cliPath) {
1086
- try {
1087
- const packageJsonPath = join(dirname(cliPath), "package.json");
1088
- if (!existsSync(packageJsonPath)) return null;
1089
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
1090
- if (typeof packageJson === "object" && packageJson !== null && "version" in packageJson && typeof packageJson.version === "string") return packageJson.version;
1091
- return null;
1092
- } catch {
1093
- return null;
1094
- }
1106
+ function removeClient(ws) {
1107
+ clients.delete(ws);
1095
1108
  }
1096
- /**
1097
- * Search volta tools directory for Claude Code
1098
- */
1099
- function findInVoltaTools(voltaHome) {
1100
- const paths = [];
1101
- const packagesPath = join(voltaHome, "tools", "image", "packages", "@anthropic-ai", "claude-code", "lib", "node_modules", "@anthropic-ai", "claude-code", "cli.js");
1102
- if (existsSync(packagesPath)) paths.push(packagesPath);
1103
- const toolsDir = join(voltaHome, "tools", "image", "node");
1104
- if (existsSync(toolsDir)) try {
1105
- for (const version of readdirSync(toolsDir)) {
1106
- const claudePath = join(toolsDir, version, "lib", "node_modules", "@anthropic-ai", "claude-code", "cli.js");
1107
- if (existsSync(claudePath)) paths.push(claudePath);
1108
- }
1109
- } catch {}
1110
- return paths;
1111
- }
1112
- /**
1113
- * Find all Claude Code CLI paths by checking common locations
1114
- */
1115
- function findAllClaudeCodePaths() {
1116
- const possiblePaths = [];
1117
- const home = process.env.HOME || "";
1118
- const voltaHome = process.env.VOLTA_HOME || join(home, ".volta");
1119
- if (existsSync(voltaHome)) possiblePaths.push(...findInVoltaTools(voltaHome));
1120
- const npmPrefix = process.env.npm_config_prefix;
1121
- if (npmPrefix) possiblePaths.push(join(npmPrefix, "lib", "node_modules", "@anthropic-ai", "claude-code", "cli.js"));
1122
- const globalPaths = [
1123
- join(home, ".npm-global", "lib", "node_modules"),
1124
- "/usr/local/lib/node_modules",
1125
- "/usr/lib/node_modules"
1126
- ];
1127
- for (const base of globalPaths) possiblePaths.push(join(base, "@anthropic-ai", "claude-code", "cli.js"));
1128
- const bunGlobal = join(home, ".bun", "install", "global");
1129
- if (existsSync(bunGlobal)) possiblePaths.push(join(bunGlobal, "node_modules", "@anthropic-ai", "claude-code", "cli.js"));
1130
- return [...new Set(possiblePaths.filter((p) => existsSync(p)))];
1109
+ function getClientCount() {
1110
+ return clients.size;
1131
1111
  }
1132
- /**
1133
- * Get installation info for a CLI path
1134
- */
1135
- function getInstallationInfo(cliPath) {
1136
- return {
1137
- path: cliPath,
1138
- version: getClaudeCodeVersion(cliPath),
1139
- limit: getCurrentLimit(readFileSync(cliPath, "utf8"))
1140
- };
1112
+ function closeAllClients() {
1113
+ for (const client of clients) try {
1114
+ client.close(1001, "Server shutting down");
1115
+ } catch {}
1116
+ clients.clear();
1141
1117
  }
1142
- function getCurrentLimitInfo(content) {
1143
- const varMatch = content.match(PATTERNS.variable);
1144
- if (varMatch) return {
1145
- limit: Number.parseInt(varMatch[2], 10),
1146
- varName: varMatch[1]
1147
- };
1148
- const funcMatch = content.match(PATTERNS.funcPatched);
1149
- if (funcMatch) {
1150
- const limitMatch = funcMatch[0].match(/return (\d+)\}$/);
1151
- return limitMatch ? { limit: Number.parseInt(limitMatch[1], 10) } : null;
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);
1152
1126
  }
1153
- return null;
1154
1127
  }
1155
- /**
1156
- * Get current context limit from Claude Code (legacy wrapper)
1157
- */
1158
- function getCurrentLimit(content) {
1159
- return getCurrentLimitInfo(content)?.limit ?? null;
1128
+ function notifyEntryAdded(summary) {
1129
+ if (clients.size === 0) return;
1130
+ broadcast({
1131
+ type: "entry_added",
1132
+ data: summary,
1133
+ timestamp: Date.now()
1134
+ });
1160
1135
  }
1161
- /**
1162
- * Check if Claude Code version is supported for patching
1163
- */
1164
- function checkVersionSupport(cliPath) {
1165
- const version = getClaudeCodeVersion(cliPath);
1166
- if (!version) return {
1167
- supported: false,
1168
- version: null,
1169
- patternType: null,
1170
- error: "Could not detect Claude Code version"
1171
- };
1172
- const patternType = getPatternTypeForVersion(version);
1173
- if (!patternType) return {
1174
- supported: false,
1175
- version,
1176
- patternType: null,
1177
- error: `Version ${version} is not supported. Supported: ${getSupportedRangeString()}`
1178
- };
1179
- return {
1180
- supported: true,
1181
- version,
1182
- patternType
1183
- };
1136
+ function notifyEntryUpdated(summary) {
1137
+ if (clients.size === 0) return;
1138
+ broadcast({
1139
+ type: "entry_updated",
1140
+ data: summary,
1141
+ timestamp: Date.now()
1142
+ });
1184
1143
  }
1185
- /**
1186
- * Patch Claude Code to use a different context limit
1187
- */
1188
- function patchClaudeCode(cliPath, newLimit) {
1189
- const content = readFileSync(cliPath, "utf8");
1190
- const versionCheck = checkVersionSupport(cliPath);
1191
- if (!versionCheck.supported) {
1192
- consola.error(versionCheck.error);
1193
- return "failed";
1194
- }
1195
- consola.info(`Claude Code version: ${versionCheck.version}`);
1196
- const limitInfo = getCurrentLimitInfo(content);
1197
- if (limitInfo?.limit === newLimit) return "already_patched";
1198
- let newContent;
1199
- if (versionCheck.patternType === "variable") {
1200
- if (!limitInfo?.varName) {
1201
- consola.error("Could not detect variable name for patching");
1202
- return "failed";
1203
- }
1204
- newContent = content.replace(PATTERNS.variable, `var ${limitInfo.varName}=${newLimit}`);
1205
- } else {
1206
- const replacement = `function HR(A){if(A.includes("[1m]"))return 1e6;return ${newLimit}}`;
1207
- const pattern = PATTERNS.funcOriginal.test(content) ? PATTERNS.funcOriginal : PATTERNS.funcPatched;
1208
- newContent = content.replace(pattern, replacement);
1209
- }
1210
- writeFileSync(cliPath, newContent);
1211
- return "success";
1144
+ function notifyStatsUpdated(stats) {
1145
+ if (clients.size === 0) return;
1146
+ broadcast({
1147
+ type: "stats_updated",
1148
+ data: stats,
1149
+ timestamp: Date.now()
1150
+ });
1212
1151
  }
1213
- /**
1214
- * Restore Claude Code to original 200k limit
1215
- */
1216
- function restoreClaudeCode(cliPath) {
1217
- const content = readFileSync(cliPath, "utf8");
1218
- const versionCheck = checkVersionSupport(cliPath);
1219
- if (!versionCheck.supported) {
1220
- consola.error(versionCheck.error);
1221
- return false;
1222
- }
1223
- consola.info(`Claude Code version: ${versionCheck.version}`);
1224
- const limitInfo = getCurrentLimitInfo(content);
1225
- if (limitInfo?.limit === 2e5) {
1226
- consola.info("Already at original 200000 limit");
1227
- return true;
1228
- }
1229
- let newContent;
1230
- if (versionCheck.patternType === "variable") {
1231
- if (!limitInfo?.varName) {
1232
- consola.error("Could not detect variable name for restoring");
1233
- return false;
1234
- }
1235
- newContent = content.replace(PATTERNS.variable, `var ${limitInfo.varName}=200000`);
1236
- } else newContent = content.replace(PATTERNS.funcPatched, "function HR(A){if(A.includes(\"[1m]\"))return 1e6;return 200000}");
1237
- writeFileSync(cliPath, newContent);
1238
- return true;
1152
+ function notifyHistoryCleared() {
1153
+ if (clients.size === 0) return;
1154
+ broadcast({
1155
+ type: "history_cleared",
1156
+ data: null,
1157
+ timestamp: Date.now()
1158
+ });
1239
1159
  }
1240
- function showStatus(cliPath, currentLimit) {
1241
- const version = getClaudeCodeVersion(cliPath);
1242
- if (version) consola.info(`Claude Code version: ${version}`);
1243
- if (currentLimit === null) {
1244
- consola.warn("Could not detect current limit - CLI may have been updated");
1245
- consola.info("Look for a variable like 'var XXX=200000' followed by ',YYY=20000,' in cli.js");
1246
- } else if (currentLimit === 2e5) consola.info("Status: Original (200k context window)");
1247
- else consola.info(`Status: Patched (${currentLimit} context window)`);
1160
+ function notifySessionDeleted(sessionId) {
1161
+ if (clients.size === 0) return;
1162
+ broadcast({
1163
+ type: "session_deleted",
1164
+ data: { sessionId },
1165
+ timestamp: Date.now()
1166
+ });
1248
1167
  }
1249
- const patchClaude = defineCommand({
1250
- meta: {
1251
- name: "patch-claude",
1252
- description: "Patch Claude Code's context window limit to match Copilot's limits"
1253
- },
1254
- args: {
1255
- limit: {
1256
- alias: "l",
1257
- type: "string",
1258
- default: "128000",
1259
- description: "Context window limit in tokens (default: 128000 for Copilot)"
1260
- },
1261
- restore: {
1262
- alias: "r",
1263
- type: "boolean",
1264
- default: false,
1265
- description: "Restore original 200k limit"
1266
- },
1267
- path: {
1268
- alias: "p",
1269
- type: "string",
1270
- description: "Path to Claude Code cli.js (auto-detected if not specified)"
1271
- },
1272
- status: {
1273
- alias: "s",
1274
- type: "boolean",
1275
- default: false,
1276
- description: "Show current patch status without modifying"
1277
- }
1278
- },
1279
- async run({ args }) {
1280
- let cliPath;
1281
- if (args.path) {
1282
- cliPath = args.path;
1283
- if (!existsSync(cliPath)) {
1284
- consola.error(`File not found: ${cliPath}`);
1285
- process.exit(1);
1286
- }
1287
- } else {
1288
- const installations = findAllClaudeCodePaths();
1289
- if (installations.length === 0) {
1290
- consola.error("Could not find Claude Code installation");
1291
- consola.info("Searched in: volta, npm global, bun global");
1292
- consola.info("Use --path to specify the path to cli.js manually");
1293
- process.exit(1);
1294
- }
1295
- if (installations.length === 1) cliPath = installations[0];
1296
- else {
1297
- consola.info(`Found ${installations.length} Claude Code installations:`);
1298
- const options = installations.map((path) => {
1299
- const info = getInstallationInfo(path);
1300
- let status = "unknown";
1301
- if (info.limit === 2e5) status = "original";
1302
- else if (info.limit) status = `patched: ${info.limit}`;
1303
- return {
1304
- label: `v${info.version ?? "?"} (${status}) - ${path}`,
1305
- value: path
1306
- };
1307
- });
1308
- const selected = await consola.prompt("Select installation to patch:", {
1309
- type: "select",
1310
- options
1311
- });
1312
- if (typeof selected === "symbol") process.exit(0);
1313
- cliPath = selected;
1314
- }
1315
- }
1316
- consola.info(`Claude Code path: ${cliPath}`);
1317
- const currentLimit = getCurrentLimit(readFileSync(cliPath, "utf8"));
1318
- if (args.status) {
1319
- showStatus(cliPath, currentLimit);
1320
- return;
1321
- }
1322
- if (args.restore) {
1323
- if (restoreClaudeCode(cliPath)) consola.success("Restored to original 200k limit");
1324
- else {
1325
- consola.error("Failed to restore - pattern not found");
1326
- consola.info("Claude Code may have been updated to a new version");
1327
- process.exit(1);
1328
- }
1329
- return;
1330
- }
1331
- const limit = Number.parseInt(args.limit, 10);
1332
- if (Number.isNaN(limit) || limit < 1e3) {
1333
- consola.error("Invalid limit value. Must be a number >= 1000");
1334
- process.exit(1);
1335
- }
1336
- const result = patchClaudeCode(cliPath, limit);
1337
- if (result === "success") {
1338
- consola.success(`Patched context window: ${currentLimit ?? 2e5} → ${limit}`);
1339
- consola.info("Note: You may need to re-run this after Claude Code updates");
1340
- } else if (result === "already_patched") consola.success(`Already patched with limit ${limit}`);
1341
- else {
1342
- consola.error("Failed to patch - pattern not found");
1343
- consola.info("Claude Code may have been updated to a new version");
1344
- process.exit(1);
1345
- }
1346
- }
1347
- });
1348
1168
 
1349
1169
  //#endregion
1350
- //#region package.json
1351
- var version = "0.11.4";
1352
-
1353
- //#endregion
1354
- //#region src/lib/adaptive-rate-limiter.ts
1355
- const DEFAULT_CONFIG$1 = {
1356
- baseRetryIntervalSeconds: 10,
1357
- maxRetryIntervalSeconds: 120,
1358
- requestIntervalSeconds: 10,
1359
- recoveryTimeoutMinutes: 10,
1360
- consecutiveSuccessesForRecovery: 5,
1361
- gradualRecoverySteps: [
1362
- 5,
1363
- 2,
1364
- 1,
1365
- 0
1366
- ]
1170
+ //#region src/lib/history.ts
1171
+ function generateId$1() {
1172
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 9);
1173
+ }
1174
+ const historyState = {
1175
+ enabled: false,
1176
+ entries: [],
1177
+ sessions: /* @__PURE__ */ new Map(),
1178
+ currentSessionId: "",
1179
+ maxEntries: 1e3,
1180
+ sessionTimeoutMs: 1800 * 1e3
1367
1181
  };
1368
- /**
1369
- * Adaptive rate limiter that switches between normal, rate-limited, and recovering modes
1370
- * based on API responses.
1371
- */
1372
- var AdaptiveRateLimiter = class {
1373
- config;
1374
- mode = "normal";
1375
- queue = [];
1376
- processing = false;
1377
- rateLimitedAt = null;
1378
- consecutiveSuccesses = 0;
1379
- lastRequestTime = 0;
1380
- /** Current step in gradual recovery (index into gradualRecoverySteps) */
1381
- recoveryStepIndex = 0;
1382
- constructor(config = {}) {
1383
- this.config = {
1384
- ...DEFAULT_CONFIG$1,
1385
- ...config
1386
- };
1387
- }
1388
- /**
1389
- * Execute a request with adaptive rate limiting.
1390
- * Returns a promise that resolves when the request succeeds.
1391
- * The request will be retried automatically on 429 errors.
1392
- */
1393
- async execute(fn) {
1394
- if (this.mode === "normal") return this.executeInNormalMode(fn);
1395
- if (this.mode === "recovering") return this.executeInRecoveringMode(fn);
1396
- return this.enqueue(fn);
1397
- }
1398
- /**
1399
- * Check if an error is a rate limit error (429) and extract Retry-After if available
1400
- */
1401
- isRateLimitError(error) {
1402
- if (error && typeof error === "object") {
1403
- if ("status" in error && error.status === 429) return {
1404
- isRateLimit: true,
1405
- retryAfter: this.extractRetryAfter(error)
1406
- };
1407
- if ("responseText" in error && typeof error.responseText === "string") try {
1408
- const parsed = JSON.parse(error.responseText);
1409
- 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 };
1410
- } catch {}
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();
1190
+ }
1191
+ function isHistoryEnabled() {
1192
+ return historyState.enabled;
1193
+ }
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;
1411
1201
  }
1412
- return { isRateLimit: false };
1413
- }
1414
- /**
1415
- * Extract Retry-After value from error response
1416
- */
1417
- extractRetryAfter(error) {
1418
- if (!error || typeof error !== "object") return void 0;
1419
- if ("responseText" in error && typeof error.responseText === "string") try {
1420
- const parsed = JSON.parse(error.responseText);
1421
- if (parsed && typeof parsed === "object" && "retry_after" in parsed && typeof parsed.retry_after === "number") return parsed.retry_after;
1422
- 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;
1423
- } catch {}
1424
1202
  }
1425
- /**
1426
- * Execute in normal mode - full speed
1427
- */
1428
- async executeInNormalMode(fn) {
1429
- try {
1430
- return {
1431
- result: await fn(),
1432
- queueWaitMs: 0
1433
- };
1434
- } catch (error) {
1435
- const { isRateLimit, retryAfter } = this.isRateLimitError(error);
1436
- if (isRateLimit) {
1437
- this.enterRateLimitedMode();
1438
- return this.enqueue(fn, retryAfter);
1439
- }
1440
- throw error;
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;
1216
+ }
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
1441
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);
1442
1244
  }
1443
- /**
1444
- * Execute in recovering mode - gradual speedup
1445
- */
1446
- async executeInRecoveringMode(fn) {
1447
- const startTime = Date.now();
1448
- const currentInterval = this.config.gradualRecoverySteps[this.recoveryStepIndex] ?? 0;
1449
- if (currentInterval > 0) {
1450
- const elapsedMs = Date.now() - this.lastRequestTime;
1451
- const requiredMs = currentInterval * 1e3;
1452
- if (this.lastRequestTime > 0 && elapsedMs < requiredMs) {
1453
- const waitMs = requiredMs - elapsedMs;
1454
- await this.sleep(waitMs);
1455
- }
1456
- }
1457
- this.lastRequestTime = Date.now();
1458
- try {
1459
- const result = await fn();
1460
- this.recoveryStepIndex++;
1461
- if (this.recoveryStepIndex >= this.config.gradualRecoverySteps.length) this.completeRecovery();
1462
- else {
1463
- const nextInterval = this.config.gradualRecoverySteps[this.recoveryStepIndex] ?? 0;
1464
- consola.info(`[RateLimiter] Recovery step ${this.recoveryStepIndex}/${this.config.gradualRecoverySteps.length} (next interval: ${nextInterval}s)`);
1465
- }
1466
- return {
1467
- result,
1468
- queueWaitMs: Date.now() - startTime
1469
- };
1470
- } catch (error) {
1471
- const { isRateLimit, retryAfter } = this.isRateLimitError(error);
1472
- if (isRateLimit) {
1473
- consola.warn("[RateLimiter] Hit rate limit during recovery, returning to rate-limited mode");
1474
- this.enterRateLimitedMode();
1475
- return this.enqueue(fn, retryAfter);
1476
- }
1477
- throw error;
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);
1478
1250
  }
1479
1251
  }
1480
- /**
1481
- * Enter rate-limited mode
1482
- */
1483
- enterRateLimitedMode() {
1484
- if (this.mode === "rate-limited") return;
1485
- this.mode = "rate-limited";
1486
- this.rateLimitedAt = Date.now();
1487
- this.consecutiveSuccesses = 0;
1488
- consola.warn(`[RateLimiter] Entering rate-limited mode. Requests will be queued with exponential backoff (base: ${this.config.baseRetryIntervalSeconds}s).`);
1489
- }
1490
- /**
1491
- * Check if we should try to recover to normal mode
1492
- */
1493
- shouldAttemptRecovery() {
1494
- if (this.consecutiveSuccesses >= this.config.consecutiveSuccessesForRecovery) {
1495
- consola.info(`[RateLimiter] ${this.consecutiveSuccesses} consecutive successes. Starting gradual recovery.`);
1496
- return true;
1497
- }
1498
- if (this.rateLimitedAt) {
1499
- if (Date.now() - this.rateLimitedAt >= this.config.recoveryTimeoutMinutes * 60 * 1e3) {
1500
- consola.info(`[RateLimiter] ${this.config.recoveryTimeoutMinutes} minutes elapsed. Starting gradual recovery.`);
1501
- return true;
1502
- }
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();
1503
1272
  }
1504
- return false;
1505
- }
1506
- /**
1507
- * Start gradual recovery mode
1508
- */
1509
- startGradualRecovery() {
1510
- this.mode = "recovering";
1511
- this.recoveryStepIndex = 0;
1512
- this.rateLimitedAt = null;
1513
- this.consecutiveSuccesses = 0;
1514
- const firstInterval = this.config.gradualRecoverySteps[0] ?? 0;
1515
- consola.info(`[RateLimiter] Starting gradual recovery (${this.config.gradualRecoverySteps.length} steps, first interval: ${firstInterval}s)`);
1516
- }
1517
- /**
1518
- * Complete recovery to normal mode
1519
- */
1520
- completeRecovery() {
1521
- this.mode = "normal";
1522
- this.recoveryStepIndex = 0;
1523
- consola.success("[RateLimiter] Recovery complete. Full speed enabled.");
1524
- }
1525
- /**
1526
- * Enqueue a request for later execution
1527
- */
1528
- enqueue(fn, retryAfterSeconds) {
1529
- return new Promise((resolve, reject) => {
1530
- const request = {
1531
- execute: fn,
1532
- resolve,
1533
- reject,
1534
- retryCount: 0,
1535
- retryAfterSeconds,
1536
- enqueuedAt: Date.now()
1537
- };
1538
- this.queue.push(request);
1539
- if (this.queue.length > 1) {
1540
- const position = this.queue.length;
1541
- const estimatedWait = (position - 1) * this.config.requestIntervalSeconds;
1542
- consola.info(`[RateLimiter] Request queued (position ${position}, ~${estimatedWait}s wait)`);
1543
- }
1544
- this.processQueue();
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
1545
1286
  });
1546
1287
  }
1547
- /**
1548
- * Calculate retry interval with exponential backoff
1549
- */
1550
- calculateRetryInterval(request) {
1551
- if (request.retryAfterSeconds !== void 0 && request.retryAfterSeconds > 0) return request.retryAfterSeconds;
1552
- const backoff = this.config.baseRetryIntervalSeconds * Math.pow(2, request.retryCount);
1553
- return Math.min(backoff, this.config.maxRetryIntervalSeconds);
1554
- }
1555
- /**
1556
- * Process the queue
1557
- */
1558
- async processQueue() {
1559
- if (this.processing) return;
1560
- this.processing = true;
1561
- while (this.queue.length > 0) {
1562
- const request = this.queue[0];
1563
- if (this.shouldAttemptRecovery()) this.startGradualRecovery();
1564
- const elapsedMs = Date.now() - this.lastRequestTime;
1565
- const requiredMs = (request.retryCount > 0 ? this.calculateRetryInterval(request) : this.config.requestIntervalSeconds) * 1e3;
1566
- if (this.lastRequestTime > 0 && elapsedMs < requiredMs) {
1567
- const waitMs = requiredMs - elapsedMs;
1568
- const waitSec = Math.ceil(waitMs / 1e3);
1569
- consola.info(`[RateLimiter] Waiting ${waitSec}s before next request...`);
1570
- await this.sleep(waitMs);
1571
- }
1572
- this.lastRequestTime = Date.now();
1573
- try {
1574
- const result = await request.execute();
1575
- this.queue.shift();
1576
- this.consecutiveSuccesses++;
1577
- request.retryAfterSeconds = void 0;
1578
- const queueWaitMs = Date.now() - request.enqueuedAt;
1579
- request.resolve({
1580
- result,
1581
- queueWaitMs
1582
- });
1583
- if (this.mode === "rate-limited") consola.info(`[RateLimiter] Request succeeded (${this.consecutiveSuccesses}/${this.config.consecutiveSuccessesForRecovery} for recovery)`);
1584
- } catch (error) {
1585
- const { isRateLimit, retryAfter } = this.isRateLimitError(error);
1586
- if (isRateLimit) {
1587
- request.retryCount++;
1588
- request.retryAfterSeconds = retryAfter;
1589
- this.consecutiveSuccesses = 0;
1590
- this.rateLimitedAt = Date.now();
1591
- const nextInterval = this.calculateRetryInterval(request);
1592
- const source = retryAfter ? "server Retry-After" : "exponential backoff";
1593
- consola.warn(`[RateLimiter] Request failed with 429 (retry #${request.retryCount}). Retrying in ${nextInterval}s (${source})...`);
1594
- } else {
1595
- this.queue.shift();
1596
- request.reject(error);
1597
- }
1598
- }
1599
- }
1600
- this.processing = false;
1601
- }
1602
- sleep(ms) {
1603
- return new Promise((resolve) => setTimeout(resolve, ms));
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));
1604
1296
  }
1605
- /**
1606
- * Reject all currently queued requests during shutdown.
1607
- * Returns the number of requests that were rejected.
1608
- */
1609
- rejectQueued() {
1610
- const count = this.queue.length;
1611
- for (const request of this.queue) request.reject(/* @__PURE__ */ new Error("Server is shutting down"));
1612
- this.queue = [];
1613
- return count;
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;
1614
1312
  }
1615
- /**
1616
- * Get current status for debugging/monitoring
1617
- */
1618
- getStatus() {
1619
- return {
1620
- mode: this.mode,
1621
- queueLength: this.queue.length,
1622
- consecutiveSuccesses: this.consecutiveSuccesses,
1623
- rateLimitedAt: this.rateLimitedAt
1624
- };
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;
1322
+ });
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
+ });
1625
1328
  }
1626
- };
1627
- let rateLimiterInstance = null;
1628
- /**
1629
- * Initialize the adaptive rate limiter with configuration
1630
- */
1631
- function initAdaptiveRateLimiter(config = {}) {
1632
- rateLimiterInstance = new AdaptiveRateLimiter(config);
1633
- const baseRetry = config.baseRetryIntervalSeconds ?? DEFAULT_CONFIG$1.baseRetryIntervalSeconds;
1634
- const maxRetry = config.maxRetryIntervalSeconds ?? DEFAULT_CONFIG$1.maxRetryIntervalSeconds;
1635
- const interval = config.requestIntervalSeconds ?? DEFAULT_CONFIG$1.requestIntervalSeconds;
1636
- const recovery = config.recoveryTimeoutMinutes ?? DEFAULT_CONFIG$1.recoveryTimeoutMinutes;
1637
- const successes = config.consecutiveSuccessesForRecovery ?? DEFAULT_CONFIG$1.consecutiveSuccessesForRecovery;
1638
- const steps = config.gradualRecoverySteps ?? DEFAULT_CONFIG$1.gradualRecoverySteps;
1639
- consola.info(`[RateLimiter] Initialized (backoff: ${baseRetry}s-${maxRetry}s, interval: ${interval}s, recovery: ${recovery}min or ${successes} successes, gradual: [${steps.join("s, ")}s])`);
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
1339
+ };
1640
1340
  }
1641
- /**
1642
- * Get the rate limiter instance
1643
- */
1644
- function getAdaptiveRateLimiter() {
1645
- return rateLimiterInstance;
1341
+ function getEntry(id) {
1342
+ return entryIndex.get(id);
1646
1343
  }
1647
- /**
1648
- * Execute a request with adaptive rate limiting.
1649
- * If rate limiter is not initialized, executes immediately.
1650
- * Returns the result along with queue wait time.
1651
- */
1652
- async function executeWithAdaptiveRateLimit(fn) {
1653
- if (!rateLimiterInstance) return {
1654
- result: await fn(),
1655
- queueWaitMs: 0
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
1656
1349
  };
1657
- return rateLimiterInstance.execute(fn);
1658
1350
  }
1659
-
1660
- //#endregion
1661
- //#region src/lib/auth-gate.ts
1662
- /**
1663
- * Auth gate — the inbound authentication decision point for the proxy.
1664
- *
1665
- * Protects this proxy's *inbound* surface with a configured **Proxy API key**
1666
- * (NOT the outbound GitHub OAuth token or Copilot token). The decision logic
1667
- * is expressed as pure functions so it can be unit-tested without booting the
1668
- * server or reaching upstream.
1669
- */
1670
- /**
1671
- * Extract candidate presented credential values from request headers.
1672
- *
1673
- * Two header shapes are read, and **both** contribute candidates when present
1674
- * (compare-all-present) so neither is silently ignored in favor of the other —
1675
- * a later any-match over the candidates decides acceptance:
1676
- * - `Authorization`: the scheme prefix is stripped case-insensitively
1677
- * (`Bearer ` / `bearer ` …) because the scheme is case-insensitive per
1678
- * RFC 7235, while the secret itself is case-sensitive. A bare value with no
1679
- * scheme prefix is tolerated and returned verbatim.
1680
- * - `x-api-key` (Issue 02): the Anthropic-native header. Taken verbatim — no
1681
- * scheme stripping (a value that happens to start with `Bearer ` is kept
1682
- * as-is).
1683
- *
1684
- * Order is `[Authorization, x-api-key]` for any present header; absent headers
1685
- * contribute nothing.
1686
- */
1687
- function extractCredentials(headers) {
1688
- const candidates = [];
1689
- const authorization = headers.get("authorization");
1690
- if (authorization !== null) candidates.push(authorization.replace(/^Bearer\s+/i, ""));
1691
- const apiKey = headers.get("x-api-key");
1692
- if (apiKey !== null) candidates.push(apiKey);
1693
- return candidates;
1351
+ function getSession(id) {
1352
+ return historyState.sessions.get(id);
1694
1353
  }
1695
- /**
1696
- * Hard-coded exemption set: the liveness (`/`) and readiness (`/health`)
1697
- * endpoints are reachable without a key so container orchestration probes are
1698
- * never blocked. Everything else is protected (fail-closed) — unknown / future
1699
- * routes default to protected.
1700
- *
1701
- * Matching is by **exact path**, with a trailing slash tolerated (so `/health/`
1702
- * is exempt too) and `/` itself handled explicitly. Prefix matching is
1703
- * deliberately avoided: `/healthz` or `/health/extra` must NOT be exempt. The
1704
- * server registers a matching `/health/` route, so an exempt `/health/` request
1705
- * resolves to the readiness handler rather than 404ing.
1706
- */
1707
- function isExemptPath(path) {
1708
- if (path === "/") return true;
1709
- return (path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path) === "/health";
1354
+ function getSessionEntries(sessionId) {
1355
+ return historyState.entries.filter((e) => e.sessionId === sessionId).sort((a, b) => a.timestamp - b.timestamp);
1710
1356
  }
1711
- /**
1712
- * Compute the fixed-length sha256 digest (32 bytes) of the configured key.
1713
- * The configured key is trimmed before hashing (config-side trim).
1714
- */
1715
- function digestConfiguredKey(configuredKey) {
1716
- return createHash("sha256").update(configuredKey.trim()).digest();
1357
+ function clearHistory() {
1358
+ historyState.entries = [];
1359
+ historyState.sessions = /* @__PURE__ */ new Map();
1360
+ historyState.currentSessionId = generateId$1();
1361
+ entryIndex.clear();
1362
+ notifyHistoryCleared();
1717
1363
  }
1718
- /**
1719
- * Constant-time membership test: does any presented candidate match the
1720
- * configured key?
1721
- *
1722
- * Each candidate is sha256'd to a fixed 32-byte digest and compared against the
1723
- * configured digest. Hashing to a fixed length sidesteps the `RangeError` that
1724
- * `crypto.timingSafeEqual` throws on length-mismatched buffers, so a
1725
- * wrong-length presented value yields `false` rather than throwing.
1726
- */
1727
- function matchesConfiguredKey(configuredDigest, candidates) {
1728
- return candidates.some((candidate) => {
1729
- return timingSafeEqual(createHash("sha256").update(candidate).digest(), configuredDigest);
1730
- });
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;
1731
1373
  }
1732
- /**
1733
- * Resolve the inbound Proxy API key from its two operator-facing sources,
1734
- * applying the precedence + normalization contract (Issue 03):
1735
- *
1736
- * - `--api-key` flag (`flag`) and `COPILOT_API_KEY` env (`env`) are each
1737
- * **trimmed first**; a trimmed-empty source (`""`, whitespace, or
1738
- * `undefined`) counts as **not provided**.
1739
- * - When both provide a non-empty value, the **flag wins** (env ignored).
1740
- * - When only one provides a non-empty value, that one is used.
1741
- * - When neither does, `key` is `undefined` and `source` is `"none"` → auth
1742
- * stays disabled (same as the no-`--api-key` default).
1743
- *
1744
- * Pure: it reads nothing from `process.env` itself (the caller passes the env
1745
- * value in), so it is fully unit-testable and the precedence logic is decoupled
1746
- * from how the values are sourced.
1747
- */
1748
- function resolveProxyApiKey(sources) {
1749
- const flag = sources.flag?.trim() ?? "";
1750
- if (flag !== "") return {
1751
- key: flag,
1752
- source: "flag"
1753
- };
1754
- const env = sources.env?.trim() ?? "";
1755
- if (env !== "") return {
1756
- key: env,
1757
- source: "env"
1374
+ function getStats() {
1375
+ const entries = historyState.entries;
1376
+ const modelDist = {};
1377
+ const endpointDist = {};
1378
+ const hourlyActivity = {};
1379
+ let totalInput = 0;
1380
+ let totalOutput = 0;
1381
+ let totalDuration = 0;
1382
+ let durationCount = 0;
1383
+ let successCount = 0;
1384
+ let failCount = 0;
1385
+ for (const entry of entries) {
1386
+ const model = entry.response?.model || entry.request.model;
1387
+ modelDist[model] = (modelDist[model] || 0) + 1;
1388
+ endpointDist[entry.endpoint] = (endpointDist[entry.endpoint] || 0) + 1;
1389
+ const hour = new Date(entry.timestamp).toISOString().slice(0, 13);
1390
+ hourlyActivity[hour] = (hourlyActivity[hour] || 0) + 1;
1391
+ if (entry.response) {
1392
+ if (entry.response.success) successCount++;
1393
+ else failCount++;
1394
+ totalInput += entry.response.usage.input_tokens;
1395
+ totalOutput += entry.response.usage.output_tokens;
1396
+ }
1397
+ if (entry.durationMs) {
1398
+ totalDuration += entry.durationMs;
1399
+ durationCount++;
1400
+ }
1401
+ }
1402
+ const recentActivity = Object.entries(hourlyActivity).sort(([a], [b]) => a.localeCompare(b)).slice(-24).map(([hour, count]) => ({
1403
+ hour,
1404
+ count
1405
+ }));
1406
+ const now = Date.now();
1407
+ let activeSessions = 0;
1408
+ for (const session of historyState.sessions.values()) if (now - session.lastActivity < historyState.sessionTimeoutMs) activeSessions++;
1409
+ return {
1410
+ totalRequests: entries.length,
1411
+ successfulRequests: successCount,
1412
+ failedRequests: failCount,
1413
+ totalInputTokens: totalInput,
1414
+ totalOutputTokens: totalOutput,
1415
+ averageDurationMs: durationCount > 0 ? totalDuration / durationCount : 0,
1416
+ modelDistribution: modelDist,
1417
+ endpointDistribution: endpointDist,
1418
+ recentActivity,
1419
+ activeSessions
1758
1420
  };
1421
+ }
1422
+ function getTokenStats() {
1423
+ const models = {};
1424
+ const timeline = [];
1425
+ for (const entry of historyState.entries) {
1426
+ if (!entry.response) continue;
1427
+ const model = entry.response.model || entry.request.model;
1428
+ const inputTokens = entry.response.usage.input_tokens;
1429
+ const outputTokens = entry.response.usage.output_tokens;
1430
+ const existing = models[model];
1431
+ if (existing) {
1432
+ existing.inputTokens += inputTokens;
1433
+ existing.outputTokens += outputTokens;
1434
+ existing.requestCount++;
1435
+ } else models[model] = {
1436
+ inputTokens,
1437
+ outputTokens,
1438
+ requestCount: 1
1439
+ };
1440
+ timeline.push({
1441
+ timestamp: entry.timestamp,
1442
+ model,
1443
+ inputTokens,
1444
+ outputTokens
1445
+ });
1446
+ }
1447
+ timeline.sort((a, b) => a.timestamp - b.timestamp);
1759
1448
  return {
1760
- key: void 0,
1761
- source: "none"
1449
+ models,
1450
+ timeline
1762
1451
  };
1763
1452
  }
1453
+ function getHistoryEntryCount() {
1454
+ return historyState.entries.length;
1455
+ }
1456
+ function getHistoryMaxEntries() {
1457
+ return historyState.maxEntries;
1458
+ }
1459
+ function setHistoryMaxEntries(max) {
1460
+ historyState.maxEntries = max;
1461
+ }
1764
1462
  /**
1765
- * Resolve the hostname the server will *actually* bind to, decided at the CLI
1766
- * edge with flag-over-env precedence and a **safe loopback default** — the same
1767
- * flag-over-env shape this codebase already uses to reconcile a CLI flag with
1768
- * its env twin (`--api-key`/`COPILOT_API_KEY`, `--github-token`/`GH_TOKEN`).
1769
- *
1770
- * - `--host` flag wins when present; otherwise the `HOST` env; otherwise the
1771
- * default `127.0.0.1`.
1772
- * - The default is **loopback, not all-interfaces**: an unconfigured instance
1773
- * must not expose `/token` (which echoes the plaintext Copilot token) and the
1774
- * otherwise-unauthenticated API to the whole network. Binding every interface
1775
- * is now an explicit opt-in — pass `--host 0.0.0.0` (or `HOST=0.0.0.0`).
1776
- * - **flag vs env asymmetry on a blank value** (the security-critical part): an
1777
- * explicit `--host` flag is the operator's deliberate choice, so a blank flag
1778
- * (`--host ""` / whitespace) is taken as the wildcard-bind escape hatch and
1779
- * canonicalized to an explicit `0.0.0.0` (rather than left as `""` to lean on
1780
- * srvx's undocumented empty-string handling). But a *set-but-blank* `HOST` env
1781
- * (`HOST=`, or `HOST=$UNSET` in a shell / compose where the var is unset →
1782
- * empty — NOT a deliberate keystroke) is accidental plumbing, so it is treated
1783
- * as **not provided** and falls through to the loopback default. This mirrors
1784
- * `resolveProxyApiKey` trimming `""` to not-provided, so an empty `HOST` can't
1785
- * silently reopen the all-interfaces-unauthenticated exposure the loopback
1786
- * default exists to prevent.
1787
- * - **Both sources are trimmed**: a padded `--host " 10.0.0.5 "` or
1788
- * `HOST=" 10.0.0.5 "` would otherwise reach the socket bind verbatim and fail
1789
- * with `ENOTFOUND`. Trimming also decides blank-ness for the rules above.
1790
- *
1791
- * `env` is passed in (not read here) to keep the function pure and unit-testable.
1463
+ * Evict the oldest `count` entries from the history store.
1464
+ * Returns the actual number of entries evicted.
1792
1465
  */
1793
- function resolveBindHost(flag, env) {
1794
- if (flag !== void 0) {
1795
- const trimmed = flag.trim();
1796
- return trimmed === "" ? "0.0.0.0" : trimmed;
1797
- }
1798
- const envTrimmed = env?.trim() ?? "";
1799
- if (envTrimmed !== "") return envTrimmed;
1800
- return "127.0.0.1";
1466
+ function evictOldestEntries(count) {
1467
+ if (count <= 0) return 0;
1468
+ const actual = Math.min(count, historyState.entries.length);
1469
+ const removed = historyState.entries.splice(0, actual);
1470
+ for (const e of removed) entryIndex.delete(e.id);
1471
+ for (const entry of removed) if (!historyState.entries.some((e) => e.sessionId === entry.sessionId)) historyState.sessions.delete(entry.sessionId);
1472
+ return actual;
1801
1473
  }
1802
- /**
1803
- * Resolve the address the server will *actually* bind to for the startup banner
1804
- * (Issue 04).
1805
- *
1806
- * Mirrors srvx's own host resolution EXACTLY so the banner reports the TRUE bind
1807
- * rather than a guess that could diverge from what srvx passes to the runtime.
1808
- * srvx computes `hostname = opts.hostname ?? process.env.HOST` (a raw nullish
1809
- * coalesce — no trimming, no empty-string special-casing), and start.ts passes
1810
- * `hostname: options.host`. So:
1811
- * - an explicit `--host` (even `""` / whitespace) is what srvx uses verbatim —
1812
- * it does NOT fall back to HOST once `opts.hostname` is a non-null string;
1813
- * - only an absent (`undefined`) `--host` lets srvx fall back to `HOST`;
1814
- * - when the coalesced value is `undefined` or empty, the runtime binds all
1815
- * interfaces, which we report as the explicit `0.0.0.0` so a wide-open bind
1816
- * is unmistakable (srvx renders the same bind as "localhost (all
1817
- * interfaces)").
1818
- *
1819
- * Critically, the resolved non-empty value is returned VERBATIM (not trimmed):
1820
- * srvx hands the runtime exactly that string, so the banner must report exactly
1821
- * that string — trimming here would make the banner claim a different address
1822
- * than the one actually bound. `env` is passed in (not read) to keep the
1823
- * function pure and unit-testable.
1824
- */
1825
- function resolveBindAddress(host, env) {
1826
- const resolved = host ?? env;
1827
- if (resolved === void 0 || resolved === "") return "0.0.0.0";
1828
- return resolved;
1474
+ function exportHistory(format = "json") {
1475
+ if (format === "json") return JSON.stringify({
1476
+ sessions: Array.from(historyState.sessions.values()),
1477
+ entries: historyState.entries
1478
+ }, null, 2);
1479
+ const headers = [
1480
+ "id",
1481
+ "session_id",
1482
+ "timestamp",
1483
+ "endpoint",
1484
+ "request_model",
1485
+ "message_count",
1486
+ "stream",
1487
+ "success",
1488
+ "response_model",
1489
+ "input_tokens",
1490
+ "output_tokens",
1491
+ "duration_ms",
1492
+ "stop_reason",
1493
+ "error"
1494
+ ];
1495
+ const rows = historyState.entries.map((e) => [
1496
+ e.id,
1497
+ e.sessionId,
1498
+ new Date(e.timestamp).toISOString(),
1499
+ e.endpoint,
1500
+ e.request.model,
1501
+ e.request.messages.length,
1502
+ e.request.stream,
1503
+ e.response?.success ?? "",
1504
+ e.response?.model ?? "",
1505
+ e.response?.usage.input_tokens ?? "",
1506
+ e.response?.usage.output_tokens ?? "",
1507
+ e.durationMs ?? "",
1508
+ e.response?.stop_reason ?? "",
1509
+ e.response?.error ?? ""
1510
+ ]);
1511
+ return [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
1829
1512
  }
1513
+
1514
+ //#endregion
1515
+ //#region src/lib/history-memory-pressure.ts
1830
1516
  /**
1831
- * Resolve the CLIENT-FACING host for generated configs and viewer links
1832
- * (Issue 04), derived from the SAME srvx host resolution as the banner so the
1833
- * two never disagree about what was bound — and formatted as a valid URL
1834
- * authority so the links actually parse.
1835
- *
1836
- * Two differences from {@link resolveBindAddress}:
1837
- * - All-interfaces rendering: a wildcard bind (`0.0.0.0` / `::` / `[::]` /
1838
- * empty) is not a connectable target, so it maps to `localhost` for URLs a
1839
- * client will actually dial (matching srvx's "localhost (all interfaces)"
1840
- * presentation). A narrowed bind (e.g. `127.0.0.1`, `192.168.1.10`, an IPv6
1841
- * address) is kept so generated links point at the real interface — fixing
1842
- * the prior bug where setting `HOST` (with `--host` omitted) yielded
1843
- * `http://localhost:<port>` links the narrowed bind wasn't listening on.
1844
- * - IPv6 bracketing: a literal IPv6 host (contains `:`) is wrapped in `[...]`,
1845
- * exactly as srvx's own `fmtURL` does, so `http://[2001:db8::1]:<port>` is a
1846
- * valid authority rather than the unparseable `http://2001:db8::1:<port>`.
1517
+ * Memory pressure monitor proactively evicts old history entries
1518
+ * when heap usage approaches the V8 heap limit, preventing OOM crashes.
1847
1519
  *
1848
- * Returns a host token ready to drop into `http://<token>:<port>`.
1520
+ * Graduated response:
1521
+ * 75–80% Warning: log only, no eviction
1522
+ * 80–90% High: evict entries, reduce maxEntries by 25%
1523
+ * 90%+ Critical: aggressive eviction, reduce maxEntries by 50%
1849
1524
  */
1850
- function resolveClientHost(host, env) {
1851
- const bind = resolveBindAddress(host, env);
1852
- if (bind === "0.0.0.0" || bind === "::" || bind === "[::]") return "localhost";
1853
- if (bind.includes(":") && !bind.startsWith("[")) return `[${bind}]`;
1854
- return bind;
1525
+ const CHECK_INTERVAL_MS = 3e4;
1526
+ const WARN_THRESHOLD = .75;
1527
+ const EVICT_THRESHOLD = .8;
1528
+ const CRITICAL_THRESHOLD = .9;
1529
+ const WARN_LOG_COOLDOWN_MS = 3e5;
1530
+ let resolvedHeapLimit = null;
1531
+ let timer = null;
1532
+ let lastWarningTime = 0;
1533
+ let totalEvictedCount = 0;
1534
+ async function resolveHeapLimit() {
1535
+ if (resolvedHeapLimit !== null) return resolvedHeapLimit;
1536
+ let limit;
1537
+ try {
1538
+ limit = (await import("node:v8")).getHeapStatistics().heap_size_limit;
1539
+ } catch {
1540
+ limit = 512 * 1024 * 1024;
1541
+ }
1542
+ resolvedHeapLimit = limit;
1543
+ return limit;
1855
1544
  }
1856
- /**
1857
- * Build the inbound-auth startup banner lines (Issue 04).
1858
- *
1859
- * Returns the human-readable lines the proxy prints at boot so operators can see,
1860
- * at a glance, the security posture of *this* instance:
1861
- * - auth ON → `认证开启`, plus the key's origin (`flag` / `env`), plus the real
1862
- * bind address.
1863
- * - auth OFF → `认证关闭`, plus the real bind address (so a careless all-
1864
- * interfaces bind without auth is visible).
1865
- *
1866
- * The configured key value is **never** an input here, so it can never leak into
1867
- * the banner — the function only knows the *source* tag, not the secret. Pure
1868
- * (string in → strings out) so the banner copy is pinned by unit tests.
1869
- */
1870
- function buildStartupAuthLines(params) {
1871
- const { source, bindAddress } = params;
1872
- return [source === "none" ? `Inbound auth: 认证关闭 (no proxy API key configured)` : `Inbound auth: 认证开启 (source: ${source})`, `Binding to: ${bindAddress}`];
1545
+ function formatMB(bytes) {
1546
+ return `${Math.round(bytes / 1024 / 1024)}MB`;
1873
1547
  }
1874
- /**
1875
- * The env var Claude Code reads for its inbound credential, and the placeholder
1876
- * value the `--claude-code` setup always embeds for it. Exported so the
1877
- * generated env script (src/start.ts) and the auth hint below reference the SAME
1878
- * literals — changing the placeholder or the var name in one place can't silently
1879
- * desync the other (the hint would otherwise keep naming a string the generated
1880
- * command no longer contains).
1881
- */
1882
- const CLAUDE_CODE_AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN";
1883
- const CLAUDE_CODE_AUTH_TOKEN_PLACEHOLDER = "dummy";
1884
- /**
1885
- * Build the auth-aware hint lines for the `--claude-code` setup (Issue 05).
1886
- *
1887
- * The generated env script ALWAYS sets `ANTHROPIC_AUTH_TOKEN="dummy"` — a real
1888
- * key is deliberately never embedded, so the secret can't land in the clipboard
1889
- * or shell history. When inbound auth is ON, that placeholder won't authenticate
1890
- * against this proxy, so the operator must replace it. This builder returns the
1891
- * visible hint that tells them which variable to change:
1892
- * - auth OFF (`source === "none"`) → no hint (today's behavior, unchanged).
1893
- * - auth ON (`flag` / `env`) → a one-line hint naming
1894
- * `ANTHROPIC_AUTH_TOKEN` as the field to set to the proxy API key value.
1895
- *
1896
- * Like {@link buildStartupAuthLines}, the key value is **never** an input here —
1897
- * the builder only knows the `source` tag — so it is structurally impossible for
1898
- * the secret to leak into the hint. Pure (tag in → strings out) so the copy is
1899
- * pinned by unit tests.
1900
- */
1901
- function buildClaudeCodeAuthHint(source) {
1902
- if (source === "none") return [];
1903
- return [`Inbound auth is ON: replace ${CLAUDE_CODE_AUTH_TOKEN_ENV}="${CLAUDE_CODE_AUTH_TOKEN_PLACEHOLDER}" with your proxy API key value before using Claude Code.`];
1548
+ function formatPct(ratio) {
1549
+ return `${Math.round(ratio * 100)}%`;
1904
1550
  }
1905
- /**
1906
- * Configure the proxy API key on global state from a raw configured value.
1907
- *
1908
- * The value is trimmed; a trimmed-empty value (or `undefined`) is treated as
1909
- * "not provided" auth stays disabled. Otherwise the precomputed digest is
1910
- * stored on state (presence === enabled). Returns whether auth is enabled.
1911
- *
1912
- * The `--api-key` flag and `COPILOT_API_KEY` env source are reconciled upstream
1913
- * by `resolveProxyApiKey` (flag-over-env precedence, Issue 03); this function
1914
- * receives only the already-resolved value.
1915
- */
1916
- function configureProxyApiKey(rawKey) {
1917
- const trimmed = rawKey?.trim() ?? "";
1918
- if (trimmed === "") {
1919
- state.proxyApiKeyDigest = void 0;
1920
- return false;
1551
+ async function checkMemoryPressure() {
1552
+ const heapLimit = await resolveHeapLimit();
1553
+ const { heapUsed } = process.memoryUsage();
1554
+ const ratio = heapUsed / heapLimit;
1555
+ if (ratio < WARN_THRESHOLD) return;
1556
+ const currentEntries = getHistoryEntryCount();
1557
+ if (currentEntries <= state.historyMinEntries) {
1558
+ if (ratio >= EVICT_THRESHOLD && Date.now() - lastWarningTime > WARN_LOG_COOLDOWN_MS) {
1559
+ lastWarningTime = Date.now();
1560
+ consola.warn(`[memory] Heap ${formatMB(heapUsed)}/${formatMB(heapLimit)} (${formatPct(ratio)}) — only ${currentEntries} history entries remain. Consider increasing --max-old-space-size`);
1561
+ }
1562
+ return;
1921
1563
  }
1922
- state.proxyApiKeyDigest = digestConfiguredKey(trimmed);
1923
- return true;
1564
+ if (ratio < EVICT_THRESHOLD) {
1565
+ if (Date.now() - lastWarningTime > WARN_LOG_COOLDOWN_MS) {
1566
+ lastWarningTime = Date.now();
1567
+ consola.warn(`[memory] Heap ${formatMB(heapUsed)}/${formatMB(heapLimit)} (${formatPct(ratio)}) — approaching limit, ${currentEntries} history entries in memory`);
1568
+ }
1569
+ return;
1570
+ }
1571
+ lastWarningTime = Date.now();
1572
+ const currentMax = getHistoryMaxEntries();
1573
+ const newMaxEntries = ratio >= CRITICAL_THRESHOLD ? Math.max(state.historyMinEntries, Math.floor(currentMax * .5)) : Math.max(state.historyMinEntries, Math.floor(currentMax * .75));
1574
+ const evictCount = Math.max(0, currentEntries - newMaxEntries);
1575
+ if (evictCount <= 0) return;
1576
+ const evicted = evictOldestEntries(evictCount);
1577
+ totalEvictedCount += evicted;
1578
+ if (newMaxEntries < currentMax) setHistoryMaxEntries(newMaxEntries);
1579
+ const afterHeapUsed = process.memoryUsage().heapUsed;
1580
+ consola.warn(`[memory] Evicted ${evicted} history entries due to memory pressure (heap: ${formatMB(heapUsed)} → ${formatMB(afterHeapUsed)}/${formatMB(heapLimit)}, entries: ${currentEntries} → ${currentEntries - evicted}, max: ${newMaxEntries})`);
1581
+ globalThis.gc?.();
1924
1582
  }
1925
- /**
1926
- * Path → auth-family selector (Issue 02).
1927
- *
1928
- * The Anthropic-native surface is `/v1/messages` and its `count_tokens`
1929
- * subpath; both map to the Anthropic family so a native Anthropic client gets
1930
- * the `authentication_error` body. Everything else — including every other
1931
- * `/v1/…` endpoint and any unknown / future route — defaults to the OpenAI
1932
- * family.
1933
- *
1934
- * Matching is by **exact path** (a trailing slash tolerated), deliberately not
1935
- * a prefix test: the shared `/v1/` prefix must not sweep OpenAI-style endpoints
1936
- * into the Anthropic family, and `/v1/messages-extra` or a deeper unexpected
1937
- * subpath must not be misclassified either. This mirrors `isExemptPath`'s
1938
- * exact-with-trailing-slash convention.
1939
- */
1940
- function selectFamily(path) {
1941
- const normalized = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
1942
- if (normalized === "/v1/messages" || normalized === "/v1/messages/count_tokens") return "anthropic";
1943
- return "openai";
1583
+ function startMemoryPressureMonitor() {
1584
+ if (timer) return;
1585
+ timer = setInterval(() => {
1586
+ checkMemoryPressure().catch((error) => {
1587
+ consola.error("[memory] Error in memory pressure check:", error);
1588
+ });
1589
+ }, CHECK_INTERVAL_MS);
1590
+ if ("unref" in timer) timer.unref();
1944
1591
  }
1945
- /**
1946
- * OpenAI-family 401 response body. The literal field values are pinned by the
1947
- * ADR so OpenAI-compatible SDKs recognize the failure as an auth error. The
1948
- * same body is returned whether credentials were missing or wrong (no oracle).
1949
- */
1950
- function unauthorizedOpenAIBody() {
1951
- return { error: {
1952
- message: "Invalid API key provided.",
1953
- type: "invalid_request_error",
1954
- code: "invalid_api_key",
1955
- param: null
1956
- } };
1957
- }
1958
- /**
1959
- * Anthropic-family 401 response body (Issue 02). Shape is pinned so Anthropic
1960
- * SDKs (and Claude Code via `/v1/messages`) recognize the failure as an auth
1961
- * error: a top-level `{type:"error", error:{type:"authentication_error",
1962
- * message}}`. As with the OpenAI body, missing and wrong credentials return the
1963
- * identical body (no oracle).
1964
- */
1965
- function unauthorizedAnthropicBody() {
1966
- return {
1967
- type: "error",
1968
- error: {
1969
- type: "authentication_error",
1970
- message: "Invalid API key provided."
1971
- }
1972
- };
1973
- }
1974
- /**
1975
- * Global fail-closed authentication middleware.
1976
- *
1977
- * Registered after the request logger and CORS but before route dispatch.
1978
- * Behavior:
1979
- * - Disabled (no configured digest) → pass through unchanged (default).
1980
- * - Exempt path (`/`, `/health`) → pass through.
1981
- * - CORS preflight `OPTIONS` on a protected path → pass through so browser
1982
- * preflight isn't mistaken for a 401 (blocking it surfaces as an opaque CORS
1983
- * error, very hard to diagnose). Scoped to *actual* preflights — an
1984
- * `OPTIONS` carrying `Access-Control-Request-Method` — rather than any
1985
- * `OPTIONS`, so the bypass surface can't silently widen. Preflights carry no
1986
- * protected payload, so this doesn't weaken fail-closed.
1987
- * - Otherwise require a valid Proxy API key; on failure return 401 with a
1988
- * `WWW-Authenticate: Bearer` header and a **family-appropriate** body —
1989
- * Anthropic-family (`/v1/messages*`) gets the `authentication_error` shape,
1990
- * everything else the OpenAI `invalid_api_key` shape (Issue 02). The family
1991
- * only selects the body shape; it does not change what is protected. Missing
1992
- * and wrong credentials return the field-identical body for that family.
1993
- */
1994
- function authGate() {
1995
- return async (c, next) => {
1996
- const configuredDigest = state.proxyApiKeyDigest;
1997
- if (!configuredDigest) return next();
1998
- if (isExemptPath(c.req.path)) return next();
1999
- if (c.req.method === "OPTIONS" && c.req.raw.headers.get("access-control-request-method") !== null) return next();
2000
- if (matchesConfiguredKey(configuredDigest, extractCredentials(c.req.raw.headers))) return next();
2001
- c.header("WWW-Authenticate", "Bearer");
2002
- if (selectFamily(c.req.path) === "anthropic") return c.json(unauthorizedAnthropicBody(), 401);
2003
- return c.json(unauthorizedOpenAIBody(), 401);
2004
- };
1592
+ function stopMemoryPressureMonitor() {
1593
+ if (timer) {
1594
+ clearInterval(timer);
1595
+ timer = null;
1596
+ }
2005
1597
  }
2006
1598
 
2007
1599
  //#endregion
2008
- //#region src/lib/context/request.ts
2009
- let idCounter = 0;
2010
- function createRequestContext(opts) {
2011
- const id = `req_${Date.now()}_${++idCounter}`;
2012
- const startTime = Date.now();
2013
- const onEvent = opts.onEvent;
2014
- let _state = "pending";
2015
- let _originalRequest = null;
2016
- let _response = null;
2017
- let settled = false;
2018
- function emit(event) {
2019
- try {
2020
- onEvent(event);
2021
- } catch {}
1600
+ //#region src/lib/posthog.ts
1601
+ let client = null;
1602
+ let distinctId = "";
1603
+ function initPostHog(apiKey) {
1604
+ if (!apiKey) return;
1605
+ try {
1606
+ client = new PostHog(apiKey, {
1607
+ host: "https://us.i.posthog.com",
1608
+ flushAt: 20,
1609
+ flushInterval: 1e4
1610
+ });
1611
+ distinctId = createHash("sha256").update(os.hostname() + os.userInfo().username).digest("hex");
1612
+ } catch (error) {
1613
+ consola.warn("Failed to initialize PostHog:", error instanceof Error ? error.message : error);
1614
+ client = null;
2022
1615
  }
2023
- const ctx = {
2024
- id,
2025
- tuiLogId: opts.tuiLogId,
2026
- startTime,
2027
- endpoint: opts.endpoint,
2028
- get state() {
2029
- return _state;
2030
- },
2031
- get durationMs() {
2032
- return Date.now() - startTime;
2033
- },
2034
- get settled() {
2035
- return settled;
2036
- },
2037
- get originalRequest() {
2038
- return _originalRequest;
2039
- },
2040
- get response() {
2041
- return _response;
2042
- },
2043
- setOriginalRequest(req) {
2044
- _originalRequest = req;
2045
- emit({
2046
- type: "updated",
2047
- context: ctx,
2048
- field: "originalRequest"
2049
- });
2050
- },
2051
- transition(newState) {
2052
- const previousState = _state;
2053
- _state = newState;
2054
- emit({
2055
- type: "state_changed",
2056
- context: ctx,
2057
- previousState
2058
- });
2059
- },
2060
- complete(response) {
2061
- if (settled) return;
2062
- settled = true;
2063
- _response = response;
2064
- _state = "completed";
2065
- emit({
2066
- type: "completed",
2067
- context: ctx,
2068
- entry: ctx.toHistoryEntry()
2069
- });
2070
- },
2071
- fail(model, error) {
2072
- if (settled) return;
2073
- settled = true;
2074
- _response = {
2075
- success: false,
2076
- model,
2077
- usage: {
2078
- input_tokens: 0,
2079
- output_tokens: 0
2080
- },
2081
- error: error instanceof Error ? error.message : String(error),
2082
- content: null
2083
- };
2084
- _state = "failed";
2085
- emit({
2086
- type: "failed",
2087
- context: ctx,
2088
- entry: ctx.toHistoryEntry()
2089
- });
2090
- },
2091
- toHistoryEntry() {
2092
- const entry = {
2093
- id,
2094
- endpoint: opts.endpoint,
2095
- timestamp: startTime,
2096
- durationMs: Date.now() - startTime,
2097
- request: {
2098
- model: _originalRequest?.model,
2099
- messages: _originalRequest?.messages,
2100
- stream: _originalRequest?.stream,
2101
- tools: _originalRequest?.tools,
2102
- system: _originalRequest?.system
2103
- }
2104
- };
2105
- if (_response) entry.response = _response;
2106
- return entry;
2107
- }
1616
+ }
1617
+ function isPostHogEnabled() {
1618
+ return client !== null;
1619
+ }
1620
+ function captureRequest(params) {
1621
+ if (!client) return;
1622
+ const properties = {
1623
+ model: params.model,
1624
+ input_tokens: params.inputTokens,
1625
+ output_tokens: params.outputTokens,
1626
+ duration_ms: params.durationMs,
1627
+ success: params.success,
1628
+ stream: params.stream,
1629
+ tool_count: params.toolCount
2108
1630
  };
2109
- return ctx;
1631
+ if (params.reasoningTokens !== void 0) properties.reasoning_tokens = params.reasoningTokens;
1632
+ if (params.stopReason !== void 0) properties.stop_reason = params.stopReason;
1633
+ if (params.status !== void 0) properties.status = params.status;
1634
+ if (params.copilotErrorCode !== void 0) properties.copilot_error_code = params.copilotErrorCode;
1635
+ if (params.errorPhase !== void 0) properties.error_phase = params.errorPhase;
1636
+ if (params.endpoint !== void 0) properties.endpoint = params.endpoint;
1637
+ if (params.attempt !== void 0) properties.attempt = params.attempt;
1638
+ if (params.errorName !== void 0) properties.error_name = params.errorName;
1639
+ if (params.errorCode !== void 0) properties.error_code = params.errorCode;
1640
+ if (params.causeName !== void 0) properties.cause_name = params.causeName;
1641
+ if (params.causeCode !== void 0) properties.cause_code = params.causeCode;
1642
+ if (params.errorMessage !== void 0) properties.error_message = params.errorMessage;
1643
+ client.capture({
1644
+ distinctId,
1645
+ event: "copilot_api_request",
1646
+ properties
1647
+ });
1648
+ }
1649
+ async function shutdownPostHog() {
1650
+ if (!client) return;
1651
+ try {
1652
+ await client.shutdown();
1653
+ } catch (error) {
1654
+ consola.warn("Failed to flush PostHog events:", error instanceof Error ? error.message : error);
1655
+ }
2110
1656
  }
2111
1657
 
2112
1658
  //#endregion
2113
- //#region src/lib/context/manager.ts
2114
- /**
2115
- * RequestContextManager Active request management
2116
- *
2117
- * Manages all in-flight RequestContext instances. Publishes events for
2118
- * WebSocket push and history persistence.
2119
- */
2120
- let _manager = null;
2121
- function initRequestContextManager(staleMaxAgeSec) {
2122
- _manager = createRequestContextManager(staleMaxAgeSec);
2123
- return _manager;
1659
+ //#region src/lib/shutdown.ts
1660
+ const DRAIN_POLL_INTERVAL_MS = 500;
1661
+ const DRAIN_PROGRESS_INTERVAL_MS = 5e3;
1662
+ let serverInstance = null;
1663
+ let _isShuttingDown = false;
1664
+ let shutdownResolve = null;
1665
+ let shutdownAbortController = null;
1666
+ function getIsShuttingDown() {
1667
+ return _isShuttingDown;
2124
1668
  }
2125
- const REAPER_INTERVAL_MS = 6e4;
2126
- const DEFAULT_STALE_MAX_AGE_SEC = 600;
2127
- function createRequestContextManager(staleMaxAgeSec) {
2128
- const maxAgeSec = staleMaxAgeSec ?? DEFAULT_STALE_MAX_AGE_SEC;
2129
- const activeContexts = /* @__PURE__ */ new Map();
2130
- const listeners = /* @__PURE__ */ new Set();
2131
- let reaperTimer = null;
2132
- function runReaperOnce() {
2133
- if (maxAgeSec <= 0) return;
2134
- const maxAgeMs = maxAgeSec * 1e3;
2135
- for (const [id, ctx] of activeContexts) if (ctx.durationMs > maxAgeMs) {
2136
- 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)`);
2137
- ctx.fail(ctx.originalRequest?.model ?? "unknown", /* @__PURE__ */ new Error(`Request exceeded maximum age of ${maxAgeSec}s (stale context reaper)`));
1669
+ function setServerInstance(server) {
1670
+ serverInstance = server;
1671
+ }
1672
+ function formatActiveRequestsSummary(requests) {
1673
+ const now = Date.now();
1674
+ const lines = requests.map((req) => {
1675
+ const age = Math.round((now - req.startTime) / 1e3);
1676
+ const model = req.model || "unknown";
1677
+ const tags = req.tags?.length ? ` [${req.tags.join(", ")}]` : "";
1678
+ return ` ${req.method} ${req.path} ${model} (${req.status}, ${age}s)${tags}`;
1679
+ });
1680
+ return `Waiting for ${requests.length} active request(s):\n${lines.join("\n")}`;
1681
+ }
1682
+ async function drainActiveRequests(timeoutMs, tracker, opts) {
1683
+ const pollInterval = opts?.pollIntervalMs ?? DRAIN_POLL_INTERVAL_MS;
1684
+ const progressInterval = opts?.progressIntervalMs ?? DRAIN_PROGRESS_INTERVAL_MS;
1685
+ const deadline = Date.now() + timeoutMs;
1686
+ let lastProgressLog = 0;
1687
+ while (Date.now() < deadline) {
1688
+ const active = tracker.getActiveRequests();
1689
+ if (active.length === 0) return "drained";
1690
+ const now = Date.now();
1691
+ if (now - lastProgressLog >= progressInterval) {
1692
+ lastProgressLog = now;
1693
+ consola.info(formatActiveRequestsSummary(active));
2138
1694
  }
1695
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
2139
1696
  }
2140
- function startReaper() {
2141
- if (reaperTimer) return;
2142
- reaperTimer = setInterval(runReaperOnce, REAPER_INTERVAL_MS);
1697
+ return "timeout";
1698
+ }
1699
+ async function gracefulShutdown(signal, deps) {
1700
+ const tracker = deps?.tracker;
1701
+ const server = deps?.server ?? serverInstance;
1702
+ const rateLimiter = deps?.rateLimiter !== void 0 ? deps.rateLimiter : getAdaptiveRateLimiter();
1703
+ const stopRefresh = deps?.stopTokenRefreshFn ?? (() => {});
1704
+ const closeWsClients = deps?.closeAllClientsFn ?? closeAllClients;
1705
+ const getWsCount = deps?.getClientCountFn ?? getClientCount;
1706
+ const gracefulWaitMs = deps?.gracefulWaitMs ?? state.shutdownGracefulWait * 1e3;
1707
+ const abortWaitMs = deps?.abortWaitMs ?? state.shutdownAbortWait * 1e3;
1708
+ const drainOpts = {
1709
+ pollIntervalMs: deps?.drainPollIntervalMs ?? DRAIN_POLL_INTERVAL_MS,
1710
+ progressIntervalMs: deps?.drainProgressIntervalMs ?? DRAIN_PROGRESS_INTERVAL_MS
1711
+ };
1712
+ _isShuttingDown = true;
1713
+ shutdownAbortController = new AbortController();
1714
+ consola.info(`Received ${signal}, shutting down gracefully...`);
1715
+ try {
1716
+ deps?.contextManager?.stopReaper();
1717
+ } catch {}
1718
+ stopMemoryPressureMonitor();
1719
+ stopEventLoopLagMonitor();
1720
+ stopRefresh();
1721
+ const wsClients = getWsCount();
1722
+ if (wsClients > 0) {
1723
+ closeWsClients();
1724
+ consola.info(`Disconnected ${wsClients} WebSocket client(s)`);
2143
1725
  }
2144
- function stopReaper() {
2145
- if (reaperTimer) {
2146
- clearInterval(reaperTimer);
2147
- reaperTimer = null;
2148
- }
1726
+ if (rateLimiter) {
1727
+ const rejected = rateLimiter.rejectQueued();
1728
+ if (rejected > 0) consola.info(`Rejected ${rejected} queued request(s) from rate limiter`);
2149
1729
  }
2150
- function emit(event) {
2151
- for (const listener of listeners) try {
2152
- listener(event);
2153
- } catch {}
1730
+ if (server) {
1731
+ server.close(false).catch((error) => {
1732
+ consola.error("Error stopping listener:", error);
1733
+ });
1734
+ consola.info("Stopped accepting new connections");
2154
1735
  }
2155
- function handleContextEvent(rawEvent) {
2156
- const { type, context } = rawEvent;
2157
- switch (type) {
2158
- case "state_changed":
2159
- if (rawEvent.previousState) emit({
2160
- type: "state_changed",
2161
- context,
2162
- previousState: rawEvent.previousState
2163
- });
2164
- break;
2165
- case "updated":
2166
- if (rawEvent.field) emit({
2167
- type: "updated",
2168
- context,
2169
- field: rawEvent.field
2170
- });
2171
- break;
2172
- case "completed":
2173
- if (rawEvent.entry) emit({
2174
- type: "completed",
2175
- context,
2176
- entry: rawEvent.entry
2177
- });
2178
- activeContexts.delete(context.id);
2179
- break;
2180
- case "failed":
2181
- if (rawEvent.entry) emit({
2182
- type: "failed",
2183
- context,
2184
- entry: rawEvent.entry
2185
- });
2186
- activeContexts.delete(context.id);
2187
- break;
2188
- default: break;
1736
+ if (tracker) {
1737
+ const activeCount = tracker.getActiveRequests().length;
1738
+ if (activeCount > 0) {
1739
+ consola.info(`Phase 2: Waiting up to ${gracefulWaitMs / 1e3}s for ${activeCount} active request(s)...`);
1740
+ try {
1741
+ if (await drainActiveRequests(gracefulWaitMs, tracker, drainOpts) === "drained") {
1742
+ consola.info("All requests completed naturally");
1743
+ await finalize(tracker);
1744
+ return;
1745
+ }
1746
+ } catch (error) {
1747
+ consola.error("Error during Phase 2 drain:", error);
1748
+ }
1749
+ const remaining = tracker.getActiveRequests().length;
1750
+ consola.info(`Phase 3: Sending abort signal to ${remaining} remaining request(s), waiting up to ${abortWaitMs / 1e3}s...`);
1751
+ shutdownAbortController.abort();
1752
+ try {
1753
+ if (await drainActiveRequests(abortWaitMs, tracker, drainOpts) === "drained") {
1754
+ consola.info("All requests completed after abort signal");
1755
+ await finalize(tracker);
1756
+ return;
1757
+ }
1758
+ } catch (error) {
1759
+ consola.error("Error during Phase 3 drain:", error);
1760
+ }
1761
+ const forceRemaining = tracker.getActiveRequests().length;
1762
+ consola.warn(`Phase 4: Force-closing ${forceRemaining} remaining request(s)`);
1763
+ if (server) try {
1764
+ await server.close(true);
1765
+ } catch (error) {
1766
+ consola.error("Error force-closing server:", error);
1767
+ }
2189
1768
  }
1769
+ await finalize(tracker);
1770
+ } else {
1771
+ await shutdownPostHog();
1772
+ consola.info("Shutdown complete");
1773
+ shutdownResolve?.();
2190
1774
  }
2191
- return {
2192
- create(opts) {
2193
- const ctx = createRequestContext({
2194
- endpoint: opts.endpoint,
2195
- tuiLogId: opts.tuiLogId,
2196
- onEvent: handleContextEvent
2197
- });
2198
- activeContexts.set(ctx.id, ctx);
2199
- emit({
2200
- type: "created",
2201
- context: ctx
2202
- });
2203
- return ctx;
2204
- },
2205
- get(id) {
2206
- return activeContexts.get(id);
2207
- },
2208
- getAll() {
2209
- return Array.from(activeContexts.values());
2210
- },
2211
- get activeCount() {
2212
- return activeContexts.size;
2213
- },
2214
- on(_event, listener) {
2215
- listeners.add(listener);
2216
- },
2217
- off(_event, listener) {
2218
- listeners.delete(listener);
2219
- },
2220
- startReaper,
2221
- stopReaper,
2222
- _runReaperOnce: runReaperOnce
2223
- };
2224
1775
  }
2225
-
2226
- //#endregion
2227
- //#region src/lib/hidden-models.ts
2228
- /**
2229
- * Hardcoded list of GitHub Copilot model ids that are hidden from listing
2230
- * endpoints (the /v1/models response, the startup ASCII banner, and the
2231
- * --claude-code interactive prompts), unless `--show-all-models` is passed.
2232
- *
2233
- * Note: this is a DISPLAY filter only. Explicit POSTs to handler endpoints
2234
- * with a hidden id are NOT rejected — they pass through to upstream verbatim.
2235
- *
2236
- * Bumping the list requires a code change + release. No env var, no config
2237
- * file, no CLI append interface.
2238
- */
2239
- const HIDDEN_MODEL_IDS = new Set([
2240
- "gpt-3.5-turbo",
2241
- "gpt-3.5-turbo-0613",
2242
- "gpt-4",
2243
- "gpt-4-0613",
2244
- "gpt-4-0125-preview",
2245
- "gpt-4o",
2246
- "gpt-4o-mini",
2247
- "gpt-4-o-preview",
2248
- "gpt-4o-2024-05-13",
2249
- "gpt-4o-2024-08-06",
2250
- "gpt-4o-2024-11-20",
2251
- "gpt-4o-mini-2024-07-18",
2252
- "gpt-4.1",
2253
- "gpt-4.1-2025-04-14",
2254
- "gpt-41-copilot",
2255
- "gpt-5-mini",
2256
- "gpt-5.3-codex",
2257
- "gpt-5.4",
2258
- "text-embedding-ada-002",
2259
- "text-embedding-3-small",
2260
- "text-embedding-3-small-inference",
2261
- "gemini-2.5-pro",
2262
- "gemini-3-flash-preview",
2263
- "claude-opus-4.5",
2264
- "claude-opus-4.6",
2265
- "claude-opus-4.7-high",
2266
- "claude-opus-4.7-xhigh",
2267
- "claude-sonnet-4.5",
2268
- "mai-code-1-flash-internal",
2269
- "trajectory-compaction"
2270
- ]);
2271
- function isHiddenModel(id, showAll) {
2272
- if (showAll) return false;
2273
- return HIDDEN_MODEL_IDS.has(id);
1776
+ async function finalize(tracker) {
1777
+ await shutdownPostHog();
1778
+ tracker.destroy();
1779
+ consola.info("Shutdown complete");
1780
+ shutdownResolve?.();
1781
+ }
1782
+ function setupShutdownHandlers() {
1783
+ const handler = (signal) => {
1784
+ if (_isShuttingDown) {
1785
+ consola.warn("Second signal received, forcing immediate exit");
1786
+ process.exit(1);
1787
+ }
1788
+ gracefulShutdown(signal).catch((error) => {
1789
+ consola.error("Fatal error during shutdown:", error);
1790
+ shutdownResolve?.();
1791
+ process.exit(1);
1792
+ });
1793
+ };
1794
+ process.on("SIGINT", () => handler("SIGINT"));
1795
+ process.on("SIGTERM", () => handler("SIGTERM"));
2274
1796
  }
2275
1797
 
2276
1798
  //#endregion
2277
- //#region src/lib/history-ws.ts
1799
+ //#region src/lib/adaptive-rate-limiter.ts
1800
+ const DEFAULT_CONFIG$1 = {
1801
+ baseRetryIntervalSeconds: 1,
1802
+ maxRetryIntervalSeconds: 60,
1803
+ maxRetries: 8
1804
+ };
2278
1805
  /**
2279
- * WebSocket support for History API.
2280
- * Enables real-time updates when new requests are recorded.
1806
+ * Per-request adaptive rate limiter. Retries the calling request on 429 with
1807
+ * exponential backoff without blocking any other in-flight request.
2281
1808
  */
2282
- const clients = /* @__PURE__ */ new Set();
2283
- function addClient(ws) {
2284
- clients.add(ws);
2285
- const msg = {
2286
- type: "connected",
2287
- data: { clientCount: clients.size },
2288
- timestamp: Date.now()
2289
- };
2290
- ws.send(JSON.stringify(msg));
2291
- }
2292
- function removeClient(ws) {
2293
- clients.delete(ws);
2294
- }
2295
- function getClientCount() {
2296
- return clients.size;
2297
- }
2298
- function closeAllClients() {
2299
- for (const client of clients) try {
2300
- client.close(1001, "Server shutting down");
2301
- } catch {}
2302
- clients.clear();
2303
- }
2304
- function broadcast(message) {
2305
- const data = JSON.stringify(message);
2306
- for (const client of clients) try {
2307
- if (client.readyState === WebSocket.OPEN) client.send(data);
2308
- else clients.delete(client);
2309
- } catch (error) {
2310
- consola.debug("WebSocket send failed, removing client:", error);
2311
- clients.delete(client);
2312
- }
2313
- }
2314
- function notifyEntryAdded(summary) {
2315
- if (clients.size === 0) return;
2316
- broadcast({
2317
- type: "entry_added",
2318
- data: summary,
2319
- timestamp: Date.now()
2320
- });
2321
- }
2322
- function notifyEntryUpdated(summary) {
2323
- if (clients.size === 0) return;
2324
- broadcast({
2325
- type: "entry_updated",
2326
- data: summary,
2327
- timestamp: Date.now()
2328
- });
2329
- }
2330
- function notifyStatsUpdated(stats) {
2331
- if (clients.size === 0) return;
2332
- broadcast({
2333
- type: "stats_updated",
2334
- data: stats,
2335
- timestamp: Date.now()
2336
- });
2337
- }
2338
- function notifyHistoryCleared() {
2339
- if (clients.size === 0) return;
2340
- broadcast({
2341
- type: "history_cleared",
2342
- data: null,
2343
- timestamp: Date.now()
2344
- });
2345
- }
2346
- function notifySessionDeleted(sessionId) {
2347
- if (clients.size === 0) return;
2348
- broadcast({
2349
- type: "session_deleted",
2350
- data: { sessionId },
2351
- timestamp: Date.now()
2352
- });
2353
- }
2354
-
2355
- //#endregion
2356
- //#region src/lib/history.ts
2357
- function generateId$1() {
2358
- return Date.now().toString(36) + Math.random().toString(36).slice(2, 9);
2359
- }
2360
- const historyState = {
2361
- enabled: false,
2362
- entries: [],
2363
- sessions: /* @__PURE__ */ new Map(),
2364
- currentSessionId: "",
2365
- maxEntries: 1e3,
2366
- sessionTimeoutMs: 1800 * 1e3
2367
- };
2368
- const entryIndex = /* @__PURE__ */ new Map();
2369
- function initHistory(enabled, maxEntries) {
2370
- historyState.enabled = enabled;
2371
- historyState.maxEntries = maxEntries;
2372
- historyState.entries = [];
2373
- historyState.sessions = /* @__PURE__ */ new Map();
2374
- historyState.currentSessionId = enabled ? generateId$1() : "";
2375
- entryIndex.clear();
2376
- }
2377
- function isHistoryEnabled() {
2378
- return historyState.enabled;
2379
- }
2380
- function getCurrentSession(endpoint) {
2381
- const now = Date.now();
2382
- if (historyState.currentSessionId) {
2383
- const session = historyState.sessions.get(historyState.currentSessionId);
2384
- if (session && now - session.lastActivity < historyState.sessionTimeoutMs) {
2385
- session.lastActivity = now;
2386
- return historyState.currentSessionId;
2387
- }
1809
+ var AdaptiveRateLimiter = class {
1810
+ config;
1811
+ constructor(config = {}) {
1812
+ this.config = {
1813
+ ...DEFAULT_CONFIG$1,
1814
+ ...config
1815
+ };
2388
1816
  }
2389
- const sessionId = generateId$1();
2390
- historyState.currentSessionId = sessionId;
2391
- historyState.sessions.set(sessionId, {
2392
- id: sessionId,
2393
- startTime: now,
2394
- lastActivity: now,
2395
- requestCount: 0,
2396
- totalInputTokens: 0,
2397
- totalOutputTokens: 0,
2398
- models: [],
2399
- endpoint
2400
- });
2401
- return sessionId;
2402
- }
2403
- function recordRequest(endpoint, request) {
2404
- if (!historyState.enabled) return "";
2405
- const sessionId = getCurrentSession(endpoint);
2406
- const session = historyState.sessions.get(sessionId);
2407
- if (!session) return "";
2408
- const entry = {
2409
- id: generateId$1(),
2410
- sessionId,
2411
- timestamp: Date.now(),
2412
- endpoint,
2413
- request: {
2414
- model: request.model,
2415
- messages: request.messages,
2416
- stream: request.stream,
2417
- tools: request.tools,
2418
- max_tokens: request.max_tokens,
2419
- temperature: request.temperature,
2420
- system: request.system
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);
2421
1838
  }
2422
- };
2423
- historyState.entries.push(entry);
2424
- entryIndex.set(entry.id, entry);
2425
- session.requestCount++;
2426
- if (!session.models.includes(request.model)) session.models.push(request.model);
2427
- if (request.tools && request.tools.length > 0) {
2428
- if (!session.toolsUsed) session.toolsUsed = [];
2429
- for (const tool of request.tools) if (!session.toolsUsed.includes(tool.name)) session.toolsUsed.push(tool.name);
2430
1839
  }
2431
- while (historyState.maxEntries > 0 && historyState.entries.length > historyState.maxEntries) {
2432
- const removed = historyState.entries.shift();
2433
- if (removed) {
2434
- entryIndex.delete(removed.id);
2435
- if (historyState.entries.filter((e) => e.sessionId === removed.sessionId).length === 0) historyState.sessions.delete(removed.sessionId);
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 {}
2436
1853
  }
1854
+ return { isRateLimit: false };
2437
1855
  }
2438
- notifyEntryAdded({
2439
- id: entry.id,
2440
- endpoint,
2441
- model: request.model,
2442
- stream: request.stream,
2443
- timestamp: entry.timestamp
2444
- });
2445
- return entry.id;
2446
- }
2447
- function recordResponse(id, response, durationMs) {
2448
- if (!historyState.enabled || !id) return;
2449
- const entry = entryIndex.get(id);
2450
- if (entry) {
2451
- entry.response = response;
2452
- entry.durationMs = durationMs;
2453
- const session = historyState.sessions.get(entry.sessionId);
2454
- if (session) {
2455
- session.totalInputTokens += response.usage.input_tokens;
2456
- session.totalOutputTokens += response.usage.output_tokens;
2457
- session.lastActivity = Date.now();
2458
- }
2459
- notifyEntryUpdated({
2460
- id: entry.id,
2461
- endpoint: entry.endpoint,
2462
- model: response.model,
2463
- success: response.success,
2464
- durationMs,
2465
- inputTokens: response.usage.input_tokens,
2466
- outputTokens: response.usage.output_tokens
2467
- });
2468
- notifyStatsUpdated({
2469
- totalRequests: historyState.entries.length,
2470
- totalInputTokens: session?.totalInputTokens ?? 0,
2471
- totalOutputTokens: session?.totalOutputTokens ?? 0
2472
- });
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 {}
2473
1866
  }
2474
- }
2475
- function getHistory(options = {}) {
2476
- const { page = 1, limit = 50, model, endpoint, status, from, to, search, sessionId } = options;
2477
- let filtered = [...historyState.entries];
2478
- if (sessionId) filtered = filtered.filter((e) => e.sessionId === sessionId);
2479
- if (model) {
2480
- const modelLower = model.toLowerCase();
2481
- filtered = filtered.filter((e) => e.request.model.toLowerCase().includes(modelLower) || e.response?.model.toLowerCase().includes(modelLower));
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);
2482
1871
  }
2483
- if (endpoint) filtered = filtered.filter((e) => e.endpoint === endpoint);
2484
- let effectiveStatus = status;
2485
- const legacySuccess = options.success;
2486
- if (!effectiveStatus && legacySuccess !== void 0) effectiveStatus = legacySuccess ? "success" : "error";
2487
- switch (effectiveStatus) {
2488
- case "success":
2489
- filtered = filtered.filter((e) => e.response?.success === true);
2490
- break;
2491
- case "error":
2492
- filtered = filtered.filter((e) => e.response !== void 0 && !e.response.success);
2493
- break;
2494
- case "pending":
2495
- filtered = filtered.filter((e) => !e.response);
2496
- break;
2497
- default: break;
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);
2498
1876
  }
2499
- if (from) filtered = filtered.filter((e) => e.timestamp >= from);
2500
- if (to) filtered = filtered.filter((e) => e.timestamp <= to);
2501
- if (search) {
2502
- const searchLower = search.toLowerCase();
2503
- filtered = filtered.filter((e) => {
2504
- const msgMatch = e.request.messages.some((m) => {
2505
- if (typeof m.content === "string") return m.content.toLowerCase().includes(searchLower);
2506
- if (Array.isArray(m.content)) return m.content.some((c) => c.text && c.text.toLowerCase().includes(searchLower));
2507
- return false;
2508
- });
2509
- const respMatch = e.response?.content && typeof e.response.content.content === "string" && e.response.content.content.toLowerCase().includes(searchLower);
2510
- const toolMatch = e.response?.toolCalls?.some((t) => t.name.toLowerCase().includes(searchLower));
2511
- const sysMatch = e.request.system?.toLowerCase().includes(searchLower);
2512
- return msgMatch || respMatch || toolMatch || sysMatch;
1877
+ sleep(ms) {
1878
+ return new Promise((resolve) => {
1879
+ const timer = setTimeout(resolve, ms);
1880
+ if (typeof timer.unref === "function") timer.unref();
2513
1881
  });
2514
1882
  }
2515
- filtered.sort((a, b) => b.timestamp - a.timestamp);
2516
- const total = filtered.length;
2517
- const totalPages = Math.ceil(total / limit);
2518
- const start = (page - 1) * limit;
2519
- return {
2520
- entries: filtered.slice(start, start + limit),
2521
- total,
2522
- page,
2523
- limit,
2524
- totalPages
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
2525
1900
  };
1901
+ consola.info(`[RateLimiter] Initialized (per-request backoff: ${resolved.baseRetryIntervalSeconds}s-${resolved.maxRetryIntervalSeconds}s, max ${resolved.maxRetries} retries)`);
2526
1902
  }
2527
- function getEntry(id) {
2528
- return entryIndex.get(id);
1903
+ /**
1904
+ * Get the rate limiter instance.
1905
+ */
1906
+ function getAdaptiveRateLimiter() {
1907
+ return rateLimiterInstance;
2529
1908
  }
2530
- function getSessions() {
2531
- const sessions = Array.from(historyState.sessions.values()).sort((a, b) => b.lastActivity - a.lastActivity);
2532
- return {
2533
- sessions,
2534
- total: sessions.length
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
2535
1917
  };
1918
+ return rateLimiterInstance.execute(fn);
2536
1919
  }
2537
- function getSession(id) {
2538
- return historyState.sessions.get(id);
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;
2539
1955
  }
2540
- function getSessionEntries(sessionId) {
2541
- return historyState.entries.filter((e) => e.sessionId === sessionId).sort((a, b) => a.timestamp - b.timestamp);
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";
2542
1971
  }
2543
- function clearHistory() {
2544
- historyState.entries = [];
2545
- historyState.sessions = /* @__PURE__ */ new Map();
2546
- historyState.currentSessionId = generateId$1();
2547
- entryIndex.clear();
2548
- notifyHistoryCleared();
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();
2549
1978
  }
2550
- function deleteSession(sessionId) {
2551
- if (!historyState.sessions.has(sessionId)) return false;
2552
- const removedEntries = historyState.entries.filter((e) => e.sessionId === sessionId);
2553
- historyState.entries = historyState.entries.filter((e) => e.sessionId !== sessionId);
2554
- for (const e of removedEntries) entryIndex.delete(e.id);
2555
- historyState.sessions.delete(sessionId);
2556
- if (historyState.currentSessionId === sessionId) historyState.currentSessionId = generateId$1();
2557
- notifySessionDeleted(sessionId);
2558
- return true;
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
+ });
2559
1992
  }
2560
- function getStats() {
2561
- const entries = historyState.entries;
2562
- const modelDist = {};
2563
- const endpointDist = {};
2564
- const hourlyActivity = {};
2565
- let totalInput = 0;
2566
- let totalOutput = 0;
2567
- let totalDuration = 0;
2568
- let durationCount = 0;
2569
- let successCount = 0;
2570
- let failCount = 0;
2571
- for (const entry of entries) {
2572
- const model = entry.response?.model || entry.request.model;
2573
- modelDist[model] = (modelDist[model] || 0) + 1;
2574
- endpointDist[entry.endpoint] = (endpointDist[entry.endpoint] || 0) + 1;
2575
- const hour = new Date(entry.timestamp).toISOString().slice(0, 13);
2576
- hourlyActivity[hour] = (hourlyActivity[hour] || 0) + 1;
2577
- if (entry.response) {
2578
- if (entry.response.success) successCount++;
2579
- else failCount++;
2580
- totalInput += entry.response.usage.input_tokens;
2581
- totalOutput += entry.response.usage.output_tokens;
2582
- }
2583
- if (entry.durationMs) {
2584
- totalDuration += entry.durationMs;
2585
- durationCount++;
2586
- }
2587
- }
2588
- const recentActivity = Object.entries(hourlyActivity).sort(([a], [b]) => a.localeCompare(b)).slice(-24).map(([hour, count]) => ({
2589
- hour,
2590
- count
2591
- }));
2592
- const now = Date.now();
2593
- let activeSessions = 0;
2594
- for (const session of historyState.sessions.values()) if (now - session.lastActivity < historyState.sessionTimeoutMs) activeSessions++;
2595
- return {
2596
- totalRequests: entries.length,
2597
- successfulRequests: successCount,
2598
- failedRequests: failCount,
2599
- totalInputTokens: totalInput,
2600
- totalOutputTokens: totalOutput,
2601
- averageDurationMs: durationCount > 0 ? totalDuration / durationCount : 0,
2602
- modelDistribution: modelDist,
2603
- endpointDistribution: endpointDist,
2604
- recentActivity,
2605
- activeSessions
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"
2606
2019
  };
2607
- }
2608
- function getTokenStats() {
2609
- const models = {};
2610
- const timeline = [];
2611
- for (const entry of historyState.entries) {
2612
- if (!entry.response) continue;
2613
- const model = entry.response.model || entry.request.model;
2614
- const inputTokens = entry.response.usage.input_tokens;
2615
- const outputTokens = entry.response.usage.output_tokens;
2616
- const existing = models[model];
2617
- if (existing) {
2618
- existing.inputTokens += inputTokens;
2619
- existing.outputTokens += outputTokens;
2620
- existing.requestCount++;
2621
- } else models[model] = {
2622
- inputTokens,
2623
- outputTokens,
2624
- requestCount: 1
2625
- };
2626
- timeline.push({
2627
- timestamp: entry.timestamp,
2628
- model,
2629
- inputTokens,
2630
- outputTokens
2631
- });
2632
- }
2633
- timeline.sort((a, b) => a.timestamp - b.timestamp);
2634
2020
  return {
2635
- models,
2636
- timeline
2021
+ key: void 0,
2022
+ source: "none"
2637
2023
  };
2638
2024
  }
2639
- function getHistoryEntryCount() {
2640
- return historyState.entries.length;
2641
- }
2642
- function getHistoryMaxEntries() {
2643
- return historyState.maxEntries;
2644
- }
2645
- function setHistoryMaxEntries(max) {
2646
- historyState.maxEntries = max;
2647
- }
2648
2025
  /**
2649
- * Evict the oldest `count` entries from the history store.
2650
- * Returns the actual number of entries evicted.
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.
2651
2053
  */
2652
- function evictOldestEntries(count) {
2653
- if (count <= 0) return 0;
2654
- const actual = Math.min(count, historyState.entries.length);
2655
- const removed = historyState.entries.splice(0, actual);
2656
- for (const e of removed) entryIndex.delete(e.id);
2657
- for (const entry of removed) if (!historyState.entries.some((e) => e.sessionId === entry.sessionId)) historyState.sessions.delete(entry.sessionId);
2658
- return actual;
2659
- }
2660
- function exportHistory(format = "json") {
2661
- if (format === "json") return JSON.stringify({
2662
- sessions: Array.from(historyState.sessions.values()),
2663
- entries: historyState.entries
2664
- }, null, 2);
2665
- const headers = [
2666
- "id",
2667
- "session_id",
2668
- "timestamp",
2669
- "endpoint",
2670
- "request_model",
2671
- "message_count",
2672
- "stream",
2673
- "success",
2674
- "response_model",
2675
- "input_tokens",
2676
- "output_tokens",
2677
- "duration_ms",
2678
- "stop_reason",
2679
- "error"
2680
- ];
2681
- const rows = historyState.entries.map((e) => [
2682
- e.id,
2683
- e.sessionId,
2684
- new Date(e.timestamp).toISOString(),
2685
- e.endpoint,
2686
- e.request.model,
2687
- e.request.messages.length,
2688
- e.request.stream,
2689
- e.response?.success ?? "",
2690
- e.response?.model ?? "",
2691
- e.response?.usage.input_tokens ?? "",
2692
- e.response?.usage.output_tokens ?? "",
2693
- e.durationMs ?? "",
2694
- e.response?.stop_reason ?? "",
2695
- e.response?.error ?? ""
2696
- ]);
2697
- return [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
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";
2698
2062
  }
2699
-
2700
- //#endregion
2701
- //#region src/lib/history-memory-pressure.ts
2702
2063
  /**
2703
- * Memory pressure monitor proactively evicts old history entries
2704
- * when heap usage approaches the V8 heap limit, preventing OOM crashes.
2064
+ * Resolve the address the server will *actually* bind to for the startup banner
2065
+ * (Issue 04).
2705
2066
  *
2706
- * Graduated response:
2707
- * 75–80% Warning: log only, no eviction
2708
- * 80–90% High: evict entries, reduce maxEntries by 25%
2709
- * 90%+ Critical: aggressive eviction, reduce maxEntries by 50%
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.
2710
2085
  */
2711
- const CHECK_INTERVAL_MS = 3e4;
2712
- const WARN_THRESHOLD = .75;
2713
- const EVICT_THRESHOLD = .8;
2714
- const CRITICAL_THRESHOLD = .9;
2715
- const WARN_LOG_COOLDOWN_MS = 3e5;
2716
- let resolvedHeapLimit = null;
2717
- let timer = null;
2718
- let lastWarningTime = 0;
2719
- let totalEvictedCount = 0;
2720
- async function resolveHeapLimit() {
2721
- if (resolvedHeapLimit !== null) return resolvedHeapLimit;
2722
- let limit;
2723
- try {
2724
- limit = (await import("node:v8")).getHeapStatistics().heap_size_limit;
2725
- } catch {
2726
- limit = 512 * 1024 * 1024;
2727
- }
2728
- resolvedHeapLimit = limit;
2729
- return limit;
2086
+ function resolveBindAddress(host, env) {
2087
+ const resolved = host ?? env;
2088
+ if (resolved === void 0 || resolved === "") return "0.0.0.0";
2089
+ return resolved;
2730
2090
  }
2731
- function formatMB(bytes) {
2732
- return `${Math.round(bytes / 1024 / 1024)}MB`;
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;
2733
2116
  }
2734
- function formatPct(ratio) {
2735
- return `${Math.round(ratio * 100)}%`;
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}`];
2736
2134
  }
2737
- async function checkMemoryPressure() {
2738
- const heapLimit = await resolveHeapLimit();
2739
- const { heapUsed } = process.memoryUsage();
2740
- const ratio = heapUsed / heapLimit;
2741
- if (ratio < WARN_THRESHOLD) return;
2742
- const currentEntries = getHistoryEntryCount();
2743
- if (currentEntries <= state.historyMinEntries) {
2744
- if (ratio >= EVICT_THRESHOLD && Date.now() - lastWarningTime > WARN_LOG_COOLDOWN_MS) {
2745
- lastWarningTime = Date.now();
2746
- consola.warn(`[memory] Heap ${formatMB(heapUsed)}/${formatMB(heapLimit)} (${formatPct(ratio)}) — only ${currentEntries} history entries remain. Consider increasing --max-old-space-size`);
2747
- }
2748
- return;
2749
- }
2750
- if (ratio < EVICT_THRESHOLD) {
2751
- if (Date.now() - lastWarningTime > WARN_LOG_COOLDOWN_MS) {
2752
- lastWarningTime = Date.now();
2753
- consola.warn(`[memory] Heap ${formatMB(heapUsed)}/${formatMB(heapLimit)} (${formatPct(ratio)}) — approaching limit, ${currentEntries} history entries in memory`);
2754
- }
2755
- return;
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;
2756
2151
  }
2757
- lastWarningTime = Date.now();
2758
- const currentMax = getHistoryMaxEntries();
2759
- const newMaxEntries = ratio >= CRITICAL_THRESHOLD ? Math.max(state.historyMinEntries, Math.floor(currentMax * .5)) : Math.max(state.historyMinEntries, Math.floor(currentMax * .75));
2760
- const evictCount = Math.max(0, currentEntries - newMaxEntries);
2761
- if (evictCount <= 0) return;
2762
- const evicted = evictOldestEntries(evictCount);
2763
- totalEvictedCount += evicted;
2764
- if (newMaxEntries < currentMax) setHistoryMaxEntries(newMaxEntries);
2765
- const afterHeapUsed = process.memoryUsage().heapUsed;
2766
- consola.warn(`[memory] Evicted ${evicted} history entries due to memory pressure (heap: ${formatMB(heapUsed)} → ${formatMB(afterHeapUsed)}/${formatMB(heapLimit)}, entries: ${currentEntries} → ${currentEntries - evicted}, max: ${newMaxEntries})`);
2767
- globalThis.gc?.();
2768
- }
2769
- function startMemoryPressureMonitor() {
2770
- if (timer) return;
2771
- timer = setInterval(() => {
2772
- checkMemoryPressure().catch((error) => {
2773
- consola.error("[memory] Error in memory pressure check:", error);
2774
- });
2775
- }, CHECK_INTERVAL_MS);
2776
- if ("unref" in timer) timer.unref();
2152
+ state.proxyApiKeyDigest = digestConfiguredKey(trimmed);
2153
+ return true;
2777
2154
  }
2778
- function stopMemoryPressureMonitor() {
2779
- if (timer) {
2780
- clearInterval(timer);
2781
- timer = null;
2782
- }
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";
2783
2174
  }
2784
-
2785
- //#endregion
2786
- //#region src/lib/posthog.ts
2787
- let client = null;
2788
- let distinctId = "";
2789
- function initPostHog(apiKey) {
2790
- if (!apiKey) return;
2791
- try {
2792
- client = new PostHog(apiKey, {
2793
- host: "https://us.i.posthog.com",
2794
- flushAt: 20,
2795
- flushInterval: 1e4
2796
- });
2797
- distinctId = createHash("sha256").update(os.hostname() + os.userInfo().username).digest("hex");
2798
- } catch (error) {
2799
- consola.warn("Failed to initialize PostHog:", error instanceof Error ? error.message : error);
2800
- client = null;
2801
- }
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
+ } };
2802
2187
  }
2803
- function isPostHogEnabled() {
2804
- return client !== null;
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
+ };
2805
2203
  }
2806
- function captureRequest(params) {
2807
- if (!client) return;
2808
- const properties = {
2809
- model: params.model,
2810
- input_tokens: params.inputTokens,
2811
- output_tokens: params.outputTokens,
2812
- duration_ms: params.durationMs,
2813
- success: params.success,
2814
- stream: params.stream,
2815
- tool_count: params.toolCount
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);
2816
2234
  };
2817
- if (params.reasoningTokens !== void 0) properties.reasoning_tokens = params.reasoningTokens;
2818
- if (params.stopReason !== void 0) properties.stop_reason = params.stopReason;
2819
- if (params.status !== void 0) properties.status = params.status;
2820
- if (params.copilotErrorCode !== void 0) properties.copilot_error_code = params.copilotErrorCode;
2821
- if (params.errorPhase !== void 0) properties.error_phase = params.errorPhase;
2822
- if (params.endpoint !== void 0) properties.endpoint = params.endpoint;
2823
- if (params.attempt !== void 0) properties.attempt = params.attempt;
2824
- if (params.errorName !== void 0) properties.error_name = params.errorName;
2825
- if (params.errorCode !== void 0) properties.error_code = params.errorCode;
2826
- if (params.causeName !== void 0) properties.cause_name = params.causeName;
2827
- if (params.causeCode !== void 0) properties.cause_code = params.causeCode;
2828
- if (params.errorMessage !== void 0) properties.error_message = params.errorMessage;
2829
- client.capture({
2830
- distinctId,
2831
- event: "copilot_api_request",
2832
- properties
2833
- });
2834
2235
  }
2835
- async function shutdownPostHog() {
2836
- if (!client) return;
2837
- try {
2838
- await client.shutdown();
2839
- } catch (error) {
2840
- consola.warn("Failed to flush PostHog events:", error instanceof Error ? error.message : error);
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 {}
2841
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;
2842
2340
  }
2843
2341
 
2844
2342
  //#endregion
2845
- //#region src/lib/shell.ts
2846
- function getShell() {
2847
- const { platform, ppid, env } = process$1;
2848
- if (platform === "win32") {
2849
- try {
2850
- if (execSync(`wmic process get ParentProcessId,Name | findstr "${ppid}"`, { stdio: "pipe" }).toString().toLowerCase().includes("powershell.exe")) return "powershell";
2851
- } catch {
2852
- return "cmd";
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)`));
2853
2368
  }
2854
- return "cmd";
2855
- } else {
2856
- const shellPath = env.SHELL;
2857
- if (shellPath) {
2858
- if (shellPath.endsWith("zsh")) return "zsh";
2859
- if (shellPath.endsWith("fish")) return "fish";
2860
- if (shellPath.endsWith("bash")) return "bash";
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;
2861
2378
  }
2862
- return "sh";
2863
2379
  }
2864
- }
2865
- /**
2866
- * Generates a copy-pasteable script to set multiple environment variables
2867
- * and run a subsequent command.
2868
- * @param {EnvVars} envVars - An object of environment variables to set.
2869
- * @param {string} commandToRun - The command to run after setting the variables.
2870
- * @returns {string} The formatted script string.
2871
- */
2872
- function generateEnvScript(envVars, commandToRun = "") {
2873
- const shell = getShell();
2874
- const filteredEnvVars = Object.entries(envVars).filter(([, value]) => value !== void 0);
2875
- let commandBlock;
2876
- switch (shell) {
2877
- case "powershell":
2878
- commandBlock = filteredEnvVars.map(([key, value]) => `$env:${key} = "${value.replaceAll("\"", "`\"")}"`).join("; ");
2879
- break;
2880
- case "cmd":
2881
- commandBlock = filteredEnvVars.map(([key, value]) => `set ${key}=${value}`).join(" & ");
2882
- break;
2883
- case "fish":
2884
- commandBlock = filteredEnvVars.map(([key, value]) => `set -gx ${key} "${value.replaceAll("\"", String.raw`\"`)}"`).join("; ");
2885
- break;
2886
- default: {
2887
- const assignments = filteredEnvVars.map(([key, value]) => `${key}="${value.replaceAll("\"", String.raw`\"`)}"`).join(" ");
2888
- commandBlock = filteredEnvVars.length > 0 ? `export ${assignments}` : "";
2889
- break;
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;
2890
2419
  }
2891
2420
  }
2892
- if (commandBlock && commandToRun) return `${commandBlock}${shell === "cmd" ? " & " : " && "}${commandToRun}`;
2893
- return commandBlock || commandToRun;
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
+ };
2894
2454
  }
2895
2455
 
2896
2456
  //#endregion
2897
- //#region src/lib/shutdown.ts
2898
- const DRAIN_POLL_INTERVAL_MS = 500;
2899
- const DRAIN_PROGRESS_INTERVAL_MS = 5e3;
2900
- let serverInstance = null;
2901
- let _isShuttingDown = false;
2902
- let shutdownResolve = null;
2903
- let shutdownAbortController = null;
2904
- function getIsShuttingDown() {
2905
- return _isShuttingDown;
2906
- }
2907
- function setServerInstance(server) {
2908
- serverInstance = server;
2909
- }
2910
- function formatActiveRequestsSummary(requests) {
2911
- const now = Date.now();
2912
- const lines = requests.map((req) => {
2913
- const age = Math.round((now - req.startTime) / 1e3);
2914
- const model = req.model || "unknown";
2915
- const tags = req.tags?.length ? ` [${req.tags.join(", ")}]` : "";
2916
- return ` ${req.method} ${req.path} ${model} (${req.status}, ${age}s)${tags}`;
2917
- });
2918
- return `Waiting for ${requests.length} active request(s):\n${lines.join("\n")}`;
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);
2919
2504
  }
2920
- async function drainActiveRequests(timeoutMs, tracker, opts) {
2921
- const pollInterval = opts?.pollIntervalMs ?? DRAIN_POLL_INTERVAL_MS;
2922
- const progressInterval = opts?.progressIntervalMs ?? DRAIN_PROGRESS_INTERVAL_MS;
2923
- const deadline = Date.now() + timeoutMs;
2924
- let lastProgressLog = 0;
2925
- while (Date.now() < deadline) {
2926
- const active = tracker.getActiveRequests();
2927
- if (active.length === 0) return "drained";
2928
- const now = Date.now();
2929
- if (now - lastProgressLog >= progressInterval) {
2930
- lastProgressLog = now;
2931
- consola.info(formatActiveRequestsSummary(active));
2932
- }
2933
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
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);
2934
2550
  }
2935
- return "timeout";
2936
- }
2937
- async function gracefulShutdown(signal, deps) {
2938
- const tracker = deps?.tracker;
2939
- const server = deps?.server ?? serverInstance;
2940
- const rateLimiter = deps?.rateLimiter !== void 0 ? deps.rateLimiter : getAdaptiveRateLimiter();
2941
- const stopRefresh = deps?.stopTokenRefreshFn ?? (() => {});
2942
- const closeWsClients = deps?.closeAllClientsFn ?? closeAllClients;
2943
- const getWsCount = deps?.getClientCountFn ?? getClientCount;
2944
- const gracefulWaitMs = deps?.gracefulWaitMs ?? state.shutdownGracefulWait * 1e3;
2945
- const abortWaitMs = deps?.abortWaitMs ?? state.shutdownAbortWait * 1e3;
2946
- const drainOpts = {
2947
- pollIntervalMs: deps?.drainPollIntervalMs ?? DRAIN_POLL_INTERVAL_MS,
2948
- progressIntervalMs: deps?.drainProgressIntervalMs ?? DRAIN_PROGRESS_INTERVAL_MS
2949
- };
2950
- _isShuttingDown = true;
2951
- shutdownAbortController = new AbortController();
2952
- consola.info(`Received ${signal}, shutting down gracefully...`);
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);
2953
2583
  try {
2954
- deps?.contextManager?.stopReaper();
2955
- } catch {}
2956
- stopMemoryPressureMonitor();
2957
- stopRefresh();
2958
- const wsClients = getWsCount();
2959
- if (wsClients > 0) {
2960
- closeWsClients();
2961
- consola.info(`Disconnected ${wsClients} WebSocket client(s)`);
2584
+ return await loadPromise;
2585
+ } finally {
2586
+ encodingInflight.delete(encoding);
2962
2587
  }
2963
- if (rateLimiter) {
2964
- const rejected = rateLimiter.rejectQueued();
2965
- if (rejected > 0) consola.info(`Rejected ${rejected} queued request(s) from rate limiter`);
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
+ }
2966
2654
  }
2967
- if (server) {
2968
- server.close(false).catch((error) => {
2969
- consola.error("Error stopping listener:", error);
2970
- });
2971
- consola.info("Stopped accepting new connections");
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;
2972
2667
  }
2973
- if (tracker) {
2974
- const activeCount = tracker.getActiveRequests().length;
2975
- if (activeCount > 0) {
2976
- consola.info(`Phase 2: Waiting up to ${gracefulWaitMs / 1e3}s for ${activeCount} active request(s)...`);
2977
- try {
2978
- if (await drainActiveRequests(gracefulWaitMs, tracker, drainOpts) === "drained") {
2979
- consola.info("All requests completed naturally");
2980
- await finalize(tracker);
2981
- return;
2982
- }
2983
- } catch (error) {
2984
- consola.error("Error during Phase 2 drain:", error);
2985
- }
2986
- const remaining = tracker.getActiveRequests().length;
2987
- consola.info(`Phase 3: Sending abort signal to ${remaining} remaining request(s), waiting up to ${abortWaitMs / 1e3}s...`);
2988
- shutdownAbortController.abort();
2989
- try {
2990
- if (await drainActiveRequests(abortWaitMs, tracker, drainOpts) === "drained") {
2991
- consola.info("All requests completed after abort signal");
2992
- await finalize(tracker);
2993
- return;
2994
- }
2995
- } catch (error) {
2996
- consola.error("Error during Phase 3 drain:", error);
2997
- }
2998
- const forceRemaining = tracker.getActiveRequests().length;
2999
- consola.warn(`Phase 4: Force-closing ${forceRemaining} remaining request(s)`);
3000
- if (server) try {
3001
- await server.close(true);
3002
- } catch (error) {
3003
- consola.error("Error force-closing server:", error);
3004
- }
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
+ });
3005
2685
  }
3006
- await finalize(tracker);
3007
2686
  } else {
3008
- await shutdownPostHog();
3009
- consola.info("Shutdown complete");
3010
- shutdownResolve?.();
2687
+ const paramText = typeof value === "string" ? value : JSON.stringify(value);
2688
+ tokens += encoder.encode(`${key}:${paramText}`).length;
3011
2689
  }
3012
- }
3013
- async function finalize(tracker) {
3014
- await shutdownPostHog();
3015
- tracker.destroy();
3016
- consola.info("Shutdown complete");
3017
- shutdownResolve?.();
3018
- }
3019
- function setupShutdownHandlers() {
3020
- const handler = (signal) => {
3021
- if (_isShuttingDown) {
3022
- consola.warn("Second signal received, forcing immediate exit");
3023
- process.exit(1);
3024
- }
3025
- gracefulShutdown(signal).catch((error) => {
3026
- consola.error("Fatal error during shutdown:", error);
3027
- shutdownResolve?.();
3028
- process.exit(1);
3029
- });
3030
- };
3031
- process.on("SIGINT", () => handler("SIGINT"));
3032
- process.on("SIGTERM", () => handler("SIGTERM"));
3033
- }
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
+ };
3034
2736
 
3035
2737
  //#endregion
3036
2738
  //#region src/lib/tui/console-renderer.ts
@@ -3154,7 +2856,7 @@ var ConsoleRenderer = class {
3154
2856
  * Format a complete log line with colored parts
3155
2857
  */
3156
2858
  formatLogLine(parts) {
3157
- 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;
3158
2860
  if (isDim) {
3159
2861
  const modelPart = model ? ` ${model}` : "";
3160
2862
  const extraPart = extra ? ` ${extra}` : "";
@@ -3168,6 +2870,7 @@ var ConsoleRenderer = class {
3168
2870
  if (duration) result += ` ${pc.yellow(duration)}`;
3169
2871
  if (queueWait) result += ` ${pc.dim(`(queued ${queueWait})`)}`;
3170
2872
  if (tokens) result += ` ${pc.blue(tokens)}`;
2873
+ if (phases) result += ` ${pc.dim(phases)}`;
3171
2874
  if (extra) result += isError ? pc.red(extra) : extra;
3172
2875
  return result;
3173
2876
  }
@@ -3188,6 +2891,19 @@ var ConsoleRenderer = class {
3188
2891
  if (request.resolvedModel) return `${request.model} -> ${request.resolvedModel}`;
3189
2892
  return request.model;
3190
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
+ }
3191
2907
  onRequestStart(request) {
3192
2908
  this.activeRequests.set(request.id, request);
3193
2909
  if (this.showActive && consola.level >= 5) {
@@ -3236,6 +2952,7 @@ var ConsoleRenderer = class {
3236
2952
  duration: formatDuration(request.durationMs ?? 0),
3237
2953
  queueWait,
3238
2954
  tokens,
2955
+ phases: this.formatPhases(request),
3239
2956
  extra: isError && request.error ? `: ${request.error}` : void 0,
3240
2957
  isError,
3241
2958
  isDim: request.isHistoryAccess
@@ -3255,7 +2972,7 @@ var ConsoleRenderer = class {
3255
2972
  //#endregion
3256
2973
  //#region src/lib/tui/tracker.ts
3257
2974
  function generateId() {
3258
- return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
2975
+ return randomUUID();
3259
2976
  }
3260
2977
  var RequestTracker = class {
3261
2978
  requests = /* @__PURE__ */ new Map();
@@ -3286,6 +3003,7 @@ var RequestTracker = class {
3286
3003
  status: "executing",
3287
3004
  isHistoryAccess: options.isHistoryAccess
3288
3005
  };
3006
+ if (this.requests.has(id)) consola.warn(`[tracker] request id collision, overwriting in-flight: ${id}`);
3289
3007
  this.requests.set(id, request);
3290
3008
  this.renderer?.onRequestStart(request);
3291
3009
  return id;
@@ -3305,6 +3023,10 @@ var RequestTracker = class {
3305
3023
  if (update.error !== void 0) request.error = update.error;
3306
3024
  if (update.queuePosition !== void 0) request.queuePosition = update.queuePosition;
3307
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
+ }
3308
3030
  this.renderer?.onRequestUpdate(id, update);
3309
3031
  }
3310
3032
  /**
@@ -3397,35 +3119,39 @@ const requestTracker = new RequestTracker();
3397
3119
  function tuiLogger() {
3398
3120
  return async (c, next) => {
3399
3121
  if (getIsShuttingDown()) return c.json({ error: "Server is shutting down" }, 503);
3400
- const method = c.req.method;
3401
- const path = c.req.path;
3402
- const isHistoryAccess = path.startsWith("/history");
3403
- const trackingId = requestTracker.startRequest({
3404
- method,
3405
- path,
3406
- model: "",
3407
- isHistoryAccess
3408
- });
3409
- c.set("trackingId", trackingId);
3410
- try {
3411
- await next();
3412
- if ((c.res.headers.get("content-type") ?? "").includes("text/event-stream")) return;
3413
- const status = c.res.status;
3414
- const inputTokens = c.res.headers.get("x-input-tokens");
3415
- const outputTokens = c.res.headers.get("x-output-tokens");
3416
- const model = c.res.headers.get("x-model");
3417
- if (model) {
3418
- const request = requestTracker.getRequest(trackingId);
3419
- 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;
3420
3153
  }
3421
- requestTracker.completeRequest(trackingId, status, inputTokens && outputTokens ? {
3422
- inputTokens: Number.parseInt(inputTokens, 10),
3423
- outputTokens: Number.parseInt(outputTokens, 10)
3424
- } : void 0);
3425
- } catch (error) {
3426
- requestTracker.failRequest(trackingId, error instanceof Error ? error.message : "Unknown error");
3427
- throw error;
3428
- }
3154
+ });
3429
3155
  };
3430
3156
  }
3431
3157
 
@@ -3471,218 +3197,6 @@ function removeSystemReminderTags(text) {
3471
3197
  return result;
3472
3198
  }
3473
3199
 
3474
- //#endregion
3475
- //#region src/lib/tokenizer.ts
3476
- const ENCODING_MAP = {
3477
- o200k_base: () => import("gpt-tokenizer/encoding/o200k_base"),
3478
- cl100k_base: () => import("gpt-tokenizer/encoding/cl100k_base"),
3479
- p50k_base: () => import("gpt-tokenizer/encoding/p50k_base"),
3480
- p50k_edit: () => import("gpt-tokenizer/encoding/p50k_edit"),
3481
- r50k_base: () => import("gpt-tokenizer/encoding/r50k_base")
3482
- };
3483
- const encodingCache = /* @__PURE__ */ new Map();
3484
- /**
3485
- * Calculate tokens for tool calls
3486
- */
3487
- const calculateToolCallsTokens = (toolCalls, encoder, constants) => {
3488
- let tokens = 0;
3489
- for (const toolCall of toolCalls) {
3490
- tokens += constants.funcInit;
3491
- tokens += encoder.encode(JSON.stringify(toolCall)).length;
3492
- }
3493
- tokens += constants.funcEnd;
3494
- return tokens;
3495
- };
3496
- /**
3497
- * Calculate tokens for content parts
3498
- */
3499
- const calculateContentPartsTokens = (contentParts, encoder) => {
3500
- let tokens = 0;
3501
- for (const part of contentParts) if (part.type === "image_url") tokens += encoder.encode(part.image_url.url).length + 85;
3502
- else if (part.text) tokens += encoder.encode(part.text).length;
3503
- return tokens;
3504
- };
3505
- /**
3506
- * Calculate tokens for a single message
3507
- */
3508
- const calculateMessageTokens = (message, encoder, constants) => {
3509
- const tokensPerMessage = 3;
3510
- const tokensPerName = 1;
3511
- let tokens = tokensPerMessage;
3512
- for (const [key, value] of Object.entries(message)) {
3513
- if (typeof value === "string") tokens += encoder.encode(value).length;
3514
- if (key === "name") tokens += tokensPerName;
3515
- if (key === "tool_calls") tokens += calculateToolCallsTokens(value, encoder, constants);
3516
- if (key === "content" && Array.isArray(value)) tokens += calculateContentPartsTokens(value, encoder);
3517
- }
3518
- return tokens;
3519
- };
3520
- /**
3521
- * Calculate tokens using custom algorithm
3522
- */
3523
- const calculateTokens = (messages, encoder, constants) => {
3524
- if (messages.length === 0) return 0;
3525
- let numTokens = 0;
3526
- for (const message of messages) numTokens += calculateMessageTokens(message, encoder, constants);
3527
- numTokens += 3;
3528
- return numTokens;
3529
- };
3530
- /**
3531
- * Get the corresponding encoder module based on encoding type
3532
- */
3533
- const getEncodeChatFunction = async (encoding) => {
3534
- if (encodingCache.has(encoding)) {
3535
- const cached = encodingCache.get(encoding);
3536
- if (cached) return cached;
3537
- }
3538
- const supportedEncoding = encoding;
3539
- if (!(supportedEncoding in ENCODING_MAP)) {
3540
- const fallbackModule = await ENCODING_MAP.o200k_base();
3541
- encodingCache.set(encoding, fallbackModule);
3542
- return fallbackModule;
3543
- }
3544
- const encodingModule = await ENCODING_MAP[supportedEncoding]();
3545
- encodingCache.set(encoding, encodingModule);
3546
- return encodingModule;
3547
- };
3548
- /**
3549
- * Get tokenizer type from model information
3550
- */
3551
- const getTokenizerFromModel = (model) => {
3552
- return model.capabilities?.tokenizer || "o200k_base";
3553
- };
3554
- /**
3555
- * Count tokens in a text string using the model's tokenizer.
3556
- * This is a simple wrapper for counting tokens in plain text.
3557
- */
3558
- const countTextTokens = async (text, model) => {
3559
- return (await getEncodeChatFunction(getTokenizerFromModel(model))).encode(text).length;
3560
- };
3561
- /**
3562
- * Get model-specific constants for token calculation.
3563
- * These values are empirically determined based on OpenAI's function calling token overhead.
3564
- * - funcInit: Tokens for initializing a function definition
3565
- * - propInit: Tokens for initializing the properties section
3566
- * - propKey: Tokens per property key
3567
- * - enumInit: Token adjustment when enum is present (negative because type info is replaced)
3568
- * - enumItem: Tokens per enum value
3569
- * - funcEnd: Tokens for closing the function definition
3570
- */
3571
- const getModelConstants = (model) => {
3572
- return model.id === "gpt-3.5-turbo" || model.id === "gpt-4" ? {
3573
- funcInit: 10,
3574
- propInit: 3,
3575
- propKey: 3,
3576
- enumInit: -3,
3577
- enumItem: 3,
3578
- funcEnd: 12
3579
- } : {
3580
- funcInit: 7,
3581
- propInit: 3,
3582
- propKey: 3,
3583
- enumInit: -3,
3584
- enumItem: 3,
3585
- funcEnd: 12
3586
- };
3587
- };
3588
- /**
3589
- * Calculate tokens for a single parameter
3590
- */
3591
- const calculateParameterTokens = (key, prop, context) => {
3592
- const { encoder, constants } = context;
3593
- let tokens = constants.propKey;
3594
- if (typeof prop !== "object" || prop === null) return tokens;
3595
- const param = prop;
3596
- const paramName = key;
3597
- const paramType = param.type || "string";
3598
- let paramDesc = param.description || "";
3599
- if (param.enum && Array.isArray(param.enum)) {
3600
- tokens += constants.enumInit;
3601
- for (const item of param.enum) {
3602
- tokens += constants.enumItem;
3603
- tokens += encoder.encode(String(item)).length;
3604
- }
3605
- }
3606
- if (paramDesc.endsWith(".")) paramDesc = paramDesc.slice(0, -1);
3607
- const line = `${paramName}:${paramType}:${paramDesc}`;
3608
- tokens += encoder.encode(line).length;
3609
- const excludedKeys = new Set([
3610
- "type",
3611
- "description",
3612
- "enum"
3613
- ]);
3614
- for (const propertyName of Object.keys(param)) if (!excludedKeys.has(propertyName)) {
3615
- const propertyValue = param[propertyName];
3616
- const propertyText = typeof propertyValue === "string" ? propertyValue : JSON.stringify(propertyValue);
3617
- tokens += encoder.encode(`${propertyName}:${propertyText}`).length;
3618
- }
3619
- return tokens;
3620
- };
3621
- /**
3622
- * Calculate tokens for function parameters
3623
- */
3624
- const calculateParametersTokens = (parameters, encoder, constants) => {
3625
- if (!parameters || typeof parameters !== "object") return 0;
3626
- const params = parameters;
3627
- let tokens = 0;
3628
- for (const [key, value] of Object.entries(params)) if (key === "properties") {
3629
- const properties = value;
3630
- if (Object.keys(properties).length > 0) {
3631
- tokens += constants.propInit;
3632
- for (const propKey of Object.keys(properties)) tokens += calculateParameterTokens(propKey, properties[propKey], {
3633
- encoder,
3634
- constants
3635
- });
3636
- }
3637
- } else {
3638
- const paramText = typeof value === "string" ? value : JSON.stringify(value);
3639
- tokens += encoder.encode(`${key}:${paramText}`).length;
3640
- }
3641
- return tokens;
3642
- };
3643
- /**
3644
- * Calculate tokens for a single tool
3645
- */
3646
- const calculateToolTokens = (tool, encoder, constants) => {
3647
- let tokens = constants.funcInit;
3648
- const func = tool.function;
3649
- const fName = func.name;
3650
- let fDesc = func.description || "";
3651
- if (fDesc.endsWith(".")) fDesc = fDesc.slice(0, -1);
3652
- const line = fName + ":" + fDesc;
3653
- tokens += encoder.encode(line).length;
3654
- if (typeof func.parameters === "object" && func.parameters !== null) tokens += calculateParametersTokens(func.parameters, encoder, constants);
3655
- return tokens;
3656
- };
3657
- /**
3658
- * Calculate token count for tools based on model
3659
- */
3660
- const numTokensForTools = (tools, encoder, constants) => {
3661
- let funcTokenCount = 0;
3662
- for (const tool of tools) funcTokenCount += calculateToolTokens(tool, encoder, constants);
3663
- funcTokenCount += constants.funcEnd;
3664
- return funcTokenCount;
3665
- };
3666
- /**
3667
- * Calculate the token count of messages.
3668
- * Uses the tokenizer specified by the GitHub Copilot API model info.
3669
- * All models (including Claude) use GPT tokenizers (o200k_base or cl100k_base).
3670
- */
3671
- const getTokenCount = async (payload, model) => {
3672
- const encoder = await getEncodeChatFunction(getTokenizerFromModel(model));
3673
- const simplifiedMessages = payload.messages;
3674
- const inputMessages = simplifiedMessages.filter((msg) => msg.role !== "assistant");
3675
- const outputMessages = simplifiedMessages.filter((msg) => msg.role === "assistant");
3676
- const constants = getModelConstants(model);
3677
- let inputTokens = calculateTokens(inputMessages, encoder, constants);
3678
- if (payload.tools && payload.tools.length > 0) inputTokens += numTokensForTools(payload.tools, encoder, constants);
3679
- const outputTokens = calculateTokens(outputMessages, encoder, constants);
3680
- return {
3681
- input: inputTokens,
3682
- output: outputTokens
3683
- };
3684
- };
3685
-
3686
3200
  //#endregion
3687
3201
  //#region src/lib/auto-truncate-openai.ts
3688
3202
  /**
@@ -4636,13 +4150,14 @@ function updateTrackerStatus(trackingId, status) {
4636
4150
  requestTracker.updateRequest(trackingId, { status });
4637
4151
  }
4638
4152
  /** Complete TUI tracking and send PostHog analytics */
4639
- function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, reasoningTokens, analytics) {
4153
+ function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, reasoningTokens, analytics, timings) {
4640
4154
  if (!trackingId) return;
4641
4155
  requestTracker.updateRequest(trackingId, {
4642
4156
  inputTokens,
4643
4157
  outputTokens,
4644
4158
  queueWaitMs,
4645
- reasoningTokens
4159
+ reasoningTokens,
4160
+ ...timingsToUpdate(timings)
4646
4161
  });
4647
4162
  requestTracker.completeRequest(trackingId, 200, {
4648
4163
  inputTokens,
@@ -4688,7 +4203,8 @@ function createEntryContext(args) {
4688
4203
  historyId: recordRequest(args.endpoint, args.buildHistoryRequest(payload)),
4689
4204
  trackingId,
4690
4205
  startTime,
4691
- requestedModel
4206
+ requestedModel,
4207
+ timings: getTimings()
4692
4208
  }
4693
4209
  };
4694
4210
  }
@@ -4944,7 +4460,7 @@ async function handleStreamingResponse$1(opts) {
4944
4460
  durationMs: Date.now() - ctx.startTime,
4945
4461
  stopReason: acc.finishReason || void 0,
4946
4462
  toolCount: payload.tools?.length ?? 0
4947
- });
4463
+ }, ctx.timings);
4948
4464
  } catch (error) {
4949
4465
  recordStreamError({
4950
4466
  acc,
@@ -7399,8 +6915,9 @@ const SSE_PING = ": ping\n\n";
7399
6915
  /**
7400
6916
  * Grace period before opening a keepalive stream. Normal upstream responses
7401
6917
  * resolve sub-second (response headers arrive immediately, the body is not
7402
- * buffered); only a request queued behind the rate limiter takes >=10s. 3s
7403
- * 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.
7404
6921
  */
7405
6922
  const RATE_LIMIT_GRACE_MS = 3e3;
7406
6923
  /**
@@ -8887,7 +8404,7 @@ async function handleDirectAnthropicStreamingResponse(opts) {
8887
8404
  durationMs: Date.now() - ctx.startTime,
8888
8405
  stopReason: acc.stopReason || void 0,
8889
8406
  toolCount: anthropicPayload.tools?.length ?? 0
8890
- });
8407
+ }, ctx.timings);
8891
8408
  } catch (error) {
8892
8409
  consola.error("Direct Anthropic stream error:", formatError(error));
8893
8410
  recordStreamError({
@@ -9132,7 +8649,7 @@ async function handleStreamingResponse(opts) {
9132
8649
  durationMs: Date.now() - ctx.startTime,
9133
8650
  stopReason: acc.stopReason || void 0,
9134
8651
  toolCount: anthropicPayload.tools?.length ?? 0
9135
- });
8652
+ }, ctx.timings);
9136
8653
  } catch (error) {
9137
8654
  consola.error("Stream error:", formatError(error));
9138
8655
  recordStreamError({
@@ -9724,7 +9241,7 @@ const handleResponses = async (c) => {
9724
9241
  stream: true,
9725
9242
  durationMs: Date.now() - startTime,
9726
9243
  toolCount: tools.length
9727
- });
9244
+ }, ctx.timings);
9728
9245
  } else if (streamErrorMessage) {
9729
9246
  recordResponse(historyId, {
9730
9247
  success: false,
@@ -9736,8 +9253,8 @@ const handleResponses = async (c) => {
9736
9253
  error: streamErrorMessage,
9737
9254
  content: null
9738
9255
  }, Date.now() - startTime);
9739
- completeTracking(trackingId, 0, 0, queueWaitMs);
9740
- } 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);
9741
9258
  } catch (error) {
9742
9259
  recordStreamError({
9743
9260
  acc: { model: finalResult?.model || model },
@@ -9770,7 +9287,7 @@ const handleResponses = async (c) => {
9770
9287
  stream: false,
9771
9288
  durationMs: Date.now() - startTime,
9772
9289
  toolCount: tools.length
9773
- });
9290
+ }, ctx.timings);
9774
9291
  consola.debug("Forwarding native Responses result:", JSON.stringify(result).slice(-400));
9775
9292
  return c.json(echoResponseBody(result, ctx));
9776
9293
  } catch (error) {
@@ -9925,7 +9442,6 @@ function formatModelInfo(model) {
9925
9442
  async function runServer(options) {
9926
9443
  consola.info(`copilot-api v${version}`);
9927
9444
  configureProxyApiKey(options.apiKey);
9928
- if (options.proxyEnv) initProxyFromEnv();
9929
9445
  if (options.verbose) {
9930
9446
  consola.level = 5;
9931
9447
  consola.info("Verbose logging enabled");
@@ -9939,34 +9455,12 @@ async function runServer(options) {
9939
9455
  process.exit(1);
9940
9456
  }
9941
9457
  if (options.accountType !== "individual") consola.info(`Using ${options.accountType} plan GitHub account`);
9942
- state.manualApprove = options.manual;
9943
- state.showToken = options.showToken;
9944
- state.showAllModels = options.showAllModels;
9945
- if (options.showAllModels) consola.warn("--show-all-models: hidden model blacklist is BYPASSED for this run");
9946
- state.autoTruncate = options.autoTruncate;
9947
- state.compressToolResults = options.compressToolResults;
9948
- state.redirectAnthropic = options.redirectAnthropic;
9949
- state.stripServerTools = options.stripServerTools;
9950
- state.contextEditingMode = options.contextEditing;
9951
- state.timezoneOffset = options.timezoneOffset;
9952
- if (options.rateLimit) initAdaptiveRateLimiter({
9953
- baseRetryIntervalSeconds: options.retryInterval,
9954
- requestIntervalSeconds: options.requestInterval,
9955
- recoveryTimeoutMinutes: options.recoveryTimeout,
9956
- consecutiveSuccessesForRecovery: options.consecutiveSuccesses
9957
- });
9458
+ if (options.rateLimit) initAdaptiveRateLimiter();
9958
9459
  else consola.info("Rate limiting disabled");
9959
- if (!options.autoTruncate) consola.info("Auto-truncate disabled");
9960
- if (options.compressToolResults) consola.info("Tool result compression enabled");
9961
- if (options.redirectAnthropic) consola.info("Anthropic API redirect enabled (using OpenAI translation)");
9962
- if (options.stripServerTools) consola.info("Server-side tools will be stripped from requests");
9963
- if (options.contextEditing !== "off") consola.info(`Context editing mode: ${options.contextEditing}`);
9964
- initHistory(options.history, options.historyLimit);
9965
- if (options.history) {
9966
- const limitText = options.historyLimit === 0 ? "unlimited" : `max ${options.historyLimit}`;
9967
- consola.info(`History recording enabled (${limitText} entries)`);
9968
- startMemoryPressureMonitor();
9969
- }
9460
+ initHistory(true, 1e3);
9461
+ consola.info("History recording enabled (max 1000 entries)");
9462
+ startMemoryPressureMonitor();
9463
+ startEventLoopLagMonitor();
9970
9464
  if (options.posthogKey) {
9971
9465
  initPostHog(options.posthogKey);
9972
9466
  if (isPostHogEnabled()) consola.info("PostHog analytics enabled");
@@ -9987,48 +9481,17 @@ async function runServer(options) {
9987
9481
  consola.error(error instanceof Error ? error.message : String(error));
9988
9482
  process.exit(1);
9989
9483
  }
9484
+ await warmupTokenizer();
9990
9485
  const allModels = state.models?.data ?? [];
9991
9486
  if (allModels.length === 0) {
9992
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.`);
9993
9488
  process.exit(1);
9994
9489
  }
9995
9490
  const visibleModels = allModels.filter((m) => !isHiddenModel(m.id, state.showAllModels));
9996
- if (visibleModels.length === 0) consola.warn("All upstream models are filtered by the hardcoded blacklist. /v1/models will return an empty list, but explicit POSTs with a hidden id still pass through to upstream. Restart with --show-all-models to see the full catalogue.");
9491
+ if (visibleModels.length === 0) consola.warn("All upstream models are filtered by the hardcoded blacklist. /v1/models will return an empty list, but explicit POSTs with a hidden id still pass through to upstream. Edit src/lib/hidden-models.ts to change the blacklist.");
9997
9492
  else consola.info(`Available models:\n${visibleModels.map((m) => formatModelInfo(m)).join("\n")}`);
9998
9493
  const serverUrl = `http://${resolveClientHost(options.host, void 0)}:${options.port}`;
9999
- if (options.claudeCode) {
10000
- if (visibleModels.length === 0) {
10001
- consola.error("--claude-code interactive setup needs at least one visible model. Restart with --show-all-models or update src/lib/hidden-models.ts.");
10002
- process.exit(1);
10003
- }
10004
- const selectedModel = await consola.prompt("Select a model to use with Claude Code", {
10005
- type: "select",
10006
- options: visibleModels.map((model) => model.id)
10007
- });
10008
- const selectedSmallModel = await consola.prompt("Select a small model to use with Claude Code", {
10009
- type: "select",
10010
- options: visibleModels.map((model) => model.id)
10011
- });
10012
- const command = generateEnvScript({
10013
- ANTHROPIC_BASE_URL: serverUrl,
10014
- [CLAUDE_CODE_AUTH_TOKEN_ENV]: CLAUDE_CODE_AUTH_TOKEN_PLACEHOLDER,
10015
- ANTHROPIC_MODEL: selectedModel,
10016
- ANTHROPIC_DEFAULT_SONNET_MODEL: selectedModel,
10017
- ANTHROPIC_SMALL_FAST_MODEL: selectedSmallModel,
10018
- ANTHROPIC_DEFAULT_HAIKU_MODEL: selectedSmallModel,
10019
- DISABLE_NON_ESSENTIAL_MODEL_CALLS: "1",
10020
- CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
10021
- }, "claude");
10022
- try {
10023
- clipboard.writeSync(command);
10024
- consola.success("Copied Claude Code command to clipboard!");
10025
- } catch {
10026
- consola.warn("Failed to copy to clipboard. Here is the Claude Code command:");
10027
- consola.log(command);
10028
- }
10029
- for (const line of buildClaudeCodeAuthHint(options.apiKeySource)) consola.warn(line);
10030
- }
10031
- consola.box(`🌐 Usage Viewer: https://ericc-ch.github.io/copilot-api?endpoint=${serverUrl}/usage${options.history ? `\n📜 History UI: ${serverUrl}/history` : ""}`);
9494
+ consola.box(`🌐 Usage Viewer: https://ericc-ch.github.io/copilot-api?endpoint=${serverUrl}/usage\n📜 History UI: ${serverUrl}/history`);
10032
9495
  for (const line of buildStartupAuthLines({
10033
9496
  source: options.apiKeySource,
10034
9497
  bindAddress: resolveBindAddress(options.host, void 0)
@@ -10049,23 +9512,6 @@ async function runServer(options) {
10049
9512
  } }
10050
9513
  }));
10051
9514
  }
10052
- function parseTimezoneOffset(value) {
10053
- if (typeof value !== "string") return 8;
10054
- const n = Number(value);
10055
- if (!Number.isFinite(n)) return 8;
10056
- return n;
10057
- }
10058
- const validContextEditingModes = [
10059
- "off",
10060
- "clear-thinking",
10061
- "clear-tooluse",
10062
- "clear-both"
10063
- ];
10064
- function parseContextEditing(value) {
10065
- if (validContextEditingModes.includes(value)) return value;
10066
- consola.warn(`Invalid context editing mode: "${value}", using "off". Valid: ${validContextEditingModes.join(", ")}`);
10067
- return "off";
10068
- }
10069
9515
  const start = defineCommand({
10070
9516
  meta: {
10071
9517
  name: "start",
@@ -10095,101 +9541,15 @@ const start = defineCommand({
10095
9541
  default: "individual",
10096
9542
  description: "Account type to use (individual, business, enterprise)"
10097
9543
  },
10098
- manual: {
10099
- type: "boolean",
10100
- default: false,
10101
- description: "Enable manual request approval"
10102
- },
10103
9544
  "no-rate-limit": {
10104
9545
  type: "boolean",
10105
9546
  default: false,
10106
9547
  description: "Disable adaptive rate limiting"
10107
9548
  },
10108
- "retry-interval": {
10109
- type: "string",
10110
- default: "10",
10111
- description: "Seconds to wait before retrying after rate limit error (default: 10)"
10112
- },
10113
- "request-interval": {
10114
- type: "string",
10115
- default: "10",
10116
- description: "Seconds between requests in rate-limited mode (default: 10)"
10117
- },
10118
- "recovery-timeout": {
10119
- type: "string",
10120
- default: "10",
10121
- description: "Minutes before attempting to recover from rate-limited mode (default: 10)"
10122
- },
10123
- "consecutive-successes": {
10124
- type: "string",
10125
- default: "5",
10126
- description: "Number of consecutive successes needed to recover from rate-limited mode (default: 5)"
10127
- },
10128
9549
  "github-token": {
10129
9550
  alias: "g",
10130
9551
  type: "string",
10131
- description: "Provide GitHub token directly (must be generated using the `auth` subcommand). Falls back to the GH_TOKEN env var if the flag is omitted — prefer the env for automation since argv is visible via /proc/<pid>/cmdline."
10132
- },
10133
- "claude-code": {
10134
- alias: "c",
10135
- type: "boolean",
10136
- default: false,
10137
- description: "Generate a command to launch Claude Code with Copilot API config"
10138
- },
10139
- "show-token": {
10140
- type: "boolean",
10141
- default: false,
10142
- description: "Show GitHub and Copilot tokens on fetch and refresh"
10143
- },
10144
- "show-all-models": {
10145
- type: "boolean",
10146
- default: false,
10147
- description: "Show ALL upstream models, including the hardcoded blacklist (default: false, blacklist filtered from listings)"
10148
- },
10149
- "proxy-env": {
10150
- type: "boolean",
10151
- default: false,
10152
- description: "Initialize proxy from environment variables"
10153
- },
10154
- "no-history": {
10155
- type: "boolean",
10156
- default: false,
10157
- description: "Disable request history recording and Web UI"
10158
- },
10159
- "history-limit": {
10160
- type: "string",
10161
- default: "1000",
10162
- description: "Maximum number of history entries to keep in memory (0 = unlimited)"
10163
- },
10164
- "no-auto-truncate": {
10165
- type: "boolean",
10166
- default: false,
10167
- description: "Disable automatic conversation history truncation when exceeding limits"
10168
- },
10169
- "compress-tool-results": {
10170
- type: "boolean",
10171
- default: false,
10172
- description: "Compress old tool_result content before truncating messages (may lose context details)"
10173
- },
10174
- "redirect-anthropic": {
10175
- type: "boolean",
10176
- default: false,
10177
- description: "Redirect Anthropic models through OpenAI translation (instead of direct API)"
10178
- },
10179
- "strip-server-tools": {
10180
- type: "boolean",
10181
- default: false,
10182
- description: "Strip Anthropic server-side tools (web_search, etc.) from requests"
10183
- },
10184
- "context-editing": {
10185
- type: "string",
10186
- default: "off",
10187
- description: "Context editing mode: off, clear-thinking, clear-tooluse, clear-both"
10188
- },
10189
- "timezone-offset": {
10190
- type: "string",
10191
- default: "+8",
10192
- description: "Timezone offset in hours from UTC for log timestamps (e.g., +8, -5, 0)"
9552
+ description: "Provide GitHub token directly (must be generated using the `login` subcommand). Falls back to the GH_TOKEN env var if the flag is omitted — prefer the env for automation since argv is visible via /proc/<pid>/cmdline."
10193
9553
  },
10194
9554
  "posthog-key": {
10195
9555
  type: "string",
@@ -10210,25 +9570,8 @@ const start = defineCommand({
10210
9570
  host: resolveBindHost(args.host, process.env.HOST),
10211
9571
  verbose: args.verbose,
10212
9572
  accountType: args["account-type"],
10213
- manual: args.manual,
10214
9573
  rateLimit: !args["no-rate-limit"],
10215
- retryInterval: Number.parseInt(args["retry-interval"], 10),
10216
- requestInterval: Number.parseInt(args["request-interval"], 10),
10217
- recoveryTimeout: Number.parseInt(args["recovery-timeout"], 10),
10218
- consecutiveSuccesses: Number.parseInt(args["consecutive-successes"], 10),
10219
9574
  githubToken: args["github-token"] || process.env.GH_TOKEN,
10220
- claudeCode: args["claude-code"],
10221
- showToken: args["show-token"],
10222
- showAllModels: args["show-all-models"],
10223
- proxyEnv: args["proxy-env"],
10224
- history: !args["no-history"],
10225
- historyLimit: Number.parseInt(args["history-limit"], 10),
10226
- autoTruncate: !args["no-auto-truncate"],
10227
- compressToolResults: args["compress-tool-results"],
10228
- redirectAnthropic: args["redirect-anthropic"],
10229
- stripServerTools: args["strip-server-tools"],
10230
- contextEditing: parseContextEditing(args["context-editing"]),
10231
- timezoneOffset: parseTimezoneOffset(args["timezone-offset"]),
10232
9575
  posthogKey: args["posthog-key"],
10233
9576
  apiKey: resolvedApiKey.key,
10234
9577
  apiKeySource: resolvedApiKey.source
@@ -10245,12 +9588,10 @@ await runMain(defineCommand({
10245
9588
  description: "A wrapper around GitHub Copilot API to make it OpenAI compatible, making it usable for other tools."
10246
9589
  },
10247
9590
  subCommands: {
10248
- auth,
9591
+ login,
10249
9592
  logout,
10250
9593
  start,
10251
- "check-usage": checkUsage,
10252
- debug,
10253
- "patch-claude": patchClaude
9594
+ debug
10254
9595
  }
10255
9596
  }));
10256
9597