llm-switcher 1.6.18 → 1.6.19

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.19 — 2026-10-22
4
+
5
+ - **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.
6
+
3
7
  ## Release 1.6.18 — 2026-10-22
4
8
 
5
9
  - **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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.18",
3
+ "version": "1.6.19",
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",
@@ -284,6 +284,22 @@ 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
+
287
303
  test('Responses renderer: emits output_item.done for every item with full content', () => {
288
304
  const events = [];
289
305
  const r = createResponsesStream((event, data) => events.push({ event, data }), 'm');