llm-switcher 1.6.0 → 1.6.2

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/ui.html CHANGED
@@ -905,6 +905,10 @@
905
905
  <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>
906
906
  Routes & Profiles<span class="ct" id="ct-routes"></span>
907
907
  </a>
908
+ <a class="nav-link" href="#/compact" data-nav="compact">
909
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 14h6m0 0v6m0-6L3 21"/><path d="M20 10h-6m0 0V4m0 6l7-7"/><circle cx="12" cy="12" r="2"/></svg>
910
+ Idle Compaction<span class="ct" id="ct-compact"></span>
911
+ </a>
908
912
  <a class="nav-link" href="#/models" data-nav="models">
909
913
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/></svg>
910
914
  Model Catalog<span class="ct" id="ct-models"></span>
@@ -952,57 +956,6 @@
952
956
  <!-- VIEW 1: Routes & Profiles -->
953
957
  <div id="view-routes" class="view-panel" style="display:none;">
954
958
  <div class="sec">
955
- <!-- Idle compaction -->
956
- <div class="card">
957
- <div class="cardh">
958
- <h2 style="font-size:1.02rem; font-weight:600;">Idle compaction</h2>
959
- <span class="badge outline" id="badge-idle-compact">OFF</span>
960
- </div>
961
- <p style="margin:.2rem 0 .7rem; color:var(--muted); font-size:.86rem;">
962
- A Claude Code conversation that comes back after a pause has lost the provider's prompt
963
- cache, so its history is paid for at full price. Shorten it then, and write the
964
- compaction into the session file so the next resume starts short.
965
- </p>
966
- <div class="row">
967
- <label class="cb" style="display:flex; align-items:center; gap:.5rem;">
968
- <input type="checkbox" id="idle-compact-enabled" onchange="saveIdleCompact()">
969
- <span>Shorten a conversation that was idle too long</span>
970
- </label>
971
- </div>
972
- <div class="row">
973
- <div class="who">
974
- <div class="l">Pause that counts as losing the cache</div>
975
- <div class="meta">minutes · Anthropic 5m default, OpenAI 30m</div>
976
- </div>
977
- <div style="min-width:9rem;">
978
- <input type="number" id="idle-compact-minutes" min="1" max="1440" class="inp"
979
- style="width:100%;" onchange="saveIdleCompact()">
980
- </div>
981
- </div>
982
- <div class="row">
983
- <div class="who">
984
- <div class="l">Context it must be larger than</div>
985
- <div class="meta">KB · below this nothing is touched</div>
986
- </div>
987
- <div style="min-width:9rem;">
988
- <input type="number" id="idle-compact-kb" min="8" max="4096" class="inp"
989
- style="width:100%;" onchange="saveIdleCompact()">
990
- </div>
991
- </div>
992
- <div class="row">
993
- <div class="who">
994
- <div class="l">Model that writes the summary</div>
995
- <div class="meta">a cheaper model writes a cheaper and worse summary</div>
996
- </div>
997
- <div style="min-width:16rem; flex:1;">
998
- <input type="text" id="idle-compact-model" class="inp" style="width:100%;"
999
- placeholder="empty = the model of the conversation itself"
1000
- onchange="saveIdleCompact()">
1001
- </div>
1002
- </div>
1003
- <span id="idle-compact-status" class="meta" aria-live="polite"></span>
1004
- </div>
1005
-
1006
959
  <!-- Active Coding CLI Targets -->
1007
960
  <div class="card">
1008
961
  <div class="cardh">
@@ -1067,6 +1020,186 @@
1067
1020
  </div>
1068
1021
  </div>
1069
1022
 
