llm-switcher 1.6.9 → 1.6.11

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,21 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.6.11 — 2026-10-11
4
+
5
+ - **Output limit.** The proxy keeps the output limit of each model from the `/models` list of the profile (`max_output_tokens` or `top_provider.max_completion_tokens`) in `model-windows.json`. If the client asks for more tokens than this limit, the proxy sends the limit. This applies to `max_tokens`, `max_completion_tokens` and `maxOutputTokens`. If the limit is not known, the value does not change. The limit does not apply to the anthropic-to-anthropic passthrough (direct mode, outFormat `anthropic`, and the native goal-check request). These requests go to the upstream unchanged.
6
+ - **Images to a text-only chat model.** If a chat upstream refuses an image with HTTP 400, the proxy sends the request one more time. In that request, each image is the text `[image omitted: this model does not accept images]`. The retry happens in each request. The proxy remembers nothing between requests.
7
+ - **Test cleanup.** The browser tests and the gateway tests stop the gateway, the stand-in interceptor, an interceptor that the gateway started, and Chromium. They also delete their temp directories. This occurs also when a test fails or the test process gets `SIGTERM`, `SIGINT` or `SIGHUP`.
8
+
9
+ ## Release 1.6.10 — 2026-10-11
10
+
11
+ - **`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.
12
+ - **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.
13
+ - **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.`.
14
+ - **Schema sanitizer.** Keywords with a `null` value and `pattern` values with lookaround are removed.
15
+ - **Long tool names from Codex.** Names longer than 64 characters use the hashed shortener, so two long names do not collide.
16
+ - **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.
17
+ - **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`.
18
+
3
19
  ## Release 1.6.9 — 2026-10-11
4
20
 
5
21
  - **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);
@@ -1601,6 +1654,13 @@ function emitUpstreamBody(outFormat, ir, model, opts = {}) {
1601
1654
  // thinkingMode 'off' applies to all upstreams: drop the client's thinking entirely.
1602
1655
  let src = normThinkingMode(opts.thinkingMode) === 'off' ? { ...ir, thinking: { type: 'disabled' } } : ir;
1603
1656
  if (isAntigravityModel(model) && src.system) src = { ...src, system: src.system.replace(BILLING_HEADER_RE, '') };
1657
+ // An upstream refuses a max tokens above the model's output limit. The Anthropic body always
1658
+ // carries one (4096 when the client sent none), so that default is clamped too.
1659
+ const limit = opts.maxOutputTokens;
1660
+ if (Number.isFinite(limit) && limit > 0) {
1661
+ const asked = typeof src.params?.maxTokens === 'number' ? src.params.maxTokens : (outFormat === 'anthropic' ? 4096 : null);
1662
+ if (asked != null && asked > limit) src = { ...src, params: { ...src.params, maxTokens: limit } };
1663
+ }
1604
1664
  switch (outFormat) {
1605
1665
  case 'anthropic': return irToAnthropicBody(src, model, opts);
1606
1666
  case 'vertex': return irToVertexBody(src, model, opts);
@@ -1609,6 +1669,31 @@ function emitUpstreamBody(outFormat, ir, model, opts = {}) {
1609
1669
  }
1610
1670
  }
1611
1671
 
1672
+ // A text-only chat model refuses an image part with a 400. These are the refusals seen so far;
1673
+ // a looser match would turn an unrelated 400 into a silent retry without the image.
1674
+ export const IMAGE_OMITTED_TEXT = '[image omitted: this model does not accept images]';
1675
+
1676
+ export function refusesImageInput(text) {
1677
+ const t = String(text || '').toLowerCase();
1678
+ return t.includes('unknown variant `image_url`') || t.includes('image_url is only supported by certain models')
1679
+ || t.includes('does not support image') || t.includes('image input is not supported')
1680
+ || (t.includes('vision') && t.includes('not supported'));
1681
+ }
1682
+
1683
+ // Replaces every image part of a chat body with IMAGE_OMITTED_TEXT, in place. Returns how many it replaced.
1684
+ export function omitChatImages(body) {
1685
+ let replaced = 0;
1686
+ for (const m of Array.isArray(body?.messages) ? body.messages : []) {
1687
+ if (!Array.isArray(m?.content)) continue;
1688
+ m.content = m.content.map(p => {
1689
+ if (p?.type !== 'image_url') return p;
1690
+ replaced++;
1691
+ return { type: 'text', text: IMAGE_OMITTED_TEXT };
1692
+ });
1693
+ }
1694
+ return replaced;
1695
+ }
1696
+
1612
1697
  // ---------------- native Anthropic healer (direct passthrough) ----------------
1613
1698
  // The anthropic -> anthropic branch skips IR (to preserve thinking signatures, cache_control, documents...),
1614
1699
  // so it patches the Anthropic payload directly, touching only what the API will definitely reject:
@@ -1647,6 +1732,15 @@ function toolResultText(block) {
1647
1732
  return `[Tool Result${block.tool_use_id ? ` (${block.tool_use_id})` : ''}]: ${text}`;
1648
1733
  }
1649
1734
 
1735
+ // Anthropic: "a final assistant message must start with a thinking block" when thinking is on mid tool loop.
1736
+ function endsInUnsignedToolLoop(messages) {
1737
+ const lastUser = messages[messages.length - 1];
1738
+ const lastAssistant = messages[messages.length - 2];
1739
+ const inToolLoop = lastUser?.role === 'user' && toAnthropicBlocks(lastUser.content).some(b => b.type === 'tool_result');
1740
+ const first = lastAssistant?.role === 'assistant' ? toAnthropicBlocks(lastAssistant.content)[0] : null;
1741
+ return Boolean(inToolLoop && first && first.type !== 'thinking' && first.type !== 'redacted_thinking');
1742
+ }
1743
+
1650
1744
  function healAnthropicPayload(payload) {
1651
1745
  const notes = [];
1652
1746
  if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
@@ -1711,6 +1805,41 @@ function healAnthropicPayload(payload) {
1711
1805
  }
1712
1806
  if (emptyUser && msgs[msgs.length - 1]?.role !== 'user') msgs.push(emptyTurn());
1713
1807
 
1808
+ // Step 0b: an upstream that reuses an id (Kimi `functions.Bash:0`) leaves one id on two tool_use
1809
+ // blocks, which Anthropic refuses. A later use gets a suffix, and so does its result in the next turn.
1810
+ const allIds = new Set(msgs.flatMap(m => toAnthropicBlocks(m.content).filter(b => b.type === 'tool_use' && b.id).map(b => b.id)));
1811
+ const usedIds = new Set();
1812
+ for (let i = 0; i < msgs.length; i++) {
1813
+ if (msgs[i].role !== 'assistant' || !Array.isArray(msgs[i].content)) continue;
1814
+ const resultIds = new Map();
1815
+ let renamed = false;
1816
+ const content = msgs[i].content.map(b => {
1817
+ if (b?.type !== 'tool_use' || !b.id) return b;
1818
+ let id = b.id;
1819
+ if (usedIds.has(id)) {
1820
+ let n = 2;
1821
+ while (allIds.has(`${b.id}_${n}`)) n++;
1822
+ id = `${b.id}_${n}`;
1823
+ allIds.add(id);
1824
+ renamed = true;
1825
+ }
1826
+ usedIds.add(id);
1827
+ resultIds.set(b.id, [...(resultIds.get(b.id) || []), id]);
1828
+ return id === b.id ? b : { ...b, id };
1829
+ });
1830
+ if (!renamed) continue;
1831
+ msgs[i] = { ...msgs[i], content };
1832
+ const next = msgs[i + 1];
1833
+ if (next?.role === 'user' && Array.isArray(next.content)) {
1834
+ msgs[i + 1] = { ...next, content: next.content.map(b => {
1835
+ const id = b?.type === 'tool_result' ? resultIds.get(b.tool_use_id)?.shift() : undefined;
1836
+ return id && id !== b.tool_use_id ? { ...b, tool_use_id: id } : b;
1837
+ }) };
1838
+ }
1839
+ changed = true;
1840
+ notes.push('deduplicated repeated tool_use ids');
1841
+ }
1842
+
1714
1843
  // Steps 1-3: pair up tool_use / tool_result.
1715
1844
  const out = [];
1716
1845
  for (let i = 0; i < msgs.length; i++) {
@@ -1771,11 +1900,7 @@ function healAnthropicPayload(payload) {
1771
1900
  // Step 5: thinking + an in-progress tool loop whose last assistant turn doesn't open with thinking.
1772
1901
  const thinkingOn = payload.thinking && payload.thinking.type && payload.thinking.type !== 'disabled';
1773
1902
  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') {
1903
+ if (endsInUnsignedToolLoop(out)) {
1779
1904
  body = { ...body };
1780
1905
  delete body.thinking;
1781
1906
  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.11",
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
@@ -11,7 +11,7 @@ import { createFrameReader } from './blindfold/wsframe.mjs';
11
11
  import {
12
12
  OUT_FORMATS, parseToIR, emitUpstreamBody, irToAnthropicBody, createUpstreamNormalizer, createCollector,
13
13
  createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES, VALID_EFFORTS,
14
- toGeminiSchema, isAntigravityModel,
14
+ toGeminiSchema, isAntigravityModel, refusesImageInput, omitChatImages,
15
15
  createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
16
16
  buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
17
17
  createCodeAssistStream, buildCodeAssistMessage
@@ -26,7 +26,7 @@ import {
26
26
  codexModelEntry,
27
27
  contractLabSettings, STATE_DIR, stopRecordedBlindfold
28
28
  } from './state.mjs';
29
- import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows } from './windows.mjs';
29
+ import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows, readWindowsCache } from './windows.mjs';
30
30
  import { createToolVocab, NULL_TOOL_VOCAB, toolNameMapForIR } from './toolvocab.mjs';
31
31
  import { readUpstreamPayloads } from './upstream-stream.mjs';
32
32
  import { compactionResponse, emitCompaction, expandGatewayCompactions, collectCompaction } from './compaction.mjs';
@@ -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
  }
@@ -810,11 +828,24 @@ function resolveThinkingEffort(profile, slot, mappedModel) {
810
828
  return profile?.thinkingEffort || 'auto';
811
829
  }
812
830
 
831
+ // The output limit the profile's own model list names for this model, or 0 when no cached list
832
+ // names one. The cache is filled by /v1/models; this only reads it.
833
+ function modelOutputLimit(profile, mappedModel) {
834
+ if (!profile?.baseURL || !mappedModel) return 0;
835
+ for (const entry of Object.values(readWindowsCache(STATE_DIR))) {
836
+ const output = entry?.baseURL === profile.baseURL ? Number(entry.models?.[mappedModel]?.output) : 0;
837
+ if (output > 0) return output;
838
+ }
839
+ return 0;
840
+ }
841
+
813
842
  function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null) {
814
843
  const effort = resolveThinkingEffort(profile, slot, mappedModel);
815
844
  const upBody = emitUpstreamBody(outFormat, ir, mappedModel, {
816
845
  thinkingMode: profile.thinkingMode,
817
846
  thinkingEffort: effort,
847
+ replayReasoning: profile.replayReasoning === true,
848
+ maxOutputTokens: modelOutputLimit(profile, mappedModel),
818
849
  });
819
850
  if (upBody?.tools && Array.isArray(upBody.tools)) {
820
851
  upBody.tools = cleanSchemaDeep(upBody.tools);
@@ -826,6 +857,20 @@ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = n
826
857
  return { url, headers, upBody };
827
858
  }
828
859
 
860
+ // Sends the upstream request. A chat 400 that says the model takes no images is retried once with
861
+ // every image replaced by text. The status arrives before any byte goes to the client, so the client
862
+ // sees only the retry's answer. Nothing is remembered: a false refusal costs one request, not the model.
863
+ async function fetchUpstream(url, headers, upBody, signal, outFormat, profileKey, mappedModel) {
864
+ const send = () => fetch(url, { method: 'POST', headers, body: JSON.stringify(upBody), signal });
865
+ const res = await send();
866
+ const hasImage = outFormat === 'openai-chat' && upBody.messages?.some(m => Array.isArray(m?.content) && m.content.some(p => p?.type === 'image_url'));
867
+ if (res.status !== 400 || !hasImage) return res;
868
+ const errText = await res.text().catch(() => '');
869
+ if (!refusesImageInput(errText)) return new Response(errText, { status: res.status, statusText: res.statusText, headers: res.headers });
870
+ console.warn(`[${profileKey}] ${mappedModel} refused ${omitChatImages(upBody)} image(s); retrying this request as text`);
871
+ return send();
872
+ }
873
+
829
874
  // Feeds upstream stream events to a client renderer until the stream ends, fails or is aborted.
830
875
  // Returns the stream error, or null. `sink` is the client stream whose backpressure it waits for.
831
876
  async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal, sink, tag }) {
@@ -1263,7 +1308,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1263
1308
 
1264
1309
  let upstreamRes;
1265
1310
  try {
1266
- upstreamRes = await fetch(url, { method: 'POST', headers, body: JSON.stringify(upBody), signal: ac.signal });
1311
+ upstreamRes = await fetchUpstream(url, headers, upBody, ac.signal, outFormat, profileKey, mappedModel);
1267
1312
  } catch (fetchErr) {
1268
1313
  if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
1269
1314
  console.error(`[${profileKey}] Network error:`, fetchErr.message);
@@ -2534,7 +2579,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2534
2579
 
2535
2580
  let upstreamRes;
2536
2581
  try {
2537
- upstreamRes = await fetch(url, { method: 'POST', headers, body: JSON.stringify(upBody), signal: ac.signal });
2582
+ upstreamRes = await fetchUpstream(url, headers, upBody, ac.signal, outFormat, profileKey, mappedModel);
2538
2583
  } catch (fetchErr) {
2539
2584
  if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
2540
2585
  console.error(`[${profileKey}:ws] Network error:`, fetchErr.message);
package/tests/cdp.mjs CHANGED
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
5
5
  import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
+ import { reapDirSync, onAbnormalExit } from './helpers.mjs';
8
9
 
9
10
  const CANDIDATES = [
10
11
  process.env.LLM_SWITCHER_CHROMIUM,
@@ -48,7 +49,10 @@ export async function launchBrowser() {
48
49
  'about:blank'
49
50
  ];
50
51
  // POSIX: a process group of its own, so close() can stop the renderers and helpers too.
51
- const child = spawn(findChromium(), args, { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, HOME: dir }, detached: process.platform !== 'win32' });
52
+ // TMPDIR too: Chromium makes its own org.chromium.* dirs there, and they go with the profile.
53
+ const child = spawn(findChromium(), args, { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, HOME: dir, TMPDIR: dir }, detached: process.platform !== 'win32' });
54
+ // The browser runs detached, so a test process that ends without close() would leave it running.
55
+ const cancelExitReap = onAbnormalExit(() => reapDirSync(dir, process.platform !== 'win32' ? [-child.pid] : [child.pid]));
52
56
  const stop = (signal) => {
53
57
  try {
54
58
  if (process.platform !== 'win32') process.kill(-child.pid, signal);
@@ -71,6 +75,8 @@ export async function launchBrowser() {
71
75
  stop('SIGKILL');
72
76
  await new Promise(r => { child.once('exit', r); setTimeout(r, 3000); });
73
77
  }
78
+ cancelExitReap();
79
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
74
80
  throw new Error(`Chromium did not open a debugging port (exit: ${exited ?? 'killed'})\n${stderr}`);
75
81
  }
76
82
 
@@ -84,6 +90,7 @@ export async function launchBrowser() {
84
90
  return page;
85
91
  },
86
92
  async close() {
93
+ cancelExitReap();
87
94
  for (const p of pages) p.close();
88
95
  // The whole group: a helper that outlives the main process keeps writing into the profile.
89
96
  stop('SIGTERM');
@@ -8,6 +8,7 @@ import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { spawn } from 'node:child_process';
10
10
  import { fileURLToPath, pathToFileURL } from 'node:url';
11
+ import { stopChild, reapDir, reapDirSync, onAbnormalExit } from './helpers.mjs';
11
12
 
12
13
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
14
 
@@ -39,6 +40,11 @@ export function baselineConfig(port, blindfoldPort, upstreamBase) {
39
40
  export async function startDashboardFixture() {
40
41
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-dashboard-'));
41
42
  const cfgPath = path.join(dir, 'config.json');
43
+ let interceptor = null;
44
+ let proxy = null;
45
+ // A run that ends before stop() (a signal, a failed start) still stops the children and an
46
+ // interceptor the gateway started detached, and removes the dir.
47
+ const cancelExitReap = onAbnormalExit(() => reapDirSync(dir, [proxy?.pid, interceptor?.pid]));
42
48
  const received = [];
43
49
  // 'ok' answers like a healthy provider; 'deny' answers 401; 'down' answers 503.
44
50
  const upstream = { mode: 'ok', received, models: ['up-alpha', 'up-beta', 'up-gamma'], latest: '99.0.0' };
@@ -62,6 +68,23 @@ export async function startDashboardFixture() {
62
68
  });
63
69
  });
64
70
  await new Promise(r => upstreamServer.listen(0, '127.0.0.1', r));
71
+
72
+ async function stop() {
73
+ cancelExitReap();
74
+ // The gateway first: once it is gone it can start no interceptor.
75
+ await stopChild(proxy);
76
+ await stopChild(interceptor);
77
+ upstreamServer.close();
78
+ upstreamServer.closeAllConnections?.();
79
+ // A tool set change can start a detached interceptor of its own; stop the one the gateway recorded.
80
+ try {
81
+ const { pid } = JSON.parse(fs.readFileSync(path.join(dir, 'blindfold.json'), 'utf8'));
82
+ if (Number.isInteger(pid) && pid !== interceptor?.pid) process.kill(pid);
83
+ } catch { /* none was started */ }
84
+ // One it had not recorded yet still names the dir on its command line.
85
+ await reapDir(dir);
86
+ }
87
+
65
88
  const upstreamBase = `http://127.0.0.1:${upstreamServer.address().port}`;
