llm-switcher 1.6.13 → 1.6.15
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 +10 -0
- package/formats.mjs +34 -6
- package/package.json +1 -1
- package/shim.mjs +3 -1
- package/tests/formats.test.mjs +66 -0
- package/tests/shim.test.mjs +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.6.15 — 2026-10-13
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- **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.
|
|
7
|
+
- **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.
|
|
8
|
+
|
|
9
|
+
## Release 1.6.14 — 2026-10-12
|
|
10
|
+
|
|
11
|
+
- **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`.
|
|
12
|
+
|
|
3
13
|
## Release 1.6.13 — 2026-10-12
|
|
4
14
|
|
|
5
15
|
- **`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.
|
package/formats.mjs
CHANGED
|
@@ -462,7 +462,7 @@ function anthropicToIR(payload) {
|
|
|
462
462
|
const text =
|
|
463
463
|
typeof msg.content === 'string'
|
|
464
464
|
? msg.content
|
|
465
|
-
: (msg.content
|
|
465
|
+
: Array.isArray(msg.content) ? msg.content.filter(p => p && p.type === 'text').map(p => p.text).join('\n') : '';
|
|
466
466
|
if (text.trim()) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
|
|
467
467
|
return true;
|
|
468
468
|
};
|
|
@@ -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
|
-
|
|
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
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
|
|
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
|
package/tests/formats.test.mjs
CHANGED
|
@@ -112,6 +112,17 @@ test('healAnthropicPayload: native Anthropic passthrough keeps the billing heade
|
|
|
112
112
|
assert.equal(payload.system[0].text, header);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
test('anthropicToIR: a system turn whose content is neither text nor a list is dropped, not thrown', () => {
|
|
116
|
+
for (const content of [{ type: 'text', text: 'odd' }, 42, null]) {
|
|
117
|
+
const ir = anthropicToIR({
|
|
118
|
+
model: 'claude-opus-4-6', max_tokens: 10,
|
|
119
|
+
messages: [{ role: 'system', content }, { role: 'user', content: 'hi' }]
|
|
120
|
+
});
|
|
121
|
+
assert.equal(ir.system, '', JSON.stringify(content));
|
|
122
|
+
assert.deepEqual(ir.messages.map(m => m.role), ['user']);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
115
126
|
test('anthropicToIR: a system turn folds into the IR system prompt', () => {
|
|
116
127
|
const ir = anthropicToIR({
|
|
117
128
|
model: 'claude-opus-4-6', max_tokens: 10,
|
|
@@ -1688,3 +1699,58 @@ test('the placeholder strip refuses a client name that two padded tools share',
|
|
|
1688
1699
|
assert.deepEqual(JSON.parse(vocab.args(alias[0], filled)), { env: {} });
|
|
1689
1700
|
assert.equal(vocab.args('a_b', filled), filled, 'a_b declared its own _placeholder');
|
|
1690
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
|
+
});
|
package/tests/shim.test.mjs
CHANGED
|
@@ -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-'));
|