llm-switcher 1.6.11 → 1.6.13

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,32 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.6.13 — 2026-10-12
4
+
5
+ - **`switch on` with no profile.** `switch on` gives each tool the profile that it had before `switch off`. A tool that was off stays off. A tool that is on keeps its profile.
6
+ - **System turns on the translated route.** A `system` turn inside the `messages` of an Anthropic request now goes into the system prompt when the proxy translates the request.
7
+ - **System messages on the direct Anthropic route.** A folded `[system] ` text keeps its place between the turns around it. It never moves past an assistant turn. A system message with no text is dropped, and the healer note now says so. A system message after a final assistant prefill becomes the last user turn, so the request no longer ends on the prefill.
8
+ - **Gemini `_placeholder` in `anyOf`.** If one branch of an `anyOf` declares a real `_placeholder` property, the proxy keeps the key in the arguments.
9
+ - **Gemini `_placeholder` and tool names.** If two padded tools map back to one client tool name, the proxy removes nothing for that name.
10
+ - **Retry to another upstream format.** A request that the proxy builds again for another upstream format gets a new tool vocabulary and a new placeholder rule.
11
+ - **Reasoning before a compaction item.** In a Codex request, reasoning that waits for an assistant turn is dropped at a `compaction` item.
12
+ - **Model list cache time.** A cache entry with a time in the future is stale. It no longer stays fresh forever.
13
+ - **More token count forms.** A context overflow message with `128k`, `1m`, `131 072` (no-break space), `131'072` or `1,31,072` gives the full number to Claude Code. A decimal number after `limit` is not read as a token count. A plain `limit N` counts as the maximum only when `tokens` follows the number. A count of zero, or a count too large to be exact, is not read as a token count.
14
+ - **Output limit for every profile.** If a converted request finds no fresh model list for its profile, the proxy reads the list in the background. The request does not wait. The next request gets the limit. Each profile keeps its own limit, also on a shared base URL. The proxy reads `<baseURL>/models` with the API key of the profile, except for a profile with an `endpoints` override. That profile gets no output limit. The `/v1/models` list also does not send the key of that profile to `<baseURL>/models`.
15
+ - **Goal check when the evaluator call fails.** If the native evaluator call fails, the goal check does not let the goal through. A test now holds this behavior.
16
+ - **Test cleanup.** Every test file that makes a temp directory deletes it, also when a test fails. `npm test` deletes `node-compile-cache` only when npm made it for this run. A failed delete no longer changes the exit status of the tests.
17
+
18
+ ## Release 1.6.12 — 2026-10-11
19
+
20
+ - **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.
21
+ - **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.
22
+ - **Context overflow over WebSocket.** A Codex turn on WebSocket fails with the code `context_length_exceeded`, the same as on HTTP.
23
+ - **Goal check and images.** The goal-check request to a chat upstream uses the same image retry as other requests.
24
+ - **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.
25
+ - **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] `.
26
+ - **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.
27
+ - **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.
28
+ - **`replayReasoning` documentation.** The README files now say that `reasoning_content` goes out only on a request with thinking on.
29
+
3
30
  ## Release 1.6.11 — 2026-10-11
4
31
 
5
32
  - **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.
package/README.md CHANGED
@@ -519,7 +519,8 @@ switch status # Display status for all active CLI targets
519
519
  switch version # Show the version and tell you when npm has a newer one
520
520
  switch doctor # Audit environment, settings & routing
521
521
  switch update # Install the newest release and restart the gateway on it
522
- switch on [profile] # Start the gateway and activate a profile
522
+ switch on # Start the gateway; each tool gets back the profile it had before `switch off`
523
+ switch on <profile> # Start the gateway and activate a profile
523
524
  switch <profile> # Activate a profile for both tools
524
525
  switch claude <profile> # Set the active profile for Claude Code only
525
526
  switch codex <profile> # Set the active profile for Codex only
@@ -869,7 +870,7 @@ Because of this split, a provider fingerprint is never a rule in this gateway. I
869
870
  | `LLM_SWITCHER_BIFROST_RETRY_MS` | Time before the gateway asks intact again after a failed Bifrost lookup (default 30000 ms). |
870
871
  | `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