66
89
 
67
90
  const port = await freePort();
@@ -96,7 +119,7 @@ export async function startDashboardFixture() {
96
119
  // The gateway starts an interceptor of its own when it finds none on the port. This one is up
97
120
  // first and answers the identity proof with this gateway's port and tool set, so nothing real
98
121
  // is spawned.
99
- const interceptor = spawn(process.execPath, ['--input-type=module', '-e', `
122
+ interceptor = spawn(process.execPath, ['--input-type=module', '-e', `
100
123
  const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'state.mjs')).href)});
101
124
  const http = await import('node:http');
102
125
  s.ensureAdminToken();
@@ -112,11 +135,11 @@ export async function startDashboardFixture() {
112
135
  for (;;) {
113
136
  const probe = await fetch(`http://127.0.0.1:${blindfoldPort}/?challenge=probe`).catch(() => null);
114
137
  if (probe?.ok) break;
115
- if (Date.now() > deadline) { interceptor.kill(); throw new Error('the stand-in interceptor never came up'); }
138
+ if (Date.now() > deadline) { await stop(); throw new Error('the stand-in interceptor never came up'); }
116
139
  await new Promise(r => setTimeout(r, 100));
117
140
  }
118
141
 
119
- const proxy = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] });
142
+ proxy = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] });
120
143
  let log = '';
