llm-switcher 1.6.9 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.6.10 — 2026-10-11
4
+
5
+ - **`reasoning_content` replay.** On assistant tool-call turns, `auto` mode sends the earlier thinking as `reasoning_content`. `native` mode sends it only when the profile sets `replayReasoning: true`, because a strict OpenAI-compatible server can refuse the field. `off` mode never sends it.
6
+ - **Codex to Anthropic with thinking.** If an open tool loop has no signed thinking, the request drops `thinking`. Anthropic refused this request with HTTP 400.
7
+ - **Gemini schema and turn shape.** A nested object with no properties gets an optional `_placeholder` property. An untyped `const` becomes a string. Tool output with keys that start with `$` is wrapped as `{result: ...}`. A request never ends on a model turn: the proxy adds a user turn `Continue.`.
8
+ - **Schema sanitizer.** Keywords with a `null` value and `pattern` values with lookaround are removed.
9
+ - **Long tool names from Codex.** Names longer than 64 characters use the hashed shortener, so two long names do not collide.
10
+ - **Repeated tool ids.** On the direct Anthropic route, a repeated `tool_use` id gets a suffix (`_2`, `_3`), and the matching `tool_result` changes with it.
11
+ - **Context overflow errors.** Anthropic clients receive `invalid_request_error` with a message that starts with `prompt is too long`. Responses clients receive the code `context_length_exceeded`.
12
+
3
13
  ## Release 1.6.9 — 2026-10-11
4
14
 
5
15
  - **Compaction keeps the tools.** A Codex compaction turn with tool history now keeps the `tools` list and sets `tool_choice` to `none`. Before, Anthropic and OpenAI chat upstreams refused this turn with HTTP 400.
package/README.md CHANGED
@@ -868,7 +868,8 @@ Because of this split, a provider fingerprint is never a rule in this gateway. I
868
868
  | `LLM_SWITCHER_CODE_ASSIST_TIMEOUT_MS` | Idle time of such a call, from 1000 to 2147483647 ms (default 300000). Before the answer starts, agy gets a 504. After it starts, the connection is cut. |
869
869
  | `LLM_SWITCHER_BIFROST_RETRY_MS` | Time before the gateway asks intact again after a failed Bifrost lookup (default 30000 ms). |
870
870
  | `x-llm-profile: <key>` header (alias `x-profile`) or `?profile=<key>` | Route a single request through a specific profile. An unknown key returns HTTP 400 instead of silently falling back. |
871
- | `profile.thinkingMode` | `auto` (default, for gateways like intact or 9Router): restore stripped thinking, inject a `<think>` guide for non-reasoning models, send `thinking` + `reasoning_effort`. `native` (strict OpenAI APIs): send only `reasoning_effort` when the client asks, never touch the prompt, use `max_completion_tokens`. `off`: never send reasoning parameters. |
871
+ | `profile.thinkingMode` | `auto` (default, for gateways like intact or 9Router): restore stripped thinking, inject a `<think>` guide for non-reasoning models, send `thinking` + `reasoning_effort`, and send the earlier thinking as `reasoning_content` on assistant tool-call turns. `native` (strict OpenAI APIs): send only `reasoning_effort` when the client asks, never touch the prompt, use `max_completion_tokens`. Native mode never sends `reasoning_content` unless the profile sets `replayReasoning: true`. `off`: never send reasoning parameters. |
872
+ | `profile.replayReasoning` | `true` sends the earlier thinking as `reasoning_content` on assistant tool-call turns in `native` mode. DeepSeek and Kimi thinking mode need it. The default is `false`. `auto` mode always sends the field, and `off` never sends it. The model name and the client request do not change this rule. |
872
873
  | `profile.thinkingEffort` | `auto` (default, caller/client controls effort), or an explicit override level: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Overrides client effort before routing upstream. Automatically maps to the nearest equivalent level accepted by the target upstream (e.g. OpenAI ceiling `high`, Anthropic `max`, Gemini budget bounds). In the Web UI, this selector dynamically hides with a guidance hint when Bifrost passthrough is active. |
873
874
  | `profile.endpoints.countTokens` | Override the Anthropic `count_tokens` URL. |
874
875
  | `profile.endpoints` | Override upstream URLs per format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
package/README.vi.md CHANGED
@@ -863,7 +863,8 @@ Nhờ cách chia này, fingerprint của provider không bao giờ là rule tron
863
863
  | `LLM_SWITCHER_CODE_ASSIST_TIMEOUT_MS` | Thời gian chờ không có dữ liệu của lệnh gọi đó, từ 1000 tới 2147483647 ms (mặc định 300000). Trước khi câu trả lời bắt đầu, agy nhận 504. Sau khi đã bắt đầu, kết nối bị cắt. |
