llm-switcher 1.1.0 → 1.1.2
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/README.md +33 -23
- package/README.vi.md +32 -20
- package/blindfold/make-certs.sh +10 -2
- package/package.json +2 -2
- package/scripts/run-tests.mjs +19 -0
- package/state.mjs +3 -1
- package/switch.mjs +13 -3
- package/tests/datadir.test.mjs +14 -0
- package/tests/state.test.mjs +27 -1
package/README.md
CHANGED
|
@@ -191,7 +191,15 @@ flowchart LR
|
|
|
191
191
|
|
|
192
192
|
---
|
|
193
193
|
|
|
194
|
-
## Changes in
|
|
194
|
+
## Changes in 1.1.2
|
|
195
|
+
|
|
196
|
+
- **npm package.** Install with `npm install -g llm-switcher` and run `switch`. An npm install keeps its data in `~/.llm-switcher`, so an upgrade does not erase your configuration. A git checkout keeps its data next to the code, as before.
|
|
197
|
+
- **Contract lab.** The gateway can send a small sample of complete exchanges to an [intact](https://github.com/louisphamdev/intact) server, which finds fields that the converter loses. It is off by default. See "Contract lab" below.
|
|
198
|
+
- **macOS.** `blindfold/make-certs.sh` now runs with LibreSSL, the default `openssl` on macOS.
|
|
199
|
+
- **Upgrade from 1.1.0 or older.** A running gateway older than 1.1.1 cannot prove its identity. `switch` now names it and does not stop it. Stop it by hand once, then run `switch on`.
|
|
200
|
+
- **Tests.** `npm test` runs only `tests/**/*.test.mjs`, also on Node.js 18 and 20.
|
|
201
|
+
|
|
202
|
+
### Earlier changes
|
|
195
203
|
|
|
196
204
|
- The desktop dashboard now uses a compact developer-tool layout. It has clearer route controls, keyboard-accessible tabs, labeled model fields, and no decorative emoji.
|
|
197
205
|
- Codex profiles now use three documented roles: `main`, `review`, and `subagent`.
|
|
@@ -222,6 +230,8 @@ cp "$(npm root -g)/llm-switcher/config.example.json" ~/.llm-switcher/config.json
|
|
|
222
230
|
|
|
223
231
|
An npm install keeps `config.json`, `admin.token` and the launch files in `~/.llm-switcher`. An upgrade replaces the package folder only, so your configuration stays.
|
|
224
232
|
|
|
233
|
+
If you upgrade from 1.1.0 or older, stop the running gateway before you run `switch`. An older gateway cannot prove its identity, so `switch` does not stop it for you.
|
|
234
|
+
|
|
225
235
|
**Option B: git clone**
|
|
226
236
|
```bash
|
|
227
237
|
git clone https://github.com/louisphamdev/llm-switcher.git
|
|
@@ -235,21 +245,18 @@ A checkout keeps its data next to the code, as before. To use another folder in
|
|
|
235
245
|
|
|
236
246
|
Edit `config.json` with your provider base URLs and API keys.
|
|
237
247
|
|
|
238
|
-
|
|
248
|
+
The **data folder** is `~/.llm-switcher` for an npm install and the checkout folder for a git clone. The examples below use the npm install. For a checkout, run `node switch.mjs <command>` instead of `switch <command>`, or put the checkout folder on `PATH`.
|
|
239
249
|
|
|
240
250
|
### 3. Start the Gateway
|
|
241
251
|
```bash
|
|
242
|
-
# Start in
|
|
243
|
-
|
|
252
|
+
# Start the gateway in the background:
|
|
253
|
+
switch on
|
|
244
254
|
|
|
245
|
-
# Or run
|
|
246
|
-
node proxy.mjs
|
|
255
|
+
# Or run it in the foreground (npm install):
|
|
256
|
+
node "$(npm root -g)/llm-switcher/proxy.mjs"
|
|
247
257
|
```
|
|
248
258
|
|
|
249
|
-
|
|
250
|
-
`switch.cmd` for Windows. Put the repository directory on PATH and `switch <command>`
|
|
251
|
-
works the same on all three. Platform differences, and the two features that are not
|
|
252
|
-
available everywhere, are in [📖 `docs/cross-platform.md`](docs/cross-platform.md).
|
|
259
|
+
`switch <command>` works the same on Linux, macOS and Windows. A checkout has its own launchers: `switch` for Linux and macOS, `switch.cmd` for Windows. Platform differences, and the two features that are not available everywhere, are in [📖 `docs/cross-platform.md`](docs/cross-platform.md).
|
|
253
260
|
|
|
254
261
|
Open the Web Dashboard at: **[http://127.0.0.1:3456/ui](http://127.0.0.1:3456/ui)**
|
|
255
262
|
|
|
@@ -259,22 +266,24 @@ Open the Web Dashboard at: **[http://127.0.0.1:3456/ui](http://127.0.0.1:3456/ui
|
|
|
259
266
|
|
|
260
267
|
### Universal Environment Loader (`env.cmd` / `env.sh`)
|
|
261
268
|
|
|
262
|
-
Every time you switch profiles, LLM Switcher writes ready-to-use environment loaders:
|
|
269
|
+
Every time you switch profiles, LLM Switcher writes ready-to-use environment loaders into the data folder:
|
|
263
270
|
|
|
264
271
|
- **Windows (Command Prompt / PowerShell wrapper):**
|
|
265
272
|
```cmd
|
|
266
|
-
call "
|
|
273
|
+
call "%USERPROFILE%\.llm-switcher\env.cmd"
|
|
267
274
|
```
|
|
268
275
|
- **macOS / Linux (Bash / Zsh):**
|
|
269
276
|
```bash
|
|
270
|
-
source
|
|
277
|
+
source ~/.llm-switcher/env.sh
|
|
271
278
|
```
|
|
272
279
|
|
|
280
|
+
For a checkout, use the same files in the checkout folder.
|
|
281
|
+
|
|
273
282
|
---
|
|
274
283
|
|
|
275
284
|
### Claude Code Setup (Windows)
|
|
276
285
|
|
|
277
|
-
1.
|
|
286
|
+
1. With the npm install, `switch` is already on `PATH`. With a checkout, create a wrapper on your `PATH` (for example `cc-switch.cmd`):
|
|
278
287
|
```cmd
|
|
279
288
|
@echo off
|
|
280
289
|
node "path\to\llm-switcher\switch.mjs" %*
|
|
@@ -283,18 +292,18 @@ Every time you switch profiles, LLM Switcher writes ready-to-use environment loa
|
|
|
283
292
|
2. Patch your global Claude Code launcher (`claude.cmd` in your npm global directory):
|
|
284
293
|
```cmd
|
|
285
294
|
SETLOCAL EnableDelayedExpansion
|
|
286
|
-
IF EXIST "
|
|
295
|
+
IF EXIST "%USERPROFILE%\.llm-switcher\active.flag" (
|
|
287
296
|
SET "ANTHROPIC_BASE_URL=http://127.0.0.1:3456"
|
|
288
297
|
SET "CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1"
|
|
289
298
|
)
|
|
290
|
-
IF EXIST "
|
|
291
|
-
SET /P M1M=<"
|
|
299
|
+
IF EXIST "%USERPROFILE%\.llm-switcher\1m.flag" (
|
|
300
|
+
SET /P M1M=<"%USERPROFILE%\.llm-switcher\1m.flag"
|
|
292
301
|
IF "!M1M!"=="" SET "M1M=opus[1m]"
|
|
293
302
|
SET "ANTHROPIC_MODEL=!M1M!"
|
|
294
303
|
SET "CLAUDE_CODE_AUTO_COMPACT_WINDOW=900000"
|
|
295
304
|
)
|
|
296
305
|
```
|
|
297
|
-
> `SETLOCAL EnableDelayedExpansion` is required for `!M1M!`. npm rewrites `claude.cmd` on every update, so prefer a separate wrapper that runs `call "
|
|
306
|
+
> `SETLOCAL EnableDelayedExpansion` is required for `!M1M!`. npm rewrites `claude.cmd` on every update, so prefer a separate wrapper that runs `call "%USERPROFILE%\.llm-switcher\env.cmd"` and then `claude %*`. For a checkout, replace `%USERPROFILE%\.llm-switcher` with the checkout folder. Only `env.cmd` / `env.sh` carry the per-tier `ANTHROPIC_DEFAULT_<TIER>_MODEL=<tier>[1m]` variables.
|
|
298
307
|
|
|
299
308
|
---
|
|
300
309
|
|
|
@@ -337,7 +346,7 @@ base URL is overridden to http://127.0.0.1:3456/v1. Selecting models may not be
|
|
|
337
346
|
Blindfold mode removes that line. Codex keeps its official endpoint, and the switcher intercepts the network hop instead. It needs no administrator rights, no certificate in a system trust store, and no change to `~/.codex/config.toml`.
|
|
338
347
|
|
|
339
348
|
```bash
|
|
340
|
-
bash blindfold/make-certs.sh chatgpt.com # once
|
|
349
|
+
bash "$(npm root -g)/llm-switcher/blindfold/make-certs.sh" chatgpt.com # once; a checkout runs blindfold/make-certs.sh
|
|
341
350
|
# then set "blindfold": true in the Codex profile
|
|
342
351
|
switch codex <profile> # the gateway starts the interceptor
|
|
343
352
|
```
|
|
@@ -414,12 +423,12 @@ A zero-dependency Model Context Protocol (MCP) server communicating over `stdio`
|
|
|
414
423
|
|
|
415
424
|
NOTE: `switcher_recent_logs` returns the first 150 characters of each recent prompt, from every client that used the gateway. The agent that calls the tool can read them.
|
|
416
425
|
|
|
417
|
-
Add to your MCP configuration (
|
|
426
|
+
Add the server to your MCP configuration (for example `opencode.jsonc`, `claude_desktop_config.json`, or Cursor). Run `npm root -g` to get the folder that holds `llm-switcher/mcp.mjs`. For a checkout, use the `mcp.mjs` in the checkout folder.
|
|
418
427
|
```json
|
|
419
428
|
"mcp": {
|
|
420
429
|
"llm-switcher": {
|
|
421
430
|
"type": "local",
|
|
422
|
-
"command": ["node", "path/
|
|
431
|
+
"command": ["node", "/path/from/npm-root-g/llm-switcher/mcp.mjs"],
|
|
423
432
|
"enabled": true
|
|
424
433
|
}
|
|
425
434
|
}
|
|
@@ -548,14 +557,15 @@ The contract lab finds fields that the converter loses. It is off by default.
|
|
|
548
557
|
|
|
549
558
|
| Option | Description |
|
|
550
559
|
|---|---|
|
|
551
|
-
| `
|
|
560
|
+
| `LLM_SWITCHER_HOME=/path` | Use this folder as the data folder (config, admin token, launch files, logs) for an npm install or a checkout. |
|
|
561
|
+
| `LLM_SWITCHER_CONFIG=/path/config.json` | Use a config file outside the data folder (the proxy, `switch` and `mcp.mjs` all honour it). |
|
|
552
562
|
| `--port <n>` / `LLM_SWITCHER_PORT` | Override the listening port (priority: flag > env > `config.port`). |
|
|
553
563
|
| `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. |
|
|
554
564
|
| `profile.thinkingMode` | `auto` (default, for gateways like 9Router): restore stripped thinking, inject a `<think>` guide for non-reasoning models, send `thinking` + `reasoning_effort`. `native` (strict OpenAI APIs): send only `reasoning_effort` when the client asks, never touch the prompt, use `max_completion_tokens`. `off`: never send reasoning parameters. |
|
|
555
565
|
| `profile.endpoints.countTokens` | Override the Anthropic `count_tokens` URL. |
|
|
556
566
|
| `profile.endpoints` | Override upstream URLs per format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
|
|
557
567
|
| `CLAUDE_CONFIG_DIR` | Respected when locating Claude Code's `settings.json`. |
|
|
558
|
-
| `LLM_SWITCHER_STATE_DIR` | Move the launch files and the logs out of the
|
|
568
|
+
| `LLM_SWITCHER_STATE_DIR` | Move the launch files and the logs out of the data folder. The tests use it; the shims read the directory that was set when they were installed. |
|
|
559
569
|
|
|
560
570
|
## Security Model
|
|
561
571
|
|
package/README.vi.md
CHANGED
|
@@ -191,7 +191,15 @@ flowchart LR
|
|
|
191
191
|
|
|
192
192
|
---
|
|
193
193
|
|
|
194
|
-
## Thay đổi trong
|
|
194
|
+
## Thay đổi trong bản 1.1.2
|
|
195
|
+
|
|
196
|
+
- **Gói npm.** Cài bằng `npm install -g llm-switcher` rồi chạy `switch`. Bản cài bằng npm lưu dữ liệu trong `~/.llm-switcher`, nên nâng cấp không xoá cấu hình. Bản git checkout vẫn lưu dữ liệu cạnh mã nguồn như trước.
|
|
197
|
+
- **Contract lab.** Gateway có thể gửi một phần nhỏ các lượt trao đổi hoàn chỉnh lên server [intact](https://github.com/louisphamdev/intact) để tìm field mà converter làm mất. Mặc định tính năng này tắt. Xem mục "Contract lab" bên dưới.
|
|
198
|
+
- **macOS.** `blindfold/make-certs.sh` giờ chạy được với LibreSSL, là `openssl` mặc định trên macOS.
|
|
199
|
+
- **Nâng cấp từ 1.1.0 trở xuống.** Gateway cũ hơn 1.1.1 không chứng minh được danh tính. `switch` giờ gọi đúng tên nó và không tự dừng nó. Dừng nó bằng tay một lần, rồi chạy `switch on`.
|
|
200
|
+
- **Test.** `npm test` chỉ chạy `tests/**/*.test.mjs`, kể cả trên Node.js 18 và 20.
|
|
201
|
+
|
|
202
|
+
### Các thay đổi trước đó
|
|
195
203
|
|
|
196
204
|
- Dashboard cho máy tính nay có bố cục gọn như một công cụ dành cho lập trình viên. Các điều khiển route rõ hơn, tab dùng được bằng bàn phím, trường model có nhãn đầy đủ và không còn emoji trang trí.
|
|
197
205
|
- Profile Codex dùng ba vai trò theo tài liệu chính thức: `main`, `review` và `subagent`.
|
|
@@ -222,6 +230,8 @@ cp "$(npm root -g)/llm-switcher/config.example.json" ~/.llm-switcher/config.json
|
|
|
222
230
|
|
|
223
231
|
Bản cài bằng npm lưu `config.json`, `admin.token` và các file khởi chạy trong `~/.llm-switcher`. Khi nâng cấp, npm chỉ thay thư mục package, nên cấu hình của bạn vẫn còn.
|
|
224
232
|
|
|
233
|
+
Nếu bạn nâng cấp từ 1.1.0 trở xuống, hãy dừng gateway đang chạy trước khi chạy `switch`. Gateway cũ không chứng minh được danh tính, nên `switch` không tự dừng nó.
|
|
234
|
+
|
|
225
235
|
**Cách B: git clone**
|
|
226
236
|
```bash
|
|
227
237
|
git clone https://github.com/louisphamdev/llm-switcher.git
|
|
@@ -235,19 +245,18 @@ Bản checkout lưu dữ liệu cạnh mã nguồn như trước. Muốn dùng t
|
|
|
235
245
|
|
|
236
246
|
Điền URL và API key của các nhà cung cấp vào `config.json`.
|
|
237
247
|
|
|
238
|
-
|
|
248
|
+
**Thư mục dữ liệu** là `~/.llm-switcher` với bản cài bằng npm, và là thư mục checkout với bản git clone. Các ví dụ bên dưới dùng bản cài bằng npm. Với bản checkout, chạy `node switch.mjs <lệnh>` thay cho `switch <lệnh>`, hoặc thêm thư mục checkout vào `PATH`.
|
|
239
249
|
|
|
240
250
|
### 3. Khởi động Gateway
|
|
241
251
|
```bash
|
|
242
252
|
# Bật gateway chạy ngầm:
|
|
243
|
-
|
|
253
|
+
switch on
|
|
244
254
|
|
|
245
|
-
# Hoặc chạy trực tiếp trên terminal:
|
|
246
|
-
node proxy.mjs
|
|
255
|
+
# Hoặc chạy trực tiếp trên terminal (bản cài bằng npm):
|
|
256
|
+
node "$(npm root -g)/llm-switcher/proxy.mjs"
|
|
247
257
|
```
|
|
248
258
|
|
|
249
|
-
|
|
250
|
-
cho Windows. Thêm thư mục repo vào PATH là `switch <lệnh>` chạy giống nhau trên cả ba.
|
|
259
|
+
`switch <lệnh>` chạy giống nhau trên Linux, macOS và Windows. Bản checkout có sẵn hai launcher: `switch` cho Linux và macOS, `switch.cmd` cho Windows.
|
|
251
260
|
Khác biệt giữa các nền tảng, và hai tính năng không chạy ở mọi nơi, nằm trong
|
|
252
261
|
[📖 `docs/cross-platform.md`](docs/cross-platform.md).
|
|
253
262
|
|
|
@@ -259,22 +268,24 @@ Mở Bảng điều khiển Web Dashboard tại: **[http://127.0.0.1:3456/ui](ht
|
|
|
259
268
|
|
|
260
269
|
### Bộ nạp Biến Môi trường Toàn năng (`env.cmd` / `env.sh`)
|
|
261
270
|
|
|
262
|
-
Mỗi khi bạn chuyển đổi profile, LLM Switcher
|
|
271
|
+
Mỗi khi bạn chuyển đổi profile, LLM Switcher sinh file nạp môi trường trong thư mục dữ liệu:
|
|
263
272
|
|
|
264
273
|
- **Trên Windows (CMD / PowerShell wrapper):**
|
|
265
274
|
```cmd
|
|
266
|
-
call "
|
|
275
|
+
call "%USERPROFILE%\.llm-switcher\env.cmd"
|
|
267
276
|
```
|
|
268
277
|
- **Trên macOS / Linux (Bash / Zsh):**
|
|
269
278
|
```bash
|
|
270
|
-
source
|
|
279
|
+
source ~/.llm-switcher/env.sh
|
|
271
280
|
```
|
|
272
281
|
|
|
282
|
+
Với bản checkout, dùng các file này trong thư mục checkout.
|
|
283
|
+
|
|
273
284
|
---
|
|
274
285
|
|
|
275
286
|
### Cấu hình cho Claude Code (Windows)
|
|
276
287
|
|
|
277
|
-
1.
|
|
288
|
+
1. Với bản cài bằng npm, `switch` đã có sẵn trong `PATH`. Với bản checkout, tạo file wrapper trong `PATH` (ví dụ `cc-switch.cmd`):
|
|
278
289
|
```cmd
|
|
279
290
|
@echo off
|
|
280
291
|
node "path\to\llm-switcher\switch.mjs" %*
|
|
@@ -283,18 +294,18 @@ Mỗi khi bạn chuyển đổi profile, LLM Switcher sẽ tự động sinh fil
|
|
|
283
294
|
2. Thêm đoạn mã sau vào wrapper chính của Claude Code (`claude.cmd` trong thư mục global npm):
|
|
284
295
|
```cmd
|
|
285
296
|
SETLOCAL EnableDelayedExpansion
|
|
286
|
-
IF EXIST "
|
|
297
|
+
IF EXIST "%USERPROFILE%\.llm-switcher\active.flag" (
|
|
287
298
|
SET "ANTHROPIC_BASE_URL=http://127.0.0.1:3456"
|
|
288
299
|
SET "CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1"
|
|
289
300
|
)
|
|
290
|
-
IF EXIST "
|
|
291
|
-
SET /P M1M=<"
|
|
301
|
+
IF EXIST "%USERPROFILE%\.llm-switcher\1m.flag" (
|
|
302
|
+
SET /P M1M=<"%USERPROFILE%\.llm-switcher\1m.flag"
|
|
292
303
|
IF "!M1M!"=="" SET "M1M=opus[1m]"
|
|
293
304
|
SET "ANTHROPIC_MODEL=!M1M!"
|
|
294
305
|
SET "CLAUDE_CODE_AUTO_COMPACT_WINDOW=900000"
|
|
295
306
|
)
|
|
296
307
|
```
|
|
297
|
-
> Cần `SETLOCAL EnableDelayedExpansion` để `!M1M!` hoạt động. npm ghi đè `claude.cmd` mỗi lần update, nên tốt hơn là tạo wrapper riêng chạy `call "
|
|
308
|
+
> Cần `SETLOCAL EnableDelayedExpansion` để `!M1M!` hoạt động. npm ghi đè `claude.cmd` mỗi lần update, nên tốt hơn là tạo wrapper riêng chạy `call "%USERPROFILE%\.llm-switcher\env.cmd"` rồi `claude %*`. Với bản checkout, thay `%USERPROFILE%\.llm-switcher` bằng thư mục checkout. Chỉ `env.cmd` / `env.sh` mới có các biến `ANTHROPIC_DEFAULT_<TIER>_MODEL=<tier>[1m]` theo từng tier.
|
|
298
309
|
|
|
299
310
|
---
|
|
300
311
|
|
|
@@ -337,7 +348,7 @@ base URL is overridden to http://127.0.0.1:3456/v1. Selecting models may not be
|
|
|
337
348
|
Blindfold xóa dòng đó. Codex giữ nguyên endpoint chính thức, switcher chặn ở tầng mạng. Không cần quyền admin, không cài chứng chỉ vào system trust store, không sửa `~/.codex/config.toml`.
|
|
338
349
|
|
|
339
350
|
```bash
|
|
340
|
-
bash blindfold/make-certs.sh chatgpt.com # chạy một lần
|
|
351
|
+
bash "$(npm root -g)/llm-switcher/blindfold/make-certs.sh" chatgpt.com # chạy một lần; bản checkout chạy blindfold/make-certs.sh
|
|
341
352
|
# rồi đặt "blindfold": true trong profile Codex
|
|
342
353
|
switch codex <profile> # gateway khởi động interceptor
|
|
343
354
|
```
|
|
@@ -414,12 +425,12 @@ Một server Model Context Protocol (MCP) chạy qua `stdio` cực nhẹ (Zero-d
|
|
|
414
425
|
|
|
415
426
|
LƯU Ý: `switcher_recent_logs` trả 150 ký tự đầu của mỗi prompt gần đây, từ mọi client đã dùng gateway. Agent gọi tool này đọc được chúng.
|
|
416
427
|
|
|
417
|
-
Thêm vào cấu hình MCP (ví dụ `opencode.jsonc`, `claude_desktop_config.json`, hoặc Cursor)
|
|
428
|
+
Thêm server vào cấu hình MCP (ví dụ `opencode.jsonc`, `claude_desktop_config.json`, hoặc Cursor). Chạy `npm root -g` để biết thư mục chứa `llm-switcher/mcp.mjs`. Với bản checkout, dùng `mcp.mjs` trong thư mục checkout.
|
|
418
429
|
```json
|
|
419
430
|
"mcp": {
|
|
420
431
|
"llm-switcher": {
|
|
421
432
|
"type": "local",
|
|
422
|
-
"command": ["node", "path/
|
|
433
|
+
"command": ["node", "/path/from/npm-root-g/llm-switcher/mcp.mjs"],
|
|
423
434
|
"enabled": true
|
|
424
435
|
}
|
|
425
436
|
}
|
|
@@ -546,13 +557,14 @@ Contract lab tìm các field mà converter làm mất. Mặc định tính năng
|
|
|
546
557
|
|
|
547
558
|
| Tuỳ chọn | Mô tả |
|
|
548
559
|
|---|---|
|
|
549
|
-
| `
|
|
560
|
+
| `LLM_SWITCHER_HOME=/path` | Dùng thư mục này làm thư mục dữ liệu (config, admin token, file launcher, log) cho cả bản npm lẫn bản checkout. |
|
|
561
|
+
| `LLM_SWITCHER_CONFIG=/path/config.json` | Dùng file cấu hình nằm ngoài thư mục dữ liệu (proxy, `switch` và `mcp.mjs` đều hỗ trợ). |
|
|
550
562
|
| `--port <n>` / `LLM_SWITCHER_PORT` | Ghi đè cổng lắng nghe (ưu tiên: flag > env > `config.port`). |
|
|
551
563
|
| 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. |
|
|
552
564
|
| `profile.thinkingMode` | `auto` (mặc định, cho gateway như 9Router): phục hồi thinking bị xoá, chèn hướng dẫn `<think>` cho model không có reasoning, gửi `thinking` + `reasoning_effort`. `native` (API OpenAI nghiêm ngặt): chỉ gửi `reasoning_effort` khi client yêu cầu, không sửa prompt, dùng `max_completion_tokens`. `off`: không bao giờ gửi tham số reasoning. |
|
|
553
565
|
| `profile.endpoints.countTokens` | Ghi đè URL `count_tokens` của Anthropic. |
|
|
554
566
|
| `profile.endpoints` | Ghi đè URL upstream theo từng format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
|
|
555
|
-
| `LLM_SWITCHER_STATE_DIR` | Chuyển file launcher và log ra khỏi thư mục
|
|
567
|
+
| `LLM_SWITCHER_STATE_DIR` | Chuyển file launcher và log ra khỏi thư mục dữ liệu. Test dùng biến này; shim đọc thư mục đã đặt lúc cài shim. |
|
|
556
568
|
| `CLAUDE_CONFIG_DIR` | Được tôn trọng khi tìm `settings.json` của Claude Code. |
|
|
557
569
|
|
|
558
570
|
## Mô hình Bảo mật
|
package/blindfold/make-certs.sh
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# Build the private CA and the leaf certificate that blindfold.mjs presents.
|
|
3
3
|
#
|
|
4
4
|
# Windows: run this from Git Bash. The openssl that ships with Git for Windows works.
|
|
5
|
+
# macOS: /usr/bin/openssl is LibreSSL. Use only options that LibreSSL also has (it has no `x509 -ext`).
|
|
5
6
|
# The earlier attempt used PowerShell New-SelfSignedCertificate, whose leaf was refused
|
|
6
7
|
# with "unsuitable certificate purpose" because it carried no serverAuth extended key
|
|
7
8
|
# usage. The extension files below are what fix that, so do not drop them.
|
|
@@ -14,7 +15,14 @@
|
|
|
14
15
|
set -euo pipefail
|
|
15
16
|
|
|
16
17
|
HOST="${1:-chatgpt.com}"
|
|
17
|
-
|
|
18
|
+
# The default must match the folder the gateway reads (state.mjs paths.blindfoldCA): an npm
|
|
19
|
+
# install keeps its data in ~/.llm-switcher, a git checkout next to the code.
|
|
20
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
21
|
+
if [ -n "${LLM_SWITCHER_BLINDFOLD_CERTS:-}" ]; then DEFAULT_OUT="$LLM_SWITCHER_BLINDFOLD_CERTS"
|
|
22
|
+
elif [ -n "${LLM_SWITCHER_HOME:-}" ]; then DEFAULT_OUT="$LLM_SWITCHER_HOME/blindfold/certs"
|
|
23
|
+
elif [ -d "$ROOT/.git" ]; then DEFAULT_OUT="$ROOT/blindfold/certs"
|
|
24
|
+
else DEFAULT_OUT="$HOME/.llm-switcher/blindfold/certs"; fi
|
|
25
|
+
OUT_DIR="${2:-$DEFAULT_OUT}"
|
|
18
26
|
CA_DAYS=3650
|
|
19
27
|
LEAF_DAYS=825
|
|
20
28
|
|
|
@@ -85,4 +93,4 @@ mv -f "$WORK/ca.key" "$WORK/ca.pem" "$WORK/leaf.key" "$WORK/leaf.pem" "$OUT_DIR/
|
|
|
85
93
|
|
|
86
94
|
echo "[blindfold] CA : $OUT_DIR/ca.pem"
|
|
87
95
|
echo "[blindfold] leaf : $OUT_DIR/leaf.pem"
|
|
88
|
-
openssl x509 -in "$OUT_DIR/leaf.pem" -noout -
|
|
96
|
+
openssl x509 -in "$OUT_DIR/leaf.pem" -noout -text | grep -A1 -E "Extended Key Usage|Subject Alternative Name"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-switcher",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code, Codex, OpenAI and Gemini clients",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"start": "node proxy.mjs",
|
|
33
|
-
"test": "node
|
|
33
|
+
"test": "node scripts/run-tests.mjs",
|
|
34
34
|
"test:live": "node tests/live-optimizer-interop.mjs"
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Runs only tests/**/*.test.mjs. A bare `node --test` also collects test files from git-ignored
|
|
2
|
+
// folders (an old copy in temp/), and Node 18 and 20 do not expand a glob argument.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const files = [];
|
|
10
|
+
(function walk(dir) {
|
|
11
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
12
|
+
const p = path.join(dir, e.name);
|
|
13
|
+
if (e.isDirectory()) walk(p);
|
|
14
|
+
else if (e.name.endsWith('.test.mjs')) files.push(path.relative(root, p));
|
|
15
|
+
}
|
|
16
|
+
})(path.join(root, 'tests'));
|
|
17
|
+
|
|
18
|
+
const r = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], { cwd: root, stdio: 'inherit' });
|
|
19
|
+
process.exit(r.status ?? 1);
|
package/state.mjs
CHANGED
|
@@ -792,13 +792,15 @@ function getJson(port, pathname, timeoutMs = 3000) {
|
|
|
792
792
|
|
|
793
793
|
const newNonce = () => crypto.randomBytes(16).toString('hex');
|
|
794
794
|
|
|
795
|
-
/** 'ours' | 'foreign' | 'silent' | 'free'. Treat 'silent' like 'foreign' in every decision. */
|
|
795
|
+
/** 'ours' | 'legacy' | 'foreign' | 'silent' | 'free'. Treat 'legacy' and 'silent' like 'foreign' in every decision. */
|
|
796
796
|
export async function probeGateway(port) {
|
|
797
797
|
const nonce = newNonce();
|
|
798
798
|
const r = await getJson(port, `/health?challenge=${nonce}`);
|
|
799
799
|
if (r.state !== 'answered') return r.state;
|
|
800
800
|
const b = r.body;
|
|
801
801
|
if (b?.proxy !== 'llm-switcher' || b.port !== port) return 'foreign';
|
|
802
|
+
// Gateways before 1.1.1 answer without a proof: name them, but never trust them enough to stop them.
|
|
803
|
+
if (!('proof' in b)) return 'legacy';
|
|
802
804
|
const proof = identityProof(nonce, { role: 'gateway', port, pid: b.pid });
|
|
803
805
|
return proof && b.proof === proof ? 'ours' : 'foreign';
|
|
804
806
|
}
|
package/switch.mjs
CHANGED
|
@@ -82,9 +82,14 @@ function startProxyBackground(port) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// 'silent' is a listener that never answered: a hung gateway of ours, or another program.
|
|
85
|
-
const isHeld = (state) => state === 'foreign' || state === 'silent';
|
|
85
|
+
const isHeld = (state) => state === 'foreign' || state === 'silent' || state === 'legacy';
|
|
86
86
|
|
|
87
87
|
function refuseForeignPort(port, owner, state = 'foreign') {
|
|
88
|
+
if (state === 'legacy') {
|
|
89
|
+
console.error(`[Error] An llm-switcher gateway older than 1.1.1 runs on port ${port}. Stop it, then run \`switch on\` again.`);
|
|
90
|
+
console.error(' It cannot prove its identity, so this switcher does not stop it. Nothing was changed.');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
88
93
|
console.error(state === 'silent'
|
|
89
94
|
? `[Error] Port ${port} accepts connections but does not answer. A hung ${owner} or another program holds it.`
|
|
90
95
|
: `[Error] Port ${port} is held by another process, not by ${owner}.`);
|
|
@@ -204,7 +209,7 @@ function listeningPids(port) {
|
|
|
204
209
|
return [...pids];
|
|
205
210
|
}
|
|
206
211
|
|
|
207
|
-
// Returns 'stopped', 'not-running', 'not-ours', 'silent' or 'still-running'. The kill targets the process that
|
|
212
|
+
// Returns 'stopped', 'not-running', 'not-ours', 'legacy', 'silent' or 'still-running'. The kill targets the process that
|
|
208
213
|
// listens on the port, right after the identity probe confirmed that listener is this switcher.
|
|
209
214
|
async function stopProxy(port) {
|
|
210
215
|
const state = await probeGateway(port);
|
|
@@ -226,6 +231,7 @@ async function stopProxy(port) {
|
|
|
226
231
|
return 'stopped';
|
|
227
232
|
}
|
|
228
233
|
if (state === 'foreign') return 'not-ours';
|
|
234
|
+
if (state === 'legacy') return 'legacy';
|
|
229
235
|
if (state === 'silent') {
|
|
230
236
|
if (ownUnit) {
|
|
231
237
|
serviceStop(svc);
|
|
@@ -465,6 +471,10 @@ async function turnOff(targetArg) {
|
|
|
465
471
|
console.error(`[Error] The gateway on port ${port} is still running. Stop it by hand; the launcher files are already cleared.`);
|
|
466
472
|
process.exit(1);
|
|
467
473
|
}
|
|
474
|
+
if (result === 'legacy') {
|
|
475
|
+
console.error(`[Error] An llm-switcher gateway older than 1.1.1 runs on port ${port}. Stop it, then run \`switch on\` again.`);
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
468
478
|
if (result === 'not-ours' || result === 'silent') {
|
|
469
479
|
console.error(result === 'silent'
|
|
470
480
|
? `[Error] Port ${port} accepts connections but does not answer, so it is not proven to be this switcher. It was not stopped.`
|
|
@@ -502,7 +512,7 @@ async function showStatus() {
|
|
|
502
512
|
const flagged = fs.existsSync(paths.activeFlag);
|
|
503
513
|
|
|
504
514
|
console.log('=== LLM Switcher Status ===');
|
|
505
|
-
const held = { foreign: `PORT ${port} HELD BY ANOTHER PROCESS`, silent: `PORT ${port} DOES NOT ANSWER (hung gateway or another program)` };
|
|
515
|
+
const held = { legacy: `OLD GATEWAY (< 1.1.1) ON PORT ${port}: stop it, then run \`switch on\``, foreign: `PORT ${port} HELD BY ANOTHER PROCESS`, silent: `PORT ${port} DOES NOT ANSWER (hung gateway or another program)` };
|
|
506
516
|
console.log(`Proxy Service: ${isRunning ? `RUNNING (port ${port})` : held[gateway] || 'STOPPED'}`);
|
|
507
517
|
console.log(`Web UI: http://127.0.0.1:${port}/ui`);
|
|
508
518
|
console.log(`Launcher Flag: ${flagged ? 'active.flag present' : 'absent (launchers use official endpoints)'}`);
|
package/tests/datadir.test.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
6
7
|
import { resolveDataDir } from '../state.mjs';
|
|
7
8
|
|
|
8
9
|
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'llm-sw-datadir-'));
|
|
@@ -35,3 +36,16 @@ test('package.json is publishable and exposes the switch command', () => {
|
|
|
35
36
|
assert.equal(pkg.bin?.switch, 'switch.mjs');
|
|
36
37
|
assert.ok(fs.existsSync(new URL('../LICENSE', import.meta.url)), 'LICENSE file exists');
|
|
37
38
|
});
|
|
39
|
+
|
|
40
|
+
// macOS ships LibreSSL as /usr/bin/openssl. It has no `x509 -ext`; the script must not depend on it.
|
|
41
|
+
test('make-certs.sh runs with an openssl that lacks LibreSSL-missing options', { skip: process.platform === 'win32' }, () => {
|
|
42
|
+
const dir = tmp();
|
|
43
|
+
const real = execFileSync('sh', ['-c', 'command -v openssl'], { encoding: 'utf8' }).trim();
|
|
44
|
+
const bin = path.join(dir, 'bin');
|
|
45
|
+
fs.mkdirSync(bin);
|
|
46
|
+
fs.writeFileSync(path.join(bin, 'openssl'), `#!/bin/sh\nfor a in "$@"; do [ "$a" = "-ext" ] && { echo "unknown option -ext" >&2; exit 1; }; done\nexec "${real}" "$@"\n`, { mode: 0o755 });
|
|
47
|
+
const out = path.join(dir, 'certs');
|
|
48
|
+
const script = new URL('../blindfold/make-certs.sh', import.meta.url).pathname;
|
|
49
|
+
execFileSync('bash', [script, 'chatgpt.com', out], { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, stdio: 'pipe' });
|
|
50
|
+
for (const f of ['ca.pem', 'leaf.pem', 'leaf.key']) assert.ok(fs.existsSync(path.join(out, f)), f);
|
|
51
|
+
});
|
package/tests/state.test.mjs
CHANGED
|
@@ -514,7 +514,9 @@ test('make-certs.sh builds a CA that can sign only for its host', (t) => {
|
|
|
514
514
|
fs.writeFileSync(path.join(dir, 'evil.ext'), 'subjectAltName = DNS:evil.test\n');
|
|
515
515
|
ossl('x509', '-req', '-in', 'evil.csr', '-CA', path.join(certs, 'ca.pem'), '-CAkey', path.join(certs, 'ca.key'),
|
|
516
516
|
'-CAcreateserial', '-days', '1', '-extfile', 'evil.ext', '-out', 'evil.pem');
|
|
517
|
-
|
|
517
|
+
// LibreSSL prints the verify error on stdout, OpenSSL 3 on stderr.
|
|
518
|
+
assert.throws(() => ossl('verify', '-CAfile', path.join(certs, 'ca.pem'), 'evil.pem'),
|
|
519
|
+
(e) => /permitted subtree violation/.test(`${e.stdout}${e.stderr}`));
|
|
518
520
|
});
|
|
519
521
|
|
|
520
522
|
// ---- Launch state, config cache, logs, probes (audit F28, F31, F42, M5, racer "blocked gateway") ----
|
|
@@ -595,6 +597,30 @@ test('a port that accepts but never answers probes as silent, not as foreign', a
|
|
|
595
597
|
assert.equal(await probeGateway(server.address().port), 'silent');
|
|
596
598
|
});
|
|
597
599
|
|
|
600
|
+
// A gateway from before 1.1.1 answers /health without an identity proof. It is ours in spirit but
|
|
601
|
+
// cannot be proven, so it is never stopped; the CLI names it instead of calling it a foreign process.
|
|
602
|
+
test('a pre-1.1.1 gateway answers without a proof and probes as legacy, not as foreign', async (t) => {
|
|
603
|
+
const http = await import('node:http');
|
|
604
|
+
const server = http.createServer((req, res) => {
|
|
605
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
606
|
+
res.end(JSON.stringify({ status: 'ok', proxy: 'llm-switcher', port: server.address().port, configLoaded: true }));
|
|
607
|
+
});
|
|
608
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
609
|
+
t.after(() => server.close());
|
|
610
|
+
assert.equal(await probeGateway(server.address().port), 'legacy');
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test('an llm-switcher answer with a wrong proof is still foreign', async (t) => {
|
|
614
|
+
const http = await import('node:http');
|
|
615
|
+
const server = http.createServer((req, res) => {
|
|
616
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
617
|
+
res.end(JSON.stringify({ status: 'ok', proxy: 'llm-switcher', port: server.address().port, pid: 1, proof: 'forged' }));
|
|
618
|
+
});
|
|
619
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
620
|
+
t.after(() => server.close());
|
|
621
|
+
assert.equal(await probeGateway(server.address().port), 'foreign');
|
|
622
|
+
});
|
|
623
|
+
|
|
598
624
|
// Codex parses an unquoted --config value as TOML first. The Windows shim passes names unquoted, so a
|
|
599
625
|
// name that TOML reads as a number, a boolean or a date would change type (audit N-3 residual).
|
|
600
626
|
test('isSafeModelName refuses names that TOML reads as something other than a string', () => {
|