llm-switcher 1.2.4 → 1.2.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,17 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.2.6
4
+
5
+ - **Deactivate in the dashboard:** The Deactivate button did not change the configuration, but the dashboard showed "deactivated". The dashboard sends `deactivate: true` and the profile key in `profile`. The gateway read `deactivate` as the profile key, so no target matched. Now `POST /api/switch` accepts both forms: `{"profile": "x", "deactivate": true}` and `{"deactivate": "x"}`.
6
+ - **No silent success:** If the profile is not active, `POST /api/switch` now answers `404` with the error `Profile "x" is not active`. If `deactivate` is `true` and no profile is named, it answers `400`. Before, both requests answered `200` with `success: true`.
7
+
8
+ ## Release 1.2.5
9
+
10
+ - **Dashboard controls:** The dashboard controls use MIT elements from Uiverse.io galaxy as a base. The CSS gives the name of each author.
11
+ - **Switch and checkbox:** The switch knob moves with a short spring and stretches while you press it. When a switch is off, a ring keeps its track visible. A checkbox shows a tick that grows when you select it.
12
+ - **Feedback:** A toast shows a status dot and a bar for the time until it closes. A styled tooltip replaces the browser tooltip for each item that has a title. Test Connection and Sync Models show a spinner while they run. The dialog fades in.
13
+ - **Accessibility:** A switch and a checkbox show a 2px outline when they have keyboard focus. When the system asks for reduced motion, only the spinner moves.
14
+
3
15
  ## Release 1.2.4
4
16
 
