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.
@@ -594,7 +594,13 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
594
594
  const view = () => page.evaluate(`[...document.querySelectorAll('.view-panel')].filter(p => p.style.display !== 'none').map(p => p.id)`);
595
595
 
596
596
  it('opens each page from the sidebar and follows the back button', async () => {
597
- for (const [nav, id, title] of [['models', 'view-models', 'Dynamic Model Catalog'], ['logs', 'view-logs', 'Request Inspector'], ['doctor', 'view-doctor', 'System Health & Doctor'], ['routes', 'view-routes', 'Routes & Profiles']]) {
597
+ for (const [nav, id, title] of [
598
+ ['compact', 'view-compact', 'Idle Compaction'],
599
+ ['models', 'view-models', 'Dynamic Model Catalog'],
600
+ ['logs', 'view-logs', 'Request Inspector'],
601
+ ['doctor', 'view-doctor', 'System Health & Doctor'],
602
+ ['routes', 'view-routes', 'Routes & Profiles']
603
+ ]) {
598
604
  await act(() => page.click(`#sideNav a[data-nav="${nav}"]`));
599
605
  assert.deepEqual(await view(), [id], `${nav}: wrong page`);
600
606
  assert.equal(await page.text('#page-title'), title);
@@ -12,7 +12,7 @@ import {
12
12
  createChatStream, buildChatMessage, chatFinish, anthropicStopReason,
13
13
  smartText, smartReasoning, sanitizeJsonSchema, normalizeUpstream, buildVertexMessage,
14
14
  createCodeAssistStream,
15
- budgetToEffort, effortToBudget, normalizeEffort, capEffort, EFFORT_LEVELS
15
+ budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS
16
16
  } from '../formats.mjs';
17
17
  import { assertValidAnthropicEvents } from './helpers.mjs';
18
18
 
@@ -1010,3 +1010,46 @@ test('a level becomes the budget each API speaks, and Gemini gets one it accepts
1010
1010
  // Anthropic keeps the budget it was given, only under max_tokens as its own rule requires.
1011
1011
  assert.equal(irToAnthropicBody(anthropicToIR({ model: 'm', max_tokens: 200000, messages: [{ role: 'user', content: 'hi' }], thinking: { type: 'enabled', budget_tokens: 100000 } }), 'm').thinking.budget_tokens, 100000);
1012
1012
  });
1013
+
1014
+ test('mapEquivalentEffort maps to nearest supported level when target lacks exact match', () => {
1015
+ const strict = ['low', 'medium', 'high'];
1016
+ assert.equal(mapEquivalentEffort('ultra', strict), 'high');
1017
+ assert.equal(mapEquivalentEffort('max', strict), 'high');
1018
+ assert.equal(mapEquivalentEffort('xhigh', strict), 'high');
1019
+ assert.equal(mapEquivalentEffort('high', strict), 'high');
1020
+ assert.equal(mapEquivalentEffort('medium', strict), 'medium');
1021
+ assert.equal(mapEquivalentEffort('low', strict), 'low');
1022
+ assert.equal(mapEquivalentEffort('minimal', strict), 'low');
1023
+
1024
+ // Gap in target levels: ['low', 'max']
1025
+ assert.equal(mapEquivalentEffort('xhigh', ['low', 'max']), 'max');
1026
+ assert.equal(mapEquivalentEffort('medium', ['low', 'max']), 'low');
1027
+
1028
+ // Intact synonyms: extra-low -> minimal
1029
+ assert.equal(mapEquivalentEffort('extra-low', ['minimal', 'medium', 'high']), 'minimal');
1030
+ });
1031
+
1032
+ test('thinkingEffort profile option overrides client effort across all upstreams', () => {
1033
+ const clientIR = responsesToIR({ model: 'gpt-5', max_output_tokens: 16000, reasoning: { effort: 'low' }, input: [{ role: 'user', content: 'solve' }] });
1034
+
1035
+ // 1. Force 'high' on OpenAI Chat
1036
+ const chatBody = emitUpstreamBody('openai-chat', clientIR, 'gpt-5', { thinkingEffort: 'high' });
1037
+ assert.equal(chatBody.reasoning_effort, 'high');
1038
+
1039
+ // 2. Force 'high' on Anthropic
1040
+ const anthropicBody = emitUpstreamBody('anthropic', clientIR, 'claude-3-7-sonnet', { thinkingEffort: 'high' });
1041
+ assert.equal(anthropicBody.output_config?.effort, 'high');
1042
+ assert.equal(anthropicBody.thinking?.budget_tokens, 8000);
1043
+
1044
+ // 3. Force 'high' on Vertex (Gemini)
1045
+ const vertexBody = emitUpstreamBody('vertex', clientIR, 'gemini-2.5-pro', { thinkingEffort: 'high' });
1046
+ assert.equal(vertexBody.generationConfig?.thinkingConfig?.thinkingBudget, 8000);
1047
+
1048
+ // 4. Force 'xhigh' on strict native OpenAI caps to 'high'
1049
+ const nativeChat = emitUpstreamBody('openai-chat', clientIR, 'gpt-5', { thinkingMode: 'native', thinkingEffort: 'xhigh' });
1050
+ assert.equal(nativeChat.reasoning_effort, 'high');
1051
+
1052
+ // 5. 'auto' inherits client's original effort ('low')
1053
+ const autoChat = emitUpstreamBody('openai-chat', clientIR, 'gpt-5', { thinkingEffort: 'auto' });
1054
+ assert.equal(autoChat.reasoning_effort, 'low');
1055
+ });
@@ -0,0 +1,203 @@
1
+ // Actual proxy -> HTTP mock upstream; no external provider or official account is used.
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import http from 'node:http';
8
+ import { spawn } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
12
+ const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
13
+ async function unusedPort() {
14
+ const server = http.createServer(); await listen(server); const port = server.address().port;
15
+ await new Promise(resolve => server.close(resolve)); return port;
16
+ }
17
+ async function until(predicate, detail = 'condition', timeout = 5000) {
18
+ const deadline = Date.now() + timeout;
19
+ while (Date.now() < deadline) { if (await predicate()) return; await new Promise(resolve => setTimeout(resolve, 20)); }
20
+ throw new Error(`timeout waiting for ${detail}`);
21
+ }
22
+ function history() {
23
+ const messages = [{ role: 'user', content: 'task' }];
24
+ for (let i = 0; i < 12; i++) messages.push(
25
+ { role: 'assistant', content: [{ type: 'text', text: `DECISION_${i}` },
26
+ { type: 'tool_use', id: `t${i}`, name: 'Read', input: { file_path: '/test' } }] },
27
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: `t${i}`, content: `RESULT_${i} ` + 'X'.repeat(4000) }] });
28
+ return messages;
29
+ }
30
+
31
+ async function fixture(t, { model = '', codex = false } = {}) {
32
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-idle-e2e-'));
33
+ const received = []; const pendingSummaries = [];
34
+ const upstream = http.createServer((req, res) => {
35
+ let raw = '';
36
+ req.on('data', chunk => raw += chunk);
37
+ req.on('end', () => {
38
+ const body = raw ? JSON.parse(raw) : {};
39
+ received.push({ url: req.url, body });
40
+ res.setHeader('content-type', 'application/json');
41
+ if (req.method === 'GET') return res.end(JSON.stringify({ data: [],
42
+ bifrost_ua: req.url.includes('codex') ? 'codex_exec/' : '' }));
43
+ if (body.model === 'cheap-summary') {
44
+ pendingSummaries.push({ res, body }); return;
45
+ }
46
+ if (req.url.endsWith('/responses')) return res.end(JSON.stringify({ id: 'resp_test', object: 'response',
47
+ status: 'completed', model: body.model, output: [{ type: 'message', role: 'assistant',
48
+ content: [{ type: 'output_text', text: 'ok' }] }] }));
49
+ res.end(JSON.stringify({ id: 'msg_test', type: 'message', role: 'assistant', model: body.model,
50
+ content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn', usage: { input_tokens: 2, output_tokens: 1 } }));
51
+ });
52
+ });
53
+ await listen(upstream); const port = await unusedPort();
54
+ const cfg = { port, activeProfiles: {}, profiles: {
55
+ p1: { name: 'Offline', mode: 'hybrid', baseURL: `http://127.0.0.1:${upstream.address().port}/v1`, apiKey: 'fixture-only' },
56
+ }, idleCompact: { enabled: true, codex, model, minBytes: 8192, keepRecent: 6 } };
57
+ const config = path.join(dir, 'config.json'); fs.writeFileSync(config, JSON.stringify(cfg));
58
+ const nativeDir = path.join(dir, 'native'); fs.mkdirSync(nativeDir);
59
+ const nativeFiles = ['projects/test-project/session-one.jsonl', 'sessions/2026/10/08/rollout-A.jsonl',
60
+ 'sessions/2026/10/08/rollout-B.jsonl', 'thread_history_1.sqlite'];
61
+ for (const file of nativeFiles) {
62
+ fs.mkdirSync(path.dirname(path.join(nativeDir, file)), { recursive: true });
63
+ fs.writeFileSync(path.join(nativeDir, file), JSON.stringify({ type: 'user', uuid: 'leaf-one',
64
+ sessionId: 'session-one', message: { role: 'user', content: `UNCHANGED_${file}` } }) + '\n');
65
+ }
66
+ const nativeBefore = nativeFiles.map(file => fs.readFileSync(path.join(nativeDir, file)));
67
+ const nativeMtimes = nativeFiles.map(file => fs.statSync(path.join(nativeDir, file)).mtimeMs);
68
+ let child, logs = '';
69
+ const start = async () => {
70
+ child = spawn(process.execPath, [path.join(root, 'proxy.mjs'), '--port', String(port)], {
71
+ env: { ...process.env, LLM_SWITCHER_CONFIG: config, LLM_SWITCHER_STATE_DIR: dir,
72
+ LLM_SWITCHER_PORT: '', LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/',
73
+ CODEX_HOME: nativeDir, CLAUDE_CONFIG_DIR: nativeDir }, stdio: ['ignore', 'pipe', 'pipe'],
74
+ });
75
+ child.stdout.on('data', chunk => logs += chunk); child.stderr.on('data', chunk => logs += chunk);
76
+ await until(async () => (await fetch(`http://127.0.0.1:${port}/health`).catch(() => null))?.ok, `proxy start: ${logs}`);
77
+ };
78
+ const stop = async () => {
79
+ if (!child || child.exitCode !== null) return;
80
+ const exited = new Promise(resolve => child.once('exit', resolve)); child.kill('SIGTERM');
81
+ await Promise.race([exited, new Promise(resolve => setTimeout(resolve, 2000))]);
82
+ if (child.exitCode === null) { child.kill('SIGKILL'); await exited; }
83
+ };
84
+ t.after(async () => { await stop(); upstream.closeAllConnections?.();
85
+ await new Promise(resolve => upstream.close(resolve)); fs.rmSync(dir, { recursive: true, force: true }); });
86
+ await start();
87
+ const send = async (body, { session = 'session-one', responses = false } = {}) => {
88
+ const response = await fetch(`http://127.0.0.1:${port}/v1/${responses ? 'responses' : 'messages'}`, {
89
+ method: 'POST', headers: { 'content-type': 'application/json', 'x-profile': 'p1',
90
+ ...(responses ? { 'thread-id': session, 'user-agent': 'codex_exec/0.160' } : { 'x-claude-code-session-id': session }) },
91
+ body: JSON.stringify(body) });
92
+ assert.equal(response.status, 200, await response.text());
93
+ return received.filter(entry => !entry.url.includes('/models') && entry.body.model !== 'cheap-summary').at(-1).body;
94
+ };
95
+ const age = () => {
96
+ const files = fs.readdirSync(path.join(dir, 'idle-compact')).filter(file => file.endsWith('.json'));
97
+ for (const file of files) { const location = path.join(dir, 'idle-compact', file);
98
+ const record = JSON.parse(fs.readFileSync(location)); record.seenAt = Date.now() - 16 * 60000;
99
+ fs.writeFileSync(location, JSON.stringify(record)); }
100
+ };
101
+ const finish = index => pendingSummaries[index].res.end(JSON.stringify({ choices: [{ message: { content: `SUMMARY_${index}` } }] }));
102
+ return { dir, config, cfg, received, pendingSummaries, send, age, finish,
103
+ restart: async () => { await stop(); await start(); },
104
+ assertNativeUntouched: () => nativeFiles.forEach((file, index) =>
105
+ { assert.deepEqual(fs.readFileSync(path.join(nativeDir, file)), nativeBefore[index]); assert.equal(fs.statSync(path.join(nativeDir, file)).mtimeMs, nativeMtimes[index]); }),
106
+ logs: () => logs, port };
107
+ }
108
+
109
+ test('proxy retains a stable compact prefix through appended turns and restart without model calls or client writes', { timeout: 20000 }, async t => {
110
+ const f = await fixture(t); const body = { model: 'claude-probe', stream: false, messages: history() };
111
+ const initial = await f.send(body); assert.match(JSON.stringify(initial), /RESULT_1 /);
112
+ f.age(); const shortened = await f.send(body);
113
+ assert.doesNotMatch(JSON.stringify(shortened), /RESULT_1 /); assert.match(JSON.stringify(shortened), /DECISION_1"/);
114
+ assert.ok(JSON.stringify(shortened.messages).length < JSON.stringify(body.messages).length / 2);
115
+ body.messages.push({ role: 'assistant', content: 'NEW_ANSWER' }, { role: 'user', content: 'NEW_REQUEST' });
116
+ const next = await f.send(body); assert.match(JSON.stringify(next), /NEW_ANSWER.*NEW_REQUEST/);
117
+ assert.deepEqual(next.messages.slice(0, shortened.messages.length), shortened.messages);
118
+ await f.restart(); assert.deepEqual((await f.send(body)).messages, next.messages);
119
+ const cachedFile = fs.readdirSync(path.join(f.dir, 'idle-compact')).find(file => file.endsWith('.json'));
120
+ fs.writeFileSync(path.join(f.dir, 'idle-compact', cachedFile), '{bad');
121
+ assert.match(JSON.stringify(await f.send(body)), /RESULT_1 /, 'corrupt cache forwards original history');
122
+ assert.equal(f.pendingSummaries.length, 0); f.assertNativeUntouched();
123
+ });
124
+
125
+ test('delayed summary preserves new suffix, maps its own protocol, and loses CAS to a newer idle generation', { timeout: 20000 }, async t => {
126
+ const f = await fixture(t, { model: 'cheap-summary' });
127
+ const body = { model: 'claude-probe', stream: false, messages: history() };
128
+ await f.send(body); f.age(); await f.send(body);
129
+ await until(() => f.pendingSummaries.length === 1, 'first summary');
130
+ assert.ok(f.received.some(entry => entry.url === '/v1/chat/completions' && entry.body.model === 'cheap-summary'));
131
+ assert.doesNotMatch(JSON.stringify(f.pendingSummaries[0].body), /RESULT_11/);
132
+ body.messages.push({ role: 'assistant', content: 'AFTER_SNAPSHOT_ANSWER' }, { role: 'user', content: 'AFTER_SNAPSHOT_USER' });
133
+ await f.send(body);
134
+ f.finish(0); await until(() => f.logs().includes('summary cached'), 'summary storage');
135
+ const resumed = await f.send(body); assert.match(JSON.stringify(resumed), /SUMMARY_0.*AFTER_SNAPSHOT_ANSWER.*AFTER_SNAPSHOT_USER/);
136
+ // Force a larger prefix during a subsequent idle window while an older generation is pending.
137
+ for (let i = 0; i < 8; i++) body.messages.push({ role: 'assistant', content: `NEW_WINDOW_${i} ` + 'Y'.repeat(1000) }, { role: 'user', content: `question ${i}` });
138
+ f.age(); await f.send(body); await until(() => f.pendingSummaries.length === 2, 'second summary');
139
+ for (let i = 0; i < 8; i++) body.messages.push({ role: 'assistant', content: `THIRD_WINDOW_${i}` }, { role: 'user', content: `third question ${i}` });
140
+ f.age(); await f.send(body); await until(() => f.pendingSummaries.length === 3, 'third summary');
141
+ f.finish(2); await until(() => f.logs().split('summary cached').length >= 3, 'newest summary cached');
142
+ f.finish(1); await until(() => f.logs().includes('discarded after a newer generation'), 'stale summary rejection');
143
+ const latest = JSON.stringify(await f.send(body)); assert.match(latest, /SUMMARY_2/); assert.doesNotMatch(latest, /SUMMARY_1/);
144
+ f.assertNativeUntouched();
145
+ });
146
+
147
+ test('Responses opt-in compacts actual forwarded input, keeps assistant output_text and both native threads untouched', { timeout: 20000 }, async t => {
148
+ const f = await fixture(t, { codex: true, model: 'cheap-summary' });
149
+ const input = [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'task' }] }];
150
+ for (let i = 0; i < 12; i++) input.push(
151
+ { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: `DECISION_${i}` }] },
152
+ { type: 'function_call', call_id: `c${i}`, name: 'read_file', arguments: '{}' },
153
+ { type: 'function_call_output', call_id: `c${i}`, output: `OUTPUT_${i} ` + 'X'.repeat(4000) });
154
+ const body = { model: 'codex/probe', input, stream: false, store: false };
155
+ await f.send(body, { responses: true }); f.age();
156
+ const shortened = await f.send(body, { responses: true });
157
+ assert.doesNotMatch(JSON.stringify(shortened.input), /OUTPUT_1 /); assert.match(JSON.stringify(shortened.input), /DECISION_1/);
158
+ assert.equal(shortened.input.find(item => item.role === 'assistant').content[0].type, 'output_text');
159
+ await until(() => f.pendingSummaries.length === 1, 'Codex configured summary');
160
+ f.finish(0); await until(() => f.logs().includes('summary cached'), 'Codex summary storage');
161
+ body.input.push({ role: 'user', content: 'NEW_CODEX_USER' });
162
+ await f.restart(); assert.match(JSON.stringify(await f.send(body, { responses: true })), /NEW_CODEX_USER/);
163
+ f.assertNativeUntouched();
164
+ });
165
+
166
+ test('native compaction and configuration changes each invalidate an in-flight summary', { timeout: 20000 }, async t => {
167
+ const f = await fixture(t, { model: 'cheap-summary' });
168
+ const body = { model: 'claude-probe', messages: history(), stream: false };
169
+ await f.send(body); f.age(); await f.send(body);
170
+ await until(() => f.pendingSummaries.length === 1, 'summary before native compact');
171
+ const native = { ...body, messages: [{ role: 'user', content: 'NATIVE_CONTEXT' },
172
+ { role: 'assistant', content: 'NATIVE_ANSWER' }, { role: 'user', content: 'NEW_TURN' }] };
173
+ assert.match(JSON.stringify(await f.send(native)), /NATIVE_CONTEXT.*NEW_TURN/);
174
+ f.finish(0); await until(() => f.logs().includes('discarded after a newer generation'), 'native compact discards late summary');
175
+ assert.doesNotMatch(JSON.stringify(await f.send(native)), /SUMMARY_0/);
176
+ // The larger raw history replaces the native prefix and starts its own idle window.
177
+ await f.send(body); f.age(); await f.send(body);
178
+ await until(() => f.pendingSummaries.length === 2, 'summary before config change');
179
+ const cfg = JSON.parse(fs.readFileSync(f.config)); cfg.idleCompact.model = null;
180
+ fs.writeFileSync(f.config, JSON.stringify(cfg));
181
+ f.finish(1); await until(() => f.logs().includes('discarded after configuration change'), 'configuration guard');
182
+ assert.doesNotMatch(JSON.stringify(await f.send(body)), /SUMMARY_1/);
183
+ f.assertNativeUntouched();
184
+ });
185
+
186
+ test('API rejects invalid patch without writing config, accepts all settings, and anonymous calls remain unmodified', { timeout: 20000 }, async t => {
187
+ const f = await fixture(t); const token = fs.readFileSync(path.join(f.dir, 'admin.token'), 'utf8').trim();
188
+ const update = patch => fetch(`http://127.0.0.1:${f.port}/api/idle-compact`, { method: 'POST',
189
+ headers: { 'content-type': 'application/json', 'x-llm-switcher-token': token }, body: JSON.stringify(patch) });
190
+ const before = fs.readFileSync(f.config);
191
+ for (const patch of [{ unknown: 1 }, { userChars: 0 }, { codex: 'yes' }, { keepRecent: 0.5 }]) {
192
+ const response = await update(patch); assert.equal(response.status, 400); await response.text();
193
+ assert.deepEqual(fs.readFileSync(f.config), before);
194
+ }
195
+ const valid = await update({ codex: true, model: null, userChars: 128, summaryMaxChars: 256, sessionLookbackHours: 24 });
196
+ assert.equal(valid.status, 200); const settings = await valid.json(); assert.equal(settings.idleCompact.codex, true);
197
+ const body = { model: 'claude-probe', messages: history(), stream: false };
198
+ const response = await fetch(`http://127.0.0.1:${f.port}/v1/messages`, { method: 'POST', headers: {
199
+ 'content-type': 'application/json', 'x-profile': 'p1' }, body: JSON.stringify(body) });
200
+ assert.equal(response.status, 200); await response.text();
201
+ assert.match(JSON.stringify(f.received.at(-1).body), /RESULT_1 /);
202
+ assert.equal(fs.existsSync(path.join(f.dir, 'idle-compact')), false);
203
+ });