llm-switcher 1.3.5 → 1.3.7
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 +9 -0
- package/package.json +1 -1
- package/tests/dashboard.e2e.test.mjs +28 -8
- package/tests/version.test.mjs +15 -0
- package/ui.html +9 -6
- package/version.mjs +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.3.7
|
|
4
|
+
|
|
5
|
+
- **Update notice in time:** The gateway kept the npm answer for 12 hours. A release published after the last check did not show an update button until the next day. It now asks npm again after 5 minutes.
|
|
6
|
+
|
|
7
|
+
## Release 1.3.6
|
|
8
|
+
|
|
9
|
+
- **Dashboard from a bookmark:** In 1.3.4 the tab kept the token in `sessionStorage`. A bookmark, a typed URL or a new tab then had no token: every call got HTTP 401, and the dashboard showed no profiles and every route as OFF. The token is now in `localStorage`, so after one `switch ui` every tab of that browser works. The page still does not carry the token.
|
|
10
|
+
- **Dashboard without a token:** The header now says to open the dashboard with `switch ui`. Before, it said "Gateway is idle", which looked like a config with no routes.
|
|
11
|
+
|
|
3
12
|
## Release 1.3.5
|
|
4
13
|
|
|
5
14
|
- **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.
|
package/package.json
CHANGED
|
@@ -24,17 +24,37 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
24
24
|
await page.goto(fx.tokenUrl());
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// Close the tab itself, then raise the main one: a tab left in the background gets throttled
|
|
28
|
+
// timers and no animation frames, which stalls every later test.
|
|
29
|
+
async function closeTab(tab) {
|
|
30
|
+
await tab.send('Page.close').catch(() => {});
|
|
31
|
+
tab.close();
|
|
32
|
+
await page.send('Page.bringToFront');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
it('a new tab, opened from a bookmark after switch ui ran once, shows the profiles', async () => {
|
|
36
|
+
const fresh = await browser.newPage();
|
|
37
|
+
try {
|
|
38
|
+
await fresh.goto(fx.dashboardUrl + '#/routes');
|
|
39
|
+
await fresh.waitFor(`document.querySelectorAll('#profiles-grid .pcard').length === ${Object.keys(fx.config().profiles).length}`, 'the profile cards');
|
|
40
|
+
} finally {
|
|
41
|
+
await closeTab(fresh);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('a browser that never got the launcher token says so on the page, not only in a toast', async () => {
|
|
28
46
|
const fresh = await browser.newPage();
|
|
29
47
|
try {
|
|
30
|
-
await fresh.goto(fx.dashboardUrl);
|
|
31
|
-
await fresh.
|
|
48
|
+
await fresh.goto(fx.dashboardUrl + '#/routes');
|
|
49
|
+
await fresh.evaluate(`localStorage.clear(), sessionStorage.clear()`);
|
|
50
|
+
await fresh.reload();
|
|
51
|
+
await fresh.waitFor(`/switch ui/.test(document.getElementById('status-sub')?.textContent || '')`, 'the switch ui hint in the header');
|
|
32
52
|
} finally {
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
await page.
|
|
53
|
+
await closeTab(fresh);
|
|
54
|
+
// The storage is shared by the origin, so the main tab needs the token again. A #hash change
|
|
55
|
+
// alone fires no load event, so leave the page first.
|
|
56
|
+
await page.goto('about:blank');
|
|
57
|
+
await page.goto(fx.tokenUrl());
|
|
38
58
|
}
|
|
39
59
|
});
|
|
40
60
|
|
package/tests/version.test.mjs
CHANGED
|
@@ -63,6 +63,21 @@ test('the same version is no update; a stale cache asks the registry again', asy
|
|
|
63
63
|
}
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
test('a release published after the last check shows within 5 minutes, not after 12 hours', async () => {
|
|
67
|
+
const reg = await registry((req, res) => res.end(JSON.stringify({ version: '99.0.0' })));
|
|
68
|
+
const stateDir = tmpState();
|
|
69
|
+
try {
|
|
70
|
+
const answeredBeforeTheRelease = { checkedAt: Date.now() - 6 * 60 * 1000, latest: CURRENT_VERSION };
|
|
71
|
+
fs.writeFileSync(path.join(stateDir, 'version-check.json'), JSON.stringify(answeredBeforeTheRelease));
|
|
72
|
+
const r = await checkForUpdate({ stateDir, url: reg.url });
|
|
73
|
+
assert.equal(reg.hits(), 1, 'a 6-minute-old answer is asked again');
|
|
74
|
+
assert.equal(r.updateAvailable, true);
|
|
75
|
+
} finally {
|
|
76
|
+
reg.close();
|
|
77
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
66
81
|
test('an unreachable or broken registry gives no notice and never throws', async () => {
|
|
67
82
|
const reg = await registry((req, res) => { res.statusCode = 500; res.end('down'); });
|
|
68
83
|
const stateDir = tmpState();
|
package/ui.html
CHANGED
|
@@ -1256,12 +1256,13 @@
|
|
|
1256
1256
|
<script>
|
|
1257
1257
|
// State & Globals
|
|
1258
1258
|
const ADMIN_TOKEN_KEY = 'llm-switcher-admin-token';
|
|
1259
|
-
// The token comes only in the #token of the private launcher that `switch ui` opens
|
|
1260
|
-
//
|
|
1259
|
+
// The token comes only in the #token of the private launcher that `switch ui` opens. The page itself
|
|
1260
|
+
// never carries it: any process on the machine can load /ui. localStorage, not sessionStorage, so a
|
|
1261
|
+
// bookmark or a new tab in the same browser keeps working after one `switch ui`.
|
|
1261
1262
|
const NO_TOKEN_HINT = 'This tab has no access token. Open the dashboard with `switch ui`.';
|
|
1262
1263
|
const hashTokenMatch = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
|
|
1263
1264
|
if (hashTokenMatch) {
|
|
1264
|
-
|
|
1265
|
+
localStorage.setItem(ADMIN_TOKEN_KEY, hashTokenMatch[1]);
|
|
1265
1266
|
}
|
|
1266
1267
|
if (hashTokenMatch) {
|
|
1267
1268
|
history.replaceState(null, '', location.pathname + location.search + '#/routes');
|
|
@@ -1289,7 +1290,7 @@
|
|
|
1289
1290
|
|
|
1290
1291
|
// API Helper
|
|
1291
1292
|
async function api(path, opts = {}) {
|
|
1292
|
-
const token =
|
|
1293
|
+
const token = localStorage.getItem(ADMIN_TOKEN_KEY) || '';
|
|
1293
1294
|
const headers = {
|
|
1294
1295
|
...opts.headers,
|
|
1295
1296
|
'x-llm-switcher-token': token
|
|
@@ -1461,6 +1462,8 @@
|
|
|
1461
1462
|
const subEl = document.getElementById('status-sub');
|
|
1462
1463
|
const actionsEl = document.getElementById('ph-actions');
|
|
1463
1464
|
if (!titleEl || !subEl || !actionsEl) return;
|
|
1465
|
+
// Without the token every call is a 401, and the empty state would read as "nothing routed".
|
|
1466
|
+
const noToken = !localStorage.getItem(ADMIN_TOKEN_KEY);
|
|
1464
1467
|
|
|
1465
1468
|
const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
|
|
1466
1469
|
const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
|
|
@@ -1474,7 +1477,7 @@
|
|
|
1474
1477
|
if (activeMap.agy) parts.push('agy: ' + activeMap.agy);
|
|
1475
1478
|
subEl.textContent = 'Port ' + state.port + ' · Active for ' + parts.join(' · ');
|
|
1476
1479
|
} else {
|
|
1477
|
-
subEl.textContent = 'Gateway is idle. Clients connect directly to official endpoints.';
|
|
1480
|
+
subEl.textContent = noToken ? NO_TOKEN_HINT : 'Gateway is idle. Clients connect directly to official endpoints.';
|
|
1478
1481
|
}
|
|
1479
1482
|
actionsEl.innerHTML = `
|
|
1480
1483
|
<button class="btn btn-primary" onclick="openProfileModal(true)">+ Add Profile</button>
|
|
@@ -2810,7 +2813,7 @@
|
|
|
2810
2813
|
}
|
|
2811
2814
|
|
|
2812
2815
|
document.addEventListener('DOMContentLoaded', () => {
|
|
2813
|
-
if (!
|
|
2816
|
+
if (!localStorage.getItem(ADMIN_TOKEN_KEY)) showToast(NO_TOKEN_HINT, true);
|
|
2814
2817
|
// Setup initial theme buttons state
|
|
2815
2818
|
const currentTheme = document.documentElement.dataset.theme || 'dark';
|
|
2816
2819
|
document.getElementById('btn-theme-dark')?.setAttribute('aria-pressed', currentTheme === 'dark');
|
package/version.mjs
CHANGED
|
@@ -11,7 +11,8 @@ export const CURRENT_VERSION = (() => {
|
|
|
11
11
|
|
|
12
12
|
const REGISTRY_URL = 'https://registry.npmjs.org/llm-switcher/latest';
|
|
13
13
|
const CACHE_FILE = 'version-check.json';
|
|
14
|
-
|
|
14
|
+
// A release shows on the dashboard within minutes; one small GET per 5 minutes.
|
|
15
|
+
const MAX_AGE_MS = 5 * 60 * 1000;
|
|
15
16
|
|
|
16
17
|
const parse = (v) => {
|
|
17
18
|
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v ?? ''));
|
|
@@ -30,7 +31,7 @@ export function isNewer(latest, current) {
|
|
|
30
31
|
const updateCommand = () => (fs.existsSync(path.join(ROOT, '.git')) ? `git -C "${ROOT}" pull` : 'npm install -g llm-switcher@latest');
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
* Asks the registry at most once per
|
|
34
|
+
* Asks the registry at most once per 5 minutes; a failed ask also waits, so an offline machine is not
|
|
34
35
|
* slowed on every call. Never throws: without an answer there is simply no notice.
|
|
35
36
|
*/
|
|
36
37
|
export async function checkForUpdate({ stateDir, url = process.env.LLM_SWITCHER_REGISTRY_URL || REGISTRY_URL } = {}) {
|