llm-switcher 1.6.19 → 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 CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## Release 1.6.19 — 2026-10-22
4
8
 
5
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.
package/formats.mjs CHANGED
@@ -1203,8 +1203,21 @@ function irToChatBody(ir, model, opts = {}) {
1203
1203
  if (toolNotice) systemText = systemText ? `${systemText}\n\n${toolNotice}` : toolNotice;
1204
1204
  if (systemText.trim()) messages.push({ role: 'system', content: systemText });
1205
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();
1206
1213
  for (const m of healToolPairs(ir.messages)) {
1207
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
+ }
1208
1221
  messages.push({ role: 'tool', tool_call_id: m.toolCallId, content: m.content ?? '' });
1209
1222
  if (m.images?.length) pendingImages.push({ type: 'text', text: `Images returned by tool call ${m.toolCallId}:` }, ...m.images);
1210
1223
  continue;
@@ -1212,8 +1225,23 @@ function irToChatBody(ir, model, opts = {}) {
1212
1225
  flushImages();
1213
1226
  const out = { role: m.role === 'assistant' ? 'assistant' : 'user' };
1214
1227
  if (m.content !== undefined) out.content = m.content;
1215
- if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length) {
1216
- out.tool_calls = m.toolCalls.map(tc => {
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 => {
1217
1245
  const call = { id: tc.id, type: 'function', function: { name: toolName(tc.name), arguments: stringifyArgs(tc.args) } };
1218
1246
  const sig = tc.sig || lookupToolSignature(tc.id);
1219
1247
  if (sig) call.extra_content = { google: { thought_signature: sig } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.19",
3
+ "version": "1.6.20",
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",
@@ -300,6 +300,45 @@ test('a streamed tool call that opens with no arguments does not start with {}',
300
300
  assert.deepEqual(JSON.parse(args), { cmd: 'ls' });
301
301
  });
302
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
+
303
342
  test('Responses renderer: emits output_item.done for every item with full content', () => {
304
343
  const events = [];
305
344
  const r = createResponsesStream((event, data) => events.push({ event, data }), 'm');