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.
- package/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/proxy.mjs +14 -4
- package/switch.cmd +2 -2
- package/tests/cdp.mjs +278 -0
- package/tests/dashboard-fixture.mjs +167 -0
- package/tests/dashboard.e2e.test.mjs +670 -0
- package/tests/gateway.e2e.test.mjs +27 -0
- package/tests/ui.test.mjs +1 -1
- package/ui.html +36 -14
|
@@ -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\)
|
|
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)
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
2052
|
-
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
|
}
|