llm-switcher 1.2.5 → 1.2.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.
@@ -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/tests/ui.test.mjs CHANGED
@@ -41,7 +41,7 @@ test('Enter in a profile field does not submit the form', () => {
41
41
 
42
42
  test('the overlay closes the dialog only for a press that started on the overlay', () => {
43
43
  assert.match(html, /id="profile-modal-overlay"[^>]*onmousedown="overlayPressed = event\.target === this"/);
44
- assert.match(html, /id="profile-modal-overlay"[^>]*onclick="if \(overlayPressed && event\.target === this\) closeProfileModal\(\)"/);
44
+ assert.match(html, /id="profile-modal-overlay"[^>]*onclick="if \(overlayPressed && event\.target === this\) requestCloseProfileModal\(\)"/);
45
45
  });
46
46
 
47
47
  test('Escape inside a dialog field is left to the field', () => {
package/ui.html CHANGED
@@ -1086,17 +1086,17 @@
1086
1086
  </div>
1087
1087
 
1088
1088
  <!-- Profile Modal Editor -->
1089
- <div id="profile-modal-overlay" class="overlay" style="display:none;" onmousedown="overlayPressed = event.target === this" onclick="if (overlayPressed && event.target === this) closeProfileModal()">
1089
+ <div id="profile-modal-overlay" class="overlay" style="display:none;" onmousedown="overlayPressed = event.target === this" onclick="if (overlayPressed && event.target === this) requestCloseProfileModal()">
1090
1090
  <div class="modal" role="dialog" aria-modal="true" aria-labelledby="form-title">
1091
1091
  <div class="mh">
1092
1092
  <div>
1093
1093
  <strong id="form-title">Edit Provider Profile</strong>
1094
1094
  <span id="active-tag" style="font-size:0.75rem; color:var(--ok); margin-left:0.5rem; font-weight:600;"></span>
1095
1095
  </div>
1096
- <button class="icon" type="button" onclick="closeProfileModal()" aria-label="Close dialog">✕</button>
1096
+ <button class="icon" type="button" onclick="requestCloseProfileModal()" aria-label="Close dialog">✕</button>
1097
1097
  </div>
1098
1098
 
1099
- <form id="profile-form" onsubmit="saveProfile(event)" onkeydown="blockImplicitSubmit(event)" style="display:flex; flex-direction:column; overflow:hidden;">
1099
+ <form id="profile-form" onsubmit="saveProfile(event)" onkeydown="blockImplicitSubmit(event)" oninput="formDirty = true" onchange="formDirty = true" style="display:flex; flex-direction:column; overflow:hidden;">
1100
1100
  <!-- Modal Inset Tabs -->
1101
1101
  <div style="padding:0.85rem 1.25rem 0.5rem; background:var(--card2);">
1102
1102
  <div class="tabs">
@@ -1206,7 +1206,7 @@
1206
1206
  </div>
1207
1207
  <div style="display:flex; gap:0.6rem;">
1208
1208
  <button type="button" id="btn-test-conn" class="btn btn-outline" onclick="testConnection()">Test Connection</button>
1209
- <button type="button" class="btn btn-outline" onclick="closeProfileModal()">Cancel</button>
1209
+ <button type="button" class="btn btn-outline" onclick="requestCloseProfileModal()">Cancel</button>
1210
1210
  <button type="submit" class="btn btn-primary">Save Profile</button>
1211
1211
  </div>
1212
1212
  </div>
@@ -1334,6 +1334,14 @@
1334
1334
  function closeProfileModal() {
1335
1335
  const modal = document.getElementById('profile-modal-overlay');
1336
1336
  if (modal) modal.style.display = 'none';
1337
+ formDirty = false;
1338
+ }
1339
+
1340
+ // The close button, Cancel, the dark area and Escape. A save or a delete calls closeProfileModal
1341
+ // directly, because by then nothing is left to lose.
1342
+ function requestCloseProfileModal() {
1343
+ if (formDirty && !confirm('Discard unsaved changes?')) return;
1344
+ closeProfileModal();
1337
1345
  }
1338
1346
 
1339
1347
  // A text selection that starts in a field and ends on the overlay also clicks the overlay.
@@ -1349,7 +1357,7 @@
1349
1357
  if (e.key === 'Escape' && !e.target.closest?.('input, select, textarea')) {
1350
1358
  const modal = document.getElementById('profile-modal-overlay');
1351
1359
  if (modal && modal.style.display !== 'none') {
1352
- closeProfileModal();
1360
+ requestCloseProfileModal();
1353
1361
  }
1354
1362
  }
1355
1363
  });
@@ -1405,7 +1413,7 @@
1405
1413
  }
1406
1414
  actionsEl.innerHTML = `
1407
1415
  <button class="btn btn-primary" onclick="openProfileModal(true)">+ Add Profile</button>
1408
- <button class="btn btn-outline" id="btn-toggle" onclick="toggleProxy()">${activeTargets.length > 0 ? 'Use official endpoints' : 'Activate compatible routes'}</button>
1416
+ <button class="btn btn-outline" id="btn-toggle-routes" onclick="toggleProxy()">${activeTargets.length > 0 ? 'Use official endpoints' : 'Activate compatible routes'}</button>
1409
1417
  <button class="btn btn-outline" onclick="loadStatus()">Refresh</button>
1410
1418
  `;
1411
1419
  } else if (route === 'models') {
@@ -1856,13 +1864,13 @@
1856
1864
  const data = await res.json();
1857
1865
  if (data.success) {
1858
1866
  showToast('Updated ' + target + ' routing');
1859
- await loadStatus();
1860
1867
  } else {
1861
1868
  showToast(data.error || 'Failed to switch target', true);
1862
1869
  }
1863
1870
  } catch (err) {
1864
1871
  showToast('Network error switching target', true);
1865
1872
  }