1023
+ <!-- VIEW: Idle Prefix Cache & Compaction -->
1024
+ <div id="view-compact" class="view-panel" style="display:none;">
1025
+ <div class="sec">
1026
+ <!-- Overview Stats -->
1027
+ <div class="stats">
1028
+ <div class="stat">
1029
+ <div class="k">Compaction Engine</div>
1030
+ <div class="v" id="stat-compact-status" style="color:var(--muted); font-size:1.4rem;">Disabled</div>
1031
+ <div class="status" id="stat-compact-indicator" style="margin-top:0.3rem;">Prefix caching inactive</div>
1032
+ </div>
1033
+
1034
+ <div class="stat">
1035
+ <div class="k">Inactivity Pause</div>
1036
+ <div class="v" id="stat-compact-pause" style="color:var(--accent); font-size:1.4rem;">15 min</div>
1037
+ <div class="hint" style="margin-top:0.3rem;">Trigger pause threshold</div>
1038
+ </div>
1039
+
1040
+ <div class="stat">
1041
+ <div class="k">Min Size Threshold</div>
1042
+ <div class="v" id="stat-compact-size" style="color:var(--mint); font-size:1.4rem;">64 KB</div>
1043
+ <div class="hint" style="margin-top:0.3rem;">Smaller payloads bypassed</div>
1044
+ </div>
1045
+
1046
+ <div class="stat">
1047
+ <div class="k">Cache Lookback</div>
1048
+ <div class="v" id="stat-compact-lookback" style="color:var(--code); font-size:1.4rem;">72 hrs</div>
1049
+ <div class="hint" style="margin-top:0.3rem;">Storage retention window</div>
1050
+ </div>
1051
+ </div>
1052
+
1053
+ <!-- Hero Master Card -->
1054
+ <div class="card pad">
1055
+ <div class="sech">
1056
+ <div>
1057
+ <div style="display:flex; align-items:center; gap:0.6rem;">
1058
+ <h2 style="font-size:1.05rem; font-weight:600;">Idle Prefix Cache &amp; Compaction</h2>
1059
+ <span class="badge outline" id="badge-idle-compact">OFF</span>
1060
+ </div>
1061
+ <p class="hint" style="margin-top:0.35rem; max-width:54rem; line-height:1.45;">
1062
+ Shortens long conversation histories when an agent session remains inactive past a configured pause.
1063
+ The edge gateway maintains a canonical shortened prefix for resumed turns, cutting context tokens while keeping client transcripts 100% untouched.
1064
+ </p>
1065
+ </div>
1066
+ <div style="display:flex; align-items:center; gap:0.8rem;">
1067
+ <label class="switch" title="Toggle Idle Compaction">
1068
+ <input type="checkbox" id="idle-compact-enabled" onchange="saveIdleCompact()">
1069
+ <span></span>
1070
+ </label>
1071
+ </div>
1072
+ </div>
1073
+ <div id="idle-compact-status" style="margin-top:0.75rem; font-size:0.82rem; min-height:1.2rem;" aria-live="polite"></div>
1074
+ </div>
1075
+
1076
+ <!-- Configuration Section (Hidden when inactive) -->
1077
+ <div id="idle-compact-config-section" style="display:none; flex-direction:column; gap:1.25rem;">
1078
+ <!-- Card 1: Core Policy & Trigger Thresholds -->
1079
+ <div class="card">
1080
+ <div class="cardh">
1081
+ <h3 style="font-size:0.95rem; font-weight:600;">Compaction Policy &amp; Triggers</h3>
1082
+ <span class="badge ok">Gateway Invariant</span>
1083
+ </div>
1084
+
1085
+ <div class="row">
1086
+ <div class="who">
1087
+ <div class="l">Inactivity Pause Threshold</div>
1088
+ <div class="meta">Elapsed idle minutes after which the conversation prefix is compacted</div>
1089
+ </div>
1090
+ <div style="display:flex; align-items:center; gap:0.45rem; margin-left:auto;">
1091
+ <input type="number" id="idle-compact-minutes" min="1" max="1440" step="any" onchange="saveIdleCompact()" style="width:7.5rem; text-align:right;" title="Inactivity pause threshold in minutes (1 – 1440 min)" placeholder="15">
1092
+ <span style="font-size:0.8rem; color:var(--muted); min-width:2.2rem; user-select:none;">min</span>
1093
+ </div>
1094
+ </div>
1095
+
1096
+ <div class="row">
1097
+ <div class="who">
1098
+ <div class="l">Minimum Context Payload</div>
1099
+ <div class="meta">Context threshold; conversations smaller than this are kept untouched</div>
1100
+ </div>
1101
+ <div style="display:flex; align-items:center; gap:0.45rem; margin-left:auto;">
1102
+ <input type="number" id="idle-compact-kb" min="8" max="4096" step="any" onchange="saveIdleCompact()" style="width:7.5rem; text-align:right;" title="Minimum context payload size in kilobytes (8 – 4096 KB)" placeholder="64">
1103
+ <span style="font-size:0.8rem; color:var(--muted); min-width:2.2rem; user-select:none;">KB</span>
1104
+ </div>
1105
+ </div>
1106
+
1107
+ <div class="row">
1108
+ <div class="who">
1109
+ <div class="l">Summarization Model</div>
1110
+ <div class="meta">Upstream model ID to generate turn summaries (empty = deterministic truncation)</div>
1111
+ </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">
1114
+ </div>
1115
+ </div>
1116
+
1117
+ <div class="row">
1118
+ <div class="who">
1119
+ <div class="l">Shorten Codex CLI Sessions</div>
1120
+ <div class="meta">Off by default; Codex retains native turn history in local SQLite storage</div>
1121
+ </div>
1122
+ <label class="switch" title="Toggle Codex compaction" style="margin-left:auto;">
1123
+ <input type="checkbox" id="idle-compact-codex" onchange="saveIdleCompact()">
1124
+ <span></span>
1125
+ </label>
1126
+ </div>
1127
+ </div>
1128
+
1129
+ <!-- Card 2: Context Retention & Budget Controls -->
1130
+ <div class="card">
1131
+ <div class="cardh">
1132
+ <h3 style="font-size:0.95rem; font-weight:600;">Context Retention &amp; Cache Budgets</h3>
1133
+ <span class="badge accent">Turn Budgets</span>
1134
+ </div>
1135
+
1136
+ <div class="row">
1137
+ <div class="who">
1138
+ <div class="l">Verbatim Recent Turns</div>
1139
+ <div class="meta">Number of most recent turns always preserved byte-for-byte</div>
1140
+ </div>
1141
+ <div style="display:flex; align-items:center; gap:0.45rem; margin-left:auto;">
1142
+ <input type="number" id="idle-compact-keep" min="1" max="100" onchange="saveIdleCompact()" style="width:7.5rem; text-align:right;" title="Number of most recent turns to retain verbatim (1 – 100 turns)" placeholder="6">
1143
+ <span style="font-size:0.8rem; color:var(--muted); min-width:2.4rem; user-select:none;">turns</span>
1144
+ </div>
1145
+ </div>
1146
+
1147
+ <div class="row">
1148
+ <div class="who">
1149
+ <div class="l">Character Budget Per Earlier Turn</div>
1150
+ <div class="meta">Maximum character budget preserved per earlier conversation turn</div>
1151
+ </div>
1152
+ <div style="display:flex; align-items:center; gap:0.45rem; margin-left:auto;">
1153
+ <input type="number" id="idle-compact-user-chars" min="128" max="24000" onchange="saveIdleCompact()" style="width:7.5rem; text-align:right;" title="Character budget limit per earlier text turn (128 – 24000 chars)" placeholder="3000">
1154
+ <span style="font-size:0.8rem; color:var(--muted); min-width:2.4rem; user-select:none;">chars</span>
1155
+ </div>
1156
+ </div>
1157
+
1158
+ <div class="row">
1159
+ <div class="who">
1160
+ <div class="l">Maximum Summary Length</div>
1161
+ <div class="meta">Upper character limit allocated for generated turn summary blocks</div>
1162
+ </div>
1163
+ <div style="display:flex; align-items:center; gap:0.45rem; margin-left:auto;">
1164
+ <input type="number" id="idle-compact-summary-chars" min="256" max="128000" onchange="saveIdleCompact()" style="width:7.5rem; text-align:right;" title="Upper character limit allocated for generated summary (256 – 128000 chars)" placeholder="24000">
1165
+ <span style="font-size:0.8rem; color:var(--muted); min-width:2.4rem; user-select:none;">chars</span>
1166
+ </div>
1167
+ </div>
1168
+
1169
+ <div class="row">
1170
+ <div class="who">
1171
+ <div class="l">Cache Prefix Retention Window</div>
1172
+ <div class="meta">Duration in hours before idle cached prefixes are pruned from state disk</div>
1173
+ </div>
1174
+ <div style="display:flex; align-items:center; gap:0.45rem; margin-left:auto;">
1175
+ <input type="number" id="idle-compact-lookback" min="1" max="168" step="any" onchange="saveIdleCompact()" style="width:7.5rem; text-align:right;" title="Duration in hours before idle cached prefixes are pruned (1 – 168 hours)" placeholder="72">
1176
+ <span style="font-size:0.8rem; color:var(--muted); min-width:2.4rem; user-select:none;">hours</span>
1177
+ </div>
1178
+ </div>
1179
+ </div>
1180
+
1181
+ <!-- Card 3: Architecture & Safety Invariants -->
1182
+ <div class="card pad">
1183
+ <h3 style="font-size:0.95rem; font-weight:600; margin-bottom:0.75rem;">Edge Gateway Invariants &amp; Storage Safety</h3>
1184
+ <div class="sec">
1185
+ <div class="copyline">
1186
+ <code>Zero Client Mutation: Transcripts on client disk (Claude Code / Codex) are never modified.</code>
1187
+ </div>
1188
+ <div class="copyline">
1189
+ <code>Atomic CAS Cache: Compare-and-swap generation counters prevent stale compaction races.</code>
1190
+ </div>
1191
+ <div class="copyline">
1192
+ <code>Private State Storage: Cached prefixes saved under ~/.llm-switcher/state/idle-compact/ (0600 permissions).</code>
1193
+ </div>
1194
+ <div class="copyline">
1195
+ <code>Deterministic Safety: If no summary model is configured, gateway uses pure heuristic truncation without external calls.</code>
1196
+ </div>
1197
+ </div>
1198
+ </div>
1199
+ </div>
1200
+ </div>
1201
+ </div>
1202
+
1070
1203
  <!-- VIEW 2: Discovered Models Catalog -->
