@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.
- package/README.md +9 -49
- package/dist/main.mjs +1724 -2383
- 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
|
|
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 {
|
|
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
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
-
*
|
|
1093
|
+
* WebSocket support for History API.
|
|
1094
|
+
* Enables real-time updates when new requests are recorded.
|
|
1078
1095
|
*/
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
}
|
|
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
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
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
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
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
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
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
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
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
|
-
|
|
1643
|
-
*/
|
|
1644
|
-
function getAdaptiveRateLimiter() {
|
|
1645
|
-
return rateLimiterInstance;
|
|
1341
|
+
function getEntry(id) {
|
|
1342
|
+
return entryIndex.get(id);
|
|
1646
1343
|
}
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
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
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
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
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
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
|
-
|
|
1761
|
-
|
|
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
|
-
*
|
|
1766
|
-
*
|
|
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
|
|
1794
|
-
if (
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
const
|
|
1799
|
-
|
|
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
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
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
|
-
*
|
|
1832
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
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
|
-
|
|
1923
|
-
|
|
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
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
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
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
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/
|
|
2009
|
-
let
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
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
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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
|
-
|
|
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/
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
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
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
const
|
|
2130
|
-
const
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
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
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
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
|
-
|
|
2151
|
-
|
|
2152
|
-
listener
|
|
2153
|
-
}
|
|
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
|
-
|
|
2156
|
-
const
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
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
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
"
|
|
2244
|
-
"
|
|
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/
|
|
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
|
-
*
|
|
2280
|
-
*
|
|
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
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
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
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
}
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
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
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
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
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
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
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
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
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
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
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
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
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
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
|
-
|
|
2528
|
-
|
|
1903
|
+
/**
|
|
1904
|
+
* Get the rate limiter instance.
|
|
1905
|
+
*/
|
|
1906
|
+
function getAdaptiveRateLimiter() {
|
|
1907
|
+
return rateLimiterInstance;
|
|
2529
1908
|
}
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
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
|
-
|
|
2538
|
-
|
|
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
|
-
|
|
2541
|
-
|
|
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
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
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
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
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
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
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
|
-
|
|
2636
|
-
|
|
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
|
-
*
|
|
2650
|
-
*
|
|
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
|
|
2653
|
-
if (
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
return
|
|
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
|
-
*
|
|
2704
|
-
*
|
|
2064
|
+
* Resolve the address the server will *actually* bind to for the startup banner
|
|
2065
|
+
* (Issue 04).
|
|
2705
2066
|
*
|
|
2706
|
-
*
|
|
2707
|
-
*
|
|
2708
|
-
*
|
|
2709
|
-
*
|
|
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
|
-
|
|
2712
|
-
const
|
|
2713
|
-
|
|
2714
|
-
|
|
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
|
-
|
|
2732
|
-
|
|
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
|
-
|
|
2735
|
-
|
|
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
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
if (
|
|
2751
|
-
|
|
2752
|
-
|
|
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
|
-
|
|
2758
|
-
|
|
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
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
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
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
function
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
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
|
-
|
|
2804
|
-
|
|
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
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
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
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
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/
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
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
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
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
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
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
|
-
|
|
2893
|
-
|
|
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/
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
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
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
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
|
|
2936
|
-
}
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
const
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
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
|
-
|
|
2955
|
-
}
|
|
2956
|
-
|
|
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
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
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 (
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
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
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
const
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
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
|
-
|
|
3009
|
-
|
|
3010
|
-
shutdownResolve?.();
|
|
2687
|
+
const paramText = typeof value === "string" ? value : JSON.stringify(value);
|
|
2688
|
+
tokens += encoder.encode(`${key}:${paramText}`).length;
|
|
3011
2689
|
}
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
const
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
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
|
|
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
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
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
|
-
|
|
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
|
|
7403
|
-
* cleanly separates the two — a request still pending
|
|
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
|
-
|
|
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
|
-
|
|
9960
|
-
|
|
9961
|
-
|
|
9962
|
-
|
|
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.
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
9591
|
+
login,
|
|
10249
9592
|
logout,
|
|
10250
9593
|
start,
|
|
10251
|
-
|
|
10252
|
-
debug,
|
|
10253
|
-
"patch-claude": patchClaude
|
|
9594
|
+
debug
|
|
10254
9595
|
}
|
|
10255
9596
|
}));
|
|
10256
9597
|
|