5
17
  - **Codex blindfold starts on a new machine:** A switch to a Codex profile stopped with "ca.pem is missing" on every new data directory. The certificates came only from `make-certs.sh`, which needs bash and OpenSSL. Now the switcher builds the same set with Node's crypto before it does any other check. It builds a new leaf when the leaf is missing, does not cover the three hosts, or does not match its key. It keeps a CA that still works, because Codex already trusts it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
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
@@ -1367,7 +1367,12 @@ async function routeConfigApi(res, method, pathname, body) {
1367
1367
  if (pathname === '/api/switch') {
1368
1368
  let err = null;
1369
1369
  if (body.deactivate) {
1370
- deactivateProfile(cfg, body.deactivate);
1370
+ // The dashboard sends `deactivate: true` with the key in `profile`; the CLI sends the key itself.
1371
+ const key = body.deactivate === true ? body.profile : body.deactivate;
1372
+ if (typeof key !== 'string' || !key) return sendJson(res, 400, { error: 'deactivate needs a profile key' });
1373
+ // A no-op must not answer success: the dashboard trusts `success` alone.
1374
+ if (!isProfileActive(cfg, key)) return sendJson(res, 404, { error: `Profile "${key}" is not active` });
1375
+ deactivateProfile(cfg, key);
1371
1376
  } else if (body.target) {
1372
1377
  err = setTargetProfile(cfg, body.target, body.profile || null);
1373
1378
  } else if (body.profile) {
package/switch.cmd CHANGED
@@ -1,2 +1,2 @@
1
- @echo off
2
- node "%~dp0switch.mjs" %*
1
+ @echo off
2
+ node "%~dp0switch.mjs" %*
@@ -1208,3 +1208,30 @@ test('Admin test-upstream reports latency and a sample from the upstream', async
1208
1208
  assert.match(json.sample, /Final answer/);
1209
1209
  assert.equal(typeof json.latency, 'number');
1210
1210
  });
1211
+
1212
+ // Runs last: turning `codex` off and on stops and restarts the shared interceptor, which the
1213
+ // Codex WebSocket tests above depend on.
1214
+ test('/api/switch deactivate: the dashboard payload works, the CLI payload works, a no-op is an error', async () => {
1215
+ try {
1216
+ // What the dashboard sends: the profile in `profile`, a boolean in `deactivate`.
1217
+ const dash = await post('/api/switch', { profile: 'chat', deactivate: true });
1218
+ assert.equal(dash.status, 200);
1219
+ assert.deepEqual((await dash.json()).activeProfiles, { claude: null, codex: null });
1220
+
1221
+ // The same profile is now inactive, so asking again must not report success.
1222
+ const again = await post('/api/switch', { profile: 'chat', deactivate: true });
1223
+ assert.equal(again.status, 404);
1224
+ assert.match((await again.json()).error, /not active/);
1225
+
1226
+ // What the CLI sends: the profile key in `deactivate`.
1227
+ assert.equal((await post('/api/switch', { profile: 'chat' })).status, 200);
1228
+ const cli = await post('/api/switch', { deactivate: 'chat' });
1229
+ assert.equal(cli.status, 200);
1230
+ assert.deepEqual((await cli.json()).activeProfiles, { claude: null, codex: null });
1231
+
1232
+ // `deactivate: true` with no profile names nothing.
1233
+ assert.equal((await post('/api/switch', { deactivate: true })).status, 400);
1234
+ } finally {
1235
+ await post('/api/switch', { profile: 'chat' });
1236
+ }
1237
+ });
package/ui.html CHANGED
@@ -472,8 +472,10 @@
472
472
  text-decoration: none;
473
473
  font-size: 0.84rem;
474
474
  font-weight: 500;
475
- transition: filter 0.15s, background 0.15s, color 0.15s, border-color 0.15s;
475
+ transition: filter 0.15s, background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
476
476
  }
477
+ /* Press feedback after Uiverse.io ItsEnigma143 (MIT). */
478
+ button:active:not(:disabled), .btn:active:not(:disabled) { transform: scale(0.97); }
477
479
  button.primary, .btn.btn-primary {
478
480
  background: var(--accent-strong);
479
481
  color: var(--accent-fg);
@@ -503,6 +505,21 @@
503
505
  button.icon { min-height: 28px; min-width: 28px; padding: 0.2rem; background: transparent; color: var(--muted); border-radius: 6px; }
504
506
  button.icon:hover { color: var(--fg); background: var(--card2); }
505
507
  button:disabled, .btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
508
+ /* A busy button keeps its full colour: the spinner, not the fade, says it works. */
509
+ button[aria-busy="true"]:disabled { opacity: 1; }
510
+ /* Spinner after Uiverse.io Cybercom682 (MIT): a ring with one open side. */
511
+ button[aria-busy="true"]::before {
512
+ content: "";
513
+ width: 14px;
514
+ height: 14px;
515
+ flex: none;
516
+ box-sizing: border-box;
517
+ border-radius: 50%;
518
+ border: 2px solid currentColor;
519
+ border-right-color: transparent;
520
+ animation: spin 0.7s linear infinite;
521
+ }
522
+ @keyframes spin { to { transform: rotate(1turn); } }
506
523
  .btn-sm { min-height: 26px; padding: 0.2rem 0.6rem; font-size: 0.78rem; border-radius: 6px; }
507
524
 
508
525
  /* Inputs */
@@ -516,7 +533,9 @@
516
533
  min-height: 36px;
517
534
  width: 100%;
518
535
  font-size: 0.85rem;
536
+ transition: border-color 0.15s, box-shadow 0.15s;
519
537
  }
538
+ input:hover:not(:focus):not(:disabled), select:hover:not(:focus):not(:disabled), textarea:hover:not(:focus):not(:disabled) { border-color: var(--faint); }
520
539
  input:focus, select:focus, textarea:focus, button:focus-visible, a:focus-visible {
521
540
  outline: none;
522
541
  border-color: var(--accent);
@@ -567,12 +586,50 @@
567
586
  @media (prefers-reduced-motion: reduce) { .combo-toggle svg { transition: none; } }
568
587
 
569
588
  /* Switch */
570
- .switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; }
589
+ /* Switch after Uiverse.io iSweat-exe (MIT); the knob stretches while pressed.
590
+ The inset ring keeps the off track visible: --line alone nearly vanishes on a card. */
591
+ .switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; vertical-align: middle; }
571
592
  .switch input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; min-height: 0; }
572
- .switch span { position: absolute; inset: 0; border-radius: 999px; background: var(--line); transition: background 0.15s; pointer-events: none; }
573
- .switch span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #ffffff; transition: transform 0.15s; }
574
- .switch input:checked + span { background: var(--accent-strong); }
593
+ .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; }
594
+ .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; }
595
+ .switch input:active + span::after { width: 20px; }
596
+ .switch input:checked + span { background: var(--accent-strong); box-shadow: none; }
575
597
  .switch input:checked + span::after { transform: translateX(16px); }
