llm-switcher 1.4.1 → 1.6.2

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/proxy.mjs CHANGED
@@ -9,8 +9,8 @@ import { spawn } from 'node:child_process';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { createFrameReader } from './blindfold/wsframe.mjs';
11
11
  import {
12
- OUT_FORMATS, parseToIR, emitUpstreamBody, createUpstreamNormalizer, createCollector,
13
- createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES,
12
+ OUT_FORMATS, parseToIR, emitUpstreamBody, irToAnthropicBody, createUpstreamNormalizer, createCollector,
13
+ createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES, VALID_EFFORTS,
14
14
  toGeminiSchema, isAntigravityModel,
15
15
  createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
16
16
  buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
@@ -32,6 +32,11 @@ import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog
32
32
  import { checkForUpdate } from './version.mjs';
33
33
  import { applyUpdate } from './update.mjs';
34
34
  import { createContractLab, createHalfTap, tapClientWrites, capText, capJson, toolVersionFromUA, finishHalf, PROBE_HEADER, TRACE_ID_RE } from './contract.mjs';
35
+ import {
36
+ idleCompactPolicy, validateIdleCompactPatch, compactRawHistory, summaryReplacement, IDLE_COMPACT_KEY,
37
+ } from './idlecompact.mjs';
38
+ import { askSummary } from './idlecall.mjs';
39
+ import { IdlePrefixCache, compactCacheKey, policyFingerprint } from './idlecache.mjs';
35
40
 
36
41
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
42
  const uiHtmlPath = path.join(__dirname, 'ui.html');
@@ -743,8 +748,102 @@ function cleanSchemaDeep(obj) {
743
748
  // 1. Strip disallowed keywords ('encrypted', '$schema', 'cache_control')
744
749
  // 2. Fix invalid schema values where a property has a string value "object" instead of a valid schema object
745
750
  // 3. For ag/* targets (Gemini behind 9Router): rewrite to the strict Schema subset
746
- function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req) {
747
- const upBody = emitUpstreamBody(outFormat, ir, mappedModel, { thinkingMode: profile.thinkingMode });
751
+ // ---- idle compaction ----
752
+
753
+ // Persist the gateway's exact-prefix mapping, never the client's native history.
754
+ const idlePrefixCache = new IdlePrefixCache(STATE_DIR, {
755
+ onDiagnostic: code => console.warn(`[llm-switcher] idle compact: ${code}; forwarding is preserved`),
756
+ });
757
+
758
+ async function maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer) {
759
+ const policy = idleCompactPolicy(loadConfig());
760
+ const wants = policy.enabled && (clientFormat === 'anthropic' ||
761
+ (clientFormat === 'responses' && policy.codex));
762
+ if (!wants) return null;
763
+ const field = clientFormat === 'responses' ? 'input' : 'messages';
764
+ const history = payload[field];
765
+ if (!Array.isArray(history)) return null;
766
+ const key = compactCacheKey(clientFormat, req, payload, ir, profile, profileKey, policy);
767
+ // Shared opening prompts do not prove a session identity. Anonymous requests stay unchanged.
768
+ if (!key) return null;
769
+ const visited = idlePrefixCache.visit(key, history, policy);
770
+ const rewrite = messages => {
771
+ const next = { ...payload, [field]: messages };
772
+ return { payload: next, bodyBuffer: Buffer.from(JSON.stringify(next), 'utf8'), cacheKey: key };
773
+ };
774
+ if (visited.idleMs <= policy.idleMs || Buffer.byteLength(JSON.stringify(payload)) < policy.minBytes) {
775
+ return visited.history ? rewrite(visited.history) : null;
776
+ }
777
+ const compacted = compactRawHistory(history, clientFormat, policy);
778
+ // Reuse one stable prefix throughout a window. A later idle window can compact a larger
779
+ // original prefix; an unchanged prefix never asks for the same summary twice.
780
+ if (!compacted || (visited.record?.prefixCount && compacted.prefixCount <= visited.record.prefixCount)) {
781
+ return visited.history ? rewrite(visited.history) : null;
782
+ }
783
+ const record = idlePrefixCache.create(key, history, compacted.prefixCount, compacted.replacement, policy);
784
+ if (!record) return null; // persistence failed: send the complete original request
785
+ console.log(`[llm-switcher] idle compact ${key.slice(0, 12)} [${profileKey}]: ` +
786
+ `${Buffer.byteLength(JSON.stringify(history))} -> ${Buffer.byteLength(JSON.stringify(compacted.history))} bytes; ` +
787
+ (policy.model ? 'configured summary requested' : 'deterministic prefix cached; no model called'));
788
+ if (policy.model) {
789
+ const snapshotPolicy = policyFingerprint(policy);
790
+ const prefixIR = parseToIR(clientFormat, { ...payload, [field]: history.slice(0, compacted.prefixCount) });
791
+ const summaryModel = mapModel(policy.model, profile, clientFormat);
792
+ const summaryFormat = resolveOutFormat(profile, summaryModel);
793
+ askSummary({ ir: prefixIR, policy, profile, model: summaryModel,
794
+ build: (prof, model, summaryIR) => buildUpstreamRequest(prof, summaryFormat, summaryIR, model, req),
795
+ onDiagnostic: ({ code, status }) => console.warn(`[llm-switcher] idle compact ${key.slice(0, 12)}: ${code}${status ? ` (${status})` : ''}`),
796
+ }).then(summary => {
797
+ if (!summary) return;
798
+ const cfg = loadConfig();
799
+ const livePolicy = idleCompactPolicy(cfg);
800
+ const liveProfile = cfg?.profiles?.[profileKey];
801
+ if (policyFingerprint(livePolicy) !== snapshotPolicy || !liveProfile ||
802
+ compactCacheKey(clientFormat, req, payload, ir, liveProfile, profileKey, livePolicy) !== key) {
803
+ console.log(`[llm-switcher] idle compact ${key.slice(0, 12)}: summary discarded after configuration change`);
804
+ return;
805
+ }
806
+ const stored = idlePrefixCache.upgrade(key, record.generation, summaryReplacement(compacted, summary), policy);
807
+ console.log(`[llm-switcher] idle compact ${key.slice(0, 12)}: summary ${stored ? 'cached' : 'discarded after a newer generation or history change'}`);
808
+ }).catch(() => console.warn(`[llm-switcher] idle compact ${key.slice(0, 12)}: summary-call-failed`));
809
+ }
810
+ return rewrite(compacted.history);
811
+ }
812
+
813
+ function resolveSlot(requestedModel, mappedModel, profile, clientFormat) {
814
+ if (clientFormat === 'codeassist') return 'main';
815
+ const clean = String(requestedModel || '').replace(/\[1m\]/gi, '').trim().toLowerCase();
816
+ if (clientFormat === 'responses') {
817
+ if (clean.includes('review')) return 'review';
818
+ if (clean.includes('subagent')) return 'subagent';
819
+ return 'main';
820
+ }
821
+ if (clean.includes('fable')) return 'fable';
822
+ if (clean.includes('opus')) return 'opus';
823
+ if (clean.includes('haiku')) return 'haiku';
824
+ if (clean.includes('sonnet')) return 'sonnet';
825
+ if (profile?.defaultModels) {
826
+ for (const [s, m] of Object.entries(profile.defaultModels)) {
827
+ if (m && (m === mappedModel || m === requestedModel || String(m).toLowerCase() === clean)) return s;
828
+ }
829
+ }
830
+ return 'default';
831
+ }
832
+
833
+ function resolveThinkingEffort(profile, slot, mappedModel) {
834
+ if (profile?.modelEfforts && typeof profile.modelEfforts === 'object') {
835
+ if (slot && profile.modelEfforts[slot]) return profile.modelEfforts[slot];
836
+ if (mappedModel && profile.modelEfforts[mappedModel]) return profile.modelEfforts[mappedModel];
837
+ }
838
+ return profile?.thinkingEffort || 'auto';
839
+ }
840
+
841
+ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null) {
842
+ const effort = resolveThinkingEffort(profile, slot, mappedModel);
843
+ const upBody = emitUpstreamBody(outFormat, ir, mappedModel, {
844
+ thinkingMode: profile.thinkingMode,
845
+ thinkingEffort: effort,
846
+ });
748
847
  if (upBody?.tools && Array.isArray(upBody.tools)) {
749
848
  upBody.tools = cleanSchemaDeep(upBody.tools);
750
849
  if (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)) {
@@ -839,6 +938,22 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
839
938
  // Code Assist names streaming in the path (:streamGenerateContent), not in the body.
840
939
  if (clientFormat === 'codeassist') ir.stream = Boolean(opts.codeAssistStream);
841
940
 
941
+ // Compaction rewrites the request itself, not just the intermediate form: when the client and
942
+ // the provider speak the same shape this gateway forwards the client's own bytes, so a change
943
+ // made only to the parsed form would shorten nothing that leaves the building.
944
+ const shrunk = await maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer);
945
+ if (shrunk) {
946
+ try {
947
+ const compactedIR = parseToIR(clientFormat, shrunk.payload);
948
+ payload = shrunk.payload;
949
+ bodyBuffer = shrunk.bodyBuffer;
950
+ ir = compactedIR;
951
+ } catch {
952
+ idlePrefixCache.remove(shrunk.cacheKey);
953
+ console.warn('[llm-switcher] idle compact: cache-reparse-failed; forwarding original request');
954
+ }
955
+ }
956
+
842
957
  const reqStartTime = Date.now();
843
958
  const requestPreview = previewOf(ir);
844
959
  const requestedModel = ir.model || payload.model || '';
@@ -912,7 +1027,8 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
912
1027
  return;
913
1028
  }
