llm-switcher 1.6.2 → 1.6.3

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 +4 -0
  2. package/package.json +1 -1
  3. package/ui.html +214 -10
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.6.3 — 2026-10-08
4
+
5
+ - **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.
6
+
3
7
  ## Release 1.6.2 — 2026-10-08
4
8
 
5
9
  - **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.3",
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; }
@@ -1104,13 +1105,22 @@
1104
1105
  </div>
1105
1106
  </div>
1106
1107
 
1107
- <div class="row">
1108
- <div class="who">
1108
+ <div class="row" style="align-items:flex-start;">
1109
+ <div class="who" style="padding-top:0.25rem;">
1109
1110
  <div class="l">Summarization Model</div>
1110
- <div class="meta">Upstream model ID to generate turn summaries (empty = deterministic truncation)</div>
1111
+ <div class="meta">Select from discovered/profile models or enter custom ID (empty = deterministic truncation)</div>
1111
1112
  </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">
1113
+ <div style="min-width:18rem; flex:1; max-width:28rem; margin-left:auto; display:flex; flex-direction:column; gap:0.4rem;">
1114
+ <div class="combo" id="combo-compact-model">
1115
+ <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">
1116
+ <button type="button" class="combo-toggle" tabindex="-1" aria-label="Show available summary models">
1117
+ <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>
1118
+ </button>
1119
+ <ul class="combo-list" id="idle-compact-model-list" role="listbox" aria-label="Available summarization models" hidden></ul>
1120
+ </div>
1121
+ <div id="compact-model-presets" style="display:flex; flex-wrap:wrap; align-items:center; gap:0.35rem;">
1122
+ <!-- Quick Presets rendered dynamically -->
1123
+ </div>
1114
1124
  </div>
1115
1125
  </div>
1116
1126
 
@@ -2062,7 +2072,10 @@
2062
2072
  const elKb = document.getElementById('idle-compact-kb');
2063
2073
  if (elKb) elKb.value = (c.minBytes ?? 65536) / 1024;
2064
2074
  const elModel = document.getElementById('idle-compact-model');
2065
- if (elModel) elModel.value = c.model || '';
2075
+ if (elModel) {
2076
+ elModel.value = c.model || '';
2077
+ updateCompactModelPresets(c.model || '');
2078
+ }
2066
2079
  const elCodex = document.getElementById('idle-compact-codex');
2067
2080
  if (elCodex) elCodex.checked = !!c.codex;
2068
2081
  const elKeep = document.getElementById('idle-compact-keep');
@@ -2112,7 +2125,62 @@
2112
2125
  }
2113
2126
  }
2114
2127
 