598
+ .switch input:checked:active + span::after { transform: translateX(12px); }
599
+ .switch input:focus-visible + span, input[type="checkbox"]:not(.switch input):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
600
+
601
+ /* Checkbox after Uiverse.io adamgiebl (MIT): the tick scales in.
602
+ !important beats the inline sizes on the log and 1M boxes. */
603
+ input[type="checkbox"]:not(.switch input) {
604
+ appearance: none;
605
+ width: 16px !important;
606
+ height: 16px !important;
607
+ min-height: 0 !important;
608
+ padding: 0;
609
+ margin: 0;
610
+ flex: none;
611
+ border: 1.5px solid var(--faint);
612
+ border-radius: 4px;
613
+ background: var(--bg);
614
+ cursor: pointer;
615
+ display: inline-grid;
616
+ place-content: center;
617
+ vertical-align: middle;
618
+ transition: background-color 0.15s, border-color 0.15s;
619
+ }
620
+ input[type="checkbox"]:not(.switch input)::before {
621
+ content: "";
622
+ width: 4px;
623
+ height: 8px;
624
+ margin-top: -2px;
625
+ border: solid var(--accent-fg);
626
+ border-width: 0 2px 2px 0;
627
+ transform: rotate(45deg) scale(0);
628
+ transition: transform 0.15s cubic-bezier(0.3, 1.4, 0.6, 1);
629
+ }
630
+ input[type="checkbox"]:not(.switch input):checked { background: var(--accent-strong); border-color: var(--accent-strong); }
631
+ input[type="checkbox"]:not(.switch input):checked::before { transform: rotate(45deg) scale(1); }
632
+ input[type="checkbox"]:not(.switch input):disabled { opacity: 0.45; cursor: default; }
576
633
 
577
634
  /* Modal */
578
635
  .overlay {
@@ -584,7 +641,11 @@
584
641
  place-items: center;
585
642
  padding: 1rem;
586
643
  z-index: 50;
644
+ opacity: 1;
645
+ transition: opacity 0.18s;
587
646
  }
647
+ .overlay .modal { transition: transform 0.18s cubic-bezier(0.2, 0.9, 0.3, 1.2); }
648
+ @starting-style { .overlay { opacity: 0; } .overlay .modal { transform: scale(0.96) translateY(8px); } }
588
649
  .modal {
589
650
  background: var(--card);
590
651
  border: 1px solid var(--line);
@@ -675,6 +736,7 @@
675
736
  }
676
737
 
677
738
  /* Toast */
739
+ /* Toast after Uiverse.io alexruix (MIT): it slides in; the bar shows how long it stays. */
678
740
  .toast {
679
741
  position: fixed;
680
742
  bottom: 1.25rem;
@@ -684,15 +746,50 @@
684
746
  color: var(--fg);
685
747
  border: 1px solid var(--line);
686
748
  box-shadow: var(--shadow);
687
- padding: 0.55rem 1.1rem;
749
+ padding: 0.6rem 1.1rem 0.6rem 0.85rem;
688
750
  border-radius: 10px;
689
751
  font-size: 0.85rem;
690
752
  z-index: 60;
691
753
  display: none;
692
- animation: fadeIn 0.15s ease-out;
754
+ align-items: center;
755
+ gap: 0.6rem;
756
+ overflow: hidden;
757
+ max-width: 90vw;
758
+ animation: fadeIn 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.2);
693
759
  }
760
+ .toast::before { content: ""; width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
761
+ .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; }
694
762
  .toast.err { border-color: var(--err); color: var(--err); }
695
- @keyframes fadeIn { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } }
763
+ .toast.err::before { background: var(--err); box-shadow: 0 0 0 3px var(--err-soft); }
764
+ .toast.err::after { background: var(--err); }
765
+ @keyframes fadeIn { from { opacity: 0; transform: translate(-50%, 12px); } to { opacity: 1; transform: translate(-50%, 0); } }
766
+ @keyframes toastbar { to { transform: scaleX(0); } }
767
+ /* Tooltip after Uiverse.io elijahgummer (MIT): a short fade and rise that
768
+ replaces the native title bubble. */
769
+ .tip {
770
+ position: fixed;
771
+ z-index: 70;
772
+ pointer-events: none;
773
+ background: var(--fg);
774
+ color: var(--bg);
775
+ font-size: 0.72rem;
776
+ font-weight: 500;
777
+ padding: 0.3rem 0.55rem;
778
+ border-radius: 6px;
779
+ white-space: nowrap;
780
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
781
+ opacity: 1;
782
+ translate: 0 0;
783
+ transition: opacity 0.15s, translate 0.2s cubic-bezier(0.3, 1.4, 0.6, 1), display 0.15s;
784
+ transition-behavior: allow-discrete;
785
+ }
786
+ .tip[hidden] { display: none; opacity: 0; translate: 0 4px; }
787
+ @starting-style { .tip { opacity: 0; translate: 0 4px; } }
788
+ @media (prefers-reduced-motion: reduce) {
789
+ *, ::before, ::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
790
+ button[aria-busy="true"]::before { animation-duration: 1.5s !important; }
791
+ .toast::after { animation-duration: 3.2s !important; }
792
+ }
696
793
 