1873
+ await loadStatus();
1866
1874
  }
1867
1875
 
1868
1876
  async function toggleProfileAllCli(key, enable) {
@@ -1876,19 +1884,20 @@
1876
1884
  const data = await res.json();
1877
1885
  if (data.success) {
1878
1886
  showToast(key + (enable ? ' activated' : ' deactivated'));
1879
- await loadStatus();
1880
1887
  } else {
1881
1888
  showToast(data.error || 'Failed to toggle profile', true);
1882
1889
  }
1883
1890
  } catch (err) {
1884
1891
  showToast('Network error toggling profile', true);
1885
1892
  }
1893
+ await loadStatus();
1886
1894
  }
1887
1895
 
1888
1896
  function loadProfileToForm(key) {
1889
1897
  const p = state.config?.profiles?.[key];
1890
1898
  if (!p) return;
1891
1899
  formLoadedKey = key;
1900
+ formDirty = false;
1892
1901
  currentProfileBuffer = JSON.parse(JSON.stringify(p));
1893
1902
 
1894
1903
  document.getElementById('form-title').textContent = 'Edit Profile: ' + p.name;
@@ -1916,6 +1925,7 @@
1916
1925
 
1917
1926
  function initNewProfile() {
1918
1927
  creatingNew = true;
1928
+ formDirty = false;
1919
1929
  currentlyEditingKey = '';
1920
1930
  formLoadedKey = null;
1921
1931
  currentProfileBuffer = {
@@ -1977,7 +1987,7 @@
1977
1987
  vertex: {
1978
1988
  name: 'Google Vertex AI',
1979
1989
  baseURL: 'https://us-central1-aiplatform.googleapis.com/v1',
1980
- tool: 'anthropic',
1990
+ tool: 'claude',
1981
1991
  outFormat: 'vertex',
1982
1992
  mode: 'convert',
1983
1993
  thinkingMode: 'auto',
@@ -2007,6 +2017,7 @@
2007
2017
  document.getElementById('p-mode').value = t.mode;
2008
2018
  document.getElementById('p-thinking-mode').value = t.thinkingMode;
2009
2019
  currentProfileBuffer = { ...currentProfileBuffer, ...t };
2020
+ formDirty = true;
2010
2021
  renderModelSlots(t.tool || 'auto', t);
2011
2022
  showToast('Template "' + t.name + '" applied');
2012
2023
  }
@@ -2024,13 +2035,13 @@
2024
2035
  const data = await res.json();
2025
2036
  if (data.success) {
2026
2037
  showToast(isAnyActive ? 'Using official endpoints' : 'Gateway activated');
2027
- await loadStatus();
2028
2038
  } else {
2029
2039
  showToast(data.error || 'Failed to toggle routes', true);
2030
2040
  }
2031
2041
  } catch (err) {
2032
2042
  showToast('Network error toggling routes', true);
2033
2043
  }
2044
+ await loadStatus();
2034
2045
  }
2035
2046
 
2036
2047
  async function saveProfile(e) {
@@ -2039,19 +2050,29 @@
2039
2050
  if (!key) return;
2040
2051
 
2041
2052
  collectCurrentSlotValues();
2053
+ if (creatingNew && state.config?.profiles?.[key]) {
2054
+ showToast('Profile "' + key + '" already exists. Use another key, or edit that profile.', true);
2055
+ return;
2056
+ }
2042
2057
  const existing = state.config?.profiles?.[key] || {};
2043
2058
  const keyVal = document.getElementById('p-key-val').value.trim();
2044
2059
 
2045
2060
  const profilePayload = {
2046
2061
  name: document.getElementById('p-name').value.trim(),
2047
2062
  baseURL: document.getElementById('p-url').value.trim(),
2048
- tool: document.getElementById('p-tool').value,
2063
+ // "auto" in the list means a profile for both tools, which config.json stores as null.
2064
+ tool: document.getElementById('p-tool').value === 'auto' ? null : document.getElementById('p-tool').value,
2049
2065
  outFormat: document.getElementById('p-outformat').value,
2050
2066
  mode: document.getElementById('p-mode').value,
2051
- thinkingMode: document.getElementById('p-thinking-mode').value,
2052
- defaultModels: { ...(existing.defaultModels || {}), ...(currentProfileBuffer?.defaultModels || {}) },
2053
- model1M: { ...(existing.model1M || {}), ...(currentProfileBuffer?.model1M || {}) }
2067
+ // The buffer already holds what the profile had, minus what the person emptied.
2068
+ defaultModels: { ...(currentProfileBuffer?.defaultModels || {}) }
2054
2069
  };
2070
+ // A save with no edit must leave the profile as it was: write a default only where the profile
2071
+ // already had the key.
2072
+ const model1M = { ...(currentProfileBuffer?.model1M || {}) };
2073
+ if (Object.keys(model1M).length || existing.model1M) profilePayload.model1M = model1M;
2074
+ const thinkingMode = document.getElementById('p-thinking-mode').value;
2075
+ if (thinkingMode !== 'auto' || existing.thinkingMode) profilePayload.thinkingMode = thinkingMode;
2055
2076
 
2056
2077
  if (keyVal === MASKED_KEY) {
2057
2078
  profilePayload.apiKey = existing.apiKey || '';
@@ -2356,6 +2377,7 @@
2356
2377
  const model = combo?.matches?.[Number(li.dataset.i)];
2357
2378
  if (!model) return;
2358
2379
  combo.querySelector('input').value = model;
2380
+ formDirty = true;
2359
2381
  closeCombo(combo);
2360
2382
  update1mWarnings();
2361
2383
  }