llm-switcher 1.6.12 → 1.6.14

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.6.14 — 2026-10-12
4
+
5
+ - **System turn with an odd content shape.** On the translated route, a `system` turn whose `content` is not text and not a list is dropped. Before, the request failed with a `TypeError`.
6
+
7
+ ## Release 1.6.13 — 2026-10-12
8
+
9
+ - **`switch on` with no profile.** `switch on` gives each tool the profile that it had before `switch off`. A tool that was off stays off. A tool that is on keeps its profile.
10
+ - **System turns on the translated route.** A `system` turn inside the `messages` of an Anthropic request now goes into the system prompt when the proxy translates the request.
11
+ - **System messages on the direct Anthropic route.** A folded `[system] ` text keeps its place between the turns around it. It never moves past an assistant turn. A system message with no text is dropped, and the healer note now says so. A system message after a final assistant prefill becomes the last user turn, so the request no longer ends on the prefill.
12
+ - **Gemini `_placeholder` in `anyOf`.** If one branch of an `anyOf` declares a real `_placeholder` property, the proxy keeps the key in the arguments.
13
+ - **Gemini `_placeholder` and tool names.** If two padded tools map back to one client tool name, the proxy removes nothing for that name.
14
+ - **Retry to another upstream format.** A request that the proxy builds again for another upstream format gets a new tool vocabulary and a new placeholder rule.
15
+ - **Reasoning before a compaction item.** In a Codex request, reasoning that waits for an assistant turn is dropped at a `compaction` item.
16
+ - **Model list cache time.** A cache entry with a time in the future is stale. It no longer stays fresh forever.
17
+ - **More token count forms.** A context overflow message with `128k`, `1m`, `131 072` (no-break space), `131'072` or `1,31,072` gives the full number to Claude Code. A decimal number after `limit` is not read as a token count. A plain `limit N` counts as the maximum only when `tokens` follows the number. A count of zero, or a count too large to be exact, is not read as a token count.
18
+ - **Output limit for every profile.** If a converted request finds no fresh model list for its profile, the proxy reads the list in the background. The request does not wait. The next request gets the limit. Each profile keeps its own limit, also on a shared base URL. The proxy reads `<baseURL>/models` with the API key of the profile, except for a profile with an `endpoints` override. That profile gets no output limit. The `/v1/models` list also does not send the key of that profile to `<baseURL>/models`.
19
+ - **Goal check when the evaluator call fails.** If the native evaluator call fails, the goal check does not let the goal through. A test now holds this behavior.
20
+ - **Test cleanup.** Every test file that makes a temp directory deletes it, also when a test fails. `npm test` deletes `node-compile-cache` only when npm made it for this run. A failed delete no longer changes the exit status of the tests.
21
+
3
22
  ## Release 1.6.12 — 2026-10-11
4
23
 
5
24
  - **Reasoning from an aborted turn.** In a Codex request, reasoning that waits for an assistant turn is dropped at the next user message. Separate reasoning items are joined with a newline.
package/README.md CHANGED
@@ -519,7 +519,8 @@ switch status # Display status for all active CLI targets
519
519
  switch version # Show the version and tell you when npm has a newer one
520
520
  switch doctor # Audit environment, settings & routing
521
521
  switch update # Install the newest release and restart the gateway on it
522
- switch on [profile] # Start the gateway and activate a profile
522
+ switch on # Start the gateway; each tool gets back the profile it had before `switch off`
523
+ switch on <profile> # Start the gateway and activate a profile
523
524
  switch <profile> # Activate a profile for both tools
524
525
  switch claude <profile> # Set the active profile for Claude Code only
525
526
  switch codex <profile> # Set the active profile for Codex only
package/README.vi.md CHANGED
@@ -516,7 +516,8 @@ switch status # Xem trạng thái kích hoạt của tất cả
516
516
  switch version # Xem version đang chạy và báo khi npm có bản mới
517
517
  switch doctor # Quét & thanh tra toàn bộ môi trường, settings và định tuyến
518
518
  switch update # Cài bản mới nhất và khởi động lại gateway trên bản đó
519
- switch on [profile] # Khởi động gateway và kích hoạt một profile
519
+ switch on # Khởi động gateway; mỗi công cụ lấy lại profile nó có trước `switch off`
520
+ switch on <profile> # Khởi động gateway và kích hoạt một profile
520
521
  switch <profile> # Kích hoạt một profile cho cả hai công cụ
521
522
  switch claude <profile> # Đặt profile kích hoạt riêng cho Claude Code
522
523
  switch codex <profile> # Đặt profile kích hoạt riêng cho Codex
package/formats.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
  // }
25
25
  // ============================================================
26
26
 
