llm-switcher 1.6.14 → 1.6.16

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,16 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.6.16 — 2026-10-13
4
+
5
+ - **A silent upstream stream.** The proxy now ends an upstream stream that sends no byte for 5 minutes, with an error. Before, only the default limit of the Node HTTP client stopped it. Set `LLM_SWITCHER_UPSTREAM_IDLE_MS` to change the limit.
6
+ - **A test for a stream that ends with no finish reason.** A converted stream that ends cleanly without a finish reason gives an error, not a finished answer. A test now holds this behavior.
7
+
8
+ ## Release 1.6.15 — 2026-10-13
9
+
10
+ - **A message inside a tool batch.** Codex can put a notice between two tool outputs of one parallel batch. The proxy now moves the notice after the batch. Before, the next real output was replaced with a placeholder.
11
+ - **Files and documents in a tool output.** An `input_file` in a Responses tool output and a `document` in an Anthropic `tool_result` now go to the model as their name. Before, the proxy wrote their base64 data into the text, which can use millions of tokens. The `input_image` parts of a Responses tool output now reach the model as images.
12
+ - **Two shims on `PATH`.** A shim now skips every LLM Switcher shim directory when it looks for the real binary. Before, two shims on `PATH` called each other with no end, and each call wrote a new CA bundle.
13
+
3
14
  ## Release 1.6.14 — 2026-10-12
4
15
 