872
  | `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. |
873
+ | `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
874
  | `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
875
  | `profile.endpoints.countTokens` | Override the Anthropic `count_tokens` URL. |
875
876
  | `profile.endpoints` | Override upstream URLs per format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
package/README.vi.md CHANGED
@@ -516,7 +516,8 @@ switch status # Xem trạng thái kích hoạt của tất cả
516
516
  switch version # Xem version đang chạy và báo khi npm có bản mới
517
517
  switch doctor # Quét & thanh tra toàn bộ môi trường, settings và định tuyến
518
518
  switch update # Cài bản mới nhất và khởi động lại gateway trên bản đó
519
- switch on [profile] # Khởi động gateway và kích hoạt một profile
519
+ switch on # Khởi động gateway; mỗi công cụ lấy lại profile nó có trước `switch off`
520
+ switch on <profile> # Khởi động gateway và kích hoạt một profile
520
521
  switch <profile> # Kích hoạt một profile cho cả hai công cụ
521
522
  switch claude <profile> # Đặt profile kích hoạt riêng cho Claude Code
522
523
  switch codex <profile> # Đặt profile kích hoạt riêng cho Codex
@@ -864,7 +865,7 @@ Nhờ cách chia này, fingerprint của provider không bao giờ là rule tron
864
865
  | `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
866
  | 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