27
- import { sanitizeToolName, toolNameMapForIR, rewriteToolNameMentions, rewriteToolSearchArgs } from './toolvocab.mjs';
27
+ import { sanitizeToolName, toolNameMapForIR, rewriteToolNameMentions, rewriteToolSearchArgs, createToolVocab, NULL_TOOL_VOCAB } from './toolvocab.mjs';
28
28
  import { decodeCompaction, COMPACTION_INSTRUCTION } from './compaction.mjs';
29
29
 
30
30
  export const OUT_FORMATS = ['openai-chat', 'anthropic', 'vertex'];
@@ -455,9 +455,22 @@ function anthropicToIR(payload) {
455
455
  }
456
456
  }
457
457
 
458
+ // Messages has no system role. Left in place, the emitters send such a turn as user text, so fold
459
+ // it into the system prompt, as responsesToIR and the Chat reader already do.
460
+ const foldSystemTurn = (msg) => {
461
+ if (msg.role !== 'system' && msg.role !== 'developer') return false;
462
+ const text =
463
+ typeof msg.content === 'string'
464
+ ? msg.content
465
+ : Array.isArray(msg.content) ? msg.content.filter(p => p && p.type === 'text').map(p => p.text).join('\n') : '';
466
+ if (text.trim()) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
467
+ return true;
468
+ };
469
+
458
470
  if (Array.isArray(payload.messages)) {
459
471
  for (const msg of payload.messages) {
460
472
  if (!msg) continue;
473
+ if (foldSystemTurn(msg)) continue;
461
474
  if (typeof msg.content === 'string') {
462
475
  ir.messages.push({ role: msg.role, content: msg.content });
463
476
  continue;
@@ -693,8 +706,8 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
693
706
  if (text) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
694
707
  };
695
708
 
696
- // A reasoning item precedes the assistant output it belongs to. A user message ends that turn,
697
- // so reasoning still pending there came from an aborted turn and is dropped.
709
+ // A reasoning item precedes the assistant output it belongs to. A user message or a compaction item
710
+ // ends that turn, so reasoning still pending there came from an aborted turn and is dropped.
698
711
  let reasoning = '';
699
712
  const joinLines = (a, b) => (a ? `${a}\n${b}` : b);
700
713
  const takeReasoning = (m) => {
@@ -763,6 +776,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
763
776
  } else if (item.type === 'additional_tools' && Array.isArray(item.tools)) {
764
777
  toolList.push(...item.tools);
765
778
  } else if (item.type === 'compaction') {
779
+ reasoning = '';
766
780
  if (allowOpaqueCompaction && !item.encrypted_content?.startsWith('llm-gateway-compact-v1:')) { ir.opaqueCompaction = true; continue; }
767
781
  pushText('user', `Conversation summary from an earlier turn:\n${decodeCompaction(item.encrypted_content)}`);
768
782
  } else if (item.type === 'compaction_trigger') {
@@ -1592,7 +1606,10 @@ export function stripGeminiPlaceholders(value, schema) {
1592
1606
  const next = stripGeminiPlaceholders(v, props[k]);
1593
1607
  if (next !== v) out = { ...out, [k]: next };
1594
1608
  }
1595
- for (const branch of Array.isArray(schema.anyOf) ? schema.anyOf : []) out = stripGeminiPlaceholders(out, branch);
1609
+ // A branch that declares a real `_placeholder` makes the key the client's own, so no padded branch strips it.
1610
+ const branches = Array.isArray(schema.anyOf) ? schema.anyOf : [];
1611
+ const declared = branches.some(b => !isGeminiPadded(b) && b?.properties && Object.hasOwn(b.properties, '_placeholder'));
1612
+ for (const branch of branches) if (!(declared && isGeminiPadded(branch))) out = stripGeminiPlaceholders(out, branch);
1596
1613
  return out;
1597
1614
  }
1598
1615
 
@@ -1618,8 +1635,14 @@ export function geminiPaddedTools(upBody) {
1618
1635
  // whose needsArgsRewrite is true until the JSON is whole, so the strip also works on a stream.
1619
1636
  export function withGeminiPlaceholderStrip(vocab, padded) {
1620
1637
  if (!padded?.size) return vocab;
1621
- const schemaOf = (name) => padded.get(name) || padded.get(vocab.name(name))
1622
- || [...padded].find(([upstream]) => vocab.name(upstream) === name)?.[1];
1638
+ // By client name too. Two padded tools that one client name maps back to make that name
1639
+ // ambiguous, so it gets no strip, as the vocabulary refuses a tie.
1640
+ const byClient = new Map();
1641
+ for (const [upstream, schema] of padded) {
1642
+ const client = vocab.name(upstream);
1643
+ byClient.set(client, byClient.has(client) ? null : schema);
1644
+ }
1645
+ const schemaOf = (name) => padded.get(name) || byClient.get(vocab.name(name)) || byClient.get(name);
1623
1646
  const args = (name, argsJson) => {
1624
1647
  const out = vocab.args(name, argsJson);
1625
1648
  const schema = schemaOf(name);
@@ -1640,6 +1663,26 @@ export function withGeminiPlaceholderStrip(vocab, padded) {
1640
1663
  };
1641
1664
  }
1642
1665
 
1666
+ // The tool vocabulary the caller declared, so a model that answers with a name from its own
1667
+ // training is written back in the caller's words instead of ending the turn with "No such tool
1668
+ // available". Built once per request from the tools the caller actually sent, so a request that
1669
+ // declared none pays nothing and a turn where the model agreed with the list changes nothing.
1670
+ export function toolVocabForIR(ir, outFormat) {
1671
+ if (!ir || typeof ir !== 'object') return NULL_TOOL_VOCAB;
1672
+ if (ir._vocab?.outFormat !== outFormat) {
1673
+ const vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
1674
+ ir._vocab = { outFormat, vocab: withGeminiPlaceholderStrip(vocab, ir._geminiPadded) };
1675
+ }
1676
+ return ir._vocab.vocab;
1677
+ }
1678
+
1679
+ // Read by toolVocabForIR when the answer is rendered: the arguments lose the keys padding added.
1680
+ // Called on every emit, so a rebuild of the same IR never keeps the strip of an earlier body.
1681
+ export function recordGeminiPadding(ir, upBody, gemini) {
1682
+ ir._geminiPadded = gemini && Array.isArray(upBody?.tools) ? geminiPaddedTools(upBody) : null;
1683
+ ir._vocab = null;
1684
+ }
1685
+
1643
1686
  function toGeminiSchema(schema, root, seen) {
1644
1687
  const nested = Boolean(root);
1645
1688
  const emptyProperties = () => (nested ? { ...GEMINI_PLACEHOLDER_PROPERTIES } : {});
@@ -1824,29 +1867,28 @@ function endsInUnsignedToolLoop(messages) {
1824
1867
  function healAnthropicPayload(payload) {
1825
1868
  const notes = [];
1826
1869
  if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
1827
- // Anthropic has no system role inside messages. Its text joins the next user turn (else the previous
1828
- // one) after the other blocks, because a tool_result must stay first in its turn.
1870
+ // Anthropic has no system role inside messages. Its text becomes a user turn in the same place, so it
1871
+ // never moves past an assistant turn; Step 0 merges it into a user turn beside it, Steps 1-3 keep a tool_result first.
1829
1872
  const src = [];
1830
- let pendingSystem = [];
1831
- const withSystem = (m) => ({ ...m, content: [...toAnthropicBlocks(m.content), ...pendingSystem] });
1873
+ let folded = false;
1874
+ let droppedSystem = false;
1832
1875
  for (const m of payload.messages) {
1833
1876
  if (m?.role === 'system') {
1834
- const text = toAnthropicBlocks(m.content).filter(b => b.type === 'text' && !isBlankText(b)).map(b => b.text).join('\n');
1835
- if (text) pendingSystem.push({ type: 'text', text: `[system] ${text}` });
1877
+ const blocks = toAnthropicBlocks(m.content);
1878
+ const kept = blocks.filter(b => b.type === 'text' && !isBlankText(b));
1879
+ if (!kept.length || kept.length !== blocks.length) droppedSystem = true;
1880
+ if (kept.length) {
1881
+ src.push({ role: 'user', content: [{ type: 'text', text: `[system] ${kept.map(b => b.text).join('\n')}` }] });
1882
+ folded = true;
1883
+ }
1836
1884
  continue;
1837
1885
  }
1838
1886
  if (!m || (m.role !== 'user' && m.role !== 'assistant')) continue;
1839
- if (m.role === 'user' && pendingSystem.length) { src.push(withSystem(m)); pendingSystem = []; continue; }
1840
1887
  src.push(m);
1841
1888
  }
1842
- if (pendingSystem.length) {
1843
- const i = src.findLastIndex(m => m.role === 'user');
1844
- if (i >= 0) src[i] = withSystem(src[i]);
1845
- else src.unshift({ role: 'user', content: pendingSystem });
1846
- }
1847
- const hadSystem = payload.messages.some(m => m?.role === 'system');
1848
- if (hadSystem) notes.push('folded system messages into user turns');
1849
- let changed = hadSystem || src.length !== payload.messages.length;
1889
+ if (folded) notes.push('folded system messages into user turns');
1890
+ if (droppedSystem) notes.push('dropped system content with no text');
1891
+ let changed = folded || droppedSystem || src.length !== payload.messages.length;
1850
1892
 
1851
1893
  let system = payload.system;
1852
1894
  if (Array.isArray(system) && system.some(isBlankText)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.12",
3
+ "version": "1.6.14",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code and Codex, with OpenAI-compatible, Anthropic and Vertex upstreams",
5
5
  "keywords": [
6
6
  "llm",
package/proxy.mjs CHANGED
@@ -11,7 +11,7 @@ import { createFrameReader } from './blindfold/wsframe.mjs';
11
11
  import {
12
12
  OUT_FORMATS, parseToIR, emitUpstreamBody, irToAnthropicBody, createUpstreamNormalizer, createCollector,
13
13
  createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES, VALID_EFFORTS,
14
- geminiPaddedTools, withGeminiPlaceholderStrip,
14
+ toolVocabForIR, recordGeminiPadding,
15
15
  toGeminiSchema, isAntigravityModel, refusesImageInput, omitChatImages,
16
16
  createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
17
17
  buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
@@ -27,8 +27,7 @@ import {
27
27
  codexModelEntry,
28
28
  contractLabSettings, STATE_DIR, stopRecordedBlindfold
29
29
  } from './state.mjs';
30
- import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows, windowsCacheKey, cachedProfileWindows } from './windows.mjs';
31
- import { createToolVocab, NULL_TOOL_VOCAB, toolNameMapForIR } from './toolvocab.mjs';
30
+ import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows, windowsCacheKey, cachedProfileWindows, createWindowsRefresher } from './windows.mjs';
32
31
  import { readUpstreamPayloads } from './upstream-stream.mjs';
33
32
  import { compactionResponse, emitCompaction, expandGatewayCompactions, collectCompaction } from './compaction.mjs';
34
33
  import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
@@ -387,15 +386,26 @@ function isContextOverflow(status, message) {
387
386
  }
388
387
 
389
388
  // Claude Code compacts on "prompt is too long: N tokens > M maximum", so the numbers lead when the upstream names them.
390
- // A count may group its digits ("131,072", "131.072", "131 072", "131_072"); the group form is tried first.
391
- const TOKEN_COUNT = String.raw`(\d{1,3}(?:[,. _]\d{3})+(?!\d)|\d+)`;
392
- const MAX_TOKENS_RE = new RegExp(String.raw`(?:maximum context length|context window|limit) (?:is |of )?` + TOKEN_COUNT, 'i');
389
+ // A count may group its digits (131,072 / 131.072 / 131 072 / 131'072 / 1,31,072, also with U+00A0 or U+202F) or
390
+ // carry a k or m suffix. A dot before exactly three digits reads as a group, so 3.141 reads as 3141: the cost of
391
+ // reading 131.072. Any other decimal part (0.125, 3.14) with no k or m after it is no token count.
392
+ const TOKEN_COUNT = String.raw`(\d+(?:\.\d+)?[km](?![a-z])|[1-9]\d?(?:,\d\d)+,\d{3}(?![.,]?\d)|[1-9]\d{0,2}(?:[,. _'\u00a0\u202f]\d{3})+(?![.,]?\d)|\d+(?![.,]?\d))`;
393
+ // A bare "limit" may be a rate or a time, so it counts only when "tokens" follows the number.
394
+ const MAX_TOKENS_RE = new RegExp(String.raw`(?:maximum context length|context window|tokens? limit) (?:is |of )?${TOKEN_COUNT}|limit (?:is |of )?${TOKEN_COUNT}(?= ?tokens?\b)`, 'i');
393
395
  const ASKED_TOKENS_RE = new RegExp(String.raw`(?:requested|resulted in|prompt (?:is|has|contains)|input (?:is|has)) (?:about )?` + TOKEN_COUNT, 'i');
394
396
 
397
+ function tokenCount(text) {
398
+ if (!text) return '';
399
+ const short = text.match(/^(\d+(?:\.\d+)?)([km])$/i);
400
+ const n = short ? Math.round(Number(short[1]) * (short[2].toLowerCase() === 'k' ? 1e3 : 1e6)) : Number(text.replace(/\D/g, ''));
401
+ return Number.isSafeInteger(n) && n > 0 ? String(n) : '';
402
+ }
403
+
395
404
  function promptTooLongMessage(message) {
396
405
  if (/^prompt is too long/i.test(message)) return message;
397
- const max = message.match(MAX_TOKENS_RE)?.[1]?.replace(/\D/g, '');
398
- const asked = message.match(ASKED_TOKENS_RE)?.[1]?.replace(/\D/g, '');
406
+ const m = message.match(MAX_TOKENS_RE);
407
+ const max = tokenCount(m?.[1] ?? m?.[2]);
408
+ const asked = tokenCount(message.match(ASKED_TOKENS_RE)?.[1]);
399
409
  return max && asked ? `prompt is too long: ${asked} tokens > ${max} maximum (${message})` : `prompt is too long: ${message}`;
400
410
  }
401
411
 
@@ -494,19 +504,6 @@ function upstreamEndpoint(profile, outFormat, model, stream, req) {
494
504
  return { url: ov['openai-chat'] || `${base}/chat/completions`, headers };
495
505
  }
496
506
 
497
- // The tool vocabulary the caller declared, so a model that answers with a name from its own
498
- // training is written back in the caller's words instead of ending the turn with "No such tool
499
- // available". Built once per request from the tools the caller actually sent, so a request that
500
- // declared none pays nothing and a turn where the model agreed with the list changes nothing.
501
- function toolVocabFor(ir, outFormat) {
502
- if (!ir || typeof ir !== 'object') return NULL_TOOL_VOCAB;
503
- if (!ir._vocab) {
504
- const vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
505
- ir._vocab = withGeminiPlaceholderStrip(vocab, ir._geminiPadded);
506
- }
507
- return ir._vocab;
508
- }
509
-
510
507
  function clientRenderer(clientFormat, res, model, opts = {}) {
511
508
  if (clientFormat === 'anthropic') return createAnthropicStream((e, d) => sendSSE(res, e, d), model, opts);
512
509
  if (clientFormat === 'responses') return createResponsesStream((e, d) => sendSSE(res, e, d), model, opts);
@@ -837,11 +834,20 @@ function resolveThinkingEffort(profile, slot, mappedModel) {
837
834
  return profile?.thinkingEffort || 'auto';
838
835
  }
839
836
 
837
+ const refreshProfileWindows = createWindowsRefresher(STATE_DIR, { debug: (...a) => debugLog(...a) });
838
+
839
+ // A profile with an endpoints override sends its requests past baseURL, so its key must not go to baseURL/models.
840
+ const routesThroughOverride = (profile) => Object.values(profile?.endpoints || {}).some(Boolean);
841
+
840
842
  // The output limit this profile's own model list names for this model, or 0 when no fresh cached
841
- // list names one. The cache is filled by /v1/models; this only reads it.
843
+ // list names one. With no fresh entry the list is read in the background; this request never waits.
842
844
  function modelOutputLimit(profileKey, profile, mappedModel) {
843
845
  if (!profile?.baseURL || !mappedModel) return 0;
844
- const output = Number(cachedProfileWindows(STATE_DIR, windowsCacheKey(profileKey, profile.baseURL))[mappedModel]?.output);
846
+ if (routesThroughOverride(profile)) return 0;
847
+ const key = windowsCacheKey(profileKey, profile.baseURL);
848
+ const models = cachedProfileWindows(STATE_DIR, key);
849
+ if (!Object.keys(models).length) refreshProfileWindows(key, profile.baseURL, profile.apiKey);
850
+ const output = Number(models[mappedModel]?.output);
845
851
  return output > 0 ? output : 0;
846
852
  }
847
853
 
@@ -858,9 +864,8 @@ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = n
858
864
  if (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)) {
859
865
  upBody.tools = geminiSafeTools(upBody.tools);
860
866
  }
861
- // Read by toolVocabFor when the answer is rendered: the arguments lose the keys padding added.
862
- if (outFormat === 'vertex' || (outFormat === 'openai-chat' && isAntigravityModel(mappedModel))) ir._geminiPadded = geminiPaddedTools(upBody);
863
867
  }
868
+ recordGeminiPadding(ir, upBody, outFormat === 'vertex' || (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)));
864
869
  const { url, headers } = upstreamEndpoint(profile, outFormat, mappedModel, ir.stream, req);
865
870
  return { url, headers, upBody };
866
871
  }
@@ -1382,7 +1387,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1382
1387
  const out = clientMessage(clientFormat, {
1383
1388
  model: requestedModel || mappedModel, think, text: [split.text], tools, toolMeta: ir.toolMeta,
1384
1389
  finish: col.finish, prompt: col.prompt, completion, cached: col.cached,
1385
- reasoning: col.reasoning, sig: col.sig, vocab: toolVocabFor(ir, outFormat)
1390
+ reasoning: col.reasoning, sig: col.sig, vocab: toolVocabForIR(ir, outFormat)
1386
1391
  });
1387
1392
  sendJson(res, 200, out);
1388
1393
  answered = true;
@@ -1399,7 +1404,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1399
1404
  'Connection': 'keep-alive',
1400
1405
  'X-Accel-Buffering': 'no'
1401
1406
  });
1402
- const renderer = clientRenderer(clientFormat, res, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir, outFormat) });
1407
+ const renderer = clientRenderer(clientFormat, res, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabForIR(ir, outFormat) });
1403
1408
  renderer.start();
