llm-switcher 1.6.8 → 1.6.10

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/formats.mjs CHANGED
@@ -39,6 +39,8 @@ function canonFinish(raw) {
39
39
  if (['length', 'max_tokens', 'max_output_tokens'].includes(r)) return 'length';
40
40
  if (['tool_calls', 'tool_call', 'function_call', 'function_calls', 'tool_use'].includes(r)) return 'tool_calls';
41
41
  if (['content_filter', 'safety', 'recitation', 'language', 'blocklist', 'prohibited_content', 'spii'].includes(r)) return 'content_filter';
42
+ // Gemini gave up on a tool call: as 'stop' the client would see a silent, empty turn.
43
+ if (r === 'malformed_function_call') return 'error';
42
44
  return 'stop';
43
45
  }
44
46
 
@@ -254,13 +256,18 @@ function smartDelta(choice) {
254
256
  }
255
257
 
256
258
  const SCHEMA_MAPS = new Set(['properties', 'patternProperties', '$defs', 'definitions']);
259
+ // Instance data, not schema: null is a real value here, and nothing inside may be rewritten.
260
+ const SCHEMA_VALUES = new Set(['default', 'const', 'enum', 'examples', 'example']);
261
+ // Lookahead and lookbehind; RE2 upstreams refuse the whole request ("invalid regex").
262
+ const LOOKAROUND_RE = /\(\?<?[=!]/;
257
263
 
258
264
  function sanitizeJsonSchema(schema) {
259
265
  if (schema === null || schema === undefined) return { type: 'object', properties: {} };
260
266
  return sanitizeSchemaNode(schema);
261
267
  }
262
268
 
263
- // Inside a schema 0, false, "" and null are values (minimum: 0, additionalProperties: false), not a missing schema.
269
+ // Inside a schema 0, false and "" are values (minimum: 0, additionalProperties: false), not a missing schema.
270
+ // A null keyword is dropped (strict upstreams answer "null is not of type array"), except as instance data.
264
271
  function sanitizeSchemaNode(schema) {
265
272
  if (schema === null || typeof schema !== 'object') return schema;
266
273
  if (Array.isArray(schema)) return schema.map(sanitizeSchemaNode);
@@ -268,6 +275,9 @@ function sanitizeSchemaNode(schema) {
268
275
  for (const [k, v] of Object.entries(schema)) {
269
276
  if (k === '$schema' || k === 'cache_control' || k === 'encrypted') continue;
270
277
  if (k === 'format' && ['uri', 'uri-reference'].includes(v)) continue;
278
+ if (SCHEMA_VALUES.has(k)) { clean[k] = v; continue; }
279
+ if (v === null) continue;
280
+ if (k === 'pattern' && typeof v === 'string' && LOOKAROUND_RE.test(v)) continue;
271
281
  // A name map: each value is a schema, the map itself is not. A parameter may be named "properties".
272
282
  if (SCHEMA_MAPS.has(k) && v && typeof v === 'object' && !Array.isArray(v)) {
273
283
  clean[k] = Object.fromEntries(Object.entries(v).map(([name, sub]) => [name, sanitizeSchemaNode(sub)]));
@@ -394,6 +404,7 @@ function parseArgs(v) {
394
404
  if (v === undefined || v === null) return {};
395
405
  if (typeof v === 'object') return v;
396
406
  if (typeof v === 'string') {
407
+ if (!v.trim()) return {};
397
408
  try { return JSON.parse(v); } catch { return { raw: v }; }
398
409
  }
399
410
  return {};
@@ -454,10 +465,13 @@ function anthropicToIR(payload) {
454
465
  if (Array.isArray(msg.content)) {
455
466
  const parts = [];
456
467
  const toolCalls = [];
468
+ let reasoning = '';
457
469
  for (const part of msg.content) {
458
470
  if (!part) continue;
459
471
  if (part.type === 'text') {
460
472
  if (part.text) parts.push({ type: 'text', text: part.text });
473
+ } else if (part.type === 'thinking' && typeof part.thinking === 'string') {
474
+ reasoning += part.thinking;
461
475
  } else if (part.type === 'image' && part.source?.type === 'base64') {
462
476
  const mimeType = part.source.media_type || 'image/jpeg';
463
477
  parts.push({ type: 'image_url', image_url: { url: `data:${mimeType};base64,${part.source.data}` } });
@@ -498,6 +512,7 @@ function anthropicToIR(payload) {
498
512
  out.content = parts.every(p => p.type === 'text') ? parts.map(p => p.text).join('\n') : parts;
499
513
  }
500
514
  if (toolCalls.length) out.toolCalls = toolCalls;
515
+ if (reasoning && msg.role === 'assistant') out.reasoning = reasoning;
501
516
  if (out.content !== undefined || out.toolCalls) ir.messages.push(out);
502
517
  } else if (msg.content) {
503
518
  // Fallback for payloads that preprocessing/compression tools mangled into an object
@@ -628,9 +643,10 @@ const LOCAL_SHELL_PARAMS = {
628
643
  required: ['command']
629
644
  };
630
645
 
646
+ // A plain cut at 64 would give two long namespaced tools one name; the hashed form keeps them apart.
631
647
  function responsesToolName(namespace, name) {
632
648
  const raw = namespace ? `${namespace}__${name}` : String(name || '');
633
- return raw.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64);
649
+ return raw ? sanitizeToolName(raw) : '';
634
650
  }
635
651
 
636
652
  function customToolDescription(t) {
@@ -677,12 +693,19 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
677
693
  if (text) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
678
694
  };
679
695
 
696
+ // A reasoning item precedes the assistant output it belongs to.
697
+ let reasoning = '';
698
+ const takeReasoning = (m) => {
699
+ if (reasoning && m?.role === 'assistant') { m.reasoning = (m.reasoning || '') + reasoning; reasoning = ''; }
700
+ };
701
+
680
702
  // Parallel tool calls arrive as consecutive items -> merge into 1 assistant turn,
681
703
  // otherwise OpenAI Chat/Anthropic will error that tool_calls aren't answered adjacently.
682
704
  const pushCall = (call) => {
683
705
  const last = ir.messages[ir.messages.length - 1];
684
706
  if (last && last.role === 'assistant') last.toolCalls = [...(last.toolCalls || []), call];
685
707
  else ir.messages.push({ role: 'assistant', toolCalls: [call] });
708
+ takeReasoning(ir.messages[ir.messages.length - 1]);
686
709
  };
687
710
 
688
711
  // Responses Lite sends the tools as an input item, not in payload.tools.
@@ -721,6 +744,10 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
721
744
  } else {
722
745
  ir.messages.push({ role, content: parts });
723
746
  }
747
+ if (role === 'assistant') takeReasoning(ir.messages[ir.messages.length - 1]);
748
+ } else if (item.type === 'reasoning') {
749
+ const texts = (Array.isArray(item.summary) ? item.summary : []).map(x => x?.text).filter(t => typeof t === 'string' && t);
750
+ reasoning += texts.join('\n');
724
751
  } else if (item.type === 'function_call') {
725
752
  pushCall({ id: item.call_id || item.id || null, name: responsesToolName(item.namespace, item.name), args: parseArgs(item.arguments) });
726
753
  } else if (item.type === 'custom_tool_call') {
@@ -788,7 +815,9 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
788
815
  ir.thinking = thinkingFromReasoningParam(payload.reasoning);
789
816
  }
790
817
  ir.responseFormat = responseFormatIR(payload.text?.format);
791
- if (ir.compaction) { ir.tools = []; ir.toolChoice = 'none'; ir.responseFormat = null; }
818
+ // Tools stay declared: Anthropic refuses tool_use history without them, and OpenAI refuses
819
+ // tool_choice without tools. 'none' alone keeps the summary turn free of calls.
820
+ if (ir.compaction) { ir.toolChoice = ir.tools.length ? 'none' : null; ir.responseFormat = null; }
792
821
  return ir;
793
822
  }
794
823
 
@@ -1072,6 +1101,7 @@ function normThinkingEffort(effort) {
1072
1101
  // - 'native' (genuine OpenAI / strict OpenAI-compatible servers): only send `reasoning_effort` when
1073
1102
  // the client requests thinking, don't touch the system prompt, use `max_completion_tokens`.
1074
1103
  // - 'off' : never send reasoning params, never inject prompts.
1104
+ // opts.replayReasoning: profile opt-in; native mode sends reasoning_content on tool-call turns only when it is true.
1075
1105
  function irToChatBody(ir, model, opts = {}) {
1076
1106
  const names = toolNameMapForIR(ir);
1077
1107
  const toolName = names.name;
@@ -1085,6 +1115,8 @@ function irToChatBody(ir, model, opts = {}) {
1085
1115
  const wantsThinking = mode === 'off' ? false
1086
1116
  : mode === 'native' ? clientWantsThinking
1087
1117
  : Boolean(clientWantsThinking || (modelIsThinking && (!ir.thinking || ir.thinking.type !== 'disabled')));
1118
+ // Strict OpenAI-compatible servers may refuse an unknown message key, so native mode needs the profile flag.
1119
+ const replayReasoning = wantsThinking && (mode === 'auto' || opts.replayReasoning === true);
1088
1120
 
1089
1121
  let systemText = ir.system || '';
1090
1122
  if (mode === 'auto' && wantsThinking && !modelIsThinking && !String(model).toLowerCase().includes('gemini')) {
@@ -1120,6 +1152,8 @@ function irToChatBody(ir, model, opts = {}) {
1120
1152
  return call;
1121
1153
  });
1122
1154
  if (out.content === undefined || out.content === '') out.content = null;
1155
+ // DeepSeek/Kimi thinking mode refuse the next tool step without it; an empty string is accepted.
1156
+ if (replayReasoning) out.reasoning_content = m.reasoning ?? '';
1123
1157
  }
1124
1158
  if (out.content === undefined) out.content = '';
1125
1159
  messages.push(out);
@@ -1232,7 +1266,7 @@ function irToAnthropicBody(ir, model, opts = {}) {
1232
1266
  if (m.role === 'tool') {
1233
1267
  messages.push({
1234
1268
  role: 'user',
1235
- content: [{ type: 'tool_result', tool_use_id: m.toolCallId,
1269
+ content: [{ type: 'tool_result', tool_use_id: safeToolId(m.toolCallId),
1236
1270
  content: m.images?.length ? [...contentToAnthropicBlocks(m.content), ...contentToAnthropicBlocks(m.images)] : m.content ?? '' }]
1237
1271
  });
1238
1272
  continue;
@@ -1240,7 +1274,7 @@ function irToAnthropicBody(ir, model, opts = {}) {
1240
1274
  if (m.role === 'assistant') {
1241
1275
  const blocks = contentToAnthropicBlocks(m.content).filter(b => b.type === 'text');
1242
1276
  for (const tc of (m.toolCalls || [])) {
1243
- blocks.push({ type: 'tool_use', id: tc.id, name: toolName(tc.name), input: parseArgs(tc.args) });
1277
+ blocks.push({ type: 'tool_use', id: safeToolId(tc.id), name: toolName(tc.name), input: parseArgs(tc.args) });
1244
1278
  }
1245
1279
  if (blocks.length) messages.push({ role: 'assistant', content: blocks });
1246
1280
  continue;
@@ -1327,6 +1361,8 @@ function irToAnthropicBody(ir, model, opts = {}) {
1327
1361
  body.thinking = { type: 'enabled', budget_tokens: Math.min(budget, body.max_tokens - 1) };
1328
1362
  }
1329
1363
  }
1364
+ // This route never emits signed thinking blocks, so an open tool loop cannot keep thinking on.
1365
+ if (body.thinking && endsInUnsignedToolLoop(body.messages)) delete body.thinking;
1330
1366
  if (body.thinking) {
1331
1367
  // When thinking is on, Anthropic rejects temperature != 1, top_k, or top_p < 0.95.
1332
1368
  if (body.temperature !== undefined && body.temperature !== 1) delete body.temperature;
@@ -1346,6 +1382,12 @@ function dataUrlToInlineData(url) {
1346
1382
  return { inlineData: { mimeType: m[1], data: m[2] } };
1347
1383
  }
1348
1384
 
1385
+ function hasDollarKey(v) {
1386
+ if (Array.isArray(v)) return v.some(hasDollarKey);
1387
+ if (!v || typeof v !== 'object') return false;
1388
+ return Object.entries(v).some(([k, x]) => k.startsWith('$') || hasDollarKey(x));
1389
+ }
1390
+
1349
1391
  // IR -> Vertex generateContent body.
1350
1392
  function irToVertexBody(ir, model, opts = {}) {
1351
1393
  const names = toolNameMapForIR(ir, { allowHyphens: false });
@@ -1395,6 +1437,8 @@ function irToVertexBody(ir, model, opts = {}) {
1395
1437
  const parsed = typeof m.content === 'string' ? JSON.parse(m.content) : m.content;
1396
1438
  // functionResponse.response must be an object (Struct), not an array/primitive.
1397
1439
  resp = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : { result: parsed ?? '' };
1440
+ // Gemini refuses a Struct key that starts with `$` (`$ref` in a schema file), so send the text.
1441
+ if (hasDollarKey(resp)) resp = { result: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) };
1398
1442
  } catch { resp = { result: m.content ?? '' }; }
1399
1443
  // Gemini API & Vertex only accept roles 'user' | 'model' (functionResponse lives in the 'user' role).
1400
1444
  push('user', [{ functionResponse: { name: toolName(m.name || 'tool'), response: resp } }]);
@@ -1418,6 +1462,9 @@ function irToVertexBody(ir, model, opts = {}) {
1418
1462
  push('user', textPartsOf(m.content));
1419
1463
  }
1420
1464
  flushImages();
1465
+ // Gemini refuses a request that ends on a model turn (an assistant prefill). A user turn is
1466
+ // appended rather than the prefill dropped, so no text the client sent is lost.
1467
+ if (contents[contents.length - 1]?.role === 'model') contents.push({ role: 'user', parts: [{ text: 'Continue.' }] });
1421
1468
 
1422
1469
  const body = { contents };
1423
1470
  if (ir.system && ir.system.trim()) {
@@ -1509,15 +1556,21 @@ function collapseNullableUnion(branches) {
1509
1556
  return { schema: rest[0], nullable: rest.length !== branches.length };
1510
1557
  }
1511
1558
 
1559
+ // Gemini refuses a nested OBJECT with no properties (a free-form map such as `env`). One optional
1560
+ // key keeps the type an object; the root of a tool with no arguments may stay empty.
1561
+ const GEMINI_PLACEHOLDER_PROPERTIES = { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } };
1562
+
1512
1563
  function toGeminiSchema(schema, root, seen) {
1564
+ const nested = Boolean(root);
1565
+ const emptyProperties = () => (nested ? { ...GEMINI_PLACEHOLDER_PROPERTIES } : {});
1513
1566
  // Shorthand left by some MCP servers: a bare "object"/"string" where a Schema belongs.
1514
1567
  // Vertex rejects the raw string with INVALID_ARGUMENT, so expand it.
1515
1568
  if (typeof schema === 'string') {
1516
- return schema === 'object' ? { type: 'object', properties: {} } : { type: schema };
1569
+ return schema === 'object' ? { type: 'object', properties: emptyProperties() } : { type: schema };
1517
1570
  }
1518
1571
  if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema;
1519
1572
  // The same hole as the string shorthand above: an empty schema has no type Vertex can read.
1520
- if (Object.keys(schema).length === 0) return { type: 'object', properties: {} };
1573
+ if (Object.keys(schema).length === 0) return { type: 'object', properties: emptyProperties() };
1521
1574
  root = root || schema;
1522
1575
  seen = seen || new Set();
1523
1576
  if (seen.has(schema)) return {};
@@ -1568,8 +1621,13 @@ function toGeminiSchema(schema, root, seen) {
1568
1621
  }
1569
1622
  }
1570
1623
  if (!out.type && out.properties) out.type = 'object';
1571
- if (out.type === 'object' && !out.properties) out.properties = {};
1572
- if (out.enum && !out.type) out.type = 'string';
1624
+ if (out.type === 'object' && !(out.properties && Object.keys(out.properties).length)) out.properties = emptyProperties();
1625
+ if (out.enum && !out.type) {
1626
+ // Gemini enum values are strings only; an untyped `const: true` would otherwise stay boolean.
1627
+ out.type = 'string';
1628
+ out.enum = out.enum.filter(x => x !== null).map(String);
1629
+ if (!out.enum.length) delete out.enum;
1630
+ }
1573
1631
  if (out.enum && out.type !== 'string') {
1574
1632
  // Gemini supports enum for strings only. Keep the constraint as text for the model.
1575
1633
  const values = (schema.enum || (Object.hasOwn(schema, 'const') ? [schema.const] : [])).filter(x => x !== null);
@@ -1629,6 +1687,11 @@ function toAnthropicBlocks(content) {
1629
1687
  return Array.isArray(content) ? content.filter(Boolean) : [];
1630
1688
  }
1631
1689
 
1690
+ // Anthropic refuses a text block with no visible text ("text content blocks must be non-empty").
1691
+ function isBlankText(b) {
1692
+ return b?.type === 'text' && !(typeof b.text === 'string' && b.text.trim());
1693
+ }
1694
+
1632
1695
  function toolResultText(block) {
1633
1696
  const c = block.content;
1634
1697
  const text = typeof c === 'string' ? c
@@ -1637,15 +1700,35 @@ function toolResultText(block) {
1637
1700
  return `[Tool Result${block.tool_use_id ? ` (${block.tool_use_id})` : ''}]: ${text}`;
1638
1701
  }
1639
1702
 
1703
+ // Anthropic: "a final assistant message must start with a thinking block" when thinking is on mid tool loop.
1704
+ function endsInUnsignedToolLoop(messages) {
1705
+ const lastUser = messages[messages.length - 1];
1706
+ const lastAssistant = messages[messages.length - 2];
1707
+ const inToolLoop = lastUser?.role === 'user' && toAnthropicBlocks(lastUser.content).some(b => b.type === 'tool_result');
1708
+ const first = lastAssistant?.role === 'assistant' ? toAnthropicBlocks(lastAssistant.content)[0] : null;
1709
+ return Boolean(inToolLoop && first && first.type !== 'thinking' && first.type !== 'redacted_thinking');
1710
+ }
1711
+
1640
1712
  function healAnthropicPayload(payload) {
1641
1713
  const notes = [];
1642
1714
  if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
1643
1715
  const src = payload.messages.filter(m => m && (m.role === 'user' || m.role === 'assistant'));
1644
1716
  let changed = src.length !== payload.messages.length;
1645
1717
 
1646
- // Step 0: drop thinking blocks with fake signatures; merge adjacent user turns (so a tool_result split
1647
- // into a later turn can still be matched with its tool_use).
1718
+ let system = payload.system;
1719
+ if (Array.isArray(system) && system.some(isBlankText)) {
1720
+ system = system.filter(b => !isBlankText(b));
1721
+ changed = true;
1722
+ notes.push('dropped empty text blocks');
1723
+ }
1724
+ const dropSystem = Array.isArray(system) ? !system.length : typeof system === 'string' && !system.trim();
1725
+ if (dropSystem) changed = true;
1726
+
1727
+ // Step 0: drop thinking blocks with fake signatures and empty text blocks; rewrite foreign tool ids;
1728
+ // merge adjacent user turns (so a tool_result split into a later turn can still be matched with its tool_use).
1648
1729
  const msgs = [];
1730
+ const emptyTurn = () => ({ role: 'user', content: [{ type: 'text', text: '(empty)' }] });
1731
+ let emptyUser = false;
1649
1732
  for (const m of src) {
1650
1733
  let content = m.content;
1651
1734
  if (m.role === 'assistant' && Array.isArray(content)) {
@@ -1655,11 +1738,31 @@ function healAnthropicPayload(payload) {
1655
1738
  notes.push('stripped placeholder-signed thinking blocks');
1656
1739
  content = kept;
1657
1740
  }
1658
- if (!content.length) {
1741
+ }
1742
+ if (Array.isArray(content)) {
1743
+ const kept = [];
1744
+ let renamed = false;
1745
+ for (const b of content) {
1746
+ if (isBlankText(b)) continue;
1747
+ if (b?.type === 'tool_use' && b.id && safeToolId(b.id) !== b.id) { kept.push({ ...b, id: safeToolId(b.id) }); renamed = true; }
1748
+ else if (b?.type === 'tool_result' && b.tool_use_id && safeToolId(b.tool_use_id) !== b.tool_use_id) { kept.push({ ...b, tool_use_id: safeToolId(b.tool_use_id) }); renamed = true; }
1749
+ else kept.push(b);
1750
+ }
1751
+ if (kept.length !== content.length) notes.push('dropped empty text blocks');
1752
+ if (renamed) notes.push('sanitized foreign tool ids');
1753
+ if (renamed || kept.length !== content.length) {
1659
1754
  changed = true;
1660
- continue;
1755
+ content = kept;
1661
1756
  }
1662
1757
  }
1758
+ if (!toAnthropicBlocks(typeof content === 'string' ? content.trim() : content).length) {
1759
+ // An emptied user turn still holds its place between two assistant turns, or at the end.
1760
+ changed = true;
1761
+ if (m.role === 'user') emptyUser = true;
1762
+ continue;
1763
+ }
1764
+ if (emptyUser && m.role === 'assistant' && msgs[msgs.length - 1]?.role !== 'user') msgs.push(emptyTurn());
1765
+ emptyUser = false;
1663
1766
  const last = msgs[msgs.length - 1];
1664
1767
  if (m.role === 'user' && last && last.role === 'user') {
1665
1768
  last.content = [...toAnthropicBlocks(last.content), ...toAnthropicBlocks(content)];
@@ -1668,6 +1771,42 @@ function healAnthropicPayload(payload) {
1668
1771
  }
1669
1772
  msgs.push({ ...m, content });
1670
1773
  }
1774
+ if (emptyUser && msgs[msgs.length - 1]?.role !== 'user') msgs.push(emptyTurn());
1775
+
1776
+ // Step 0b: an upstream that reuses an id (Kimi `functions.Bash:0`) leaves one id on two tool_use
1777
+ // blocks, which Anthropic refuses. A later use gets a suffix, and so does its result in the next turn.
1778
+ const allIds = new Set(msgs.flatMap(m => toAnthropicBlocks(m.content).filter(b => b.type === 'tool_use' && b.id).map(b => b.id)));
1779
+ const usedIds = new Set();
1780
+ for (let i = 0; i < msgs.length; i++) {
1781
+ if (msgs[i].role !== 'assistant' || !Array.isArray(msgs[i].content)) continue;
1782
+ const resultIds = new Map();
1783
+ let renamed = false;
1784
+ const content = msgs[i].content.map(b => {
1785
+ if (b?.type !== 'tool_use' || !b.id) return b;
1786
+ let id = b.id;
1787
+ if (usedIds.has(id)) {
1788
+ let n = 2;
1789
+ while (allIds.has(`${b.id}_${n}`)) n++;
1790
+ id = `${b.id}_${n}`;
1791
+ allIds.add(id);
1792
+ renamed = true;
1793
+ }
1794
+ usedIds.add(id);
1795
+ resultIds.set(b.id, [...(resultIds.get(b.id) || []), id]);
1796
+ return id === b.id ? b : { ...b, id };
1797
+ });
1798
+ if (!renamed) continue;
1799
+ msgs[i] = { ...msgs[i], content };
1800
+ const next = msgs[i + 1];
1801
+ if (next?.role === 'user' && Array.isArray(next.content)) {
1802
+ msgs[i + 1] = { ...next, content: next.content.map(b => {
1803
+ const id = b?.type === 'tool_result' ? resultIds.get(b.tool_use_id)?.shift() : undefined;
1804
+ return id && id !== b.tool_use_id ? { ...b, tool_use_id: id } : b;
1805
+ }) };
1806
+ }
1807
+ changed = true;
1808
+ notes.push('deduplicated repeated tool_use ids');
1809
+ }
1671
1810
 
1672
1811
  // Steps 1-3: pair up tool_use / tool_result.
1673
1812
  const out = [];
@@ -1723,15 +1862,13 @@ function healAnthropicPayload(payload) {
1723
1862
  }
1724
1863
 
1725
1864
  let body = { ...payload, messages: out };
1865
+ if (dropSystem) delete body.system;
1866
+ else if (system !== payload.system) body.system = system;
1726
1867
 
1727
1868
  // Step 5: thinking + an in-progress tool loop whose last assistant turn doesn't open with thinking.
1728
1869
  const thinkingOn = payload.thinking && payload.thinking.type && payload.thinking.type !== 'disabled';
1729
1870
  if (thinkingOn) {
1730
- const lastUser = out[out.length - 1];
1731
- const lastAssistant = out[out.length - 2];
1732
- const inToolLoop = lastUser?.role === 'user' && toAnthropicBlocks(lastUser.content).some(b => b.type === 'tool_result');
1733
- const first = lastAssistant?.role === 'assistant' ? toAnthropicBlocks(lastAssistant.content)[0] : null;
1734
- if (inToolLoop && first && first.type !== 'thinking' && first.type !== 'redacted_thinking') {
1871
+ if (endsInUnsignedToolLoop(out)) {
1735
1872
  body = { ...body };
1736
1873
  delete body.thinking;
1737
1874
  changed = true;
@@ -1887,6 +2024,7 @@ function normalizeUpstream(parsed, outFormat) {
1887
2024
  for (const tc of smartToolCalls(node)) ev.tools.push(chunkToolFull(tc));
1888
2025
  const f = smartFinish(parsed, choice);
1889
2026
  if (f) ev.finish = canonFinish(f);
2027
+ if (ev.finish === 'error') { ev.finish = null; ev.error = `Upstream finished with ${f}`; }
1890
2028
  return ev;
1891
2029
  }
1892
2030
 
@@ -2054,6 +2192,17 @@ function rand(n = 6) {
2054
2192
  return s.slice(0, n);
2055
2193
  }
2056
2194
 
2195
+ // Anthropic takes tool ids matching ^[a-zA-Z0-9_-]+$ only (Kimi sends "functions.Read:0"). The map must
2196
+ // stay pure: a replayed history has to give the same id, and the hash keeps "a.b" and "a:b" apart.
2197
+ function safeToolId(id) {
2198
+ const s = String(id ?? '');
2199
+ if (!s) return `toolu_${rand(24)}`;
2200
+ if (/^[a-zA-Z0-9_-]+$/.test(s)) return s;
2201
+ let h = 0x811c9dc5;
2202
+ for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
2203
+ return `${s.replace(/[^a-zA-Z0-9_-]/g, '_')}_${(h >>> 0).toString(36)}`;
2204
+ }
2205
+
2057
2206
  function anthropicStopReason(canonical, hasTools) {
2058
2207
  if (canonical === 'length') return 'max_tokens';
2059
2208
  if (hasTools) return 'tool_use';
@@ -2147,7 +2296,7 @@ function createAnthropicStream(emit, model, opts = {}) {
2147
2296
  const fixed = vocab ? vocab.name(tc.name) : tc.name;
2148
2297
  const isShort = Boolean(vocab && typeof vocab.isShortened === 'function' && vocab.isShortened(tc.name));
2149
2298
  st = {
2150
- index: nextIndex++, id: tc.id || `toolu_${rand(24)}`, name: fixed || 'tool', closed: false,
2299
+ index: nextIndex++, id: safeToolId(tc.id), name: fixed || 'tool', closed: false,
2151
2300
  // Shortened names keep caller's schema; ToolSearch query aliases still need restoring.
2152
2301
  buffered: '', held: Boolean(vocab && ((fixed && fixed !== tc.name && !isShort) || vocab.needsArgsRewrite?.(tc.name)))
2153
2302
  };
@@ -2212,7 +2361,7 @@ function buildAnthropicMessage({ model, think, text, tools, finish, prompt, comp
2212
2361
  // The name is written in the vocabulary the caller declared. A name it already uses is not
2213
2362
  // touched, so the whole layer costs nothing on a turn where the model agreed with the list.
2214
2363
  const fixed = repairToolCall(tc, vocab);
2215
- content.push({ type: 'tool_use', id: tc.id || `toolu_${rand(24)}`, name: fixed.name, input: parseArgs(fixed.args) });
2364
+ content.push({ type: 'tool_use', id: safeToolId(tc.id), name: fixed.name, input: parseArgs(fixed.args) });
2216
2365
  }
2217
2366
  if (!content.length) content.push({ type: 'text', text: '' });
2218
2367
  const p = prompt || 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.8",
3
+ "version": "1.6.10",
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",
package/proxy.mjs CHANGED
@@ -378,15 +378,33 @@ function vertexStatus(status) {
378
378
  }
379
379
  }
380
380
 
381
+ // A chat upstream names an overflow its own way; a 429 about tokens per minute is a rate limit, not this.
382
+ const CONTEXT_OVERFLOW_RE = /maximum context length|context[_ ]length[_ ]exceeded|prompt is too long|exceeds? the (?:model'?s? )?context window/i;
383
+
384
+ function isContextOverflow(status, message) {
385
+ return status !== 429 && status < 500 && CONTEXT_OVERFLOW_RE.test(String(message || ''));
386
+ }
387
+
388
+ // Claude Code compacts on "prompt is too long: N tokens > M maximum", so the numbers lead when the upstream names them.
389
+ function promptTooLongMessage(message) {
390
+ if (/^prompt is too long/i.test(message)) return message;
391
+ const max = message.match(/(?:maximum context length|context window|limit) (?:is |of )?(\d+)/i)?.[1];
392
+ const asked = message.match(/(?:requested|resulted in|prompt (?:is|has|contains)|input (?:is|has)) (?:about )?(\d+)/i)?.[1];
393
+ return max && asked ? `prompt is too long: ${asked} tokens > ${max} maximum (${message})` : `prompt is too long: ${message}`;
394
+ }
395
+
381
396
  // Each SDK parses errors in its own shape; Claude Code relies on error.type + retry-after to decide retries.
382
397
  function sendClientError(res, clientFormat, status, message, headers = {}) {
383
398
  let body;
399
+ const overflow = isContextOverflow(status, message);
384
400
  if (clientFormat === 'anthropic') {
385
- body = { type: 'error', error: { type: anthropicErrorType(status), message } };
401
+ body = overflow
402
+ ? { type: 'error', error: { type: 'invalid_request_error', message: promptTooLongMessage(String(message)) } }
403
+ : { type: 'error', error: { type: anthropicErrorType(status), message } };
386
404
  } else if (clientFormat === 'vertex' || clientFormat === 'codeassist') {
387
405
  body = { error: { code: status, message, status: vertexStatus(status) } };
388
406
  } else {
389
- body = { error: { message, type: status >= 500 ? 'server_error' : 'invalid_request_error', code: String(status) } };
407
+ body = { error: { message, type: status >= 500 ? 'server_error' : 'invalid_request_error', code: overflow ? 'context_length_exceeded' : String(status) } };
390
408
  }
391
409
  sendJson(res, status, body, headers);
392
410
  }
@@ -815,6 +833,7 @@ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = n
815
833
  const upBody = emitUpstreamBody(outFormat, ir, mappedModel, {
816
834
  thinkingMode: profile.thinkingMode,
817
835
  thinkingEffort: effort,
836
+ replayReasoning: profile.replayReasoning === true,
818
837
  });
819
838
  if (upBody?.tools && Array.isArray(upBody.tools)) {
820
839
  upBody.tools = cleanSchemaDeep(upBody.tools);
@@ -831,8 +850,9 @@ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = n
831
850
  async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal, sink, tag }) {
832
851
  let streamError = null;
833
852
  let events = 0;
853
+ const framing = {};
834
854
  try {
835
- for await (const parsed of readUpstreamPayloads(upstreamRes)) {
855
+ for await (const parsed of readUpstreamPayloads(upstreamRes, framing)) {
836
856
  events++;
837
857
  const ev = normalize(parsed);
838
858
  col.add(ev);
@@ -850,6 +870,8 @@ async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, sig
850
870
  await drained(sink, signal);
851
871
  }
852
872
  if (!streamError && events === 0) streamError = 'Upstream returned an empty stream';
873
+ // A clean close with no finish reason is a cut: rendered as done, it hands the client half a tool call.
874
+ else if (!streamError && col.finish == null && !framing.done) streamError = 'Upstream stream ended before a finish reason';
853
875
  } catch (streamErr) {
854
876
  if (!signal.aborted) {
855
877
  console.error(`[${tag}] Stream error:`, streamErr.message);
@@ -21,7 +21,7 @@ async function fixture(t) {
21
21
  if (body.stream) {
22
22
  res.setHeader('content-type', 'text/event-stream');
23
23
  res.write(`data: {"choices":\ndata: [{"index":0,"delta":{"content":"${summary}"}}]}\n\n`);
24
- if (!truncate) res.write(`data: {"choices":[{"index":0,"delta":{},"finish_reason":"${finish}"}]}\n\ndata: [DONE]\n\n`);
24
+ if (!truncate) res.write(`data: {"choices":[{"index":0,"delta":{},"finish_reason":${JSON.stringify(finish)}}]}\n\ndata: [DONE]\n\n`);
25
25
  res.end();
26
26
  } else { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { content: summary }, finish_reason: finish }] })); }
27
27
  });
@@ -77,3 +77,19 @@ test('WS compaction replays the compacted state through previous_response_id', {
77
77
  assert.match(JSON.stringify(f.received.at(-1).messages), /KEEP_DECISION_7391.*NEXT_TURN/);
78
78
  assert.doesNotMatch(JSON.stringify(f.received.at(-1).messages), /ORIGINAL_HISTORY/);
79
79
  });
80
+ test('stream cut without finish_reason renders error, not completed', { timeout: 20000 }, async t => {
81
+ const f = await fixture(t); f.failure('stop', true);
82
+ const codex = events(await (await f.send({ model: 'm', stream: true, input: [{ role: 'user', content: 'hi' }] })).text());
83
+ assert.ok(codex.some(e => e.type === 'response.failed'), JSON.stringify(codex));
84
+ assert.ok(!codex.some(e => e.type === 'response.completed'), JSON.stringify(codex));
85
+ const claude = await fetch(`http://127.0.0.1:${f.port}/v1/messages`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-profile': 'p1' },
86
+ body: JSON.stringify({ model: 'm', max_tokens: 64, stream: true, messages: [{ role: 'user', content: 'hi' }] }) });
87
+ const claudeEvents = events(await claude.text());
88
+ assert.ok(claudeEvents.some(e => e.type === 'error'), JSON.stringify(claudeEvents));
89
+ assert.ok(!claudeEvents.some(e => e.type === 'message_stop'), JSON.stringify(claudeEvents));
90
+ });
91
+ test('stream closed by [DONE] without finish_reason still completes', { timeout: 20000 }, async t => {
92
+ const f = await fixture(t); f.failure(null);
93
+ const codex = events(await (await f.send({ model: 'm', stream: true, input: [{ role: 'user', content: 'hi' }] })).text());
94
+ assert.ok(codex.some(e => e.type === 'response.completed'), JSON.stringify(codex));
95
+ });