5
16
  - **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`.
package/formats.mjs CHANGED
@@ -509,7 +509,7 @@ function anthropicToIR(payload) {
509
509
  return '';
510
510
  }
511
511
  if (c?.type === 'tool_reference' && typeof c.tool_name === 'string') return c;
512
- return JSON.stringify(c);
512
+ return mediaPlaceholder(c) ?? JSON.stringify(c);
513
513
  }).filter(Boolean);
514
514
  resultText = resultParts.map(c => typeof c === 'string' ? c : JSON.stringify(c)).join('\n');
515
515
  } else if (part.content) resultText = JSON.stringify(part.content);
@@ -679,12 +679,30 @@ function responsesOutputToText(out) {
679
679
  if (typeof x === 'string') return x;
680
680
  if (typeof x?.text === 'string') return x.text;
681
681
  if (x?.type === 'input_image') return '[image]';
682
- return JSON.stringify(x);
682
+ return mediaPlaceholder(x) ?? JSON.stringify(x);
683
683
  }).join('\n');
684
684
  }
685
685
  return out == null ? '' : JSON.stringify(out);
686
686
  }
687
687
 
688
+ // The input_image parts of a Responses tool output, as chat image parts.
689
+ function responsesOutputImages(out) {
690
+ if (!Array.isArray(out)) return [];
691
+ return out.filter(x => x?.type === 'input_image')
692
+ .map(x => (typeof x.image_url === 'string' ? x.image_url : x.image_url?.url))
693
+ .filter(Boolean)
694
+ .map(url => ({ type: 'image_url', image_url: { url } }));
695
+ }
696
+
697
+ // A file or document block carries its bytes as base64; stringified into text it costs millions of tokens
698
+ // (The-NeXT-AI/ai-gateway#28), so it is named instead. Null for any other block.
699
+ function mediaPlaceholder(b) {
700
+ if (b?.type === 'input_file') return `[file: ${b.filename || b.file_id || 'attachment'}]`;
701
+ if (b?.type === 'document') return `[document${b.title ? `: ${b.title}` : ''}]`;
702
+ if (b?.source?.type === 'base64' || typeof b?.file_data === 'string') return `[${b.type || 'attachment'}]`;
703
+ return null;
704
+ }
705
+
688
706
  // OpenAI Responses API (Codex) -> IR.
689
707
  function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
690
708
  const ir = baseIR();
@@ -772,7 +790,8 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
772
790
  const a = item.action || {};
773
791
  pushCall({ id: item.call_id || item.id || null, name: 'local_shell', args: { command: a.command || [], workdir: a.working_directory ?? undefined, timeout_ms: a.timeout_ms ?? undefined } });
774
792
  } else if (item.type === 'function_call_output' || item.type === 'custom_tool_call_output' || item.type === 'local_shell_call_output') {
775
- ir.messages.push({ role: 'tool', toolCallId: item.call_id || item.id, content: responsesOutputToText(item.output) });
793
+ const images = responsesOutputImages(item.output);
794
+ ir.messages.push({ role: 'tool', toolCallId: item.call_id || item.id, content: responsesOutputToText(item.output), ...(images.length ? { images } : {}) });
776
795
  } else if (item.type === 'additional_tools' && Array.isArray(item.tools)) {
777
796
  toolList.push(...item.tools);
778
797
  } else if (item.type === 'compaction') {
@@ -1032,13 +1051,15 @@ function hasNativeReasoning(model) {
1032
1051
  // -> OpenAI: "assistant message with 'tool_calls' must be followed by tool messages"
1033
1052
  //
1034
1053
  // Rule: a tool result is only valid when placed right after the assistant turn that declared it. Orphaned results are
1035
- // converted to user text (preserving context); tool calls missing a result get a placeholder result.
1054
+ // converted to user text (preserving context); tool calls missing a result get a placeholder result. A non-assistant
1055
+ // message inside an unanswered batch (Codex inserts notices there) moves after the batch, so later outputs still pair.
1036
1056
  const MISSING_TOOL_RESULT = '[Tool result unavailable: it was removed from the conversation history]';
1037
1057
 
1038
1058
  function healToolPairs(messages) {
1039
1059
  const out = [];
1040
1060
  let pending = null; // Map<callId, name> of the most recent assistant turn
1041
1061
  let deferred = []; // orphaned results seen while awaiting results -> flushed later to preserve adjacency
1062
+ let held = []; // non-tool messages seen while awaiting results -> emitted after the batch
1042
1063
  let seq = 0;
1043
1064
 
1044
1065
  const orphanText = (m) => ({
@@ -1055,6 +1076,8 @@ function healToolPairs(messages) {
1055
1076
  }
1056
1077
  for (const m of deferred) out.push(orphanText(m));
1057
1078
  deferred = [];
1079
+ out.push(...held);
1080
+ held = [];
1058
1081
  };
1059
1082
 
1060
1083
  let unnamed = []; // ids given to calls that arrived with no id, in order
@@ -1068,6 +1091,7 @@ function healToolPairs(messages) {
1068
1091
  const name = pending.get(id);
1069
1092
  pending.delete(id);
1070
1093
  out.push({ ...m, toolCallId: id, name: m.name || name });
1094
+ if (!pending.size) flush();
1071
1095
  } else if (pending) {
1072
1096
  deferred.push(m);
1073
1097
  } else {
@@ -1075,6 +1099,10 @@ function healToolPairs(messages) {
1075
1099
  }
1076
1100
  continue;
1077
1101
  }
1102
+ if (pending?.size && m.role !== 'assistant') {
1103
+ held.push(m);
1104
+ continue;
1105
+ }
1078
1106
  flush();
1079
1107
  if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length) {
1080
1108
  unnamed = [];
@@ -1850,7 +1878,7 @@ function isBlankText(b) {
1850
1878
  function toolResultText(block) {
1851
1879
  const c = block.content;
1852
1880
  const text = typeof c === 'string' ? c
1853
- : Array.isArray(c) ? c.map(x => (x?.type === 'text' ? x.text : x?.type === 'image' ? '[image omitted]' : JSON.stringify(x))).join('\n')
1881
+ : Array.isArray(c) ? c.map(x => (x?.type === 'text' ? x.text : x?.type === 'image' ? '[image omitted]' : mediaPlaceholder(x) ?? JSON.stringify(x))).join('\n')
1854
1882
  : c ? JSON.stringify(c) : '';
1855
1883
  return `[Tool Result${block.tool_use_id ? ` (${block.tool_use_id})` : ''}]: ${text}`;
1856
1884
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.14",
3
+ "version": "1.6.16",
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/shim.mjs CHANGED
@@ -201,7 +201,8 @@ for _v in $(compgen -v LLM_SWITCHER_CODEX_ 2>/dev/null); do
201
201
  done
202
202
  ${caBlock}
203
203
 
204
- # Find the real binary: drop the shim directory from PATH so it doesn't call itself.
204
+ # Find the real binary: drop every switcher shim directory from PATH, this one and any other
205
+ # (a second install, a test HOME), or two shims call each other forever.
205
206
  CLEAN_PATH=""
206
207
  IFS=':'
207
208
  for p in $PATH; do
@@ -209,6 +210,7 @@ for p in $PATH; do
209
210
  "$SHIM_DIR"|"$SHIM_DIR"/) continue ;;
210
211
  esac
211
212
  [ -z "$p" ] && continue
213
+ if [ -f "$p/${name}" ] && grep -q 'Auto-generated by LLM Switcher' "$p/${name}" 2>/dev/null; then continue; fi
212
214
  if [ -z "$CLEAN_PATH" ]; then CLEAN_PATH="$p"; else CLEAN_PATH="$CLEAN_PATH:$p"; fi
213
215
  done
214
216
  unset IFS
@@ -46,6 +46,12 @@ function startUpstream() {
46
46
  try { json = raw ? JSON.parse(raw) : null; } catch {}
47
47
  received.push({ method: req.method, url: req.url, headers: req.headers, raw, body: json });
48
48
 
49
+ if (req.url.startsWith('/eof/v1/chat/completions')) {
50
+ // A chunk of text, then a clean end with no finish_reason and no [DONE].
51
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
52
+ res.write('data: {"id":"c","choices":[{"index":0,"delta":{"content":"half an ans"}}]}\n\n');
53
+ return res.end();
54
+ }
49
55
  if (req.url.startsWith('/fail/v1/')) {
50
56
  res.writeHead(500, { 'Content-Type': 'application/json' });
51
57
  return res.end(JSON.stringify({ error: { message: 'upstream exploded' } }));
@@ -188,6 +194,7 @@ before(async () => {
188
194
  agyb: { name: 'agy on intact', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/intact/v1`, apiKey: 'sk-intact', defaultModels: { main: 'antigravity/*' } },