914
1029
 
915
- const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req);
1030
+ const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
1031
+ const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
916
1032
  if (traceId) headers['x-intact-trace'] = traceId;
917
1033
  debugLog(`[${profileKey}] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 500));
918
1034
 
@@ -1140,6 +1256,15 @@ function validateProfileInput(p) {
1140
1256
  if (p.outFormat && !OUT_FORMATS.includes(p.outFormat)) return `Invalid outFormat "${p.outFormat}"`;
1141
1257
  if (p.mode && !VALID_MODES.includes(p.mode)) return `Invalid mode "${p.mode}"`;
1142
1258
  if (p.thinkingMode && !THINKING_MODES.includes(p.thinkingMode)) return `Invalid thinkingMode "${p.thinkingMode}"`;
1259
+ if (p.thinkingEffort && !VALID_EFFORTS.includes(p.thinkingEffort)) return `Invalid thinkingEffort "${p.thinkingEffort}"`;
1260
+ if (p.modelEfforts !== undefined) {
1261
+ if (!p.modelEfforts || typeof p.modelEfforts !== 'object' || Array.isArray(p.modelEfforts)) {
1262
+ return 'modelEfforts must be an object';
1263
+ }
1264
+ for (const [slot, eff] of Object.entries(p.modelEfforts)) {
1265
+ if (eff && !VALID_EFFORTS.includes(eff)) return `Invalid modelEfforts.${slot} value "${eff}"`;
1266
+ }
1267
+ }
1143
1268
  if (p.baseURL !== undefined) {
1144
1269
  try {
1145
1270
  const u = new URL(p.baseURL);
@@ -1575,6 +1700,30 @@ async function routeApi(req, res, method, pathname) {
1575
1700
  return sendJson(res, 200, syncLocalCatalog(STATE_DIR));
1576
1701
  }
1577
1702
 
1703
+ // Idle compaction. The settings live under one key so the dashboard and `switch compact`
1704
+ // write the same place, and the same defaults, instead of two views that can disagree.
1705
+ if (method === 'GET' && pathname === '/api/idle-compact') {
1706
+ return sendJson(res, 200, { idleCompact: idleCompactPolicy(loadConfig()) });
1707
+ }
1708
+ if (method === 'POST' && pathname === '/api/idle-compact') {
1709
+ let patch;
1710
+ try {
1711
+ patch = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8'));
1712
+ } catch (err) {
1713
+ return sendJson(res, err.status || 400, { error: err.message });
1714
+ }
1715
+ const invalid = validateIdleCompactPatch(patch);
1716
+ if (invalid) return sendJson(res, 400, { error: invalid });
1717
+ const cfg = loadConfig() || {};
1718
+ cfg[IDLE_COMPACT_KEY] = { ...(cfg[IDLE_COMPACT_KEY] || {}), ...patch };
1719
+ try {
1720
+ saveConfig(cfg);
1721
+ } catch (err) {
1722
+ return sendJson(res, 500, { error: err.message });
1723
+ }
1724
+ return sendJson(res, 200, { idleCompact: idleCompactPolicy(loadConfig()) });
1725
+ }
1726
+
1578
1727
  if (method !== 'POST') {
1579
1728
  req.resume();
1580
1729
  return sendJson(res, 404, { error: `Not found: ${method} ${pathname}` });
@@ -1716,9 +1865,12 @@ async function routeConfigApi(res, method, pathname, body) {
1716
1865
  return sendJson(res, 400, { error: 'The stored API key is sent only to the baseURL and endpoints it was saved with. Enter the key again to use a new URL.' });
1717
1866
  }
1718
1867
  merged.apiKey = keepsKey ? (existing.apiKey || '') : String(profile.apiKey || '');
1719
- for (const k of ['outFormat', 'optimizerURL', 'thinkingMode']) {
1868
+ for (const k of ['outFormat', 'optimizerURL', 'thinkingMode', 'thinkingEffort']) {
1720
1869
  if (Object.hasOwn(profile, k) && !profile[k]) delete merged[k];
1721
1870
  }
1871
+ if (Object.hasOwn(profile, 'modelEfforts')) {
1872
+ merged.modelEfforts = profile.modelEfforts || {};
1873
+ }
1722
1874
  // The merge above keeps a key the payload does not mention, so a retired one has to be dropped
1723
1875
  // here: the window comes from the model's own list now, and a flag left in config.json would
1724
1876
  // be read by nothing while still telling the next reader that the window is a choice.
@@ -2036,7 +2188,8 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2036
2188
 
2037
2189
  let answered = false; // set only after a complete answer; the half upload depends on it
2038
2190
  try {
2039
- const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req);
2191
+ const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
2192
+ const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
2040
2193
  if (traceId) headers['x-intact-trace'] = traceId;
2041
2194
  debugLog(`[${profileKey}:ws] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 300));