867
  | `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. |
868
+ | `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
869
  | `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
870
  | `profile.endpoints.countTokens` | Ghi đè URL `count_tokens` của Anthropic. |
870
871
  | `profile.endpoints` | Ghi đè URL upstream theo từng format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
package/formats.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
  // }
25
25
  // ============================================================
26
26
 
27
- import { sanitizeToolName, toolNameMapForIR, rewriteToolNameMentions, rewriteToolSearchArgs } from './toolvocab.mjs';
27
+ import { sanitizeToolName, toolNameMapForIR, rewriteToolNameMentions, rewriteToolSearchArgs, createToolVocab, NULL_TOOL_VOCAB } from './toolvocab.mjs';
28
28
  import { decodeCompaction, COMPACTION_INSTRUCTION } from './compaction.mjs';
29
29
 
30
30
  export const OUT_FORMATS = ['openai-chat', 'anthropic', 'vertex'];
@@ -455,9 +455,22 @@ function anthropicToIR(payload) {
455
455
  }
456
456
  }
457
457
 
458
+ // Messages has no system role. Left in place, the emitters send such a turn as user text, so fold
459
+ // it into the system prompt, as responsesToIR and the Chat reader already do.
460
+ const foldSystemTurn = (msg) => {
461
+ if (msg.role !== 'system' && msg.role !== 'developer') return false;
462
+ const text =
463
+ typeof msg.content === 'string'
464
+ ? msg.content
465
+ : (msg.content || []).filter(p => p && p.type === 'text').map(p => p.text).join('\n');
466
+ if (text.trim()) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
467
+ return true;
468
+ };
469
+
458
470
  if (Array.isArray(payload.messages)) {
459
471
  for (const msg of payload.messages) {
460
472
  if (!msg) continue;
473
+ if (foldSystemTurn(msg)) continue;
461
474
  if (typeof msg.content === 'string') {
462
475
  ir.messages.push({ role: msg.role, content: msg.content });
463
476
  continue;
@@ -693,10 +706,12 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
693
706
  if (text) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
694
707
  };
695
708
 
696
- // A reasoning item precedes the assistant output it belongs to.
709
+ // A reasoning item precedes the assistant output it belongs to. A user message or a compaction item
710
+ // ends that turn, so reasoning still pending there came from an aborted turn and is dropped.
697
711
  let reasoning = '';
712
+ const joinLines = (a, b) => (a ? `${a}\n${b}` : b);
698
713
  const takeReasoning = (m) => {
699
- if (reasoning && m?.role === 'assistant') { m.reasoning = (m.reasoning || '') + reasoning; reasoning = ''; }
714
+ if (reasoning && m?.role === 'assistant') { m.reasoning = joinLines(m.reasoning || '', reasoning); reasoning = ''; }
700
715
  };
701
716
 
702
717
  // Parallel tool calls arrive as consecutive items -> merge into 1 assistant turn,
@@ -715,7 +730,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
715
730
  ir.messages.push({ role: 'user', content: input });
716
731
  } else if (Array.isArray(input)) {
717
732
  for (const item of input) {
718
- if (typeof item === 'string') { pushText('user', item); continue; }
733
+ if (typeof item === 'string') { reasoning = ''; pushText('user', item); continue; }
719
734
  if (!item || typeof item !== 'object') continue;
720
735
  // EasyInputMessage ({role, content}) doesn't require `type`.
721
736
  if (item.type === 'message' || (!item.type && item.role)) {
@@ -739,6 +754,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
739
754
  continue;
740
755
  }
741
756
  const role = item.role === 'assistant' ? 'assistant' : 'user';
757
+ if (role === 'user') reasoning = '';
742
758
  if (parts.every(p => p.type === 'text')) {
743
759
  pushText(role, parts.map(p => p.text).join('\n'));
744
760
  } else {
@@ -747,7 +763,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
747
763
  if (role === 'assistant') takeReasoning(ir.messages[ir.messages.length - 1]);
748
764
  } else if (item.type === 'reasoning') {
749
765
  const texts = (Array.isArray(item.summary) ? item.summary : []).map(x => x?.text).filter(t => typeof t === 'string' && t);
750
- reasoning += texts.join('\n');
766
+ if (texts.length) reasoning = joinLines(reasoning, texts.join('\n'));
751
767
  } else if (item.type === 'function_call') {
752
768
  pushCall({ id: item.call_id || item.id || null, name: responsesToolName(item.namespace, item.name), args: parseArgs(item.arguments) });
753
769
  } else if (item.type === 'custom_tool_call') {
@@ -760,6 +776,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
760
776
  } else if (item.type === 'additional_tools' && Array.isArray(item.tools)) {
761
777
  toolList.push(...item.tools);
762
778
  } else if (item.type === 'compaction') {
779
+ reasoning = '';
763
780
  if (allowOpaqueCompaction && !item.encrypted_content?.startsWith('llm-gateway-compact-v1:')) { ir.opaqueCompaction = true; continue; }
764
781
  pushText('user', `Conversation summary from an earlier turn:\n${decodeCompaction(item.encrypted_content)}`);
765
782
  } else if (item.type === 'compaction_trigger') {
@@ -1560,6 +1577,112 @@ function collapseNullableUnion(branches) {
1560
1577
  // key keeps the type an object; the root of a tool with no arguments may stay empty.
1561
1578
  const GEMINI_PLACEHOLDER_PROPERTIES = { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } };
1562
1579
 
1580
+ // A node the padding produced: its only property is the placeholder. Matched by shape, not by
1581
+ // reference, because the schema is deep-copied again before it is sent.
1582
+ function isGeminiPadded(schema) {
1583
+ const props = schema?.properties;
1584
+ const p = props?._placeholder;
1585
+ return Boolean(p && Object.keys(props).length === 1 && p.type === GEMINI_PLACEHOLDER_PROPERTIES._placeholder.type
1586
+ && p.description === GEMINI_PLACEHOLDER_PROPERTIES._placeholder.description);
1587
+ }
1588
+
1589
+ // Removes `_placeholder` from the arguments wherever the schema sent upstream was padded, and only there.
1590
+ export function stripGeminiPlaceholders(value, schema) {
1591
+ if (!value || typeof value !== 'object' || !schema || typeof schema !== 'object') return value;
1592
+ if (Array.isArray(value)) {
1593
+ if (!schema.items) return value;
1594
+ const out = value.map(v => stripGeminiPlaceholders(v, schema.items));
1595
+ return out.some((v, i) => v !== value[i]) ? out : value;
1596
+ }
1597
+ if (isGeminiPadded(schema)) {
1598
+ if (!Object.hasOwn(value, '_placeholder')) return value;
1599
+ const { _placeholder, ...rest } = value;
1600
+ return rest;
1601
+ }
1602
+ let out = value;
1603
+ const props = schema.properties && typeof schema.properties === 'object' ? schema.properties : {};
1604
+ for (const [k, v] of Object.entries(value)) {
1605
+ if (!Object.hasOwn(props, k)) continue;
1606
+ const next = stripGeminiPlaceholders(v, props[k]);
1607
+ if (next !== v) out = { ...out, [k]: next };
1608
+ }
1609
+ // A branch that declares a real `_placeholder` makes the key the client's own, so no padded branch strips it.
1610
+ const branches = Array.isArray(schema.anyOf) ? schema.anyOf : [];
1611
+ const declared = branches.some(b => !isGeminiPadded(b) && b?.properties && Object.hasOwn(b.properties, '_placeholder'));
1612
+ for (const branch of branches) if (!(declared && isGeminiPadded(branch))) out = stripGeminiPlaceholders(out, branch);
1613
+ return out;
1614
+ }
1615
+
1616
+ function hasGeminiPadding(schema) {
1617
+ if (!schema || typeof schema !== 'object') return false;
1618
+ if (isGeminiPadded(schema)) return true;
1619
+ return Object.values(schema.properties || {}).some(hasGeminiPadding) || hasGeminiPadding(schema.items)
1620
+ || (Array.isArray(schema.anyOf) && schema.anyOf.some(hasGeminiPadding));
1621
+ }
1622
+
1623
+ // The upstream tool name -> the schema sent, for each tool whose schema was padded. Reads both the
1624
+ // Vertex and the chat body shape; an empty map means no argument needs the strip.
1625
+ export function geminiPaddedTools(upBody) {
1626
+ const padded = new Map();
1627
+ for (const t of Array.isArray(upBody?.tools) ? upBody.tools : []) {
1628
+ const decls = Array.isArray(t?.functionDeclarations) ? t.functionDeclarations : t?.function ? [t.function] : [];
1629
+ for (const d of decls) if (d?.name && hasGeminiPadding(d.parameters)) padded.set(d.name, d.parameters);
1630
+ }
1631
+ return padded;
1632
+ }
1633
+
1634
+ // The tool vocabulary, plus the strip for padded tools. A renderer holds the arguments of a tool
1635
+ // whose needsArgsRewrite is true until the JSON is whole, so the strip also works on a stream.
1636
+ export function withGeminiPlaceholderStrip(vocab, padded) {
1637
+ if (!padded?.size) return vocab;
1638
+ // By client name too. Two padded tools that one client name maps back to make that name
1639
+ // ambiguous, so it gets no strip, as the vocabulary refuses a tie.
1640
+ const byClient = new Map();
1641
+ for (const [upstream, schema] of padded) {
1642
+ const client = vocab.name(upstream);
1643
+ byClient.set(client, byClient.has(client) ? null : schema);
1644
+ }
1645
+ const schemaOf = (name) => padded.get(name) || byClient.get(vocab.name(name)) || byClient.get(name);
1646
+ const args = (name, argsJson) => {
1647
+ const out = vocab.args(name, argsJson);
1648
+ const schema = schemaOf(name);
1649
+ if (!schema) return out;
1650
+ let parsed;
1651
+ try { parsed = JSON.parse(out || '{}'); } catch { return out; }
1652
+ const stripped = stripGeminiPlaceholders(parsed, schema);
1653
+ return stripped === parsed ? out : JSON.stringify(stripped);
1654
+ };
1655
+ return {
1656
+ ...vocab,
1657
+ args,
1658
+ needsArgsRewrite: (name) => Boolean(schemaOf(name)) || Boolean(vocab.needsArgsRewrite?.(name)),
1659
+ repair(upstream, argsJson) {
1660
+ const fixedName = vocab.name(upstream);
1661
+ return { name: fixedName, args: args(upstream, argsJson), renamed: fixedName !== upstream };
1662
+ }
1663
+ };
1664
+ }
1665
+
1666
+ // The tool vocabulary the caller declared, so a model that answers with a name from its own
1667
+ // training is written back in the caller's words instead of ending the turn with "No such tool
1668
+ // available". Built once per request from the tools the caller actually sent, so a request that
1669
+ // declared none pays nothing and a turn where the model agreed with the list changes nothing.
1670
+ export function toolVocabForIR(ir, outFormat) {
1671
+ if (!ir || typeof ir !== 'object') return NULL_TOOL_VOCAB;
1672
+ if (ir._vocab?.outFormat !== outFormat) {
1673
+ const vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
1674
+ ir._vocab = { outFormat, vocab: withGeminiPlaceholderStrip(vocab, ir._geminiPadded) };
1675
+ }
1676
+ return ir._vocab.vocab;
1677
+ }
1678
+
1679
+ // Read by toolVocabForIR when the answer is rendered: the arguments lose the keys padding added.
1680
+ // Called on every emit, so a rebuild of the same IR never keeps the strip of an earlier body.
1681
+ export function recordGeminiPadding(ir, upBody, gemini) {
1682
+ ir._geminiPadded = gemini && Array.isArray(upBody?.tools) ? geminiPaddedTools(upBody) : null;
1683
+ ir._vocab = null;
1684
+ }
1685
+
1563
1686
  function toGeminiSchema(schema, root, seen) {
1564
1687
  const nested = Boolean(root);
1565
1688
  const emptyProperties = () => (nested ? { ...GEMINI_PLACEHOLDER_PROPERTIES } : {});
@@ -1744,8 +1867,28 @@ function endsInUnsignedToolLoop(messages) {
1744
1867
  function healAnthropicPayload(payload) {
1745
1868
  const notes = [];
1746
1869
  if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
1747
- const src = payload.messages.filter(m => m && (m.role === 'user' || m.role === 'assistant'));
1748
- let changed = src.length !== payload.messages.length;
1870
+ // Anthropic has no system role inside messages. Its text becomes a user turn in the same place, so it
1871
+ // never moves past an assistant turn; Step 0 merges it into a user turn beside it, Steps 1-3 keep a tool_result first.
1872
+ const src = [];
1873
+ let folded = false;
1874
+ let droppedSystem = false;
1875
+ for (const m of payload.messages) {
1876
+ if (m?.role === 'system') {
1877
+ const blocks = toAnthropicBlocks(m.content);
1878
+ const kept = blocks.filter(b => b.type === 'text' && !isBlankText(b));
1879
+ if (!kept.length || kept.length !== blocks.length) droppedSystem = true;
1880
+ if (kept.length) {
1881
+ src.push({ role: 'user', content: [{ type: 'text', text: `[system] ${kept.map(b => b.text).join('\n')}` }] });
1882
+ folded = true;
1883
+ }
1884
+ continue;
1885
+ }
1886
+ if (!m || (m.role !== 'user' && m.role !== 'assistant')) continue;
1887
+ src.push(m);
1888
+ }
1889
+ if (folded) notes.push('folded system messages into user turns');
1890
+ if (droppedSystem) notes.push('dropped system content with no text');
1891
+ let changed = folded || droppedSystem || src.length !== payload.messages.length;
1749
1892
 
1750
1893
  let system = payload.system;
1751
1894
  if (Array.isArray(system) && system.some(isBlankText)) {
@@ -2753,7 +2896,7 @@ function vertexUsage(prompt, completion, cached, reasoning) {
2753
2896
  return u;
2754
2897
  }
2755
2898
 
2756
- function buildVertexMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, sig, ids = false }) {
2899
+ function buildVertexMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, sig, ids = false, vocab = null }) {
2757
2900
  const parts = [];
2758
2901
  const thinking = (think || []).join('');
2759
2902
  if (thinking) {
@@ -2764,7 +2907,8 @@ function buildVertexMessage({ model, think, text, tools, finish, prompt, complet
2764
2907
  const body = (text || []).join('');
2765
2908
  if (body) parts.push({ text: body });
2766
2909
  for (const tc of (tools || [])) {
2767
- const part = { functionCall: { ...(ids && tc.id ? { id: tc.id } : {}), name: tc.name || 'tool', args: parseArgs(tc.args) } };
2910
+ const args = vocab && typeof tc.args === 'string' ? vocab.args(tc.name, tc.args) : tc.args;
2911
+ const part = { functionCall: { ...(ids && tc.id ? { id: tc.id } : {}), name: tc.name || 'tool', args: parseArgs(args) } };
2768
2912
  const tsig = tc.sig || lookupToolSignature(tc.id);
2769
2913
  if (tsig) part.thoughtSignature = tsig;
2770
2914
  parts.push(part);
@@ -2778,7 +2922,7 @@ function buildVertexMessage({ model, think, text, tools, finish, prompt, complet
2778
2922
  }
2779
2923
 
2780
2924
  // ids: name each functionCall, as Code Assist does. Plain Vertex has no call id.
2781
- function createVertexStream(emit, model, { ids = false } = {}) {
2925
+ function createVertexStream(emit, model, { ids = false, vocab = null } = {}) {
2782
2926
  const cand = (parts) => emit(null, { candidates: [{ content: { role: 'model', parts }, index: 0 }], modelVersion: model });
2783
2927
  // Tool args arrive from OpenAI/Anthropic as deltas; Vertex needs complete functionCalls -> buffer them, emit at the end.
2784
2928
  const tools = new Map();
@@ -2805,7 +2949,8 @@ function createVertexStream(emit, model, { ids = false } = {}) {
2805
2949
  const parts = [...tools.values()]
2806
2950
  .filter(t => t.name)
2807
2951
  .map(t => {
2808
- const part = { functionCall: { ...(ids && t.id ? { id: t.id } : {}), name: t.name, args: parseArgs(t.args || '{}') } };
2952
+ const args = vocab ? vocab.args(t.name, t.args || '{}') : (t.args || '{}');
2953
+ const part = { functionCall: { ...(ids && t.id ? { id: t.id } : {}), name: t.name, args: parseArgs(args) } };
2809
2954
  const tsig = t.sig || lookupToolSignature(t.id);
2810
2955
  if (tsig) part.thoughtSignature = tsig;
2811
2956
  return part;
@@ -2830,10 +2975,10 @@ function codeAssistTrace() {
2830
2975
  return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
2831
2976
  }
2832
2977
 
2833
- function createCodeAssistStream(emit, model) {
2978
+ function createCodeAssistStream(emit, model, { vocab = null } = {}) {
2834
2979
  const traceId = codeAssistTrace();
2835
2980
  const responseId = `agy-${traceId}`;
2836
- return createVertexStream((e, d) => emit(e, d?.error ? d : { response: { ...d, responseId }, traceId }), model, { ids: true });
2981
+ return createVertexStream((e, d) => emit(e, d?.error ? d : { response: { ...d, responseId }, traceId }), model, { ids: true, vocab });
2837
2982
  }
2838
2983
 
2839
2984
  function buildCodeAssistMessage(args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.11",
3
+ "version": "1.6.13",
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,6 +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
+ toolVocabForIR, recordGeminiPadding,
14
15
  toGeminiSchema, isAntigravityModel, refusesImageInput, omitChatImages,
15
16
  createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
16
17
  buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
@@ -26,8 +27,7 @@ import {
26
27
  codexModelEntry,
27
28
  contractLabSettings, STATE_DIR, stopRecordedBlindfold
28
29
  } from './state.mjs';
29
- import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows, readWindowsCache } from './windows.mjs';
30
- import { createToolVocab, NULL_TOOL_VOCAB, toolNameMapForIR } from './toolvocab.mjs';
30
+ import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows, windowsCacheKey, cachedProfileWindows, createWindowsRefresher } from './windows.mjs';
31
31
  import { readUpstreamPayloads } from './upstream-stream.mjs';
32
32
  import { compactionResponse, emitCompaction, expandGatewayCompactions, collectCompaction } from './compaction.mjs';
33
33
  import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
@@ -386,10 +386,26 @@ function isContextOverflow(status, message) {
386
386
  }
387
387
 
388
388
  // Claude Code compacts on "prompt is too long: N tokens > M maximum", so the numbers lead when the upstream names them.
389
+ // A count may group its digits (131,072 / 131.072 / 131 072 / 131'072 / 1,31,072, also with U+00A0 or U+202F) or
390
+ // carry a k or m suffix. A dot before exactly three digits reads as a group, so 3.141 reads as 3141: the cost of
391
+ // reading 131.072. Any other decimal part (0.125, 3.14) with no k or m after it is no token count.
392
+ const TOKEN_COUNT = String.raw`(\d+(?:\.\d+)?[km](?![a-z])|[1-9]\d?(?:,\d\d)+,\d{3}(?![.,]?\d)|[1-9]\d{0,2}(?:[,. _'\u00a0\u202f]\d{3})+(?![.,]?\d)|\d+(?![.,]?\d))`;
393
+ // A bare "limit" may be a rate or a time, so it counts only when "tokens" follows the number.
394
+ const MAX_TOKENS_RE = new RegExp(String.raw`(?:maximum context length|context window|tokens? limit) (?:is |of )?${TOKEN_COUNT}|limit (?:is |of )?${TOKEN_COUNT}(?= ?tokens?\b)`, 'i');
395
+ const ASKED_TOKENS_RE = new RegExp(String.raw`(?:requested|resulted in|prompt (?:is|has|contains)|input (?:is|has)) (?:about )?` + TOKEN_COUNT, 'i');
396
+
397
+ function tokenCount(text) {
398
+ if (!text) return '';
399
+ const short = text.match(/^(\d+(?:\.\d+)?)([km])$/i);
400
+ const n = short ? Math.round(Number(short[1]) * (short[2].toLowerCase() === 'k' ? 1e3 : 1e6)) : Number(text.replace(/\D/g, ''));
401
+ return Number.isSafeInteger(n) && n > 0 ? String(n) : '';
402
+ }
403
+
389
404
  function promptTooLongMessage(message) {
390
405
  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];
406
+ const m = message.match(MAX_TOKENS_RE);
407
+ const max = tokenCount(m?.[1] ?? m?.[2]);
408
+ const asked = tokenCount(message.match(ASKED_TOKENS_RE)?.[1]);
393
409
  return max && asked ? `prompt is too long: ${asked} tokens > ${max} maximum (${message})` : `prompt is too long: ${message}`;
394
410
  }
395
411
 
@@ -488,21 +504,11 @@ function upstreamEndpoint(profile, outFormat, model, stream, req) {
488
504
  return { url: ov['openai-chat'] || `${base}/chat/completions`, headers };
489
505
  }
490
506
 
491
- // The tool vocabulary the caller declared, so a model that answers with a name from its own
492
- // training is written back in the caller's words instead of ending the turn with "No such tool
493
- // available". Built once per request from the tools the caller actually sent, so a request that
494
- // declared none pays nothing and a turn where the model agreed with the list changes nothing.
495
- function toolVocabFor(ir, outFormat) {
496
- 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()] });
498
- return ir._vocab;
499
- }
500
-
501
507
  function clientRenderer(clientFormat, res, model, opts = {}) {
502
508
  if (clientFormat === 'anthropic') return createAnthropicStream((e, d) => sendSSE(res, e, d), model, opts);
503
509
  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);
