llm-switcher 1.6.17 → 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 +9 -0
- package/formats.mjs +11 -1
- package/package.json +1 -1
- package/preflight-cache.json +11 -0
- package/proxy.mjs +2 -0
- package/tests/formats.test.mjs +36 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
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
|
+
|
|
7
|
+
## Release 1.6.18 — 2026-10-22
|
|
8
|
+
|
|
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.
|
|
10
|
+
- **Tool names in the debug log.** With `debug: true`, the proxy now logs the names of the tools it declares upstream and the `tool_choice`, on both the HTTP and the WebSocket route. Before, the log held the first 300 to 500 characters of the body, which never reached `tools`. This is how to tell whether an upstream "undeclared tool" error comes from a missing declaration.
|
|
11
|
+
|
|
3
12
|
## Release 1.6.17 — 2026-10-13
|
|
4
13
|
|
|
5
14
|
- **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]`.
|
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
|
};
|
|
@@ -1049,7 +1055,11 @@ function withUpstreamToolNames(ir, names) {
|
|
|
1049
1055
|
function hasNativeReasoning(model) {
|
|
1050
1056
|
// model IDs change often, but all current Opus variants support reasoning.
|
|
1051
1057
|
const m = String(model || '').toLowerCase();
|
|
1058
|
+
// DeepSeek and Kimi run in thinking mode by default and refuse the next tool step when an earlier
|
|
1059
|
+
// tool-call turn lacks reasoning_content, even if the client (Codex on a continue/compaction turn)
|
|
1060
|
+
// sent no reasoning item with the request.
|
|
1052
1061
|
return m.includes('thinking') || m.includes('reasoning') || m.includes('reasoner') || m.includes('opus')
|
|
1062
|
+
|| m.includes('deepseek') || m.includes('kimi')
|
|
1053
1063
|
|| /(^|[/_.:-])(o[134]|r1|qwq)([/_.:-]|$)/.test(m);
|
|
1054
1064
|
}
|
|
1055
1065
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"claude": {
|
|
3
|
+
"cacheKey": "claude-intact:zencore/space-bunny-free,zencore/muse-spark-1.3-contributor-free",
|
|
4
|
+
"time": 1792631963371,
|
|
5
|
+
"lines": [
|
|
6
|
+
"[llm-switcher] claude -> claude-intact | 127.0.0.1:20142 | zencore/space-bunny-free",
|
|
7
|
+
"[llm-switcher:preflight] ⚠️ CẢNH BÁO: Model đã setup không còn khả dụng trên provider: 'zencore/space-bunny-free' [slot sonnet], 'zencore/muse-spark-1.3-contributor-free' [slot haiku].",
|
|
8
|
+
"[llm-switcher:preflight] Gợi ý: Chạy 'switch ui' để cập nhật lại model hợp lệ."
|
|
9
|
+
]
|
|
10
|
+
}
|
|
11
|
+
}
|
package/proxy.mjs
CHANGED
|
@@ -1320,6 +1320,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1320
1320
|
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
1321
1321
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
1322
1322
|
debugLog(`[${profileKey}] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 500));
|
|
1323
|
+
debugLog(`[${profileKey}] tools declared upstream:`, JSON.stringify((upBody.tools || []).map(t => t.function?.name ?? t.name ?? t.type)), 'tool_choice:', JSON.stringify(upBody.tool_choice ?? null));
|
|
1323
1324
|
|
|
1324
1325
|
let upstreamRes;
|
|
1325
1326
|
try {
|
|
@@ -2592,6 +2593,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
|
|
|
2592
2593
|
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
2593
2594
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
2594
2595
|
debugLog(`[${profileKey}:ws] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 300));
|
|
2596
|
+
debugLog(`[${profileKey}:ws] tools declared upstream:`, JSON.stringify((upBody.tools || []).map(t => t.function?.name ?? t.name ?? t.type)), 'tool_choice:', JSON.stringify(upBody.tool_choice ?? null));
|
|
2595
2597
|
|
|
2596
2598
|
let upstreamRes;
|
|
2597
2599
|
try {
|
package/tests/formats.test.mjs
CHANGED
|
@@ -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');
|
|
@@ -1263,6 +1279,26 @@ test('irToChatBody replays thinking as reasoning_content on tool-call turns', ()
|
|
|
1263
1279
|
assert.equal(rb.messages.find(m => m.tool_calls).reasoning_content, 'Plan: run ls');
|
|
1264
1280
|
});
|
|
1265
1281
|
|
|
1282
|
+
// Codex continue/compaction turns carry no reasoning item; DeepSeek still answers 400 "reasoning_content must be passed back".
|
|
1283
|
+
test('irToChatBody auto mode replays reasoning_content for DeepSeek/Kimi even when the request has no reasoning', () => {
|
|
1284
|
+
const ir = responsesToIR({ model: 'x', input: [
|
|
1285
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
1286
|
+
{ type: 'function_call', call_id: 'c1', name: 'exec', arguments: '{}' },
|
|
1287
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'ok' },
|
|
1288
|
+
{ type: 'message', role: 'user', content: 'continue' }
|
|
1289
|
+
] });
|
|
1290
|
+
for (const model of ['zencore/deepseek-v4.1-flash-free', 'buddycore/deepseek-v4.1-flash', 'zencore/kimi-k2.7']) {
|
|
1291
|
+
const call = irToChatBody(ir, model, { thinkingMode: 'auto' }).messages.find(m => m.tool_calls);
|
|
1292
|
+
assert.equal(call.reasoning_content, '', model);
|
|
1293
|
+
}
|
|
1294
|
+
for (const model of ['gpt-5', 'zencore/glm-5.3-flash-free']) {
|
|
1295
|
+
const body = irToChatBody(ir, model, { thinkingMode: 'auto' });
|
|
1296
|
+
assert.ok(body.messages.every(m => !('reasoning_content' in m)), model);
|
|
1297
|
+
}
|
|
1298
|
+
const off = irToChatBody(ir, 'zencore/deepseek-v4.1-flash-free', { thinkingMode: 'off' });
|
|
1299
|
+
assert.ok(off.messages.every(m => !('reasoning_content' in m)));
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1266
1302
|
// Strict OpenAI-compatible servers may refuse the extra key, so native mode sends it only on the profile opt-in.
|
|
1267
1303
|
test('irToChatBody native mode sends no reasoning_content without the profile opt-in', () => {
|
|
1268
1304
|
const ir = anthropicToIR({ model: 'x', max_tokens: 8000, thinking: { type: 'enabled', budget_tokens: 4000 }, messages: [
|