llm-switcher 1.2.2 → 1.2.4
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/README.md +7 -3
- package/README.vi.md +3 -3
- package/blindfold/certs.mjs +156 -0
- package/blindfold/make-certs.sh +3 -1
- package/catalog.mjs +132 -42
- package/docs/codex-blindfold.md +6 -4
- package/mcp.mjs +2 -2
- package/package.json +1 -1
- package/proxy.mjs +32 -12
- package/scripts/run-tests.mjs +3 -1
- package/state.mjs +88 -6
- package/switch.mjs +34 -9
- package/tests/blindfold-e2e.test.mjs +2 -2
- package/tests/blindfold-task5.test.mjs +1 -1
- package/tests/blindfold.task3.test.mjs +2 -2
- package/tests/blindfold.wire.test.mjs +3 -1
- package/tests/catalog.test.mjs +117 -19
- package/tests/certs.test.mjs +86 -0
- package/tests/codex-daemon.test.mjs +92 -0
- package/tests/gateway.e2e.test.mjs +36 -0
- package/tests/lifecycle.test.mjs +90 -57
- package/tests/make-certs.test.mjs +22 -0
- package/tests/real-user-sim.test.mjs +3 -0
- package/tests/shim.test.mjs +8 -3
- package/tests/state.test.mjs +8 -3
- package/tests/switch.test.mjs +30 -24
- package/tests/ui.test.mjs +110 -0
- package/tests/version.test.mjs +78 -0
- package/ui.html +325 -82
- package/version.mjs +53 -0
package/ui.html
CHANGED
|
@@ -177,6 +177,14 @@
|
|
|
177
177
|
letter-spacing: 0.02em;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
.update-notice {
|
|
181
|
+
display: grid; gap: 0.4rem; margin: 0 0.25rem 0.75rem; padding: 0.6rem 0.65rem;
|
|
182
|
+
border: 1px solid var(--warn); border-radius: 8px; background: var(--warn-soft); font-size: 0.75rem; color: var(--fg);
|
|
183
|
+
}
|
|
184
|
+
.update-notice[hidden] { display: none; }
|
|
185
|
+
.update-notice b { color: var(--warn); font-weight: 600; }
|
|
186
|
+
.update-notice code { font-family: var(--font-mono); font-size: 0.72rem; overflow-wrap: anywhere; color: var(--muted); }
|
|
187
|
+
|
|
180
188
|
aside nav { display: grid; gap: 0.25rem; }
|
|
181
189
|
aside a.nav-link {
|
|
182
190
|
display: flex;
|
|
@@ -520,6 +528,44 @@
|
|
|
520
528
|
.search svg { position: absolute; left: 0.65rem; top: 50%; transform: translateY(-50%); color: var(--faint); width: 15px; height: 15px; pointer-events: none; }
|
|
521
529
|
.search input { padding-left: 2rem; min-height: 32px; }
|
|
522
530
|
|
|
531
|
+
/* Model slot combobox: one listbox per slot, provider prefix dimmed, match in the accent. */
|
|
532
|
+
.combo { position: relative; }
|
|
533
|
+
.slot-1m-note { font-size: 0.72rem; color: var(--faint); }
|
|
534
|
+
/* Kept in the page when empty: a live region added later is often not read. */
|
|
535
|
+
.slot-1m-note:empty { margin-top: -0.25rem; }
|
|
536
|
+
.slot-1m-note.warn { color: var(--warn); }
|
|
537
|
+
.slot-1m-note.err { color: var(--err); }
|
|
538
|
+
.combo > input { padding-right: 2.25rem; font-family: var(--font-mono); font-size: 0.8rem; }
|
|
539
|
+
.combo-toggle {
|
|
540
|
+
position: absolute; right: 4px; top: 4px; width: 28px; height: 28px;
|
|
541
|
+
display: grid; place-items: center; padding: 0; min-height: 0;
|
|
542
|
+
border: 0; border-radius: 6px; background: transparent; color: var(--faint); cursor: pointer;
|
|
543
|
+
}
|
|
544
|
+
.combo-toggle:hover { color: var(--fg); background: var(--card2); }
|
|
545
|
+
.combo-toggle svg { width: 14px; height: 14px; transition: transform 0.15s ease; }
|
|
546
|
+
.combo[data-open] .combo-toggle svg { transform: rotate(180deg); }
|
|
547
|
+
.combo-list {
|
|
548
|
+
position: absolute; z-index: 30; left: 0; right: 0; top: calc(100% + 4px);
|
|
549
|
+
max-height: 15rem; overflow-y: auto; margin: 0; padding: 0.25rem; list-style: none;
|
|
550
|
+
background: var(--card2); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow);
|
|
551
|
+
}
|
|
552
|
+
.combo[data-up] .combo-list { top: auto; bottom: calc(100% + 4px); }
|
|
553
|
+
.combo-opt {
|
|
554
|
+
display: flex; align-items: baseline; gap: 0.5rem; padding: 0.4rem 0.55rem;
|
|
555
|
+
border-left: 2px solid transparent; border-radius: 6px; cursor: pointer;
|
|
556
|
+
font-family: var(--font-mono); font-size: 0.8rem; color: var(--fg);
|
|
557
|
+
}
|
|
558
|
+
.combo-opt .id { overflow-wrap: anywhere; }
|
|
559
|
+
.combo-opt .pfx { color: var(--muted); } /* --faint fails 4.5:1 on the selected row in light */
|
|
560
|
+
.combo-opt mark { background: transparent; color: var(--accent); font-weight: 600; }
|
|
561
|
+
.combo-opt:hover { background: var(--bg); }
|
|
562
|
+
.combo-opt[aria-selected="true"] { background: var(--accent-soft); border-left-color: var(--accent); }
|
|
563
|
+
.combo-opt .cur { margin-left: auto; flex: none; color: var(--accent); font-size: 0.7rem; }
|
|
564
|
+
.combo-empty { padding: 0.5rem 0.55rem; font-size: 0.78rem; color: var(--faint); }
|
|
565
|
+
.models-status { display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--faint); }
|
|
566
|
+
.models-status[data-state="error"] { color: var(--err); }
|
|
567
|
+
@media (prefers-reduced-motion: reduce) { .combo-toggle svg { transition: none; } }
|
|
568
|
+
|
|
523
569
|
/* Switch */
|
|
524
570
|
.switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; }
|
|
525
571
|
.switch input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; min-height: 0; }
|
|
@@ -716,7 +762,7 @@
|
|
|
716
762
|
<body>
|
|
717
763
|
<!-- Sidebar -->
|
|
718
764
|
<aside>
|
|
719
|
-
<a class="brand" href="#/routes"
|
|
765
|
+
<a class="brand" href="#/routes">
|
|
720
766
|
<span class="logo">
|
|
721
767
|
<svg viewBox="0 0 32 32" aria-hidden="true">
|
|
722
768
|
<defs>
|
|
@@ -732,9 +778,16 @@
|
|
|
732
778
|
<div>
|
|
733
779
|
<b>LLM Switcher</b>
|
|
734
780
|
<small>Edge Gateway · :<span class="gateway-port">3456</span></small>
|
|
781
|
+
<small id="app-version"></small>
|
|
735
782
|
</div>
|
|
736
783
|
</a>
|
|
737
784
|
|
|
785
|
+
<div id="update-notice" class="update-notice" role="status" hidden>
|
|
786
|
+
<b>Update available: v<span id="update-latest"></span></b>
|
|
787
|
+
<code id="update-command"></code>
|
|
788
|
+
<button type="button" class="btn btn-outline btn-sm" onclick="copyUpdateCommand()">Copy command</button>
|
|
789
|
+
</div>
|
|
790
|
+
|
|
738
791
|
<nav id="sideNav">
|
|
739
792
|
<a class="nav-link" href="#/routes" data-nav="routes">
|
|
740
793
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 12h16M12 4v16"/><circle cx="12" cy="12" r="8"/></svg>
|
|
@@ -802,7 +855,7 @@
|
|
|
802
855
|
<div class="meta">Native Anthropic Messages · <code>/v1/messages</code></div>
|
|
803
856
|
</div>
|
|
804
857
|
<div style="min-width:14rem;">
|
|
805
|
-
<select id="select-slot-claude" onchange="switchCliTarget('claude', this.value)">
|
|
858
|
+
<select id="select-slot-claude" aria-label="Claude Code route" onchange="switchCliTarget('claude', this.value)">
|
|
806
859
|
<option value="">Official endpoint</option>
|
|
807
860
|
</select>
|
|
808
861
|
</div>
|
|
@@ -817,7 +870,7 @@
|
|
|
817
870
|
<div class="meta">Responses WebSocket · <code>/v1/responses</code></div>
|
|
818
871
|
</div>
|
|
819
872
|
<div style="min-width:14rem;">
|
|
820
|
-
<select id="select-slot-codex" onchange="switchCliTarget('codex', this.value)">
|
|
873
|
+
<select id="select-slot-codex" aria-label="Codex route" onchange="switchCliTarget('codex', this.value)">
|
|
821
874
|
<option value="">Official endpoint</option>
|
|
822
875
|
</select>
|
|
823
876
|
</div>
|
|
@@ -936,7 +989,7 @@
|
|
|
936
989
|
</div>
|
|
937
990
|
|
|
938
991
|
<!-- Profile Modal Editor -->
|
|
939
|
-
<div id="profile-modal-overlay" class="overlay" style="display:none;" onclick="if(event.target===this) closeProfileModal()">
|
|
992
|
+
<div id="profile-modal-overlay" class="overlay" style="display:none;" onmousedown="overlayPressed = event.target === this" onclick="if (overlayPressed && event.target === this) closeProfileModal()">
|
|
940
993
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="form-title">
|
|
941
994
|
<div class="mh">
|
|
942
995
|
<div>
|
|
@@ -946,7 +999,7 @@
|
|
|
946
999
|
<button class="icon" type="button" onclick="closeProfileModal()" aria-label="Close dialog">✕</button>
|
|
947
1000
|
</div>
|
|
948
1001
|
|
|
949
|
-
<form id="profile-form" onsubmit="saveProfile(event)" style="display:flex; flex-direction:column; overflow:hidden;">
|
|
1002
|
+
<form id="profile-form" onsubmit="saveProfile(event)" onkeydown="blockImplicitSubmit(event)" style="display:flex; flex-direction:column; overflow:hidden;">
|
|
950
1003
|
<!-- Modal Inset Tabs -->
|
|
951
1004
|
<div style="padding:0.85rem 1.25rem 0.5rem; background:var(--card2);">
|
|
952
1005
|
<div class="tabs">
|
|
@@ -1000,6 +1053,7 @@
|
|
|
1000
1053
|
</label>
|
|
1001
1054
|
<label class="f">Outbound Format
|
|
1002
1055
|
<select id="p-outformat">
|
|
1056
|
+
<option value="">Auto (detect from the model)</option>
|
|
1003
1057
|
<option value="openai-chat">OpenAI Chat (/v1/chat/completions)</option>
|
|
1004
1058
|
<option value="anthropic">Anthropic Messages (/v1/messages)</option>
|
|
1005
1059
|
<option value="vertex">Google Vertex AI (/models/...)</option>
|
|
@@ -1031,20 +1085,12 @@
|
|
|
1031
1085
|
<span id="warn-1m-icon">⚠️</span> <span id="warn-1m-text">1M context flag is enabled</span>
|
|
1032
1086
|
</div>
|
|
1033
1087
|
|
|
1034
|
-
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
1088
|
+
<div style="display:flex; justify-content:space-between; align-items:center; gap:0.75rem;">
|
|
1035
1089
|
<h3 id="model-slot-heading" style="font-size:0.92rem; font-weight:600;">Model Slots Mapping</h3>
|
|
1036
|
-
<
|
|
1090
|
+
<div id="models-status" class="models-status" aria-live="polite"></div>
|
|
1037
1091
|
</div>
|
|
1038
1092
|
|
|
1039
1093
|
<div id="slot-fields" style="display:grid; gap:0.75rem;"></div>
|
|
1040
|
-
|
|
1041
|
-
<div id="model-browser" style="display:none; background:var(--card2); padding:0.85rem; border-radius:8px; border:1px solid var(--line);">
|
|
1042
|
-
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5rem;">
|
|
1043
|
-
<span id="model-count-label" class="hint">Loaded models</span>
|
|
1044
|
-
<input type="text" id="model-filter" placeholder="Search..." oninput="filterModels()" style="width:12rem; min-height:28px;">
|
|
1045
|
-
</div>
|
|
1046
|
-
<div id="model-items" style="max-height:8rem; overflow-y:auto; display:flex; flex-wrap:wrap; gap:0.35rem;"></div>
|
|
1047
|
-
</div>
|
|
1048
1094
|
</div>
|
|
1049
1095
|
|
|
1050
1096
|
<!-- Test Connection Box -->
|
|
@@ -1072,7 +1118,6 @@
|
|
|
1072
1118
|
</div>
|
|
1073
1119
|
|
|
1074
1120
|
<div id="toast" class="toast"></div>
|
|
1075
|
-
<datalist id="model-options"></datalist>
|
|
1076
1121
|
|
|
1077
1122
|
<script>
|
|
1078
1123
|
// State & Globals
|
|
@@ -1098,6 +1143,7 @@
|
|
|
1098
1143
|
let formLoadedKey = null;
|
|
1099
1144
|
let formDirty = false;
|
|
1100
1145
|
let loadedModels = [];
|
|
1146
|
+
let loadedLimits = {}; // model id -> { context, input, output }, as the gateway lists them
|
|
1101
1147
|
let currentProfileBuffer = null;
|
|
1102
1148
|
let renderedSlotFormat = 'auto';
|
|
1103
1149
|
let logRefreshTimer = null;
|
|
@@ -1163,9 +1209,17 @@
|
|
|
1163
1209
|
if (modal) modal.style.display = 'none';
|
|
1164
1210
|
}
|
|
1165
1211
|
|
|
1166
|
-
//
|
|
1212
|
+
// A text selection that starts in a field and ends on the overlay also clicks the overlay.
|
|
1213
|
+
let overlayPressed = false;
|
|
1214
|
+
|
|
1215
|
+
// Enter in a field would submit the form and save a half-typed value. Only Save Profile saves.
|
|
1216
|
+
function blockImplicitSubmit(e) {
|
|
1217
|
+
if (e.key === 'Enter' && e.target.tagName === 'INPUT' && e.target.type !== 'submit') e.preventDefault();
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Close modal on Escape. Inside a field, Escape belongs to the field (it closes the autocomplete list).
|
|
1167
1221
|
window.addEventListener('keydown', (e) => {
|
|
1168
|
-
if (e.key === 'Escape') {
|
|
1222
|
+
if (e.key === 'Escape' && !e.target.closest?.('input, select, textarea')) {
|
|
1169
1223
|
const modal = document.getElementById('profile-modal-overlay');
|
|
1170
1224
|
if (modal && modal.style.display !== 'none') {
|
|
1171
1225
|
closeProfileModal();
|
|
@@ -1265,6 +1319,7 @@
|
|
|
1265
1319
|
target.classList.add('active');
|
|
1266
1320
|
target.style.display = 'grid';
|
|
1267
1321
|
}
|
|
1322
|
+
if (tabId === 'tab-models') ensureModelsLoaded();
|
|
1268
1323
|
}
|
|
1269
1324
|
|
|
1270
1325
|
// Slot Configs
|
|
@@ -1387,13 +1442,21 @@
|
|
|
1387
1442
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
1388
1443
|
<label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
|
|
1389
1444
|
<label style="display:flex; align-items:center; gap:0.35rem; font-size:0.75rem; color:var(--accent); cursor:pointer;">
|
|
1390
|
-
<input type="checkbox" id="p-${s.id}-1m" style="width:13px; height:13px; min-height:0;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}>
|
|
1445
|
+
<input type="checkbox" id="p-${s.id}-1m" aria-label="1M context for ${escapeHtml(s.name)}" aria-describedby="p-${s.id}-1m-note" style="width:13px; height:13px; min-height:0;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}>
|
|
1391
1446
|
1M context
|
|
1392
1447
|
</label>
|
|
1393
1448
|
</div>
|
|
1394
|
-
<
|
|
1449
|
+
<div class="combo">
|
|
1450
|
+
<input type="text" id="p-${s.id}" oninput="update1mWarnings()" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="p-${s.id}-list" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
|
|
1451
|
+
<button type="button" class="combo-toggle" tabindex="-1" aria-label="Show models for ${escapeHtml(s.name)}">
|
|
1452
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg>
|
|
1453
|
+
</button>
|
|
1454
|
+
<ul class="combo-list" id="p-${s.id}-list" role="listbox" aria-label="${escapeHtml(s.name)} models" hidden></ul>
|
|
1455
|
+
</div>
|
|
1456
|
+
<div class="slot-1m-note" id="p-${s.id}-1m-note" role="status" aria-live="polite"></div>
|
|
1395
1457
|
`;
|
|
1396
1458
|
container.appendChild(slotRow);
|
|
1459
|
+
wireCombo(slotRow.querySelector('.combo'));
|
|
1397
1460
|
}
|
|
1398
1461
|
update1mWarnings();
|
|
1399
1462
|
}
|
|
@@ -1426,12 +1489,43 @@
|
|
|
1426
1489
|
return { defaultModels, model1M };
|
|
1427
1490
|
}
|
|
1428
1491
|
|
|
1492
|
+
// A window known to be under 1M locks the box; an unknown one only warns, since many
|
|
1493
|
+
// gateways list no limits at all.
|
|
1494
|
+
function contextVerdict(l) {
|
|
1495
|
+
const window = (l && (l.context || l.input)) || 0;
|
|
1496
|
+
if (!window) return { kind: 'unknown', window: 0 };
|
|
1497
|
+
return { kind: window >= 1000000 ? 'ok' : 'small', window };
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// 128000 reads as 128K and 262144 as 256K: a round decimal count keeps base 1000.
|
|
1501
|
+
function fmtTokens(n) {
|
|
1502
|
+
const b = n % 1000 === 0 ? 1000 : 1024;
|
|
1503
|
+
return n >= b * b ? +(n / b / b).toFixed(1) + 'M' : n >= b ? Math.round(n / b) + 'K' : String(n);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1429
1506
|
function update1mWarnings() {
|
|
1430
1507
|
const cfg = getActiveSlotConfig(renderedSlotFormat);
|
|
1431
1508
|
let any1m = false;
|
|
1432
1509
|
for (const s of cfg.slots) {
|
|
1433
1510
|
const chk = document.getElementById('p-' + s.id + '-1m');
|
|
1434
|
-
|
|
1511
|
+
const note = document.getElementById('p-' + s.id + '-1m-note');
|
|
1512
|
+
const model = document.getElementById('p-' + s.id)?.value.trim() || '';
|
|
1513
|
+
if (!chk) continue;
|
|
1514
|
+
const v = contextVerdict(model ? loadedLimits[model] : null);
|
|
1515
|
+
const locked = v.kind === 'small';
|
|
1516
|
+
if (locked) chk.checked = false;
|
|
1517
|
+
chk.disabled = locked;
|
|
1518
|
+
chk.closest('label').style.opacity = locked ? '0.5' : '';
|
|
1519
|
+
chk.closest('label').style.cursor = locked ? 'not-allowed' : 'pointer';
|
|
1520
|
+
if (note) {
|
|
1521
|
+
let text = '', cls = '';
|
|
1522
|
+
if (locked) { text = `Context ${fmtTokens(v.window)} is under 1M, so 1M context is off for this model.`; cls = 'err'; }
|
|
1523
|
+
else if (v.kind === 'ok') text = `Context ${fmtTokens(v.window)}.`;
|
|
1524
|
+
else if (chk.checked && model) { text = 'The gateway does not list this model\'s context. Make sure it takes 1M tokens.'; cls = 'warn'; }
|
|
1525
|
+
note.textContent = text;
|
|
1526
|
+
note.className = 'slot-1m-note' + (cls ? ' ' + cls : '');
|
|
1527
|
+
}
|
|
1528
|
+
if (chk.checked) any1m = true;
|
|
1435
1529
|
}
|
|
1436
1530
|
const warnCallout = document.getElementById('warn-1m-callout');
|
|
1437
1531
|
if (warnCallout) warnCallout.style.display = any1m ? 'inline-flex' : 'none';
|
|
@@ -1591,7 +1685,7 @@
|
|
|
1591
1685
|
</div>
|
|
1592
1686
|
</div>
|
|
1593
1687
|
<label class="switch" onclick="event.stopPropagation()">
|
|
1594
|
-
<input type="checkbox" ${isActiveAny ? 'checked' : ''} onchange="toggleProfileAllCli('${escapeHtml(key)}', this.checked)">
|
|
1688
|
+
<input type="checkbox" aria-label="Use ${escapeHtml(p.name)} (${escapeHtml(key)}) for every compatible CLI" ${isActiveAny ? 'checked' : ''} onchange="toggleProfileAllCli('${escapeHtml(key)}', this.checked)">
|
|
1595
1689
|
<span></span>
|
|
1596
1690
|
</label>
|
|
1597
1691
|
</div>
|
|
@@ -1633,7 +1727,7 @@
|
|
|
1633
1727
|
body: JSON.stringify({ target, profile: profileKey || null })
|
|
1634
1728
|
});
|
|
1635
1729
|
const data = await res.json();
|
|
1636
|
-
if (data.
|
|
1730
|
+
if (data.success) {
|
|
1637
1731
|
showToast('Updated ' + target + ' routing');
|
|
1638
1732
|
await loadStatus();
|
|
1639
1733
|
} else {
|
|
@@ -1653,7 +1747,7 @@
|
|
|
1653
1747
|
body: JSON.stringify({ profile: key, deactivate: !enable })
|
|
1654
1748
|
});
|
|
1655
1749
|
const data = await res.json();
|
|
1656
|
-
if (data.
|
|
1750
|
+
if (data.success) {
|
|
1657
1751
|
showToast(key + (enable ? ' activated' : ' deactivated'));
|
|
1658
1752
|
await loadStatus();
|
|
1659
1753
|
} else {
|
|
@@ -1678,7 +1772,7 @@
|
|
|
1678
1772
|
document.getElementById('p-key-val').value = p.apiKey ? MASKED_KEY : '';
|
|
1679
1773
|
const toolVal = p.tool || 'auto';
|
|
1680
1774
|
document.getElementById('p-tool').value = toolVal;
|
|
1681
|
-
document.getElementById('p-outformat').value = p.outFormat || '
|
|
1775
|
+
document.getElementById('p-outformat').value = p.outFormat || '';
|
|
1682
1776
|
document.getElementById('p-mode').value = p.mode || 'convert';
|
|
1683
1777
|
document.getElementById('p-thinking-mode').value = p.thinkingMode || 'auto';
|
|
1684
1778
|
document.getElementById('btn-delete').style.display = 'inline-flex';
|
|
@@ -1801,7 +1895,7 @@
|
|
|
1801
1895
|
body: JSON.stringify({ enabled: !isAnyActive })
|
|
1802
1896
|
});
|
|
1803
1897
|
const data = await res.json();
|
|
1804
|
-
if (data.
|
|
1898
|
+
if (data.success) {
|
|
1805
1899
|
showToast(isAnyActive ? 'Using official endpoints' : 'Gateway activated');
|
|
1806
1900
|
await loadStatus();
|
|
1807
1901
|
} else {
|
|
@@ -1851,7 +1945,7 @@
|
|
|
1851
1945
|
body: JSON.stringify({ key, profile: profilePayload })
|
|
1852
1946
|
});
|
|
1853
1947
|
const data = await res.json();
|
|
1854
|
-
if (data.
|
|
1948
|
+
if (data.success) {
|
|
1855
1949
|
showToast('Profile "' + key + '" saved successfully');
|
|
1856
1950
|
closeProfileModal();
|
|
1857
1951
|
await loadStatus();
|
|
@@ -1875,7 +1969,7 @@
|
|
|
1875
1969
|
body: JSON.stringify({ key })
|
|
1876
1970
|
});
|
|
1877
1971
|
const data = await res.json();
|
|
1878
|
-
if (data.
|
|
1972
|
+
if (data.success) {
|
|
1879
1973
|
showToast('Profile "' + key + '" deleted');
|
|
1880
1974
|
closeProfileModal();
|
|
1881
1975
|
await loadStatus();
|
|
@@ -1949,89 +2043,220 @@
|
|
|
1949
2043
|
}
|
|
1950
2044
|
}
|
|
1951
2045
|
|
|
2046
|
+
// Provider models for the slot comboboxes. The Model Slots tab loads them once per source.
|
|
2047
|
+
let modelsLoadedFor = '';
|
|
2048
|
+
let modelsRequest = 0;
|
|
2049
|
+
|
|
2050
|
+
function modelsSourceKey() {
|
|
2051
|
+
return [document.getElementById('p-url').value.trim(), formLoadedKey || '', document.getElementById('p-key-val').value.trim()].join('\n');
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
function setModelsStatus(stateName, text, action) {
|
|
2055
|
+
const el = document.getElementById('models-status');
|
|
2056
|
+
if (!el) return;
|
|
2057
|
+
el.dataset.state = stateName;
|
|
2058
|
+
el.innerHTML = `<span>${escapeHtml(text)}</span>`
|
|
2059
|
+
+ (action ? `<button type="button" class="btn btn-outline btn-sm" onclick="ensureModelsLoaded(true)">${action}</button>` : '');
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// The upstream error body is often JSON; show its message, not the raw text.
|
|
2063
|
+
function modelsError(raw) {
|
|
2064
|
+
let text = String(raw || 'no models returned');
|
|
2065
|
+
try { const j = JSON.parse(text); text = j?.error?.message || j?.error || j?.message || text; } catch {}
|
|
2066
|
+
return String(text).slice(0, 140);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
async function ensureModelsLoaded(force = false) {
|
|
2070
|
+
if (!force && modelsSourceKey() === modelsLoadedFor) return;
|
|
2071
|
+
await fetchModelList();
|
|
2072
|
+
}
|
|
2073
|
+
|
|
1952
2074
|
async function fetchModelList() {
|
|
1953
|
-
const btn = document.getElementById('btn-fetch-models');
|
|
1954
|
-
const browser = document.getElementById('model-browser');
|
|
1955
2075
|
const baseURL = document.getElementById('p-url').value.trim();
|
|
1956
2076
|
const apiKeyVal = document.getElementById('p-key-val').value.trim();
|
|
1957
2077
|
const apiKey = (apiKeyVal === MASKED_KEY) ? (state.config?.profiles?.[formLoadedKey]?.apiKey || '') : apiKeyVal;
|
|
2078
|
+
const source = modelsSourceKey();
|
|
2079
|
+
const request = ++modelsRequest;
|
|
1958
2080
|
|
|
1959
2081
|
if (!baseURL) {
|
|
1960
|
-
|
|
2082
|
+
loadedModels = [];
|
|
2083
|
+
modelsLoadedFor = '';
|
|
2084
|
+
setModelsStatus('idle', 'Enter a Base URL on the General tab to list models');
|
|
1961
2085
|
return;
|
|
1962
2086
|
}
|
|
1963
2087
|
|
|
1964
|
-
|
|
1965
|
-
btn.innerText = 'Fetching...';
|
|
2088
|
+
setModelsStatus('loading', 'Loading models...');
|
|
1966
2089
|
try {
|
|
1967
2090
|
const res = await api('/api/fetch-models', {
|
|
1968
2091
|
method: 'POST',
|
|
1969
2092
|
headers: { 'Content-Type': 'application/json' },
|
|
1970
|
-
body: JSON.stringify({ baseURL, apiKey })
|
|
2093
|
+
body: JSON.stringify({ baseURL, apiKey, key: formLoadedKey })
|
|
1971
2094
|
});
|
|
1972
2095
|
const data = await res.json();
|
|
2096
|
+
if (request !== modelsRequest) return; // a newer load started while this one waited
|
|
1973
2097
|
if (data.ok && Array.isArray(data.models)) {
|
|
1974
2098
|
loadedModels = data.models;
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
showToast('Loaded ' + loadedModels.length + ' models');
|
|
2099
|
+
loadedLimits = data.limits || {};
|
|
2100
|
+
modelsLoadedFor = source;
|
|
2101
|
+
setModelsStatus('ready', loadedModels.length + (loadedModels.length === 1 ? ' model' : ' models'), 'Reload');
|
|
1979
2102
|
} else {
|
|
1980
|
-
|
|
2103
|
+
loadedModels = [];
|
|
2104
|
+
modelsLoadedFor = '';
|
|
2105
|
+
setModelsStatus('error', 'Models not loaded: ' + modelsError(data.error), 'Retry');
|
|
1981
2106
|
}
|
|
1982
2107
|
} catch (err) {
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
2108
|
+
if (request !== modelsRequest) return;
|
|
2109
|
+
loadedModels = [];
|
|
2110
|
+
modelsLoadedFor = '';
|
|
2111
|
+
setModelsStatus('error', 'Models not loaded: ' + err.message, 'Retry');
|
|
1987
2112
|
}
|
|
2113
|
+
document.querySelectorAll('.combo[data-open]').forEach(renderComboOptions);
|
|
2114
|
+
update1mWarnings();
|
|
1988
2115
|
}
|
|
1989
2116
|
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
2117
|
+
// Slot combobox (ARIA combobox with a listbox). Typing filters; the arrow opens the full list.
|
|
2118
|
+
// Free text stays valid: a model the provider does not list is typed and kept.
|
|
2119
|
+
function wireCombo(combo) {
|
|
2120
|
+
const input = combo.querySelector('input');
|
|
2121
|
+
combo.activeIndex = -1;
|
|
2122
|
+
input.addEventListener('input', () => { combo.dataset.filter = '1'; combo.activeIndex = -1; openCombo(combo); });
|
|
2123
|
+
input.addEventListener('keydown', onComboKeydown);
|
|
2124
|
+
input.addEventListener('blur', () => closeCombo(combo));
|
|
2125
|
+
// Keep the focus in the field while the list or the arrow is used.
|
|
2126
|
+
combo.querySelector('.combo-list').addEventListener('mousedown', e => e.preventDefault());
|
|
2127
|
+
const toggle = combo.querySelector('.combo-toggle');
|
|
2128
|
+
toggle.addEventListener('mousedown', e => e.preventDefault());
|
|
2129
|
+
toggle.addEventListener('click', () => {
|
|
2130
|
+
if (combo.hasAttribute('data-open')) return closeCombo(combo);
|
|
2131
|
+
input.focus();
|
|
2132
|
+
input.select(); // typing then replaces the current model instead of joining it
|
|
2133
|
+
delete combo.dataset.filter;
|
|
2134
|
+
openCombo(combo);
|
|
2135
|
+
});
|
|
1999
2136
|
}
|
|
2000
2137
|
|
|
2001
|
-
function
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2138
|
+
function openCombo(combo) {
|
|
2139
|
+
combo.setAttribute('data-open', '');
|
|
2140
|
+
combo.querySelector('input').setAttribute('aria-expanded', 'true');
|
|
2141
|
+
combo.querySelector('.combo-list').hidden = false;
|
|
2142
|
+
renderComboOptions(combo);
|
|
2143
|
+
placeCombo(combo);
|
|
2144
|
+
setComboActive(combo, combo.activeIndex); // again, now that the list has its final height
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function closeCombo(combo) {
|
|
2148
|
+
if (!combo.hasAttribute('data-open')) return;
|
|
2149
|
+
const input = combo.querySelector('input');
|
|
2150
|
+
combo.removeAttribute('data-open');
|
|
2151
|
+
combo.removeAttribute('data-up');
|
|
2152
|
+
delete combo.dataset.filter;
|
|
2153
|
+
combo.activeIndex = -1;
|
|
2154
|
+
input.setAttribute('aria-expanded', 'false');
|
|
2155
|
+
input.removeAttribute('aria-activedescendant');
|
|
2156
|
+
combo.querySelector('.combo-list').hidden = true;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
// The modal body scrolls and clips the list: open toward the larger room and fit the list into it.
|
|
2160
|
+
function placeCombo(combo) {
|
|
2161
|
+
const list = combo.querySelector('.combo-list');
|
|
2162
|
+
const scroller = combo.closest('.mb');
|
|
2163
|
+
combo.removeAttribute('data-up');
|
|
2164
|
+
list.style.maxHeight = '';
|
|
2165
|
+
if (!scroller) return;
|
|
2166
|
+
const box = scroller.getBoundingClientRect();
|
|
2167
|
+
const field = combo.getBoundingClientRect();
|
|
2168
|
+
const below = box.bottom - field.bottom - 8;
|
|
2169
|
+
const above = field.top - box.top - 8;
|
|
2170
|
+
const up = below < list.offsetHeight && above > below;
|
|
2171
|
+
if (up) combo.setAttribute('data-up', '');
|
|
2172
|
+
list.style.maxHeight = Math.max(120, Math.min(240, up ? above : below)) + 'px';
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
function markMatch(text, q) {
|
|
2176
|
+
const at = q ? text.toLowerCase().indexOf(q) : -1;
|
|
2177
|
+
if (at < 0) return escapeHtml(text);
|
|
2178
|
+
return escapeHtml(text.slice(0, at)) + '<mark>' + escapeHtml(text.slice(at, at + q.length)) + '</mark>' + escapeHtml(text.slice(at + q.length));
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
function renderComboOptions(combo) {
|
|
2182
|
+
const input = combo.querySelector('input');
|
|
2183
|
+
const list = combo.querySelector('.combo-list');
|
|
2184
|
+
const q = combo.dataset.filter ? input.value.trim().toLowerCase() : '';
|
|
2185
|
+
const current = input.value.trim();
|
|
2186
|
+
const matches = loadedModels.filter(m => !q || m.toLowerCase().includes(q)).slice(0, 200);
|
|
2187
|
+
combo.matches = matches;
|
|
2188
|
+
if (!loadedModels.length) {
|
|
2189
|
+
const status = document.getElementById('models-status')?.textContent || '';
|
|
2190
|
+
list.innerHTML = `<li class="combo-empty">${escapeHtml(status || 'No models loaded')}</li>`;
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
if (!matches.length) {
|
|
2194
|
+
list.innerHTML = '<li class="combo-empty">No listed model matches. The typed name is kept as it is.</li>';
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
if (!(combo.activeIndex >= 0 && combo.activeIndex < matches.length)) combo.activeIndex = matches.indexOf(current);
|
|
2198
|
+
list.innerHTML = matches.map((m, i) => {
|
|
2199
|
+
const cut = m.lastIndexOf('/') + 1;
|
|
2200
|
+
const prefix = cut ? `<span class="pfx">${markMatch(m.slice(0, cut), q)}</span>` : '';
|
|
2201
|
+
return `<li class="combo-opt" role="option" id="${input.id}-opt-${i}" data-i="${i}" aria-selected="${i === combo.activeIndex}"`
|
|
2202
|
+
+ ` onmousedown="event.preventDefault(); chooseComboOption(this)">`
|
|
2203
|
+
+ `<span class="id">${prefix}${markMatch(m.slice(cut), q)}</span>`
|
|
2204
|
+
+ (m === current ? '<span class="cur">current</span>' : '') + '</li>';
|
|
2205
|
+
}).join('');
|
|
2206
|
+
setComboActive(combo, combo.activeIndex);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
function setComboActive(combo, i) {
|
|
2210
|
+
const input = combo.querySelector('input');
|
|
2211
|
+
combo.activeIndex = i;
|
|
2212
|
+
combo.querySelectorAll('.combo-opt').forEach(li => li.setAttribute('aria-selected', String(Number(li.dataset.i) === i)));
|
|
2213
|
+
const active = i >= 0 ? document.getElementById(`${input.id}-opt-${i}`) : null;
|
|
2214
|
+
if (active) {
|
|
2215
|
+
input.setAttribute('aria-activedescendant', active.id);
|
|
2216
|
+
// Scroll the list only. The browser's scroll-into-view also scrolls the dialog body.
|
|
2217
|
+
const list = combo.querySelector('.combo-list');
|
|
2218
|
+
if (active.offsetTop < list.scrollTop) list.scrollTop = active.offsetTop - 4;
|
|
2219
|
+
else if (active.offsetTop + active.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = active.offsetTop + active.offsetHeight - list.clientHeight + 4;
|
|
2220
|
+
} else {
|
|
2221
|
+
input.removeAttribute('aria-activedescendant');
|
|
2016
2222
|
}
|
|
2017
2223
|
}
|
|
2018
2224
|
|
|
2019
|
-
function
|
|
2020
|
-
const
|
|
2021
|
-
const
|
|
2022
|
-
|
|
2225
|
+
function chooseComboOption(li) {
|
|
2226
|
+
const combo = li.closest('.combo');
|
|
2227
|
+
const model = combo?.matches?.[Number(li.dataset.i)];
|
|
2228
|
+
if (!model) return;
|
|
2229
|
+
combo.querySelector('input').value = model;
|
|
2230
|
+
closeCombo(combo);
|
|
2231
|
+
update1mWarnings();
|
|
2023
2232
|
}
|
|
2024
2233
|
|
|
2025
|
-
function
|
|
2026
|
-
const
|
|
2027
|
-
const
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
if (
|
|
2234
|
+
function onComboKeydown(e) {
|
|
2235
|
+
const combo = e.currentTarget.closest('.combo');
|
|
2236
|
+
const open = combo.hasAttribute('data-open');
|
|
2237
|
+
const count = combo.matches?.length || 0;
|
|
2238
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
2239
|
+
e.preventDefault();
|
|
2240
|
+
if (!open) return openCombo(combo);
|
|
2241
|
+
if (!count) return;
|
|
2242
|
+
const from = combo.activeIndex;
|
|
2243
|
+
const next = e.key === 'ArrowDown' ? (from < 0 ? 0 : (from + 1) % count) : (from <= 0 ? count - 1 : from - 1);
|
|
2244
|
+
setComboActive(combo, next);
|
|
2245
|
+
} else if (e.key === 'Enter') {
|
|
2246
|
+
// Never submit from a slot. Enter picks the highlighted model, or keeps the typed text.
|
|
2247
|
+
e.preventDefault();
|
|
2248
|
+
e.stopPropagation();
|
|
2249
|
+
if (open && combo.activeIndex >= 0) chooseComboOption(document.getElementById(`${e.currentTarget.id}-opt-${combo.activeIndex}`));
|
|
2250
|
+
else closeCombo(combo);
|
|
2251
|
+
} else if (e.key === 'Escape') {
|
|
2252
|
+
if (open) {
|
|
2253
|
+
e.preventDefault();
|
|
2254
|
+
e.stopPropagation();
|
|
2255
|
+
closeCombo(combo);
|
|
2256
|
+
}
|
|
2257
|
+
} else if (e.key === 'Tab') {
|
|
2258
|
+
closeCombo(combo);
|
|
2033
2259
|
}
|
|
2034
|
-
showToast('Selected: ' + modelId);
|
|
2035
2260
|
}
|
|
2036
2261
|
|
|
2037
2262
|
// Dynamic Model Catalog View
|
|
@@ -2104,7 +2329,7 @@
|
|
|
2104
2329
|
try {
|
|
2105
2330
|
const res = await api('/api/catalog/refresh', { method: 'POST' });
|
|
2106
2331
|
const data = await res.json();
|
|
2107
|
-
if (data.
|
|
2332
|
+
if (data.success) {
|
|
2108
2333
|
showToast('Model catalog synchronized');
|
|
2109
2334
|
await renderCatalogView();
|
|
2110
2335
|
} else {
|
|
@@ -2181,7 +2406,7 @@
|
|
|
2181
2406
|
try {
|
|
2182
2407
|
const res = await api('/api/logs/clear', { method: 'POST' });
|
|
2183
2408
|
const data = await res.json();
|
|
2184
|
-
if (data.
|
|
2409
|
+
if (data.success) {
|
|
2185
2410
|
showToast('Request inspector logs cleared');
|
|
2186
2411
|
await loadInspectorLogs();
|
|
2187
2412
|
}
|
|
@@ -2206,6 +2431,23 @@
|
|
|
2206
2431
|
}
|
|
2207
2432
|
|
|
2208
2433
|
// Initial Bootstrap
|
|
2434
|
+
// The version check never blocks the dashboard: without an answer there is no notice.
|
|
2435
|
+
async function loadVersion() {
|
|
2436
|
+
try {
|
|
2437
|
+
const v = await (await api('/api/version')).json();
|
|
2438
|
+
document.getElementById('app-version').textContent = 'v' + v.current;
|
|
2439
|
+
if (!v.updateAvailable) return;
|
|
2440
|
+
document.getElementById('update-latest').textContent = v.latest;
|
|
2441
|
+
document.getElementById('update-command').textContent = v.updateCommand;
|
|
2442
|
+
document.getElementById('update-notice').hidden = false;
|
|
2443
|
+
} catch {}
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
function copyUpdateCommand() {
|
|
2447
|
+
const cmd = document.getElementById('update-command').textContent;
|
|
2448
|
+
navigator.clipboard.writeText(cmd).then(() => showToast('Copied: ' + cmd), () => showToast('Copy failed. Select the command and copy it by hand.', true));
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2209
2451
|
document.addEventListener('DOMContentLoaded', () => {
|
|
2210
2452
|
// Setup initial theme buttons state
|
|
2211
2453
|
const currentTheme = document.documentElement.dataset.theme || 'dark';
|
|
@@ -2220,6 +2462,7 @@
|
|
|
2220
2462
|
|
|
2221
2463
|
// Load initial state from edge gateway
|
|
2222
2464
|
loadStatus();
|
|
2465
|
+
loadVersion();
|
|
2223
2466
|
});
|
|
2224
2467
|
</script>
|
|
2225
2468
|
</body>
|