llm-switcher 1.6.8 → 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 +13 -2
- package/README.md +57 -3
- package/README.vi.md +58 -3
- package/formats.mjs +65 -9
- package/package.json +1 -1
- package/proxy.mjs +4 -1
- package/tests/compaction.e2e.test.mjs +17 -1
- package/tests/formats.test.mjs +115 -1
- package/tests/ui.test.mjs +7 -1
- package/ui.html +761 -126
- package/upstream-stream.mjs +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
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
|
+
|
|
3
12
|
## Release 1.6.8 — 2026-10-09
|
|
4
13
|
|
|
5
|
-
- **
|
|
6
|
-
- **
|
|
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.
|
|
7
18
|
|
|
8
19
|
## Release 1.6.7 — 2026-10-09
|
|
9
20
|
|
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" }
|
|
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
|
-
|
|
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" }
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
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"
|
|
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
|
+
});
|
package/tests/formats.test.mjs
CHANGED
|
@@ -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
|
@@ -124,10 +124,11 @@ test('documentation tab is properly integrated with sidebar navigation, sections
|
|
|
124
124
|
// Table of Contents navigation exists
|
|
125
125
|
assert.match(html, /<nav class="docs-toc" id="docsToc"/);
|
|
126
126
|
|
|
127
|
-
// All
|
|
127
|
+
// All 11 core sections exist
|
|
128
128
|
const expectedSections = [
|
|
129
129
|
'doc-overview',
|
|
130
130
|
'doc-install',
|
|
131
|
+
'doc-intact',
|
|
131
132
|
'doc-cli',
|
|
132
133
|
'doc-blindfold',
|
|
133
134
|
'doc-agy',
|
|
@@ -150,3 +151,8 @@ test('documentation tab is properly integrated with sidebar navigation, sections
|
|
|
150
151
|
assert.match(html, /function copyDocCode\(/);
|
|
151
152
|
assert.match(html, /function initDocsScrollSpy\(/);
|
|
152
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
|
+
});
|