llm-switcher 1.6.0 → 1.6.3

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
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
10
10
  import { createFrameReader } from './blindfold/wsframe.mjs';
11
11
  import {
12
12
  OUT_FORMATS, parseToIR, emitUpstreamBody, irToAnthropicBody, createUpstreamNormalizer, createCollector,
13
- createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES,
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,
@@ -33,11 +33,10 @@ 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
35
  import {
36
- idleCompactPolicy, conversationKey, idleFor, noteConversation, compactIR, compactResponsesInput, IDLE_COMPACT_KEY,
36
+ idleCompactPolicy, validateIdleCompactPatch, compactRawHistory, summaryReplacement, IDLE_COMPACT_KEY,
37
37
  } from './idlecompact.mjs';
38
38
  import { askSummary } from './idlecall.mjs';
39
- import { claudeSessionId, findSessionFile, writeCompaction } from './claudesession.mjs';
40
- import { writeCodexCompaction } from './codexsession.mjs';
39
+ import { IdlePrefixCache, compactCacheKey, policyFingerprint } from './idlecache.mjs';
41
40
 
42
41
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
43
42
  const uiHtmlPath = path.join(__dirname, 'ui.html');
@@ -751,124 +750,100 @@ function cleanSchemaDeep(obj) {
751
750
  // 3. For ag/* targets (Gemini behind 9Router): rewrite to the strict Schema subset
752
751
  // ---- idle compaction ----
753
752
 
754
- // Claude Code only. Codex is left alone on purpose: its compaction is a protocol item, which the
755
- // provider gateway answers, and the answer is stored by Codex in its own history. Shortening a
756
- // Codex request here would replace that with a summary no client ever records, which costs the
757
- // tokens of the summary and saves only one turn. The Antigravity CLI has no session file of its
758
- // own to write, so there would be nothing to make the shortening last.
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
+
759
758
  async function maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer) {
760
759
  const policy = idleCompactPolicy(loadConfig());
761
- const key = conversationKey(clientFormat, req, ir);
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.
762
768
  if (!key) return null;
763
- const wants = clientFormat === 'anthropic'
764
- ? policy.enabled
765
- : clientFormat === 'responses' && policy.enabled && policy.codex;
766
- if (wants && idleFor(key) > policy.idleMs && payloadBytes(payload) >= policy.minBytes) {
767
- const r = compactNow({ req, payload, ir, profile, profileKey, policy, key, bodyBuffer, clientFormat });
768
- // Recorded on the way through rather than on the way out, so a request the provider then
769
- // fails still counts as the conversation having been here. What is being measured is the
770
- // client's pause, not the provider's answer.
771
- noteConversation(key);
772
- return r;
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`));
773
809
  }
774
- noteConversation(key);
775
- return null;
810
+ return rewrite(compacted.history);
776
811
  }
777
812
 
778
- // Measured from the parsed body rather than from Content-Length, which is absent on a chunked
779
- // request and smaller than the body once it has been decoded.
780
- function payloadBytes(payload) {
781
- try { return JSON.stringify(payload).length; } catch { return 0; }
782
- }
783
-
784
- function compactNow({ req, payload, ir, profile, profileKey, policy, key, bodyBuffer, clientFormat }) {
785
- const compacted = compactIR(ir, policy);
786
- if (!compacted) return null;
787
-
788
- const before = JSON.stringify(ir.messages || []).length;
789
- const mapped = mapModel(ir.model || payload.model || '', profile, 'anthropic');
790
- const outFormat = resolveOutFormat(profile, mapped);
791
- const build = (prof, model, ir2) => buildUpstreamRequest(prof, outFormat, ir2, model, req);
792
-
793
- // A summary is only made when the reader named a model for it, and never with the conversation's
794
- // own: that would spend the expensive model of an official account to save the tokens of the
795
- // turn, which is a bad trade and, on a plan with limits, worse. With no model named there is
796
- // still a saving, because the request leaves shortened, and without a summary there is nothing to
797
- // write to the session file -- so a model call here would cost tokens and buy nothing.
798
- const summary = policy.model
799
- ? askSummary({ ir, policy, profile, model: policy.model, build })
800
- : Promise.resolve('');
801
-
802
- // The request goes out shortened now, before the summary exists. The summary belongs in the
803
- // next resume, not in this answer, so this turn carries the text that stands in for the middle
804
- // and nothing else; a turn that also carried the summary would pay for it twice.
805
- // Written back into the client's own body. Claude Code goes through the renderer this gateway
806
- // already uses, because it is translated on the way out. Codex is not: a Responses client
807
- // reaches a Responses provider as the bytes it sent, so its history is shortened where it
808
- // lives, in the request itself.
809
- const field = clientFormat === 'responses' ? 'input' : 'messages';
810
- const rendered = clientFormat === 'responses'
811
- ? compactResponsesInput(payload.input, policy)
812
- : irToAnthropicBody({ ...ir, messages: compacted.messages }, ir.model || payload.model).messages;
813
- const next = { ...payload, [field]: rendered };
814
- const nextBuf = Buffer.from(JSON.stringify(next), 'utf8');
815
-
816
- console.log(
817
- `[llm-switcher] idle compact ${key.slice(0, 12)} [${profileKey}]: ${before} -> ${JSON.stringify(rendered).length} bytes, ` +
818
- `${compacted.middle} messages kept as text, ${compacted.dropped} dropped; summary call in flight`);
819
-
820
- // The summary is not waited for. It serves the next resume, not this answer, and holding the
821
- // client's turn open for a call nobody reads would make the saving feel like a cost. The file is
822
- // written when it lands, which is also later than the response and therefore never races the
823
- // entries Claude Code is appending right now.
824
- Promise.resolve(summary).then(async (s) => {
825
- if (!s) {
826
- console.log(`[llm-switcher] idle compact ${key.slice(0, 12)}: the summary call produced nothing; the request went out shortened anyway`);
827
- return;
828
- }
829
- // Found by session id rather than by working directory: nothing in the request says what the
830
- // client's directory is, and a compaction written to the wrong project is one nothing reads.
831
- // Codex and Claude Code keep their history in different places, so each is written its own
832
- // way: Codex in its rollout file and its store, Claude Code in its session file.
833
- let written;
834
- if (clientFormat === 'responses') {
835
- written = await writeCodexCompaction({ summary: s });
836
- } else {
837
- const sessionId = claudeSessionId(req, payload);
838
- const file = sessionId ? findSessionFile(sessionId) : '';
839
- written = file
840
- ? writeCompaction({
841
- file, summary: s, sessionId,
842
- preTokens: Math.round(before / 4), postTokens: Math.round(JSON.stringify(rendered).length / 4),
843
- })
844
- : null;
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;
845
828
  }
846
- console.log(`[llm-switcher] idle compact ${key.slice(0, 12)}: summary ${written ? 'stored' : 'made, not stored'}`);
847
- }).catch(() => {});
848
-
849
- return { payload: next, bodyBuffer: nextBuf };
850
- }
851
-
852
- // The summary goes in as a user message right after the opening request, which is where Claude
853
- // Code puts its own. A conversation handed a hole in it reads the hole as something it forgot
854
- // unless something says otherwise.
855
- function withSummary(msgs, insertAt, summary) {
856
- const at = Math.min(Math.max(1, insertAt), msgs.length);
857
- return [
858
- ...msgs.slice(0, at),
859
- { role: 'user', content: [{ type: 'text', text: summaryNotice(summary) }] },
860
- ...msgs.slice(at),
861
- ];
829
+ }
830
+ return 'default';
862
831
  }
863
832
 
864
- function summaryNotice(summary) {
865
- return 'Earlier in this conversation the history was shortened because it had been idle long ' +
866
- 'enough that the provider no longer held its prompt cache. What follows is a summary of the ' +
867
- 'part that was replaced, then the more recent messages unchanged.\n\nSummary:\n' + summary;
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';
868
839
  }
869
840
 
870
- function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req) {
871
- const upBody = emitUpstreamBody(outFormat, ir, mappedModel, { thinkingMode: profile.thinkingMode });
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
+ });
872
847
  if (upBody?.tools && Array.isArray(upBody.tools)) {
873
848
  upBody.tools = cleanSchemaDeep(upBody.tools);
874
849
  if (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)) {
@@ -968,15 +943,14 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
968
943
  // made only to the parsed form would shorten nothing that leaves the building.
969
944
  const shrunk = await maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer);
970
945
  if (shrunk) {
971
- payload = shrunk.payload;
972
- bodyBuffer = shrunk.bodyBuffer;
973
946
  try {
974
- ir = parseToIR(clientFormat, payload);
947
+ const compactedIR = parseToIR(clientFormat, shrunk.payload);
948
+ payload = shrunk.payload;
949
+ bodyBuffer = shrunk.bodyBuffer;
950
+ ir = compactedIR;
975
951
  } catch {
976
- // The compacted body was built from this one, so it parses. If it ever did not, the
977
- // original is still what the caller meant to send.
978
- payload = JSON.parse(bodyBuffer.toString('utf8'));
979
- return sendClientError(res, clientFormat, 400, 'Cannot reparse the compacted request');
952
+ idlePrefixCache.remove(shrunk.cacheKey);
953
+ console.warn('[llm-switcher] idle compact: cache-reparse-failed; forwarding original request');
980
954
  }
981
955
  }
982
956
 
@@ -1053,7 +1027,8 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1053
1027
  return;
1054
1028
  }
1055
1029
 
1056
- 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);
1057
1032
  if (traceId) headers['x-intact-trace'] = traceId;
1058
1033
  debugLog(`[${profileKey}] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 500));
1059
1034
 
@@ -1281,6 +1256,15 @@ function validateProfileInput(p) {
1281
1256
  if (p.outFormat && !OUT_FORMATS.includes(p.outFormat)) return `Invalid outFormat "${p.outFormat}"`;
1282
1257
  if (p.mode && !VALID_MODES.includes(p.mode)) return `Invalid mode "${p.mode}"`;
1283
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
+ }
1284
1268
  if (p.baseURL !== undefined) {
1285
1269
  try {
1286
1270
  const u = new URL(p.baseURL);
@@ -1728,20 +1712,8 @@ async function routeApi(req, res, method, pathname) {
1728
1712
  } catch (err) {
1729
1713
  return sendJson(res, err.status || 400, { error: err.message });
1730
1714
  }
1731
- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
1732
- return sendJson(res, 400, { error: 'body must be an object' });
1733
- }
1734
- // Per key, not per type-group. A loose check lets "yes" through for a boolean, and the
1735
- // policy then reads it as false: a typo would switch the feature off without saying so.
1736
- const kinds = { enabled: 'boolean', model: 'string', idleMinutes: 'number', minBytes: 'number', keepRecent: 'number' };
1737
- for (const [k, want] of Object.entries(kinds)) {
1738
- if (!(k in patch)) continue;
1739
- const v = patch[k];
1740
- const ok = want === 'number'
1741
- ? (typeof v === 'number' && Number.isFinite(v))
1742
- : typeof v === want;
1743
- if (!ok) return sendJson(res, 400, { error: `${k} must be ${want === 'number' ? 'a number' : `a ${want}`}` });
1744
- }
1715
+ const invalid = validateIdleCompactPatch(patch);
1716
+ if (invalid) return sendJson(res, 400, { error: invalid });
1745
1717
  const cfg = loadConfig() || {};
1746
1718
  cfg[IDLE_COMPACT_KEY] = { ...(cfg[IDLE_COMPACT_KEY] || {}), ...patch };
1747
1719
  try {
@@ -1893,9 +1865,12 @@ async function routeConfigApi(res, method, pathname, body) {
1893
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.' });
1894
1866
  }
1895
1867
  merged.apiKey = keepsKey ? (existing.apiKey || '') : String(profile.apiKey || '');
1896
- for (const k of ['outFormat', 'optimizerURL', 'thinkingMode']) {
1868
+ for (const k of ['outFormat', 'optimizerURL', 'thinkingMode', 'thinkingEffort']) {
1897
1869
  if (Object.hasOwn(profile, k) && !profile[k]) delete merged[k];
1898
1870
  }
1871
+ if (Object.hasOwn(profile, 'modelEfforts')) {
1872
+ merged.modelEfforts = profile.modelEfforts || {};
1873
+ }
1899
1874
  // The merge above keeps a key the payload does not mention, so a retired one has to be dropped
1900
1875
  // here: the window comes from the model's own list now, and a flag left in config.json would
1901
1876
  // be read by nothing while still telling the next reader that the window is a choice.
@@ -2213,7 +2188,8 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2213
2188
 
2214
2189
  let answered = false; // set only after a complete answer; the half upload depends on it
2215
2190
  try {
2216
- 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);
2217
2193
  if (traceId) headers['x-intact-trace'] = traceId;
2218
2194
  debugLog(`[${profileKey}:ws] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 300));
2219
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,7 +13,7 @@ import {
13
13
  getMigrationError, getMigrationCollision, getLastLoadError,
14
14
  casClearToolPointers, emptyToolEnvFiles, syncInterceptorTools
15
15
  } from './state.mjs';
16
- import { idleCompactPolicy } from './idlecompact.mjs';
16
+ import { idleCompactPolicy, validateIdleCompactPatch } from './idlecompact.mjs';
17
17
  import { runProbe, runCheck } from './contract.mjs';
18
18
  import { installPlugin, uninstallPlugin, pluginStatus, PLUGIN_NAME } from './plugin.mjs';
19
19
  import {
@@ -853,13 +853,16 @@ async function manageCompact(action = 'status', arg = '') {
853
853
  const policy = idleCompactPolicy(cfg);
854
854
  const key = 'idleCompact';
855
855
  const set = (patch) => {
856
+ const invalid = validateIdleCompactPatch(patch);
857
+ if (invalid) { console.error(`[Error] ${invalid}`); process.exit(1); }
856
858
  cfg[key] = { ...(cfg[key] || {}), ...patch };
857
859
  saveConfig(cfg);
858
860
  };
859
861
  const act = (action || 'status').toLowerCase();
860
862
  const show = () => {
861
863
  const p = idleCompactPolicy(loadConfig() || {});
862
- console.log('Idle compaction (Claude Code)');
864
+ console.log('Idle compaction (gateway prefix cache)');
865
+ console.log(` Codex opt-in: ${p.codex ? 'yes' : 'no'}`);
863
866
  console.log(` Enabled: ${p.enabled ? 'yes' : 'no'}`);
864
867
  console.log(` Idle over: ${p.idleMinutes} minutes`);
865
868
  console.log(` Context over: ${Math.round(p.minBytes / 1024)} KB`);
@@ -879,14 +882,18 @@ async function manageCompact(action = 'status', arg = '') {
879
882
  } else if (act === 'model') {
880
883
  const m = String(arg || '').trim();
881
884
  if (!m) { console.log(`Summary model is currently: ${policy.model || '(none set)'}`); return; }
882
- if (m === 'none' || m === 'default') { set({ model: '' }); console.log('[OK] The conversation\'s own model will write the summary.'); return; }
885
+ if (m === 'none' || m === 'default') { set({ model: '' }); console.log('[OK] No summary model: no model is called.'); return; }
883
886
  set({ model: m });
884
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.`);
885
892
  } else if (act === 'idle') {
886
893
  const n = Number(arg);
887
894
  if (!Number.isFinite(n) || n <= 0) { console.error('Usage: switch compact idle <minutes>'); process.exit(1); }
888
- set({ idleMinutes: Math.floor(n) });
889
- console.log(`[OK] Compact a conversation idle over ${Math.floor(n)} minutes.`);
895
+ set({ idleMinutes: n });
896
+ console.log(`[OK] Compact a conversation idle over ${n} minutes.`);
890
897
  } else if (act === 'min') {
891
898
  const n = Number(arg);
892
899
  if (!Number.isFinite(n) || n <= 0) { console.error('Usage: switch compact min <KB>'); process.exit(1); }
@@ -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
+ });