864
864
  | `LLM_SWITCHER_BIFROST_RETRY_MS` | Thời gian trước khi gateway hỏi lại intact sau một lần tra cứu Bifrost thất bại (mặc định 30000 ms). |
865
865
  | Header `x-llm-profile: <key>` (tên khác `x-profile`) hoặc `?profile=<key>` | Định tuyến riêng 1 request qua profile chỉ định. Key không tồn tại trả HTTP 400 thay vì âm thầm dùng profile khác. |
866
- | `profile.thinkingMode` | `auto` (mặc định, cho gateway như intact hoặc 9Router): phục hồi thinking bị xoá, inject hướng dẫn `<think>` cho model không có reasoning, gửi `thinking` + `reasoning_effort`. `native` (API OpenAI nghiêm ngặt): chỉ gửi `reasoning_effort` khi client yêu cầu, không sửa prompt, dùng `max_completion_tokens`. `off`: không bao giờ gửi tham số reasoning. |
866
+ | `profile.thinkingMode` | `auto` (mặc định, cho gateway như intact hoặc 9Router): phục hồi thinking bị xoá, inject hướng dẫn `<think>` cho model không có reasoning, gửi `thinking` + `reasoning_effort`, và gửi lại thinking trước đó thành `reasoning_content` ở lượt assistant có tool call. `native` (API OpenAI nghiêm ngặt): chỉ gửi `reasoning_effort` khi client yêu cầu, không sửa prompt, dùng `max_completion_tokens`; không gửi `reasoning_content` trừ khi profile đặt `replayReasoning: true`. `off`: không bao giờ gửi tham số reasoning. |
867
+ | `profile.replayReasoning` | `true`: ở chế độ `native`, gửi lại thinking trước đó thành `reasoning_content` ở lượt assistant có tool call. Thinking mode của DeepSeek và Kimi cần field này. Mặc định là `false`. `auto` luôn gửi, `off` không bao giờ gửi. Tên model và request của client không đổi quy tắc này. |
867
868
  | `profile.thinkingEffort` | `auto` (mặc định, client tự quyết định), hoặc đặt cố định mức override: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Ghi đè mức reasoning effort từ công cụ trước khi gửi lên upstream. Tự động ánh xạ về mức tương đương gần nhất mà upstream đích hỗ trợ (OpenAI trần `high`, Anthropic trần `max`, Gemini điều chỉnh `thinkingBudget`). Trên Web UI, tùy chọn này tự động ẩn cùng thông báo gợi ý khi profile bật chế độ Bifrost passthrough. |
868
869
  | `profile.endpoints.countTokens` | Ghi đè URL `count_tokens` của Anthropic. |
869
870
  | `profile.endpoints` | Ghi đè URL upstream theo từng format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
package/formats.mjs CHANGED
@@ -256,13 +256,18 @@ function smartDelta(choice) {
256
256
  }
257
257
 
