llm-switcher 1.1.8 → 1.1.10

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 CHANGED
@@ -191,7 +191,15 @@ flowchart LR
191
191
 
192
192
  ---
193
193
 
194
- ## Changes in 1.1.8
194
+ ## Changes in 1.1.10
195
+
196
+ - **README.** A new section, "Self-improvement with intact", explains how this gateway and intact correct their own faults. intact is now public and on npm as `intact-proxy`.
197
+
198
+ ### Changes in 1.1.9
199
+
200
+ - **Dashboard.** Open `http://127.0.0.1:3456/ui` directly. The page no longer needs the link from `switch ui`: the gateway puts the admin token in the page. Pages of other sites still cannot read it.
201
+
202
+ ### Changes in 1.1.8
195
203
 
196
204
  - **Claude Code tools.** A tool schema value of `0`, `false`, `""` or `null` (for example `minimum: 0`) became an empty object schema. Gemini refused every Claude Code request with HTTP 400 "Starting an object on a scalar field". These values now stay as they are.
197
205
  - **Shim PATH order.** If the shim folder is on `PATH` but after the real `claude` or `codex`, `switch shim status` and `switch doctor` now tell you to put the export line last in your shell files.
@@ -590,6 +598,15 @@ Before it sends a sample, the gateway masks every string value with `x` of the s
590
598
  - `switch contract-probe [--model m]` sends six test requests per model and format through the gateway.
591
599
  - `switch contract-check` gets the open findings from intact and writes one test file for each lost field.
592
600
 