189
195
  agyc: { name: 'agy converted', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-chat', defaultModels: { main: 'gem/flash' } },
190
196
  agye: { name: 'agy empty slot', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-chat', defaultModels: {} },
197
+ agyeof: { name: 'agy clean EOF', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/eof/v1`, apiKey: 'sk-eof', defaultModels: { main: 'gem/flash' } },
191
198
  agy500: { name: 'agy failing upstream', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/fail/v1`, apiKey: 'sk-fail', defaultModels: { main: 'gem/flash' } },
192
199
  agyf: { name: 'agy flaky lookup', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/flaky/v1`, apiKey: 'sk-flaky', defaultModels: { main: 'antigravity/*' } },
193
200
  agynf: { name: 'agy lookup 404', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/nf/v1`, apiKey: 'sk-nf', defaultModels: { main: 'antigravity/*' } },
@@ -456,6 +463,15 @@ test('an agy profile with no main slot sends the model that agy picked', async (
456
463
  assert.equal(lastTo('/chat/v1/chat/completions').body.model, 'gemini-3.8-flash-high');
457
464
  });
458
465
 
466
+ test('a converted stream that ends cleanly without a finish reason is an error, not a finished answer', async () => {
467
+ // router-for-me/CLIProxyAPI#6533: a clean EOF must not become finishReason STOP.
468
+ const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders({ 'x-llm-profile': 'agyeof' }), body: JSON.stringify(envelope()) });
469
+ const text = await r.text();
470
+ assert.ok(text.includes('half an ans'), text);
471
+ assert.ok(!/"finishReason":"STOP"/.test(text), `a cut stream ended as STOP:\n${text}`);
472
+ assert.match(text, /error/i);
473
+ });
474
+
459
475
  test('a converted agy turn whose upstream fails gets the Code Assist error shape', async () => {
460
476
  const r = await fetch(url('/v1internal:generateContent'), { method: 'POST', headers: agyHeaders({ 'x-llm-profile': 'agy500' }), body: JSON.stringify(envelope()) });
461
477
  assert.ok(r.status >= 500, `status ${r.status}`);
@@ -1699,3 +1699,58 @@ test('the placeholder strip refuses a client name that two padded tools share',
1699
1699
  assert.deepEqual(JSON.parse(vocab.args(alias[0], filled)), { env: {} });
1700
1700
  assert.equal(vocab.args('a_b', filled), filled, 'a_b declared its own _placeholder');
1701
1701
  });
1702
+
1703
+ test('responsesToIR + irToChatBody: a notice inside a parallel batch moves after it and every real output is kept', () => {
1704
+ // Codex inserts a notice between two outputs of one batch (openai/codex#52834).
1705
+ const ir = responsesToIR({
1706
+ model: 'x', input: [
1707
+ { role: 'user', content: 'do two things' },
1708
+ { type: 'function_call', call_id: 'c1', name: 'a', arguments: '{}' },
1709
+ { type: 'function_call', call_id: 'c2', name: 'b', arguments: '{}' },
1710
+ { type: 'function_call_output', call_id: 'c1', output: 'out1' },
1711
+ { role: 'user', content: 'notice' },
1712
+ { type: 'function_call_output', call_id: 'c2', output: 'out2' }
1713
+ ]
1714
+ });
1715
+ for (const body of [irToChatBody(ir, 'gpt-4o')]) {
1716
+ const shape = body.messages.map(m => m.role === 'tool' ? `tool:${m.content}` : `${m.role}`);
1717
+ assert.deepEqual(shape, ['user', 'assistant', 'tool:out1', 'tool:out2', 'user']);
1718
+ }
1719
+ const anth = irToAnthropicBody(ir, 'claude-x');
1720
+ const results = anth.messages[2].content.filter(b => b.type === 'tool_result').map(b => JSON.stringify(b.content));
1721
+ assert.ok(results.every(r => !r.includes('unavailable')), `real outputs replaced: ${results}`);
1722
+ });
1723
+
1724
+ test('a tool output that carries a file or an image never reaches the model as base64 text', () => {
1725
+ // The-NeXT-AI/ai-gateway#28: a 5.9 MB image serialized as text became 4.1M tokens.
1726
+ const blob = 'QUFB'.repeat(5000);
1727
+ const fromResponses = responsesToIR({
1728
+ model: 'x', input: [
1729
+ { type: 'function_call', call_id: 'c1', name: 'shot', arguments: '{}' },
1730
+ { type: 'function_call_output', call_id: 'c1', output: [
1731
+ { type: 'input_image', image_url: `data:image/png;base64,${blob}` },
1732
+ { type: 'input_file', filename: 'a.pdf', file_data: `data:application/pdf;base64,${blob}` }] }
1733
+ ]
1734
+ });
1735
+ const fromAnthropic = anthropicToIR({
1736
+ model: 'x', messages: [
1737
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'read', input: {} }] },
1738
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: [
1739
+ { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: blob } }] }] }
1740
+ ]
1741
+ });
1742
+ for (const [name, ir] of [['responses', fromResponses], ['anthropic', fromAnthropic]]) {
1743
+ const tool = ir.messages.find(m => m.role === 'tool');
1744
+ assert.ok(!tool.content.includes(blob), `${name}: tool text carries the base64`);
1745
+ }
1746
+ const tool = fromResponses.messages.find(m => m.role === 'tool');
1747
+ assert.match(tool.content, /a\.pdf/);
1748
+ assert.equal(tool.images?.[0]?.image_url?.url, `data:image/png;base64,${blob}`);
1749
+ });
1750
+
1751
+ test('irToAnthropicBody: a whitespace-only text block is blank too', () => {
1752
+ // Anthropic: "text content blocks must contain non-whitespace text".
1753
+ const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: [{ type: 'text', text: ' \n' }, { type: 'text', text: 'hi' }] }] });
1754
+ const body = irToAnthropicBody(ir, 'claude');
1755
+ assert.ok(body.messages.every(m => m.content.every(b => b.type !== 'text' || b.text.trim())), JSON.stringify(body.messages));
1756
+ });
@@ -119,6 +119,18 @@ test('shim never recurses into itself', (t) => {
119
119
  assert.match(out, /ARGS=x/);
120
120
  });
