llm-switcher 1.6.7 → 1.6.9

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.9 — 2026-10-11
4
+
5
+ - **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.
6
+ - **Foreign tool ids.** Tool ids that do not match `^[a-zA-Z0-9_-]+$` (for example `functions.Read:0`) are rewritten before they go to Anthropic. The rewrite is deterministic, so a replayed history gives the same id.
7
+ - **Empty text blocks.** The direct Anthropic route removes empty `system` blocks and empty text blocks. Anthropic refused them with HTTP 400.
8
+ - **Cut streams.** If an upstream stream closes without a finish reason and without `[DONE]`, the client receives an error. Before, the client received a completed turn with a partial tool call.
9
+ - **Gemini `MALFORMED_FUNCTION_CALL`.** This finish reason is now an error, not an empty turn.
10
+ - **Empty tool arguments.** An empty arguments string becomes `{}`, not `{"raw":""}`.
11
+
12
+ ## Release 1.6.8 — 2026-10-09
13
+
14
+ - **Reorganized Sidebar Navigation by Frequency of Use:** Reordered sidebar navigation to align strictly with developer operational workflows: 1. `Routes & Profiles` (daily configuration & profile switching), 2. `Request Inspector` (live request monitoring & debugging), 3. `Model Catalog` (model discovery & testing), 4. `System Doctor` (health checks, port conflicts & shims), 5. `Goal Check` (agent task completion verification & veto), 6. `Idle Compaction` (idle prefix compaction & cache metrics), and 7. `Docs & Guide` (reference manual & technical guides at the bottom of navigation).
15
+ - **Dedicated intact Gateway Upstream Pooler Guide (`#doc-intact`):** Added a comprehensive, in-depth installation and operational manual for intact inside the Web UI Documentation hub (`#/docs`). Covers Zero-CGO Go build & npm global install, CLI flags, environment variables, systemd service unit setup, Cloudflare Tunnel ingress, TOTP zero-password authentication, account rotation (Round Robin 1 vs N reqs for prompt caching, Fallback, Standby), conversation stickiness, 429 Retry Shorter, Blacklist auto-healing, Bifrost passthrough, and Contract Lab synchronization.
16
+ - **Dedicated Documentation & User Guide Hub (`#/docs`):** Integrated a full-featured, zero-dependency documentation viewer into the Web UI. Features a sticky scroll-spy table of contents, 1-click code copy with clipboard toast feedback, and 11 comprehensive guides covering edge gateway architecture, cross-platform installation, intact upstream pooler, complete CLI cheatsheet, Blindfold TLS interception & CA safety guarantees, Antigravity CLI (`agy`) relay security, token optimizer interop with the Healer engine, Agent Skill (`SKILL.md`) & MCP server setup, Idle Compaction, Jev Goal Check gate, annotated `config.json` schema, and troubleshooting.
17
+ - **Audit & Documentation Alignment:** Reconciled documentation across README.md, README.vi.md, agent skills, and Model Context Protocol definitions to strictly reflect v1.6.8 multi-active routing, zero-mutation invariants, and Bifrost passthrough dynamics.
18
+
3
19
  ## Release 1.6.7 — 2026-10-09
4
20
 
5
21
  - **Jev Goal Check Interception Gate:** Intercept Claude Code stopping condition evaluators (`/goal`) and verify task completion independently using Jev System One typed decision judgments (`complete`, `evidence`, `unfinished`). Fails closed on missing or unverified work, preventing premature completion claims when models merely edit reports rather than implementing fixes.
package/README.md CHANGED
@@ -597,6 +597,23 @@ reuse a stable prefix. Native Codex compaction remains Codex's own responsibilit
597
597
  When using intact, configure the profile with an authorized intact API key. Client User-Agent
598
598
  matching chooses the native protocol route; it does not replace upstream authentication.
599
599
 
600
+ ### Goal check (Jev System One evaluator veto)
601
+
602
+ In Claude Code, when setting `/goal`, a specialized prompt-based evaluator reads the transcript and
603
+ decides whether the condition was met. However, the evaluator only inspects text and can hallucinate
604
+ completion — mistaking report editing or retracted audit findings for genuine code fixes (false completion).
605
+
606
+ **Goal Check** intercepts the evaluator response before it reaches Claude Code, and queries the **Jev
607
+ (System One)** decision model across 3 independent scoring axes:
608
+ - `complete` (>= 0.90): The task condition is genuinely fulfilled.
609
+ - `evidence` (>= 0.85): Concrete tool-execution results exist (test runs, builds, file edits).
610
+ - `unfinished` (<= 0.10): No pending work, unaddressed errors, or missing verification.
611
+
612
+ **Fail-Closed principle:** The gateway yields `ok: true` only when **both** the native evaluator and Jev
613
+ agree. Any network error, timeout, missing evidence, or schema violation results in an automatic Veto
614
+ (`ok: false`), instructing the agent to provide verifiable tool evidence. Configure via the **Goal Check**
615
+ dashboard tab or `config.json` (`goalCheck`).
616
+
600
617
  ### Updates
601
618
 
602
619
  When npm has a newer release, the dashboard shows a notice. Click **Update now** in this notice. The gateway installs the release and starts the new code. Then the dashboard reloads. `switch update` does the same from a terminal.
@@ -749,7 +766,7 @@ of the routing.
749
766
  // A profile that serves Codex MUST have publicModels (see Codex-first setup).
750
767
  "publicModels": ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"], // official names for main, review, subagent
751
768
  "codexRoles": { "review": "gpt-5.6-sol" }, // optional: pair one role with another public name