510
+ if (clientFormat === 'vertex') return createVertexStream((e, d) => sendSSE(res, null, d), model, opts);
511
+ if (clientFormat === 'codeassist') return createCodeAssistStream((e, d) => sendSSE(res, null, d), model, opts);
506
512
  return createChatStream((e, d) => sendSSE(res, null, d), model, opts);
507
513
  }
508
514
 
@@ -781,7 +787,7 @@ async function maybeCompactIdle(req, clientFormat, payload, ir, profile, profile
781
787
  const summaryModel = mapModel(policy.model, profile, clientFormat);
782
788
  const summaryFormat = resolveOutFormat(profile, summaryModel);
783
789
  askSummary({ ir: prefixIR, policy, profile, model: summaryModel,
784
- build: (prof, model, summaryIR) => buildUpstreamRequest(prof, summaryFormat, summaryIR, model, req),
790
+ build: (prof, model, summaryIR) => buildUpstreamRequest(prof, summaryFormat, summaryIR, model, req, null, profileKey),
785
791
  onDiagnostic: ({ code, status }) => console.warn(`[llm-switcher] idle compact ${key.slice(0, 12)}: ${code}${status ? ` (${status})` : ''}`),
786
792
  }).then(summary => {
787
793
  if (!summary) return;
@@ -828,24 +834,30 @@ function resolveThinkingEffort(profile, slot, mappedModel) {
828
834
  return profile?.thinkingEffort || 'auto';
829
835
  }
830
836
 
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) {
837
+ const refreshProfileWindows = createWindowsRefresher(STATE_DIR, { debug: (...a) => debugLog(...a) });
838
+
839
+ // A profile with an endpoints override sends its requests past baseURL, so its key must not go to baseURL/models.
840
+ const routesThroughOverride = (profile) => Object.values(profile?.endpoints || {}).some(Boolean);
841
+
842
+ // The output limit this profile's own model list names for this model, or 0 when no fresh cached
843
+ // list names one. With no fresh entry the list is read in the background; this request never waits.
844
+ function modelOutputLimit(profileKey, profile, mappedModel) {
834
845
  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;
846
+ if (routesThroughOverride(profile)) return 0;
847
+ const key = windowsCacheKey(profileKey, profile.baseURL);
848
+ const models = cachedProfileWindows(STATE_DIR, key);
849
+ if (!Object.keys(models).length) refreshProfileWindows(key, profile.baseURL, profile.apiKey);
850
+ const output = Number(models[mappedModel]?.output);
851
+ return output > 0 ? output : 0;
840
852
  }
841
853
 
842
- function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null) {
854
+ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null, profileKey = null) {
843
855
  const effort = resolveThinkingEffort(profile, slot, mappedModel);
844
856
  const upBody = emitUpstreamBody(outFormat, ir, mappedModel, {
845
857
  thinkingMode: profile.thinkingMode,
846
858
  thinkingEffort: effort,
847
859
  replayReasoning: profile.replayReasoning === true,
848
- maxOutputTokens: modelOutputLimit(profile, mappedModel),
860
+ maxOutputTokens: modelOutputLimit(profileKey, profile, mappedModel),
849
861
  });
850
862
  if (upBody?.tools && Array.isArray(upBody.tools)) {
851
863
  upBody.tools = cleanSchemaDeep(upBody.tools);
@@ -853,6 +865,7 @@ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = n
853
865
  upBody.tools = geminiSafeTools(upBody.tools);
854
866
  }
855
867
  }
868
+ recordGeminiPadding(ir, upBody, outFormat === 'vertex' || (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)));
856
869
  const { url, headers } = upstreamEndpoint(profile, outFormat, mappedModel, ir.stream, req);
857
870
  return { url, headers, upBody };
858
871
  }