1404
1409
  const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
1405
1410
  const streamError = await pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal: ac.signal, sink: res, tag: profileKey });
@@ -1972,6 +1977,7 @@ async function servedModels(req) {
1972
1977
  // still gets the cached numbers, because an entry with the template window is better than none.
1973
1978
  async function modelWindowsFor(profileKey, profile) {
1974
1979
  if (!profile?.baseURL) return {};
1980
+ if (routesThroughOverride(profile)) return readLocalCodexWindows();
1975
1981
  const listed = await resolveProfileWindows(STATE_DIR, windowsCacheKey(profileKey, profile.baseURL), profile.baseURL, profile.apiKey);
1976
1982
  if (Object.keys(listed).length) return listed;
1977
1983
  return readLocalCodexWindows();
@@ -2629,7 +2635,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2629
2635
  if (socket.writable) {
2630
2636
  socket.write(encodeWsFrame(JSON.stringify(d)));
2631
2637
  }
2632
- }, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir, outFormat) });
2638
+ }, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabForIR(ir, outFormat) });
2633
2639
 
2634
2640
  renderer.start();
2635
2641
  const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
@@ -6,28 +6,64 @@ import path from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
 
9
- const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
10
- const files = [];
11
- (function walk(dir) {
12
- for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
13
- const p = path.join(dir, e.name);
14
- if (e.isDirectory()) walk(p);
15
- else if (e.name.endsWith('.test.mjs')) files.push(path.relative(root, p));
9
+ function holdsFile(dir) {
10
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
11
+ if (!e.isDirectory() || holdsFile(path.join(dir, e.name))) return true;
16
12
  }
17
- })(path.join(root, 'tests'));
18
-
19
- // A version check must never reach the real npm registry.
20
- const env = { ...process.env, LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' };
21
- // `npm test` itself, not a test, makes node-compile-cache in TMPDIR as it starts. Only a cache born
22
- // in the seconds before this run is removed afterwards; one that was there before stays.
23
- const compileCache = path.join(os.tmpdir(), 'node-compile-cache');
24
- const launchedAt = Date.now() - process.uptime() * 1000;
25
- let npmMadeCache = false;
26
- try {
27
- const born = fs.statSync(compileCache).birthtimeMs;
28
- npmMadeCache = Boolean(process.env.npm_lifecycle_event) && born > 0 && born >= launchedAt - 10_000;
29
- } catch { /* no cache: nothing to remove */ }
30
-
31
- const r = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], { cwd: root, stdio: 'inherit', env });
32
- if (npmMadeCache) fs.rmSync(compileCache, { recursive: true, force: true });
33
- process.exit(r.status ?? 1);
13
+ return false;
14
+ }
15
+
16
+ // npm makes node-compile-cache in TMPDIR as it starts and writes files only as it exits, so an empty cache is taken
17
+ // as made by this run. Not exact: the empty cache of another npm run still in progress goes too, which costs that
18
+ // run a rebuild only. npm makes the directory before this script starts, so no earlier existence check can tell.
19
+ export function compileCacheIsNew(dir, env = process.env) {
20
+ if (!env.npm_lifecycle_event) return false;
21
+ try {
22
+ return !holdsFile(dir);
23
+ } catch {
24
+ return false; // missing or unreadable: nothing of ours to remove
25
+ }
26
+ }
27
+
28
+ export function removeQuietly(dir, rm = fs.rmSync) {
29
+ try {
30
+ rm(dir, { recursive: true, force: true });
31
+ } catch (err) {
32
+ console.error(`run-tests: could not remove ${dir}: ${err.message}`);
33
+ }
34
+ }
35
+
36
+ function main() {
37
+ const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
38
+ const files = [];
39
+ (function walk(dir) {
40
+ for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
41
+ const p = path.join(dir, e.name);
42
+ if (e.isDirectory()) walk(p);
43
+ else if (e.name.endsWith('.test.mjs')) files.push(path.relative(root, p));
44
+ }
45
+ })(path.join(root, 'tests'));
46
+
47
+ // A version check must never reach the real npm registry.
48
+ const env = { ...process.env, LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' };
49
+ const compileCache = path.join(os.tmpdir(), 'node-compile-cache');
50
+ const npmMadeCache = compileCacheIsNew(compileCache);
51
+
52
+ const r = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], { cwd: root, stdio: 'inherit', env });
53
+ if (npmMadeCache) removeQuietly(compileCache);
54
+ process.exit(r.status ?? 1);
55
+ }
56
+
57
+ // node runs a symlinked script from its real path, so only real paths compare. A path that cannot be
58
+ // resolved is not this script: returning false keeps an import by the test from running the suite.
59
+ export function isMainModule(argv1, selfUrl = import.meta.url) {
60
+ if (!argv1) return false;
61
+ try {
62
+ return fs.realpathSync(path.resolve(argv1)) === fs.realpathSync(fileURLToPath(selfUrl));
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // Imported by its test, run by `npm test`.
69
+ if (isMainModule(process.argv[1])) main();
package/switch.mjs CHANGED
@@ -357,6 +357,7 @@ function printTargets(activeMap) {
357
357
  // Applies the switch to a copy of `base`. Exits on an error; nothing is written here.
358
358
  function planSwitch(base, key, cliTarget) {
359
359
  const planned = structuredClone(base);
360
+ delete planned.resumeProfiles;
360
361
  const err = cliTarget ? setTargetProfile(planned, cliTarget, key) : activateProfile(planned, key);
361
362
  if (err) {
362
363
  console.error(`[Error] ${show(err)}`);
@@ -365,6 +366,18 @@ function planSwitch(base, key, cliTarget) {
365
366
  return planned;
366
367
  }
367
368
 
369
+ // Bare `switch on`: the targets `switch off` turned off come back as they were. A target the user
370
+ // switched off stays off, and a target that is on keeps its profile.
371
+ function planResume(base) {
372
+ const planned = structuredClone(base);
373
+ const saved = planned.resumeProfiles;
374
+ delete planned.resumeProfiles;
375
+ if (saved && typeof saved === 'object' && !Object.values(getActiveMap(planned)).some(Boolean)) {
376
+ for (const t of TOOLS) if (saved[t]) setTargetProfile(planned, t, saved[t]);
377
+ }
378
+ return planned;
379
+ }
380
+
368
381
  function refuseBlindfoldProblem(planned, port) {
369
382
  const bf = computeLaunchState(planned, port).blindfold;
370
383
  // A missing or outdated certificate is built here; only a set that cannot be built stops the switch.
@@ -386,21 +399,26 @@ async function turnOn(profileName, cliTarget) {
386
399
  }
387
400
 
388
401
  const port = getTargetPort();
389
- const wanted = profileName || config.activeProfile || Object.keys(config.profiles)[0];
390
- const key = findProfileKey(config, wanted);
391
- if (!key) {
392
- console.error(`[Error] Profile "${show(wanted)}" not found in config.json!`);
402
+ const resume = !profileName && !cliTarget;
403
+ const key = resume ? null : findProfileKey(config, profileName);
404
+ if (!resume && !key) {
405
+ console.error(`[Error] Profile "${show(profileName)}" not found in config.json!`);
393
406
  console.error(`Available profiles: ${show(Object.keys(config.profiles).join(', ')) || '(none)'}`);
394
407
  process.exit(1);
395
408
  }
396
409
 
397
410
  // Plan on a copy. Nothing is written until every check below passes.
398
- let planned = planSwitch(config, key, cliTarget);
411
+ const plan = (base) => (resume ? planResume(base) : planSwitch(base, key, cliTarget));
412
+ let planned = plan(config);
399
413
  // The example config routes to YOUR-ROUTER-HOST; activating it silently makes every request fail.
400
- if (hasPlaceholder(planned.profiles[key])) {
401
- console.warn(`[WARN] Profile "${show(key)}" still has a placeholder baseURL/apiKey. Edit ${configPath} first, or requests will fail.`);
414
+ for (const k of new Set(resume ? Object.values(getActiveMap(planned)).filter(Boolean) : [key])) {
415
+ if (hasPlaceholder(planned.profiles[k])) {
416
+ console.warn(`[WARN] Profile "${show(k)}" still has a placeholder baseURL/apiKey. Edit ${configPath} first, or requests will fail.`);
417
+ }
402
418
  }
403
- console.log(`Activating profile: [${show(planned.profiles[key].name || key)}] (${show(key)})${cliTarget ? ` for ${cliTarget}` : ''} on port ${port}...`);
419
+ console.log(resume
420
+ ? `Starting the gateway on port ${port} with the targets as they were...`
421
+ : `Activating profile: [${show(planned.profiles[key].name || key)}] (${show(key)})${cliTarget ? ` for ${cliTarget}` : ''} on port ${port}...`);
404
422
 
405
423
  const gatewayState = await probeGateway(port);
406
424
  if (isHeld(gatewayState)) refuseForeignPort(port, 'this switcher', gatewayState);
@@ -417,9 +435,8 @@ async function turnOn(profileName, cliTarget) {
417
435
  console.error(`[Error] config.json does not parse any more: ${getLastLoadError()?.message}. Nothing was changed.`);
418
436
  process.exit(1);
419
437
  }
420
- planned = planSwitch(current, key, cliTarget);
438
+ planned = plan(current);
421
439
  refuseBlindfoldProblem(planned, port);
422
- const profile = planned.profiles[key];
423
440
 
424
441
  const previousBytes = fs.readFileSync(configPath);
425
442
  try {
@@ -471,8 +488,14 @@ async function turnOn(profileName, cliTarget) {
471
488
  console.warn(`[Shim] ${err.message}`);
472
489
  }
473
490
 
474
- console.log(`\n[SUCCESS] Switched to profile "${show(profile.name || key)}".`);
475
- printProfile(profile);
491
+ if (resume) {
492
+ console.log('\n[SUCCESS] Gateway is on.');
493
+ if (!Object.values(getActiveMap(planned)).some(Boolean)) console.log('No target is on. Turn one on with: switch <profile>');
494
+ } else {
495
+ const profile = planned.profiles[key];
496
+ console.log(`\n[SUCCESS] Switched to profile "${show(profile.name || key)}".`);
497
+ printProfile(profile);
498
+ }
476
499
  console.log('\nActive targets:');
477
500
  printTargets(getActiveMap(planned));
478
501
  const codexKey = getActiveMap(planned).codex;
@@ -578,6 +601,7 @@ async function turnOff(targetArg) {
578
601
  console.log('\n[SUCCESS] Switched back to Claude Official Subscription. Run `switch on` to re-enable.');
579
602
  return;
580
603
  }
604
+ config.resumeProfiles = getActiveMap(config);
581
605
  deactivateAll(config);
582
606
  saveConfig(config);
583
607
  reportSettings(clearLaunchState(port));
@@ -1337,7 +1361,8 @@ if (cmd === 'off' || cmd === 'stop') {
1337
1361
  console.log(' switch version # Show the version and check for a newer one');
1338
1362
  console.log(' switch doctor # Audit environment, settings & routing');
1339
1363
  console.log(' switch update # Install the newest release and restart the gateway');
1340
- console.log(' switch on [profile] # Start gateway & activate profile for all compatible targets');
1364
+ console.log(' switch on # Start gateway; each target gets back what it had before `switch off`');
1365
+ console.log(' switch on <profile> # Start gateway & activate profile for all compatible targets');
1341
1366
  console.log(' switch <profile> # Activate profile for all compatible targets');
1342
1367
  console.log(' switch claude <profile> # Set active profile for Claude Code');
1343
1368
  console.log(' switch codex <profile> # Set active profile for Codex');
@@ -207,7 +207,7 @@ test('make-certs.sh writes private files and refuses a directory it does not own
207
207
  }
208
208
  // A failed rebuild (here: no openssl on PATH) must leave the working set untouched.
209
209
  const caBefore = fs.readFileSync(path.join(out, 'ca.pem'));
210
- const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-bin-'));
210
+ const bin = fs.mkdtempSync(path.join(base, 'bin-'));
211
211
  for (const tool of ['mkdir', 'stat', 'id', 'chmod', 'rm', 'cat', 'dirname', 'mv', 'mktemp']) {
212
212
  const real = execFileSync('bash', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).trim();
213
213
  fs.symlinkSync(real, path.join(bin, tool));
@@ -1,4 +1,4 @@
1
- import { test } from 'node:test';
1
+ import { test, after } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
@@ -9,9 +9,13 @@ import { fileURLToPath } from 'node:url';
9
9
  import { openResponsesWs } from './ws-client.mjs';
10
10
  const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
11
11
  const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
12
+ // Every temp directory goes once more after the file, so a setup that fails early leaves none behind.
13
+ const made = [];
14
+ after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });
12
15
  async function until(check) { const deadline = Date.now() + 6000; while (Date.now() < deadline) { if (await check()) return; await new Promise(r => setTimeout(r, 20)); } throw new Error('timeout'); }
13
16
  async function fixture(t) {
14
17
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-compaction-'));
18
+ made.push(dir);
15
19
  const received = []; let finish = 'stop', truncate = false;
16
20
  const up = http.createServer((req, res) => {
17
21
  let raw = ''; req.on('data', b => raw += b); req.on('end', () => {
@@ -70,7 +70,7 @@ test('the tool version is read from the User-Agent, and a bad one is dropped', (
70
70
 
71
71
  // ---------------------------------------------------------------- unit: config block
72
72
 
73
- test('contractLab is absent by default, and a saved block survives a rewrite', () => {
73
+ test('contractLab is absent by default, and a saved block survives a rewrite', (t) => {
74
74
  assert.deepEqual(contractLabSettings({}), { url: '', apiKey: '', enabled: false });
75
75
  assert.deepEqual(contractLabSettings(null), { url: '', apiKey: '', enabled: false });
76
76
  // Enabled needs a real http(s) URL and a key: a half-filled block stays off.
@@ -80,6 +80,7 @@ test('contractLab is absent by default, and a saved block survives a rewrite', (
80
80
  assert.equal(contractLabSettings({ contractLab: { url: 'http://127.0.0.1:9/', apiKey: 'k', enabled: true } }).url, 'http://127.0.0.1:9');
81
81
 
82
82
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-switcher-lab-'));
83
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
83
84
  const cfgPath = path.join(dir, 'config.json');
84
85
  const cfg = { port: 3456, profiles: {}, contractLab: { url: 'http://127.0.0.1:20142', apiKey: 'sk-lab', enabled: true } };
85
86
  fs.writeFileSync(cfgPath, JSON.stringify(cfg));
@@ -94,7 +95,6 @@ test('contractLab is absent by default, and a saved block survives a rewrite', (
94
95
  // The dashboard must never read the intact key back out of /api/config.
95
96
  assert.equal(out.masked.apiKey, MASKED_KEY);
96
97
  assert.equal(out.masked.hasApiKey, true);
97
- fs.rmSync(dir, { recursive: true, force: true });
98
98
  });
99
99
 
100
100
  // state.mjs reads its config path once per process, so a config test runs in its own process.