601
+ ### Self-improvement with intact
602
+
603
+ [intact](https://github.com/louisphamdev/intact) is the credential proxy that this gateway can use as its upstream. The two tools find and correct their own faults, in two loops.
604
+
605
+ - **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".
606
+ - **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.
607
+
608
+ Because of this split, a provider fingerprint is never a rule in this gateway. It is a filter in intact, which intact finds and proves by replay.
609
+
593
610
  ---
594
611
 
595
612
  ## Advanced Options
@@ -609,7 +626,7 @@ Before it sends a sample, the gateway masks every string value with `x` of the s
609
626
  ## Security Model
610
627
 
611
628
  - The gateway binds to `127.0.0.1` only and rejects requests whose `Host` is not a loopback name (DNS-rebinding protection) or whose `Origin` is not the dashboard itself (CSRF protection).
612
- - The admin API (`/api/*`) requires the `x-llm-switcher-token` header. The gateway creates the token in `admin.token`, next to `config.json`, with mode 0600. `switch ui` opens the dashboard with this token, and the MCP server reads the file. The dashboard keeps the token in `sessionStorage` of that tab only, so a page that another account serves on the same port while the gateway is off cannot read it; open the dashboard with `switch ui` again after the browser restarts. `/v1/*` and `/health` need no token.
629
+ - The admin API (`/api/*`) requires the `x-llm-switcher-token` header. The gateway creates the token in `admin.token`, next to `config.json`, with mode 0600. The gateway puts this token in the dashboard page, so `http://127.0.0.1:3456/ui` works when you open it directly. The Host and Origin checks keep the page and the token from other sites. The MCP server reads the file. `/v1/*` and `/health` need no token.
613
630
  - API keys are never sent to the browser: `/api/status` returns redacted profiles and the dashboard keeps the stored key unless you type a new one. The stored key goes only to the stored `baseURL` and `endpoints` of that profile. A save that changes either one must carry the key again.
614
631
  - Every dashboard change carries the config revision that the page loaded. If another tab, the CLI or the MCP server saved in the meantime, the gateway answers 409 and the page reloads instead of overwriting that change.
615
632
  - Client credentials such as `x-api-key`, `authorization` or `x-goog-api-key` are **not** forwarded to upstreams. Other `x-*` headers, `traceparent` and `tracestate` pass through. The gateway drops its own control headers (`x-profile`, `x-llm-profile`) and the network identity headers (`x-forwarded-*`, `x-real-ip`).
package/README.vi.md CHANGED
@@ -191,7 +191,15 @@ flowchart LR
191
191
 
192
192
  ---
193
193
 
194
- ## Thay đổi trong bản 1.1.8
194
+ ## Thay đổi trong bản 1.1.10
195
+
196
+ - **README.** Mục mới "Tự cải thiện cùng intact" giải thích cách gateway này và intact tự sửa lỗi của nhau. intact giờ đã public và có trên npm với tên `intact-proxy`.
197
+
198
+ ### Thay đổi trong bản 1.1.9
199
+
200
+ - **Dashboard.** Mở thẳng `http://127.0.0.1:3456/ui` là dùng được. Trang không cần link từ `switch ui` nữa: gateway đặt admin token vào trang. Trang của web khác vẫn không đọc được token.
201
+
202
+ ### Thay đổi trong bản 1.1.8
195
203
 
196
204
  - **Tool của Claude Code.** Giá trị `0`, `false`, `""` hoặc `null` trong schema của tool (ví dụ `minimum: 0`) bị đổi thành schema object rỗng. Gemini từ chối mọi request của Claude Code với HTTP 400 "Starting an object on a scalar field". Giờ các giá trị này được giữ nguyên.
197
205
  - **Thứ tự PATH của shim.** Nếu thư mục shim có trong `PATH` nhưng đứng sau `claude` hoặc `codex` thật, `switch shim status` và `switch doctor` giờ chỉ cách sửa: đặt dòng export ở cuối các file cấu hình shell.
@@ -590,6 +598,15 @@ Trước khi gửi mẫu, gateway che mọi giá trị string bằng chuỗi `x`
590
598
  - `switch contract-probe [--model m]` gửi sáu request thử cho mỗi model và mỗi format qua gateway.
591
599
  - `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.
592
600
 
601
+ ### Tự cải thiện cùng intact
602
+
603
+ [intact](https://github.com/louisphamdev/intact) là proxy giữ credential mà gateway này có thể dùng làm upstream. Hai công cụ tự tìm và tự sửa lỗi của nhau theo hai vòng.
604
+
605
+ - **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".
606
+ - **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.
607
+
608
+ Nhờ cách chia này, fingerprint của provider không bao giờ là rule trong gateway này. Nó là filter trong intact, do intact tự tìm và chứng minh bằng cách gửi lại request.
609
+
593
610
  ---
594
611
 
595
612
  ## Tuỳ chọn Nâng cao
@@ -609,7 +626,7 @@ Trước khi gửi mẫu, gateway che mọi giá trị string bằng chuỗi `x`
609
626
  ## Mô hình Bảo mật
610
627
 
611
628
  - Gateway chỉ lắng nghe `127.0.0.1` và từ chối request có `Host` không phải loopback (chống DNS rebinding) hoặc `Origin` không phải chính dashboard (chống CSRF).
612
- - Admin API (`/api/*`) bắt buộc header `x-llm-switcher-token`. Gateway tạo token trong file `admin.token`, cạnh `config.json`, với mode 0600. `switch ui` mở dashboard kèm token này, MCP server đọc token từ file. Dashboard giữ token trong `sessionStorage` của đúng tab đó, nên một trang do tài khoản khác phục vụ trên cùng cổng lúc gateway tắt không đọc được token; sau khi khởi động lại trình duyệt, mở dashboard bằng `switch ui` lần nữa. `/v1/*` và `/health` không cần token.
629
+ - Admin API (`/api/*`) bắt buộc header `x-llm-switcher-token`. Gateway tạo token trong file `admin.token`, cạnh `config.json`, với mode 0600. Gateway đặt token này vào trang dashboard, nên mở thẳng `http://127.0.0.1:3456/ui` là dùng được. Lớp kiểm tra Host và Origin ngăn trang web khác đọc trang và token. MCP server đọc token từ file. `/v1/*` và `/health` không cần token.
613
630
  - API key không bao giờ gửi xuống trình duyệt: `/api/status` trả profile đã che key, dashboard giữ nguyên key đã lưu nếu bạn không nhập key mới. Key đã lưu chỉ được gửi tới `baseURL` và `endpoints` đã lưu của chính profile đó. Lần lưu nào đổi một trong hai thì phải nhập lại key.
614
631
  - Mỗi thay đổi từ dashboard mang theo revision của config mà trang đã tải. Nếu tab khác, CLI hoặc MCP server đã lưu trước đó, gateway trả 409 và trang tải lại thay vì ghi đè thay đổi kia.
615
632
  - Credential của client (`x-api-key`, `authorization`, `x-goog-api-key`) **không** được chuyển tiếp lên upstream. Các header `x-*` khác, `traceparent` và `tracestate` được chuyển tiếp. Gateway bỏ header điều khiển của chính nó (`x-profile`, `x-llm-profile`) và header định danh mạng (`x-forwarded-*`, `x-real-ip`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
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",
package/proxy.mjs CHANGED
@@ -1114,7 +1114,10 @@ async function route(req, res) {
1114
1114
  'X-Frame-Options': 'DENY',
1115
1115
  'X-Content-Type-Options': 'nosniff'
1116
1116
  });
1117
- return res.end(fs.readFileSync(uiHtmlPath, 'utf8'));
1117
+ // One gateway serves one user, so the page opened at /ui carries its own token. The Host and Origin
1118
+ // guard above keeps it from other sites; the token is hex, so it needs no escaping.
1119
+ const meta = `<meta name="llm-switcher-token" content="${ADMIN_TOKEN.toString()}">`;
1120
+ return res.end(fs.readFileSync(uiHtmlPath, 'utf8').replace('<head>', `<head>\n ${meta}`));
1118
1121
  }
1119
1122
  }
1120
1123
 
@@ -530,6 +530,25 @@ test('Security: foreign Host / Origin are rejected (DNS rebinding & CSRF)', asyn
530
530
 
531
531
  // Any local process can reach loopback. Without a token it must get nothing from /api/*,
532
532
  // and a masked key must never be resolved for a baseURL the profile does not have.
533
+ // One gateway serves one user on one machine: the dashboard opened at /ui, with no link from `switch ui`,
534
+ // carries its own token. A page of another site and a rebound Host still get nothing.
535
+ test('Dashboard: /ui opened directly carries the admin token, other sites do not get it', async () => {
536
+ for (const p of ['/ui', '/']) {
537
+ const html = await (await fetch(url(p))).text();
538
+ assert.ok(html.includes(`<meta name="llm-switcher-token" content="${adminToken()}">`), `${p} has no token`);
539
+ }
540
+ const foreign = await fetch(url('/ui'), { headers: { origin: 'https://evil.example' } });
541
+ assert.equal(foreign.status, 403);
542
+ assert.ok(!(await foreign.text()).includes(adminToken()));
543
+ const rebound = await new Promise((resolve, reject) => {
544
+ http.get({ host: '127.0.0.1', port: proxyPort, path: '/ui', headers: { host: 'evil.example' } }, res => {
545
+ let b = ''; res.on('data', d => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, body: b }));
546
+ }).on('error', reject);
547
+ });
548
+ assert.equal(rebound.status, 403);
549
+ assert.ok(!rebound.body.includes(adminToken()));
550
+ });
551
+
533
552
  test('Security: the admin API refuses a caller without the token and changes nothing', async () => {
534
553
  const hits = [];
535
554
  const sink = http.createServer((req, res) => { hits.push(req.headers); res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"data":[]}'); });
@@ -560,10 +579,6 @@ test('Security: the admin API refuses a caller without the token and changes not
560
579
  assert.equal(hits.length, 1);
561
580
  assert.ok(!JSON.stringify(hits[0]).includes('sk-secret-chat'), 'the stored key is not sent to a foreign baseURL');
562
581
 
563
- for (const p of ['/', '/ui']) {
564
- const page = await (await fetch(url(p))).text();
565
- assert.ok(!page.includes(adminToken()), `${p} must not embed the token`);
566
- }
567
582
  assert.equal((await fetch(url('/health'))).status, 200, '/health needs no token');
568
583
  assert.equal((fs.statSync(path.join(tmpDir, 'admin.token')).mode & 0o777).toString(8), '600');
569
584
  } finally {
package/ui.html CHANGED
@@ -1160,18 +1160,15 @@
1160
1160
  <div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
1161
1161
 
1162
1162
  <script>
1163
- // /api/* needs the per-install admin token. `switch ui` passes it in the URL fragment, which
1164
- // the browser never sends to the server; keep it here and clear the address bar. sessionStorage, not
1165
- // localStorage: while the gateway is off, another account can serve a page on this origin, and that
1166
- // page must not find the token.
1163
+ // /api/* needs the admin token. The gateway puts it in this page; an older `switch ui` link still
1164
+ // brings it in the URL fragment, which is cleared from the address bar.
1167
1165
  const ADMIN_TOKEN_KEY = 'llmSwitcherAdminToken';
1168
1166
  (() => {
1169
1167
  localStorage.removeItem(ADMIN_TOKEN_KEY);
1168
+ const served = document.querySelector('meta[name="llm-switcher-token"]')?.content;
1170
1169
  const m = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
1171
- if (m) {
1172
- sessionStorage.setItem(ADMIN_TOKEN_KEY, m[1]);
1173
- history.replaceState(null, '', location.pathname + location.search);
1174
- }
1170
+ if (served || m) sessionStorage.setItem(ADMIN_TOKEN_KEY, served || m[1]);
1171
+ if (m) history.replaceState(null, '', location.pathname + location.search);
1175
1172
  })();
1176
1173
 
1177
1174
  // A config change carries the revision this page loaded. The gateway answers 409 when another tab