llm-switcher 1.4.1 → 1.6.0

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.
@@ -0,0 +1,328 @@
1
+ // Idle compaction.
2
+ //
3
+ // The part worth testing hard is the session file, because nothing in any API guarantees it: the
4
+ // shape was read off a real session Claude Code wrote itself, and the only proof it works is that
5
+ // Claude Code reads it back. The test that matters is TestResumeReadsTheBoundary, which runs the
6
+ // real binary.
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { execFile, execFileSync } from 'node:child_process';
13
+ import { promisify } from 'node:util';
14
+
15
+ // Async, never execFileSync: the probe server that answers Claude Code runs in this same process,
16
+ // and a blocking call would stop the event loop from ever answering it.
17
+ const run = promisify(execFile);
18
+ import http from 'node:http';
19
+ import crypto from 'node:crypto';
20
+
21
+ import {
22
+ idleCompactPolicy, conversationKey, idleFor, noteConversation, compactIR,
23
+ summaryMessages, clampSummary, SUMMARY_INSTRUCTION,
24
+ } from '../idlecompact.mjs';
25
+ import { sessionFileFor, claudeSessionId, writeCompaction, readLeaf, claudeConfigDir } from '../claudesession.mjs';
26
+
27
+ const long = 'X'.repeat(4000);
28
+
29
+ function agentIR(turns = 10, fill = 4000) {
30
+ const messages = [{ role: 'user', content: [{ type: 'text', text: 'fix the failing test' }] }];
31
+ for (let i = 0; i < turns; i++) {
32
+ messages.push({ role: 'assistant', content: [
33
+ { type: 'text', text: `step ${i}` },
34
+ { type: 'tool_use', id: `t${i}`, name: 'Bash', input: { command: 'ls' } },
35
+ ] });
36
+ messages.push({ role: 'user', content: [
37
+ { type: 'tool_result', tool_use_id: `t${i}`, content: `turn${i}:` + 'Y'.repeat(fill) },
38
+ ] });
39
+ }
40
+ return { system: 'be brief', messages, tools: [], model: 'claude-x' };
41
+ }
42
+
43
+ function policy(over = {}) {
44
+ return idleCompactPolicy({ idleCompact: { enabled: true, idleMinutes: 15, minBytes: 1024, ...over } });
45
+ }
46
+
47
+ // ---- the policy ----
48
+
49
+ test('the feature is off until it is asked for', () => {
50
+ assert.equal(idleCompactPolicy({}).enabled, false);
51
+ assert.equal(idleCompactPolicy({ idleCompact: { enabled: true } }).enabled, true);
52
+ });
53
+
54
+ test('a nonsense setting falls back to the default instead of breaking', () => {
55
+ const p = idleCompactPolicy({ idleCompact: { idleMinutes: -3, minBytes: 'x', keepRecent: 0 } });
56
+ assert.equal(p.idleMinutes, 15);
57
+ assert.ok(p.minBytes > 0);
58
+ assert.equal(p.keepRecent, 6);
59
+ });
60
+
61
+ test('a conversation that was never here is not idle', () => {
62
+ assert.equal(idleFor('nope'), 0);
63
+ });
64
+
65
+ test('the time since the last request is what the pause means', () => {
66
+ const now = 1_000_000;
67
+ noteConversation('k', now - 20 * 60000);
68
+ assert.ok(Math.abs(idleFor('k', now) - 20 * 60000) < 50);
69
+ });
70
+
71
+ // ---- which conversation ----
72
+
73
+ test('the session the client names wins over its content', () => {
74
+ const k = conversationKey('claude', { headers: { 'x-claude-code-session-id': 'abc' } }, agentIR());
75
+ assert.equal(k, 'h:abc');
76
+ });
77
+
78
+ test('a client that names nothing is keyed on its opening request', () => {
79
+ const k1 = conversationKey('claude', { headers: {} }, agentIR());
80
+ const k2 = conversationKey('claude', { headers: {} }, agentIR());
81
+ assert.equal(k1, k2, 'the same conversation keys the same both times');
82
+ const other = agentIR();
83
+ other.messages[0].content[0].text = 'a different task entirely';
84
+ assert.notEqual(k1, conversationKey('claude', { headers: {} }, other));
85
+ });
86
+
87
+ test('the key survives the compaction it causes', () => {
88
+ // The opening request is the one message a compaction keeps, which is the whole reason the
89
+ // fallback key is built from it and not from the whole body.
90
+ const ir = agentIR();
91
+ const before = conversationKey('claude', { headers: {} }, ir);
92
+ const c = compactIR(ir, policy());
93
+ ir.messages = c.messages;
94
+ assert.equal(conversationKey('claude', { headers: {} }, ir), before);
95
+ });
96
+
97
+ // ---- the shortened history ----
98
+
99
+ test('the opening request and the recent turns survive as bytes', () => {
100
+ const ir = agentIR(10);
101
+ const c = compactIR(ir, policy({ keepRecent: 6 }));
102
+ assert.ok(c);
103
+ assert.equal(c.messages[0].content[0].text, 'fix the failing test');
104
+ const tail = JSON.stringify(c.messages.slice(-6));
105
+ assert.ok(tail.includes('tool_use'), 'the recent tool calls are kept: the next turn reasons over them');
106
+ });
107
+
108
+ test('the bulky middle goes and its words stay', () => {
109
+ // Each turn is tagged, so the test can tell an early turn from a recent one. The recent turns
110
+ // keep their tool output on purpose: the next turn reasons over those results verbatim.
111
+ const ir = agentIR(10);
112
+ const c = compactIR(ir, policy({ keepRecent: 6 }));
113
+ const text = JSON.stringify(c.messages);
114
+ assert.ok(!text.includes('turn1:'), 'an early middle tool result came through whole');
115
+ assert.ok(text.includes('turn9:'), 'a recent tool result was cut, so the next turn lost what it reasons over');
116
+ assert.ok(text.includes('step 3'), 'a middle assistant message lost its text');
117
+ assert.ok(c.dropped > 0);
118
+ });
119
+
120
+ test('no tool result survives without the call that made it', () => {
121
+ // A result whose call was cut away is a request the provider refuses, so this is the invariant
122
+ // that has to hold whatever the window lands on.
123
+ for (const keep of [3, 4, 5, 6, 8]) {
124
+ const c = compactIR(agentIR(12), policy({ keepRecent: keep }));
125
+ assert.ok(c, `keepRecent=${keep} produced nothing`);
126
+ const calls = new Set();
127
+ for (const m of c.messages) {
128
+ for (const p of m.content || []) {
129
+ if (p?.type === 'tool_use') calls.add(p.id);
130
+ if (p?.type === 'tool_result') {
131
+ assert.ok(calls.has(p.tool_use_id), `keepRecent=${keep}: result ${p.tool_use_id} has no call`);
132
+ }
133
+ }
134
+ }
135
+ }
136
+ });
137
+
138
+ test('a conversation too short to be worth it is left alone', () => {
139
+ assert.equal(compactIR(agentIR(2, 10), policy()), null);
140
+ assert.equal(compactIR({ messages: [{ role: 'user', content: 'hi' }] }, policy()), null);
141
+ });
142
+
143
+ // ---- the summary ----
144
+
145
+ test('the summarizer is handed the history without its tool output', () => {
146
+ const msgs = summaryMessages(agentIR(10), policy());
147
+ assert.ok(msgs.length > 1);
148
+ assert.match(msgs[0].content[0].text, /summarize/i);
149
+ assert.ok(!JSON.stringify(msgs).includes('Y'.repeat(4000)));
150
+ });
151
+
152
+ test('an over-long summary is cut at a boundary, not mid-word', () => {
153
+ const s = clampSummary('A'.repeat(10) + '\n\n' + 'B'.repeat(30000), policy({ summaryMaxChars: 1000 }));
154
+ assert.ok(s.length <= 1000);
155
+ assert.ok(!s.includes('A'.repeat(11)));
156
+ });
157
+
158
+ test('a summary of nothing is nothing', () => {
159
+ assert.equal(clampSummary('', policy()), '');
160
+ assert.equal(clampSummary(null, policy()), '');
161
+ });
162
+
163
+ // ---- the session file ----
164
+
165
+ function tmpSession(t) {
166
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-sess-'));
167
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
168
+ const file = path.join(dir, 'sess.jsonl');
169
+ return { file, dir };
170
+ }
171
+
172
+ function seed(file, entries) {
173
+ fs.writeFileSync(file, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
174
+ }
175
+
176
+ test('the compaction is written as the two entries Claude Code writes', (t) => {
177
+ const { file } = tmpSession(t);
178
+ seed(file, [{ type: 'user', uuid: 'u1', sessionId: 's1', message: { role: 'user', content: 'a' } }]);
179
+ const r = writeCompaction({ file, summary: 'the summary', sessionId: 's1', preTokens: 900, postTokens: 80 });
180
+ assert.ok(r);
181
+ const lines = fs.readFileSync(file, 'utf8').trim().split('\n').map(l => JSON.parse(l));
182
+ const boundary = lines.at(-2);
183
+ const summary = lines.at(-1);
184
+ assert.equal(boundary.type, 'system');
185
+ assert.equal(boundary.subtype, 'compact_boundary');
186
+ assert.equal(boundary.logicalParentUuid, 'u1', 'the chain is cut at the entry before');
187
+ assert.equal(boundary.compactMetadata.preTokens, 900);
188
+ assert.equal(summary.type, 'user');
189
+ assert.equal(summary.isCompactSummary, true);
190
+ assert.match(summary.message.content, /the summary/);
191
+ assert.match(summary.message.content, /continued from a previous conversation/);
192
+ });
193
+
194
+ test('the old entries stay, because a transcript is worth more than a small file', (t) => {
195
+ // This is what Claude Code itself does: its own compaction drops ~949k tokens and leaves the
196
+ // entries. The boundary makes them unreachable, not gone.
197
+ const { file } = tmpSession(t);
198
+ seed(file, Array.from({ length: 20 }, (_, i) => ({ type: 'user', uuid: `u${i}`, sessionId: 's1', message: { role: 'user', content: `m${i}` } })));
199
+ writeCompaction({ file, summary: 'the summary', sessionId: 's1' });
200
+ const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
201
+ assert.equal(lines.length, 22);
202
+ assert.ok(lines.some(l => l.includes('"m0"')), 'the earliest entry is still readable');
203
+ });
204
+
205
+ test('a session already compacted is not compacted twice', (t) => {
206
+ const { file } = tmpSession(t);
207
+ seed(file, [{ type: 'user', uuid: 'u1', sessionId: 's1', message: { role: 'user', content: 'a' } }]);
208
+ assert.ok(writeCompaction({ file, summary: 'first', sessionId: 's1' }));
209
+ assert.equal(writeCompaction({ file, summary: 'second', sessionId: 's1' }), null);
210
+ });
211
+
212
+ test('nothing is written where there is nothing to write', (t) => {
213
+ const { file } = tmpSession(t);
214
+ seed(file, [{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'a' } }]);
215
+ assert.equal(writeCompaction({ file, summary: '', sessionId: 's1' }), null);
216
+ assert.equal(writeCompaction({ file: path.join(path.dirname(file), 'gone.jsonl'), summary: 'x' }), null);
217
+ assert.equal(readLeaf(file).uuid, 'u1');
218
+ });
219
+
220
+ test('every appended line is valid json', (t) => {
221
+ // A torn line makes Claude Code drop the rest of the file, which loses the transcript.
222
+ const { file } = tmpSession(t);
223
+ seed(file, [{ type: 'user', uuid: 'u1', sessionId: 's1', message: { role: 'user', content: 'a' } }]);
224
+ writeCompaction({ file, summary: 'multi\nline\nsummary', sessionId: 's1' });
225
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
226
+ if (line.trim()) JSON.parse(line);
227
+ }
228
+ });
229
+
230
+ test('the session id comes from the header or the body Claude Code sends', () => {
231
+ assert.equal(claudeSessionId({ headers: { 'x-claude-code-session-id': 'S1' } }, {}), 'S1');
232
+ assert.equal(claudeSessionId({ headers: {} }, { metadata: { user_id: 'u_1_account_a_session_dead-beef' } }), 'dead-beef');
233
+ assert.equal(claudeSessionId({ headers: {} }, {}), '');
234
+ });
235
+
236
+ test('the session file sits where Claude Code puts it', () => {
237
+ const { file } = sessionFileFor('abc', '/opt/hermes/proj', { CLAUDE_CONFIG_DIR: '/cfg' });
238
+ assert.equal(file, '/cfg/projects/-opt-hermes-proj/abc.jsonl');
239
+ assert.equal(claudeConfigDir({ CLAUDE_CONFIG_DIR: '/x' }), '/x');
240
+ });
241
+
242
+ // ---- the real thing ----
243
+
244
+ // This is the test that settles whether writing the file does anything at all. It runs the real
245
+ // Claude Code binary against a local server and reads the request it sends.
246
+ const probe = (() => {
247
+ let last = null;
248
+ const server = http.createServer((req, res) => {
249
+ let b = '';
250
+ req.on('data', c => (b += c));
251
+ req.on('end', () => {
252
+ try { last = JSON.parse(b); } catch { /* keep the last good one */ }
253
+ res.writeHead(200, { 'Content-Type': 'application/json' });
254
+ res.end(JSON.stringify({
255
+ id: 'msg_probe', type: 'message', role: 'assistant', model: last?.model || 'x',
256
+ content: [{ type: 'text', text: 'probe ok' }], stop_reason: 'end_turn',
257
+ usage: { input_tokens: 1, output_tokens: 1 },
258
+ }));
259
+ });
260
+ });
261
+ return { server, get last() { return last; } };
262
+ })();
263
+
264
+ test('Claude Code resumes from the boundary and leaves the old turns out', { timeout: 240000, skip: !hasClaude() }, async () => {
265
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-cc-'));
266
+ await new Promise(r => probe.server.listen(0, '127.0.0.1', r));
267
+ const port = probe.server.address().port;
268
+ const cfg = path.join(dir, 'cfg');
269
+ const proj = path.join(dir, 'proj');
270
+ fs.mkdirSync(proj, { recursive: true });
271
+ fs.writeFileSync(path.join(proj, 'CLAUDE.md'), 'probe project\n');
272
+ const env = {
273
+ ...process.env,
274
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
275
+ ANTHROPIC_API_KEY: 'probe-key',
276
+ CLAUDE_CONFIG_DIR: cfg,
277
+ };
278
+
279
+ // One real turn, so the session file is written by Claude Code and has its own shape in it.
280
+ await run('claude', ['-p', 'say ok', '--model', 'claude-probe-1'], { env, cwd: proj, timeout: 180000 });
281
+ const sessions = path.join(cfg, 'projects', proj.replace(/[\\/:]/g, '-'));
282
+ const file = fs.readdirSync(sessions).map(f => path.join(sessions, f))[0];
283
+ const sid = path.basename(file, '.jsonl');
284
+ const sidFromBody = JSON.parse(fs.readFileSync(file, 'utf8').split('\n')[0]).sessionId;
285
+
286
+ // Twelve turns of history, then a compaction boundary before them.
287
+ const entries = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
288
+ const leaf = entries.filter(e => e.uuid).at(-1);
289
+ const base = (u, parent) => ({
290
+ parentUuid: parent, isSidechain: false, userType: 'external', cwd: proj,
291
+ version: '2.1.292', sessionId: sidFromBody || sid, uuid: u, timestamp: new Date().toISOString(),
292
+ });
293
+ let parent = leaf.uuid;
294
+ for (let i = 0; i < 12; i++) {
295
+ const u = crypto.randomUUID();
296
+ entries.push({ ...base(u, parent), type: 'user', message: { role: 'user', content: `turn ${i} question` } });
297
+ parent = u;
298
+ const a = crypto.randomUUID();
299
+ entries.push({ ...base(a, parent), type: 'assistant', message: { role: 'assistant', content: [
300
+ { type: 'text', text: `answer ${i}` },
301
+ { type: 'tool_use', id: `toolu_${i}`, name: 'Read', input: { file_path: '/x/y.py' } }] } });
302
+ parent = a;
303
+ const t = crypto.randomUUID();
304
+ entries.push({ ...base(t, parent), type: 'user', message: { role: 'user', content: [
305
+ { type: 'tool_result', tool_use_id: `toolu_${i}`, content: long }] } });
306
+ parent = t;
307
+ }
308
+ fs.writeFileSync(file, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
309
+ const beforeBytes = fs.statSync(file).size;
310
+
311
+ const wrote = writeCompaction({ file, summary: 'PROBE_SUMMARY_MARKER_12345', sessionId: sid, cwd: proj });
312
+ assert.ok(wrote, 'the boundary was written');
313
+
314
+ await run('claude', ['-p', 'after compact, reply ok', '--model', 'claude-probe-1', '--resume', sid],
315
+ { env, cwd: proj, timeout: 180000 });
316
+
317
+ const sent = JSON.stringify(probe.last?.messages || []);
318
+ assert.match(sent, /PROBE_SUMMARY_MARKER_12345/, 'the summary is what the request carries');
319
+ assert.equal((sent.match(new RegExp(long, 'g')) || []).length, 0, 'none of the twelve turns came through');
320
+ assert.ok(fs.statSync(file).size > beforeBytes, 'the entries are appended, not rewritten');
321
+
322
+ await new Promise(r => probe.server.close(r));
323
+ fs.rmSync(dir, { recursive: true, force: true });
324
+ });
325
+
326
+ function hasClaude() {
327
+ try { execFileSync('claude', ['--version'], { stdio: 'ignore', timeout: 30000 }); return true; } catch { return false; }
328
+ }
package/ui.html CHANGED
@@ -952,6 +952,57 @@
952
952
  <!-- VIEW 1: Routes & Profiles -->
953
953
  <div id="view-routes" class="view-panel" style="display:none;">
954
954
  <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
+
955
1006
  <!-- Active Coding CLI Targets -->
956
1007
  <div class="card">
957
1008
  <div class="cardh">
@@ -1461,6 +1512,7 @@
1461
1512
  const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
1462
1513
 
1463
1514
  if (route === 'routes') {
1515
+ loadIdleCompact();
1464
1516
  titleEl.textContent = 'Routes & Profiles';
1465
1517
  if (activeTargets.length > 0) {
1466
1518
  const parts = [];
@@ -1734,6 +1786,49 @@
1734
1786
  }
1735
1787
 
1736
1788
  // Load Status
1789
+ // Idle compaction, read and written through the same endpoint `switch compact` uses, so the
1790
+ // dashboard and the terminal cannot show two different settings.
1791
+ function showIdleCompact(c) {
1792
+ 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 || '';
1797
+ const badge = document.getElementById('badge-idle-compact');
1798
+ badge.textContent = c.enabled ? 'ON' : 'OFF';
1799
+ badge.className = 'badge ' + (c.enabled ? 'ok' : 'outline');
1800
+ }
1801
+
1802
+ async function loadIdleCompact() {
1803
+ try {
1804
+ const r = await api('/api/idle-compact');
1805
+ if (r.ok) showIdleCompact((await r.json()).idleCompact);
1806
+ } catch { /* the card stays at its defaults; nothing here is required to route */ }
1807
+ }
1808
+
1809
+ async function saveIdleCompact() {
1810
+ const status = document.getElementById('idle-compact-status');
1811
+ const patch = {
1812
+ 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,
1815
+ model: document.getElementById('idle-compact-model').value.trim()
1816
+ };
1817
+ status.textContent = 'Saving...';
1818
+ try {
1819
+ const r = await api('/api/idle-compact', {
1820
+ method: 'POST',
1821
+ headers: { 'Content-Type': 'application/json' },
1822
+ body: JSON.stringify(patch)
1823
+ });
1824
+ if (!r.ok) throw new Error((await r.json()).error || r.status);
1825
+ showIdleCompact((await r.json()).idleCompact);
1826
+ status.textContent = 'Saved. It applies to the next conversation that comes back after a pause.';
1827
+ } catch (e) {
1828
+ status.textContent = 'Not saved: ' + e.message;
1829
+ }
1830
+ }
1831
+
1737
1832
  async function loadStatus() {
1738
1833
  try {
1739
1834
  const res = await api('/api/status');