llm-switcher 1.6.2 → 1.6.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/package.json +1 -1
  3. package/ui.html +214 -7
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.6.4 — 2026-10-08
4
+
5
+ - **Dynamic Model Provider Source for Idle Compaction:** Query live model listings directly from the selected profile's `baseURL` via `/models` instead of hardcoding provider URLs or static model lists.
6
+ - **Clean Model Combobox without Slot Aliases:** Eliminate hardcoded quick preset buttons, popular model arrays, and slot aliases from the summarization model combobox. Models are loaded dynamically from the upstream provider, sorted alphabetically with context limits, with support for instant search filtering and deterministic heuristic truncation (`""`).
7
+
8
+ ## Release 1.6.3 — 2026-10-08
9
+
10
+ - **Interactive Summarization Model Selection & Presets:** Replace raw text input for Idle Compaction's summarization model with a fully searchable combobox dropdown and quick 1-click preset chips. Users can pick directly from deterministic heuristic mode (`""`), slot aliases (`haiku`, `subagent`, `sonnet`, `main`), configured profile models (`zencore/*`, `codex/*`), and discovered catalog models, or type to filter in real-time. Selection automatically saves and updates active highlights across both light and dark themes.
11
+
3
12
  ## Release 1.6.2 — 2026-10-08
4
13
 
5
14
  - **Per-Model Slot Target Thinking Effort:** Support granular reasoning effort configuration per model slot (`profile.modelEfforts`). Individual slot selectors in the dashboard allow setting target effort levels (`auto`, `none`, `low`, `medium`, `high`, `xhigh`, `max`) independently for each model tier (e.g. Sonnet, Opus, Haiku, Codex roles) with automatic fallback to profile-level defaults.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code and Codex, with OpenAI-compatible, Anthropic and Vertex upstreams",
5
5
  "keywords": [
6
6
  "llm",
package/ui.html CHANGED
@@ -551,6 +551,7 @@
551
551
 
552
552
  /* Model slot combobox: one listbox per slot, provider prefix dimmed, match in the accent. */
553
553
  .combo { position: relative; }
554
+ .combo[data-open] { z-index: 50; }
554
555
  .slot-note { font-size: 0.72rem; color: var(--faint); } /* Kept in the page when empty: a live region added later is often not read. */
555
556
  .slot-note:empty { margin-top: -0.25rem; }
556
557
  .combo > input { padding-right: 2.25rem; font-family: var(--font-mono); font-size: 0.8rem; }
@@ -1106,11 +1107,28 @@
1106
1107
 
1107
1108
  <div class="row">
1108
1109
  <div class="who">
1110
+ <div class="l">Model Provider Source</div>
1111
+ <div class="meta" id="idle-compact-source-meta">Profile Base URL queried via /models to list upstream models</div>
1112
+ </div>
1113
+ <div style="display:flex; align-items:center; gap:0.5rem; margin-left:auto; min-width:18rem; max-width:26rem;">
1114
+ <select id="idle-compact-source-profile" onchange="onCompactSourceProfileChange()" style="flex:1; font-size:0.8rem;"></select>
1115
+ <button type="button" class="btn btn-outline btn-sm" id="btn-reload-compact-models" onclick="reloadCompactModels(true)" title="Fetch live models from provider Base URL">Reload</button>
1116
+ </div>
1117
+ </div>
1118
+
1119
+ <div class="row" style="align-items:flex-start;">
1120
+ <div class="who" style="padding-top:0.25rem;">
1109
1121
  <div class="l">Summarization Model</div>
1110
- <div class="meta">Upstream model ID to generate turn summaries (empty = deterministic truncation)</div>
1122
+ <div class="meta" id="idle-compact-models-status">Select model from provider URL or enter custom ID (empty = deterministic truncation)</div>
1111
1123
  </div>
1112
- <div style="min-width:16rem; flex:1; max-width:26rem; margin-left:auto;">
1113
- <input type="text" id="idle-compact-model" placeholder="empty = no model call (deterministic)" maxlength="512" onchange="saveIdleCompact()" style="font-family:var(--font-mono); font-size:0.8rem;" title="Upstream model used to generate turn summary text">
1124
+ <div style="min-width:18rem; flex:1; max-width:26rem; margin-left:auto;">
1125
+ <div class="combo" id="combo-compact-model">
1126
+ <input type="text" id="idle-compact-model" placeholder="empty = deterministic truncation" maxlength="512" onchange="saveIdleCompact()" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="idle-compact-model-list" autocomplete="off" spellcheck="false" style="font-family:var(--font-mono); font-size:0.8rem;" title="Upstream model used to generate turn summary text">
1127
+ <button type="button" class="combo-toggle" tabindex="-1" aria-label="Show available summary models">
1128
+ <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>
1129
+ </button>
1130
+ <ul class="combo-list" id="idle-compact-model-list" role="listbox" aria-label="Available summarization models" hidden></ul>
1131
+ </div>
1114
1132
  </div>
1115
1133
  </div>
1116
1134
 
@@ -2112,11 +2130,107 @@
2112
2130
  }
