llm-switcher 1.2.7 → 1.2.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 +49 -0
- package/README.md +87 -4
- package/README.vi.md +88 -4
- package/blindfold/blindfold.mjs +2 -1
- package/docs/cross-platform.md +3 -1
- package/hook-status.mjs +51 -0
- package/notify-route.ps1 +46 -0
- package/package.json +2 -2
- package/plugin.mjs +204 -0
- package/proxy.mjs +76 -7
- package/service.mjs +1 -1
- package/shim.mjs +28 -2
- package/state.mjs +41 -5
- package/switch.mjs +53 -2
- package/tests/blindfold.test.mjs +1 -1
- package/tests/cdp.mjs +21 -2
- package/tests/contract-lab.test.mjs +5 -0
- package/tests/dashboard.e2e.test.mjs +116 -0
- package/tests/gateway.e2e.test.mjs +51 -2
- package/tests/hook-status.test.mjs +119 -0
- package/tests/plugin.test.mjs +132 -0
- package/tests/real-user-sim.test.mjs +2 -2
- package/tests/service.test.mjs +2 -0
- package/tests/shim.test.mjs +112 -0
- package/tests/state.test.mjs +130 -5
- package/ui.html +86 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.2.9
|
|
4
|
+
|
|
5
|
+
- **Bifrost:** Claude Code that reaches a Claude Code account on intact now goes through unchanged. Only the key changes. Before, a `convert` profile, or a `hybrid` profile with a `claude/...` model, changed the request to OpenAI Chat. The `direct` route also ran the healer and `thinkingMode`. Now the gateway asks intact for `bifrost_ua` of the mapped model. If the `User-Agent` of the client starts with this value, the gateway sends every client header, the body bytes, and the query string. There is no setting. intact 0.1.10 or newer gives `bifrost_ua`.
|
|
6
|
+
|
|
7
|
+
## Release 1.2.8
|
|
8
|
+
|
|
9
|
+
- **Launch notice:** A person opened `claude` or `codex`, and nothing on screen said that the switcher took the traffic. The shim now raises a notice at launch. On Windows it is a desktop toast, because both tools can claim the whole screen and hide a printed line. On Linux and macOS it is one line on stderr. The notice names the profile, the host, the model, and the 1M window.
|
|
10
|
+
- **Only a routed tool:** The notice appears only while that tool has a profile. A tool on its official endpoint stays silent. `switch off codex` empties `route-codex.txt`, and the codex shim then says nothing.
|
|
11
|
+
- **New files:** `route-claude.txt` and `route-codex.txt` hold the notice text. The switcher writes each one with the env file of the same tool. `notify-route.ps1` raises the toast, and it needs no PowerShell module.
|
|
12
|
+
- **Tests:** New tests prove the state files and the notice. One test runs the real `.cmd` shim through cmd.exe with a fake `powershell` on PATH. It reads what the shim asked for.
|
|
13
|
+
- **A second notice, inside the tool:** The shim notice needs the shim directory first on `PATH`. A person who keeps their own `claude` wrapper never sees it. `switch plugin install` adds a hook that runs inside each tool instead. It reports three states: routed and the gateway answers, routed and the gateway does not answer, and not routed. The second state used to appear only as a connection error with no cause.
|
|
14
|
+
- **No configuration file is opened:** Claude Code loads `~/.claude/skills/llm-switcher-status/` as a plugin on the next session, with no marketplace and no install step. Codex reads `~/.codex/hooks.json` by itself. `settings.json` and `config.toml` stay closed. An existing `~/.codex/hooks.json` is merged, and a file that does not parse is refused.
|
|
15
|
+
- **The notice is optional:** `switch plugin install` is a choice. Install nothing and the gateway still routes every request.
|
|
16
|
+
|
|
17
|
+
### Found by pressing the dashboard like a person
|
|
18
|
+
|
|
19
|
+
A pass over the running dashboard in a real browser, with real clicks and real keys, never a value
|
|
20
|
+
set through the page's own JavaScript. Each item below has a browser test that reproduces it.
|
|
21
|
+
|
|
22
|
+
- **A narrow window hid the navigation:** At 400 px the sidebar collapsed to its padding, 16 px, and the navigation was drawn outside it. No link could be pressed. The `@media` rule set `height: auto` but kept `overflow-y: auto` from the base rule, and a grid item that scrolls has an automatic minimum size of zero. The rule now resets `overflow`.
|
|
23
|
+
- **A card stayed in the editing state:** After Escape, Cancel or a press on the dark area, the card kept its ring and its button still read "Editing Profile". `closeProfileModal` never cleared the key that the render reads.
|
|
24
|
+
- **The keyboard stayed outside the dialog:** Opening a profile dialog left the focus on the button behind the overlay, so the first Tab walked into the page under the dialog. The dialog now takes the focus when it opens.
|
|
25
|
+
- **A connection result from the profile before:** A new profile opened showing the "HTTP 200 OK" and the latency of the profile opened before it. The three result fields are emptied on every open.
|
|
26
|
+
- **A double-click on Save sent two saves:** One double-click sent two `POST /api/save-profile`. The button is disabled while the request runs, the same way the catalog sync button already was.
|
|
27
|
+
- **A model search with no match looked broken:** Every row was hidden while the headers kept the counts of the full list, and nothing said why the list was empty. The count follows the filter now, and an empty result says so in words.
|
|
28
|
+
- **Tests:** Eight new browser tests, one for each finding. Two more findings from that pass did not reproduce under a real click and are kept as guards rather than reported as bugs.
|
|
29
|
+
|
|
30
|
+
### Linux and Node 18
|
|
31
|
+
|
|
32
|
+
- **Tests on Node 18 and 20:** `engines` accepts Node 18.17 and later, but four tests failed on Node 18 and Node 20. A global `WebSocket` client arrived in Node 22, and `zlib.zstdCompressSync` arrived in Node 22.15. The runtime code already tested for zstd before it used it, so only the tests assumed the newer runtime. The three WebSocket tests and the zstd assertion now skip when the runtime does not hold those APIs.
|
|
33
|
+
- **A zstd capture on an older runtime:** `DECODERS` mapped `zstd` to `zlib.zstdDecompressSync`, which is `undefined` before Node 22.15. The capture then wrote the compressed bytes as UTF-8 noise, and that noise is what the decoder table exists to prevent. The capture now holds `[capture: cannot decode zstd body of N bytes: zstd is not supported by this Node.js version]`.
|
|
34
|
+
- **`LLM_SWITCHER_HOME` and the background service:** The README gives `LLM_SWITCHER_HOME` as the override for the data folder, but `switch service install` did not copy it into the systemd unit or the launchd plist. The service then read a different `config.json` and a different `admin.token` than the command that installed it. The variable now goes into the unit with the other three.
|
|
35
|
+
- **Mode of the data folder:** The Quick Start created the data folder with `mkdir -p`, so a common umask left it readable by the group and by everyone. `state.mjs` applies mode 700 only when it creates the folder itself. The README now uses `mkdir -p -m 700`.
|
|
36
|
+
- **Notes for the Linux service:** The unit is a systemd user service, so it starts at login and not at boot. On a headless host, run `loginctl enable-linger $USER` first. `ExecStart` holds the path of the running Node binary. After you remove that Node version, run `switch service install` again.
|
|
37
|
+
|
|
38
|
+
### The profile switch
|
|
39
|
+
|
|
40
|
+
- **A backup with the API keys on every switch:** `setTargetProfile` and `activateProfile` still wrote the legacy `activeProfile` pointer. `needsMigration` reports any configuration that holds that key, so the next load migrated the file again and wrote another `config.json.bak-*`. Each backup is a full copy of `config.json`, with every provider API key, and the backups stayed after a success. Neither function writes the pointer now. `ensureActiveMap` already folds a legacy pointer into `activeProfiles`, and a load still migrates an old file once. Four switches left three backups before this change and none after it.
|
|
41
|
+
- **A placeholder profile activated without a warning:** With the unedited `config.example.json`, `switch on` printed `[SUCCESS]` and routed to `https://YOUR-ROUTER-HOST/v1`. Only `switch doctor` gave a warning. Activation now uses the same `hasPlaceholder` test as `doctor`, and it names the file to edit. Activation only warns and does not refuse, so a scripted setup still works.
|
|
42
|
+
- **Tests:** New tests cover the service environment keys, `hasPlaceholder`, and a migrated configuration that does not need migration again after a switch. One test pins the same invariant on `deleteProfile`, the third place that can write the legacy pointer.
|
|
43
|
+
|
|
44
|
+
### Ports, shells, and what the package serves
|
|
45
|
+
|
|
46
|
+
- **A second instance had no port for the interceptor:** `--port` and `LLM_SWITCHER_PORT` move the gateway through `resolvePort`, but `computeLaunchState` read the interceptor port from `config.json` alone. `LLM_SWITCHER_BLINDFOLD_PORT` reached a hand-started `blindfold.mjs` and nothing else. A gateway sent to 3457 also landed on the port of the interceptor. The interceptor port now follows the same precedence as the gateway port, and it moves off the gateway port when the two meet. The README names both variables together.
|
|
47
|
+
- **A fish user got a line that fish cannot run:** The PATH hint printed `export PATH="..."`, which is not fish syntax, and it named `~/.profile`, which fish does not read. The hint now names `~/.config/fish/config.fish` and gives `fish_add_path -m`. zsh and bash keep what they had.
|
|
48
|
+
- **`switch on` installs the shims:** The README gave `switch shim install` as a separate step. `switch on` already installs the shims and prints what it installed. The README now says so, and it names the one case that still needs the command: a set `LLM_SWITCHER_STATE_DIR`.
|
|
49
|
+
- **What the package says it serves:** The description, both README taglines and the dashboard subtitle gave OpenAI and Gemini as clients. This gateway accepts two clients, Claude Code and Codex, and `toIR` accepts no other input format. OpenAI, Anthropic and Vertex are upstreams. All four texts now say that.
|
|
50
|
+
- **A test that failed on a loaded machine:** The lock test released the lock 400 ms after it started the child, then asserted that the child waited 100 ms or more. A spawn plus an import measured 163 to 541 ms on Node 18, so the child sometimes found the lock gone. The test now counts the 400 ms from the moment the child reports its start, the same way the test below it does.
|
|
51
|
+
|
|
3
52
|
## Release 1.2.7
|
|
4
53
|
|
|
5
54
|
- **Save from the dashboard:** The dashboard sent `tool: "auto"` for a profile that serves both tools. The gateway refuses that value. As a result, a new profile with the default tool, four of the five templates, and a save of any both-tools profile all failed. Now the dashboard saves `tool: null` for such a profile. The Vertex AI template uses `claude`.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
4
|
<b>Zero-dependency, multi-protocol edge gateway & provider switcher</b><br>
|
|
5
|
-
|
|
5
|
+
Bridge <b>Claude Code</b> and <b>Codex</b> to any upstream LLM API: OpenAI-compatible, Anthropic, or Vertex.<br>
|
|
6
6
|
Full bi-directional protocol conversion, official-model context windows, thinking protocol extraction, and edge message healing.
|
|
7
7
|
</p>
|
|
8
8
|
|
|
@@ -171,7 +171,7 @@ LLM Switcher acts as a transparent man-in-the-middle without ever touching clien
|
|
|
171
171
|
npm install -g llm-switcher
|
|
172
172
|
|
|
173
173
|
# Copy the example configuration into your data folder.
|
|
174
|
-
mkdir -p ~/.llm-switcher
|
|
174
|
+
mkdir -p -m 700 ~/.llm-switcher
|
|
175
175
|
cp "$(npm root -g)/llm-switcher/config.example.json" ~/.llm-switcher/config.json
|
|
176
176
|
```
|
|
177
177
|
|
|
@@ -234,6 +234,10 @@ In practice you never call these files. `switch shim install` puts `~/.llm-switc
|
|
|
234
234
|
and every `claude` and `codex` invocation — including `claude --resume` in a brand-new terminal —
|
|
235
235
|
runs the shim, which injects the variables into that one process.
|
|
236
236
|
|
|
237
|
+
`switch on` installs the shims itself and prints what it installed, so the command above is only
|
|
238
|
+
necessary when `LLM_SWITCHER_STATE_DIR` is set. A shim holds the state directory, and `switch on`
|
|
239
|
+
leaves the install to you in that case, because a temporary state directory outlives no shim.
|
|
240
|
+
|
|
237
241
|
---
|
|
238
242
|
|
|
239
243
|
### Claude Code Setup (Windows)
|
|
@@ -399,6 +403,19 @@ Add the server to your MCP configuration (for example `opencode.jsonc`, `claude_
|
|
|
399
403
|
|
|
400
404
|
---
|
|
401
405
|
|
|
406
|
+
## Bifrost: Claude Code to a Claude Code account on intact
|
|
407
|
+
|
|
408
|
+
Bifrost sends a Claude Code request to intact without a change. Only the key changes. The healer, `thinkingMode`, and the format conversion do not run.
|
|
409
|
+
|
|
410
|
+
Bifrost has no setting. The gateway turns it on for each request when two conditions are true:
|
|
411
|
+
|
|
412
|
+
1. intact gives `bifrost_ua` for the mapped model in `GET /v1/models/{model}`. intact gives it only for a model of a Claude Code account.
|
|
413
|
+
2. The `User-Agent` of the client starts with the `bifrost_ua` value.
|
|
414
|
+
|
|
415
|
+
The gateway keeps the answer from intact for 10 minutes for each model. If intact does not answer, the gateway keeps the result for 30 seconds and uses the normal route.
|
|
416
|
+
|
|
417
|
+
The gateway sends every client header, the body bytes, and the query string. It removes the credentials of the client and the hop-by-hop headers, and it sets `x-api-key` to the profile key. If the profile maps the model, the gateway also changes the `model` field. A different client, or a model that is not a Claude Code account, uses the normal route of the profile.
|
|
418
|
+
|
|
402
419
|
## CLI Reference
|
|
403
420
|
|
|
404
421
|
```bash
|
|
@@ -416,6 +433,9 @@ switch service uninstall # Remove background autostart service
|
|
|
416
433
|
switch shim install # Route new Claude and Codex sessions through the gateway
|
|
417
434
|
switch shim status # Verify shims + detect running sessions that bypass the gateway
|
|
418
435
|
switch shim uninstall # Remove the launcher shims
|
|
436
|
+
switch plugin install # Optional: a launch notice inside Claude Code and Codex
|
|
437
|
+
switch plugin status # Report whether that notice is installed
|
|
438
|
+
switch plugin uninstall # Remove that notice
|
|
419
439
|
switch off # Stop everything and restore the official endpoints
|
|
420
440
|
switch off claude # Turn Claude Code off; Codex keeps running
|
|
421
441
|
switch off codex # Turn Codex off; Claude Code keeps running
|
|
@@ -428,7 +448,9 @@ either `"claude"` or `"codex"` (or `null` for a profile that is switched off), s
|
|
|
428
448
|
`switch codex` can never point at the same profile by accident. There is no `openai` or `vertex`
|
|
429
449
|
target any more — the input routes those names stood for are gone.
|
|
430
450
|
|
|
431
|
-
The service runs without your shell. `switch service install` therefore copies `CLAUDE_CONFIG_DIR`, `LLM_SWITCHER_CONFIG`, `LLM_SWITCHER_STATE_DIR` and `LLM_SWITCHER_BLINDFOLD_CERTS` into the systemd unit or the launchd plist when they are set. The Windows task cannot carry them; set them as User environment variables instead. If the installed definition differs from the new one, for example after a hand edit, the old file is kept as `<file>.bak`. On Windows the task is created from an XML definition, so paths with spaces need no extra quoting and the task has no run-time limit. This Windows path is not tested on Windows yet.
|
|
451
|
+
The service runs without your shell. `switch service install` therefore copies `LLM_SWITCHER_HOME`, `CLAUDE_CONFIG_DIR`, `LLM_SWITCHER_CONFIG`, `LLM_SWITCHER_STATE_DIR` and `LLM_SWITCHER_BLINDFOLD_CERTS` into the systemd unit or the launchd plist when they are set. The Windows task cannot carry them; set them as User environment variables instead. If the installed definition differs from the new one, for example after a hand edit, the old file is kept as `<file>.bak`. On Windows the task is created from an XML definition, so paths with spaces need no extra quoting and the task has no run-time limit. This Windows path is not tested on Windows yet.
|
|
452
|
+
|
|
453
|
+
On Linux the unit is a systemd *user* service: it starts when you log in. To start it at boot without a login (a server reached over SSH), run `loginctl enable-linger $USER` once. The unit records the absolute path of the current `node`; if you manage Node with nvm and remove that version, run `switch service install` again.
|
|
432
454
|
|
|
433
455
|
### Resumed sessions & the shim (important)
|
|
434
456
|
|
|
@@ -472,6 +494,66 @@ re-opened from a shell where the shim is on `PATH`.
|
|
|
472
494
|
|
|
473
495
|
---
|
|
474
496
|
|
|
497
|
+
### Knowing that the switcher is on
|
|
498
|
+
|
|
499
|
+
Transparency has a price. The switcher opens neither `settings.json` nor `config.toml`, so no banner
|
|
500
|
+
appears, and you can forget that another provider answers every request. Two notices correct that.
|
|
501
|
+
The first is always on. The second is your choice, and you lose nothing if you skip it.
|
|
502
|
+
|
|
503
|
+
**1. The notice from the shim — always on**
|
|
504
|
+
|
|
505
|
+
When the shim starts a routed tool, it reports where the traffic goes:
|
|
506
|
+
|
|
507
|
+
```
|
|
508
|
+
claude -> intact-claude | intact.louispham.qzz.io | antigravity/gemini-3.8-flash | 1M
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
On Windows this is a desktop toast. Both tools can claim the whole screen, so a printed line
|
|
512
|
+
disappears behind the interface. On Linux and macOS it is one line on stderr. A tool that is off says
|
|
513
|
+
nothing at all, because the notice is read from `route-<tool>.txt`, which is written with that tool's
|
|
514
|
+
env file and is empty exactly when the tool is not routed.
|
|
515
|
+
|
|
516
|
+
This notice needs the shim, so it needs `~/.llm-switcher/bin` first on `PATH`. If you keep your own
|
|
517
|
+
`claude` or `codex` wrapper, the shim never runs and the notice never appears. The second notice
|
|
518
|
+
covers that case.
|
|
519
|
+
|
|
520
|
+
**2. The notice inside the tool — optional**
|
|
521
|
+
|
|
522
|
+
```bash
|
|
523
|
+
switch plugin install # add it
|
|
524
|
+
switch plugin status # report whether it is there
|
|
525
|
+
switch plugin uninstall # remove it
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
This writes one file for each tool, and it opens no configuration file of either tool:
|
|
529
|
+
|
|
530
|
+
| Tool | What is written | Why the tool loads it |
|
|
531
|
+
| --- | --- | --- |
|
|
532
|
+
| Claude Code | `~/.claude/skills/llm-switcher-status/` | A folder with `.claude-plugin/plugin.json` under a skills directory loads as a plugin on the next session. There is no marketplace and no install step. |
|
|
533
|
+
| Codex | `~/.codex/hooks.json` | Codex reads this file by itself. `config.toml` stays closed. |
|
|
534
|
+
|
|
535
|
+
An existing `~/.codex/hooks.json` is merged. Your own hooks stay, and `switch plugin uninstall` takes
|
|
536
|
+
only ours away. If the file does not parse, the install refuses it and changes nothing, because that
|
|
537
|
+
file can hold work that no backup returns.
|
|
538
|
+
|
|
539
|
+
The hook runs inside the tool, so it reports whatever launched the tool. It reports three states:
|
|
540
|
+
|
|
541
|
+
- **Routed, and the gateway answers** — it names the profile, the host, the model and the 1M window.
|
|
542
|
+
- **Routed, and the gateway does not answer** — it warns that the tool cannot reach the provider, it
|
|
543
|
+
names the port, and it names `switch on`. Before this notice, that state appeared only as a
|
|
544
|
+
connection error with no cause.
|
|
545
|
+
- **Not routed** — nothing. A tool on its official endpoint stays silent.
|
|
546
|
+
|
|
547
|
+
Codex asks you once to trust a new hook. If you decline, the hook stays off and nothing else changes.
|
|
548
|
+
|
|
549
|
+
**If you want neither notice**
|
|
550
|
+
|
|
551
|
+
Install no plugin, and keep the shim off `PATH`. The gateway still routes every request, the healer
|
|
552
|
+
still runs, and `switch status` still reports the state. A notice is a reminder. It is never a part
|
|
553
|
+
of the routing.
|
|
554
|
+
|
|
555
|
+
---
|
|
556
|
+
|
|
475
557
|
## Configuration Schema (`config.json`)
|
|
476
558
|
|
|
477
559
|
```jsonc
|
|
@@ -564,7 +646,8 @@ Because of this split, a provider fingerprint is never a rule in this gateway. I
|
|
|
564
646
|
|---|---|
|
|
565
647
|
| `LLM_SWITCHER_HOME=/path` | Use this folder as the data folder (config, admin token, launch files, logs) for an npm install or a checkout. |
|
|
566
648
|
| `LLM_SWITCHER_CONFIG=/path/config.json` | Use a config file outside the data folder (the proxy, `switch` and `mcp.mjs` all honour it). |
|
|
567
|
-
| `--port <n>` / `LLM_SWITCHER_PORT` | Override the listening port (priority: flag > env > `config.port`). |
|
|
649
|
+
| `--port <n>` / `LLM_SWITCHER_PORT` | Override the listening port of the gateway (priority: flag > env > `config.port`). |
|
|
650
|
+
| `LLM_SWITCHER_BLINDFOLD_PORT` | Override the listening port of the interceptor (priority: env > `config.blindfold.port` > 3457). Set it together with `LLM_SWITCHER_PORT` to run a second instance. |
|
|
568
651
|
| `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. |
|
|
569
652
|
| `profile.thinkingMode` | `auto` (default, for gateways like intact or 9Router): restore stripped thinking, inject a `<think>` guide for non-reasoning models, send `thinking` + `reasoning_effort`. `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. |
|
|
570
653
|
| `profile.endpoints.countTokens` | Override the Anthropic `count_tokens` URL. |
|
package/README.vi.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
4
|
<b>Cổng ngõ biên (Edge Gateway) chuyển đổi đa giao thức LLM siêu nhẹ, Zero-Dependency</b><br>
|
|
5
|
-
Cầu nối hai chiều giữa <b>Claude Code</b
|
|
5
|
+
Cầu nối hai chiều giữa <b>Claude Code</b> và <b>Codex</b> với mọi nhà cung cấp LLM: OpenAI-compatible, Anthropic hoặc Vertex.<br>
|
|
6
6
|
Chuyển đổi giao thức qua IR, cửa sổ context theo model chính thức, trích xuất thinking blocks và tự chữa lành đồ thị tin nhắn trước khi ra Internet.
|
|
7
7
|
</p>
|
|
8
8
|
|
|
@@ -171,7 +171,7 @@ LLM Switcher hoạt động như một lớp trung gian mạng trong suốt (tra
|
|
|
171
171
|
npm install -g llm-switcher
|
|
172
172
|
|
|
173
173
|
# Chép file cấu hình mẫu vào thư mục dữ liệu.
|
|
174
|
-
mkdir -p ~/.llm-switcher
|
|
174
|
+
mkdir -p -m 700 ~/.llm-switcher
|
|
175
175
|
cp "$(npm root -g)/llm-switcher/config.example.json" ~/.llm-switcher/config.json
|
|
176
176
|
```
|
|
177
177
|
|
|
@@ -236,6 +236,10 @@ Trong thực tế bạn không bao giờ tự gọi các file này. `switch shim
|
|
|
236
236
|
vào `PATH`, và mọi lệnh `claude` hay `codex` — kể cả `claude --resume` trong một terminal hoàn toàn
|
|
237
237
|
mới — đều chạy shim, vốn chỉ tiêm biến vào đúng tiến trình đó.
|
|
238
238
|
|
|
239
|
+
`switch on` tự cài shim và in ra những gì đã cài, nên lệnh trên chỉ cần khi `LLM_SWITCHER_STATE_DIR`
|
|
240
|
+
được đặt. Shim mang theo state directory, và trong trưồng hợp đó `switch on` để việc cài cho bạn,
|
|
241
|
+
vì một state directory tạm thời sẽ biến mất trước shim.
|
|
242
|
+
|
|
239
243
|
---
|
|
240
244
|
|
|
241
245
|
### Cấu hình cho Claude Code (Windows)
|
|
@@ -396,6 +400,19 @@ Thêm server vào cấu hình MCP (ví dụ `opencode.jsonc`, `claude_desktop_co
|
|
|
396
400
|
|
|
397
401
|
---
|
|
398
402
|
|
|
403
|
+
## Bifrost: Claude Code tới tài khoản Claude Code trên intact
|
|
404
|
+
|
|
405
|
+
Bifrost gửi request của Claude Code tới intact nguyên vẹn. Chỉ key thay đổi. Healer, `thinkingMode` và bước chuyển định dạng không chạy.
|
|
406
|
+
|
|
407
|
+
Bifrost không có cấu hình. Gateway tự bật Bifrost cho từng request khi đủ hai điều kiện:
|
|
408
|
+
|
|
409
|
+
1. intact trả `bifrost_ua` cho model đã map trong `GET /v1/models/{model}`. intact chỉ trả trường này cho model của tài khoản Claude Code.
|
|
410
|
+
2. `User-Agent` của client bắt đầu bằng giá trị `bifrost_ua`.
|
|
411
|
+
|
|
412
|
+
Gateway giữ câu trả lời của intact 10 phút cho mỗi model. Nếu intact không trả lời, gateway giữ kết quả 30 giây và dùng đường thường.
|
|
413
|
+
|
|
414
|
+
Gateway gửi mọi header của client, nguyên byte body và query string. Gateway bỏ credential của client và các header hop-by-hop, rồi đặt `x-api-key` bằng key của profile. Nếu profile map model, gateway đổi thêm trường `model`. Client khác, hoặc model không thuộc tài khoản Claude Code, đi đường thường của profile.
|
|
415
|
+
|
|
399
416
|
## Bảng Tra cứu Lệnh CLI (`switch`)
|
|
400
417
|
|
|
401
418
|
```bash
|
|
@@ -413,6 +430,9 @@ switch service uninstall # Gỡ bỏ service chạy ngầm
|
|
|
413
430
|
switch shim install # Route phiên Claude và Codex mới qua gateway
|
|
414
431
|
switch shim status # Kiểm tra shim + phát hiện phiên đang chạy ngoài gateway
|
|
415
432
|
switch shim uninstall # Gỡ shim khỏi launcher
|
|
433
|
+
switch plugin install # Tuỳ chọn: nhắc nhở ngay trong Claude Code và Codex
|
|
434
|
+
switch plugin status # Xem nhắc nhở đó đã cài chưa
|
|
435
|
+
switch plugin uninstall # Gỡ nhắc nhở đó
|
|
416
436
|
switch off # Tắt tất cả và quay về endpoint chính thức
|
|
417
437
|
switch off claude # Tắt Claude Code; Codex vẫn chạy tiếp
|
|
418
438
|
switch off codex # Tắt Codex; Claude Code vẫn chạy tiếp
|
|
@@ -426,7 +446,9 @@ cụ: `tool` là `"claude"` hoặc `"codex"` (hoặc `null` khi profile đang t
|
|
|
426
446
|
các route đầu vào mà chúng đại diện đã bị gỡ.
|
|
427
447
|
|
|
428
448
|
|
|
429
|
-
Service không chạy trong shell của bạn. Vì vậy `switch service install` chép `CLAUDE_CONFIG_DIR`, `LLM_SWITCHER_CONFIG`, `LLM_SWITCHER_STATE_DIR` và `LLM_SWITCHER_BLINDFOLD_CERTS` vào unit systemd hoặc plist launchd khi các biến này có giá trị. Task Windows không mang được các biến này; hãy đặt chúng thành biến môi trường User. Nếu file định nghĩa đã cài khác file mới (ví dụ đã sửa tay), file cũ được giữ lại thành `<file>.bak`. Trên Windows, task được tạo từ file XML, nên đường dẫn có dấu cách không cần quote thêm và task không bị giới hạn thời gian chạy. Đường Windows này chưa được test trên Windows.
|
|
449
|
+
Service không chạy trong shell của bạn. Vì vậy `switch service install` chép `LLM_SWITCHER_HOME`, `CLAUDE_CONFIG_DIR`, `LLM_SWITCHER_CONFIG`, `LLM_SWITCHER_STATE_DIR` và `LLM_SWITCHER_BLINDFOLD_CERTS` vào unit systemd hoặc plist launchd khi các biến này có giá trị. Task Windows không mang được các biến này; hãy đặt chúng thành biến môi trường User. Nếu file định nghĩa đã cài khác file mới (ví dụ đã sửa tay), file cũ được giữ lại thành `<file>.bak`. Trên Windows, task được tạo từ file XML, nên đường dẫn có dấu cách không cần quote thêm và task không bị giới hạn thời gian chạy. Đường Windows này chưa được test trên Windows.
|
|
450
|
+
|
|
451
|
+
Trên Linux, unit là service systemd *của user*: nó khởi động khi bạn đăng nhập. Muốn nó chạy ngay khi máy boot mà không cần đăng nhập (ví dụ server truy cập qua SSH), chạy một lần `loginctl enable-linger $USER`. Unit ghi đường dẫn tuyệt đối của `node` hiện tại; nếu bạn quản lý Node bằng nvm và gỡ phiên bản đó, hãy chạy lại `switch service install`.
|
|
430
452
|
### Phiên mở lại (`--resume`) và cơ chế shim — quan trọng
|
|
431
453
|
|
|
432
454
|
`switch on` ghi `env-claude.*` và `env-codex.*`, và **không ghi gì** vào
|
|
@@ -466,6 +488,67 @@ trong `PATH`.
|
|
|
466
488
|
|
|
467
489
|
---
|
|
468
490
|
|
|
491
|
+
### Biết được switcher đang bật
|
|
492
|
+
|
|
493
|
+
Vô hình cũng có giá của nó. Switcher không mở `settings.json` cũng không mở `config.toml`, nên không
|
|
494
|
+
có banner nào hiện lên, và bạn dễ quên rằng mọi request đang do một provider khác trả lời. Có hai lớp
|
|
495
|
+
nhắc nhở để bù chỗ đó. Lớp thứ nhất luôn bật. Lớp thứ hai là tuỳ bạn, không cài cũng không mất gì.
|
|
496
|
+
|
|
497
|
+
**1. Nhắc nhở từ shim — luôn bật**
|
|
498
|
+
|
|
499
|
+
Khi shim mở một tool đang được route, nó cho biết traffic đi đâu:
|
|
500
|
+
|
|
501
|
+
```
|
|
502
|
+
claude -> intact-claude | intact.louispham.qzz.io | antigravity/gemini-3.8-flash | 1M
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Trên Windows đây là một toast của hệ điều hành. Cả hai tool đều có thể chiếm trọn màn hình, nên một
|
|
506
|
+
dòng chữ in ra sẽ bị giao diện che mất. Trên Linux và macOS thì là một dòng trên stderr. Tool nào
|
|
507
|
+
đang tắt thì im lặng hoàn toàn, vì nội dung nhắc nhở đọc từ `route-<tool>.txt`, file này được ghi
|
|
508
|
+
cùng lượt với file env của chính tool đó và rỗng đúng khi tool đó không được route.
|
|
509
|
+
|
|
510
|
+
Nhắc nhở này cần shim, nên cần `~/.llm-switcher/bin` đứng trước trong `PATH`. Nếu bạn dùng wrapper
|
|
511
|
+
`claude` hoặc `codex` tự viết thì shim không chạy và nhắc nhở này không xuất hiện. Lớp thứ hai lo
|
|
512
|
+
đúng trường hợp đó.
|
|
513
|
+
|
|
514
|
+
**2. Nhắc nhở ngay trong tool — tuỳ chọn**
|
|
515
|
+
|
|
516
|
+
```bash
|
|
517
|
+
switch plugin install # cài
|
|
518
|
+
switch plugin status # xem đã cài chưa
|
|
519
|
+
switch plugin uninstall # gỡ
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
Lệnh này ghi một file cho mỗi tool, và không mở file cấu hình nào của hai tool đó:
|
|
523
|
+
|
|
524
|
+
| Tool | Ghi gì | Vì sao tool tự nạp |
|
|
525
|
+
| --- | --- | --- |
|
|
526
|
+
| Claude Code | `~/.claude/skills/llm-switcher-status/` | Một thư mục có `.claude-plugin/plugin.json` nằm dưới skills directory sẽ được nạp như một plugin ở phiên kế tiếp. Không cần marketplace, không cần bước install. |
|
|
527
|
+
| Codex | `~/.codex/hooks.json` | Codex tự đọc file này. `config.toml` không bị mở. |
|
|
528
|
+
|
|
529
|
+
Nếu `~/.codex/hooks.json` đã có sẵn thì lệnh sẽ **merge**. Hook của bạn được giữ nguyên, và
|
|
530
|
+
`switch plugin uninstall` chỉ lấy đi phần của switcher. Nếu file không parse được thì lệnh từ chối và
|
|
531
|
+
không đổi gì, vì file đó có thể đang giữ công việc mà không bản backup nào lấy lại được.
|
|
532
|
+
|
|
533
|
+
Hook chạy bên trong tool, nên nó báo được bất kể cái gì đã mở tool. Nó báo ba trạng thái:
|
|
534
|
+
|
|
535
|
+
- **Đang route, gateway trả lời** — nêu profile, host, model và cửa sổ 1M.
|
|
536
|
+
- **Đang route, gateway không trả lời** — cảnh báo tool không tới được provider, nêu số cổng, và nêu
|
|
537
|
+
lệnh `switch on`. Trước khi có nhắc nhở này, trạng thái đó chỉ hiện ra dưới dạng một lỗi kết nối
|
|
538
|
+
không rõ nguyên nhân.
|
|
539
|
+
- **Không route** — im lặng. Tool đang dùng endpoint chính thức thì không nói gì.
|
|
540
|
+
|
|
541
|
+
Codex sẽ hỏi bạn một lần để tin cậy hook mới. Nếu bạn từ chối thì hook không chạy và mọi thứ khác
|
|
542
|
+
giữ nguyên.
|
|
543
|
+
|
|
544
|
+
**Nếu bạn không muốn lớp nhắc nhở nào**
|
|
545
|
+
|
|
546
|
+
Đừng cài plugin, và để shim ngoài `PATH`. Gateway vẫn route mọi request, healer vẫn chạy, và
|
|
547
|
+
`switch status` vẫn báo trạng thái. Nhắc nhở chỉ là nhắc nhở. Nó không bao giờ là một phần của việc
|
|
548
|
+
route.
|
|
549
|
+
|
|
550
|
+
---
|
|
551
|
+
|
|
469
552
|
## Cấu trúc Cấu hình (`config.json`)
|
|
470
553
|
|
|
471
554
|
```jsonc
|
|
@@ -559,7 +642,8 @@ Nhờ cách chia này, fingerprint của provider không bao giờ là rule tron
|
|
|
559
642
|
|---|---|
|
|
560
643
|
| `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
644
|
| `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ợ). |
|
|
562
|
-
| `--port <n>` / `LLM_SWITCHER_PORT` | Ghi đè cổng lắng nghe (ưu tiên: flag > env > `config.port`). |
|
|
645
|
+
| `--port <n>` / `LLM_SWITCHER_PORT` | Ghi đè cổng lắng nghe của gateway (ưu tiên: flag > env > `config.port`). |
|
|
646
|
+
| `LLM_SWITCHER_BLINDFOLD_PORT` | Ghi đè cổng lắng nghe của interceptor (ưu tiên: env > `config.blindfold.port` > 3457). Đảm bảo đặt cùng `LLM_SWITCHER_PORT` khi chạy instance thứ hai. |
|
|
563
647
|
| 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. |
|
|
564
648
|
| `profile.thinkingMode` | `auto` (mặc định, cho gateway như intact hoặc 9Router): phục hồi thinking bị xoá, inject hướng dẫn `<think>` cho model không có reasoning, gửi `thinking` + `reasoning_effort`. `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. |
|
|
565
649
|
| `profile.endpoints.countTokens` | Ghi đè URL `count_tokens` của Anthropic. |
|
package/blindfold/blindfold.mjs
CHANGED
|
@@ -122,7 +122,8 @@ const DECODERS = {
|
|
|
122
122
|
'x-gzip': zlib.gunzipSync,
|
|
123
123
|
br: zlib.brotliDecompressSync,
|
|
124
124
|
deflate: zlib.inflateSync,
|
|
125
|
-
zstd
|
|
125
|
+
// zstd arrived in Node 22.15; older runtimes report the body as undecodable instead of noise.
|
|
126
|
+
zstd: zlib.zstdDecompressSync || (() => { throw new Error('zstd is not supported by this Node.js version'); })
|
|
126
127
|
};
|
|
127
128
|
|
|
128
129
|
export function decodeBody(buffer, contentEncoding) {
|
package/docs/cross-platform.md
CHANGED
|
@@ -141,5 +141,7 @@ Run the test suite:
|
|
|
141
141
|
npm test
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
On Linux and macOS every test runs. On Windows four shim tests skip.
|
|
144
|
+
On Linux and macOS every test runs. On Windows four shim tests skip. On Node older than 22,
|
|
145
|
+
the three WebSocket client tests skip (no global `WebSocket`), and the zstd assertion is
|
|
146
|
+
skipped before Node 22.15. A failure on any
|
|
145
147
|
platform is a real defect; report it with the platform name and the Node version.
|
package/hook-status.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Reports the switcher route to a coding tool at the start of a session.
|
|
3
|
+
//
|
|
4
|
+
// The shim toast fires only when the shim runs, and that needs the shim directory first on PATH.
|
|
5
|
+
// This runs inside the tool, so it reports even when the launcher is the person's own script. Claude
|
|
6
|
+
// Code and Codex both read one JSON object from stdout and show `systemMessage` to the person.
|
|
7
|
+
//
|
|
8
|
+
// Two rules hold, whatever happens:
|
|
9
|
+
// - It prints exactly one JSON object and exits 0. A hook must never fail a session.
|
|
10
|
+
// - A tool with no profile gets an empty object. Silence for a tool on its official endpoint.
|
|
11
|
+
//
|
|
12
|
+
// Usage: node hook-status.mjs <claude|codex>
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
|
|
15
|
+
const TOOLS = ['claude', 'codex'];
|
|
16
|
+
|
|
17
|
+
const readOrEmpty = (file) => {
|
|
18
|
+
try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function message(tool) {
|
|
22
|
+
if (!TOOLS.includes(tool)) return {};
|
|
23
|
+
// Imported here, not at the top: a failure to load the state module must still print an object.
|
|
24
|
+
const s = await import('./state.mjs');
|
|
25
|
+
|
|
26
|
+
// The route file is written with the env file of the same tool, so it is empty exactly when that
|
|
27
|
+
// tool is not routed. One read answers both "is the switcher on" and "is this tool on".
|
|
28
|
+
const route = readOrEmpty(tool === 'codex' ? s.paths.routeCodex : s.paths.routeClaude).trim();
|
|
29
|
+
if (!route || !fs.existsSync(s.paths.activeFlag)) return {};
|
|
30
|
+
|
|
31
|
+
const port = s.resolvePort([], s.loadConfig() || {});
|
|
32
|
+
const state = await s.probeGateway(port);
|
|
33
|
+
if (state === 'ours') {
|
|
34
|
+
return { systemMessage: `LLM Switcher is ON. ${route}. This tool does not reach its official endpoint.` };
|
|
35
|
+
}
|
|
36
|
+
// The dangerous state: the launch files route the tool, and nothing answers. The tool fails on
|
|
37
|
+
// its first request with a connection error that names no cause.
|
|
38
|
+
const held = state === 'free'
|
|
39
|
+
? `the gateway on port ${port} does not answer`
|
|
40
|
+
: `port ${port} does not answer as this switcher (another program holds it)`;
|
|
41
|
+
return {
|
|
42
|
+
systemMessage: `WARNING: LLM Switcher is set to route this tool (${route}), but ${held}. `
|
|
43
|
+
+ 'The tool cannot reach the provider. Run `switch on` to start the gateway, '
|
|
44
|
+
+ 'or `switch off` to use the official endpoint.'
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
message(process.argv[2])
|
|
49
|
+
.then((out) => process.stdout.write(JSON.stringify(out)))
|
|
50
|
+
.catch(() => process.stdout.write('{}'))
|
|
51
|
+
.finally(() => process.exit(0));
|
package/notify-route.ps1
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Raises one Windows toast that says the switcher took this tool's traffic.
|
|
2
|
+
#
|
|
3
|
+
# The launcher shim calls this detached, so it must never block and never fail loudly: a coding
|
|
4
|
+
# tool must start even when the notification does not. It reads one argument, the route file of
|
|
5
|
+
# the tool. An absent or empty file means the tool is not routed, and then nothing is shown.
|
|
6
|
+
#
|
|
7
|
+
# No module is installed. WinRT is used through the PowerShell application id, which exists on
|
|
8
|
+
# every Windows 10 and 11 machine.
|
|
9
|
+
param([Parameter(Mandatory = $true)][string]$RouteFile)
|
|
10
|
+
|
|
11
|
+
$ErrorActionPreference = 'Stop'
|
|
12
|
+
trap { exit 0 }
|
|
13
|
+
|
|
14
|
+
if (-not (Test-Path -LiteralPath $RouteFile)) { exit 0 }
|
|
15
|
+
$line = (Get-Content -LiteralPath $RouteFile -Raw -ErrorAction SilentlyContinue)
|
|
16
|
+
if ($null -eq $line) { exit 0 }
|
|
17
|
+
$line = $line.Trim()
|
|
18
|
+
if ($line.Length -eq 0) { exit 0 }
|
|
19
|
+
|
|
20
|
+
# The line is data, so it is escaped before it enters the toast XML.
|
|
21
|
+
$body = [System.Security.SecurityElement]::Escape($line)
|
|
22
|
+
|
|
23
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
24
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime] | Out-Null
|
|
25
|
+
|
|
26
|
+
$appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
|
|
27
|
+
$xml = @"
|
|
28
|
+
<toast activationType="protocol" launch="http://127.0.0.1:3456/ui">
|
|
29
|
+
<visual>
|
|
30
|
+
<binding template="ToastGeneric">
|
|
31
|
+
<text>LLM Switcher is ON</text>
|
|
32
|
+
<text>$body</text>
|
|
33
|
+
<text placement="attribution">This tool does not reach its official endpoint.</text>
|
|
34
|
+
</binding>
|
|
35
|
+
</visual>
|
|
36
|
+
</toast>
|
|
37
|
+
"@
|
|
38
|
+
|
|
39
|
+
$doc = [Windows.Data.Xml.Dom.XmlDocument]::new()
|
|
40
|
+
$doc.LoadXml($xml)
|
|
41
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($doc)
|
|
42
|
+
# One tag per tool: a second launch of the same tool replaces its notice instead of stacking.
|
|
43
|
+
$toast.Tag = 'llm-switcher-route'
|
|
44
|
+
$toast.Group = 'llm-switcher'
|
|
45
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
|
|
46
|
+
exit 0
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-switcher",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code
|
|
3
|
+
"version": "1.2.9",
|
|
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",
|
|
7
7
|
"gateway",
|