@@ -1001,7 +1014,7 @@ async function executeGoalCheckGate({ req, res, clientFormat, payload, ir, profi
1001
1014
  try {
1002
1015
  const nativePromise = (async () => {
1003
1016
  try {
1004
- let url, headers, body;
1017
+ let url, headers, body, upBody = null;
1005
1018
  if (bifrost) {
1006
1019
  url = bifrostURL(profile, clientFormat, req, opts);
1007
1020
  headers = bifrostHeaders(req, profile);
@@ -1019,16 +1032,18 @@ async function executeGoalCheckGate({ req, res, clientFormat, payload, ir, profi
1019
1032
  body = Buffer.from(JSON.stringify(json), 'utf8');
1020
1033
  } else {
1021
1034
  const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
1022
- const reqData = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
1035
+ const reqData = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
1023
1036
  url = reqData.url;
1024
1037
  headers = reqData.headers;
1025
- body = JSON.stringify(reqData.upBody);
1038
+ upBody = reqData.upBody;
1026
1039
  }
1027
1040
 
1028
1041
  const traceId = clientFormat === 'codeassist' ? null : (probeTraceId(req) || contractLab.traceFor(mappedModel));
1029
1042
  if (traceId) headers['x-intact-trace'] = traceId;
1030
1043
 
1031
- const upRes = await fetch(url, { method: 'POST', headers, body, signal: gateAC.signal });
1044
+ const upRes = upBody
1045
+ ? await fetchUpstream(url, headers, upBody, gateAC.signal, outFormat, profileKey, mappedModel)
1046
+ : await fetch(url, { method: 'POST', headers, body, signal: gateAC.signal });
1032
1047
  if (!upRes.ok) {
1033
1048
  const errText = await upRes.text().catch(() => '');
1034
1049
  return { malformed: true, error: `Upstream HTTP ${upRes.status}: ${errText.slice(0, 150)}` };
@@ -1302,7 +1317,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1302
1317
  return log({ status: 400, error: 'unsupported compaction state' });
1303
1318
  }
1304
1319
  const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
1305
- const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
1320
+ const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
1306
1321
  if (traceId) headers['x-intact-trace'] = traceId;
1307
1322
  debugLog(`[${profileKey}] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 500));
1308
1323
 
@@ -1372,7 +1387,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1372
1387
  const out = clientMessage(clientFormat, {
1373
1388
  model: requestedModel || mappedModel, think, text: [split.text], tools, toolMeta: ir.toolMeta,
1374
1389
  finish: col.finish, prompt: col.prompt, completion, cached: col.cached,
1375
- reasoning: col.reasoning, sig: col.sig, vocab: toolVocabFor(ir, outFormat)
1390
+ reasoning: col.reasoning, sig: col.sig, vocab: toolVocabForIR(ir, outFormat)
1376
1391
  });
1377
1392
  sendJson(res, 200, out);
1378
1393
  answered = true;
@@ -1389,7 +1404,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1389
1404
  'Connection': 'keep-alive',
1390
1405
  'X-Accel-Buffering': 'no'
1391
1406
  });
1392
- const renderer = clientRenderer(clientFormat, res, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir, outFormat) });
1407
+ const renderer = clientRenderer(clientFormat, res, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabForIR(ir, outFormat) });
1393
1408
  renderer.start();
1394
1409
  const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
1395
1410
  const streamError = await pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal: ac.signal, sink: res, tag: profileKey });
@@ -1962,7 +1977,8 @@ async function servedModels(req) {
1962
1977
  // still gets the cached numbers, because an entry with the template window is better than none.
1963
1978
  async function modelWindowsFor(profileKey, profile) {
1964
1979
  if (!profile?.baseURL) return {};
1965
- const listed = await resolveProfileWindows(STATE_DIR, `${profileKey || 'profile'}@${profile.baseURL}`, profile.baseURL, profile.apiKey);
1980
+ if (routesThroughOverride(profile)) return readLocalCodexWindows();
1981
+ const listed = await resolveProfileWindows(STATE_DIR, windowsCacheKey(profileKey, profile.baseURL), profile.baseURL, profile.apiKey);
1966
1982
  if (Object.keys(listed).length) return listed;
1967
1983
  return readLocalCodexWindows();
1968
1984
  }
@@ -2385,7 +2401,7 @@ function sendWsFailed(socket, model, message, status = 500, tap = null, code = n
2385
2401
  if (socket.writable) socket.write(encodeWsFrame(JSON.stringify(d)));
2386
2402
  }, model || 'main');
2387
2403
  renderer.start();
2388
- renderer.error(message, code || responsesErrorCode(status));
2404
+ renderer.error(message, code || (isContextOverflow(status, message) ? 'context_length_exceeded' : responsesErrorCode(status)));
2389
2405
  }
2390
2406
 
2391
2407
  // Codex on WebSocket sends only the new items of a turn and names the turn before in
@@ -2573,7 +2589,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2573
2589
  let answered = false; // set only after a complete answer; the half upload depends on it
2574
2590
  try {
2575
2591
  const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
2576
- const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
2592
+ const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
2577
2593
  if (traceId) headers['x-intact-trace'] = traceId;
2578
2594
  debugLog(`[${profileKey}:ws] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 300));
2579
2595
 
@@ -2619,7 +2635,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2619
2635
  if (socket.writable) {
2620
2636
  socket.write(encodeWsFrame(JSON.stringify(d)));
2621
2637
  }
2622
- }, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir, outFormat) });
2638
+ }, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabForIR(ir, outFormat) });
2623
2639
 
2624
2640
  renderer.start();
2625
2641
  const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));