258
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 = /\(\?<?[=!]/;
259
263
 
260
264
  function sanitizeJsonSchema(schema) {
261
265
  if (schema === null || schema === undefined) return { type: 'object', properties: {} };
262
266
  return sanitizeSchemaNode(schema);
263
267
  }
264
268
 
265
- // 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.
266
271
  function sanitizeSchemaNode(schema) {
267
272
  if (schema === null || typeof schema !== 'object') return schema;
268
273
  if (Array.isArray(schema)) return schema.map(sanitizeSchemaNode);
@@ -270,6 +275,9 @@ function sanitizeSchemaNode(schema) {
270
275
  for (const [k, v] of Object.entries(schema)) {
271
276
  if (k === '$schema' || k === 'cache_control' || k === 'encrypted') continue;
272
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;
273
281
  // A name map: each value is a schema, the map itself is not. A parameter may be named "properties".
274
282
  if (SCHEMA_MAPS.has(k) && v && typeof v === 'object' && !Array.isArray(v)) {
275
283
  clean[k] = Object.fromEntries(Object.entries(v).map(([name, sub]) => [name, sanitizeSchemaNode(sub)]));
@@ -457,10 +465,13 @@ function anthropicToIR(payload) {
457
465
  if (Array.isArray(msg.content)) {
458
466
  const parts = [];
459
467
  const toolCalls = [];
468
+ let reasoning = '';
460
469
  for (const part of msg.content) {
461
470
  if (!part) continue;
462
471
  if (part.type === 'text') {
463
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;
464
475
  } else if (part.type === 'image' && part.source?.type === 'base64') {
465
476
  const mimeType = part.source.media_type || 'image/jpeg';
466
477
  parts.push({ type: 'image_url', image_url: { url: `data:${mimeType};base64,${part.source.data}` } });
@@ -501,6 +512,7 @@ function anthropicToIR(payload) {
501
512
  out.content = parts.every(p => p.type === 'text') ? parts.map(p => p.text).join('\n') : parts;
502
513
  }
503
514
  if (toolCalls.length) out.toolCalls = toolCalls;
515
+ if (reasoning && msg.role === 'assistant') out.reasoning = reasoning;
504
516
  if (out.content !== undefined || out.toolCalls) ir.messages.push(out);
505
517
  } else if (msg.content) {
506
518
  // Fallback for payloads that preprocessing/compression tools mangled into an object
@@ -631,9 +643,10 @@ const LOCAL_SHELL_PARAMS = {
631
643
  required: ['command']
632
644
  };
633
645
 
646
+ // A plain cut at 64 would give two long namespaced tools one name; the hashed form keeps them apart.
634
647
  function responsesToolName(namespace, name) {
635
648
  const raw = namespace ? `${namespace}__${name}` : String(name || '');
636
- return raw.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64);
649
+ return raw ? sanitizeToolName(raw) : '';
637
650
  }
638
651
 
639
652
  function customToolDescription(t) {
@@ -680,12 +693,19 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
680
693
  if (text) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
681
694
  };
682
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
+
683
702
  // Parallel tool calls arrive as consecutive items -> merge into 1 assistant turn,
684
703
  // otherwise OpenAI Chat/Anthropic will error that tool_calls aren't answered adjacently.
685
704
  const pushCall = (call) => {
686
705
  const last = ir.messages[ir.messages.length - 1];
687
706
  if (last && last.role === 'assistant') last.toolCalls = [...(last.toolCalls || []), call];
688
707
  else ir.messages.push({ role: 'assistant', toolCalls: [call] });
708
+ takeReasoning(ir.messages[ir.messages.length - 1]);
689
709
  };
690
710
 
691
711
  // Responses Lite sends the tools as an input item, not in payload.tools.
@@ -724,6 +744,10 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
724
744
  } else {
725
745
  ir.messages.push({ role, content: parts });
726
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');
727
751
  } else if (item.type === 'function_call') {
728
752
  pushCall({ id: item.call_id || item.id || null, name: responsesToolName(item.namespace, item.name), args: parseArgs(item.arguments) });
729
753
  } else if (item.type === 'custom_tool_call') {
@@ -1077,6 +1101,7 @@ function normThinkingEffort(effort) {
1077
1101
  // - 'native' (genuine OpenAI / strict OpenAI-compatible servers): only send `reasoning_effort` when
1078
1102
  // the client requests thinking, don't touch the system prompt, use `max_completion_tokens`.
1079
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.
1080
1105
  function irToChatBody(ir, model, opts = {}) {
1081
1106
  const names = toolNameMapForIR(ir);
1082
1107
  const toolName = names.name;
@@ -1090,6 +1115,8 @@ function irToChatBody(ir, model, opts = {}) {
1090
1115
  const wantsThinking = mode === 'off' ? false
1091
1116
  : mode === 'native' ? clientWantsThinking
1092
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);
1093
1120
 
1094
1121
  let systemText = ir.system || '';
1095
1122
  if (mode === 'auto' && wantsThinking && !modelIsThinking && !String(model).toLowerCase().includes('gemini')) {
@@ -1125,6 +1152,8 @@ function irToChatBody(ir, model, opts = {}) {
1125
1152
  return call;
1126
1153
  });
1127
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 ?? '';
1128
1157
  }
1129
1158
  if (out.content === undefined) out.content = '';
1130
1159
  messages.push(out);
@@ -1332,6 +1361,8 @@ function irToAnthropicBody(ir, model, opts = {}) {
1332
1361
  body.thinking = { type: 'enabled', budget_tokens: Math.min(budget, body.max_tokens - 1) };
1333
1362
  }
1334
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;
1335
1366
  if (body.thinking) {
1336
1367
  // When thinking is on, Anthropic rejects temperature != 1, top_k, or top_p < 0.95.
1337
1368
  if (body.temperature !== undefined && body.temperature !== 1) delete body.temperature;
@@ -1351,6 +1382,12 @@ function dataUrlToInlineData(url) {
1351
1382
  return { inlineData: { mimeType: m[1], data: m[2] } };
1352
1383
  }
1353
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
+
1354
1391
  // IR -> Vertex generateContent body.
1355
1392
  function irToVertexBody(ir, model, opts = {}) {
1356
1393
  const names = toolNameMapForIR(ir, { allowHyphens: false });
@@ -1400,6 +1437,8 @@ function irToVertexBody(ir, model, opts = {}) {
1400
1437
  const parsed = typeof m.content === 'string' ? JSON.parse(m.content) : m.content;
1401
1438
  // functionResponse.response must be an object (Struct), not an array/primitive.
1402
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) };
1403
1442
  } catch { resp = { result: m.content ?? '' }; }
1404
1443
  // Gemini API & Vertex only accept roles 'user' | 'model' (functionResponse lives in the 'user' role).
1405
1444
  push('user', [{ functionResponse: { name: toolName(m.name || 'tool'), response: resp } }]);
@@ -1423,6 +1462,9 @@ function irToVertexBody(ir, model, opts = {}) {
1423
1462
  push('user', textPartsOf(m.content));
1424
1463
  }
1425
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.' }] });
1426
1468
 
1427
1469
  const body = { contents };
1428
1470
  if (ir.system && ir.system.trim()) {
@@ -1514,15 +1556,21 @@ function collapseNullableUnion(branches) {
1514
1556
  return { schema: rest[0], nullable: rest.length !== branches.length };
1515
1557
  }
1516
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
+
1517
1563
  function toGeminiSchema(schema, root, seen) {
1564
+ const nested = Boolean(root);
1565
+ const emptyProperties = () => (nested ? { ...GEMINI_PLACEHOLDER_PROPERTIES } : {});
1518
1566
  // Shorthand left by some MCP servers: a bare "object"/"string" where a Schema belongs.
1519
1567
  // Vertex rejects the raw string with INVALID_ARGUMENT, so expand it.
1520
1568
  if (typeof schema === 'string') {
1521
- return schema === 'object' ? { type: 'object', properties: {} } : { type: schema };
1569
+ return schema === 'object' ? { type: 'object', properties: emptyProperties() } : { type: schema };
1522
1570
  }
1523
1571
  if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema;
1524
1572
  // The same hole as the string shorthand above: an empty schema has no type Vertex can read.
1525
- if (Object.keys(schema).length === 0) return { type: 'object', properties: {} };
1573
+ if (Object.keys(schema).length === 0) return { type: 'object', properties: emptyProperties() };
1526
1574
  root = root || schema;
1527
1575
  seen = seen || new Set();
1528
1576
  if (seen.has(schema)) return {};
@@ -1573,8 +1621,13 @@ function toGeminiSchema(schema, root, seen) {
1573
1621
  }
1574
1622
  }
1575
1623
  if (!out.type && out.properties) out.type = 'object';
1576
- if (out.type === 'object' && !out.properties) out.properties = {};
1577
- 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
+ }
1578
1631
  if (out.enum && out.type !== 'string') {
1579
1632
  // Gemini supports enum for strings only. Keep the constraint as text for the model.
1580
1633
  const values = (schema.enum || (Object.hasOwn(schema, 'const') ? [schema.const] : [])).filter(x => x !== null);
@@ -1647,6 +1700,15 @@ function toolResultText(block) {
1647
1700
  return `[Tool Result${block.tool_use_id ? ` (${block.tool_use_id})` : ''}]: ${text}`;
1648
1701
  }
1649
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
+
1650
1712
  function healAnthropicPayload(payload) {
1651
1713
  const notes = [];
1652
1714
  if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
@@ -1711,6 +1773,41 @@ function healAnthropicPayload(payload) {
1711
1773
  }
1712
1774
  if (emptyUser && msgs[msgs.length - 1]?.role !== 'user') msgs.push(emptyTurn());
1713
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
+ }
1810
+
1714
1811
  // Steps 1-3: pair up tool_use / tool_result.
1715
1812
  const out = [];
1716
1813
  for (let i = 0; i < msgs.length; i++) {
@@ -1771,11 +1868,7 @@ function healAnthropicPayload(payload) {
1771
1868
  // Step 5: thinking + an in-progress tool loop whose last assistant turn doesn't open with thinking.
1772
1869
  const thinkingOn = payload.thinking && payload.thinking.type && payload.thinking.type !== 'disabled';
1773
1870
  if (thinkingOn) {
1774
- const lastUser = out[out.length - 1];
1775
- const lastAssistant = out[out.length - 2];
1776
- const inToolLoop = lastUser?.role === 'user' && toAnthropicBlocks(lastUser.content).some(b => b.type === 'tool_result');
1777
- const first = lastAssistant?.role === 'assistant' ? toAnthropicBlocks(lastAssistant.content)[0] : null;
1778
- if (inToolLoop && first && first.type !== 'thinking' && first.type !== 'redacted_thinking') {
1871
+ if (endsInUnsignedToolLoop(out)) {
1779
1872
  body = { ...body };
1780
1873
  delete body.thinking;
1781
1874
  changed = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.9",
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);
@@ -420,7 +420,7 @@ test('toGeminiSchema: bare string shorthands become Schema objects (Vertex 400 f
420
420
  assert.deepEqual(toGeminiSchema('string'), { type: 'string' });
421
421
  assert.deepEqual(
422
422
  toGeminiSchema({ type: 'object', properties: { tags: { type: 'array', items: 'object' }, n: 'string' } }),
423
- { type: 'object', properties: { tags: { type: 'array', items: { type: 'object', properties: {} } }, n: { type: 'string' } } }
423
+ { type: 'object', properties: { tags: { type: 'array', items: { type: 'object', properties: { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } } } }, n: { type: 'string' } } }
424
424
  );
425
425
  });
426
426
 
@@ -1167,3 +1167,179 @@ test('Gemini MALFORMED_FUNCTION_CALL finish is an error, not stop', () => {
1167
1167
  const chat = normalizeUpstream({ choices: [{ delta: {}, finish_reason: 'malformed_function_call' }] }, 'openai-chat');
1168
1168
  assert.match(String(chat.error), /malformed_function_call/i);
1169
1169
  });
1170
+
1171
+ // DeepSeek/Kimi thinking mode refuse a tool step whose earlier tool-call turn lost its reasoning_content.
1172
+ test('irToChatBody replays thinking as reasoning_content on tool-call turns', () => {
1173
+ const ir = anthropicToIR({ model: 'x', max_tokens: 8000, thinking: { type: 'enabled', budget_tokens: 4000 }, messages: [
1174
+ { role: 'user', content: 'list files' },
1175
+ { role: 'assistant', content: [{ type: 'thinking', thinking: 'I should run ls', signature: 'sig' }, { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] },
1176
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' }] },
1177
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Bash', input: { command: 'pwd' } }] },
1178
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_2', content: '/' }] }
1179
+ ] });
1180
+ for (const opts of [{ thinkingMode: 'auto' }, { thinkingMode: 'native', replayReasoning: true }]) {
1181
+ const calls = irToChatBody(ir, 'deepseek-reasoner', opts).messages.filter(m => m.tool_calls);
1182
+ assert.equal(calls[0].reasoning_content, 'I should run ls', opts.thinkingMode);
1183
+ assert.equal(calls[1].reasoning_content, '', opts.thinkingMode);
1184
+ }
1185
+ const off = emitUpstreamBody('openai-chat', ir, 'deepseek-reasoner', { thinkingMode: 'off' });
1186
+ assert.ok(off.messages.every(m => !('reasoning_content' in m)));
1187
+
1188
+ const rir = responsesToIR({ model: 'x', reasoning: { effort: 'high' }, input: [
1189
+ { type: 'message', role: 'user', content: 'go' },
1190
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'Plan: run ls' }], encrypted_content: 'opaque' },
1191
+ { type: 'function_call', call_id: 'c1', name: 'shell', arguments: '{}' },
1192
+ { type: 'function_call_output', call_id: 'c1', output: 'ok' }
1193
+ ] });
1194
+ const rb = irToChatBody(rir, 'kimi-k2-thinking', { thinkingMode: 'native', replayReasoning: true });
1195
+ assert.equal(rb.messages.find(m => m.tool_calls).reasoning_content, 'Plan: run ls');
1196
+ });
1197
+
1198
+ // Strict OpenAI-compatible servers may refuse the extra key, so native mode sends it only on the profile opt-in.
1199
+ test('irToChatBody native mode sends no reasoning_content without the profile opt-in', () => {
1200
+ const ir = anthropicToIR({ model: 'x', max_tokens: 8000, thinking: { type: 'enabled', budget_tokens: 4000 }, messages: [
1201
+ { role: 'user', content: 'list files' },
1202
+ { role: 'assistant', content: [{ type: 'thinking', thinking: 'I should run ls', signature: 'sig' }, { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] },
1203
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' }] },
1204
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Bash', input: { command: 'pwd' } }] },
1205
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_2', content: '/' }] }
1206
+ ] });
1207
+ const cases = [
1208
+ ['gpt-5', ir, { thinkingMode: 'native' }],
1209
+ ['deepseek-reasoner', ir, { thinkingMode: 'native' }],
1210
+ ['gpt-5', ir, { thinkingMode: 'native', replayReasoning: 'yes' }],
1211
+ ['gpt-5', { ...ir, replayReasoning: true }, { thinkingMode: 'native' }]
1212
+ ];
1213
+ for (const [model, input, opts] of cases) {
1214
+ const body = irToChatBody(input, model, opts);
1215
+ assert.ok(body.messages.some(m => m.tool_calls), 'fixture has tool-call turns');
1216
+ assert.ok(body.messages.every(m => !('reasoning_content' in m)), `${model} ${JSON.stringify(opts)}`);
1217
+ }
1218
+ });
1219
+
1220
+ test('irToAnthropicBody drops thinking for an unsigned tool loop', () => {
1221
+ const ir = responsesToIR({ model: 'x', reasoning: { effort: 'high' }, input: [
1222
+ { type: 'message', role: 'user', content: 'run ls' },
1223
+ { type: 'function_call', call_id: 'c1', name: 'shell', arguments: '{"command":["ls"]}' },
1224
+ { type: 'function_call_output', call_id: 'c1', output: 'a.txt' }
1225
+ ] });
1226
+ const body = irToAnthropicBody(ir, 'claude-opus-4-6');
1227
+ assert.equal(body.thinking, undefined);
1228
+ assert.equal(body.messages.at(-1).content[0].type, 'tool_result');
1229
+ const forced = irToAnthropicBody(ir, 'claude-opus-4-6', { thinkingEffort: 'high' });
1230
+ assert.equal(forced.thinking, undefined);
1231
+ const fresh = irToAnthropicBody(responsesToIR({ model: 'x', reasoning: { effort: 'high' }, input: 'hi' }), 'claude-opus-4-6');
1232
+ assert.equal(fresh.thinking?.type, 'enabled');
1233
+ });
1234
+
1235
+ test('toGeminiSchema never emits an empty nested OBJECT', () => {
1236
+ const out = toGeminiSchema({ type: 'object', properties: {
1237
+ env: { type: 'object', additionalProperties: { type: 'string' } },
1238
+ list: { type: 'array', items: { type: 'object' } },
1239
+ either: { anyOf: [{ type: 'object' }, { type: 'string' }] },
1240
+ short: 'object'
1241
+ } });
1242
+ const walk = (s, nested) => {
1243
+ if (!s || typeof s !== 'object') return;
1244
+ if (nested && s.type === 'object') assert.ok(Object.keys(s.properties || {}).length, JSON.stringify(s));
1245
+ for (const p of Object.values(s.properties || {})) walk(p, true);
1246
+ if (s.items) walk(s.items, true);
1247
+ for (const b of s.anyOf || []) walk(b, true);
1248
+ };
1249
+ walk(out, false);
1250
+ assert.deepEqual(toGeminiSchema({ type: 'object' }), { type: 'object', properties: {} });
1251
+ });
1252
+
1253
+ test('irToVertexBody wraps tool output with $ keys', () => {
1254
+ const output = JSON.stringify({ $ref: '#/definitions/Pet', definitions: { Pet: { $id: 'p' } } });
1255
+ const nested = JSON.stringify({ a: { b: [{ $schema: 'x' }] } });
1256
+ const ir = { ...anthropicToIR({ model: 'x', messages: [
1257
+ { role: 'user', content: 'go' },
1258
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }, { type: 'tool_use', id: 't2', name: 'Bash', input: {} }, { type: 'tool_use', id: 't3', name: 'Bash', input: {} }] },
1259
+ { role: 'user', content: [
1260
+ { type: 'tool_result', tool_use_id: 't1', content: output },
1261
+ { type: 'tool_result', tool_use_id: 't2', content: nested },
1262
+ { type: 'tool_result', tool_use_id: 't3', content: '{"ok":true}' }
1263
+ ] }
1264
+ ] }) };
1265
+ const responses = irToVertexBody(ir, 'gemini-2.5-pro').contents.flatMap(c => c.parts).filter(p => p.functionResponse).map(p => p.functionResponse.response);
1266
+ assert.deepEqual(responses[0], { result: output });
1267
+ assert.deepEqual(responses[1], { result: nested });
1268
+ assert.deepEqual(responses[2], { ok: true });
1269
+ });
1270
+
1271
+ test('toGeminiSchema stringifies an untyped const', () => {
1272
+ const out = toGeminiSchema({ type: 'object', properties: { flag: { const: true }, n: { const: 3 }, s: { const: 'x' } } });
1273
+ assert.deepEqual(out.properties.flag, { enum: ['true'], type: 'string' });
1274
+ assert.deepEqual(out.properties.n, { enum: ['3'], type: 'string' });
1275
+ assert.deepEqual(out.properties.s, { enum: ['x'], type: 'string' });
1276
+ });
1277
+
1278
+ test('responsesToIR keeps long colliding tool names distinct', () => {
1279
+ const ns = `mcp__${'server_with_a_long_name_'.repeat(3)}`;
1280
+ const ir = responsesToIR({ model: 'x', input: [
1281
+ { type: 'message', role: 'user', content: 'go' },
1282
+ { type: 'function_call', call_id: 'c1', namespace: ns, name: 'list_items_with_filter_b', arguments: '{}' },
1283
+ { type: 'function_call_output', call_id: 'c1', output: 'ok' }
1284
+ ], tools: [{ type: 'namespace', name: ns, tools: [
1285
+ { type: 'function', name: 'list_items_with_filter_a', parameters: { type: 'object', properties: {} } },
1286
+ { type: 'function', name: 'list_items_with_filter_b', parameters: { type: 'object', properties: {} } }
1287
+ ] }] });
1288
+ const names = ir.tools.map(t => t.name);
1289
+ assert.equal(names.length, 2);
1290
+ assert.notEqual(names[0], names[1]);
1291
+ assert.ok(names.every(n => n.length <= 64 && /^[A-Za-z0-9_-]+$/.test(n)), names.join());
1292
+ assert.equal(ir.toolMeta[names[1]].name, 'list_items_with_filter_b');
1293
+ assert.equal(ir.messages[1].toolCalls[0].name, names[1]);
1294
+ assert.equal(responsesToIR({ model: 'x', input: 'hi', tools: [{ type: 'function', name: 'read_file', parameters: {} }] }).tools[0].name, 'read_file');
1295
+ });
1296
+
1297
+ test('sanitizeJsonSchema drops null keywords and lookaround patterns', () => {
1298
+ const out = sanitizeJsonSchema({ type: 'object', required: null, description: null, properties: {
1299
+ slug: { type: 'string', pattern: '^(?!-)[a-z-]+$' },
1300
+ tag: { type: 'string', pattern: '(?<=#)\\w+' },
1301
+ plain: { type: 'string', pattern: '^[a-z]+$', default: null },
1302
+ named: { type: 'string', pattern: '^(?<year>\\d{4})$' },
1303
+ pattern: { type: 'string', enum: ['a', null], const: null }
1304
+ } });
1305
+ assert.equal('required' in out, false);
1306
+ assert.equal('description' in out, false);
1307
+ assert.equal('pattern' in out.properties.slug, false);
1308
+ assert.equal('pattern' in out.properties.tag, false);
1309
+ assert.equal(out.properties.plain.pattern, '^[a-z]+$');
1310
+ assert.equal(out.properties.plain.default, null);
1311
+ assert.equal(out.properties.named.pattern, '^(?<year>\\d{4})$');
1312
+ assert.deepEqual(out.properties.pattern, { type: 'string', enum: ['a', null], const: null });
1313
+ assert.deepEqual(sanitizeJsonSchema({ type: 'object', properties: {}, default: { a: null, b: {} } }).default, { a: null, b: {} });
1314
+ });
1315
+
1316
+ test('irToVertexBody never ends on a model turn', () => {
1317
+ const ir = anthropicToIR({ model: 'x', messages: [
1318
+ { role: 'user', content: 'Write a haiku' },
1319
+ { role: 'assistant', content: 'Autumn moon' }
1320
+ ] });
1321
+ const body = irToVertexBody(ir, 'gemini-2.5-pro');
1322
+ assert.equal(body.contents.at(-1).role, 'user');
1323
+ assert.ok(body.contents.some(c => c.role === 'model' && c.parts.some(p => p.text === 'Autumn moon')), 'the prefill text is kept');
1324
+ });
1325
+
1326
+ test('healAnthropicPayload dedupes repeated tool_use ids', () => {
1327
+ const use = (id, cmd) => ({ type: 'tool_use', id, name: 'Bash', input: { command: cmd } });
1328
+ const result = (id, text) => ({ type: 'tool_result', tool_use_id: id, content: text });
1329
+ const r = healAnthropicPayload({ model: 'claude', messages: [
1330
+ { role: 'user', content: 'go' },
1331
+ { role: 'assistant', content: [use('functions_Bash_0', 'ls')] },
1332
+ { role: 'user', content: [result('functions_Bash_0', 'a.txt')] },
1333
+ { role: 'assistant', content: [use('functions_Bash_0', 'pwd'), use('functions_Bash_1', 'id')] },
1334
+ { role: 'user', content: [result('functions_Bash_0', '/'), result('functions_Bash_1', 'uid')] }
1335
+ ] });
1336
+ assert.equal(r.changed, true);
1337
+ const msgs = r.payload.messages;
1338
+ const ids = msgs.filter(m => m.role === 'assistant').flatMap(m => m.content.filter(b => b.type === 'tool_use').map(b => b.id));
1339
+ assert.equal(new Set(ids).size, ids.length, ids.join());
1340
+ assert.ok(ids.every(id => /^[a-zA-Z0-9_-]+$/.test(id)));
1341
+ assert.equal(ids[0], 'functions_Bash_0');
1342
+ // Each result still answers its own call.
1343
+ assert.deepEqual(msgs[2].content.map(b => [b.tool_use_id, b.content]), [[ids[0], 'a.txt']]);
1344
+ assert.deepEqual(msgs[4].content.map(b => [b.tool_use_id, b.content]), [[ids[1], '/'], [ids[2], 'uid']]);
1345
+ });
@@ -112,6 +112,10 @@ function startUpstream() {
112
112
  res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '7' });
113
113
  return res.end(JSON.stringify({ error: { message: 'slow down' } }));
114
114
  }
115
+ if (lastUser.includes('CONTEXT_OVERFLOW')) {
116
+ res.writeHead(400, { 'Content-Type': 'application/json' });
117
+ return res.end(JSON.stringify({ error: { message: "This model's maximum context length is 131072 tokens. However, you requested 140000 tokens (132000 in the messages, 8000 in the completion). Please reduce the length of the messages or completion.", type: 'invalid_request_error', code: 'invalid_request_error' } }));
118
+ }
115
119
  if (lastUser.includes('APPLY_PATCH')) {
116
120
  return sse(res, [
117
121
  { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_patch', type: 'function', function: { name: 'apply_patch', arguments: '{"input":"*** Begin Patch\\n' } }] } }] },
@@ -407,7 +411,7 @@ test('Codex via ag/* target: Gemini-hostile tool schemas are rewritten before up
407
411
  const params = up.body.tools[0].function.parameters;
408
412
  assert.deepEqual(params.properties.ids, { type: 'array', items: { type: 'string' }, nullable: true });
409
413
  assert.deepEqual(params.properties.part, { type: 'object', properties: { t: { type: 'string' } } });
410
- assert.deepEqual(params.properties.raw, { type: 'object', properties: {} });
414
+ assert.deepEqual(params.properties.raw, { type: 'object', properties: { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } } });
411
415
  assert.deepEqual(params.required, ['ids']);
412
416
  assert.ok(!JSON.stringify(params).includes('$ref'), 'no $ref survives');
413
417
  assert.equal(params.$defs, undefined);
@@ -594,6 +598,20 @@ test('Upstream 429 is returned in Anthropic error shape with retry-after', async
594
598
  assert.deepEqual(json, { type: 'error', error: { type: 'rate_limit_error', message: 'slow down' } });
595
599
  });
596
600
 
601
+ // Claude Code compacts only on "prompt is too long", and Codex only on context_length_exceeded.
602
+ test('sendClientError maps context overflow per client', async () => {
603
+ const cc = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: 'CONTEXT_OVERFLOW' }] });
604
+ assert.equal(cc.status, 400);
605
+ const ccJson = await cc.json();
606
+ assert.equal(ccJson.error.type, 'invalid_request_error');
607
+ assert.match(ccJson.error.message, /^prompt is too long: 140000 tokens > 131072 maximum/);
608
+ const codex = await post('/v1/responses', { model: 'gpt-5-codex', input: 'CONTEXT_OVERFLOW' });
609
+ assert.equal(codex.status, 400);
610
+ const codexJson = await codex.json();
611
+ assert.equal(codexJson.error.code, 'context_length_exceeded');
612
+ assert.match(codexJson.error.message, /maximum context length is 131072/);
613
+ });
614
+
597
615
  test('Mid-stream upstream error surfaces as an error event instead of a fake end_turn', async () => {
598
616
  const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, stream: true, messages: [{ role: 'user', content: 'MID_STREAM_ERROR' }] });
599
617
  const events = parseSSE(await res.text());