121
144
  proxy.stdout.on('data', d => { log += d; });
122
145
  proxy.stderr.on('data', d => { log += d; });
@@ -125,7 +148,7 @@ export async function startDashboardFixture() {
125
148
  try { started = (await fetch(`http://127.0.0.1:${port}/health`)).ok; } catch { /* not listening yet */ }
126
149
  if (!started) await new Promise(r => setTimeout(r, 100));
127
150
  }
128
- if (!started) { proxy.kill(); interceptor.kill(); throw new Error(`gateway did not start:\n${log}`); }
151
+ if (!started) { await stop(); throw new Error(`gateway did not start:\n${log}`); }
129
152
 
130
153
  const adminToken = () => fs.readFileSync(path.join(dir, 'admin.token'), 'utf8').trim();
131
154
  return {
@@ -153,17 +176,6 @@ export async function startDashboardFixture() {
153
176
  headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
154
177
  body: JSON.stringify({ model: 'claude-opus-4-6', max_tokens: 64, messages: [{ role: 'user', content: 'hello from the test' }] })
155
178
  }).then(async r => ({ status: r.status, text: await r.text() })),
156
- async stop() {
157
- interceptor.kill();
158
- proxy.kill();
159
- upstreamServer.close();
160
- upstreamServer.closeAllConnections?.();
161
- // A tool set change can start a detached interceptor of its own; stop the one the gateway recorded.
162
- try {
163
- const { pid } = JSON.parse(fs.readFileSync(path.join(dir, 'blindfold.json'), 'utf8'));
164
- if (Number.isInteger(pid) && pid !== interceptor.pid) process.kill(pid);
165
- } catch { /* none was started */ }
166
- fs.rmSync(dir, { recursive: true, force: true });
167
- }
179
+ stop
168
180
  };
169
181
  }