121
121
 
122
+ test('two switcher shims on PATH never call each other', (t) => {
123
+ // A test HOME's shim ahead of the account's own shim: each dropped only its own directory, so they
124
+ // found each other forever, and every hop built a new CA bundle (2026-10-13: 702 files in 2 minutes).
125
+ if (process.platform === 'win32') return t.skip('posix only');
126
+ const other = fs.mkdtempSync(path.join(SCRATCH, 'shimother-'));
127
+ fs.copyFileSync(path.join(RENDER_DIR, 'claude'), path.join(other, 'claude'));
128
+ fs.chmodSync(path.join(other, 'claude'), 0o755);
129
+ const fake = makeFakeBin('claude');
130
+ const out = runShim('claude', ['x'], { active: true, fakeDir: other, extraPath: `${fake}:/usr/bin:/bin` });
131
+ assert.match(out, /ARGS=x/);
132
+ });
133
+
122
134
  test('shim fails loudly (127) when the real binary is missing', (t) => {
123
135
  if (process.platform === 'win32') return t.skip('posix only');
124
136
  const empty = fs.mkdtempSync(path.join(SCRATCH, 'shimempty-'));
@@ -28,3 +28,22 @@ test('malformed JSON is surfaced and stopping consumption cancels the upstream',
28
28
  for await (const item of readUpstreamPayloads({ body })) { assert.equal(item.one, 1); break; }
29
29
  assert.equal(canceled, true);
30
30
  });
31
+
32
+ test('an upstream that goes silent mid-stream ends with an error after the idle limit', async () => {
33
+ // A dead HTTP/2 session can hold a stream open with no bytes (router-for-me/CLIProxyAPI#6534).
34
+ let sent = false;
35
+ let cancelled = false;
36
+ const body = new ReadableStream({
37
+ pull(controller) {
38
+ if (!sent) { sent = true; controller.enqueue(Buffer.from('data: {"a":1}\n\n')); }
39
+ return new Promise(() => {});
40
+ },
41
+ cancel() { cancelled = true; }
42
+ });
43
+ const values = [];
44
+ await assert.rejects(async () => {
45
+ for await (const v of readUpstreamPayloads({ body }, { idleMs: 50 })) values.push(v);
46
+ }, /sent nothing for/);
47
+ assert.deepEqual(values, [{ a: 1 }]);
48
+ assert.ok(cancelled, 'the silent upstream body is cancelled');
49
+ });
@@ -1,8 +1,20 @@
1
1
  // SSE dispatches one event at a blank line; data fields are joined with newlines.
2
2
  // Vertex also sends bare JSON lines. Keep both framings without parsing data fields separately.
3
3
  // `state.done` turns true on an OpenAI [DONE] line, which ends a stream cleanly even without a finish reason.
4
+ // A stream that sends no byte for `state.idleMs` ends with an error: a dead HTTP/2 session can hold it open
5
+ // with no bytes, and the default limit of the HTTP client is not this module's to rely on.
6
+ export const UPSTREAM_IDLE_MS = Number(process.env.LLM_SWITCHER_UPSTREAM_IDLE_MS) || 300000;
7
+
4
8
  export async function* readUpstreamPayloads(upstreamRes, state = {}) {
5
9
  const reader = upstreamRes.body.getReader();
10
+ const idleMs = state.idleMs || UPSTREAM_IDLE_MS;
11
+ const read = () => {
12
+ let timer;
13
+ const idle = new Promise((_, reject) => {
14
+ timer = setTimeout(() => reject(new Error(`upstream stream sent nothing for ${Math.round(idleMs / 1000)}s`)), idleMs);
15
+ });
16
+ return Promise.race([reader.read(), idle]).finally(() => clearTimeout(timer));
17
+ };
6
18
  const decoder = new TextDecoder();
7
19
  let buffer = '', data = [], finished = false;
8
20
  const parse = value => {
@@ -20,7 +32,7 @@ export async function* readUpstreamPayloads(upstreamRes, state = {}) {
20
32
  }
21
33
  try {
22
34
  while (true) {
23
- const chunk = await reader.read();
35
+ const chunk = await read();
24
36
  if (chunk.done) { finished = true; buffer += decoder.decode(); break; }
25
37
  buffer += decoder.decode(chunk.value, { stream: true });
26
38
  let newline;