llm-switcher 1.2.6 → 1.2.8
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/.impeccable/hook.cache.json +1 -0
- package/CHANGELOG.md +57 -0
- package/README.md +74 -4
- package/README.vi.md +75 -4
- package/blindfold/blindfold.mjs +2 -1
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
- package/docs/cross-platform.md +3 -1
- package/docs/response-matrix.json +1130 -1130
- package/hook-status.mjs +51 -0
- package/notify-route.ps1 +46 -0
- package/package.json +2 -2
- package/plugin.mjs +204 -0
- package/proxy.mjs +8 -3
- package/service.mjs +1 -1
- package/shim.mjs +28 -2
- package/skills/llm-switcher/SKILL.md +93 -93
- package/state.mjs +41 -5
- package/switch +0 -0
- package/switch.cmd +2 -2
- package/switch.mjs +53 -2
- package/tests/blindfold.test.mjs +1 -1
- package/tests/cdp.mjs +297 -0
- package/tests/dashboard-fixture.mjs +167 -0
- package/tests/dashboard.e2e.test.mjs +786 -0
- package/tests/gateway.e2e.test.mjs +1 -1
- package/tests/helpers.mjs +24 -24
- package/tests/hook-status.test.mjs +119 -0
- package/tests/live-optimizer-interop.mjs +205 -205
- package/tests/plugin.test.mjs +132 -0
- package/tests/real-user-sim.test.mjs +2 -2
- package/tests/service.test.mjs +2 -0
- package/tests/shim.test.mjs +112 -0
- package/tests/state.test.mjs +130 -5
- package/tests/ui.test.mjs +1 -1
- package/ui.html +121 -20
package/ui.html
CHANGED
|
@@ -811,6 +811,10 @@
|
|
|
811
811
|
position: sticky;
|
|
812
812
|
top: 0;
|
|
813
813
|
height: auto;
|
|
814
|
+
/* The base rule makes this a scroll container, and a grid item that scrolls has an automatic
|
|
815
|
+
minimum size of zero. The row then collapsed to the padding (16 px measured) and the nav
|
|
816
|
+
was drawn outside it, so no link could be pressed. Reset it, or the height is ignored. */
|
|
817
|
+
overflow: visible;
|
|
814
818
|
flex-direction: row;
|
|
815
819
|
flex-wrap: wrap;
|
|
816
820
|
align-items: center;
|
|
@@ -1086,17 +1090,17 @@
|
|
|
1086
1090
|
</div>
|
|
1087
1091
|
|
|
1088
1092
|
<!-- 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)
|
|
1093
|
+
<div id="profile-modal-overlay" class="overlay" style="display:none;" onmousedown="overlayPressed = event.target === this" onclick="if (overlayPressed && event.target === this) requestCloseProfileModal()">
|
|
1090
1094
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="form-title">
|
|
1091
1095
|
<div class="mh">
|
|
1092
1096
|
<div>
|
|
1093
1097
|
<strong id="form-title">Edit Provider Profile</strong>
|
|
1094
1098
|
<span id="active-tag" style="font-size:0.75rem; color:var(--ok); margin-left:0.5rem; font-weight:600;"></span>
|
|
1095
1099
|
</div>
|
|
1096
|
-
<button class="icon" type="button" onclick="
|
|
1100
|
+
<button class="icon" type="button" onclick="requestCloseProfileModal()" aria-label="Close dialog">✕</button>
|
|
1097
1101
|
</div>
|
|
1098
1102
|
|
|
1099
|
-
<form id="profile-form" onsubmit="saveProfile(event)" onkeydown="blockImplicitSubmit(event)" style="display:flex; flex-direction:column; overflow:hidden;">
|
|
1103
|
+
<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
1104
|
<!-- Modal Inset Tabs -->
|
|
1101
1105
|
<div style="padding:0.85rem 1.25rem 0.5rem; background:var(--card2);">
|
|
1102
1106
|
<div class="tabs">
|
|
@@ -1206,7 +1210,7 @@
|
|
|
1206
1210
|
</div>
|
|
1207
1211
|
<div style="display:flex; gap:0.6rem;">
|
|
1208
1212
|
<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="
|
|
1213
|
+
<button type="button" class="btn btn-outline" onclick="requestCloseProfileModal()">Cancel</button>
|
|
1210
1214
|
<button type="submit" class="btn btn-primary">Save Profile</button>
|
|
1211
1215
|
</div>
|
|
1212
1216
|
</div>
|
|
@@ -1323,17 +1327,51 @@
|
|
|
1323
1327
|
for (const ev of ['focusout', 'click', 'scroll']) addEventListener(ev, hideTip, true);
|
|
1324
1328
|
|
|
1325
1329
|
// Modal Control
|
|
1330
|
+
|
|
1331
|
+
// A result belongs to the profile it was measured on. Cleared on every open, because a dialog
|
|
1332
|
+
// that opens showing "HTTP 200 OK" from the profile before it says the wrong thing about this one.
|
|
1333
|
+
// Empties the three fields, never the box: they are static markup, and innerHTML would delete
|
|
1334
|
+
// them for the rest of the session.
|
|
1335
|
+
function clearTestResult() {
|
|
1336
|
+
const box = document.getElementById('test-result');
|
|
1337
|
+
if (!box) return;
|
|
1338
|
+
for (const id of ['test-status-badge', 'test-latency', 'test-sample']) {
|
|
1339
|
+
const el = document.getElementById(id);
|
|
1340
|
+
if (el) el.textContent = '';
|
|
1341
|
+
}
|
|
1342
|
+
document.getElementById('test-status-badge')?.setAttribute('class', 'badge');
|
|
1343
|
+
box.className = 'test-result-box';
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1326
1346
|
function openProfileModal(isNew = false) {
|
|
1327
1347
|
const modal = document.getElementById('profile-modal-overlay');
|
|
1328
1348
|
if (modal) modal.style.display = 'grid';
|
|
1349
|
+
clearTestResult();
|
|
1329
1350
|
if (isNew) {
|
|
1330
1351
|
initNewProfile();
|
|
1331
1352
|
}
|
|
1353
|
+
// Without this the focus stays on the button behind the overlay, so the first Tab walks into
|
|
1354
|
+
// the page under the dialog instead of through the dialog.
|
|
1355
|
+
const first = modal?.querySelector('input:not([readonly]):not([disabled]), select, textarea, button');
|
|
1356
|
+
first?.focus();
|
|
1332
1357
|
}
|
|
1333
1358
|
|
|
1334
1359
|
function closeProfileModal() {
|
|
1335
1360
|
const modal = document.getElementById('profile-modal-overlay');
|
|
1336
1361
|
if (modal) modal.style.display = 'none';
|
|
1362
|
+
formDirty = false;
|
|
1363
|
+
// render() reads this to draw the ring and the "Editing Profile" label. Left set, a card stayed
|
|
1364
|
+
// marked as being edited after Escape, Cancel or a press on the dark area.
|
|
1365
|
+
currentlyEditingKey = '';
|
|
1366
|
+
formLoadedKey = null;
|
|
1367
|
+
render();
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// The close button, Cancel, the dark area and Escape. A save or a delete calls closeProfileModal
|
|
1371
|
+
// directly, because by then nothing is left to lose.
|
|
1372
|
+
function requestCloseProfileModal() {
|
|
1373
|
+
if (formDirty && !confirm('Discard unsaved changes?')) return;
|
|
1374
|
+
closeProfileModal();
|
|
1337
1375
|
}
|
|
1338
1376
|
|
|
1339
1377
|
// A text selection that starts in a field and ends on the overlay also clicks the overlay.
|
|
@@ -1349,7 +1387,7 @@
|
|
|
1349
1387
|
if (e.key === 'Escape' && !e.target.closest?.('input, select, textarea')) {
|
|
1350
1388
|
const modal = document.getElementById('profile-modal-overlay');
|
|
1351
1389
|
if (modal && modal.style.display !== 'none') {
|
|
1352
|
-
|
|
1390
|
+
requestCloseProfileModal();
|
|
1353
1391
|
}
|
|
1354
1392
|
}
|
|
1355
1393
|
});
|
|
@@ -1405,7 +1443,7 @@
|
|
|
1405
1443
|
}
|
|
1406
1444
|
actionsEl.innerHTML = `
|
|
1407
1445
|
<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>
|
|
1446
|
+
<button class="btn btn-outline" id="btn-toggle-routes" onclick="toggleProxy()">${activeTargets.length > 0 ? 'Use official endpoints' : 'Activate compatible routes'}</button>
|
|
1409
1447
|
<button class="btn btn-outline" onclick="loadStatus()">Refresh</button>
|
|
1410
1448
|
`;
|
|
1411
1449
|
} else if (route === 'models') {
|
|
@@ -1856,13 +1894,13 @@
|
|
|
1856
1894
|
const data = await res.json();
|
|
1857
1895
|
if (data.success) {
|
|
1858
1896
|
showToast('Updated ' + target + ' routing');
|
|
1859
|
-
await loadStatus();
|
|
1860
1897
|
} else {
|
|
1861
1898
|
showToast(data.error || 'Failed to switch target', true);
|
|
1862
1899
|
}
|
|
1863
1900
|
} catch (err) {
|
|
1864
1901
|
showToast('Network error switching target', true);
|
|
1865
1902
|
}
|
|
1903
|
+
await loadStatus();
|
|
1866
1904
|
}
|
|
1867
1905
|
|
|
1868
1906
|
async function toggleProfileAllCli(key, enable) {
|
|
@@ -1876,19 +1914,20 @@
|
|
|
1876
1914
|
const data = await res.json();
|
|
1877
1915
|
if (data.success) {
|
|
1878
1916
|
showToast(key + (enable ? ' activated' : ' deactivated'));
|
|
1879
|
-
await loadStatus();
|
|
1880
1917
|
} else {
|
|
1881
1918
|
showToast(data.error || 'Failed to toggle profile', true);
|
|
1882
1919
|
}
|
|
1883
1920
|
} catch (err) {
|
|
1884
1921
|
showToast('Network error toggling profile', true);
|
|
1885
1922
|
}
|
|
1923
|
+
await loadStatus();
|
|
1886
1924
|
}
|
|
1887
1925
|
|
|
1888
1926
|
function loadProfileToForm(key) {
|
|
1889
1927
|
const p = state.config?.profiles?.[key];
|
|
1890
1928
|
if (!p) return;
|
|
1891
1929
|
formLoadedKey = key;
|
|
1930
|
+
formDirty = false;
|
|
1892
1931
|
currentProfileBuffer = JSON.parse(JSON.stringify(p));
|
|
1893
1932
|
|
|
1894
1933
|
document.getElementById('form-title').textContent = 'Edit Profile: ' + p.name;
|
|
@@ -1916,6 +1955,7 @@
|
|
|
1916
1955
|
|
|
1917
1956
|
function initNewProfile() {
|
|
1918
1957
|
creatingNew = true;
|
|
1958
|
+
formDirty = false;
|
|
1919
1959
|
currentlyEditingKey = '';
|
|
1920
1960
|
formLoadedKey = null;
|
|
1921
1961
|
currentProfileBuffer = {
|
|
@@ -1977,7 +2017,7 @@
|
|
|
1977
2017
|
vertex: {
|
|
1978
2018
|
name: 'Google Vertex AI',
|
|
1979
2019
|
baseURL: 'https://us-central1-aiplatform.googleapis.com/v1',
|
|
1980
|
-
tool: '
|
|
2020
|
+
tool: 'claude',
|
|
1981
2021
|
outFormat: 'vertex',
|
|
1982
2022
|
mode: 'convert',
|
|
1983
2023
|
thinkingMode: 'auto',
|
|
@@ -2007,6 +2047,7 @@
|
|
|
2007
2047
|
document.getElementById('p-mode').value = t.mode;
|
|
2008
2048
|
document.getElementById('p-thinking-mode').value = t.thinkingMode;
|
|
2009
2049
|
currentProfileBuffer = { ...currentProfileBuffer, ...t };
|
|
2050
|
+
formDirty = true;
|
|
2010
2051
|
renderModelSlots(t.tool || 'auto', t);
|
|
2011
2052
|
showToast('Template "' + t.name + '" applied');
|
|
2012
2053
|
}
|
|
@@ -2024,13 +2065,13 @@
|
|
|
2024
2065
|
const data = await res.json();
|
|
2025
2066
|
if (data.success) {
|
|
2026
2067
|
showToast(isAnyActive ? 'Using official endpoints' : 'Gateway activated');
|
|
2027
|
-
await loadStatus();
|
|
2028
2068
|
} else {
|
|
2029
2069
|
showToast(data.error || 'Failed to toggle routes', true);
|
|
2030
2070
|
}
|
|
2031
2071
|
} catch (err) {
|
|
2032
2072
|
showToast('Network error toggling routes', true);
|
|
2033
2073
|
}
|
|
2074
|
+
await loadStatus();
|
|
2034
2075
|
}
|
|
2035
2076
|
|
|
2036
2077
|
async function saveProfile(e) {
|
|
@@ -2039,19 +2080,29 @@
|
|
|
2039
2080
|
if (!key) return;
|
|
2040
2081
|
|
|
2041
2082
|
collectCurrentSlotValues();
|
|
2083
|
+
if (creatingNew && state.config?.profiles?.[key]) {
|
|
2084
|
+
showToast('Profile "' + key + '" already exists. Use another key, or edit that profile.', true);
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2042
2087
|
const existing = state.config?.profiles?.[key] || {};
|
|
2043
2088
|
const keyVal = document.getElementById('p-key-val').value.trim();
|
|
2044
2089
|
|
|
2045
2090
|
const profilePayload = {
|
|
2046
2091
|
name: document.getElementById('p-name').value.trim(),
|
|
2047
2092
|
baseURL: document.getElementById('p-url').value.trim(),
|
|
2048
|
-
|
|
2093
|
+
// "auto" in the list means a profile for both tools, which config.json stores as null.
|
|
2094
|
+
tool: document.getElementById('p-tool').value === 'auto' ? null : document.getElementById('p-tool').value,
|
|
2049
2095
|
outFormat: document.getElementById('p-outformat').value,
|
|
2050
2096
|
mode: document.getElementById('p-mode').value,
|
|
2051
|
-
|
|
2052
|
-
defaultModels: { ...(
|
|
2053
|
-
model1M: { ...(existing.model1M || {}), ...(currentProfileBuffer?.model1M || {}) }
|
|
2097
|
+
// The buffer already holds what the profile had, minus what the person emptied.
|
|
2098
|
+
defaultModels: { ...(currentProfileBuffer?.defaultModels || {}) }
|
|
2054
2099
|
};
|
|
2100
|
+
// A save with no edit must leave the profile as it was: write a default only where the profile
|
|
2101
|
+
// already had the key.
|
|
2102
|
+
const model1M = { ...(currentProfileBuffer?.model1M || {}) };
|
|
2103
|
+
if (Object.keys(model1M).length || existing.model1M) profilePayload.model1M = model1M;
|
|
2104
|
+
const thinkingMode = document.getElementById('p-thinking-mode').value;
|
|
2105
|
+
if (thinkingMode !== 'auto' || existing.thinkingMode) profilePayload.thinkingMode = thinkingMode;
|
|
2055
2106
|
|
|
2056
2107
|
if (keyVal === MASKED_KEY) {
|
|
2057
2108
|
profilePayload.apiKey = existing.apiKey || '';
|
|
@@ -2065,6 +2116,15 @@
|
|
|
2065
2116
|
if (existing.publicModels) profilePayload.publicModels = existing.publicModels;
|
|
2066
2117
|
|
|
2067
2118
|
showToast('Saving profile "' + key + '"...');
|
|
2119
|
+
// A double-click sent the save twice. The same pattern as the catalog sync button: disabled
|
|
2120
|
+
// while the request runs, re-enabled in `finally` so a failed save can be tried again.
|
|
2121
|
+
const submit = e.target?.querySelector?.('button[type="submit"]')
|
|
2122
|
+
|| document.querySelector('#profile-modal-overlay button[type="submit"]');
|
|
2123
|
+
if (submit) {
|
|
2124
|
+
if (submit.disabled) return;
|
|
2125
|
+
submit.disabled = true;
|
|
2126
|
+
submit.setAttribute('aria-busy', 'true');
|
|
2127
|
+
}
|
|
2068
2128
|
try {
|
|
2069
2129
|
const res = await api('/api/save-profile', {
|
|
2070
2130
|
method: 'POST',
|
|
@@ -2081,6 +2141,11 @@
|
|
|
2081
2141
|
}
|
|
2082
2142
|
} catch (err) {
|
|
2083
2143
|
showToast('Network error saving profile', true);
|
|
2144
|
+
} finally {
|
|
2145
|
+
if (submit) {
|
|
2146
|
+
submit.disabled = false;
|
|
2147
|
+
submit.removeAttribute('aria-busy');
|
|
2148
|
+
}
|
|
2084
2149
|
}
|
|
2085
2150
|
}
|
|
2086
2151
|
|
|
@@ -2356,6 +2421,7 @@
|
|
|
2356
2421
|
const model = combo?.matches?.[Number(li.dataset.i)];
|
|
2357
2422
|
if (!model) return;
|
|
2358
2423
|
combo.querySelector('input').value = model;
|
|
2424
|
+
formDirty = true;
|
|
2359
2425
|
closeCombo(combo);
|
|
2360
2426
|
update1mWarnings();
|
|
2361
2427
|
}
|
|
@@ -2412,8 +2478,15 @@
|
|
|
2412
2478
|
const ctModels = document.getElementById('ct-models');
|
|
2413
2479
|
if (ctModels) ctModels.textContent = claudeModels.length + codexModels.length;
|
|
2414
2480
|
|
|
2415
|
-
|
|
2416
|
-
|
|
2481
|
+
// `data-full` is what the search box restores when it is emptied again.
|
|
2482
|
+
for (const [id, text] of [
|
|
2483
|
+
['badge-claude-count', claudeModels.length + ' models · ' + claudeTiers.size + ' tiers'],
|
|
2484
|
+
['badge-codex-count', codexModels.length + ' models · ' + codexRoles.size + ' roles']
|
|
2485
|
+
]) {
|
|
2486
|
+
const el = document.getElementById(id);
|
|
2487
|
+
el.textContent = text;
|
|
2488
|
+
el.dataset.full = text;
|
|
2489
|
+
}
|
|
2417
2490
|
|
|
2418
2491
|
claudeContainer.innerHTML = claudeModels.map(m => `
|
|
2419
2492
|
<div class="row" style="padding:0.6rem 0.8rem; background:var(--card2); border-radius:8px;">
|
|
@@ -2443,12 +2516,40 @@
|
|
|
2443
2516
|
}
|
|
2444
2517
|
}
|
|
2445
2518
|
|
|
2519
|
+
// A filter that hides every row but keeps the header count reads as a broken page: the header
|
|
2520
|
+
// still promised 11 models over an empty list. So the count follows the filter, and an empty
|
|
2521
|
+
// result says so in words.
|
|
2446
2522
|
function filterCatalogView(q) {
|
|
2447
2523
|
const term = (q || '').toLowerCase();
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2524
|
+
for (const [list, badge, unit] of [
|
|
2525
|
+
['#catalog-claude-items', 'badge-claude-count', 'models'],
|
|
2526
|
+
['#catalog-codex-items', 'badge-codex-count', 'models']
|
|
2527
|
+
]) {
|
|
2528
|
+
const container = document.querySelector(list);
|
|
2529
|
+
if (!container) continue;
|
|
2530
|
+
let shown = 0;
|
|
2531
|
+
container.querySelectorAll('.row').forEach(row => {
|
|
2532
|
+
const hit = row.textContent.toLowerCase().includes(term);
|
|
2533
|
+
row.style.display = hit ? 'flex' : 'none';
|
|
2534
|
+
if (hit) shown++;
|
|
2535
|
+
});
|
|
2536
|
+
let empty = container.querySelector('.catalog-empty');
|
|
2537
|
+
if (!shown) {
|
|
2538
|
+
if (!empty) {
|
|
2539
|
+
empty = document.createElement('div');
|
|
2540
|
+
empty.className = 'catalog-empty hint';
|
|
2541
|
+
empty.style.padding = '0.8rem';
|
|
2542
|
+
container.appendChild(empty);
|
|
2543
|
+
}
|
|
2544
|
+
empty.textContent = term ? 'No model matches "' + term + '".' : 'No model in this list.';
|
|
2545
|
+
empty.style.display = 'block';
|
|
2546
|
+
} else if (empty) {
|
|
2547
|
+
empty.style.display = 'none';
|
|
2548
|
+
}
|
|
2549
|
+
const el = document.getElementById(badge);
|
|
2550
|
+
// `data-full` is the unfiltered text that the render wrote. An empty box restores it.
|
|
2551
|
+
if (el) el.textContent = term ? shown + ' ' + unit + ' shown' : (el.dataset.full || el.textContent);
|
|
2552
|
+
}
|
|
2452
2553
|
}
|
|
2453
2554
|
|
|
2454
2555
|
async function refreshCatalogUpstream() {
|