2042
2195
 
package/shim.mjs CHANGED
@@ -96,6 +96,24 @@ if [ -O "$SWITCHER_DIR" ] && [ -O "$SWITCHER_DIR/${envFile}" ] && [ -f "$SWITCHE
96
96
  else
97
97
  TOOL_ACTIVE=0
98
98
  fi
99
+ # Codex builds its TLS client from this at startup and cannot start at all without reading it, so a
100
+ # CA that is not on disk takes Codex down instead of routing it. The shim cannot build the file, but
101
+ # it can refuse to hand over a path that is already dead, which leaves Codex on the system roots.
102
+ if [ -n "\${CODEX_CA_CERTIFICATE:-}" ]; then
103
+ if [ ! -e "\${CODEX_CA_CERTIFICATE}" ]; then
104
+ printf '[llm-switcher] %s missing; Codex uses the system roots.\\n' "\${CODEX_CA_CERTIFICATE}" >&2
105
+ unset CODEX_CA_CERTIFICATE
106
+ elif [ -d "\${CODEX_CA_CERTIFICATE}" ] || [ ! -f "\${CODEX_CA_CERTIFICATE}" ]; then
107
+ printf '[llm-switcher] %s nonregular; Codex uses the system roots.\\n' "\${CODEX_CA_CERTIFICATE}" >&2
108
+ unset CODEX_CA_CERTIFICATE
109
+ elif [ ! -r "\${CODEX_CA_CERTIFICATE}" ]; then
110
+ printf '[llm-switcher] %s unreadable; Codex uses the system roots.\\n' "\${CODEX_CA_CERTIFICATE}" >&2
111
+ unset CODEX_CA_CERTIFICATE
112
+ elif [ ! -s "\${CODEX_CA_CERTIFICATE}" ]; then
113
+ printf '[llm-switcher] %s empty; Codex uses the system roots.\\n' "\${CODEX_CA_CERTIFICATE}" >&2
114
+ unset CODEX_CA_CERTIFICATE
115
+ fi
116
+ fi
99
117
  ${name !== 'agy' ? '' : `
100
118
  # The gateway proves itself before agy's token goes to it; any doubt keeps agy on its official endpoint.
101
119
  if [ "$TOOL_ACTIVE" = "1" ] && ! node "${GATEWAY_CHECK}" "\${CLOUD_CODE_URL:-}" >/dev/null 2>&1; then
@@ -243,6 +261,17 @@ if exist "%SWITCHER_DIR%\\active.flag" if exist "%SWITCHER_DIR%\\${envFile}" (
243
261
  for %%A in ("%SWITCHER_DIR%\\${envFile}") do if %%~zA GTR 0 set "TOOL_ACTIVE=1"
244
262
  )
245
263
  if "%TOOL_ACTIVE%"=="1" call "%SWITCHER_DIR%\\${envFile}"
264
+ if defined CODEX_CA_CERTIFICATE (
265
+ if not exist "%CODEX_CA_CERTIFICATE%" (
266
+ echo [llm-switcher] %CODEX_CA_CERTIFICATE% missing; Codex uses the system roots. 1>&2
267
+ set "CODEX_CA_CERTIFICATE="
268
+ ) else (
269
+ for %%A in ("%CODEX_CA_CERTIFICATE%") do if %%~zA EQU 0 (
270
+ echo [llm-switcher] %CODEX_CA_CERTIFICATE% empty; Codex uses the system roots. 1>&2
271
+ set "CODEX_CA_CERTIFICATE="
272
+ )
273
+ )
274
+ )
246
275
  ${name !== 'agy' ? '' : `
247
276
  REM The gateway proves itself before agy's token goes to it; any doubt keeps agy on its official endpoint.
248
277
  if "%TOOL_ACTIVE%"=="1" call :CHECK_GATEWAY
package/state.mjs CHANGED
@@ -1366,7 +1366,18 @@ export function computeLaunchState(cfg, port) {
1366
1366
  // NODE_EXTRA_CA_CERTS is deliberately absent from env-claude: the shim decides it at launch,
1367
1367
  // because only then does it know whether the user brought a CA of their own (R2).
1368
1368
  if (claude) state.envClaude = proxyPairs();
1369
- if (codex) state.envCodex = [...proxyPairs(), ['CODEX_CA_CERTIFICATE', paths.blindfoldCA]];
1369
+ // Codex builds its TLS client from CODEX_CA_CERTIFICATE the moment it starts, and a path it cannot
1370
+ // read is fatal: it dies before its first request, and its long-lived daemon then fails its own
1371
+ // update with "Failed to read CA certificate file". So the CA is only offered when it is really
1372
+ // usable. A missing or invalid one drops the switcher's proxy pairs as well, which leaves Codex
1373
+ // on the system roots talking directly to its official endpoint instead of breaking TLS.
1374
+ if (codex) {
1375
+ if (usableCACertificate(paths.blindfoldCA)) {
1376
+ state.envCodex = [...proxyPairs(), ['CODEX_CA_CERTIFICATE', paths.blindfoldCA]];
1377
+ } else {
1378
+ state.envCodex = [];
1379
+ }
1380
+ }
1370
1381
  // agy reaches the gateway directly over plain http: no proxy and no CA (Go on Windows reads only
1371
1382
  // the system store). Remote control keeps its own host, so it never crosses the switcher.
1372
1383
  if (agy) state.envAgy = [['CLOUD_CODE_URL', `http://127.0.0.1:${port}`]];
@@ -1488,6 +1499,29 @@ export function applyLaunchState(cfg, port, opts = {}) {
1488
1499
  return st;
1489
1500
  }
1490
1501
 
1502
+ export function usableCACertificate(file) {
1503
+ if (typeof file !== 'string' || !file.trim()) return false;
1504
+ try {
1505
+ const stat = fs.statSync(file);
1506
+ if (!stat.isFile() || stat.size === 0 || stat.size > 4 * 1024 * 1024) return false;
1507
+ fs.accessSync(file, fs.constants.R_OK);
1508
+ const text = fs.readFileSync(file, 'utf8');
1509
+ const opens = text.match(/-----BEGIN CERTIFICATE-----/g) || [];
1510
+ const blocks = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [];
1511
+ if (opens.length === 0 || opens.length !== blocks.length) return false;
1512
+ return blocks.every(pem => {
1513
+ try {
1514
+ new crypto.X509Certificate(pem);
1515
+ return true;
1516
+ } catch {
1517
+ return false;
1518
+ }
1519
+ });
1520
+ } catch {
1521
+ return false;
1522
+ }
1523
+ }
1524
+
1491
1525
  const LOOPBACK_URL = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?\/?$/i;
1492
1526
 
1493
1527
  /**
@@ -1516,8 +1550,20 @@ export function restartCodexDaemon(pairs, {
1516
1550
  const env = { ...baseEnv };
1517
1551
  for (const k of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) if (LOOPBACK_URL.test(env[k] || '')) delete env[k];
1518
1552
  for (const k of ['NO_PROXY', 'no_proxy']) if (env[k] === '127.0.0.1,localhost') delete env[k];
1519
- if (env.CODEX_CA_CERTIFICATE === ca) delete env.CODEX_CA_CERTIFICATE;
1553
+ // The daemon keeps the environment it started with, so a CA it cannot read outlives the switcher
1554
+ // writing it: the daemon comes back up unable to build a TLS client and fails its own update with
1555
+ // "Failed to read CA certificate file". The check has to come after the route is applied, because a
1556
+ // dead CA can arrive in the route as well as in the environment it was already carrying.
1520
1557
  for (const [k, v] of pairs) env[k] = v;
1558
+ if (!usableCACertificate(env.CODEX_CA_CERTIFICATE)) {
1559
+ delete env.CODEX_CA_CERTIFICATE;
1560
+ for (const k of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) {
1561
+ if (LOOPBACK_URL.test(env[k] || '')) delete env[k];
1562
+ }
1563
+ for (const k of ['NO_PROXY', 'no_proxy']) {
1564
+ if (env[k] === '127.0.0.1,localhost') delete env[k];
1565
+ }
1566
+ }
1521
1567
 
1522
1568
  try {
1523
1569
  const child = spawnFn(bin, ['app-server', 'daemon', 'restart'], { env, stdio: 'ignore', detached: true });
package/switch.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  getMigrationError, getMigrationCollision, getLastLoadError,
14
14
  casClearToolPointers, emptyToolEnvFiles, syncInterceptorTools
15
15
  } from './state.mjs';
16
+ import { idleCompactPolicy, validateIdleCompactPatch } from './idlecompact.mjs';
16
17
  import { runProbe, runCheck } from './contract.mjs';
17
18
  import { installPlugin, uninstallPlugin, pluginStatus, PLUGIN_NAME } from './plugin.mjs';
18
19
  import {
@@ -845,6 +846,64 @@ async function managePlugin(action = 'status') {
845
846
  }
846
847
  }
847
848
 
849
+ // Idle compaction: a conversation that comes back after a pause has lost the provider's prompt
850
+ // cache, so its history is paid for at full price. This turns that on and says what it will do.
851
+ async function manageCompact(action = 'status', arg = '') {
852
+ const cfg = loadConfig() || {};
853
+ const policy = idleCompactPolicy(cfg);
854
+ const key = 'idleCompact';
855
+ const set = (patch) => {
856
+ const invalid = validateIdleCompactPatch(patch);
857
+ if (invalid) { console.error(`[Error] ${invalid}`); process.exit(1); }
858
+ cfg[key] = { ...(cfg[key] || {}), ...patch };
859
+ saveConfig(cfg);
860
+ };
861
+ const act = (action || 'status').toLowerCase();
862
+ const show = () => {
863
+ const p = idleCompactPolicy(loadConfig() || {});
864
+ console.log('Idle compaction (gateway prefix cache)');
865
+ console.log(` Codex opt-in: ${p.codex ? 'yes' : 'no'}`);
866
+ console.log(` Enabled: ${p.enabled ? 'yes' : 'no'}`);
867
+ console.log(` Idle over: ${p.idleMinutes} minutes`);
868
+ console.log(` Context over: ${Math.round(p.minBytes / 1024)} KB`);
869
+ console.log(` Keep recent: ${p.keepRecent} messages`);
870
+ console.log(` Summary model: ${p.model || '(none set)'}`);
871
+ if (!p.enabled) console.log('\n Turn it on with: switch compact on');
872
+ else if (!p.model) console.log('\n No summary model: requests still go out shortened, and no model is called at all.');
873
+ };
874
+
875
+ if (act === 'on' || act === 'enable') {
876
+ set({ enabled: true });
877
+ console.log('[OK] Idle compaction is on.');
878
+ show();
879
+ } else if (act === 'off' || act === 'disable') {
880
+ set({ enabled: false });
881
+ console.log('[OK] Idle compaction is off.');
882
+ } else if (act === 'model') {
883
+ const m = String(arg || '').trim();
884
+ if (!m) { console.log(`Summary model is currently: ${policy.model || '(none set)'}`); return; }
885
+ if (m === 'none' || m === 'default') { set({ model: '' }); console.log('[OK] No summary model: no model is called.'); return; }
886
+ set({ model: m });
887
+ console.log(`[OK] Summaries will be written by: ${m}`);
888
+ } else if (act === 'codex') {
889
+ if (!['on', 'off'].includes(arg)) { console.log(`Codex prefix cache: ${policy.codex ? 'on' : 'off'} (switch compact codex on|off)`); return; }
890
+ set({ codex: arg === 'on' });
891
+ console.log(`[OK] Codex wire prefix cache is ${arg}; client history stays unchanged.`);
892
+ } else if (act === 'idle') {
893
+ const n = Number(arg);
894
+ if (!Number.isFinite(n) || n <= 0) { console.error('Usage: switch compact idle <minutes>'); process.exit(1); }
895
+ set({ idleMinutes: n });
896
+ console.log(`[OK] Compact a conversation idle over ${n} minutes.`);
897
+ } else if (act === 'min') {
898
+ const n = Number(arg);
899
+ if (!Number.isFinite(n) || n <= 0) { console.error('Usage: switch compact min <KB>'); process.exit(1); }
900
+ set({ minBytes: Math.floor(n) * 1024 });
901
+ console.log(`[OK] Compact a conversation over ${Math.floor(n)} KB.`);
902
+ } else {
903
+ show();
904
+ }
905
+ }
906
+
848
907
  async function manageShim(action = 'status') {
849
908
  const act = (action || 'status').toLowerCase();
850
909
 
@@ -1247,6 +1306,8 @@ if (cmd === 'off' || cmd === 'stop') {
1247
1306
  await runDoctor();
1248
1307
  } else if (cmd === 'service' || cmd === 'daemon') {
1249
1308
  await manageService(subArg.toLowerCase() || 'status');
1309
+ } else if (cmd === 'compact') {
1310
+ await manageCompact(subArg.toLowerCase() || 'status', positionalArgs()[2] || '');
1250
1311
  } else if (cmd === 'shim' || cmd === 'shims') {
1251
1312
  await manageShim(subArg.toLowerCase() || 'status');
1252
1313
  } else if (cmd === 'plugin' || cmd === 'plugins') {
@@ -60,7 +60,13 @@ test('openssl verifies the leaf, and the name constraints refuse a leaf for anot
60
60
  // A leaked ca.key signs for any name; a client that obeys the constraints must refuse it.
61
61
  const evil = path.join(dir, 'evil.pem');
62
62
  fs.writeFileSync(evil, signLeaf(dir, ['evil.example']).cert);
63
- assert.throws(() => execFileSync('openssl', ['verify', '-CAfile', path.join(dir, 'ca.pem'), evil], { stdio: 'pipe' }), /permitted subtree violation/);
63
+ assert.throws(() => {
64
+ try {
65
+ execFileSync('openssl', ['verify', '-CAfile', path.join(dir, 'ca.pem'), evil], { stdio: 'pipe' });
66
+ } catch (err) {
67
+ throw new Error([err.stdout?.toString() || '', err.stderr?.toString() || '', err.message].join(' '));
68
+ }
69
+ }, /permitted subtree violation/);
64
70
  });
65
71
 
66
72
  test('ensureBlindfoldCerts builds a missing set, rebuilds a missing leaf on the same CA, and leaves a good set alone', (t) => {
@@ -6,15 +6,21 @@ import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
8
 
9
+ import { buildCerts } from '../blindfold/certs.mjs';
10
+
9
11
  // Temporary HOME and state dir before the import, so nothing here can reach the real Codex daemon.
10
12
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-codex-daemon-'));
11
13
  process.env.HOME = path.join(root, 'home');
12
14
  process.env.LLM_SWITCHER_STATE_DIR = path.join(root, 'state');
13
15
  delete process.env.CODEX_HOME;
14
16
  fs.mkdirSync(process.env.LLM_SWITCHER_STATE_DIR, { recursive: true });
15
- const { restartCodexDaemon, applyLaunchState, clearLaunchState, emptyToolEnvFiles, STATE_DIR } = await import('../state.mjs');
17
+ const { restartCodexDaemon, applyLaunchState, clearLaunchState, emptyToolEnvFiles, computeLaunchState, STATE_DIR, INTERCEPT_HOSTS, usableCACertificate } = await import('../state.mjs');
16
18
  test.after(() => fs.rmSync(root, { recursive: true, force: true }));
17
19
 
20
+ const certsDir = path.join(root, 'certs');
21
+ buildCerts(certsDir, INTERCEPT_HOSTS);
22
+ const validCA = path.join(certsDir, 'ca.pem');
23
+
18
24
  // A machine where the codex shim routes through `stateDir` and a daemon is running.
19
25
  function machine(stateDir) {
20
26
  const home = fs.mkdtempSync(path.join(root, 'm-'));
@@ -32,7 +38,7 @@ function machine(stateDir) {
32
38
  }
33
39
 
34
40
  const ROUTE = [['HTTPS_PROXY', 'http://127.0.0.1:3457'], ['https_proxy', 'http://127.0.0.1:3457'],
35
- ['NO_PROXY', '127.0.0.1,localhost'], ['no_proxy', '127.0.0.1,localhost'], ['CODEX_CA_CERTIFICATE', '/ca.pem']];
41
+ ['NO_PROXY', '127.0.0.1,localhost'], ['no_proxy', '127.0.0.1,localhost'], ['CODEX_CA_CERTIFICATE', validCA]];
36
42
 
37
43
  test('a route change restarts the running daemon with the new Codex variables', () => {
38
44
  const m = machine('/switcher/state');
@@ -42,10 +48,58 @@ test('a route change restarts the running daemon with the new Codex variables',
42
48
  assert.equal(m.calls[0].cmd, m.bin);
43
49
  assert.deepEqual(m.calls[0].args, ['app-server', 'daemon', 'restart']);
44
50
  assert.equal(m.calls[0].env.HTTPS_PROXY, 'http://127.0.0.1:3457');
45
- assert.equal(m.calls[0].env.CODEX_CA_CERTIFICATE, '/ca.pem');
46
51
  assert.equal(m.calls[0].env.PATH, '/bin');
47
52
  });
48
53
 
54
+ // Codex reads this path when it builds its TLS client and cannot start without it, and its daemon
55
+ // keeps the environment it started with. A path that is not on disk therefore does not route Codex,
56
+ // it takes Codex down, and the daemon then fails its own update with "Failed to read CA certificate
57
+ // file" -- which is what a caller sees as a daemon error while using Codex.
58
+ test('a CA that is not on disk is never handed to Codex', () => {
59
+ const m = machine('/switcher/state');
60
+ const deadRoute = [...ROUTE.filter(([k]) => k !== 'CODEX_CA_CERTIFICATE'), ['CODEX_CA_CERTIFICATE', '/nonexistent/ca.pem']];
61
+ restartCodexDaemon(deadRoute, { home: m.home, codexHome: m.codexHome, stateDir: '/switcher/state', spawnFn: m.spawnFn, baseEnv: { PATH: '/bin' } });
62
+ assert.equal(m.calls[0].env.CODEX_CA_CERTIFICATE, undefined,
63
+ 'a CA the switcher never built must not reach the daemon');
64
+ });
65
+
66
+ test('a CA that is on disk is passed through, so the interceptor still works', () => {
67
+ const m = machine('/switcher/state');
68
+ const certsDir = path.join(m.home, 'certs');
69
+ buildCerts(certsDir, INTERCEPT_HOSTS);
70
+ const ca = path.join(certsDir, 'ca.pem');
71
+ restartCodexDaemon([...ROUTE.filter(([k]) => k !== 'CODEX_CA_CERTIFICATE'), ['CODEX_CA_CERTIFICATE', ca]],
72
+ { home: m.home, codexHome: m.codexHome, stateDir: '/switcher/state', spawnFn: m.spawnFn, baseEnv: { PATH: '/bin' }, ca });
73
+ assert.equal(m.calls[0].env.CODEX_CA_CERTIFICATE, ca, 'a CA that exists is the whole point of the route');
74
+ });
75
+
76
+ test('directories, empty, garbage, and unreadable files are dropped with proxy pairs', () => {
77
+ const m = machine('/switcher/state');
78
+ const dir = path.join(m.home, 'certs-dir');
79
+ fs.mkdirSync(dir, { recursive: true });
80
+ const empty = path.join(m.home, 'empty.pem');
81
+ fs.writeFileSync(empty, '');
82
+ const garbage = path.join(m.home, 'garbage.pem');
83
+ fs.writeFileSync(garbage, '-----BEGIN CERTIFICATE-----\nnot a valid cert\n-----END CERTIFICATE-----\n');
84
+
85
+ for (const bad of [dir, empty, garbage]) {
86
+ restartCodexDaemon([...ROUTE.filter(([k]) => k !== 'CODEX_CA_CERTIFICATE'), ['CODEX_CA_CERTIFICATE', bad]],
87
+ { home: m.home, codexHome: m.codexHome, stateDir: '/switcher/state', spawnFn: m.spawnFn, baseEnv: { PATH: '/bin' } });
88
+ const env = m.calls[m.calls.length - 1].env;
89
+ assert.equal(env.CODEX_CA_CERTIFICATE, undefined);
90
+ assert.equal(env.HTTPS_PROXY, undefined);
91
+ }
92
+ });
93
+
94
+ // The route a restart hands over is the last word: the pairs win, so a CA in them reaches the daemon
95
+ // whatever the environment already held. This is the path that ends in a dead daemon.
96
+ test('the route written into env-codex carries the CA only when it exists', () => {
97
+ const st = computeLaunchState({ activeProfiles: { codex: 'p' }, profiles: { p: { tool: 'codex', baseURL: 'http://x/v1', apiKey: 'k' } } }, 3456);
98
+ const withCa = st.envCodex.find(([k]) => k === 'CODEX_CA_CERTIFICATE');
99
+ assert.ok(!withCa || fs.existsSync(withCa[1]),
100
+ `env-codex points Codex at ${withCa ? withCa[1] : 'nothing'}; a missing CA stops Codex entirely`);
101
+ });
102
+
49
103
  test('switching Codex off restarts the daemon without the switcher variables, and keeps the user\'s own proxy', () => {
50
104
  const m = machine('/switcher/state');
51
105
  const baseEnv = { HTTPS_PROXY: 'http://127.0.0.1:3457', https_proxy: 'http://proxy.corp:8080', CODEX_CA_CERTIFICATE: '/ca.pem', NO_PROXY: '127.0.0.1,localhost' };
@@ -0,0 +1,34 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ test('compact CLI disables summary without fallback, exposes Codex opt-in, and rejects invalid bounds', t => {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-compact-cli-'));
11
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
12
+ const config = path.join(dir, 'config.json');
13
+ fs.writeFileSync(config, JSON.stringify({ activeProfiles: {}, profiles: {},
14
+ idleCompact: { enabled: true, model: 'expensive-official' } }));
15
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
16
+ const run = (...args) => spawnSync(process.execPath, [path.join(root, 'switch.mjs'), 'compact', ...args], {
17
+ env: { ...process.env, LLM_SWITCHER_CONFIG: config, LLM_SWITCHER_STATE_DIR: dir,
18
+ LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' }, encoding: 'utf8', timeout: 5000,
19
+ });
20
+ const disabled = run('model', 'default'); assert.equal(disabled.status, 0, disabled.stderr);
21
+ assert.match(disabled.stdout, /no model is called/);
22
+ assert.doesNotMatch(disabled.stdout, /own model/);
23
+ assert.equal(JSON.parse(fs.readFileSync(config)).idleCompact.model, '');
24
+ const codex = run('codex', 'on'); assert.equal(codex.status, 0, codex.stderr);
25
+ assert.match(codex.stdout, /wire prefix cache is on/);
26
+ assert.equal(JSON.parse(fs.readFileSync(config)).idleCompact.codex, true);
27
+ const before = fs.readFileSync(config);
28
+ for (const args of [['idle', '1441'], ['idle', '0'], ['min', '7']]) {
29
+ const invalid = run(...args); assert.equal(invalid.status, 1);
30
+ assert.deepEqual(fs.readFileSync(config), before);
31
+ }
32
+ const status = run('status'); assert.equal(status.status, 0);
33
+ assert.match(status.stdout, /gateway prefix cache/); assert.match(status.stdout, /Codex opt-in:\s+yes/);
34
+ });
@@ -594,7 +594,13 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
594
594
  const view = () => page.evaluate(`[...document.querySelectorAll('.view-panel')].filter(p => p.style.display !== 'none').map(p => p.id)`);
595
595
 
596
596
  it('opens each page from the sidebar and follows the back button', async () => {
597
- for (const [nav, id, title] of [['models', 'view-models', 'Dynamic Model Catalog'], ['logs', 'view-logs', 'Request Inspector'], ['doctor', 'view-doctor', 'System Health & Doctor'], ['routes', 'view-routes', 'Routes & Profiles']]) {
597
+ for (const [nav, id, title] of [
598
+ ['compact', 'view-compact', 'Idle Compaction'],
599
+ ['models', 'view-models', 'Dynamic Model Catalog'],
600
+ ['logs', 'view-logs', 'Request Inspector'],
601
+ ['doctor', 'view-doctor', 'System Health & Doctor'],
602
+ ['routes', 'view-routes', 'Routes & Profiles']
603
+ ]) {
598
604
  await act(() => page.click(`#sideNav a[data-nav="${nav}"]`));
599
605
  assert.deepEqual(await view(), [id], `${nav}: wrong page`);
600
606
  assert.equal(await page.text('#page-title'), title);
@@ -12,7 +12,7 @@ import {
12
12
  createChatStream, buildChatMessage, chatFinish, anthropicStopReason,
13
13
  smartText, smartReasoning, sanitizeJsonSchema, normalizeUpstream, buildVertexMessage,
14
14
  createCodeAssistStream,
15
- budgetToEffort, effortToBudget, normalizeEffort, capEffort, EFFORT_LEVELS
15
+ budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS
16
16
  } from '../formats.mjs';
17
17
  import { assertValidAnthropicEvents } from './helpers.mjs';
18
18
 
@@ -1010,3 +1010,46 @@ test('a level becomes the budget each API speaks, and Gemini gets one it accepts
1010
1010
  // Anthropic keeps the budget it was given, only under max_tokens as its own rule requires.
1011
1011
  assert.equal(irToAnthropicBody(anthropicToIR({ model: 'm', max_tokens: 200000, messages: [{ role: 'user', content: 'hi' }], thinking: { type: 'enabled', budget_tokens: 100000 } }), 'm').thinking.budget_tokens, 100000);
1012
1012
  });
1013
+
1014
+ test('mapEquivalentEffort maps to nearest supported level when target lacks exact match', () => {
1015
+ const strict = ['low', 'medium', 'high'];
1016
+ assert.equal(mapEquivalentEffort('ultra', strict), 'high');
1017
+ assert.equal(mapEquivalentEffort('max', strict), 'high');
1018
+ assert.equal(mapEquivalentEffort('xhigh', strict), 'high');
1019
+ assert.equal(mapEquivalentEffort('high', strict), 'high');
1020
+ assert.equal(mapEquivalentEffort('medium', strict), 'medium');
1021
+ assert.equal(mapEquivalentEffort('low', strict), 'low');
1022
+ assert.equal(mapEquivalentEffort('minimal', strict), 'low');
1023
+
1024
+ // Gap in target levels: ['low', 'max']
1025
+ assert.equal(mapEquivalentEffort('xhigh', ['low', 'max']), 'max');
1026
+ assert.equal(mapEquivalentEffort('medium', ['low', 'max']), 'low');
1027
+
1028
+ // Intact synonyms: extra-low -> minimal
1029
+ assert.equal(mapEquivalentEffort('extra-low', ['minimal', 'medium', 'high']), 'minimal');
1030
+ });
1031
+
1032
+ test('thinkingEffort profile option overrides client effort across all upstreams', () => {
1033
+ const clientIR = responsesToIR({ model: 'gpt-5', max_output_tokens: 16000, reasoning: { effort: 'low' }, input: [{ role: 'user', content: 'solve' }] });
1034
+
1035
+ // 1. Force 'high' on OpenAI Chat
1036
+ const chatBody = emitUpstreamBody('openai-chat', clientIR, 'gpt-5', { thinkingEffort: 'high' });
1037
+ assert.equal(chatBody.reasoning_effort, 'high');
1038
+
1039
+ // 2. Force 'high' on Anthropic
1040
+ const anthropicBody = emitUpstreamBody('anthropic', clientIR, 'claude-3-7-sonnet', { thinkingEffort: 'high' });
1041
+ assert.equal(anthropicBody.output_config?.effort, 'high');
1042
+ assert.equal(anthropicBody.thinking?.budget_tokens, 8000);
1043
+
1044
+ // 3. Force 'high' on Vertex (Gemini)
1045
+ const vertexBody = emitUpstreamBody('vertex', clientIR, 'gemini-2.5-pro', { thinkingEffort: 'high' });
1046
+ assert.equal(vertexBody.generationConfig?.thinkingConfig?.thinkingBudget, 8000);
1047
+
1048
+ // 4. Force 'xhigh' on strict native OpenAI caps to 'high'
1049
+ const nativeChat = emitUpstreamBody('openai-chat', clientIR, 'gpt-5', { thinkingMode: 'native', thinkingEffort: 'xhigh' });
1050
+ assert.equal(nativeChat.reasoning_effort, 'high');
1051
+
1052
+ // 5. 'auto' inherits client's original effort ('low')
1053
+ const autoChat = emitUpstreamBody('openai-chat', clientIR, 'gpt-5', { thinkingEffort: 'auto' });
1054
+ assert.equal(autoChat.reasoning_effort, 'low');
1055
+ });