llm-switcher 1.6.10 → 1.6.12

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,23 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.6.12 — 2026-10-11
4
+
5
+ - **Reasoning from an aborted turn.** In a Codex request, reasoning that waits for an assistant turn is dropped at the next user message. Separate reasoning items are joined with a newline.
6
+ - **Token counts with separators.** A context overflow message with `131,072`, `131.072`, `131 072` or `131_072` gives the full number to Claude Code. A message without numbers keeps the plain form.
7
+ - **Context overflow over WebSocket.** A Codex turn on WebSocket fails with the code `context_length_exceeded`, the same as on HTTP.
8
+ - **Goal check and images.** The goal-check request to a chat upstream uses the same image retry as other requests.
9
+ - **Output limit cache.** The output limit comes only from the cache entry of the same profile, and only when the entry is less than one hour old. The cache key ignores the case of scheme and host and a trailing slash. A new model list is merged per model, so a list with only output limits keeps the cached windows. A cached output limit is kept only while the newest list still states it.
10
+ - **System messages on the direct Anthropic route.** A `system` message inside `messages` is no longer lost. Its text goes into the next user turn, or into the previous one if no user turn follows, with the prefix `[system] `.
11
+ - **Gemini `_placeholder` in tool calls.** If the model fills the `_placeholder` property that the proxy added, the proxy removes it from the arguments. This applies to stream and non-stream answers for every client format, Code Assist (agy) included, and only where the proxy added the property.
12
+ - **Test temp files.** The tests delete all their temp directories, also when a test fails. `npm test` deletes the `node-compile-cache` directory that npm made at the start of the run.
13
+ - **`replayReasoning` documentation.** The README files now say that `reasoning_content` goes out only on a request with thinking on.
14
+
15
+ ## Release 1.6.11 — 2026-10-11
16
+
17
+ - **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.
18
+ - **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.
19
+ - **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`.
20
+
3
21
  ## Release 1.6.10 — 2026-10-11
4
22
 
5
23
  - **`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.
package/README.md CHANGED
@@ -869,7 +869,7 @@ Because of this split, a provider fingerprint is never a rule in this gateway. I
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
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
+ | `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`. The field goes out only on a request with thinking on. In `native` mode, thinking is on when the client asks for it. In `auto` mode, the flag is not needed: thinking is on when the client asks for it, or when the model reasons natively and the client did not turn thinking off. `off` never sends the field. |
873
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. |
874
874
  | `profile.endpoints.countTokens` | Override the Anthropic `count_tokens` URL. |
875
875
  | `profile.endpoints` | Override upstream URLs per format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
package/README.vi.md CHANGED
@@ -864,7 +864,7 @@ Nhờ cách chia này, fingerprint của provider không bao giờ là rule tron
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
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
+ | `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`. Field chỉ được gửi khi request bật thinking. Ở `native`, thinking bật khi client yêu cầu. Ở `auto`, không cần cờ này: thinking bật khi client yêu cầu, hoặc khi model có reasoning sẵn và client không tắt thinking. `off` không bao giờ gửi field này. |
868
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. |
869
869
  | `profile.endpoints.countTokens` | Ghi đè URL `count_tokens` của Anthropic. |
870
870
  | `profile.endpoints` | Ghi đè URL upstream theo từng format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
package/formats.mjs CHANGED
@@ -693,10 +693,12 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
693
693
  if (text) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
694
694
  };
695
695
 
696
- // A reasoning item precedes the assistant output it belongs to.
696
+ // A reasoning item precedes the assistant output it belongs to. A user message ends that turn,
697
+ // so reasoning still pending there came from an aborted turn and is dropped.
697
698
  let reasoning = '';
699
+ const joinLines = (a, b) => (a ? `${a}\n${b}` : b);
698
700
  const takeReasoning = (m) => {
699
- if (reasoning && m?.role === 'assistant') { m.reasoning = (m.reasoning || '') + reasoning; reasoning = ''; }
701
+ if (reasoning && m?.role === 'assistant') { m.reasoning = joinLines(m.reasoning || '', reasoning); reasoning = ''; }
700
702
  };
701
703
 
702
704
  // Parallel tool calls arrive as consecutive items -> merge into 1 assistant turn,
@@ -715,7 +717,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
715
717
  ir.messages.push({ role: 'user', content: input });
