llm-switcher 1.3.3 → 1.3.5
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 -0
- package/README.md +5 -4
- package/README.vi.md +5 -4
- package/package.json +1 -1
- package/proxy.mjs +4 -5
- package/switch.mjs +1 -1
- package/tests/dashboard-fixture.mjs +2 -0
- package/tests/dashboard.e2e.test.mjs +33 -0
- package/tests/gateway.e2e.test.mjs +8 -5
- package/tests/shim.test.mjs +40 -6
- package/tests/switch.test.mjs +21 -0
- package/tests/update.e2e.test.mjs +8 -4
- package/ui.html +39 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.3.5
|
|
4
|
+
|
|
5
|
+
- **Dashboard motion:** The switch, the checkbox, the dialog, the toast and the tooltip used easing curves that overshoot and bounce back. They now use one smooth ease-out curve (`--ease-out`). The durations did not change.
|
|
6
|
+
- **Dashboard elevation:** The dialog, the toast and the model list used a wide 50 px shadow on top of their border. The shadow is now short and tight, so the border defines the edge, in both themes.
|
|
7
|
+
- **Dialog tabs on a phone:** At 390 px wide, the profile dialog cut off the **Model Slots** tab, and the tab bar hides its scrollbar. On a narrow screen the tabs now have less padding, and they wrap if they still do not fit.
|
|
8
|
+
- **API key field:** The field has `autocomplete="off"`, so a password manager does not offer to save the key.
|
|
9
|
+
- **Update now that times out:** The message "The new gateway did not answer" now also says what the last check saw: the gateway from before the update, an HTTP status, or a network error.
|
|
10
|
+
|
|
11
|
+
## Release 1.3.4
|
|
12
|
+
|
|
13
|
+
- **The dashboard page carries no token:** `/ui` sent `admin.token` in the page, and any program on the machine can load `/ui`. The page now gets the token only from the `#token=` of the private launcher that `switch ui` opens (a file with mode 0600). The tab keeps it in `sessionStorage`, so it does not stay in the browser after the tab closes. A tab opened at `/ui` directly says to use `switch ui`. `switch status` and the gateway start line say the same.
|
|
14
|
+
- **Tests:** The agy shim tests start a real relay and send a real request through it. The request must reach the gateway with the Google token, and a gateway without a valid proof must get nothing. The Update now test waits longer than the page polls, so a failure shows what the page saw.
|
|
15
|
+
|
|
3
16
|
## Release 1.3.3
|
|
4
17
|
|
|
5
18
|
- **agy runs behind a relay:** A routed agy no longer gets the gateway port. The shim starts it behind `agy-relay.mjs`, which holds its own loopback port for as long as agy runs. For each connection, the relay asks the gateway for its identity proof on the same socket and sends agy's request only after the proof holds. Before, the shim checked the gateway only at launch, so a gateway that stopped during an agy session left its port free for another program to take, with agy's Google token sent to it. If the proof fails now, agy gets a 502 and the token stays in the relay.
|
package/README.md
CHANGED
|
@@ -211,7 +211,8 @@ node "$(npm root -g)/llm-switcher/proxy.mjs"
|
|
|
211
211
|
|
|
212
212
|
`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).
|
|
213
213
|
|
|
214
|
-
Open the Web Dashboard
|
|
214
|
+
Open the Web Dashboard with `switch ui`. It opens `http://127.0.0.1:3456/ui` with the access token of
|
|
215
|
+
this install, and the browser tab keeps the token while it stays open.
|
|
215
216
|
|
|
216
217
|
---
|
|
217
218
|
|
|
@@ -345,8 +346,8 @@ socket, and sends the request on that socket only after the proof holds. If the
|
|
|
345
346
|
gets a 502, and its token stays in the relay.
|
|
346
347
|
|
|
347
348
|
The relay accepts only a proof keyed by `gateway.secret`, a random key next to `admin.token`. No
|
|
348
|
-
page and no route of the gateway serves this key
|
|
349
|
-
|
|
349
|
+
page and no route of the gateway serves this key, and no program can derive it from `admin.token`.
|
|
350
|
+
If the running
|
|
350
351
|
gateway is older than the relay, agy gets a 502 that says to restart it (`switch off`, then
|
|
351
352
|
`switch on`).
|
|
352
353
|
|
|
@@ -782,7 +783,7 @@ Because of this split, a provider fingerprint is never a rule in this gateway. I
|
|
|
782
783
|
## Security Model
|
|
783
784
|
|
|
784
785
|
- 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).
|
|
785
|
-
- 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
|
|
786
|
+
- 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 dashboard page does not carry this token, because any program on the computer can load the page. `switch ui` opens the dashboard through a private file (mode 0600) that gives the token to the browser tab in the URL fragment, and the page removes it from the address bar. A tab opened at `http://127.0.0.1:3456/ui` without this file shows a message that says to use `switch ui`. The MCP server reads the file. `/v1/*` and `/health` need no token.
|
|
786
787
|
- 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.
|
|
787
788
|
- 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.
|
|
788
789
|
- 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
|
@@ -213,7 +213,8 @@ node "$(npm root -g)/llm-switcher/proxy.mjs"
|
|
|
213
213
|
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
|
|
214
214
|
[📖 `docs/cross-platform.md`](docs/cross-platform.md).
|
|
215
215
|
|
|
216
|
-
Mở Bảng điều khiển Web Dashboard
|
|
216
|
+
Mở Bảng điều khiển Web Dashboard bằng `switch ui`. Lệnh này mở `http://127.0.0.1:3456/ui` kèm token truy
|
|
217
|
+
cập của bản cài này, và tab trình duyệt giữ token trong lúc tab còn mở.
|
|
217
218
|
|
|
218
219
|
---
|
|
219
220
|
|
|
@@ -345,8 +346,8 @@ socket đó, và chỉ gửi request trên socket ấy khi bằng chứng đúng
|
|
|
345
346
|
vẫn nằm lại trong relay.
|
|
346
347
|
|
|
347
348
|
Relay chỉ chấp nhận bằng chứng ký bằng `gateway.secret`, một khóa ngẫu nhiên nằm cạnh `admin.token`.
|
|
348
|
-
Không trang và không route nào của gateway trả về khóa này
|
|
349
|
-
|
|
349
|
+
Không trang và không route nào của gateway trả về khóa này, và không chương trình nào suy ra được nó từ
|
|
350
|
+
`admin.token`. Nếu gateway đang
|
|
350
351
|
chạy cũ hơn relay, agy nhận 502 kèm hướng dẫn khởi động lại (`switch off`, rồi `switch on`).
|
|
351
352
|
|
|
352
353
|
### Chế độ blindfold (tùy chọn)
|
|
@@ -776,7 +777,7 @@ Nhờ cách chia này, fingerprint của provider không bao giờ là rule tron
|
|
|
776
777
|
## Mô hình Bảo mật
|
|
777
778
|
|
|
778
779
|
- 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).
|
|
779
|
-
- 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.
|
|
780
|
+
- 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. Trang dashboard không chứa token này, vì chương trình nào trên máy cũng tải được trang. `switch ui` mở dashboard qua một file riêng tư (mode 0600), file này đưa token cho tab trình duyệt qua phần fragment của URL, rồi trang xóa nó khỏi thanh địa chỉ. Tab mở thẳng `http://127.0.0.1:3456/ui` mà không qua file này sẽ hiện thông báo bảo dùng `switch ui`. MCP server đọc token từ file. `/v1/*` và `/health` không cần token.
|
|
780
781
|
- 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.
|
|
781
782
|
- 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.
|
|
782
783
|
- 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
package/proxy.mjs
CHANGED
|
@@ -1300,10 +1300,9 @@ async function route(req, res) {
|
|
|
1300
1300
|
'X-Frame-Options': 'DENY',
|
|
1301
1301
|
'X-Content-Type-Options': 'nosniff'
|
|
1302
1302
|
});
|
|
1303
|
-
//
|
|
1304
|
-
//
|
|
1305
|
-
|
|
1306
|
-
return res.end(fs.readFileSync(uiHtmlPath, 'utf8').replace('<head>', `<head>\n ${meta}`));
|
|
1303
|
+
// No token in the page: any process on the machine can GET /ui. The dashboard gets it from the
|
|
1304
|
+
// #token of the private launcher that `switch ui` opens.
|
|
1305
|
+
return res.end(fs.readFileSync(uiHtmlPath, 'utf8'));
|
|
1307
1306
|
}
|
|
1308
1307
|
}
|
|
1309
1308
|
|
|
@@ -2266,7 +2265,7 @@ if (!handedOver) server.listen(PORT, '127.0.0.1', () => {
|
|
|
2266
2265
|
serialized(() => reconcile(cfg));
|
|
2267
2266
|
}
|
|
2268
2267
|
console.log(`[llm-switcher] Server running on http://127.0.0.1:${PORT}`);
|
|
2269
|
-
console.log(`[llm-switcher] Web UI available at: http://127.0.0.1:${PORT}/ui`);
|
|
2268
|
+
console.log(`[llm-switcher] Web UI available at: http://127.0.0.1:${PORT}/ui (open it with \`switch ui\`)`);
|
|
2270
2269
|
console.log(`[llm-switcher] Endpoints: /v1/messages (Claude Code) | /v1/responses (Codex)`);
|
|
2271
2270
|
// Asynchronously discover latest tool models in the background without blocking startup
|
|
2272
2271
|
refreshCatalog(STATE_DIR).catch(() => {});
|
package/switch.mjs
CHANGED
|
@@ -616,7 +616,7 @@ async function showStatus() {
|
|
|
616
616
|
console.log('=== LLM Switcher Status ===');
|
|
617
617
|
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)` };
|
|
618
618
|
console.log(`Proxy Service: ${isRunning ? `RUNNING (port ${port})` : held[gateway] || 'STOPPED'}`);
|
|
619
|
-
console.log(`Web UI: http://127.0.0.1:${port}/ui`);
|
|
619
|
+
console.log(`Web UI: http://127.0.0.1:${port}/ui (open it with \`switch ui\`)`);
|
|
620
620
|
console.log(`Launcher Flag: ${flagged ? 'active.flag present' : 'absent (launchers use official endpoints)'}`);
|
|
621
621
|
if (flagged && !isRunning) {
|
|
622
622
|
console.log(`[WARN] active.flag exists but proxy is STOPPED -> launched CLIs will fail to connect. Run 'switch on' or 'switch off'.`);
|
|
@@ -134,6 +134,8 @@ export async function startDashboardFixture() {
|
|
|
134
134
|
upstream,
|
|
135
135
|
upstreamBase,
|
|
136
136
|
dashboardUrl: `http://127.0.0.1:${port}/ui`,
|
|
137
|
+
// How `switch ui` opens it: the token comes in the fragment, never in the page.
|
|
138
|
+
tokenUrl: () => `http://127.0.0.1:${port}/ui#token=${adminToken()}`,
|
|
137
139
|
// What is on disk, which the dashboard has to agree with after every action.
|
|
138
140
|
config: () => JSON.parse(fs.readFileSync(cfgPath, 'utf8')),
|
|
139
141
|
// A change made outside the dashboard, as `switch` in a terminal or an editor would make it.
|
|
@@ -20,6 +20,22 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
20
20
|
fx = await startDashboardFixture();
|
|
21
21
|
browser = await launchBrowser();
|
|
22
22
|
page = await browser.newPage();
|
|
23
|
+
// Once per tab, as the launcher of `switch ui` does; the tab keeps the token from then on.
|
|
24
|
+
await page.goto(fx.tokenUrl());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('a tab opened without the launcher token says to open the dashboard with switch ui', async () => {
|
|
28
|
+
const fresh = await browser.newPage();
|
|
29
|
+
try {
|
|
30
|
+
await fresh.goto(fx.dashboardUrl);
|
|
31
|
+
await fresh.waitFor(`/switch ui/.test(document.getElementById('toast')?.textContent || '')`, 'the switch ui hint');
|
|
32
|
+
} finally {
|
|
33
|
+
// Close the tab itself, then raise the main one: a tab left in the background gets throttled
|
|
34
|
+
// timers and no animation frames, which stalls every later test.
|
|
35
|
+
await fresh.send('Page.close').catch(() => {});
|
|
36
|
+
fresh.close();
|
|
37
|
+
await page.send('Page.bringToFront');
|
|
38
|
+
}
|
|
23
39
|
});
|
|
24
40
|
|
|
25
41
|
after(async () => {
|
|
@@ -760,6 +776,23 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
760
776
|
}
|
|
761
777
|
});
|
|
762
778
|
|
|
779
|
+
it('shows every dialog tab whole on a phone', async () => {
|
|
780
|
+
await page.resize(390, 844);
|
|
781
|
+
try {
|
|
782
|
+
await editProfile('intact-claude');
|
|
783
|
+
await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "grid"', 'the dialog');
|
|
784
|
+
const cut = await page.evaluate(`(() => {
|
|
785
|
+
const bar = document.querySelector('#profile-form .tabs').getBoundingClientRect();
|
|
786
|
+
return [...document.querySelectorAll('#profile-form .tabs .tab-btn')]
|
|
787
|
+
.filter(b => { const r = b.getBoundingClientRect(); return r.left < bar.left - 0.5 || r.right > bar.right + 0.5; })
|
|
788
|
+
.map(b => b.textContent.trim());
|
|
789
|
+
})()`);
|
|
790
|
+
assert.deepEqual(cut, [], 'these tabs are cut off by the tab bar at 390 px');
|
|
791
|
+
} finally {
|
|
792
|
+
await page.resize(1280, 900);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
763
796
|
it('does not let a long profile name break the dialog header', async () => {
|
|
764
797
|
const cfg = fx.config();
|
|
765
798
|
cfg.profiles['intact-claude'].name = 'N'.repeat(300);
|
|
@@ -679,12 +679,15 @@ test('Security: foreign Host / Origin are rejected (DNS rebinding & CSRF)', asyn
|
|
|
679
679
|
|
|
680
680
|
// Any local process can reach loopback. Without a token it must get nothing from /api/*,
|
|
681
681
|
// and a masked key must never be resolved for a baseURL the profile does not have.
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
test('Dashboard: /ui
|
|
682
|
+
// Any process on the machine can GET /ui, another account's included, so the page carries no token.
|
|
683
|
+
// The dashboard gets it from the #token of the private launcher that `switch ui` opens.
|
|
684
|
+
test('Dashboard: /ui and / never carry the admin token, for this machine or another site', async () => {
|
|
685
685
|
for (const p of ['/ui', '/']) {
|
|
686
|
-
const
|
|
687
|
-
assert.
|
|
686
|
+
const r = await fetch(url(p));
|
|
687
|
+
assert.equal(r.status, 200, p);
|
|
688
|
+
const html = await r.text();
|
|
689
|
+
assert.ok(html.includes('<title>'), `${p} serves the dashboard`);
|
|
690
|
+
assert.ok(!html.includes(adminToken()), `${p} carries the admin token`);
|
|
688
691
|
}
|
|
689
692
|
const foreign = await fetch(url('/ui'), { headers: { origin: 'https://evil.example' } });
|
|
690
693
|
assert.equal(foreign.status, 403);
|
package/tests/shim.test.mjs
CHANGED
|
@@ -476,6 +476,21 @@ const AGY_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'agyconfig-'));
|
|
|
476
476
|
const AGY_CONFIG = path.join(AGY_CONFIG_DIR, 'config.json');
|
|
477
477
|
test.after(() => fs.rmSync(AGY_CONFIG_DIR, { recursive: true, force: true }));
|
|
478
478
|
fs.writeFileSync(path.join(path.dirname(AGY_CONFIG), 'admin.token'), AGY_TOKEN);
|
|
479
|
+
// The relay key of this test install: the relay of a routed agy checks every connection with it.
|
|
480
|
+
const AGY_SECRET = 'd'.repeat(64);
|
|
481
|
+
fs.writeFileSync(path.join(path.dirname(AGY_CONFIG), 'gateway.secret'), AGY_SECRET);
|
|
482
|
+
// Stands in for agy: sends one request with its Google token to CLOUD_CODE_URL, when it has one.
|
|
483
|
+
const AGY_REQUEST = path.join(AGY_CONFIG_DIR, 'agy-request.mjs');
|
|
484
|
+
fs.writeFileSync(AGY_REQUEST, `
|
|
485
|
+
import http from 'node:http';
|
|
486
|
+
const url = process.env.CLOUD_CODE_URL;
|
|
487
|
+
if (url) await new Promise((resolve) => {
|
|
488
|
+
const req = http.request(url + '/v1internal:loadCodeAssist', { method: 'POST', agent: false, headers: { authorization: 'Bearer google-secret' } },
|
|
489
|
+
(res) => { res.resume(); res.on('end', () => { console.log('STATUS ' + res.statusCode); resolve(); }); });
|
|
490
|
+
req.on('error', (e) => { console.log('ERROR ' + e.message); resolve(); });
|
|
491
|
+
req.end('{}');
|
|
492
|
+
});
|
|
493
|
+
`);
|
|
479
494
|
const VERIFY = path.join(ROOT, 'verify-gateway.mjs');
|
|
480
495
|
// Async on purpose: the /health listener lives in this process, and a spawnSync would block it.
|
|
481
496
|
const { spawn: spawnAsync } = await import('node:child_process');
|
|
@@ -491,19 +506,31 @@ function runAsync(cmd, args, opts) {
|
|
|
491
506
|
}
|
|
492
507
|
const NOTICE = /did not prove it is this switcher/;
|
|
493
508
|
|
|
494
|
-
// A
|
|
509
|
+
// A gateway listener: 'real' signs like this gateway (both proofs), 'forged' sends wrong proofs,
|
|
510
|
+
// 'silent' never answers. `server.seen` holds the Authorization of every request that is not a probe.
|
|
495
511
|
async function agyListener(kind) {
|
|
496
512
|
const http = await import('node:http');
|
|
497
513
|
const net = await import('node:net');
|
|
514
|
+
const crypto = await import('node:crypto');
|
|
515
|
+
const seen = [];
|
|
498
516
|
const server = kind === 'silent'
|
|
499
517
|
? net.createServer((sock) => sock.on('error', () => {}))
|
|
500
518
|
: http.createServer((req, res) => {
|
|
501
519
|
const port = server.address().port;
|
|
502
|
-
const
|
|
503
|
-
const proof = kind === 'real' ? identityProof(nonce, { role: 'gateway', port, pid: 4242 }, AGY_TOKEN) : 'forged';
|
|
520
|
+
const u = new URL(req.url, 'http://x');
|
|
504
521
|
res.setHeader('content-type', 'application/json');
|
|
505
|
-
|
|
522
|
+
if (u.pathname !== '/health') {
|
|
523
|
+
seen.push(req.headers.authorization || '');
|
|
524
|
+
req.resume();
|
|
525
|
+
return res.end('{"answeredBy":"gateway"}');
|
|
526
|
+
}
|
|
527
|
+
const nonce = u.searchParams.get('challenge');
|
|
528
|
+
const real = kind === 'real';
|
|
529
|
+
const proof = real ? identityProof(nonce, { role: 'gateway', port, pid: 4242 }, AGY_TOKEN) : 'forged';
|
|
530
|
+
const relayProof = real ? crypto.createHmac('sha256', AGY_SECRET).update(['relay', port, 4242, nonce].join('|')).digest('hex') : 'forged';
|
|
531
|
+
res.end(JSON.stringify({ status: 'ok', proxy: 'llm-switcher', port, pid: 4242, proof, relayProof }));
|
|
506
532
|
});
|
|
533
|
+
server.seen = seen;
|
|
507
534
|
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
508
535
|
return server;
|
|
509
536
|
}
|
|
@@ -511,7 +538,7 @@ async function agyListener(kind) {
|
|
|
511
538
|
async function runAgyWindowsShim(port) {
|
|
512
539
|
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyshim-'));
|
|
513
540
|
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyfake-'));
|
|
514
|
-
fs.writeFileSync(path.join(fakeDir, 'agy.cmd'),
|
|
541
|
+
fs.writeFileSync(path.join(fakeDir, 'agy.cmd'), `@echo off\r\necho CCU=[%CLOUD_CODE_URL%]\r\nnode "${AGY_REQUEST}"\r\n`);
|
|
515
542
|
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
516
543
|
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
517
544
|
fs.writeFileSync(path.join(STATE, 'env-agy.cmd'), `SET "CLOUD_CODE_URL=http://127.0.0.1:${port}"\r\n`);
|
|
@@ -537,10 +564,14 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
|
|
|
537
564
|
// agy gets the port of its own relay, which proves the gateway on every connection.
|
|
538
565
|
const relayPort = Number((out.match(/CCU=\[http:\/\/127\.0\.0\.1:(\d+)\]/) || [])[1]);
|
|
539
566
|
assert.ok(relayPort > 0 && relayPort !== port, `agy must get the relay port, not ${port}: ${out}`);
|
|
567
|
+
// The request really crossed the relay and reached this gateway, token included.
|
|
568
|
+
assert.match(out, /STATUS 200/, out);
|
|
569
|
+
assert.deepEqual(server.seen, ['Bearer google-secret']);
|
|
540
570
|
assert.doesNotMatch(err, NOTICE);
|
|
541
571
|
} else {
|
|
542
572
|
assert.match(out, /CCU=\[\]/, 'the token must not go to a port that did not prove itself');
|
|
543
573
|
assert.match(err, NOTICE);
|
|
574
|
+
assert.deepEqual(server.seen ?? [], [], 'the listener never received the token');
|
|
544
575
|
}
|
|
545
576
|
assert.ok(ms < 8000, `the shim took ${ms} ms`);
|
|
546
577
|
});
|
|
@@ -553,7 +584,7 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
|
|
|
553
584
|
t.after(() => server.close());
|
|
554
585
|
const port = server.address().port;
|
|
555
586
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyposix-'));
|
|
556
|
-
fs.writeFileSync(path.join(dir, 'agy'),
|
|
587
|
+
fs.writeFileSync(path.join(dir, 'agy'), `#!/usr/bin/env bash\necho "CCU=[\${CLOUD_CODE_URL:-}]"\nnode "${AGY_REQUEST}"\n`);
|
|
557
588
|
fs.chmodSync(path.join(dir, 'agy'), 0o755);
|
|
558
589
|
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
559
590
|
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
@@ -566,10 +597,13 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
|
|
|
566
597
|
if (routed) {
|
|
567
598
|
const relayPort = Number((out.stdout.match(/CCU=\[http:\/\/127\.0\.0\.1:(\d+)\]/) || [])[1]);
|
|
568
599
|
assert.ok(relayPort > 0 && relayPort !== port, `agy must get the relay port, not ${port}: ${out.stdout}`);
|
|
600
|
+
assert.match(out.stdout, /STATUS 200/, out.stdout);
|
|
601
|
+
assert.deepEqual(server.seen, ['Bearer google-secret']);
|
|
569
602
|
assert.doesNotMatch(out.stderr, NOTICE);
|
|
570
603
|
} else {
|
|
571
604
|
assert.match(out.stdout, /CCU=\[\]/);
|
|
572
605
|
assert.match(out.stderr, NOTICE);
|
|
606
|
+
assert.deepEqual(server.seen ?? [], [], 'the listener never received the token');
|
|
573
607
|
}
|
|
574
608
|
assert.ok(Date.now() - started < 8000, `the shim took ${Date.now() - started} ms`);
|
|
575
609
|
});
|
package/tests/switch.test.mjs
CHANGED
|
@@ -640,3 +640,24 @@ test('switch off stops the installed service only when that service owns the por
|
|
|
640
640
|
const log = fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '';
|
|
641
641
|
assert.doesNotMatch(log, /\b(stop|restart)\b/, `systemctl was told to stop the instance on port 3456:\n${log}`);
|
|
642
642
|
});
|
|
643
|
+
|
|
644
|
+
// The same rule for `switch port`: the unit of another instance keeps its port and is not restarted.
|
|
645
|
+
test('switch port leaves the installed service of another port unchanged', { skip: process.platform !== 'linux' && 'a systemd fake' }, async (t) => {
|
|
646
|
+
const ws = await workspace(t, await freePort());
|
|
647
|
+
const unitDir = path.join(ws.home, '.config', 'systemd', 'user');
|
|
648
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
649
|
+
const unit = path.join(unitDir, 'llm-switcher.service');
|
|
650
|
+
const original = '[Service]\nExecStart=/usr/bin/node /opt/llm-switcher/proxy.mjs --port 3456\n';
|
|
651
|
+
fs.writeFileSync(unit, original);
|
|
652
|
+
const bin = path.join(ws.dir, 'fakebin');
|
|
653
|
+
fs.mkdirSync(bin);
|
|
654
|
+
const calls = path.join(ws.dir, 'systemctl.log');
|
|
655
|
+
fs.writeFileSync(path.join(bin, 'systemctl'), `#!/bin/sh\necho "$@" >> "${calls}"\nexit 0\n`, { mode: 0o755 });
|
|
656
|
+
const env = { PATH: `${bin}${path.delimiter}${process.env.PATH}` };
|
|
657
|
+
assert.equal((await run(ws, ['on', 'plain'], { env })).status, 0);
|
|
658
|
+
const r = await run(ws, ['port', String(await freePort())], { env });
|
|
659
|
+
assert.equal(r.status, 0, r.out);
|
|
660
|
+
assert.equal(fs.readFileSync(unit, 'utf8'), original, 'the unit of port 3456 was rewritten');
|
|
661
|
+
const log = fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '';
|
|
662
|
+
assert.doesNotMatch(log, /\b(stop|restart|start|enable)\b/, `systemctl was told to act on the other instance:\n${log}`);
|
|
663
|
+
});
|
|
@@ -115,6 +115,8 @@ async function fixture(t, { registryUrl = 'http://127.0.0.1:9/' } = {}) {
|
|
|
115
115
|
c.on('close', code => resolve({ code, out }));
|
|
116
116
|
}),
|
|
117
117
|
dashboardUrl: `http://127.0.0.1:${port}/ui`,
|
|
118
|
+
// How `switch ui` opens it: the token comes in the fragment, never in the page.
|
|
119
|
+
tokenUrl: () => `http://127.0.0.1:${port}/ui#token=${fs.readFileSync(path.join(data, 'admin.token'), 'utf8').trim()}`,
|
|
118
120
|
api: (p, opts = {}) => fetch(`http://127.0.0.1:${port}${p}`, {
|
|
119
121
|
...opts,
|
|
120
122
|
headers: { 'x-llm-switcher-token': fs.readFileSync(path.join(data, 'admin.token'), 'utf8').trim(), 'Content-Type': 'application/json' }
|
|
@@ -184,20 +186,22 @@ test('switch update asks the running gateway and waits for the new one', { timeo
|
|
|
184
186
|
test('the dashboard Update now button installs the release and reloads on the new gateway', { timeout: 90000, skip: skipReason() }, async (t) => {
|
|
185
187
|
const fx = await fixture(t, { registryUrl: await registry(t, '9.9.12') });
|
|
186
188
|
await fx.start();
|
|
187
|
-
await fx.waitFor(fx.pid, 'the gateway');
|
|
189
|
+
const firstPid = await fx.waitFor(fx.pid, 'the gateway');
|
|
188
190
|
fx.release('9.9.12');
|
|
189
191
|
const browser = await launchBrowser();
|
|
190
192
|
t.after(() => browser.close());
|
|
191
193
|
const page = await browser.newPage();
|
|
192
|
-
await page.goto(fx.
|
|
194
|
+
await page.goto(fx.tokenUrl());
|
|
193
195
|
await page.waitFor(`!document.getElementById('update-notice').hidden`, 'the update notice');
|
|
194
196
|
await page.click('#update-now');
|
|
195
197
|
try {
|
|
196
|
-
|
|
198
|
+
// Longer than the 30 s the page polls for the new gateway, so a failure shows the page's own verdict.
|
|
199
|
+
await page.waitFor(`document.getElementById('app-version')?.textContent === 'v9.9.12'`, 'the reloaded dashboard on the new version', 40000);
|
|
197
200
|
} catch (err) {
|
|
198
201
|
// A CI runner cannot be watched: put what the gateway and the page saw into the failure.
|
|
199
202
|
const stage = await page.evaluate(`document.getElementById('update-status')?.textContent || ''`).catch(() => '(page gone)');
|
|
200
|
-
|
|
203
|
+
// The pid seen from this process tells a gateway that does not answer from a page that cannot reach it.
|
|
204
|
+
err.message += `\nupdate status on the page: ${stage}\npid on /health from the test: ${await fx.pid()} (first gateway ${firstPid})\ngateway log:\n${fx.log()}\npage problems: ${JSON.stringify(page.problems)}`;
|
|
201
205
|
throw err;
|
|
202
206
|
}
|
|
203
207
|
assert.equal(await page.evaluate(`document.getElementById('update-notice').hidden`), true);
|
package/ui.html
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
<style>
|
|
18
18
|
:root {
|
|
19
19
|
color-scheme: dark;
|
|
20
|
+
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
20
21
|
--bg: #0c1017;
|
|
21
22
|
--side: #10151e;
|
|
22
23
|
--card: #141a25;
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
--err: #fb7185;
|
|
38
39
|
--err-soft: rgba(251, 113, 133, 0.12);
|
|
39
40
|
--code: #5eead4;
|
|
40
|
-
--shadow: 0
|
|
41
|
+
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 12px -6px rgba(0, 0, 0, 0.55);
|
|
41
42
|
|
|
42
43
|
/* Compatibility aliases */
|
|
43
44
|
--surface: var(--side);
|
|
@@ -89,7 +90,7 @@
|
|
|
89
90
|
--err: #be123c;
|
|
90
91
|
--err-soft: rgba(190, 18, 60, 0.08);
|
|
91
92
|
--code: #0f766e;
|
|
92
|
-
--shadow: 0
|
|
93
|
+
--shadow: 0 1px 2px rgba(20, 26, 37, 0.08), 0 8px 12px -6px rgba(20, 26, 37, 0.16);
|
|
93
94
|
--border-strong: #cbd5e1;
|
|
94
95
|
}
|
|
95
96
|
}
|
|
@@ -115,7 +116,7 @@
|
|
|
115
116
|
--err: #be123c;
|
|
116
117
|
--err-soft: rgba(190, 18, 60, 0.08);
|
|
117
118
|
--code: #0f766e;
|
|
118
|
-
--shadow: 0
|
|
119
|
+
--shadow: 0 1px 2px rgba(20, 26, 37, 0.08), 0 8px 12px -6px rgba(20, 26, 37, 0.16);
|
|
119
120
|
--border-strong: #cbd5e1;
|
|
120
121
|
}
|
|
121
122
|
|
|
@@ -593,7 +594,7 @@
|
|
|
593
594
|
.switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; vertical-align: middle; }
|
|
594
595
|
.switch input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; min-height: 0; }
|
|
595
596
|
.switch span { position: absolute; inset: 0; border-radius: 999px; background: var(--line); box-shadow: inset 0 0 0 1.5px var(--faint); transition: background-color 0.25s, box-shadow 0.25s; pointer-events: none; }
|
|
596
|
-
.switch span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 999px; background: #ffffff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 0.25s
|
|
597
|
+
.switch span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 999px; background: #ffffff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 0.25s var(--ease-out), width 0.15s; }
|
|
597
598
|
.switch input:active + span::after { width: 20px; }
|
|
598
599
|
.switch input:checked + span { background: var(--accent-strong); box-shadow: none; }
|
|
599
600
|
.switch input:checked + span::after { transform: translateX(16px); }
|
|
@@ -627,7 +628,7 @@
|
|
|
627
628
|
border: solid var(--accent-fg);
|
|
628
629
|
border-width: 0 2px 2px 0;
|
|
629
630
|
transform: rotate(45deg) scale(0);
|
|
630
|
-
transition: transform 0.15s
|
|
631
|
+
transition: transform 0.15s var(--ease-out);
|
|
631
632
|
}
|
|
632
633
|
input[type="checkbox"]:not(.switch input):checked { background: var(--accent-strong); border-color: var(--accent-strong); }
|
|
633
634
|
input[type="checkbox"]:not(.switch input):checked::before { transform: rotate(45deg) scale(1); }
|
|
@@ -646,7 +647,7 @@
|
|
|
646
647
|
opacity: 1;
|
|
647
648
|
transition: opacity 0.18s;
|
|
648
649
|
}
|
|
649
|
-
.overlay .modal { transition: transform 0.18s
|
|
650
|
+
.overlay .modal { transition: transform 0.18s var(--ease-out); }
|
|
650
651
|
@starting-style { .overlay { opacity: 0; } .overlay .modal { transform: scale(0.96) translateY(8px); } }
|
|
651
652
|
.modal {
|
|
652
653
|
background: var(--card);
|
|
@@ -693,6 +694,7 @@
|
|
|
693
694
|
scrollbar-width: none;
|
|
694
695
|
}
|
|
695
696
|
.tabs::-webkit-scrollbar { display: none; }
|
|
697
|
+
.tabs-wrap { padding: 0.85rem 1.25rem 0.5rem; background: var(--card2); }
|
|
696
698
|
.tabs button.tab-btn {
|
|
697
699
|
padding: 0.55rem 0.95rem;
|
|
698
700
|
color: var(--muted);
|
|
@@ -712,6 +714,13 @@
|
|
|
712
714
|
border-bottom-color: var(--accent);
|
|
713
715
|
font-weight: 600;
|
|
714
716
|
}
|
|
717
|
+
/* A phone has no room for all three tabs at full padding, and the bar hides its scrollbar:
|
|
718
|
+
tighten them, and wrap rather than hide a tab when even that is not enough. */
|
|
719
|
+
@media (max-width: 480px) {
|
|
720
|
+
.tabs-wrap { padding-inline: 1rem; }
|
|
721
|
+
.tabs { flex-wrap: wrap; }
|
|
722
|
+
.tabs button.tab-btn { padding-inline: 0.6rem; }
|
|
723
|
+
}
|
|
715
724
|
|
|
716
725
|
/* Log Items */
|
|
717
726
|
.log-item {
|
|
@@ -757,7 +766,7 @@
|
|
|
757
766
|
gap: 0.6rem;
|
|
758
767
|
overflow: hidden;
|
|
759
768
|
max-width: 90vw;
|
|
760
|
-
animation: fadeIn 0.25s
|
|
769
|
+
animation: fadeIn 0.25s var(--ease-out);
|
|
761
770
|
}
|
|
762
771
|
.toast::before { content: ""; width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
|
|
763
772
|
.toast::after { content: ""; position: absolute; left: 0; bottom: 0; width: 100%; height: 2px; background: var(--accent); transform-origin: left; animation: toastbar 3.2s linear forwards; }
|
|
@@ -782,7 +791,7 @@
|
|
|
782
791
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
|
|
783
792
|
opacity: 1;
|
|
784
793
|
translate: 0 0;
|
|
785
|
-
transition: opacity 0.15s, translate 0.2s
|
|
794
|
+
transition: opacity 0.15s, translate 0.2s var(--ease-out), display 0.15s;
|
|
786
795
|
transition-behavior: allow-discrete;
|
|
787
796
|
}
|
|
788
797
|
.tip[hidden] { display: none; opacity: 0; translate: 0 4px; }
|
|
@@ -1123,7 +1132,7 @@
|
|
|
1123
1132
|
|
|
1124
1133
|
<form id="profile-form" onsubmit="saveProfile(event)" onkeydown="blockImplicitSubmit(event)" oninput="formDirty = true" onchange="formDirty = true" style="display:flex; flex-direction:column; overflow:hidden;">
|
|
1125
1134
|
<!-- Modal Inset Tabs -->
|
|
1126
|
-
<div
|
|
1135
|
+
<div class="tabs-wrap">
|
|
1127
1136
|
<div class="tabs">
|
|
1128
1137
|
<button type="button" id="tab-button-general" class="tab-btn active" onclick="switchTab('tab-general')">General</button>
|
|
1129
1138
|
<button type="button" id="tab-button-routing" class="tab-btn" onclick="switchTab('tab-routing')">Routing & Protocol</button>
|
|
@@ -1160,7 +1169,7 @@
|
|
|
1160
1169
|
</label>
|
|
1161
1170
|
|
|
1162
1171
|
<label class="f">API Key
|
|
1163
|
-
<input type="password" id="p-key-val" placeholder="sk-...">
|
|
1172
|
+
<input type="password" id="p-key-val" placeholder="sk-..." autocomplete="off">
|
|
1164
1173
|
</label>
|
|
1165
1174
|
</div>
|
|
1166
1175
|
|
|
@@ -1247,10 +1256,12 @@
|
|
|
1247
1256
|
<script>
|
|
1248
1257
|
// State & Globals
|
|
1249
1258
|
const ADMIN_TOKEN_KEY = 'llm-switcher-admin-token';
|
|
1250
|
-
|
|
1259
|
+
// The token comes only in the #token of the private launcher that `switch ui` opens, and stays in
|
|
1260
|
+
// this tab. The page itself never carries it: any process on the machine can load /ui.
|
|
1261
|
+
const NO_TOKEN_HINT = 'This tab has no access token. Open the dashboard with `switch ui`.';
|
|
1251
1262
|
const hashTokenMatch = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
|
|
1252
|
-
if (
|
|
1253
|
-
sessionStorage.setItem(ADMIN_TOKEN_KEY,
|
|
1263
|
+
if (hashTokenMatch) {
|
|
1264
|
+
sessionStorage.setItem(ADMIN_TOKEN_KEY, hashTokenMatch[1]);
|
|
1254
1265
|
}
|
|
1255
1266
|
if (hashTokenMatch) {
|
|
1256
1267
|
history.replaceState(null, '', location.pathname + location.search + '#/routes');
|
|
@@ -1278,13 +1289,13 @@
|
|
|
1278
1289
|
|
|
1279
1290
|
// API Helper
|
|
1280
1291
|
async function api(path, opts = {}) {
|
|
1281
|
-
const token = sessionStorage.getItem(ADMIN_TOKEN_KEY) ||
|
|
1292
|
+
const token = sessionStorage.getItem(ADMIN_TOKEN_KEY) || '';
|
|
1282
1293
|
const headers = {
|
|
1283
1294
|
...opts.headers,
|
|
1284
1295
|
'x-llm-switcher-token': token
|
|
1285
1296
|
};
|
|
1286
1297
|
const res = await fetch(path, { ...opts, headers });
|
|
1287
|
-
if (res.status === 401) showToast(
|
|
1298
|
+
if (res.status === 401) showToast(NO_TOKEN_HINT, true);
|
|
1288
1299
|
if (res.status === 409) setTimeout(loadStatus, 0);
|
|
1289
1300
|
return res;
|
|
1290
1301
|
}
|
|
@@ -2775,15 +2786,22 @@
|
|
|
2775
2786
|
return;
|
|
2776
2787
|
}
|
|
2777
2788
|
say('Installed v' + result.to + '. Restarting the gateway...');
|
|
2789
|
+
let last = 'no check ran';
|
|
2778
2790
|
for (let n = 0; n < 60; n++) {
|
|
2779
2791
|
await new Promise(r => setTimeout(r, 500));
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2792
|
+
try {
|
|
2793
|
+
const r = await fetch('/health?challenge=ui', { cache: 'no-store' });
|
|
2794
|
+
const pid = (await r.json()).pid ?? null;
|
|
2795
|
+
if (pid && pid !== before) {
|
|
2796
|
+
location.reload();
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
last = pid ? 'the gateway from before the update (pid ' + pid + ')' : 'HTTP ' + r.status + ' with no pid';
|
|
2800
|
+
} catch (err) {
|
|
2801
|
+
last = err.message;
|
|
2784
2802
|
}
|
|
2785
2803
|
}
|
|
2786
|
-
throw new Error('The new gateway did not answer. Read proxy.log.');
|
|
2804
|
+
throw new Error('The new gateway did not answer (last check: ' + last + '). Read proxy.log.');
|
|
2787
2805
|
} catch (err) {
|
|
2788
2806
|
say(err.message);
|
|
2789
2807
|
showToast('Update failed: ' + err.message, true);
|
|
@@ -2792,6 +2810,7 @@
|
|
|
2792
2810
|
}
|
|
2793
2811
|
|
|
2794
2812
|
document.addEventListener('DOMContentLoaded', () => {
|
|
2813
|
+
if (!sessionStorage.getItem(ADMIN_TOKEN_KEY)) showToast(NO_TOKEN_HINT, true);
|
|
2795
2814
|
// Setup initial theme buttons state
|
|
2796
2815
|
const currentTheme = document.documentElement.dataset.theme || 'dark';
|
|
2797
2816
|
document.getElementById('btn-theme-dark')?.setAttribute('aria-pressed', currentTheme === 'dark');
|