1071
1204
  <div id="view-models" class="view-panel" style="display:none;">
1072
1205
  <div class="sec">
@@ -1242,13 +1375,6 @@
1242
1375
  </div>
1243
1376
 
1244
1377
  <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
1245
- <label class="f">Thinking Mode
1246
- <select id="p-thinking-mode">
1247
- <option value="auto">AUTO (restores reasoning, strips tags safely)</option>
1248
- <option value="native">NATIVE (strict reasoning_effort)</option>
1249
- <option value="off">OFF (never send thinking)</option>
1250
- </select>
1251
- </label>
1252
1378
  <label class="f">Routing Mode
1253
1379
  <select id="p-mode">
1254
1380
  <option value="convert">CONVERT (Bi-directional translation)</option>
@@ -1262,11 +1388,46 @@
1262
1388
  <!-- TAB 3: Models -->
1263
1389
  <div id="tab-models" class="tab-content" style="display:none; gap:0.9rem; flex-direction:column;">
1264
1390
  <div style="display:flex; justify-content:space-between; align-items:center; gap:0.75rem;">
1265
- <h3 id="model-slot-heading" style="font-size:0.92rem; font-weight:600;">Model Slots Mapping</h3>
1391
+ <div>
1392
+ <h3 id="model-slot-heading" style="font-size:0.92rem; font-weight:600; margin:0;">Model Slots Mapping</h3>
1393
+ <span class="hint" style="font-size:0.75rem;">Configure target model and individual thinking effort for each slot</span>
1394
+ </div>
1266
1395
  <div id="models-status" class="models-status" aria-live="polite"></div>
1267
1396
  </div>
1268
1397
 
1269
1398
  <div id="slot-fields" style="display:grid; gap:0.75rem;"></div>
