llm-switcher 1.6.18 → 1.6.20
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 +8 -0
- package/formats.mjs +37 -3
- package/package.json +1 -1
- package/tests/formats.test.mjs +55 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.6.20 — 2026-10-22
|
|
4
|
+
|
|
5
|
+
- **Earlier calls to a tool the request does not declare stay as text.** Codex runs its shell through a freeform `exec` tool and keeps sending the calls it made, while a later request may declare only `exec_command`. The model read the history as its own vocabulary and answered `exec`, a tool the caller cannot run, and the upstream refused it as `undeclared tool 'exec'`. `irToChatBody` now writes such a call as `[earlier call to \`exec\`: ...]` with its result as a user message, and leaves every call to a declared tool as a real call, so a call and its result are never split. Through llm-switcher, intact and zencore with 6 to 22 earlier `exec` calls: 10 of 12 requests refused before, 0 of 12 after.
|
|
6
|
+
|
|
7
|
+
## Release 1.6.19 — 2026-10-22
|
|
8
|
+
|
|
9
|
+
- **A streamed tool call no longer starts with `{}`.** The first chunk of a streamed call names the tool and carries no arguments. `smartToolCalls` filled in `{}` there, and the pieces that followed were glued on, giving `{}{"cmd":...}`. Codex stored that item and sent it back as history on the next turn, and zencore refused it with `upstream arguments do not match schema of tool 'exec_command'`. The first turn worked, so the failure only showed from the second turn on. Through a Codex-shaped loop of several turns, 6 of 7 turns failed before and 0 of 10 after.
|
|
10
|
+
|
|
3
11
|
## Release 1.6.18 — 2026-10-22
|
|
4
12
|
|
|
5
13
|
- **DeepSeek and Kimi on a turn with no reasoning.** `hasNativeReasoning` now matches `deepseek` and `kimi` model ids. Codex sends a continue or compaction turn with no reasoning item, and the proxy took such a model for a non-thinking one. The tool-call turns then went out with no `reasoning_content`, and DeepSeek thinking mode answered 400 "reasoning_content must be passed back". A model that is not a thinking model still gets no field, and `thinkingMode: off` still sends none.
|
package/formats.mjs
CHANGED
|
@@ -178,11 +178,17 @@ function smartToolCalls(node) {
|
|
|
178
178
|
// Follow-up OpenAI stream chunks only carry {index, function:{arguments}} (no name/id):
|
|
179
179
|
// they must still be kept, otherwise all args after the first chunk are lost.
|
|
180
180
|
if (!name && (rawArgs === undefined || rawArgs === null || rawArgs === '')) return null;
|
|
181
|
+
// The opening chunk of a streamed call names the tool and carries no arguments yet (the
|
|
182
|
+
// field is absent or null). Filling in "{}" there glues onto the pieces that follow and
|
|
183
|
+
// yields `{}{"cmd":...}`, which Codex stores and sends back as history. A chunk that has an
|
|
184
|
+
// index is a stream chunk, so its missing arguments are empty, not an empty object.
|
|
185
|
+
const opening = (rawArgs === undefined || rawArgs === null) && typeof tc.index === 'number'
|
|
186
|
+
&& tc.function && typeof tc.function === 'object';
|
|
181
187
|
return {
|
|
182
188
|
index: (typeof tc.index === 'number' ? tc.index : idx),
|
|
183
189
|
id: tc.id || fn.id || null,
|
|
184
190
|
name: name || null,
|
|
185
|
-
args: typeof rawArgs === 'string' ? rawArgs : (name ? JSON.stringify(rawArgs ?? {}) : JSON.stringify(rawArgs)),
|
|
191
|
+
args: typeof rawArgs === 'string' ? rawArgs : (opening ? '' : (name ? JSON.stringify(rawArgs ?? {}) : JSON.stringify(rawArgs))),
|
|
186
192
|
sig: tc.extra_content?.google?.thought_signature || null
|
|
187
193
|
};
|
|
188
194
|
};
|
|
@@ -1197,8 +1203,21 @@ function irToChatBody(ir, model, opts = {}) {
|
|
|
1197
1203
|
if (toolNotice) systemText = systemText ? `${systemText}\n\n${toolNotice}` : toolNotice;
|
|
1198
1204
|
if (systemText.trim()) messages.push({ role: 'system', content: systemText });
|
|
1199
1205
|
|
|
1206
|
+
// An earlier call to a tool this request no longer declares stays in the conversation as text. Codex
|
|
1207
|
+
// runs its shell through a freeform `exec` tool and keeps sending the calls it made, while a later
|
|
1208
|
+
// request may declare only `exec_command`. The model reads the history as its own vocabulary and
|
|
1209
|
+
// answers `exec` again, a tool the caller cannot run; and a strict upstream refuses the history
|
|
1210
|
+
// outright. As text the model keeps what happened and has no tool of that name to copy.
|
|
1211
|
+
const declaredNames = new Set((ir.tools || []).map(t => toolName(t.name)));
|
|
1212
|
+
const demoted = new Set();
|
|
1200
1213
|
for (const m of healToolPairs(ir.messages)) {
|
|
1201
1214
|
if (m.role === 'tool') {
|
|
1215
|
+
if (demoted.has(m.toolCallId)) {
|
|
1216
|
+
flushImages();
|
|
1217
|
+
messages.push({ role: 'user', content: `[result of the earlier call]\n${m.content ?? ''}` });
|
|
1218
|
+
if (m.images?.length) pendingImages.push({ type: 'text', text: `Images returned by tool call ${m.toolCallId}:` }, ...m.images);
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1202
1221
|
messages.push({ role: 'tool', tool_call_id: m.toolCallId, content: m.content ?? '' });
|
|
1203
1222
|
if (m.images?.length) pendingImages.push({ type: 'text', text: `Images returned by tool call ${m.toolCallId}:` }, ...m.images);
|
|
1204
1223
|
continue;
|
|
@@ -1206,8 +1225,23 @@ function irToChatBody(ir, model, opts = {}) {
|
|
|
1206
1225
|
flushImages();
|
|
1207
1226
|
const out = { role: m.role === 'assistant' ? 'assistant' : 'user' };
|
|
1208
1227
|
if (m.content !== undefined) out.content = m.content;
|
|
1209
|
-
|
|
1210
|
-
|
|
1228
|
+
let calls = m.role === 'assistant' && Array.isArray(m.toolCalls) ? m.toolCalls : [];
|
|
1229
|
+
if (calls.length && declaredNames.size) {
|
|
1230
|
+
const keep = [];
|
|
1231
|
+
const notes = [];
|
|
1232
|
+
for (const tc of calls) {
|
|
1233
|
+
if (declaredNames.has(toolName(tc.name))) { keep.push(tc); continue; }
|
|
1234
|
+
if (tc.id) demoted.add(tc.id);
|
|
1235
|
+
notes.push(`[earlier call to \`${tc.name}\`: ${stringifyArgs(tc.args).slice(0, 4000)}]`);
|
|
1236
|
+
}
|
|
1237
|
+
if (notes.length) {
|
|
1238
|
+
const text = typeof out.content === 'string' ? out.content : '';
|
|
1239
|
+
out.content = (text ? `${text}\n` : '') + notes.join('\n');
|
|
1240
|
+
calls = keep;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
if (calls.length) {
|
|
1244
|
+
out.tool_calls = calls.map(tc => {
|
|
1211
1245
|
const call = { id: tc.id, type: 'function', function: { name: toolName(tc.name), arguments: stringifyArgs(tc.args) } };
|
|
1212
1246
|
const sig = tc.sig || lookupToolSignature(tc.id);
|
|
1213
1247
|
if (sig) call.extra_content = { google: { thought_signature: sig } };
|
package/package.json
CHANGED
package/tests/formats.test.mjs
CHANGED
|
@@ -284,6 +284,61 @@ test('Anthropic renderer: think -> tool -> think -> text produces a valid event
|
|
|
284
284
|
assert.ok(events.some(e => e.data.delta?.signature === 'lsw1.sig123'), 'foreign signature is wrapped, never passed off as an Anthropic signature');
|
|
285
285
|
});
|
|
286
286
|
|
|
287
|
+
// A streamed call opens with {id, name} and no arguments. Filling "{}" in there glued onto the pieces that
|
|
288
|
+
// followed and gave `{}{"cmd":...}`, which Codex stored and sent back as history; zencore then refused it.
|
|
289
|
+
test('a streamed tool call that opens with no arguments does not start with {}', () => {
|
|
290
|
+
const norm = createUpstreamNormalizer();
|
|
291
|
+
const chunks = [
|
|
292
|
+
{ choices: [{ index: 0, delta: { role: 'assistant', tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'exec_command' } }] } }] },
|
|
293
|
+
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: '{"cmd"' } }] } }] },
|
|
294
|
+
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: ':"ls"}' } }] } }] },
|
|
295
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }
|
|
296
|
+
];
|
|
297
|
+
let args = '';
|
|
298
|
+
for (const c of chunks) for (const ev of [].concat(norm(c) || [])) for (const t of ev.tools || []) args += t.args;
|
|
299
|
+
assert.equal(args, '{"cmd":"ls"}');
|
|
300
|
+
assert.deepEqual(JSON.parse(args), { cmd: 'ls' });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Codex runs its shell through a freeform `exec` tool and keeps sending the calls it made, while a later request
|
|
304
|
+
// may declare only exec_command. The model copies the history's vocabulary and answers `exec`, which the caller
|
|
305
|
+
// cannot run. The earlier call and its result stay in the conversation as text.
|
|
306
|
+
test('irToChatBody turns earlier calls to a tool the request does not declare into text', () => {
|
|
307
|
+
const S = { type: 'string' };
|
|
308
|
+
const ir = responsesToIR({ model: 'x', tools: [
|
|
309
|
+
{ type: 'function', name: 'exec_command', description: 'run', parameters: { type: 'object', properties: { cmd: S }, required: ['cmd'] } }
|
|
310
|
+
], input: [
|
|
311
|
+
{ type: 'message', role: 'user', content: 'look' },
|
|
312
|
+
{ type: 'custom_tool_call', call_id: 'c1', name: 'exec', input: 'text(await tools.exec_command({cmd:"ls"}));' },
|
|
313
|
+
{ type: 'custom_tool_call_output', call_id: 'c1', output: 'a b' },
|
|
314
|
+
{ type: 'function_call', call_id: 'f1', name: 'exec_command', arguments: '{"cmd":"pwd"}' },
|
|
315
|
+
{ type: 'function_call_output', call_id: 'f1', output: '/repo' },
|
|
316
|
+
{ type: 'message', role: 'user', content: 'again' }
|
|
317
|
+
] });
|
|
318
|
+
const msgs = irToChatBody(ir, 'zencore/deepseek-v4.1-flash-free', { thinkingMode: 'auto' }).messages;
|
|
319
|
+
const calls = msgs.flatMap(m => m.tool_calls || []);
|
|
320
|
+
assert.deepEqual(calls.map(c => c.function.name), ['exec_command']);
|
|
321
|
+
const note = msgs.find(m => m.role === 'assistant' && !m.tool_calls);
|
|
322
|
+
assert.match(note.content, /^\[earlier call to `exec`: .*tools\.exec_command/);
|
|
323
|
+
assert.ok(msgs.some(m => m.role === 'user' && m.content === '[result of the earlier call]\na b'));
|
|
324
|
+
// The declared call keeps its result as a real tool message, so the pair is never split.
|
|
325
|
+
assert.deepEqual(msgs.filter(m => m.role === 'tool').map(m => m.tool_call_id), ['f1']);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('irToChatBody leaves a history of declared tools alone', () => {
|
|
329
|
+
const ir = responsesToIR({ model: 'x', tools: [
|
|
330
|
+
{ type: 'function', name: 'exec_command', description: 'run', parameters: { type: 'object', properties: { cmd: { type: 'string' } } } }
|
|
331
|
+
], input: [
|
|
332
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
333
|
+
{ type: 'function_call', call_id: 'f1', name: 'exec_command', arguments: '{"cmd":"pwd"}' },
|
|
334
|
+
{ type: 'function_call_output', call_id: 'f1', output: '/repo' }
|
|
335
|
+
] });
|
|
336
|
+
const msgs = irToChatBody(ir, 'gpt-5', { thinkingMode: 'auto' }).messages;
|
|
337
|
+
assert.equal(msgs.filter(m => m.tool_calls).length, 1);
|
|
338
|
+
assert.equal(msgs.filter(m => m.role === 'tool').length, 1);
|
|
339
|
+
assert.ok(!msgs.some(m => typeof m.content === 'string' && m.content.includes('earlier call')));
|
|
340
|
+
});
|
|
341
|
+
|
|
287
342
|
test('Responses renderer: emits output_item.done for every item with full content', () => {
|
|
288
343
|
const events = [];
|
|
289
344
|
const r = createResponsesStream((event, data) => events.push({ event, data }), 'm');
|