2113
2131
  }
2114
2132
 
2133
+ let compactLoadedModels = [];
2134
+ let compactLoadedLimits = {};
2135
+ let compactSourceProfileKey = '';
2136
+
2137
+ function populateCompactSourceProfiles() {
2138
+ const sel = document.getElementById('idle-compact-source-profile');
2139
+ if (!sel) return;
2140
+ const profiles = state.config?.profiles || {};
2141
+ const activeClaude = state.config?.activeProfiles?.claude;
2142
+ const activeCodex = state.config?.activeProfiles?.codex;
2143
+ const keys = Object.keys(profiles);
2144
+
2145
+ if (!compactSourceProfileKey || !profiles[compactSourceProfileKey]) {
2146
+ compactSourceProfileKey = activeClaude || activeCodex || keys[0] || '';
2147
+ }
2148
+
2149
+ if (!keys.length) {
2150
+ sel.innerHTML = '<option value="">(No profiles configured)</option>';
2151
+ return;
2152
+ }
2153
+
2154
+ sel.innerHTML = keys.map(k => {
2155
+ const p = profiles[k];
2156
+ const isActive = (k === activeClaude || k === activeCodex);
2157
+ const prefix = isActive ? '★ ' : '';
2158
+ const selected = (k === compactSourceProfileKey) ? 'selected' : '';
2159
+ return `<option value="${escapeHtml(k)}" ${selected}>${escapeHtml(prefix + (p.name || k))} (${escapeHtml(p.baseURL || 'no URL')})</option>`;
2160
+ }).join('');
2161
+
2162
+ updateCompactSourceMeta();
2163
+ }
2164
+
2165
+ function updateCompactSourceMeta() {
2166
+ const meta = document.getElementById('idle-compact-source-meta');
2167
+ const p = state.config?.profiles?.[compactSourceProfileKey];
2168
+ if (meta && p) {
2169
+ meta.textContent = `${p.baseURL || 'No Base URL configured'} · ${p.outFormat || p.mode || 'convert'}`;
2170
+ }
2171
+ }
2172
+
2173
+ async function onCompactSourceProfileChange() {
2174
+ const sel = document.getElementById('idle-compact-source-profile');
2175
+ if (sel) {
2176
+ compactSourceProfileKey = sel.value;
2177
+ updateCompactSourceMeta();
2178
+ await reloadCompactModels(true);
2179
+ }
2180
+ }
2181
+
2182
+ async function reloadCompactModels(force = false) {
2183
+ const p = state.config?.profiles?.[compactSourceProfileKey];
2184
+ const statusEl = document.getElementById('idle-compact-models-status');
2185
+ const reloadBtn = document.getElementById('btn-reload-compact-models');
2186
+ if (!p || !p.baseURL) {
2187
+ compactLoadedModels = [];
2188
+ compactLoadedLimits = {};
2189
+ if (statusEl) statusEl.textContent = 'Configure a Base URL on the profile to load live models';
2190
+ return;
2191
+ }
2192
+
2193
+ if (statusEl) statusEl.textContent = `Loading models from ${p.baseURL}...`;
2194
+ if (reloadBtn) reloadBtn.disabled = true;
2195
+
2196
+ try {
2197
+ const res = await api('/api/fetch-models', {
2198
+ method: 'POST',
2199
+ headers: { 'Content-Type': 'application/json' },
2200
+ body: JSON.stringify({ baseURL: p.baseURL, apiKey: MASKED_KEY, key: compactSourceProfileKey })
2201
+ });
2202
+ const data = await res.json();
2203
+ if (data.ok && Array.isArray(data.models)) {
2204
+ compactLoadedModels = data.models;
2205
+ compactLoadedLimits = data.limits || {};
2206
+ if (statusEl) {
2207
+ statusEl.textContent = `${compactLoadedModels.length} models loaded from ${p.baseURL}. Select from list or enter model ID.`;
2208
+ }
2209
+ } else {
2210
+ compactLoadedModels = [];
2211
+ compactLoadedLimits = {};
2212
+ if (statusEl) {
2213
+ statusEl.textContent = `Models not loaded: ${modelsError(data.error)}`;
2214
+ }
2215
+ }
2216
+ } catch (err) {
2217
+ compactLoadedModels = [];
2218
+ compactLoadedLimits = {};
2219
+ if (statusEl) statusEl.textContent = `Failed to load models: ${err.message}`;
2220
+ } finally {
2221
+ if (reloadBtn) reloadBtn.disabled = false;
2222
+ const combo = document.getElementById('combo-compact-model');
2223
+ if (combo && combo.hasAttribute('data-open')) renderComboOptions(combo);
2224
+ }
2225
+ }
2226
+
2115
2227
  async function loadIdleCompact() {
2228
+ populateCompactSourceProfiles();
2116
2229
  try {
2117
2230
  const r = await api('/api/idle-compact');
2118
2231
  if (r.ok) showIdleCompact((await r.json()).idleCompact);
2119
2232
  } catch { /* the card stays at its defaults; nothing here is required to route */ }
2233
+ await reloadCompactModels();
2120
2234
  }