1399
+
1400
+ <!-- Global Reasoning Policy -->
1401
+ <div style="border-top:1px solid var(--line); padding-top:0.85rem; margin-top:0.35rem; display:grid; gap:0.85rem;">
1402
+ <div style="display:flex; justify-content:space-between; align-items:center;">
1403
+ <h4 style="font-size:0.88rem; font-weight:600; margin:0;">Reasoning Mode &amp; Policy</h4>
1404
+ <span class="hint">Controls reasoning behavior across all model slots</span>
1405
+ </div>
1406
+
1407
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
1408
+ <label class="f">Thinking Mode
1409
+ <select id="p-thinking-mode">
1410
+ <option value="auto">AUTO (restores reasoning, strips tags safely)</option>
1411
+ <option value="native">NATIVE (strict reasoning_effort)</option>
1412
+ <option value="off">OFF (never send thinking to any model)</option>
1413
+ </select>
1414
+ </label>
1415
+ <label class="f" id="f-thinking-effort" style="display:none;">Global Fallback Effort
1416
+ <select id="p-thinking-effort">
1417
+ <option value="auto">Auto (inherit from client tool)</option>
1418
+ <option value="low">Low (fast, light reasoning)</option>
1419
+ <option value="medium">Medium (balanced reasoning)</option>
1420
+ <option value="high">High (deep reasoning)</option>
1421
+ <option value="xhigh">Extra High (complex problem solving)</option>
1422
+ <option value="max">Max (maximum reasoning depth)</option>
1423
+ </select>
1424
+ </label>
1425
+ </div>
1426
+
1427
+ <div id="bifrost-hint" style="display:none; padding:0.6rem 0.85rem; border-radius:6px; background:rgba(59,130,246,0.08); border:1px solid rgba(59,130,246,0.25); font-size:0.82rem; color:#60a5fa; line-height:1.4;">
1428
+ ⚡ <strong>Bifrost Passthrough:</strong> Byte-for-byte routing to intact without format conversion. Thinking effort is controlled natively by your client tool.
1429
+ </div>
1430
+ </div>
1270
1431
  </div>
1271
1432
 
1272
1433
  <!-- Test Connection Box -->
