llm-switcher 1.2.6 → 1.2.8
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/.impeccable/hook.cache.json +1 -0
- package/CHANGELOG.md +57 -0
- package/README.md +74 -4
- package/README.vi.md +75 -4
- package/blindfold/blindfold.mjs +2 -1
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
- package/docs/cross-platform.md +3 -1
- package/docs/response-matrix.json +1130 -1130
- package/hook-status.mjs +51 -0
- package/notify-route.ps1 +46 -0
- package/package.json +2 -2
- package/plugin.mjs +204 -0
- package/proxy.mjs +8 -3
- package/service.mjs +1 -1
- package/shim.mjs +28 -2
- package/skills/llm-switcher/SKILL.md +93 -93
- package/state.mjs +41 -5
- package/switch +0 -0
- package/switch.cmd +2 -2
- package/switch.mjs +53 -2
- package/tests/blindfold.test.mjs +1 -1
- package/tests/cdp.mjs +297 -0
- package/tests/dashboard-fixture.mjs +167 -0
- package/tests/dashboard.e2e.test.mjs +786 -0
- package/tests/gateway.e2e.test.mjs +1 -1
- package/tests/helpers.mjs +24 -24
- package/tests/hook-status.test.mjs +119 -0
- package/tests/live-optimizer-interop.mjs +205 -205
- package/tests/plugin.test.mjs +132 -0
- package/tests/real-user-sim.test.mjs +2 -2
- package/tests/service.test.mjs +2 -0
- package/tests/shim.test.mjs +112 -0
- package/tests/state.test.mjs +130 -5
- package/tests/ui.test.mjs +1 -1
- package/ui.html +121 -20
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// A real gateway on its own data directory, a stand-in upstream, and a stand-in interceptor.
|
|
2
|
+
// The dashboard tests open this gateway in a browser, so nothing here may touch the real home
|
|
3
|
+
// directory, the real Codex daemon, the real npm registry or a real interceptor.
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
|
+
|
|
14
|
+
function freePort() {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const s = net.createServer();
|
|
17
|
+
s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); });
|
|
18
|
+
s.on('error', reject);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MODELS = { opus: 'up-opus', sonnet: 'up-sonnet', haiku: 'up-haiku' };
|
|
23
|
+
|
|
24
|
+
// The starting point of every test. Two profiles that each serve one tool and one that serves
|
|
25
|
+
// both, with Claude and Codex each on their own profile: the state in the Deactivate bug report.
|
|
26
|
+
export function baselineConfig(port, blindfoldPort, upstreamBase) {
|
|
27
|
+
return {
|
|
28
|
+
port,
|
|
29
|
+
blindfold: { port: blindfoldPort },
|
|
30
|
+
activeProfiles: { claude: 'intact-claude', codex: 'intact-codex' },
|
|
31
|
+
profiles: {
|
|
32
|
+
'intact-claude': { name: 'Intact Claude', mode: 'convert', tool: 'claude', outFormat: 'openai-chat', baseURL: `${upstreamBase}/chat/v1`, apiKey: 'sk-secret-claude', defaultModels: { ...MODELS } },
|
|
33
|
+
'intact-codex': { name: 'Intact Codex', mode: 'convert', tool: 'codex', inFormat: 'responses', outFormat: 'openai-chat', baseURL: `${upstreamBase}/chat/v1`, apiKey: 'sk-secret-codex', defaultModels: { main: 'up-main', review: 'up-review' } },
|
|
34
|
+
shared: { name: 'Shared Router', mode: 'convert', tool: null, outFormat: 'openai-chat', baseURL: `${upstreamBase}/chat/v1`, apiKey: 'sk-secret-shared', defaultModels: { ...MODELS } }
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function startDashboardFixture() {
|
|
40
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-dashboard-'));
|
|
41
|
+
const cfgPath = path.join(dir, 'config.json');
|
|
42
|
+
const received = [];
|
|
43
|
+
// 'ok' answers like a healthy provider; 'deny' answers 401; 'down' answers 503.
|
|
44
|
+
const upstream = { mode: 'ok', received, models: ['up-alpha', 'up-beta', 'up-gamma'], latest: '99.0.0' };
|
|
45
|
+
|
|
46
|
+
const upstreamServer = http.createServer((req, res) => {
|
|
47
|
+
let body = '';
|
|
48
|
+
req.on('data', c => { body += c; });
|
|
49
|
+
req.on('end', () => {
|
|
50
|
+
received.push({ method: req.method, url: req.url, headers: req.headers, body });
|
|
51
|
+
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
|
52
|
+
if (req.url.startsWith('/registry')) return json(200, { version: upstream.latest });
|
|
53
|
+
if (upstream.mode === 'deny') return json(401, { error: { message: 'bad key' } });
|
|
54
|
+
if (upstream.mode === 'down') return json(503, { error: { message: 'upstream is down' } });
|
|
55
|
+
if (req.url.endsWith('/models')) {
|
|
56
|
+
return json(200, { data: upstream.models.map(id => ({ id, context_length: id === 'up-alpha' ? 200000 : 1000000 })) });
|
|
57
|
+
}
|
|
58
|
+
if (req.url.includes('/chat/completions')) {
|
|
59
|
+
return json(200, { id: 'c1', object: 'chat.completion', model: 'up', choices: [{ index: 0, message: { role: 'assistant', content: 'Final answer' }, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 } });
|
|
60
|
+
}
|
|
61
|
+
return json(404, { error: 'not found' });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
await new Promise(r => upstreamServer.listen(0, '127.0.0.1', r));
|
|
65
|
+
const upstreamBase = `http://127.0.0.1:${upstreamServer.address().port}`;
|
|
66
|
+
|
|
67
|
+
const port = await freePort();
|
|
68
|
+
const blindfoldPort = await freePort();
|
|
69
|
+
fs.writeFileSync(cfgPath, JSON.stringify(baselineConfig(port, blindfoldPort, upstreamBase), null, 2));
|
|
70
|
+
|
|
71
|
+
// The model lists that Claude Code and Codex keep for the signed-in account. With both present the
|
|
72
|
+
// catalog reads them from disk and never asks the official endpoints.
|
|
73
|
+
const home = path.join(dir, 'home');
|
|
74
|
+
fs.mkdirSync(path.join(home, '.claude', 'cache', 'model-catalog'), { recursive: true });
|
|
75
|
+
fs.mkdirSync(path.join(home, '.codex'), { recursive: true });
|
|
76
|
+
fs.writeFileSync(path.join(home, '.claude', 'cache', 'model-catalog', 'account.json'), JSON.stringify({
|
|
77
|
+
fetchedAt: 1,
|
|
78
|
+
catalog: { config: { models: [{ id: 'claude-opus-4-6', name: 'Opus' }, { id: 'claude-sonnet-4-6', name: 'Sonnet' }, { id: 'claude-haiku-4-5', name: 'Haiku' }] } }
|
|
79
|
+
}));
|
|
80
|
+
fs.writeFileSync(path.join(home, '.codex', 'models_cache.json'), JSON.stringify({
|
|
81
|
+
client_version: '1.0.0',
|
|
82
|
+
models: [{ slug: 'gpt-5.6-sol', context_window: 400000 }, { slug: 'gpt-5.6-terra', context_window: 400000 }]
|
|
83
|
+
}));
|
|
84
|
+
const env = {
|
|
85
|
+
...process.env,
|
|
86
|
+
HOME: home,
|
|
87
|
+
CODEX_HOME: path.join(home, '.codex'),
|
|
88
|
+
CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
|
|
89
|
+
LLM_SWITCHER_CONFIG: cfgPath,
|
|
90
|
+
LLM_SWITCHER_STATE_DIR: dir,
|
|
91
|
+
LLM_SWITCHER_PORT: '',
|
|
92
|
+
// Without this the gateway reads and writes the certificates of the checkout.
|
|
93
|
+
LLM_SWITCHER_BLINDFOLD_CERTS: path.join(dir, 'certs'),
|
|
94
|
+
LLM_SWITCHER_REGISTRY_URL: `${upstreamBase}/registry/latest`
|
|
95
|
+
};
|
|
96
|
+
// The gateway starts an interceptor of its own when it finds none on the port. This one is up
|
|
97
|
+
// first and answers the identity proof with this gateway's port and tool set, so nothing real
|
|
98
|
+
// is spawned.
|
|
99
|
+
const interceptor = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
100
|
+
const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'state.mjs')).href)});
|
|
101
|
+
const http = await import('node:http');
|
|
102
|
+
s.ensureAdminToken();
|
|
103
|
+
http.createServer((req, res) => {
|
|
104
|
+
const nonce = new URL(req.url, 'http://x').searchParams.get('challenge');
|
|
105
|
+
const activeTools = s.deriveActiveTools(s.loadConfig() || {}).join(',');
|
|
106
|
+
const f = { role: 'blindfold', port: ${blindfoldPort}, pid: process.pid, gatewayPort: ${port}, activeTools };
|
|
107
|
+
res.setHeader('content-type', 'application/json');
|
|
108
|
+
res.end(JSON.stringify({ proxy: 'llm-switcher-blindfold', port: ${blindfoldPort}, pid: process.pid, gatewayPort: ${port}, activeTools, proof: s.identityProof(nonce, f) }));
|
|
109
|
+
}).listen(${blindfoldPort}, '127.0.0.1');
|
|
110
|
+
`], { env, stdio: 'ignore' });
|
|
111
|
+
const deadline = Date.now() + 8000;
|
|
112
|
+
for (;;) {
|
|
113
|
+
const probe = await fetch(`http://127.0.0.1:${blindfoldPort}/?challenge=probe`).catch(() => null);
|
|
114
|
+
if (probe?.ok) break;
|
|
115
|
+
if (Date.now() > deadline) { interceptor.kill(); throw new Error('the stand-in interceptor never came up'); }
|
|
116
|
+
await new Promise(r => setTimeout(r, 100));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const proxy = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
120
|
+
let log = '';
|
|
121
|
+
proxy.stdout.on('data', d => { log += d; });
|
|
122
|
+
proxy.stderr.on('data', d => { log += d; });
|
|
123
|
+
let started = false;
|
|
124
|
+
for (let i = 0; i < 80 && !started; i++) {
|
|
125
|
+
try { started = (await fetch(`http://127.0.0.1:${port}/health`)).ok; } catch { /* not listening yet */ }
|
|
126
|
+
if (!started) await new Promise(r => setTimeout(r, 100));
|
|
127
|
+
}
|
|
128
|
+
if (!started) { proxy.kill(); interceptor.kill(); throw new Error(`gateway did not start:\n${log}`); }
|
|
129
|
+
|
|
130
|
+
const adminToken = () => fs.readFileSync(path.join(dir, 'admin.token'), 'utf8').trim();
|
|
131
|
+
return {
|
|
132
|
+
port,
|
|
133
|
+
dir,
|
|
134
|
+
upstream,
|
|
135
|
+
upstreamBase,
|
|
136
|
+
dashboardUrl: `http://127.0.0.1:${port}/ui`,
|
|
137
|
+
// What is on disk, which the dashboard has to agree with after every action.
|
|
138
|
+
config: () => JSON.parse(fs.readFileSync(cfgPath, 'utf8')),
|
|
139
|
+
// A change made outside the dashboard, as `switch` in a terminal or an editor would make it.
|
|
140
|
+
writeConfig: (cfg) => fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2)),
|
|
141
|
+
baseline: () => baselineConfig(port, blindfoldPort, upstreamBase),
|
|
142
|
+
// The same call the dashboard makes, for a test that needs the server's own answer.
|
|
143
|
+
api: (p, body) => fetch(`http://127.0.0.1:${port}${p}`, {
|
|
144
|
+
method: body ? 'POST' : 'GET',
|
|
145
|
+
headers: { 'x-llm-switcher-token': adminToken(), 'Content-Type': 'application/json' },
|
|
146
|
+
body: body ? JSON.stringify(body) : undefined
|
|
147
|
+
}).then(async r => ({ status: r.status, json: await r.json().catch(() => null) })),
|
|
148
|
+
// One request from Claude Code through the route that is on, so the request inspector has a row.
|
|
149
|
+
messages: () => fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
|
|
152
|
+
body: JSON.stringify({ model: 'claude-opus-4-6', max_tokens: 64, messages: [{ role: 'user', content: 'hello from the test' }] })
|
|
153
|
+
}).then(async r => ({ status: r.status, text: await r.text() })),
|
|
154
|
+
async stop() {
|
|
155
|
+
interceptor.kill();
|
|
156
|
+
proxy.kill();
|
|
157
|
+
upstreamServer.close();
|
|
158
|
+
upstreamServer.closeAllConnections?.();
|
|
159
|
+
// A tool set change can start a detached interceptor of its own; stop the one the gateway recorded.
|
|
160
|
+
try {
|
|
161
|
+
const { pid } = JSON.parse(fs.readFileSync(path.join(dir, 'blindfold.json'), 'utf8'));
|
|
162
|
+
if (Number.isInteger(pid) && pid !== interceptor.pid) process.kill(pid);
|
|
163
|
+
} catch { /* none was started */ }
|
|
164
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|