752
- "defaultModels": { "main": "gemini-3.8-flash", "review": "gemini-3.7-flash-medium", "subagent": "gemini-3.6-flash-low" } "defaultModels": { "main": "gemini-3.8-flash", "review": "gemini-3.7-flash-medium", "subagent": "gemini-3.6-flash-low" },
769
+ "defaultModels": { "main": "gemini-3.8-flash", "review": "gemini-3.7-flash-medium", "subagent": "gemini-3.6-flash-low" }
753
770
  },
754
771
  "agy-default": {
755
772
  "name": "agy via intact",
@@ -763,6 +780,20 @@ of the routing.
763
780
  }
764
781
  },
765
782
  "debug": false,
783
+ "idleCompact": {
784
+ "enabled": false,
785
+ "codex": false,
786
+ "idleMinutes": 15,
787
+ "minBytes": 65536,
788
+ "model": ""
789
+ },
790
+ "goalCheck": {
791
+ "enabled": false,
792
+ "backend": "jev",
793
+ "baseURL": "https://intact.example.com/v1",
794
+ "apiKey": "sk-...",
795
+ "model": "typesafe/jev-latest"
796
+ },
766
797
  "contractLab": {
767
798
  "url": "https://intact.example.com",
768
799
  "apiKey": "sk-...",
@@ -791,10 +822,33 @@ Before it sends a sample, the gateway masks every string value with `x` of the s
791
822
  - `switch contract-probe [--model m]` sends six test requests per model and format through the gateway.
792
823
  - `switch contract-check` gets the open findings from intact and writes one test file for each lost field.
793
824
 
794
- ### Self-improvement with intact
825
+ ### Self-improvement & Operation with intact (Recommended Upstream Pooler)
826
+
827
+ [intact](https://github.com/louisphamdev/intact) is the credential proxy and multi-account pooler that this gateway recommends as its upstream. The two tools find and correct their own faults, in two closed loops.
828
+
829
+ #### Quick Install for intact
830
+ ```bash
831
+ # Global install via npm:
832
+ npm install -g intact-gateway
833
+
834
+ # Or compile from Go source (Zero-CGO, Pure-Go SQLite):
835
+ git clone https://github.com/louisphamdev/intact.git && cd intact
836
+ CGO_ENABLED=0 go build -o intact ./cmd/intact
837
+
838
+ # Run intact with a specified SQLite database:
839
+ intact -db /opt/intact/intact.db -addr 127.0.0.1:20142
840
+ ```
841
+
842
+ #### Initial Setup for intact:
843
+ 1. **Enroll TOTP:** Run `intact -db /opt/intact/intact.db -show-totp` and add the setup key to Google Authenticator, 1Password, or Aegis.
844
+ 2. **Sign In to Dashboard:** Open `http://127.0.0.1:20142`, enter the 6-digit TOTP code (zero password storage).
845
+ 3. **Add Accounts:** Under **Providers → Add**, connect API keys (Groq, OpenRouter, Vertex, DeepSeek) or coding tool OAuth accounts (Claude Code, OpenAI Codex, Antigravity `agy`, GitHub Copilot).
846
+ 4. **Create API Key:** Under **Endpoint → API keys → Create**, create a key starting with `sk-intact-...`.
847
+ 5. **Connect from Switcher:** Open `switch ui` ➔ **Routes & Profiles** ➔ **+ Add Profile** ➔ Click **intact** or **agy on intact** template to auto-fill the optimized profile configuration.
795
848
 
796
- [intact](https://github.com/louisphamdev/intact) is the credential proxy that this gateway can use as its upstream. The two tools find and correct their own faults, in two loops.
849
+ > 📖 **Comprehensive Manual:** For full CLI flags, environment variables, systemd service units, Cloudflare Tunnel ingress configuration, and deep troubleshooting, open the Web UI (`switch ui`) and navigate to the **Docs & Guide** tab (`#/docs`).
797
850
 
851
+ #### Two Self-Improvement Loops:
798
852
  - **intact corrects what providers refuse.** It records every provider error and groups the errors that recur. For a fake 429, intact replays the failing request and removes the system prompt text in halves. It keeps the smallest text that the provider refuses as a filter in its database. Every machine gets the fix at once, and this gateway needs no update. Two examples: Antigravity answered a fake 429 to "You are Codex, an agent based on GPT-5" and to "You are a Claude agent, built on Anthropic's Claude Agent SDK".
799
853
  - **This gateway corrects what its converter loses.** With the contract lab on, the gateway sends masked samples to intact. intact compares each sample with the request that it received, and records each field that the conversion lost. `switch contract-check` writes one failing test for each finding, and the fix goes into the converter.
800
854
 
package/README.vi.md CHANGED
@@ -592,6 +592,24 @@ thể nén prefix lớn hơn; các turn liên tiếp dùng lại prefix ổn đ
592
592
  Khi dùng intact, profile cần API key intact được cấp quyền. User-Agent chỉ chọn đường protocol
593
593
  gốc, không thay cho xác thực ở upstream.
594
594
 
595
+ ### Goal check (Cơ chế Veto Độc Lập Bằng Jev System One)
596
+
597
+ Trong Claude Code, khi dùng lệnh `/goal`, hệ thống sử dụng một evaluator model chuyên biệt đọc
598
+ transcript và đánh giá điều kiện đã hoàn thành hay chưa. Tuy nhiên, evaluator chỉ đọc văn bản và dễ
599
+ bị ảo giác (hallucinate) — nhầm lẫn giữa việc *sửa báo cáo / rút finding* với việc *sửa code thật sự*
600
+ (false completion).
601
+
602
+ **Goal Check** chặn phản hồi của evaluator trước khi gửi về client, và gọi mô hình quyết định **Jev
603
+ (System One)** chấm điểm độc lập trên 3 trục:
604
+ - `complete` (>= 0.90): Toàn bộ công việc theo condition đã hoàn thành thực sự.
605
+ - `evidence` (>= 0.85): Có kết quả thực thi tool trực tiếp chứng minh (chạy test, compile, sửa file).
606
+ - `unfinished` (<= 0.10): Không còn công việc dang dở, lỗi chưa xử lý hoặc verification thiếu.
607
+
608
+ **Nguyên tắc Fail-Closed:** Gateway chỉ trả về `ok: true` khi **cả hai bên** (evaluator gốc và Jev)
609
+ đồng thuận. Mọi lỗi mạng, timeout, thiếu tool evidence hay schema không hợp lệ đều dẫn đến Veto
610
+ (`ok: false`) tự động, yêu cầu agent tiếp tục thực hiện và cung cấp bằng chứng rõ ràng. Cấu hình tại
611
+ tab **Goal Check** trên Web UI hoặc trường `goalCheck` trong `config.json`.
612
+
595
613
  ### Cập nhật
596
614
 
597
615
  Khi npm có bản mới, dashboard hiện một thông báo. Bấm **Update now** trong thông báo này. Gateway cài bản mới và chạy code mới. Sau đó dashboard tự tải lại. `switch update` làm việc tương tự từ terminal.
@@ -742,7 +760,7 @@ route.
742
760
  // Profile phục vụ Codex BẮT BUỘC có publicModels (xem Cấu hình ưu tiên Codex).
743
761
  "publicModels": ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"], // tên chính thức cho main, review, subagent
744
762
  "codexRoles": { "review": "gpt-5.6-sol" }, // không bắt buộc: ghép một vai trò với tên public khác
745
- "defaultModels": { "main": "gemini-3.8-flash", "review": "gemini-3.7-flash-medium", "subagent": "gemini-3.6-flash-low" } "defaultModels": { "main": "gemini-3.8-flash", "review": "gemini-3.7-flash-medium", "subagent": "gemini-3.6-flash-low" },
763
+ "defaultModels": { "main": "gemini-3.8-flash", "review": "gemini-3.7-flash-medium", "subagent": "gemini-3.6-flash-low" }
746
764
  },
747
765
  "agy-default": {
748
766
  "name": "agy qua intact",
@@ -756,6 +774,20 @@ route.
756
774
  }
757
775
  },
758
776
  "debug": false,
777
+ "idleCompact": {
778
+ "enabled": false,
779
+ "codex": false,
780
+ "idleMinutes": 15,
781
+ "minBytes": 65536,
782
+ "model": ""
783
+ },
784
+ "goalCheck": {
785
+ "enabled": false,
786
+ "backend": "jev",
787
+ "baseURL": "https://intact.example.com/v1",
788
+ "apiKey": "sk-...",
789
+ "model": "typesafe/jev-latest"
790
+ },
759
791
  "contractLab": {
760
792
  "url": "https://intact.example.com",
761
793
  "apiKey": "sk-...",
@@ -785,10 +817,33 @@ Trước khi gửi mẫu, gateway che mọi giá trị string bằng chuỗi `x`
785
817
  - `switch contract-probe [--model m]` gửi sáu request thử cho mỗi model và mỗi format qua gateway.
786
818
  - `switch contract-check` lấy các finding còn mở từ intact và ghi một file test cho mỗi field bị mất.
787
819
 
788
- ### Tự cải thiện cùng intact
820
+ ### Tự cải thiện & Vận hành cùng intact (Upstream Pooler khuyên dùng)
821
+
822
+ [intact](https://github.com/louisphamdev/intact) là proxy giữ credential và pool đa tài khoản mà gateway này khuyên dùng làm upstream. Hai công cụ tự tìm và tự sửa lỗi của nhau theo hai vòng khép kín.
823
+
824
+ #### Cài đặt nhanh intact
825
+ ```bash
826
+ # Cài đặt toàn cục qua npm:
827
+ npm install -g intact-gateway
828
+
829
+ # Hoặc biên dịch từ nguồn Go (Zero-CGO, Pure-Go SQLite):
830
+ git clone https://github.com/louisphamdev/intact.git && cd intact
831
+ CGO_ENABLED=0 go build -o intact ./cmd/intact
832
+
833
+ # Khởi chạy intact với SQLite database chỉ định:
834
+ intact -db /opt/intact/intact.db -addr 127.0.0.1:20142
835
+ ```
836
+
837
+ #### Thiết lập ban đầu cho intact:
838
+ 1. **Lấy mã TOTP:** Chạy `intact -db /opt/intact/intact.db -show-totp` và quét/nhập setup key vào Google Authenticator, 1Password hoặc Aegis.
839
+ 2. **Đăng nhập Dashboard:** Mở trình duyệt tại `http://127.0.0.1:20142`, nhập mã 6 số TOTP (bảo mật tuyệt đối, không dùng mật khẩu văn bản).
840
+ 3. **Thêm tài khoản:** Vào mục **Providers → Add**, thêm các nhà cung cấp API key (Groq, OpenRouter, Vertex, DeepSeek) hoặc OAuth coding tools (Claude Code, OpenAI Codex, Antigravity `agy`, GitHub Copilot).
841
+ 4. **Tạo API key:** Vào **Endpoint → API keys → Create**, tạo key mới dạng `sk-intact-...`.
842
+ 5. **Định tuyến trên LLM Switcher:** Mở `switch ui` ➔ Tab **Routes & Profiles** ➔ Bấm **+ Add Profile** ➔ Chọn template **intact** hoặc **agy on intact** để tự động điền cấu hình tối ưu.
789
843
 
790
- [intact](https://github.com/louisphamdev/intact) là proxy giữ credential mà gateway này có thể dùng làm upstream. Hai công cụ tự tìm và tự sửa lỗi của nhau theo hai vòng.
844
+ > 📖 **Cẩm nang toàn diện:** Để xem toàn bộ bảng tra cứu CLI flags, biến môi trường, file service systemd, cấu hình Cloudflare Tunnel và hướng dẫn vận hành chi tiết nhất, hãy mở Web UI (`switch ui`) và chuyển sang tab **Docs & Guide** (`#/docs`).
791
845
 
846
+ #### 2 Vòng tự cải thiện giữa Switcher và intact:
792
847
  - **intact sửa những gì provider từ chối.** intact ghi lại mọi lỗi của provider và gom các lỗi lặp lại thành nhóm. Với 429 giả, intact gửi lại request lỗi và bỏ dần từng nửa system prompt. Đoạn nhỏ nhất mà provider từ chối được lưu thành filter trong database của intact. Mọi máy nhận bản sửa ngay, gateway này không cần cập nhật. Hai ví dụ: Antigravity trả 429 giả cho "You are Codex, an agent based on GPT-5" và cho "You are a Claude agent, built on Anthropic's Claude Agent SDK".
793
848
  - **Gateway này sửa những gì converter làm mất.** Khi bật contract lab, gateway gửi các mẫu đã che lên intact. intact so mỗi mẫu với request mà intact nhận được, rồi ghi lại mỗi field mà phép chuyển đổi làm mất. `switch contract-check` ghi một test đỏ cho mỗi finding, và bản sửa nằm trong converter.
794
849
 
package/formats.mjs CHANGED
@@ -39,6 +39,8 @@ function canonFinish(raw) {
39
39
  if (['length', 'max_tokens', 'max_output_tokens'].includes(r)) return 'length';
40
40
  if (['tool_calls', 'tool_call', 'function_call', 'function_calls', 'tool_use'].includes(r)) return 'tool_calls';
41
41
  if (['content_filter', 'safety', 'recitation', 'language', 'blocklist', 'prohibited_content', 'spii'].includes(r)) return 'content_filter';
42
+ // Gemini gave up on a tool call: as 'stop' the client would see a silent, empty turn.
43
+ if (r === 'malformed_function_call') return 'error';
42
44
  return 'stop';
43
45
  }
44
46
 
@@ -394,6 +396,7 @@ function parseArgs(v) {
394
396
  if (v === undefined || v === null) return {};
395
397
  if (typeof v === 'object') return v;
396
398
  if (typeof v === 'string') {
399
+ if (!v.trim()) return {};
397
400
  try { return JSON.parse(v); } catch { return { raw: v }; }
398
401
  }
399
402
  return {};
@@ -788,7 +791,9 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
788
791
  ir.thinking = thinkingFromReasoningParam(payload.reasoning);
789
792
  }
790
793
  ir.responseFormat = responseFormatIR(payload.text?.format);
791
- if (ir.compaction) { ir.tools = []; ir.toolChoice = 'none'; ir.responseFormat = null; }
794
+ // Tools stay declared: Anthropic refuses tool_use history without them, and OpenAI refuses
795
+ // tool_choice without tools. 'none' alone keeps the summary turn free of calls.
796
+ if (ir.compaction) { ir.toolChoice = ir.tools.length ? 'none' : null; ir.responseFormat = null; }
792
797
  return ir;
793
798
  }
794
799
 
@@ -1232,7 +1237,7 @@ function irToAnthropicBody(ir, model, opts = {}) {
1232
1237
  if (m.role === 'tool') {
1233
1238
  messages.push({
1234
1239
  role: 'user',
1235
- content: [{ type: 'tool_result', tool_use_id: m.toolCallId,
1240
+ content: [{ type: 'tool_result', tool_use_id: safeToolId(m.toolCallId),
1236
1241
  content: m.images?.length ? [...contentToAnthropicBlocks(m.content), ...contentToAnthropicBlocks(m.images)] : m.content ?? '' }]
1237
1242
  });
1238
1243
  continue;
@@ -1240,7 +1245,7 @@ function irToAnthropicBody(ir, model, opts = {}) {
1240
1245
  if (m.role === 'assistant') {
1241
1246
  const blocks = contentToAnthropicBlocks(m.content).filter(b => b.type === 'text');
1242
1247
  for (const tc of (m.toolCalls || [])) {
1243
- blocks.push({ type: 'tool_use', id: tc.id, name: toolName(tc.name), input: parseArgs(tc.args) });
1248
+ blocks.push({ type: 'tool_use', id: safeToolId(tc.id), name: toolName(tc.name), input: parseArgs(tc.args) });
1244
1249
  }
1245
1250
  if (blocks.length) messages.push({ role: 'assistant', content: blocks });
1246
1251
  continue;
@@ -1629,6 +1634,11 @@ function toAnthropicBlocks(content) {
1629
1634
  return Array.isArray(content) ? content.filter(Boolean) : [];
1630
1635
  }
1631
1636
 
1637
+ // Anthropic refuses a text block with no visible text ("text content blocks must be non-empty").
1638
+ function isBlankText(b) {
1639
+ return b?.type === 'text' && !(typeof b.text === 'string' && b.text.trim());
1640
+ }
1641
+
1632
1642
  function toolResultText(block) {
1633
1643
  const c = block.content;
1634
1644
  const text = typeof c === 'string' ? c
@@ -1643,9 +1653,20 @@ function healAnthropicPayload(payload) {
1643
1653
  const src = payload.messages.filter(m => m && (m.role === 'user' || m.role === 'assistant'));
1644
1654
  let changed = src.length !== payload.messages.length;
1645
1655
 
1646
- // Step 0: drop thinking blocks with fake signatures; merge adjacent user turns (so a tool_result split
1647
- // into a later turn can still be matched with its tool_use).
1656
+ let system = payload.system;
1657
+ if (Array.isArray(system) && system.some(isBlankText)) {
1658
+ system = system.filter(b => !isBlankText(b));
1659
+ changed = true;
1660
+ notes.push('dropped empty text blocks');
1661
+ }
1662
+ const dropSystem = Array.isArray(system) ? !system.length : typeof system === 'string' && !system.trim();
1663
+ if (dropSystem) changed = true;
1664
+
1665
+ // Step 0: drop thinking blocks with fake signatures and empty text blocks; rewrite foreign tool ids;
1666
+ // merge adjacent user turns (so a tool_result split into a later turn can still be matched with its tool_use).
1648
1667
  const msgs = [];
1668
+ const emptyTurn = () => ({ role: 'user', content: [{ type: 'text', text: '(empty)' }] });
1669
+ let emptyUser = false;
1649
1670
  for (const m of src) {
1650
1671
  let content = m.content;
1651
1672
  if (m.role === 'assistant' && Array.isArray(content)) {
@@ -1655,11 +1676,31 @@ function healAnthropicPayload(payload) {
1655
1676
  notes.push('stripped placeholder-signed thinking blocks');
1656
1677
  content = kept;
1657
1678
  }
1658
- if (!content.length) {
1679
+ }
1680
+ if (Array.isArray(content)) {
1681
+ const kept = [];
1682
+ let renamed = false;
1683
+ for (const b of content) {
1684
+ if (isBlankText(b)) continue;
1685
+ if (b?.type === 'tool_use' && b.id && safeToolId(b.id) !== b.id) { kept.push({ ...b, id: safeToolId(b.id) }); renamed = true; }
1686
+ else if (b?.type === 'tool_result' && b.tool_use_id && safeToolId(b.tool_use_id) !== b.tool_use_id) { kept.push({ ...b, tool_use_id: safeToolId(b.tool_use_id) }); renamed = true; }
1687
+ else kept.push(b);
1688
+ }
1689
+ if (kept.length !== content.length) notes.push('dropped empty text blocks');
1690
+ if (renamed) notes.push('sanitized foreign tool ids');
1691
+ if (renamed || kept.length !== content.length) {
1659
1692
  changed = true;
1660
- continue;
1693
+ content = kept;
1661
1694
  }
1662
1695
  }
1696
+ if (!toAnthropicBlocks(typeof content === 'string' ? content.trim() : content).length) {
1697
+ // An emptied user turn still holds its place between two assistant turns, or at the end.
1698
+ changed = true;
1699
+ if (m.role === 'user') emptyUser = true;
1700
+ continue;
1701
+ }
1702
+ if (emptyUser && m.role === 'assistant' && msgs[msgs.length - 1]?.role !== 'user') msgs.push(emptyTurn());
1703
+ emptyUser = false;
1663
1704
  const last = msgs[msgs.length - 1];
1664
1705
  if (m.role === 'user' && last && last.role === 'user') {
1665
1706
  last.content = [...toAnthropicBlocks(last.content), ...toAnthropicBlocks(content)];
@@ -1668,6 +1709,7 @@ function healAnthropicPayload(payload) {
1668
1709
  }
1669
1710
  msgs.push({ ...m, content });
1670
1711
  }
1712
+ if (emptyUser && msgs[msgs.length - 1]?.role !== 'user') msgs.push(emptyTurn());
1671
1713
 
1672
1714
  // Steps 1-3: pair up tool_use / tool_result.
1673
1715
  const out = [];
@@ -1723,6 +1765,8 @@ function healAnthropicPayload(payload) {
1723
1765
  }
1724
1766
 
1725
1767
  let body = { ...payload, messages: out };
1768
+ if (dropSystem) delete body.system;
1769
+ else if (system !== payload.system) body.system = system;
1726
1770
 
1727
1771
  // Step 5: thinking + an in-progress tool loop whose last assistant turn doesn't open with thinking.
1728
1772
  const thinkingOn = payload.thinking && payload.thinking.type && payload.thinking.type !== 'disabled';
@@ -1887,6 +1931,7 @@ function normalizeUpstream(parsed, outFormat) {
1887
1931
  for (const tc of smartToolCalls(node)) ev.tools.push(chunkToolFull(tc));
1888
1932
  const f = smartFinish(parsed, choice);
1889
1933
  if (f) ev.finish = canonFinish(f);
1934
+ if (ev.finish === 'error') { ev.finish = null; ev.error = `Upstream finished with ${f}`; }
1890
1935
  return ev;
1891
1936
  }
1892
1937
 
@@ -2054,6 +2099,17 @@ function rand(n = 6) {
2054
2099
  return s.slice(0, n);
2055
2100
  }
2056
2101
 
2102
+ // Anthropic takes tool ids matching ^[a-zA-Z0-9_-]+$ only (Kimi sends "functions.Read:0"). The map must
2103
+ // stay pure: a replayed history has to give the same id, and the hash keeps "a.b" and "a:b" apart.
2104
+ function safeToolId(id) {
2105
+ const s = String(id ?? '');
2106
+ if (!s) return `toolu_${rand(24)}`;
2107
+ if (/^[a-zA-Z0-9_-]+$/.test(s)) return s;
2108
+ let h = 0x811c9dc5;
2109
+ for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
2110
+ return `${s.replace(/[^a-zA-Z0-9_-]/g, '_')}_${(h >>> 0).toString(36)}`;
2111
+ }
2112
+
2057
2113
  function anthropicStopReason(canonical, hasTools) {
2058
2114
  if (canonical === 'length') return 'max_tokens';
2059
2115
  if (hasTools) return 'tool_use';
@@ -2147,7 +2203,7 @@ function createAnthropicStream(emit, model, opts = {}) {
2147
2203
  const fixed = vocab ? vocab.name(tc.name) : tc.name;
2148
2204
  const isShort = Boolean(vocab && typeof vocab.isShortened === 'function' && vocab.isShortened(tc.name));
2149
2205
  st = {
2150
- index: nextIndex++, id: tc.id || `toolu_${rand(24)}`, name: fixed || 'tool', closed: false,
2206
+ index: nextIndex++, id: safeToolId(tc.id), name: fixed || 'tool', closed: false,
2151
2207
  // Shortened names keep caller's schema; ToolSearch query aliases still need restoring.
2152
2208
  buffered: '', held: Boolean(vocab && ((fixed && fixed !== tc.name && !isShort) || vocab.needsArgsRewrite?.(tc.name)))
2153
2209
  };
@@ -2212,7 +2268,7 @@ function buildAnthropicMessage({ model, think, text, tools, finish, prompt, comp
2212
2268
  // The name is written in the vocabulary the caller declared. A name it already uses is not
2213
2269
  // touched, so the whole layer costs nothing on a turn where the model agreed with the list.
2214
2270
  const fixed = repairToolCall(tc, vocab);
2215
- content.push({ type: 'tool_use', id: tc.id || `toolu_${rand(24)}`, name: fixed.name, input: parseArgs(fixed.args) });
2271
+ content.push({ type: 'tool_use', id: safeToolId(tc.id), name: fixed.name, input: parseArgs(fixed.args) });
2216
2272
  }
2217
2273
  if (!content.length) content.push({ type: 'text', text: '' });
2218
2274
  const p = prompt || 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.7",
3
+ "version": "1.6.9",
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
@@ -831,8 +831,9 @@ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = n
831
831
  async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal, sink, tag }) {
832
832
  let streamError = null;
833
833
  let events = 0;
834
+ const framing = {};
834
835
  try {
835
- for await (const parsed of readUpstreamPayloads(upstreamRes)) {
836
+ for await (const parsed of readUpstreamPayloads(upstreamRes, framing)) {
836
837
  events++;
837
838
  const ev = normalize(parsed);
838
839
  col.add(ev);
@@ -850,6 +851,8 @@ async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, sig
850
851
  await drained(sink, signal);
851
852
  }
852
853
  if (!streamError && events === 0) streamError = 'Upstream returned an empty stream';
854
+ // A clean close with no finish reason is a cut: rendered as done, it hands the client half a tool call.
855
+ else if (!streamError && col.finish == null && !framing.done) streamError = 'Upstream stream ended before a finish reason';
853
856
  } catch (streamErr) {
854
857
  if (!signal.aborted) {
855
858
  console.error(`[${tag}] Stream error:`, streamErr.message);
@@ -21,7 +21,7 @@ async function fixture(t) {
21
21
  if (body.stream) {
22
22
  res.setHeader('content-type', 'text/event-stream');
23
23
  res.write(`data: {"choices":\ndata: [{"index":0,"delta":{"content":"${summary}"}}]}\n\n`);
24
- if (!truncate) res.write(`data: {"choices":[{"index":0,"delta":{},"finish_reason":"${finish}"}]}\n\ndata: [DONE]\n\n`);
24
+ if (!truncate) res.write(`data: {"choices":[{"index":0,"delta":{},"finish_reason":${JSON.stringify(finish)}}]}\n\ndata: [DONE]\n\n`);
25
25
  res.end();
26
26
  } else { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { content: summary }, finish_reason: finish }] })); }
27
27
  });
@@ -77,3 +77,19 @@ test('WS compaction replays the compacted state through previous_response_id', {
77
77
  assert.match(JSON.stringify(f.received.at(-1).messages), /KEEP_DECISION_7391.*NEXT_TURN/);
78
78
  assert.doesNotMatch(JSON.stringify(f.received.at(-1).messages), /ORIGINAL_HISTORY/);
79
79
  });
80
+ test('stream cut without finish_reason renders error, not completed', { timeout: 20000 }, async t => {
81
+ const f = await fixture(t); f.failure('stop', true);
82
+ const codex = events(await (await f.send({ model: 'm', stream: true, input: [{ role: 'user', content: 'hi' }] })).text());
83
+ assert.ok(codex.some(e => e.type === 'response.failed'), JSON.stringify(codex));
84
+ assert.ok(!codex.some(e => e.type === 'response.completed'), JSON.stringify(codex));
85
+ const claude = await fetch(`http://127.0.0.1:${f.port}/v1/messages`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-profile': 'p1' },
86
+ body: JSON.stringify({ model: 'm', max_tokens: 64, stream: true, messages: [{ role: 'user', content: 'hi' }] }) });
87
+ const claudeEvents = events(await claude.text());
88
+ assert.ok(claudeEvents.some(e => e.type === 'error'), JSON.stringify(claudeEvents));
89
+ assert.ok(!claudeEvents.some(e => e.type === 'message_stop'), JSON.stringify(claudeEvents));
90
+ });
91
+ test('stream closed by [DONE] without finish_reason still completes', { timeout: 20000 }, async t => {
92
+ const f = await fixture(t); f.failure(null);
93
+ const codex = events(await (await f.send({ model: 'm', stream: true, input: [{ role: 'user', content: 'hi' }] })).text());
94
+ assert.ok(codex.some(e => e.type === 'response.completed'), JSON.stringify(codex));
95
+ });
@@ -12,7 +12,7 @@ import {
12
12
  createChatStream, buildChatMessage, chatFinish, anthropicStopReason,
13
13
  smartText, smartReasoning, sanitizeJsonSchema, normalizeUpstream, buildVertexMessage,
14
14
  createCodeAssistStream,
15
- budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS
15
+ budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS, parseArgs, buildAnthropicMessage
16
16
  } from '../formats.mjs';
17
17
  import { assertValidAnthropicEvents } from './helpers.mjs';
18
18
 
@@ -1053,3 +1053,117 @@ test('thinkingEffort profile option overrides client effort across all upstreams
1053
1053
  const autoChat = emitUpstreamBody('openai-chat', clientIR, 'gpt-5', { thinkingEffort: 'auto' });
1054
1054
  assert.equal(autoChat.reasoning_effort, 'low');
1055
1055
  });
1056
+
1057
+ test('compaction_trigger with tool history keeps tools and forces tool_choice none', () => {
1058
+ const ir = responsesToIR({
1059
+ model: 'm', tools: [fn('Read')], text: { format: { type: 'json_object' } }, input: [
1060
+ { role: 'user', content: 'read it' },
1061
+ { type: 'function_call', call_id: 'c1', name: 'Read', arguments: '{"p":"a"}' },
1062
+ { type: 'function_call_output', call_id: 'c1', output: 'data' },
1063
+ { type: 'compaction_trigger' }
1064
+ ]
1065
+ });
1066
+ assert.equal(ir.toolChoice, 'none');
1067
+ assert.equal(ir.responseFormat, null);
1068
+ const chat = irToChatBody(ir, 'gpt-x');
1069
+ assert.equal(chat.tools?.length, 1);
1070
+ assert.equal(chat.tool_choice, 'none');
1071
+ assert.equal(chat.response_format, undefined);
1072
+ const anthropic = irToAnthropicBody(ir, 'claude-x');
1073
+ assert.equal(anthropic.tools?.length, 1);
1074
+ assert.deepEqual(anthropic.tool_choice, { type: 'none' });
1075
+ assert.ok(anthropic.messages.some(m => m.content.some(b => b.type === 'tool_use')));
1076
+ const vertex = irToVertexBody(ir, 'gemini-x');
1077
+ assert.equal(vertex.tools?.[0].functionDeclarations.length, 1);
1078
+ assert.equal(vertex.toolConfig.functionCallingConfig.mode, 'NONE');
1079
+ // With no tools at all there is nothing to switch off: tool_choice without tools is refused.
1080
+ const bare = responsesToIR({ model: 'm', input: [{ role: 'user', content: 'hi' }, { type: 'compaction_trigger' }] });
1081
+ assert.equal(irToChatBody(bare, 'gpt-x').tool_choice, undefined);
1082
+ assert.equal(irToAnthropicBody(bare, 'claude-x').tool_choice, undefined);
1083
+ });
1084
+
1085
+ test('anthropic emitters sanitize foreign tool ids consistently', () => {
1086
+ const foreign = 'functions.Read:0';
1087
+ const valid = /^[a-zA-Z0-9_-]+$/;
1088
+ const history = [
1089
+ { role: 'user', content: 'go' },
1090
+ { role: 'assistant', content: [{ type: 'tool_use', id: foreign, name: 'Read', input: {} }] },
1091
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: foreign, content: 'ok' }] }
1092
+ ];
1093
+ const ir = anthropicToIR({ model: 'x', tools: [{ name: 'Read', input_schema: { type: 'object', properties: {} } }], messages: history });
1094
+ const body = irToAnthropicBody(ir, 'claude-x');
1095
+ const use = body.messages[1].content.find(b => b.type === 'tool_use');
1096
+ assert.match(use.id, valid);
1097
+ assert.equal(body.messages[2].content[0].tool_use_id, use.id);
1098
+ assert.equal(irToAnthropicBody(ir, 'claude-x').messages[1].content[0].id, use.id, 'a replayed history maps identically');
1099
+
1100
+ const message = buildAnthropicMessage({ model: 'x', tools: [{ id: foreign, name: 'Read', args: '{}' }], finish: 'tool_calls' });
1101
+ assert.equal(message.content[0].id, use.id);
1102
+ const events = [];
1103
+ const stream = createAnthropicStream((e, d) => events.push(d), 'x');
1104
+ stream.start();
1105
+ stream.tool({ index: 0, id: foreign, name: 'Read', args: '{}' });
1106
+ stream.finish('tool_calls');
1107
+ assert.equal(events.find(e => e.type === 'content_block_start').content_block.id, use.id);
1108
+
1109
+ const healed = healAnthropicPayload({ model: 'claude', messages: history });
1110
+ assert.equal(healed.changed, true);
1111
+ assert.equal(healed.payload.messages[1].content[0].id, use.id);
1112
+ assert.equal(healed.payload.messages[2].content[0].tool_use_id, use.id);
1113
+
1114
+ // Two ids that differ only in an invalid character must not collide, and a valid id is kept.
1115
+ const ids = ['a.b', 'a:b', 'a_b'].map(id => buildAnthropicMessage({ model: 'x', tools: [{ id, name: 'f', args: '{}' }] }).content[0].id);
1116
+ assert.equal(new Set(ids).size, 3);
1117
+ assert.equal(ids[2], 'a_b');
1118
+ assert.match(buildAnthropicMessage({ model: 'x', tools: [{ id: '', name: 'f', args: '{}' }] }).content[0].id, valid);
1119
+ });
1120
+
1121
+ test('healAnthropicPayload drops empty system and empty text blocks', () => {
1122
+ const nonEmpty = msgs => msgs.every(m => typeof m.content === 'string' ? m.content.trim()
1123
+ : m.content.length && m.content.every(b => b.type !== 'text' || b.text.trim()));
1124
+ const alternates = msgs => msgs[0].role === 'user' && msgs.every((m, i) => !i || m.role !== msgs[i - 1].role);
1125
+ const r = healAnthropicPayload({
1126
+ model: 'claude', system: [{ type: 'text', text: '' }], messages: [
1127
+ { role: 'user', content: [{ type: 'text', text: '' }, { type: 'text', text: 'hi' }] },
1128
+ { role: 'assistant', content: [{ type: 'text', text: ' ' }, { type: 'tool_use', id: 't1', name: 'f', input: {} }] },
1129
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }] },
1130
+ { role: 'assistant', content: [{ type: 'text', text: '' }] },
1131
+ { role: 'user', content: [{ type: 'text', text: 'next' }] }
1132
+ ]
1133
+ });
1134
+ assert.equal(r.changed, true);
1135
+ assert.equal('system' in r.payload, false);
1136
+ assert.ok(nonEmpty(r.payload.messages), JSON.stringify(r.payload.messages));
1137
+ assert.ok(alternates(r.payload.messages), JSON.stringify(r.payload.messages));
1138
+ assert.deepEqual(r.payload.messages.at(-1).content.map(b => b.type), ['tool_result', 'text']);
1139
+
1140
+ // A user turn that empties out between two assistant turns, or at the end, keeps its place.
1141
+ const r2 = healAnthropicPayload({
1142
+ model: 'claude', system: [{ type: 'text', text: 'sys' }, { type: 'text', text: '' }], messages: [
1143
+ { role: 'user', content: 'go' },
1144
+ { role: 'assistant', content: 'a' },
1145
+ { role: 'user', content: [{ type: 'text', text: '' }] },
1146
+ { role: 'assistant', content: 'b' },
1147
+ { role: 'user', content: '' }
1148
+ ]
1149
+ });
1150
+ assert.deepEqual(r2.payload.system, [{ type: 'text', text: 'sys' }]);
1151
+ assert.ok(nonEmpty(r2.payload.messages), JSON.stringify(r2.payload.messages));
1152
+ assert.ok(alternates(r2.payload.messages), JSON.stringify(r2.payload.messages));
1153
+ assert.equal(r2.payload.messages.length, 5);
1154
+ assert.equal(r2.payload.messages.at(-1).role, 'user');
1155
+ });
1156
+
1157
+ test("parseArgs('') returns {}", () => {
1158
+ assert.deepEqual(parseArgs(''), {});
1159
+ assert.deepEqual(parseArgs(' \n'), {});
1160
+ assert.deepEqual(parseArgs('{"a":1}'), { a: 1 });
1161
+ });
1162
+
1163
+ test('Gemini MALFORMED_FUNCTION_CALL finish is an error, not stop', () => {
1164
+ const ev = normalizeUpstream({ candidates: [{ content: { parts: [] }, finishReason: 'MALFORMED_FUNCTION_CALL' }] }, 'vertex');
1165
+ assert.equal(ev.finish, null);
1166
+ assert.match(String(ev.error), /MALFORMED_FUNCTION_CALL/);
1167
+ const chat = normalizeUpstream({ choices: [{ delta: {}, finish_reason: 'malformed_function_call' }] }, 'openai-chat');
1168
+ assert.match(String(chat.error), /malformed_function_call/i);
1169
+ });
package/tests/ui.test.mjs CHANGED
@@ -115,3 +115,44 @@ test('the 1M switch is gone from the slots, the card and the payload', () => {
115
115
  assert.doesNotMatch(functionBody('collectCurrentSlotValues'), /model1M\s*[,}]/);
116
116
  assert.doesNotMatch(html, /profilePayload\.model1M/);
117
117
  });
118
+
119
+ test('documentation tab is properly integrated with sidebar navigation, sections, and helpers', () => {
120
+ // Sidebar navigation link
121
+ assert.match(html, /<a class="nav-link" href="#\/docs" data-nav="docs"/);
122
+ // View panel exists
123
+ assert.match(html, /<div id="view-docs" class="view-panel"/);
124
+ // Table of Contents navigation exists
125
+ assert.match(html, /<nav class="docs-toc" id="docsToc"/);
126
+
127
+ // All 11 core sections exist
128
+ const expectedSections = [
129
+ 'doc-overview',
130
+ 'doc-install',
131
+ 'doc-intact',
132
+ 'doc-cli',
133
+ 'doc-blindfold',
134
+ 'doc-agy',
135
+ 'doc-optimizers',
136
+ 'doc-agent-mcp',
137
+ 'doc-advanced',
138
+ 'doc-config',
139
+ 'doc-faq'
140
+ ];
141
+ for (const s of expectedSections) {
142
+ assert.match(html, new RegExp(`id="${s}"`), `Missing section ${s} in ui.html`);
143
+ assert.match(html, new RegExp(`href="#${s}"`), `Missing TOC link for ${s} in ui.html`);
144
+ }
145
+
146
+ // Hash router handles 'docs'
147
+ assert.match(html, /validRoutes\s*=\s*\[[^\]]*'docs'[^\]]*\]/);
148
+ assert.match(html, /activeRoute === 'docs'/);
149
+
150
+ // Helper functions exist
151
+ assert.match(html, /function copyDocCode\(/);
152
+ assert.match(html, /function initDocsScrollSpy\(/);
153
+ });
154
+
155
+ test('sidebar menu is arranged logically by frequency of use', () => {
156
+ const navMatches = [...html.matchAll(/data-nav="([^"]+)"/g)].map(m => m[1]);
157
+ assert.deepEqual(navMatches, ['routes', 'logs', 'models', 'doctor', 'goal-check', 'compact', 'docs']);
158
+ });