llm-switcher 1.6.15 → 1.6.17

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,15 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.6.17 — 2026-10-13
4
+
5
+ - **Documents in a tool result.** A document with a text source, a content source or a URL goes to the model as its text or its URL. Before 1.6.17, it went as a bare `[document]` label. Only base64 data becomes a label, for example `[file: a.pdf]`.
6
+ - **The upstream idle limit.** `LLM_SWITCHER_UPSTREAM_IDLE_MS` must be a positive number. The proxy uses 300000 for any other value, and it writes a warning. A value above 2147483647 becomes 2147483647. Before 1.6.17, a negative, infinite or too large value ended every stream after 1 ms. The limit counts from the response headers. It applies to the body only, and an SSE comment line resets it.
7
+
8
+ ## Release 1.6.16 — 2026-10-13
9
+
10
+ - **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.
11
+ - **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.
12
+
3
13
  ## Release 1.6.15 — 2026-10-13
4
14
 
5
15
  - **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.
package/formats.mjs CHANGED
@@ -697,9 +697,22 @@ function responsesOutputImages(out) {
697
697
  // A file or document block carries its bytes as base64; stringified into text it costs millions of tokens
698
698
  // (The-NeXT-AI/ai-gateway#28), so it is named instead. Null for any other block.
699
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'}]`;
700
+ const src = b?.source;
701
+ if (src?.type === 'base64' || typeof b?.file_data === 'string') {
702
+ if (b.type === 'input_file') return `[file: ${b.filename || b.file_id || 'attachment'}]`;
703
+ if (b.type === 'document') return `[document${b.title ? `: ${b.title}` : ''}]`;
704
+ return `[${b.type || 'attachment'}]`;
705
+ }
706
+ // Not base64: the content itself goes on, as text the model can read.
707
+ if (b?.type === 'document' && src?.type === 'text' && typeof src.data === 'string') return src.data;
708
+ if (b?.type === 'document' && src?.type === 'content') {
709
+ if (typeof src.content === 'string') return src.content;
710
+ if (Array.isArray(src.content)) {
711
+ return src.content.map(c => (c?.type === 'text' ? c.text || '' : c?.type === 'image' ? '[image omitted]' : '')).filter(Boolean).join('\n');
712
+ }
713
+ }
714
+ if (b?.type === 'document' && src?.type === 'url' && src.url) return `[document: ${src.url}]`;
715
+ if (b?.type === 'input_file' && b.file_url) return `[file: ${b.file_url}]`;
703
716
  return null;
704
717
  }