2128
+ async function ensureCatalogLoaded() {
2129
+ if (cachedCatalog) return cachedCatalog;
2130
+ try {
2131
+ const res = await api('/api/catalog');
2132
+ if (res.ok) cachedCatalog = await res.json();
2133
+ } catch {}
2134
+ return cachedCatalog;
2135
+ }
2136
+
2137
+ function setCompactModel(model) {
2138
+ const inp = document.getElementById('idle-compact-model');
2139
+ if (inp) {
2140
+ inp.value = model;
2141
+ saveIdleCompact();
2142
+ updateCompactModelPresets(model);
2143
+ }
2144
+ }
2145
+
2146
+ function updateCompactModelPresets(currentModel) {
2147
+ const container = document.getElementById('compact-model-presets');
2148
+ if (!container) return;
2149
+ if (currentModel === undefined) {
2150
+ currentModel = document.getElementById('idle-compact-model')?.value.trim() || '';
2151
+ }
2152
+
2153
+ const presets = [
2154
+ { id: '', label: 'Deterministic (none)' },
2155
+ { id: 'haiku', label: 'haiku' },
2156
+ { id: 'subagent', label: 'subagent' }
2157
+ ];
2158
+
2159
+ const profiles = state.config?.profiles || {};
2160
+ let added = 0;
2161
+ for (const p of Object.values(profiles)) {
2162
+ if (p.defaultModels) {
2163
+ for (const [slot, m] of Object.entries(p.defaultModels)) {
2164
+ if (m && added < 2 && !presets.some(x => x.id === m)) {
2165
+ presets.push({ id: m, label: m.includes('/') ? m.split('/')[1] : m });
2166
+ added++;
2167
+ }
2168
+ }
2169
+ }
2170
+ }
2171
+
2172
+ container.innerHTML = `<span class="hint" style="font-size:0.75rem; align-self:center; margin-right:0.2rem;">Quick Presets:</span>` +
2173
+ presets.map(p => {
2174
+ const active = (p.id === currentModel);
2175
+ const style = active
2176
+ ? 'padding:0.18rem 0.5rem; font-size:0.75rem; border-color:var(--accent); color:var(--accent); background:var(--accent-soft); font-weight:600;'
2177
+ : 'padding:0.18rem 0.5rem; font-size:0.75rem;';
2178
+ return `<button type="button" class="btn btn-outline btn-sm" style="${style}" onclick="setCompactModel('${escapeHtml(p.id)}')">${escapeHtml(p.label)}</button>`;
2179
+ }).join('');
2180
+ }
2181
+
2115
2182
  async function loadIdleCompact() {
2183
+ ensureCatalogLoaded();
2116
2184
  try {
2117
2185
  const r = await api('/api/idle-compact');
2118
2186
  if (r.ok) showIdleCompact((await r.json()).idleCompact);
@@ -2789,7 +2857,14 @@
2789
2857
  function wireCombo(combo) {
2790
2858
  const input = combo.querySelector('input');
2791
2859
  combo.activeIndex = -1;
2792
- input.addEventListener('input', () => { combo.dataset.filter = '1'; combo.activeIndex = -1; openCombo(combo); });
2860
+ input.addEventListener('input', () => {
2861
+ combo.dataset.filter = '1';
2862
+ combo.activeIndex = -1;
2863
+ openCombo(combo);
2864
+ if (input.id === 'idle-compact-model') {
2865
+ updateCompactModelPresets(input.value.trim());
2866
+ }
2867
+ });
2793
2868
  input.addEventListener('keydown', onComboKeydown);
2794
2869
  input.addEventListener('blur', () => closeCombo(combo));
2795
2870
  // Keep the focus in the field while the list or the arrow is used.
@@ -2853,6 +2928,12 @@
2853
2928
  const list = combo.querySelector('.combo-list');
2854
2929
  const q = combo.dataset.filter ? input.value.trim().toLowerCase() : '';
2855
2930
  const current = input.value.trim();
2931
+
2932
+ if (input.id === 'idle-compact-model') {
2933
+ renderCompactModelOptions(combo, q, current);
2934
+ return;
2935
+ }
2936
+
2856
2937
  const matches = loadedModels.filter(m => !q || m.toLowerCase().includes(q)).slice(0, 200);
2857
2938
  combo.matches = matches;
2858
2939
  if (!loadedModels.length) {
@@ -2876,6 +2957,119 @@
2876
2957
  setComboActive(combo, combo.activeIndex);
2877
2958
  }
2878
2959
 
2960
+ function getAvailableCompactModels() {
2961
+ const items = [];
2962
+ const seen = new Set();
2963
+
2964
+ const add = (id, label, desc, category) => {
2965
+ if (!seen.has(id)) {
2966
+ seen.add(id);
2967
+ items.push({ id, label: label || id, desc: desc || '', category });
2968
+ }
2969
+ };
2970
+
2971
+ // 1. Deterministic option (empty string)
2972
+ add('', 'Deterministic Truncation (no external call)', 'Pure heuristic context truncation · Zero latency & zero tokens', 'Deterministic');
2973
+
2974
+ // 2. Profile role/slot aliases (recommended for routing)
2975
+ add('haiku', 'haiku (Claude Haiku Slot)', 'Auto-resolves to active profile Claude Haiku model', 'Slot Aliases');
2976
+ add('subagent', 'subagent (Codex Subagent Slot)', 'Auto-resolves to active profile Codex Subagent model', 'Slot Aliases');
2977
+ add('sonnet', 'sonnet (Claude Sonnet Slot)', 'Auto-resolves to active profile Claude Sonnet model', 'Slot Aliases');
2978
+ add('main', 'main (Codex Main Slot)', 'Auto-resolves to active profile Codex Main model', 'Slot Aliases');
2979
+
2980
+ // 3. Models configured in current profiles
2981
+ const profiles = state.config?.profiles || {};
2982
+ for (const [profKey, p] of Object.entries(profiles)) {
2983
+ const profName = p.name || profKey;
2984
+ if (p.defaultModels) {
2985
+ for (const [slot, m] of Object.entries(p.defaultModels)) {
2986
+ if (m) add(m, m, `${profName} · ${slot} slot`, 'Profile Models');
2987
+ }
2988
+ }
2989
+ if (Array.isArray(p.publicModels)) {
2990
+ for (const m of p.publicModels) {
2991
+ if (m) add(m, m, `${profName}`, 'Profile Models');
2992
+ }
2993
+ }
2994
+ }
2995
+
2996
+ // 4. Discovered Catalog models
2997
+ if (cachedCatalog) {
2998
+ if (Array.isArray(cachedCatalog.claude?.models)) {
2999
+ for (const m of cachedCatalog.claude.models) {
3000
+ if (m.id) add(m.id, m.id, `Claude Catalog · ${m.tier || ''}`, 'Catalog');
3001
+ }
3002
+ }
3003
+ if (Array.isArray(cachedCatalog.codex?.models)) {
3004
+ for (const m of cachedCatalog.codex.models) {
3005
+ if (m.id) add(m.id, m.id, `Codex Catalog · ${m.role || ''}`, 'Catalog');
3006
+ }
3007
+ }
3008
+ }
3009
+
3010
+ // 5. Popular lightweight models
3011
+ const popular = [
3012
+ { id: 'zencore/muse-spark-1.3-contributor-free', desc: 'ZenCore contributor fast model' },
3013
+ { id: 'zencore/exo-free', desc: 'ZenCore free pool fast model' },
3014
+ { id: 'claude-3-5-haiku-20241022', desc: 'Anthropic lightweight summarizer' },
3015
+ { id: 'gpt-4o-mini', desc: 'OpenAI low-cost summarizer' },
3016
+ { id: 'gemini-2.5-flash', desc: 'Google Gemini fast summarizer' }
3017
+ ];
3018
+ for (const p of popular) {
3019
+ add(p.id, p.id, p.desc, 'Recommended');
3020
+ }
3021
+
3022
+ return items;
3023
+ }
3024
+
3025
+ function renderCompactModelOptions(combo, q, current) {
3026
+ const input = combo.querySelector('input');
3027
+ const list = combo.querySelector('.combo-list');
3028
+ const allItems = getAvailableCompactModels();
3029
+
3030
+ const filtered = allItems.filter(item => {
3031
+ if (!q) return true;
3032
+ if (item.id === '' && ('deterministic'.includes(q) || 'none'.includes(q) || 'empty'.includes(q) || 'heuristic'.includes(q))) return true;
3033
+ return item.id.toLowerCase().includes(q) || item.label.toLowerCase().includes(q) || item.desc.toLowerCase().includes(q);
3034
+ }).slice(0, 150);
3035
+
3036
+ combo.matches = filtered.map(item => item.id);
3037
+
3038
+ if (!filtered.length) {
3039
+ list.innerHTML = '<li class="combo-empty">No matching model found. The typed model will be used directly.</li>';
3040
+ return;
3041
+ }
3042
+
3043
+ if (!(combo.activeIndex >= 0 && combo.activeIndex < filtered.length)) {
3044
+ combo.activeIndex = filtered.findIndex(item => item.id === current);
3045
+ }
3046
+
3047
+ list.innerHTML = filtered.map((item, i) => {
3048
+ const isSelected = item.id === current;
3049
+ let badgeClass = 'badge outline';
3050
+ if (item.category === 'Deterministic') badgeClass = 'badge ok';
3051
+ else if (item.category === 'Slot Aliases') badgeClass = 'badge accent';
3052
+ else if (item.category === 'Profile Models') badgeClass = 'badge';
3053
+
3054
+ const cut = item.id.lastIndexOf('/') + 1;
3055
+ const prefix = cut ? `<span class="pfx">${markMatch(item.id.slice(0, cut), q)}</span>` : '';
3056
+ const mainText = item.id ? `${prefix}${markMatch(item.id.slice(cut), q)}` : markMatch(item.label, q);
3057
+
3058
+ 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)">`
3059
+ + `<div style="display:flex; align-items:center; justify-content:space-between; gap:0.5rem; width:100%;">`
3060
+ + `<span class="id" style="font-weight:600;">${mainText}</span>`
3061
+ + `<div style="display:flex; align-items:center; gap:0.4rem; flex:none;">`
3062
+ + `<span class="${badgeClass}" style="font-size:0.68rem; padding:0.1rem 0.35rem;">${escapeHtml(item.category)}</span>`
3063
+ + (isSelected ? '<span class="cur" style="font-weight:600;">active</span>' : '')
3064
+ + `</div>`
3065
+ + `</div>`
3066
+ + (item.desc && item.id ? `<div style="font-size:0.72rem; color:var(--muted); font-family:var(--font-sans);">${escapeHtml(item.desc)}</div>` : '')
3067
+ + `</li>`;
3068
+ }).join('');
3069
+
3070
+ setComboActive(combo, combo.activeIndex);
3071
+ }
3072
+
2879
3073
  function setComboActive(combo, i) {
2880
3074
  const input = combo.querySelector('input');
2881
3075
  combo.activeIndex = i;
@@ -2894,12 +3088,18 @@
2894
3088
 
2895
3089
  function chooseComboOption(li) {
2896
3090
  const combo = li.closest('.combo');
2897
- const model = combo?.matches?.[Number(li.dataset.i)];
2898
- if (!model) return;
2899
- combo.querySelector('input').value = model;
3091
+ const idx = Number(li.dataset.i);
3092
+ const model = combo?.matches?.[idx];
3093
+ if (model === undefined) return;
3094
+ const input = combo.querySelector('input');
3095
+ input.value = model;
2900
3096
  formDirty = true;
2901
3097
  closeCombo(combo);
2902
3098
  updateWindowNotes();
3099
+ if (input.id === 'idle-compact-model') {
3100
+ saveIdleCompact();
3101
+ updateCompactModelPresets(model);
3102
+ }
2903
3103
  }
2904
3104
 
2905
3105
  function onComboKeydown(e) {
@@ -3241,6 +3441,10 @@
3241
3441
  document.getElementById('p-url')?.addEventListener('input', syncEffortOptions);
3242
3442
  document.getElementById('p-tool')?.addEventListener('change', syncEffortOptions);
3243
3443
 
3444
+ // Wire Idle Compaction summarization model combobox
3445
+ const compactCombo = document.getElementById('combo-compact-model');
3446
+ if (compactCombo) wireCombo(compactCombo);
3447
+
3244
3448
  // Trigger initial route
3245
3449
  handleRoute();
3246
3450