716
718
  } else if (Array.isArray(input)) {
717
719
  for (const item of input) {
718
- if (typeof item === 'string') { pushText('user', item); continue; }
720
+ if (typeof item === 'string') { reasoning = ''; pushText('user', item); continue; }
719
721
  if (!item || typeof item !== 'object') continue;
720
722
  // EasyInputMessage ({role, content}) doesn't require `type`.
721
723
  if (item.type === 'message' || (!item.type && item.role)) {
@@ -739,6 +741,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
739
741
  continue;
740
742
  }
741
743
  const role = item.role === 'assistant' ? 'assistant' : 'user';
744
+ if (role === 'user') reasoning = '';
742
745
  if (parts.every(p => p.type === 'text')) {
743
746
  pushText(role, parts.map(p => p.text).join('\n'));
744
747
  } else {
@@ -747,7 +750,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
747
750
  if (role === 'assistant') takeReasoning(ir.messages[ir.messages.length - 1]);
748
751
  } else if (item.type === 'reasoning') {
749
752
  const texts = (Array.isArray(item.summary) ? item.summary : []).map(x => x?.text).filter(t => typeof t === 'string' && t);
750
- reasoning += texts.join('\n');
753
+ if (texts.length) reasoning = joinLines(reasoning, texts.join('\n'));
751
754
  } else if (item.type === 'function_call') {
752
755
  pushCall({ id: item.call_id || item.id || null, name: responsesToolName(item.namespace, item.name), args: parseArgs(item.arguments) });
753
756
  } else if (item.type === 'custom_tool_call') {
@@ -1560,6 +1563,83 @@ function collapseNullableUnion(branches) {
1560
1563
  // key keeps the type an object; the root of a tool with no arguments may stay empty.
1561
1564
  const GEMINI_PLACEHOLDER_PROPERTIES = { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } };
1562
1565
 
1566
+ // A node the padding produced: its only property is the placeholder. Matched by shape, not by
1567
+ // reference, because the schema is deep-copied again before it is sent.
1568
+ function isGeminiPadded(schema) {
1569
+ const props = schema?.properties;
1570
+ const p = props?._placeholder;
1571
+ return Boolean(p && Object.keys(props).length === 1 && p.type === GEMINI_PLACEHOLDER_PROPERTIES._placeholder.type
1572
+ && p.description === GEMINI_PLACEHOLDER_PROPERTIES._placeholder.description);
1573
+ }
1574
+
1575
+ // Removes `_placeholder` from the arguments wherever the schema sent upstream was padded, and only there.
1576
+ export function stripGeminiPlaceholders(value, schema) {
1577
+ if (!value || typeof value !== 'object' || !schema || typeof schema !== 'object') return value;
1578
+ if (Array.isArray(value)) {
1579
+ if (!schema.items) return value;
1580
+ const out = value.map(v => stripGeminiPlaceholders(v, schema.items));
1581
+ return out.some((v, i) => v !== value[i]) ? out : value;
1582
+ }
1583
+ if (isGeminiPadded(schema)) {
1584
+ if (!Object.hasOwn(value, '_placeholder')) return value;
1585
+ const { _placeholder, ...rest } = value;
1586
+ return rest;
1587
+ }
1588
+ let out = value;
1589
+ const props = schema.properties && typeof schema.properties === 'object' ? schema.properties : {};
1590
+ for (const [k, v] of Object.entries(value)) {
1591
+ if (!Object.hasOwn(props, k)) continue;
1592
+ const next = stripGeminiPlaceholders(v, props[k]);
1593
+ if (next !== v) out = { ...out, [k]: next };
1594
+ }
1595
+ for (const branch of Array.isArray(schema.anyOf) ? schema.anyOf : []) out = stripGeminiPlaceholders(out, branch);
1596
+ return out;
1597
+ }
1598
+
1599
+ function hasGeminiPadding(schema) {
1600
+ if (!schema || typeof schema !== 'object') return false;
1601
+ if (isGeminiPadded(schema)) return true;
1602
+ return Object.values(schema.properties || {}).some(hasGeminiPadding) || hasGeminiPadding(schema.items)
1603
+ || (Array.isArray(schema.anyOf) && schema.anyOf.some(hasGeminiPadding));
1604
+ }
1605
+
1606
+ // The upstream tool name -> the schema sent, for each tool whose schema was padded. Reads both the
1607
+ // Vertex and the chat body shape; an empty map means no argument needs the strip.
1608
+ export function geminiPaddedTools(upBody) {
1609
+ const padded = new Map();
1610
+ for (const t of Array.isArray(upBody?.tools) ? upBody.tools : []) {
1611
+ const decls = Array.isArray(t?.functionDeclarations) ? t.functionDeclarations : t?.function ? [t.function] : [];
1612
+ for (const d of decls) if (d?.name && hasGeminiPadding(d.parameters)) padded.set(d.name, d.parameters);
1613
+ }
1614
+ return padded;
1615
+ }
1616
+
1617
+ // The tool vocabulary, plus the strip for padded tools. A renderer holds the arguments of a tool
1618
+ // whose needsArgsRewrite is true until the JSON is whole, so the strip also works on a stream.
1619
+ export function withGeminiPlaceholderStrip(vocab, padded) {
1620
+ if (!padded?.size) return vocab;
1621
+ const schemaOf = (name) => padded.get(name) || padded.get(vocab.name(name))
1622
+ || [...padded].find(([upstream]) => vocab.name(upstream) === name)?.[1];
1623
+ const args = (name, argsJson) => {
1624
+ const out = vocab.args(name, argsJson);
1625
+ const schema = schemaOf(name);
1626
+ if (!schema) return out;
1627
+ let parsed;
1628
+ try { parsed = JSON.parse(out || '{}'); } catch { return out; }
1629
+ const stripped = stripGeminiPlaceholders(parsed, schema);
1630
+ return stripped === parsed ? out : JSON.stringify(stripped);
1631
+ };
1632
+ return {
1633
+ ...vocab,
1634
+ args,
1635
+ needsArgsRewrite: (name) => Boolean(schemaOf(name)) || Boolean(vocab.needsArgsRewrite?.(name)),
1636
+ repair(upstream, argsJson) {
1637
+ const fixedName = vocab.name(upstream);
1638
+ return { name: fixedName, args: args(upstream, argsJson), renamed: fixedName !== upstream };
1639
+ }
1640
+ };
1641
+ }
1642
+
1563
1643
  function toGeminiSchema(schema, root, seen) {
1564
1644
  const nested = Boolean(root);
1565
1645
  const emptyProperties = () => (nested ? { ...GEMINI_PLACEHOLDER_PROPERTIES } : {});
@@ -1654,6 +1734,13 @@ function emitUpstreamBody(outFormat, ir, model, opts = {}) {
1654
1734
  // thinkingMode 'off' applies to all upstreams: drop the client's thinking entirely.
1655
1735
  let src = normThinkingMode(opts.thinkingMode) === 'off' ? { ...ir, thinking: { type: 'disabled' } } : ir;
1656
1736
  if (isAntigravityModel(model) && src.system) src = { ...src, system: src.system.replace(BILLING_HEADER_RE, '') };
1737
+ // An upstream refuses a max tokens above the model's output limit. The Anthropic body always
1738
+ // carries one (4096 when the client sent none), so that default is clamped too.
1739
+ const limit = opts.maxOutputTokens;
1740
+ if (Number.isFinite(limit) && limit > 0) {
1741
+ const asked = typeof src.params?.maxTokens === 'number' ? src.params.maxTokens : (outFormat === 'anthropic' ? 4096 : null);
1742
+ if (asked != null && asked > limit) src = { ...src, params: { ...src.params, maxTokens: limit } };
1743
+ }
1657
1744
  switch (outFormat) {
1658
1745
  case 'anthropic': return irToAnthropicBody(src, model, opts);
1659
1746
  case 'vertex': return irToVertexBody(src, model, opts);
@@ -1662,6 +1749,31 @@ function emitUpstreamBody(outFormat, ir, model, opts = {}) {
1662
1749
  }
1663
1750
  }
1664
1751
 
1752
+ // A text-only chat model refuses an image part with a 400. These are the refusals seen so far;
1753
+ // a looser match would turn an unrelated 400 into a silent retry without the image.
1754
+ export const IMAGE_OMITTED_TEXT = '[image omitted: this model does not accept images]';
1755
+
1756
+ export function refusesImageInput(text) {
1757
+ const t = String(text || '').toLowerCase();
1758
+ return t.includes('unknown variant `image_url`') || t.includes('image_url is only supported by certain models')
1759
+ || t.includes('does not support image') || t.includes('image input is not supported')
1760
+ || (t.includes('vision') && t.includes('not supported'));
1761
+ }
1762
+
1763
+ // Replaces every image part of a chat body with IMAGE_OMITTED_TEXT, in place. Returns how many it replaced.
1764
+ export function omitChatImages(body) {
1765
+ let replaced = 0;
1766
+ for (const m of Array.isArray(body?.messages) ? body.messages : []) {
1767
+ if (!Array.isArray(m?.content)) continue;
1768
+ m.content = m.content.map(p => {
1769
+ if (p?.type !== 'image_url') return p;
1770
+ replaced++;
1771
+ return { type: 'text', text: IMAGE_OMITTED_TEXT };
1772
+ });
1773
+ }
1774
+ return replaced;
1775
+ }
1776
+
1665
1777
  // ---------------- native Anthropic healer (direct passthrough) ----------------
1666
1778
  // The anthropic -> anthropic branch skips IR (to preserve thinking signatures, cache_control, documents...),
1667
1779
  // so it patches the Anthropic payload directly, touching only what the API will definitely reject:
@@ -1712,8 +1824,29 @@ function endsInUnsignedToolLoop(messages) {
1712
1824
  function healAnthropicPayload(payload) {
1713
1825
  const notes = [];
1714
1826
  if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
1715
- const src = payload.messages.filter(m => m && (m.role === 'user' || m.role === 'assistant'));
1716
- let changed = src.length !== payload.messages.length;
1827
+ // Anthropic has no system role inside messages. Its text joins the next user turn (else the previous
1828
+ // one) after the other blocks, because a tool_result must stay first in its turn.
1829
+ const src = [];
1830
+ let pendingSystem = [];
1831
+ const withSystem = (m) => ({ ...m, content: [...toAnthropicBlocks(m.content), ...pendingSystem] });
1832
+ for (const m of payload.messages) {
1833
+ if (m?.role === 'system') {
1834
+ const text = toAnthropicBlocks(m.content).filter(b => b.type === 'text' && !isBlankText(b)).map(b => b.text).join('\n');
1835
+ if (text) pendingSystem.push({ type: 'text', text: `[system] ${text}` });
1836
+ continue;
1837
+ }
1838
+ if (!m || (m.role !== 'user' && m.role !== 'assistant')) continue;
1839
+ if (m.role === 'user' && pendingSystem.length) { src.push(withSystem(m)); pendingSystem = []; continue; }
1840
+ src.push(m);
1841
+ }
1842
+ if (pendingSystem.length) {
1843
+ const i = src.findLastIndex(m => m.role === 'user');
1844
+ if (i >= 0) src[i] = withSystem(src[i]);
1845
+ else src.unshift({ role: 'user', content: pendingSystem });
1846
+ }
1847
+ const hadSystem = payload.messages.some(m => m?.role === 'system');
1848
+ if (hadSystem) notes.push('folded system messages into user turns');
1849
+ let changed = hadSystem || src.length !== payload.messages.length;
1717
1850
 
1718
1851
  let system = payload.system;
1719
1852
  if (Array.isArray(system) && system.some(isBlankText)) {
@@ -2721,7 +2854,7 @@ function vertexUsage(prompt, completion, cached, reasoning) {
2721
2854
  return u;
2722
2855
  }
2723
2856
 
2724
- function buildVertexMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, sig, ids = false }) {
2857
+ function buildVertexMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, sig, ids = false, vocab = null }) {
2725
2858
  const parts = [];
2726
2859
  const thinking = (think || []).join('');
2727
2860
  if (thinking) {
@@ -2732,7 +2865,8 @@ function buildVertexMessage({ model, think, text, tools, finish, prompt, complet
2732
2865
  const body = (text || []).join('');
2733
2866
  if (body) parts.push({ text: body });
2734
2867
  for (const tc of (tools || [])) {
2735
- const part = { functionCall: { ...(ids && tc.id ? { id: tc.id } : {}), name: tc.name || 'tool', args: parseArgs(tc.args) } };
2868
+ const args = vocab && typeof tc.args === 'string' ? vocab.args(tc.name, tc.args) : tc.args;
2869
+ const part = { functionCall: { ...(ids && tc.id ? { id: tc.id } : {}), name: tc.name || 'tool', args: parseArgs(args) } };
2736
2870
  const tsig = tc.sig || lookupToolSignature(tc.id);
2737
2871
  if (tsig) part.thoughtSignature = tsig;
2738
2872
  parts.push(part);
@@ -2746,7 +2880,7 @@ function buildVertexMessage({ model, think, text, tools, finish, prompt, complet
2746
2880
  }
2747
2881
 
2748
2882
  // ids: name each functionCall, as Code Assist does. Plain Vertex has no call id.
2749
- function createVertexStream(emit, model, { ids = false } = {}) {
2883
+ function createVertexStream(emit, model, { ids = false, vocab = null } = {}) {
2750
2884
  const cand = (parts) => emit(null, { candidates: [{ content: { role: 'model', parts }, index: 0 }], modelVersion: model });
2751
2885
  // Tool args arrive from OpenAI/Anthropic as deltas; Vertex needs complete functionCalls -> buffer them, emit at the end.
2752
2886
  const tools = new Map();
@@ -2773,7 +2907,8 @@ function createVertexStream(emit, model, { ids = false } = {}) {
2773
2907
  const parts = [...tools.values()]
2774
2908
  .filter(t => t.name)
2775
2909
  .map(t => {
2776
- const part = { functionCall: { ...(ids && t.id ? { id: t.id } : {}), name: t.name, args: parseArgs(t.args || '{}') } };
2910
+ const args = vocab ? vocab.args(t.name, t.args || '{}') : (t.args || '{}');
2911
+ const part = { functionCall: { ...(ids && t.id ? { id: t.id } : {}), name: t.name, args: parseArgs(args) } };
2777
2912
  const tsig = t.sig || lookupToolSignature(t.id);
2778
2913
  if (tsig) part.thoughtSignature = tsig;
2779
2914
  return part;
@@ -2798,10 +2933,10 @@ function codeAssistTrace() {
2798
2933
  return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
2799
2934
  }
2800
2935
 
2801
- function createCodeAssistStream(emit, model) {
2936
+ function createCodeAssistStream(emit, model, { vocab = null } = {}) {
2802
2937
  const traceId = codeAssistTrace();
2803
2938
  const responseId = `agy-${traceId}`;
2804
- return createVertexStream((e, d) => emit(e, d?.error ? d : { response: { ...d, responseId }, traceId }), model, { ids: true });
2939
+ return createVertexStream((e, d) => emit(e, d?.error ? d : { response: { ...d, responseId }, traceId }), model, { ids: true, vocab });
2805
2940
  }
2806
2941
 
2807
2942
  function buildCodeAssistMessage(args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.10",
3
+ "version": "1.6.12",
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,8 @@ 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
+ geminiPaddedTools, withGeminiPlaceholderStrip,
15
+ toGeminiSchema, isAntigravityModel, refusesImageInput, omitChatImages,
15
16
  createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
16
17
  buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
17
18
  createCodeAssistStream, buildCodeAssistMessage
@@ -26,7 +27,7 @@ import {
26
27
  codexModelEntry,
27
28
  contractLabSettings, STATE_DIR, stopRecordedBlindfold
28
29
  } from './state.mjs';
29
- import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows } from './windows.mjs';
30
+ import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows, windowsCacheKey, cachedProfileWindows } from './windows.mjs';
30
31
  import { createToolVocab, NULL_TOOL_VOCAB, toolNameMapForIR } from './toolvocab.mjs';
31
32
  import { readUpstreamPayloads } from './upstream-stream.mjs';
32
33
  import { compactionResponse, emitCompaction, expandGatewayCompactions, collectCompaction } from './compaction.mjs';
@@ -386,10 +387,15 @@ function isContextOverflow(status, message) {
386
387
  }
387
388
 
388
389
  // Claude Code compacts on "prompt is too long: N tokens > M maximum", so the numbers lead when the upstream names them.
390
+ // A count may group its digits ("131,072", "131.072", "131 072", "131_072"); the group form is tried first.
391
+ const TOKEN_COUNT = String.raw`(\d{1,3}(?:[,. _]\d{3})+(?!\d)|\d+)`;
392
+ const MAX_TOKENS_RE = new RegExp(String.raw`(?:maximum context length|context window|limit) (?:is |of )?` + TOKEN_COUNT, 'i');
393
+ const ASKED_TOKENS_RE = new RegExp(String.raw`(?:requested|resulted in|prompt (?:is|has|contains)|input (?:is|has)) (?:about )?` + TOKEN_COUNT, 'i');
394
+
389
395
  function promptTooLongMessage(message) {
390
396
  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];
397
+ const max = message.match(MAX_TOKENS_RE)?.[1]?.replace(/\D/g, '');
398
+ const asked = message.match(ASKED_TOKENS_RE)?.[1]?.replace(/\D/g, '');
393
399
  return max && asked ? `prompt is too long: ${asked} tokens > ${max} maximum (${message})` : `prompt is too long: ${message}`;
394
400
  }
395
401
 
@@ -494,15 +500,18 @@ function upstreamEndpoint(profile, outFormat, model, stream, req) {
494
500
  // declared none pays nothing and a turn where the model agreed with the list changes nothing.
495
501
  function toolVocabFor(ir, outFormat) {
496
502
  if (!ir || typeof ir !== 'object') return NULL_TOOL_VOCAB;
497
- if (!ir._vocab) ir._vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
503
+ if (!ir._vocab) {
504
+ const vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
505
+ ir._vocab = withGeminiPlaceholderStrip(vocab, ir._geminiPadded);
506
+ }
498
507
  return ir._vocab;
499
508
  }
500
509
 
501
510
  function clientRenderer(clientFormat, res, model, opts = {}) {
502
511
  if (clientFormat === 'anthropic') return createAnthropicStream((e, d) => sendSSE(res, e, d), model, opts);
503
512
  if (clientFormat === 'responses') return createResponsesStream((e, d) => sendSSE(res, e, d), model, opts);
504
- if (clientFormat === 'vertex') return createVertexStream((e, d) => sendSSE(res, null, d), model);
505
- if (clientFormat === 'codeassist') return createCodeAssistStream((e, d) => sendSSE(res, null, d), model);
513
+ if (clientFormat === 'vertex') return createVertexStream((e, d) => sendSSE(res, null, d), model, opts);
514
+ if (clientFormat === 'codeassist') return createCodeAssistStream((e, d) => sendSSE(res, null, d), model, opts);
506
515
  return createChatStream((e, d) => sendSSE(res, null, d), model, opts);
507
516
  }
508
517
 
@@ -781,7 +790,7 @@ async function maybeCompactIdle(req, clientFormat, payload, ir, profile, profile
781
790
  const summaryModel = mapModel(policy.model, profile, clientFormat);
782
791
  const summaryFormat = resolveOutFormat(profile, summaryModel);
783
792
  askSummary({ ir: prefixIR, policy, profile, model: summaryModel,
784
- build: (prof, model, summaryIR) => buildUpstreamRequest(prof, summaryFormat, summaryIR, model, req),
793
+ build: (prof, model, summaryIR) => buildUpstreamRequest(prof, summaryFormat, summaryIR, model, req, null, profileKey),
785
794
  onDiagnostic: ({ code, status }) => console.warn(`[llm-switcher] idle compact ${key.slice(0, 12)}: ${code}${status ? ` (${status})` : ''}`),
786
795
  }).then(summary => {
787
796
  if (!summary) return;
@@ -828,23 +837,48 @@ function resolveThinkingEffort(profile, slot, mappedModel) {
828
837
  return profile?.thinkingEffort || 'auto';
829
838
  }
830
839
 
831
- function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null) {
840
+ // The output limit this profile's own model list names for this model, or 0 when no fresh cached
841
+ // list names one. The cache is filled by /v1/models; this only reads it.
842
+ function modelOutputLimit(profileKey, profile, mappedModel) {
843
+ if (!profile?.baseURL || !mappedModel) return 0;
844
+ const output = Number(cachedProfileWindows(STATE_DIR, windowsCacheKey(profileKey, profile.baseURL))[mappedModel]?.output);
845
+ return output > 0 ? output : 0;
846
+ }
847
+
848
+ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null, profileKey = null) {
832
849
  const effort = resolveThinkingEffort(profile, slot, mappedModel);
833
850
  const upBody = emitUpstreamBody(outFormat, ir, mappedModel, {
834
851
  thinkingMode: profile.thinkingMode,
835
852
  thinkingEffort: effort,
836
853
  replayReasoning: profile.replayReasoning === true,
854
+ maxOutputTokens: modelOutputLimit(profileKey, profile, mappedModel),
837
855
  });
838
856
  if (upBody?.tools && Array.isArray(upBody.tools)) {
839
857
  upBody.tools = cleanSchemaDeep(upBody.tools);
840
858
  if (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)) {
841
859
  upBody.tools = geminiSafeTools(upBody.tools);
842
860
  }
861
+ // Read by toolVocabFor when the answer is rendered: the arguments lose the keys padding added.
862
+ if (outFormat === 'vertex' || (outFormat === 'openai-chat' && isAntigravityModel(mappedModel))) ir._geminiPadded = geminiPaddedTools(upBody);
843
863
  }
844
864
  const { url, headers } = upstreamEndpoint(profile, outFormat, mappedModel, ir.stream, req);
845
865
  return { url, headers, upBody };
846
866
  }
847
867
 
868
+ // Sends the upstream request. A chat 400 that says the model takes no images is retried once with
869
+ // every image replaced by text. The status arrives before any byte goes to the client, so the client
870
+ // sees only the retry's answer. Nothing is remembered: a false refusal costs one request, not the model.
871
+ async function fetchUpstream(url, headers, upBody, signal, outFormat, profileKey, mappedModel) {
872
+ const send = () => fetch(url, { method: 'POST', headers, body: JSON.stringify(upBody), signal });
873
+ const res = await send();
874
+ const hasImage = outFormat === 'openai-chat' && upBody.messages?.some(m => Array.isArray(m?.content) && m.content.some(p => p?.type === 'image_url'));
875
+ if (res.status !== 400 || !hasImage) return res;
876
+ const errText = await res.text().catch(() => '');
877
+ if (!refusesImageInput(errText)) return new Response(errText, { status: res.status, statusText: res.statusText, headers: res.headers });
878
+ console.warn(`[${profileKey}] ${mappedModel} refused ${omitChatImages(upBody)} image(s); retrying this request as text`);
879
+ return send();
880
+ }
881
+
848
882
  // Feeds upstream stream events to a client renderer until the stream ends, fails or is aborted.
849
883
  // Returns the stream error, or null. `sink` is the client stream whose backpressure it waits for.
850
884
  async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal, sink, tag }) {
@@ -975,7 +1009,7 @@ async function executeGoalCheckGate({ req, res, clientFormat, payload, ir, profi
975
1009
  try {
976
1010
  const nativePromise = (async () => {
977
1011
  try {
978
- let url, headers, body;
1012
+ let url, headers, body, upBody = null;
979
1013
  if (bifrost) {
980
1014
  url = bifrostURL(profile, clientFormat, req, opts);
981
1015
  headers = bifrostHeaders(req, profile);
@@ -993,16 +1027,18 @@ async function executeGoalCheckGate({ req, res, clientFormat, payload, ir, profi
993
1027
  body = Buffer.from(JSON.stringify(json), 'utf8');
994
1028
  } else {
995
1029
  const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
996
- const reqData = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
1030
+ const reqData = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
997
1031
  url = reqData.url;
998
1032
  headers = reqData.headers;
999
- body = JSON.stringify(reqData.upBody);
1033
+ upBody = reqData.upBody;
1000
1034
  }
1001
1035
 
1002
1036
  const traceId = clientFormat === 'codeassist' ? null : (probeTraceId(req) || contractLab.traceFor(mappedModel));
1003
1037
  if (traceId) headers['x-intact-trace'] = traceId;
1004
1038
 
1005
- const upRes = await fetch(url, { method: 'POST', headers, body, signal: gateAC.signal });
1039
+ const upRes = upBody
1040
+ ? await fetchUpstream(url, headers, upBody, gateAC.signal, outFormat, profileKey, mappedModel)
1041
+ : await fetch(url, { method: 'POST', headers, body, signal: gateAC.signal });
1006
1042
  if (!upRes.ok) {
1007
1043
  const errText = await upRes.text().catch(() => '');
1008
1044
  return { malformed: true, error: `Upstream HTTP ${upRes.status}: ${errText.slice(0, 150)}` };
@@ -1276,13 +1312,13 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1276
1312
  return log({ status: 400, error: 'unsupported compaction state' });
1277
1313
  }
1278
1314
  const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
1279
- const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
1315
+ const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
1280
1316
  if (traceId) headers['x-intact-trace'] = traceId;
1281
1317
  debugLog(`[${profileKey}] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 500));
1282
1318
 
1283
1319
  let upstreamRes;
1284
1320
  try {
1285
- upstreamRes = await fetch(url, { method: 'POST', headers, body: JSON.stringify(upBody), signal: ac.signal });
1321
+ upstreamRes = await fetchUpstream(url, headers, upBody, ac.signal, outFormat, profileKey, mappedModel);
1286
1322
  } catch (fetchErr) {
1287
1323
  if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
1288
1324
  console.error(`[${profileKey}] Network error:`, fetchErr.message);
@@ -1936,7 +1972,7 @@ async function servedModels(req) {
1936
1972
  // still gets the cached numbers, because an entry with the template window is better than none.
1937
1973
  async function modelWindowsFor(profileKey, profile) {
1938
1974
  if (!profile?.baseURL) return {};
1939
- const listed = await resolveProfileWindows(STATE_DIR, `${profileKey || 'profile'}@${profile.baseURL}`, profile.baseURL, profile.apiKey);
1975
+ const listed = await resolveProfileWindows(STATE_DIR, windowsCacheKey(profileKey, profile.baseURL), profile.baseURL, profile.apiKey);
1940
1976
  if (Object.keys(listed).length) return listed;
1941
1977
  return readLocalCodexWindows();
1942
1978
  }
@@ -2359,7 +2395,7 @@ function sendWsFailed(socket, model, message, status = 500, tap = null, code = n
2359
2395
  if (socket.writable) socket.write(encodeWsFrame(JSON.stringify(d)));
2360
2396
  }, model || 'main');
2361
2397
  renderer.start();
2362
- renderer.error(message, code || responsesErrorCode(status));
2398
+ renderer.error(message, code || (isContextOverflow(status, message) ? 'context_length_exceeded' : responsesErrorCode(status)));
2363
2399
  }
2364
2400
 
2365
2401
  // Codex on WebSocket sends only the new items of a turn and names the turn before in
@@ -2547,13 +2583,13 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2547
2583
  let answered = false; // set only after a complete answer; the half upload depends on it
2548
2584
  try {
2549
2585
  const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
2550
- const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
2586
+ const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
2551
2587
  if (traceId) headers['x-intact-trace'] = traceId;
2552
2588
  debugLog(`[${profileKey}:ws] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 300));
2553
2589
 
2554
2590
  let upstreamRes;
2555
2591
  try {
2556
- upstreamRes = await fetch(url, { method: 'POST', headers, body: JSON.stringify(upBody), signal: ac.signal });
2592
+ upstreamRes = await fetchUpstream(url, headers, upBody, ac.signal, outFormat, profileKey, mappedModel);
2557
2593
  } catch (fetchErr) {
2558
2594
  if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
2559
2595
  console.error(`[${profileKey}:ws] Network error:`, fetchErr.message);
@@ -1,6 +1,7 @@
1
1
  // Runs only tests/**/*.test.mjs. A bare `node --test` also collects test files from git-ignored
2
2
  // folders (an old copy in temp/), and Node 18 and 20 do not expand a glob argument.
3
3
  import fs from 'node:fs';
4
+ import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { spawnSync } from 'node:child_process';
6
7
  import { fileURLToPath } from 'node:url';
@@ -17,5 +18,16 @@ const files = [];
17
18
 
18
19
  // A version check must never reach the real npm registry.
19
20
  const env = { ...process.env, LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' };
21
+ // `npm test` itself, not a test, makes node-compile-cache in TMPDIR as it starts. Only a cache born
22
+ // in the seconds before this run is removed afterwards; one that was there before stays.
23
+ const compileCache = path.join(os.tmpdir(), 'node-compile-cache');
24
+ const launchedAt = Date.now() - process.uptime() * 1000;
25
+ let npmMadeCache = false;
26
+ try {
27
+ const born = fs.statSync(compileCache).birthtimeMs;
28
+ npmMadeCache = Boolean(process.env.npm_lifecycle_event) && born > 0 && born >= launchedAt - 10_000;
29
+ } catch { /* no cache: nothing to remove */ }
30
+
20
31
  const r = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], { cwd: root, stdio: 'inherit', env });
32
+ if (npmMadeCache) fs.rmSync(compileCache, { recursive: true, force: true });
21
33
  process.exit(r.status ?? 1);
@@ -24,6 +24,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
24
24
 
25
25
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
26
  const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-task3-'));
27
+ test.after(() => fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }));
27
28
  process.env.LLM_SWITCHER_HOME = TMP;
28
29
  process.env.LLM_SWITCHER_CONFIG = path.join(TMP, 'config.json');
29
30
  delete process.env.LLM_SWITCHER_BLINDFOLD_CERTS;
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');
@@ -1,7 +1,7 @@
1
1
  // `switch contract-check`: it turns open findings of intact into JSON test data, and never into
2
2
  // JavaScript. Every path, enum value and name in a finding or a fixture is untrusted text, so the
3
3
  // hostile-finding test hashes every .mjs file of the repository before and after a run.
4
- import { test } from 'node:test';
4
+ import { test, after } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
6
  import http from 'node:http';
7
7
  import crypto from 'node:crypto';
@@ -15,8 +15,13 @@ import { runCheck, loadExclusions, matchExclusion, FINDING_ID_RE } from '../cont
15
15
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
16
  const HOSTILE = "x');require('child_process')";
17
17
 
18
+ // Every directory is removed once more after the file, so a failed test leaves none behind.
19
+ const made = [];
20
+ after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });
18
21
  function tmpDir(name) {
19
- return fs.mkdtempSync(path.join(os.tmpdir(), `lsw-${name}-`));
22
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `lsw-${name}-`));
23
+ made.push(dir);
24
+ return dir;
20
25
  }
21
26
 
22
27
  // One fake intact: it serves the findings list and one fixture per known trace id.