705
718
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.15",
3
+ "version": "1.6.17",
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",
@@ -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}`);
@@ -1754,3 +1754,91 @@ test('irToAnthropicBody: a whitespace-only text block is blank too', () => {
1754
1754
  const body = irToAnthropicBody(ir, 'claude');
1755
1755
  assert.ok(body.messages.every(m => m.content.every(b => b.type !== 'text' || b.text.trim())), JSON.stringify(body.messages));
1756
1756
  });
1757
+
1758
+ // ---- roundtable ruling 2026-10-13: media in tool results, held notices ----
1759
+
1760
+ const BLOB = 'QUFB'.repeat(2000);
1761
+ const MISSING_TOOL_RESULT = '[Tool result unavailable: it was removed from the conversation history]';
1762
+ function anthropicToolText(content) {
1763
+ const ir = anthropicToIR({ model: 'x', messages: [
1764
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'read', input: {} }] },
1765
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content }] }
1766
+ ] });
1767
+ return ir.messages.find(m => m.role === 'tool').content;
1768
+ }
1769
+
1770
+ test('a tool-result document keeps its text, its URL and its label; only base64 becomes a placeholder', () => {
1771
+ assert.match(anthropicToolText([{ type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'hello doc' } }]), /hello doc/);
1772
+ const content = anthropicToolText([{ type: 'document', source: { type: 'content', content: [
1773
+ { type: 'text', text: 'part one' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: BLOB } }] } }]);
1774
+ assert.match(content, /part one/);
1775
+ assert.match(content, /\[image omitted\]/);
1776
+ assert.ok(!content.includes(BLOB));
1777
+ assert.equal(anthropicToolText([{ type: 'document', source: { type: 'content', content: 'plain string' } }]), 'plain string');
1778
+ assert.match(anthropicToolText([{ type: 'document', source: { type: 'url', url: 'https://example.com/a.pdf' } }]), /https:\/\/example\.com\/a\.pdf/);
1779
+ assert.match(anthropicToolText([{ type: 'input_file', file_url: 'https://example.com/f.pdf' }]), /https:\/\/example\.com\/f\.pdf/);
1780
+ assert.equal(anthropicToolText([{ type: 'input_file', filename: 'a.pdf', file_data: `data:application/pdf;base64,${BLOB}` }]), '[file: a.pdf]');
1781
+ assert.equal(anthropicToolText([{ type: 'document', title: 'T', source: { type: 'base64', media_type: 'application/pdf', data: BLOB } }]), '[document: T]');
1782
+ assert.ok(!anthropicToolText([{ type: 'document', source: { type: 'url' } }]).includes('[document: undefined]'));
1783
+ });
1784
+
1785
+ test('an orphan tool result turned into text keeps a text document', () => {
1786
+ const { payload } = healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
1787
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'orphan', content: [{ type: 'document', source: { type: 'text', data: 'hello doc' } }] }] }
1788
+ ] });
1789
+ assert.match(JSON.stringify(payload.messages), /hello doc/);
1790
+ });
1791
+
1792
+ function chatShape(input) {
1793
+ return irToChatBody(responsesToIR({ model: 'x', input }), 'gpt-4o').messages
1794
+ .map(m => (m.role === 'tool' ? `tool:${m.content}` : m.role === 'user' ? `user:${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}` : m.role));
1795
+ }
1796
+ const callAB = [
1797
+ { type: 'function_call', call_id: 'c1', name: 'a', arguments: '{}' },
1798
+ { type: 'function_call', call_id: 'c2', name: 'b', arguments: '{}' }
1799
+ ];
1800
+
1801
+ test('healToolPairs: a held notice follows the placeholder of an output that never came', () => {
1802
+ const missing = chatShape([...callAB, { type: 'function_call_output', call_id: 'c1', output: 'out1' }, { role: 'user', content: 'notice' }]);
1803
+ assert.deepEqual(missing.slice(-4), ['assistant', 'tool:out1', `tool:${MISSING_TOOL_RESULT}`, 'user:notice']);
1804
+ const later = chatShape([...callAB, { type: 'function_call_output', call_id: 'c1', output: 'out1' }, { role: 'user', content: 'notice' },
1805
+ { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'next turn' }] }]);
1806
+ assert.deepEqual(later.slice(-2), ['user:notice', 'assistant']);
1807
+ const two = chatShape([...callAB, { role: 'user', content: 'notice1' }, { type: 'function_call_output', call_id: 'c1', output: 'out1' }, { role: 'user', content: 'notice2' }]);
1808
+ assert.deepEqual(two.slice(-5), ['assistant', 'tool:out1', `tool:${MISSING_TOOL_RESULT}`, 'user:notice1', 'user:notice2']);
1809
+ });
1810
+
1811
+ test('placeholders and URLs are exact on every route that turns a tool result into text', () => {
1812
+ assert.equal(anthropicToolText([{ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: BLOB } }]), '[document]');
1813
+ assert.equal(anthropicToolText([{ type: 'document', source: { type: 'url', url: 'https://example.com/a.pdf' } }]), '[document: https://example.com/a.pdf]');
1814
+ assert.equal(anthropicToolText([{ type: 'input_file', file_url: 'https://example.com/f.pdf' }]), '[file: https://example.com/f.pdf]');
1815
+ const orphanText = (content) => {
1816
+ const { payload } = healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
1817
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'orphan', content }] }] });
1818
+ return JSON.stringify(payload.messages);
1819
+ };
1820
+ const doc = orphanText([{ type: 'document', title: 'T', source: { type: 'base64', media_type: 'application/pdf', data: BLOB } }]);
1821
+ assert.ok(!doc.includes(BLOB) && doc.includes('[document: T]'), doc.slice(0, 300));
1822
+ const file = orphanText([{ type: 'input_file', filename: 'a.pdf', file_data: `data:application/pdf;base64,${BLOB}` }]);
1823
+ assert.ok(!file.includes(BLOB) && file.includes('[file: a.pdf]'), file.slice(0, 300));
1824
+ });
1825
+
1826
+ test('the image of a Responses tool output reaches the chat and Anthropic bodies', () => {
1827
+ for (const image_url of ['data:image/png;base64,AAAA', { url: 'data:image/png;base64,AAAA' }]) {
1828
+ const ir = responsesToIR({ model: 'x', input: [
1829
+ { type: 'function_call', call_id: 'c1', name: 'shot', arguments: '{}' },
1830
+ { type: 'function_call_output', call_id: 'c1', output: [{ type: 'input_image', image_url }] }] });
1831
+ const chat = JSON.stringify(irToChatBody(ir, 'gpt-4o').messages);
1832
+ assert.match(chat, /data:image\/png;base64,AAAA/);
1833
+ const anth = JSON.stringify(irToAnthropicBody(ir, 'claude-x').messages);
1834
+ assert.match(anth, /"type":"image"/);
1835
+ }
1836
+ });
1837
+
1838
+ test('healToolPairs: an orphan output and a held notice in one batch come out orphan first, then the notice', () => {
1839
+ const shape = chatShape([...callAB,
1840
+ { type: 'function_call_output', call_id: 'zz', output: 'stray' },
1841
+ { role: 'user', content: 'notice' },
1842
+ { type: 'function_call_output', call_id: 'c1', output: 'out1' }]);
1843
+ assert.deepEqual(shape.slice(-5), ['assistant', 'tool:out1', `tool:${MISSING_TOOL_RESULT}`, 'user:[Tool Result (zz)]: stray', 'user:notice']);
1844
+ });
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { readUpstreamPayloads } from '../upstream-stream.mjs';
3
+ import { readUpstreamPayloads, parseIdleMs } from '../upstream-stream.mjs';
4
4
 
5
5
  async function collect(text, widths = [1, 3, 7]) {
6
6
  const bytes = Buffer.from(text); let offset = 0, i = 0;
@@ -28,3 +28,44 @@ 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
+ });
50
+
51
+ test('parseIdleMs keeps the idle limit inside what setTimeout accepts', () => {
52
+ // Node fires a timer of a negative, infinite or > 2^31-1 delay after 1 ms.
53
+ assert.equal(parseIdleMs('3000000000'), 2147483647);
54
+ for (const bad of ['-5', 'Infinity', 'abc', '0', '0.5']) assert.equal(parseIdleMs(bad), 300000, bad);
55
+ assert.equal(parseIdleMs('120000'), 120000);
56
+ assert.equal(parseIdleMs(undefined), 300000);
57
+ });
58
+
59
+ test('the idle limit counts the gap between reads, not the whole stream', async () => {
60
+ let n = 0;
61
+ const body = new ReadableStream({
62
+ async pull(controller) {
63
+ if (n === 5) return controller.close();
64
+ await new Promise(r => setTimeout(r, 60));
65
+ controller.enqueue(Buffer.from(`data: {"n":${n++}}\n\n`));
66
+ }
67
+ });
68
+ const values = [];
69
+ for await (const v of readUpstreamPayloads({ body }, { idleMs: 200 })) values.push(v.n);
70
+ assert.deepEqual(values, [0, 1, 2, 3, 4]);
71
+ });
@@ -1,8 +1,34 @@
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
+ const DEFAULT_IDLE_MS = 300000;
7
+ const MAX_TIMER_MS = 2147483647;
8
+
9
+ // The idle limit as setTimeout takes it: Node fires a negative, infinite or larger delay after 1 ms.
10
+ export function parseIdleMs(value) {
11
+ if (value === undefined || value === null || value === '') return DEFAULT_IDLE_MS;
12
+ const n = Number(value);
13
+ if (!Number.isFinite(n) || n < 1) {
14
+ console.warn(`[llm-switcher] upstream idle limit ${JSON.stringify(value)} is not a positive number; using ${DEFAULT_IDLE_MS} ms`);
15
+ return DEFAULT_IDLE_MS;
16
+ }
17
+ return Math.min(n, MAX_TIMER_MS);
18
+ }
19
+
20
+ export const UPSTREAM_IDLE_MS = parseIdleMs(process.env.LLM_SWITCHER_UPSTREAM_IDLE_MS);
21
+
4
22
  export async function* readUpstreamPayloads(upstreamRes, state = {}) {
5
23
  const reader = upstreamRes.body.getReader();
24
+ const idleMs = state.idleMs === undefined ? UPSTREAM_IDLE_MS : parseIdleMs(state.idleMs);
25
+ const read = () => {
26
+ let timer;
27
+ const idle = new Promise((_, reject) => {
28
+ timer = setTimeout(() => reject(new Error(`upstream stream sent nothing for ${Math.round(idleMs / 1000)}s`)), idleMs);
29
+ });
30
+ return Promise.race([reader.read(), idle]).finally(() => clearTimeout(timer));
31
+ };
6
32
  const decoder = new TextDecoder();
7
33
  let buffer = '', data = [], finished = false;
8
34
  const parse = value => {
@@ -20,7 +46,7 @@ export async function* readUpstreamPayloads(upstreamRes, state = {}) {
20
46
  }
21
47
  try {
22
48
  while (true) {
23
- const chunk = await reader.read();
49
+ const chunk = await read();
24
50
  if (chunk.done) { finished = true; buffer += decoder.decode(); break; }
25
51
  buffer += decoder.decode(chunk.value, { stream: true });
26
52
  let newline;