2121
2235
 
2122
2236
  async function saveIdleCompact() {
@@ -2789,7 +2903,11 @@
2789
2903
  function wireCombo(combo) {
2790
2904
  const input = combo.querySelector('input');
2791
2905
  combo.activeIndex = -1;
2792
- input.addEventListener('input', () => { combo.dataset.filter = '1'; combo.activeIndex = -1; openCombo(combo); });
2906
+ input.addEventListener('input', () => {
2907
+ combo.dataset.filter = '1';
2908
+ combo.activeIndex = -1;
2909
+ openCombo(combo);
2910
+ });
2793
2911
  input.addEventListener('keydown', onComboKeydown);
2794
2912
  input.addEventListener('blur', () => closeCombo(combo));
2795
2913
  // Keep the focus in the field while the list or the arrow is used.
@@ -2853,6 +2971,12 @@
2853
2971
  const list = combo.querySelector('.combo-list');
2854
2972
  const q = combo.dataset.filter ? input.value.trim().toLowerCase() : '';
2855
2973
  const current = input.value.trim();
2974
+
2975
+ if (input.id === 'idle-compact-model') {
2976
+ renderCompactModelOptions(combo, q, current);
2977
+ return;
2978
+ }
2979
+
2856
2980
  const matches = loadedModels.filter(m => !q || m.toLowerCase().includes(q)).slice(0, 200);
2857
2981
  combo.matches = matches;
2858
2982
  if (!loadedModels.length) {
@@ -2876,6 +3000,80 @@
2876
3000
  setComboActive(combo, combo.activeIndex);
2877
3001
  }
2878
3002
 
3003
+ function getAvailableCompactModels() {
3004
+ const items = [];
3005
+ const seen = new Set();
3006
+
3007
+ const add = (id, label, desc, category) => {
3008
+ if (!seen.has(id)) {
3009
+ seen.add(id);
3010
+ items.push({ id, label: label || id, desc: desc || '', category });
3011
+ }
3012
+ };
3013
+
3014
+ // 1. Deterministic option (empty string)
3015
+ add('', 'Deterministic Truncation (no external call)', 'Pure heuristic context truncation · Zero latency & zero tokens', 'Deterministic');
3016
+
3017
+ // 2. Models fetched live from the provider Base URL
3018
+ const prof = state.config?.profiles?.[compactSourceProfileKey];
3019
+ if (Array.isArray(compactLoadedModels) && compactLoadedModels.length) {
3020
+ for (const m of compactLoadedModels) {
3021
+ const l = compactLoadedLimits[m];
3022
+ const info = l?.context ? `${Math.round(l.context / 1000)}k context` : (prof?.name || 'Upstream');
3023
+ add(m, m, info, 'Upstream Model');
3024
+ }
3025
+ }
3026
+
3027
+ return items;
3028
+ }
3029
+
3030
+ function renderCompactModelOptions(combo, q, current) {
3031
+ const input = combo.querySelector('input');
3032
+ const list = combo.querySelector('.combo-list');
3033
+ const allItems = getAvailableCompactModels();
3034
+
3035
+ const filtered = allItems.filter(item => {
3036
+ if (!q) return true;
3037
+ if (item.id === '' && ('deterministic'.includes(q) || 'none'.includes(q) || 'empty'.includes(q) || 'heuristic'.includes(q))) return true;
3038
+ return item.id.toLowerCase().includes(q) || item.label.toLowerCase().includes(q);
3039
+ }).slice(0, 150);
3040
+
3041
+ combo.matches = filtered.map(item => item.id);
3042
+
3043
+ if (!filtered.length) {
3044
+ list.innerHTML = '<li class="combo-empty">No matching model found. The typed model ID will be used directly.</li>';
3045
+ return;
3046
+ }
3047
+
3048
+ if (!(combo.activeIndex >= 0 && combo.activeIndex < filtered.length)) {
3049
+ combo.activeIndex = filtered.findIndex(item => item.id === current);
3050
+ }
3051
+
3052
+ list.innerHTML = filtered.map((item, i) => {
3053
+ const isSelected = item.id === current;
3054
+ let badgeClass = 'badge outline';
3055
+ if (item.category === 'Deterministic') badgeClass = 'badge ok';
3056
+ else if (item.category === 'Upstream Model') badgeClass = 'badge';
3057
+
3058
+ const cut = item.id.lastIndexOf('/') + 1;
3059
+ const prefix = cut ? `<span class="pfx">${markMatch(item.id.slice(0, cut), q)}</span>` : '';
3060
+ const mainText = item.id ? `${prefix}${markMatch(item.id.slice(cut), q)}` : markMatch(item.label, q);
3061
+
3062
+ return `<li class="combo-opt" role="option" id="${input.id}-opt-${i}" data-i="${i}" aria-selected="${i === combo.activeIndex}" style="display:flex; flex-direction:column; gap:0.18rem; padding:0.45rem 0.6rem; border-bottom:1px solid rgba(255,255,255,0.04);" onmousedown="event.preventDefault(); chooseComboOption(this)">`
3063
+ + `<div style="display:flex; align-items:center; justify-content:space-between; gap:0.5rem; width:100%;">`
3064
+ + `<span class="id" style="font-weight:600;">${mainText}</span>`
3065
+ + `<div style="display:flex; align-items:center; gap:0.4rem; flex:none;">`
3066
+ + `<span class="${badgeClass}" style="font-size:0.68rem; padding:0.1rem 0.35rem;">${escapeHtml(item.category)}</span>`
3067
+ + (isSelected ? '<span class="cur" style="font-weight:600;">active</span>' : '')
3068
+ + `</div>`
3069
+ + `</div>`
3070
+ + (item.desc && item.id ? `<div style="font-size:0.72rem; color:var(--muted); font-family:var(--font-sans);">${escapeHtml(item.desc)}</div>` : '')
3071
+ + `</li>`;
3072
+ }).join('');
3073
+
3074
+ setComboActive(combo, combo.activeIndex);
3075
+ }
3076
+
2879
3077
  function setComboActive(combo, i) {
2880
3078
  const input = combo.querySelector('input');
2881
3079
  combo.activeIndex = i;
@@ -2894,12 +3092,17 @@
2894
3092
 
2895
3093
  function chooseComboOption(li) {
2896
3094
  const combo = li.closest('.combo');
2897
- const model = combo?.matches?.[Number(li.dataset.i)];
2898
- if (!model) return;
2899
- combo.querySelector('input').value = model;
3095
+ const idx = Number(li.dataset.i);
3096
+ const model = combo?.matches?.[idx];
3097
+ if (model === undefined) return;
3098
+ const input = combo.querySelector('input');
3099
+ input.value = model;
2900
3100
  formDirty = true;
2901
3101
  closeCombo(combo);
2902
3102
  updateWindowNotes();
3103
+ if (input.id === 'idle-compact-model') {
3104
+ saveIdleCompact();
3105
+ }
2903
3106
  }
2904
3107
 
2905
3108
  function onComboKeydown(e) {
@@ -3241,6 +3444,10 @@
3241
3444
  document.getElementById('p-url')?.addEventListener('input', syncEffortOptions);
3242
3445
  document.getElementById('p-tool')?.addEventListener('change', syncEffortOptions);
3243
3446
 
3447
+ // Wire Idle Compaction summarization model combobox
3448
+ const compactCombo = document.getElementById('combo-compact-model');
3449
+ if (compactCombo) wireCombo(compactCombo);
3450
+
3244
3451
  // Trigger initial route
3245
3452
  handleRoute();
3246
3453