llm-switcher 1.3.4 → 1.3.6

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.3.6
4
+
5
+ - **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.
6
+ - **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.
7
+
8
+ ## Release 1.3.5
9
+
10
+ - **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.
11
+ - **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.
12
+ - **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.
13
+ - **API key field:** The field has `autocomplete="off"`, so a password manager does not offer to save the key.
14
+ - **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.
15
+
3
16
  ## Release 1.3.4
4
17
 
5
18
  - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
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",
@@ -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
- it('a tab opened without the launcher token says to open the dashboard with switch ui', async () => {
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.waitFor(`/switch ui/.test(document.getElementById('toast')?.textContent || '')`, 'the switch ui hint');
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
- // 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');
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
 
@@ -776,6 +796,23 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
776
796
  }
777
797
  });
778
798
 
799
+ it('shows every dialog tab whole on a phone', async () => {
800
+ await page.resize(390, 844);
801
+ try {
802
+ await editProfile('intact-claude');
803
+ await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "grid"', 'the dialog');
804
+ const cut = await page.evaluate(`(() => {
805
+ const bar = document.querySelector('#profile-form .tabs').getBoundingClientRect();
806
+ return [...document.querySelectorAll('#profile-form .tabs .tab-btn')]
807
+ .filter(b => { const r = b.getBoundingClientRect(); return r.left < bar.left - 0.5 || r.right > bar.right + 0.5; })
808
+ .map(b => b.textContent.trim());
809
+ })()`);
810
+ assert.deepEqual(cut, [], 'these tabs are cut off by the tab bar at 390 px');
811
+ } finally {
812
+ await page.resize(1280, 900);
813
+ }
814
+ });
815
+
779
816
  it('does not let a long profile name break the dialog header', async () => {
780
817
  const cfg = fx.config();
781
818
  cfg.profiles['intact-claude'].name = 'N'.repeat(300);
@@ -186,7 +186,7 @@ test('switch update asks the running gateway and waits for the new one', { timeo
186
186
  test('the dashboard Update now button installs the release and reloads on the new gateway', { timeout: 90000, skip: skipReason() }, async (t) => {
187
187
  const fx = await fixture(t, { registryUrl: await registry(t, '9.9.12') });
188
188
  await fx.start();
189
- await fx.waitFor(fx.pid, 'the gateway');
189
+ const firstPid = await fx.waitFor(fx.pid, 'the gateway');
190
190
  fx.release('9.9.12');
191
191
  const browser = await launchBrowser();
192
192
  t.after(() => browser.close());
@@ -200,7 +200,8 @@ test('the dashboard Update now button installs the release and reloads on the ne
200
200
  } catch (err) {
201
201
  // A CI runner cannot be watched: put what the gateway and the page saw into the failure.
202
202
  const stage = await page.evaluate(`document.getElementById('update-status')?.textContent || ''`).catch(() => '(page gone)');
203
- err.message += `\nupdate status on the page: ${stage}\ngateway log:\n${fx.log()}\npage problems: ${JSON.stringify(page.problems)}`;
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)}`;
204
205
  throw err;
205
206
  }
206
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 20px 50px rgba(0, 0, 0, 0.55);
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 20px 50px rgba(20, 26, 37, 0.14);
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 20px 50px rgba(20, 26, 37, 0.14);
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 cubic-bezier(0.3, 1.4, 0.6, 1), width 0.15s; }
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 cubic-bezier(0.3, 1.4, 0.6, 1);
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 cubic-bezier(0.2, 0.9, 0.3, 1.2); }
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 cubic-bezier(0.2, 0.9, 0.3, 1.2);
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 cubic-bezier(0.3, 1.4, 0.6, 1), display 0.15s;
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 style="padding:0.85rem 1.25rem 0.5rem; background:var(--card2);">
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,12 +1256,13 @@
1247
1256
  <script>
1248
1257
  // State & Globals
1249
1258
  const ADMIN_TOKEN_KEY = 'llm-switcher-admin-token';
1250
- // The token comes only in the #token of the private launcher that `switch ui` opens, and stays in
1251
- // this tab. The page itself never carries it: any process on the machine can load /ui.
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`.
1252
1262
  const NO_TOKEN_HINT = 'This tab has no access token. Open the dashboard with `switch ui`.';
1253
1263
  const hashTokenMatch = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
1254
1264
  if (hashTokenMatch) {
1255
- sessionStorage.setItem(ADMIN_TOKEN_KEY, hashTokenMatch[1]);
1265
+ localStorage.setItem(ADMIN_TOKEN_KEY, hashTokenMatch[1]);
1256
1266
  }
1257
1267
  if (hashTokenMatch) {
1258
1268
  history.replaceState(null, '', location.pathname + location.search + '#/routes');
@@ -1280,7 +1290,7 @@
1280
1290
 
1281
1291
  // API Helper
1282
1292
  async function api(path, opts = {}) {
1283
- const token = sessionStorage.getItem(ADMIN_TOKEN_KEY) || '';
1293
+ const token = localStorage.getItem(ADMIN_TOKEN_KEY) || '';
1284
1294
  const headers = {
1285
1295
  ...opts.headers,
1286
1296
  'x-llm-switcher-token': token
@@ -1452,6 +1462,8 @@
1452
1462
  const subEl = document.getElementById('status-sub');
1453
1463
  const actionsEl = document.getElementById('ph-actions');
1454
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);
1455
1467
 
1456
1468
  const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
1457
1469
  const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
@@ -1465,7 +1477,7 @@
1465
1477
  if (activeMap.agy) parts.push('agy: ' + activeMap.agy);
1466
1478
  subEl.textContent = 'Port ' + state.port + ' · Active for ' + parts.join(' · ');
1467
1479
  } else {
1468
- 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.';
1469
1481
  }
1470
1482
  actionsEl.innerHTML = `
1471
1483
  <button class="btn btn-primary" onclick="openProfileModal(true)">+ Add Profile</button>
@@ -2777,15 +2789,22 @@
2777
2789
  return;
2778
2790
  }
2779
2791
  say('Installed v' + result.to + '. Restarting the gateway...');
2792
+ let last = 'no check ran';
2780
2793
  for (let n = 0; n < 60; n++) {
2781
2794
  await new Promise(r => setTimeout(r, 500));
2782
- const pid = await gatewayPid();
2783
- if (pid && pid !== before) {
2784
- location.reload();
2785
- return;
2795
+ try {
2796
+ const r = await fetch('/health?challenge=ui', { cache: 'no-store' });
2797
+ const pid = (await r.json()).pid ?? null;
2798
+ if (pid && pid !== before) {
2799
+ location.reload();
2800
+ return;
2801
+ }
2802
+ last = pid ? 'the gateway from before the update (pid ' + pid + ')' : 'HTTP ' + r.status + ' with no pid';
2803
+ } catch (err) {
2804
+ last = err.message;
2786
2805
  }
2787
2806
  }
2788
- throw new Error('The new gateway did not answer. Read proxy.log.');
2807
+ throw new Error('The new gateway did not answer (last check: ' + last + '). Read proxy.log.');
2789
2808
  } catch (err) {
2790
2809
  say(err.message);
2791
2810
  showToast('Update failed: ' + err.message, true);
@@ -2794,7 +2813,7 @@
2794
2813
  }
2795
2814
 
2796
2815
  document.addEventListener('DOMContentLoaded', () => {
2797
- if (!sessionStorage.getItem(ADMIN_TOKEN_KEY)) showToast(NO_TOKEN_HINT, true);
2816
+ if (!localStorage.getItem(ADMIN_TOKEN_KEY)) showToast(NO_TOKEN_HINT, true);
2798
2817
  // Setup initial theme buttons state
2799
2818
  const currentTheme = document.documentElement.dataset.theme || 'dark';
2800
2819
  document.getElementById('btn-theme-dark')?.setAttribute('aria-pressed', currentTheme === 'dark');