697
794
  /* Test Result Box in Modal */
698
795
  .test-result-box {
@@ -1117,7 +1214,8 @@
1117
1214
  </div>
1118
1215
  </div>
1119
1216
 
1120
- <div id="toast" class="toast"></div>
1217
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
1218
+ <div id="tip" class="tip" role="tooltip" hidden></div>
1121
1219
 
1122
1220
  <script>
1123
1221
  // State & Globals
@@ -1190,11 +1288,40 @@
1190
1288
  if (!toast) return;
1191
1289
  toast.textContent = msg;
1192
1290
  toast.className = 'toast' + (isErr ? ' err' : '');
1193
- toast.style.display = 'block';
1291
+ // Hiding first and reading a layout value restarts the entry animation and the timer bar.
1292
+ toast.style.display = 'none';
1293
+ void toast.offsetWidth;
1294
+ toast.style.display = 'flex';
1194
1295
  clearTimeout(toastTimeout);
1195
1296
  toastTimeout = setTimeout(() => { toast.style.display = 'none'; }, 3200);
1196
1297
  }
1197
1298
 
1299
+ // The title moves to data-tip on first hover, so the native bubble does not show as well.
1300
+ // An input keeps its title: the browser shows it in the validation message.
1301
+ const tip = document.getElementById('tip');
1302
+ function showTip(e) {
1303
+ const n = e.target.closest && e.target.closest('[title]:not(input), [data-tip]');
1304
+ if (!n) return;
1305
+ if (n.title) {
1306
+ n.dataset.tip = n.title;
1307
+ n.removeAttribute('title');
1308
+ if (!n.hasAttribute('aria-label')) n.setAttribute(n.textContent.trim() ? 'aria-description' : 'aria-label', n.dataset.tip);
1309
+ }
1310
+ tip.textContent = n.dataset.tip;
1311
+ tip.hidden = false;
1312
+ const r = n.getBoundingClientRect(), w = tip.offsetWidth, h = tip.offsetHeight;
1313
+ tip.style.left = Math.max(8, Math.min(r.left + r.width / 2 - w / 2, innerWidth - w - 8)) + 'px';
1314
+ tip.style.top = (r.top - h - 8 < 8 ? r.bottom + 8 : r.top - h - 8) + 'px';
1315
+ }
1316
+ const hideTip = () => { tip.hidden = true; };
1317
+ document.addEventListener('pointerover', showTip);
1318
+ document.addEventListener('focusin', showTip);
1319
+ document.addEventListener('pointerout', e => {
1320
+ const n = e.target.closest && e.target.closest('[data-tip]');
1321
+ if (n && !n.contains(e.relatedTarget)) hideTip();
1322
+ });
1323
+ for (const ev of ['focusout', 'click', 'scroll']) addEventListener(ev, hideTip, true);
1324
+
1198
1325
  // Modal Control
1199
1326
  function openProfileModal(isNew = false) {
1200
1327
  const modal = document.getElementById('profile-modal-overlay');
@@ -1990,6 +2117,7 @@
1990
2117
 
1991
2118
  btn.innerText = 'Testing...';
1992
2119
  btn.disabled = true;
2120
+ btn.setAttribute('aria-busy', 'true');
1993
2121
  box.style.display = 'none';
1994
2122
 
1995
2123
  try {
@@ -2040,6 +2168,7 @@
2040
2168
  } finally {
2041
2169
  btn.innerText = 'Test Connection';
2042
2170
  btn.disabled = false;
2171
+ btn.removeAttribute('aria-busy');
2043
2172
  }
2044
2173
  }
2045
2174
 
@@ -2324,7 +2453,7 @@
2324
2453
 
2325
2454
  async function refreshCatalogUpstream() {
2326
2455
  const btn = document.getElementById('btn-sync-catalog');
2327
- if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; }
2456
+ if (btn) { btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Syncing...'; }
2328
2457
  showToast('Refreshing model catalog from upstream...');
2329
2458
  try {
2330
2459
  const res = await api('/api/catalog/refresh', { method: 'POST' });
@@ -2338,7 +2467,7 @@
2338
2467
  } catch (err) {
2339
2468
  showToast('Network error syncing catalog', true);
2340
2469
  } finally {
2341
- if (btn) { btn.disabled = false; btn.textContent = '⚡ Sync Models with Upstream'; }
2470
+ if (btn) { btn.disabled = false; btn.removeAttribute('aria-busy'); btn.textContent = '⚡ Sync Models with Upstream'; }
2342
2471
  }
2343
2472
  }
2344
2473