@@ -1474,7 +1635,7 @@
1474
1635
  function handleRoute() {
1475
1636
  const hash = window.location.hash || '#/routes';
1476
1637
  const route = hash.replace(/^#\/?/, '').split('/')[0] || 'routes';
1477
- const validRoutes = ['routes', 'models', 'logs', 'doctor'];
1638
+ const validRoutes = ['routes', 'compact', 'models', 'logs', 'doctor'];
1478
1639
  const activeRoute = validRoutes.includes(route) ? route : 'routes';
1479
1640
 
1480
1641
  // Update active nav link
@@ -1495,6 +1656,7 @@
1495
1656
  updateHeader(activeRoute);
1496
1657
 
1497
1658
  // Lazy load view content
1659
+ if (activeRoute === 'compact') loadIdleCompact();
1498
1660
  if (activeRoute === 'models') renderCatalogView();
1499
1661
  if (activeRoute === 'logs') loadInspectorLogs();
1500
1662
  }
@@ -1512,7 +1674,6 @@
1512
1674
  const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
1513
1675
 
1514
1676
  if (route === 'routes') {
1515
- loadIdleCompact();
1516
1677
  titleEl.textContent = 'Routes & Profiles';
1517
1678
  if (activeTargets.length > 0) {
1518
1679
  const parts = [];
@@ -1528,6 +1689,14 @@
1528
1689
  <button class="btn btn-outline" id="btn-toggle-routes" onclick="toggleProxy()">${activeTargets.length > 0 ? 'Use official endpoints' : 'Activate compatible routes'}</button>
1529
1690
  <button class="btn btn-outline" onclick="loadStatus()">Refresh</button>
1530
1691
  `;
1692
+ } else if (route === 'compact') {
1693
+ loadIdleCompact();
1694
+ titleEl.textContent = 'Idle Compaction';
1695
+ subEl.textContent = 'Edge gateway prefix compaction and cache pruning for long sessions.';
1696
+ actionsEl.innerHTML = `
1697
+ <button class="btn btn-outline" onclick="loadIdleCompact()">Refresh</button>
1698
+ <button class="btn btn-primary" onclick="saveIdleCompact()">Save Configuration</button>
1699
+ `;
1531
1700
  } else if (route === 'models') {
1532
1701
  titleEl.textContent = 'Dynamic Model Catalog';
1533
1702
  subEl.textContent = 'Auto-polled models from upstream providers to guarantee zero 404/drift errors.';
@@ -1675,6 +1844,11 @@
1675
1844
  return defaultModels[slotId] || '';
1676
1845
  }
1677
1846
 
1847
+ function effortForSlot(modelEfforts, slotId) {
1848
+ if (!modelEfforts) return 'auto';
1849
+ return modelEfforts[slotId] || 'auto';
1850
+ }
1851
+
1678
1852
  function renderModelSlots(slotFmt, p) {
1679
1853
  const container = document.getElementById('slot-fields');
1680
1854
  const heading = document.getElementById('model-slot-heading');
@@ -1687,11 +1861,27 @@
1687
1861
 
1688
1862
  for (const s of cfg.slots) {
1689
1863
  const val = currentProfileBuffer ? valueForSlot(currentProfileBuffer.defaultModels, s.id) : valueForSlot(p?.defaultModels, s.id);
1864
+ const effVal = currentProfileBuffer ? effortForSlot(currentProfileBuffer.modelEfforts, s.id) : effortForSlot(p?.modelEfforts, s.id);
1690
1865
 
1691
1866
  const slotRow = document.createElement('div');
1692
- slotRow.style.cssText = 'display:flex; flex-direction:column; gap:0.25rem;';
1867
+ slotRow.className = 'slot-row-card';
1868
+ slotRow.style.cssText = 'display:flex; flex-direction:column; gap:0.35rem; padding:0.6rem 0.75rem; border-radius:6px; background:var(--card-bg, rgba(255,255,255,0.02)); border:1px solid var(--line);';
1693
1869
  slotRow.innerHTML = `
1694
- <label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
1870
+ <div style="display:flex; justify-content:space-between; align-items:center; gap:0.5rem;">
1871
+ <label for="p-${s.id}" style="font-size:0.82rem; font-weight:600; margin:0;">${escapeHtml(s.name)}</label>
1872
+ <div style="display:flex; align-items:center; gap:0.35rem;">
1873
+ <label for="p-effort-${s.id}" style="font-size:0.75rem; color:var(--muted); margin:0;">Target Effort:</label>
1874
+ <select id="p-effort-${s.id}" class="slot-effort inp" onchange="formDirty = true" style="height:26px; padding:0 0.4rem; font-size:0.78rem; border-radius:4px; min-width:6.8rem;">
1875
+ <option value="auto">Auto (Default)</option>
1876
+ <option value="none">None (0)</option>
1877
+ <option value="low">Low</option>
1878
+ <option value="medium">Medium</option>
1879
+ <option value="high">High</option>
1880
+ <option value="xhigh">Extra High</option>
1881
+ <option value="max">Max</option>
1882
+ </select>
1883
+ </div>
1884
+ </div>
1695
1885
  <div class="combo">
1696
1886
  <input type="text" id="p-${s.id}" oninput="updateWindowNotes()" 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)}">
1697
1887
  <button type="button" class="combo-toggle" tabindex="-1" aria-label="Show models for ${escapeHtml(s.name)}">
@@ -1699,10 +1889,15 @@
1699
1889
  </button>
1700
1890
  <ul class="combo-list" id="p-${s.id}-list" role="listbox" aria-label="${escapeHtml(s.name)} models" hidden></ul>
1701
1891
  </div>
1702
- <div class="slot-note" id="p-${s.id}-note" role="status" aria-live="polite"></div>
1892
+ <div style="display:flex; justify-content:space-between; align-items:center; gap:0.5rem; min-height:1rem;">
1893
+ <div class="slot-note" id="p-${s.id}-note" role="status" aria-live="polite"></div>
1894
+ <div class="slot-bifrost-hint" id="p-bifrost-${s.id}" style="display:none; font-size:0.74rem; color:#60a5fa;">⚡ Bifrost Passthrough (native effort)</div>
1895
+ </div>
1703
1896
  `;
1704
1897
  container.appendChild(slotRow);
1705
1898
  wireCombo(slotRow.querySelector('.combo'));
1899
+ const effSelect = slotRow.querySelector('#p-effort-' + s.id);
1900
+ if (effSelect) effSelect.value = effVal || 'auto';
1706
1901
  }
1707
1902
  updateWindowNotes();
1708
1903
  }
@@ -1716,6 +1911,7 @@
1716
1911
  const cfg = getActiveSlotConfig(renderedSlotFormat);
1717
1912
  if (!currentProfileBuffer) currentProfileBuffer = {};
1718
1913
  const defaultModels = { ...(currentProfileBuffer.defaultModels || {}) };
1914
+ const modelEfforts = { ...(currentProfileBuffer.modelEfforts || {}) };
1719
1915
  for (const s of cfg.slots) {
1720
1916
  const inp = document.getElementById('p-' + s.id);
1721
1917
  if (inp) {
@@ -1723,13 +1919,19 @@
1723
1919
  if (val) defaultModels[s.id] = val;
1724
1920
  else delete defaultModels[s.id];
1725
1921
  }
1922
+ const effSelect = document.getElementById('p-effort-' + s.id);
1923
+ if (effSelect) {
1924
+ const effVal = effSelect.value;
1925
+ if (effVal && effVal !== 'auto') modelEfforts[s.id] = effVal;
1926
+ else delete modelEfforts[s.id];
1927
+ }
1726
1928
  }
1727
1929
  currentProfileBuffer.defaultModels = defaultModels;
1930
+ currentProfileBuffer.modelEfforts = modelEfforts;
1728
1931
  // An old config can still carry the retired 1M flags. They are dropped on the next save
1729
1932
  // instead of being written back: the window is the model's own now.
1730
1933
  delete currentProfileBuffer.model1M;
1731
- return { defaultModels };
1732
-
1934
+ return { defaultModels, modelEfforts };
1733
1935
  }
1734
1936
 
1735
1937
  // 128000 reads as 128K and 262144 as 256K: a round decimal count keeps base 1000.
@@ -1756,6 +1958,64 @@
1756
1958
  const model = document.getElementById('p-' + s.id)?.value.trim() || '';
1757
1959
  note.textContent = model ? windowLine(model) : '';
1758
1960
  }
1961
+ syncEffortOptions();
1962
+ }
1963
+
1964
+ function syncEffortOptions() {
1965
+ const mode = document.getElementById('p-thinking-mode')?.value;
1966
+ const url = (document.getElementById('p-url')?.value || '').toLowerCase();
1967
+ const tool = document.getElementById('p-tool')?.value || 'auto';
1968
+ const isIntact = url.includes('intact') || url.includes(':20130') || url.includes(':8080') ||
1969
+ url.includes(':20142') || url.includes(':20150') || url.includes(':20152');
1970
+ const isThinkingOff = mode === 'off';
1971
+
1972
+ const cfg = getActiveSlotConfig(renderedSlotFormat);
1973
+ let anyBifrost = false;
1974
+
1975
+ for (const s of cfg.slots) {
1976
+ const inp = document.getElementById('p-' + s.id);
1977
+ const effSelect = document.getElementById('p-effort-' + s.id);
1978
+ const bifrostHint = document.getElementById('p-bifrost-' + s.id);
1979
+ if (!effSelect) continue;
1980
+
1981
+ const modelVal = (inp?.value || '').trim().toLowerCase();
1982
+ let slotIsBifrost = false;
1983
+ if (isIntact && modelVal) {
1984
+ if (tool === 'claude') slotIsBifrost = modelVal.startsWith('claude/') || modelVal.startsWith('anthropic/');
1985
+ else if (tool === 'codex') slotIsBifrost = modelVal.startsWith('codex/');
1986
+ else if (tool === 'agy') slotIsBifrost = modelVal.startsWith('antigravity/');
1987
+ else if (tool === 'auto') slotIsBifrost = modelVal.startsWith('claude/') || modelVal.startsWith('anthropic/') || modelVal.startsWith('codex/') || modelVal.startsWith('antigravity/');
1988
+ }
1989
+
1990
+ if (slotIsBifrost) {
1991
+ anyBifrost = true;
1992
+ if (bifrostHint) bifrostHint.style.display = 'block';
1993
+ effSelect.disabled = true;
1994
+ effSelect.style.opacity = '0.6';
1995
+ effSelect.title = 'Bifrost passthrough active: effort controlled natively by client tool';
1996
+ } else {
1997
+ if (bifrostHint) bifrostHint.style.display = 'none';
1998
+ if (isThinkingOff) {
1999
+ effSelect.disabled = true;
2000
+ effSelect.style.opacity = '0.5';
2001
+ effSelect.title = 'Thinking mode is OFF globally';
2002
+ } else {
2003
+ effSelect.disabled = false;
2004
+ effSelect.style.opacity = '1';
2005
+ effSelect.title = 'Target thinking effort for this model';
2006
+ }
2007
+ }
2008
+ }
2009
+
2010
+ const effortEl = document.getElementById('p-thinking-effort');
2011
+ const globalBifrostHint = document.getElementById('bifrost-hint');
2012
+ if (effortEl) {
2013
+ effortEl.disabled = isThinkingOff;
2014
+ effortEl.style.opacity = isThinkingOff ? '0.5' : '1';
2015
+ }
2016
+ if (globalBifrostHint) {
2017
+ globalBifrostHint.style.display = anyBifrost ? 'block' : 'none';
2018
+ }
1759
2019
  }
1760
2020
 
1761
2021
  // Prefetch Counts for Nav Badges
@@ -1783,6 +2043,8 @@
1783
2043
  if (ctLogs && lastLogsCount > 0) ctLogs.textContent = lastLogsCount;
1784
2044
  }
1785
2045
  } catch {}
2046
+
2047
+ loadIdleCompact();
1786
2048
  }
1787
2049
 
1788
2050
  // Load Status
@@ -1790,13 +2052,64 @@
1790
2052
  // dashboard and the terminal cannot show two different settings.
1791
2053
  function showIdleCompact(c) {
1792
2054
  c = c || {};
1793
- document.getElementById('idle-compact-enabled').checked = !!c.enabled;
1794
- document.getElementById('idle-compact-minutes').value = c.idleMinutes ?? 15;
1795
- document.getElementById('idle-compact-kb').value = Math.round((c.minBytes ?? 65536) / 1024);
1796
- document.getElementById('idle-compact-model').value = c.model || '';
2055
+ const enabled = !!c.enabled;
2056
+ const elEnabled = document.getElementById('idle-compact-enabled');
2057
+ if (elEnabled) elEnabled.checked = enabled;
2058
+ const configSec = document.getElementById('idle-compact-config-section');
2059
+ if (configSec) configSec.style.display = enabled ? 'flex' : 'none';
2060
+ const elMinutes = document.getElementById('idle-compact-minutes');
2061
+ if (elMinutes) elMinutes.value = c.idleMinutes ?? 15;
2062
+ const elKb = document.getElementById('idle-compact-kb');
2063
+ if (elKb) elKb.value = (c.minBytes ?? 65536) / 1024;
2064
+ const elModel = document.getElementById('idle-compact-model');
2065
+ if (elModel) elModel.value = c.model || '';
2066
+ const elCodex = document.getElementById('idle-compact-codex');
2067
+ if (elCodex) elCodex.checked = !!c.codex;
2068
+ const elKeep = document.getElementById('idle-compact-keep');
2069
+ if (elKeep) elKeep.value = c.keepRecent ?? 6;
2070
+ const elUserChars = document.getElementById('idle-compact-user-chars');
2071
+ if (elUserChars) elUserChars.value = c.userChars ?? 3000;
2072
+ const elSummaryChars = document.getElementById('idle-compact-summary-chars');
2073
+ if (elSummaryChars) elSummaryChars.value = c.summaryMaxChars ?? 24000;
2074
+ const elLookback = document.getElementById('idle-compact-lookback');
2075
+ if (elLookback) elLookback.value = c.sessionLookbackHours ?? 72;
2076
+
2077
+ // Badge on hero card
1797
2078
  const badge = document.getElementById('badge-idle-compact');
1798
- badge.textContent = c.enabled ? 'ON' : 'OFF';
1799
- badge.className = 'badge ' + (c.enabled ? 'ok' : 'outline');
2079
+ if (badge) {
2080
+ badge.textContent = enabled ? 'ACTIVE' : 'OFF';
2081
+ badge.className = 'badge ' + (enabled ? 'ok' : 'outline');
2082
+ }
2083
+
2084
+ // Nav link count
2085
+ const ctCompact = document.getElementById('ct-compact');
2086
+ if (ctCompact) {
2087
+ ctCompact.textContent = enabled ? 'ON' : '';
2088
+ }
2089
+
2090
+ // Stats cards
2091
+ const statStatus = document.getElementById('stat-compact-status');
2092
+ if (statStatus) {
2093
+ statStatus.textContent = enabled ? 'Active' : 'Disabled';
2094
+ statStatus.style.color = enabled ? 'var(--ok)' : 'var(--muted)';
2095
+ }
2096
+ const statInd = document.getElementById('stat-compact-indicator');
2097
+ if (statInd) {
2098
+ statInd.textContent = enabled ? 'Prefix caching active' : 'Prefix caching inactive';
2099
+ statInd.className = 'status ' + (enabled ? 'on' : '');
2100
+ }
2101
+ const statPause = document.getElementById('stat-compact-pause');
2102
+ if (statPause) {
2103
+ statPause.textContent = (c.idleMinutes ?? 15) + ' min';
2104
+ }
2105
+ const statSize = document.getElementById('stat-compact-size');
2106
+ if (statSize) {
2107
+ statSize.textContent = ((c.minBytes ?? 65536) / 1024) + ' KB';
2108
+ }
2109
+ const statLookback = document.getElementById('stat-compact-lookback');
2110
+ if (statLookback) {
2111
+ statLookback.textContent = (c.sessionLookbackHours ?? 72) + ' hrs';
2112
+ }
1800
2113
  }
1801
2114
 
1802
2115
  async function loadIdleCompact() {
@@ -1807,14 +2120,35 @@
1807
2120
  }
1808
2121
 
1809
2122
  async function saveIdleCompact() {
2123
+ const isEnabled = document.getElementById('idle-compact-enabled').checked;
2124
+ const configSec = document.getElementById('idle-compact-config-section');
2125
+ if (configSec) configSec.style.display = isEnabled ? 'flex' : 'none';
1810
2126
  const status = document.getElementById('idle-compact-status');
2127
+ for (const input of document.querySelectorAll('[id^="idle-compact-"][type="number"]')) {
2128
+ if (!input.value || !input.checkValidity()) {
2129
+ input.reportValidity();
2130
+ if (status) {
2131
+ status.textContent = 'Not saved: enter a value within the displayed bounds.';
2132
+ status.style.color = 'var(--err)';
2133
+ }
2134
+ return;
2135
+ }
2136
+ }
1811
2137
  const patch = {
2138
+ codex: document.getElementById('idle-compact-codex').checked,
2139
+ keepRecent: Number(document.getElementById('idle-compact-keep').value),
2140
+ userChars: Number(document.getElementById('idle-compact-user-chars').value),
2141
+ summaryMaxChars: Number(document.getElementById('idle-compact-summary-chars').value),
2142
+ sessionLookbackHours: Number(document.getElementById('idle-compact-lookback').value),
1812
2143
  enabled: document.getElementById('idle-compact-enabled').checked,
1813
- idleMinutes: Number(document.getElementById('idle-compact-minutes').value) || 15,
1814
- minBytes: (Number(document.getElementById('idle-compact-kb').value) || 64) * 1024,
2144
+ idleMinutes: Number(document.getElementById('idle-compact-minutes').value),
2145
+ minBytes: Math.round(Number(document.getElementById('idle-compact-kb').value) * 1024),
1815
2146
  model: document.getElementById('idle-compact-model').value.trim()
1816
2147
  };
1817
- status.textContent = 'Saving...';
2148
+ if (status) {
2149
+ status.textContent = 'Saving...';
2150
+ status.style.color = 'var(--muted)';
2151
+ }
1818
2152
  try {
1819
2153
  const r = await api('/api/idle-compact', {
1820
2154
  method: 'POST',
@@ -1823,9 +2157,18 @@
1823
2157
  });
1824
2158
  if (!r.ok) throw new Error((await r.json()).error || r.status);
1825
2159
  showIdleCompact((await r.json()).idleCompact);
1826
- status.textContent = 'Saved. It applies to the next conversation that comes back after a pause.';
2160
+ if (status) {
2161
+ status.textContent = 'Saved. Applies to subsequent conversations that resume after a pause.';
2162
+ status.style.color = 'var(--ok)';
2163
+ setTimeout(() => { if (status) status.textContent = ''; }, 4500);
2164
+ }
2165
+ showToast('Idle compaction configuration saved');
1827
2166
  } catch (e) {
1828
- status.textContent = 'Not saved: ' + e.message;
2167
+ if (status) {
2168
+ status.textContent = 'Not saved: ' + e.message;
2169
+ status.style.color = 'var(--err)';
2170
+ }
2171
+ showToast('Failed to save: ' + e.message, true);
1829
2172
  }
1830
2173
  }
1831
2174
 
@@ -2036,6 +2379,7 @@
2036
2379
  formLoadedKey = key;
2037
2380
  formDirty = false;
2038
2381
  currentProfileBuffer = JSON.parse(JSON.stringify(p));
2382
+ if (!currentProfileBuffer.modelEfforts) currentProfileBuffer.modelEfforts = {};
2039
2383
 
2040
2384
  document.getElementById('form-title').textContent = 'Edit Profile: ' + p.name;
2041
2385
  document.getElementById('p-key').value = key;
@@ -2048,6 +2392,11 @@
2048
2392
  document.getElementById('p-outformat').value = p.outFormat || '';
2049
2393
  document.getElementById('p-mode').value = p.mode || 'convert';
2050
2394
  document.getElementById('p-thinking-mode').value = p.thinkingMode || 'auto';
2395
+ if (document.getElementById('p-thinking-effort')) {
2396
+ document.getElementById('p-thinking-effort').value = p.thinkingEffort || 'auto';
2397
+ }
2398
+ renderModelSlots(toolVal, p);
2399
+ syncEffortOptions();
2051
2400
  document.getElementById('btn-delete').style.display = 'inline-flex';
2052
2401
 
2053
2402
  const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
@@ -2056,8 +2405,6 @@
2056
2405
  if (activeTag) {
2057
2406
  activeTag.textContent = activeTargets.length ? '● Active: ' + activeTargets.join(', ') : '';
2058
2407
  }
2059
-
2060
- renderModelSlots(toolVal, p);
2061
2408
  }
2062
2409
 
2063
2410
  function initNewProfile() {
@@ -2072,7 +2419,8 @@
2072
2419
  outFormat: 'openai-chat',
2073
2420
  mode: 'convert',
2074
2421
  thinkingMode: 'auto',
2075
- defaultModels: {}
2422
+ defaultModels: {},
2423
+ modelEfforts: {}
2076
2424
  };
2077
2425
 
2078
2426
  document.getElementById('form-title').textContent = 'Add New Provider Profile';
@@ -2085,10 +2433,14 @@
2085
2433
  document.getElementById('p-outformat').value = 'openai-chat';
2086
2434
  document.getElementById('p-mode').value = 'convert';
2087
2435
  document.getElementById('p-thinking-mode').value = 'auto';
2436
+ if (document.getElementById('p-thinking-effort')) {
2437
+ document.getElementById('p-thinking-effort').value = 'auto';
2438
+ }
2439
+ renderModelSlots('auto', currentProfileBuffer);
2440
+ syncEffortOptions();
2088
2441
  document.getElementById('btn-delete').style.display = 'none';
2089
2442
  document.getElementById('active-tag').textContent = '';
2090
2443
 
2091
- renderModelSlots('auto', currentProfileBuffer);
2092
2444
  switchTab('tab-general');
2093
2445
  }
2094
2446
 
@@ -2163,6 +2515,8 @@
2163
2515
  document.getElementById('p-outformat').value = t.outFormat;
2164
2516
  document.getElementById('p-mode').value = t.mode;
2165
2517
  document.getElementById('p-thinking-mode').value = t.thinkingMode;
2518
+ document.getElementById('p-thinking-effort').value = t.thinkingEffort || 'auto';
2519
+ syncEffortOptions();
2166
2520
  currentProfileBuffer = { ...currentProfileBuffer, ...t };
2167
2521
  formDirty = true;
2168
2522
  renderModelSlots(t.tool || 'auto', t);
@@ -2218,6 +2572,13 @@
2218
2572
  // already had the key.
2219
2573
  const thinkingMode = document.getElementById('p-thinking-mode').value;
2220
2574
  if (thinkingMode !== 'auto' || existing.thinkingMode) profilePayload.thinkingMode = thinkingMode;
2575
+ const thinkingEffort = document.getElementById('p-thinking-effort')?.value;
2576
+ if (thinkingEffort && (thinkingEffort !== 'auto' || existing.thinkingEffort)) {
2577
+ profilePayload.thinkingEffort = thinkingEffort;
2578
+ }
2579
+ if (currentProfileBuffer?.modelEfforts && Object.keys(currentProfileBuffer.modelEfforts).length > 0) {
2580
+ profilePayload.modelEfforts = { ...currentProfileBuffer.modelEfforts };
2581
+ }
2221
2582
 
2222
2583
  if (keyVal === MASKED_KEY) {
2223
2584
  profilePayload.apiKey = existing.apiKey || '';
@@ -2873,6 +3234,13 @@
2873
3234
  // Setup log auto-refresh
2874
3235
  toggleLogAutoRefresh();
2875
3236
 
3237
+ // Setup profile modal effort & bifrost change triggers
3238
+ document.getElementById('p-thinking-mode')?.addEventListener('change', syncEffortOptions);
3239
+ document.getElementById('p-mode')?.addEventListener('change', syncEffortOptions);
3240
+ document.getElementById('p-outformat')?.addEventListener('change', syncEffortOptions);
3241
+ document.getElementById('p-url')?.addEventListener('input', syncEffortOptions);
3242
+ document.getElementById('p-tool')?.addEventListener('change', syncEffortOptions);
3243
+
2876
3244
  // Trigger initial route
2877
3245
  handleRoute();
2878
3246