llm-switcher 1.2.0 → 1.2.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.
- package/CHANGELOG.md +23 -0
- package/README.md +1 -0
- package/blindfold/make-certs.sh +3 -1
- package/catalog.mjs +132 -42
- package/classifier.mjs +238 -0
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
- package/docs/response-matrix.json +1130 -1130
- package/formats.mjs +30 -1
- package/icons/antigravity.png +0 -0
- package/icons/claude.png +0 -0
- package/icons/codex.png +0 -0
- package/icons/deepseek.png +0 -0
- package/icons/gemini.png +0 -0
- package/icons/github.png +0 -0
- package/icons/groq.png +0 -0
- package/icons/intact.svg +1 -0
- package/icons/ollama.png +0 -0
- package/icons/openai.png +0 -0
- package/icons/openrouter.png +0 -0
- package/icons/qwen.png +0 -0
- package/icons/vertex.png +0 -0
- package/mcp.mjs +2 -2
- package/package.json +1 -1
- package/proxy.mjs +34 -20
- package/scripts/run-tests.mjs +3 -1
- package/skills/llm-switcher/SKILL.md +93 -93
- package/state.mjs +58 -4
- package/switch +0 -0
- package/switch.mjs +28 -4
- package/tests/blindfold-e2e.test.mjs +2 -2
- package/tests/blindfold-task5.test.mjs +1 -1
- package/tests/blindfold.task3.test.mjs +2 -2
- package/tests/blindfold.wire.test.mjs +3 -1
- package/tests/catalog.test.mjs +117 -19
- package/tests/classifier.test.mjs +210 -0
- package/tests/codex-daemon.test.mjs +92 -0
- package/tests/formats.test.mjs +30 -0
- package/tests/helpers.mjs +24 -24
- package/tests/lifecycle.test.mjs +49 -37
- package/tests/live-optimizer-interop.mjs +205 -205
- package/tests/make-certs.test.mjs +22 -0
- package/tests/real-user-sim.test.mjs +3 -0
- package/tests/shim.test.mjs +8 -3
- package/tests/switch.test.mjs +30 -24
- package/tests/ui.test.mjs +90 -0
- package/tests/version.test.mjs +78 -0
- package/ui.html +1895 -1616
- package/version.mjs +53 -0
|
@@ -150,7 +150,7 @@ async function startInterceptor({ port, gatewayPort, config, upstreamPort }) {
|
|
|
150
150
|
child.stdout.on('data', (d) => output.push(String(d)));
|
|
151
151
|
child.stderr.on('data', (d) => output.push(String(d)));
|
|
152
152
|
const stop = async () => {
|
|
153
|
-
if (child.exitCode !== null) return;
|
|
153
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
154
154
|
child.kill();
|
|
155
155
|
await once(child, 'exit').catch(() => {});
|
|
156
156
|
};
|
|
@@ -181,7 +181,7 @@ async function startGateway(t, port, cfgPath) {
|
|
|
181
181
|
child.stdout.on('data', (d) => { log += d; });
|
|
182
182
|
child.stderr.on('data', (d) => { log += d; });
|
|
183
183
|
const stop = async () => {
|
|
184
|
-
if (child.exitCode !== null) return;
|
|
184
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
185
185
|
child.kill();
|
|
186
186
|
await once(child, 'exit').catch(() => {});
|
|
187
187
|
};
|
|
@@ -113,7 +113,7 @@ async function startInterceptor({ port, gatewayPort, config, activeTools = 'clau
|
|
|
113
113
|
child.stderr.on('data', (d) => output.push(String(d)));
|
|
114
114
|
|
|
115
115
|
const stop = async () => {
|
|
116
|
-
if (child.exitCode !== null) return;
|
|
116
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
117
117
|
child.kill();
|
|
118
118
|
await once(child, 'exit').catch(() => {});
|
|
119
119
|
};
|
|
@@ -126,7 +126,7 @@ async function startInterceptor({ port, gatewayPort, config, activeTools = 'clau
|
|
|
126
126
|
child.stderr.on('data', (d) => output.push(String(d)));
|
|
127
127
|
|
|
128
128
|
const stop = async () => {
|
|
129
|
-
if (child.exitCode !== null) return; // already gone: waiting for 'exit' would never resolve
|
|
129
|
+
if (child.exitCode !== null || child.signalCode !== null) return; // already gone: waiting for 'exit' would never resolve
|
|
130
130
|
child.kill();
|
|
131
131
|
await once(child, 'exit').catch(() => {});
|
|
132
132
|
};
|
|
@@ -650,7 +650,7 @@ test('R3b: an interceptor from 1.1.11 proves the old way and is replaced, not tr
|
|
|
650
650
|
`;
|
|
651
651
|
const legacy = spawn(process.execPath, ['--input-type=module', '-e', legacySrc], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
652
652
|
t.after(async () => {
|
|
653
|
-
if (legacy.exitCode !== null) return;
|
|
653
|
+
if (legacy.exitCode !== null || legacy.signalCode !== null) return;
|
|
654
654
|
legacy.kill();
|
|
655
655
|
await once(legacy, 'exit').catch(() => {});
|
|
656
656
|
});
|
|
@@ -53,7 +53,9 @@ async function startInterceptor() {
|
|
|
53
53
|
const port = await freePort();
|
|
54
54
|
const child = spawn(process.execPath, [path.join(ROOT, 'blindfold', 'blindfold.mjs'),
|
|
55
55
|
'--port', String(port), '--gateway-port', String(gatewayPort), '--certs', certDir,
|
|
56
|
-
'--capture', captureDir, '--token-file', path.join(dir, 'admin.token')
|
|
56
|
+
'--capture', captureDir, '--token-file', path.join(dir, 'admin.token'),
|
|
57
|
+
// Since R3 only the paths of an active tool reach the gateway; everything else passes through.
|
|
58
|
+
'--active-tools', 'codex'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
57
59
|
let out = '';
|
|
58
60
|
child.stdout.on('data', d => { out += d; });
|
|
59
61
|
child.stderr.on('data', d => { out += d; });
|
package/tests/catalog.test.mjs
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
refreshCatalog,
|
|
15
15
|
detectToolVersion,
|
|
16
16
|
checkVersionAndRefresh,
|
|
17
|
+
readLocalToolModels,
|
|
18
|
+
syncLocalCatalog,
|
|
17
19
|
BASELINE_MODELS
|
|
18
20
|
} from '../catalog.mjs';
|
|
19
21
|
|
|
@@ -124,24 +126,120 @@ test('detectToolVersion extracts semantic version from various User-Agent string
|
|
|
124
126
|
assert.equal(detectToolVersion({}), '');
|
|
125
127
|
});
|
|
126
128
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
129
|
+
// Local model lists that the tools write themselves, in the formats of Codex and Claude Code.
|
|
130
|
+
function writeToolCaches(root, { codex = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'], claude = ['claude-opus-5-5', 'claude-sonnet-5-5'] } = {}) {
|
|
131
|
+
const codexHome = path.join(root, 'codex');
|
|
132
|
+
const claudeDir = path.join(root, 'claude');
|
|
133
|
+
fs.mkdirSync(codexHome, { recursive: true });
|
|
134
|
+
fs.mkdirSync(path.join(claudeDir, 'cache', 'model-catalog'), { recursive: true });
|
|
135
|
+
if (codex) {
|
|
136
|
+
fs.writeFileSync(path.join(codexHome, 'models_cache.json'), JSON.stringify({
|
|
137
|
+
fetched_at: '2026-09-29T01:00:00Z', client_version: '0.158.0',
|
|
138
|
+
models: codex.map(slug => ({ slug, display_name: slug, context_window: 1000000, visibility: 'list' }))
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
if (claude) {
|
|
142
|
+
// An older file with another list: the newest fetchedAt wins.
|
|
143
|
+
fs.writeFileSync(path.join(claudeDir, 'cache', 'model-catalog', 'old.json'), JSON.stringify({
|
|
144
|
+
version: 2, fetchedAt: 1000, catalog: { surface: 'cc', config: { models: [{ id: 'claude-opus-4-6', name: 'Opus 4.6' }] } }
|
|
145
|
+
}));
|
|
146
|
+
fs.writeFileSync(path.join(claudeDir, 'cache', 'model-catalog', 'new.json'), JSON.stringify({
|
|
147
|
+
version: 2, fetchedAt: 2000, catalog: { surface: 'cc', config: { models: claude.map(id => ({ id, name: id })) } }
|
|
148
|
+
}));
|
|
146
149
|
}
|
|
150
|
+
return { codexHome, claudeDir };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function failingFetch(t) {
|
|
154
|
+
const realFetch = globalThis.fetch;
|
|
155
|
+
let calls = 0;
|
|
156
|
+
globalThis.fetch = async () => { calls++; return new Response('{}', { status: 401 }); };
|
|
157
|
+
t.after(() => { globalThis.fetch = realFetch; });
|
|
158
|
+
return () => calls;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
test('readLocalToolModels reads the model lists that Codex and Claude Code keep on disk', (t) => {
|
|
162
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-local-models-'));
|
|
163
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
164
|
+
const sources = writeToolCaches(root);
|
|
165
|
+
|
|
166
|
+
const codex = readLocalToolModels('codex', sources);
|
|
167
|
+
assert.deepEqual(codex.models.map(m => [m.id, m.role]), [['gpt-5.6-sol', 'main'], ['gpt-5.6-terra', 'review'], ['gpt-5.6-luna', 'subagent']]);
|
|
168
|
+
assert.equal(codex.version, '0.158.0');
|
|
169
|
+
|
|
170
|
+
const claude = readLocalToolModels('claude', sources);
|
|
171
|
+
assert.deepEqual(claude.models.map(m => [m.id, m.tier]), [['claude-opus-5-5', 'opus'], ['claude-sonnet-5-5', 'sonnet']]);
|
|
172
|
+
|
|
173
|
+
const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-no-models-'));
|
|
174
|
+
t.after(() => fs.rmSync(empty, { recursive: true, force: true }));
|
|
175
|
+
assert.equal(readLocalToolModels('codex', { codexHome: empty, claudeDir: empty }), null);
|
|
176
|
+
assert.equal(readLocalToolModels('claude', { codexHome: empty, claudeDir: empty }), null);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('refreshCatalog takes the local lists and drops the built-in names', async (t) => {
|
|
180
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-refresh-local-'));
|
|
181
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
182
|
+
const sources = writeToolCaches(root);
|
|
183
|
+
const calls = failingFetch(t);
|
|
184
|
+
|
|
185
|
+
const cache = await refreshCatalog(root, { sources });
|
|
186
|
+
assert.deepEqual(cache.codex.models.map(m => m.id), ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']);
|
|
187
|
+
assert.deepEqual(cache.claude.models.map(m => m.id), ['claude-opus-5-5', 'claude-sonnet-5-5']);
|
|
188
|
+
assert.equal(calls(), 0, 'a local list needs no network call');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('checkVersionAndRefresh records a version only after the catalog for it is refreshed', async (t) => {
|
|
192
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-bump-test-'));
|
|
193
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
194
|
+
failingFetch(t);
|
|
195
|
+
|
|
196
|
+
// No local list and a failing official list: nothing is refreshed, so the version stays unseen.
|
|
197
|
+
const none = { codexHome: path.join(root, 'none'), claudeDir: path.join(root, 'none') };
|
|
198
|
+
checkVersionAndRefresh('codex', { 'user-agent': 'codex_cli_rs/0.158.0 (Mac OS)' }, root, undefined, none);
|
|
199
|
+
await new Promise(r => setTimeout(r, 50));
|
|
200
|
+
assert.equal(loadCatalogCache(root).codex.lastSeenVersion, undefined, 'a failed refresh is tried again');
|
|
201
|
+
|
|
202
|
+
// Codex wrote its list: the next request refreshes and records the version.
|
|
203
|
+
const sources = writeToolCaches(root, { codex: ['gpt-5.7-sol'] });
|
|
204
|
+
checkVersionAndRefresh('codex', { 'user-agent': 'codex_cli_rs/0.158.0 (Mac OS)' }, root, undefined, sources);
|
|
205
|
+
await new Promise(r => setTimeout(r, 50));
|
|
206
|
+
const after = loadCatalogCache(root);
|
|
207
|
+
assert.equal(after.codex.lastSeenVersion, '0.158.0');
|
|
208
|
+
assert.deepEqual(after.codex.models.map(m => m.id), ['gpt-5.7-sol']);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// A profile key belongs to that profile's baseURL. The official model lists may see it only when
|
|
212
|
+
// the profile itself points at the official host.
|
|
213
|
+
test('refreshCatalog sends a profile key only to the host of that profile', async (t) => {
|
|
214
|
+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-catalog-keys-'));
|
|
215
|
+
const realFetch = globalThis.fetch;
|
|
216
|
+
const sent = [];
|
|
217
|
+
globalThis.fetch = async (url, init = {}) => {
|
|
218
|
+
const h = init.headers || {};
|
|
219
|
+
sent.push({ host: new URL(url).host, auth: h.authorization || h['x-api-key'] || '' });
|
|
220
|
+
return new Response('{}', { status: 401 });
|
|
221
|
+
};
|
|
222
|
+
t.after(() => { globalThis.fetch = realFetch; fs.rmSync(stateDir, { recursive: true, force: true }); });
|
|
223
|
+
|
|
224
|
+
await refreshCatalog(stateDir, {
|
|
225
|
+
claudeProfile: { baseURL: 'https://gateway.example/v1', apiKey: 'sk-third-party' },
|
|
226
|
+
codexProfile: { baseURL: 'https://api.openai.com/v1', apiKey: 'sk-openai' },
|
|
227
|
+
sources: { codexHome: stateDir, claudeDir: stateDir }
|
|
228
|
+
});
|
|
229
|
+
assert.deepEqual(sent.find(s => s.host === 'api.anthropic.com').auth, '', 'a third-party key never reaches Anthropic');
|
|
230
|
+
assert.deepEqual(sent.find(s => s.host === 'api.openai.com').auth, 'Bearer sk-openai', 'an OpenAI profile key reaches OpenAI');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Codex can update and rewrite its list without one request through the gateway.
|
|
234
|
+
test('syncLocalCatalog picks up a list that a tool rewrote, with no network call', (t) => {
|
|
235
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-sync-local-'));
|
|
236
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
237
|
+
const calls = failingFetch(t);
|
|
238
|
+
assert.deepEqual(loadCatalogCache(root).codex.models.map(m => m.id).includes('gpt-6-sol'), true, 'starts from the built-in list');
|
|
239
|
+
|
|
240
|
+
const sources = writeToolCaches(root, { codex: ['gpt-5.7-sol', 'gpt-5.7-luna'] });
|
|
241
|
+
const synced = syncLocalCatalog(root, sources);
|
|
242
|
+
assert.deepEqual(synced.codex.models.map(m => m.id), ['gpt-5.7-sol', 'gpt-5.7-luna']);
|
|
243
|
+
assert.deepEqual(loadCatalogCache(root).codex.models.map(m => m.id), ['gpt-5.7-sol', 'gpt-5.7-luna'], 'saved to disk');
|
|
244
|
+
assert.equal(calls(), 0);
|
|
147
245
|
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import {
|
|
5
|
+
heuristicClassify,
|
|
6
|
+
findJevKey,
|
|
7
|
+
classifyPrompt,
|
|
8
|
+
checkSemanticEquivalence,
|
|
9
|
+
DEFAULT_JEV_URL,
|
|
10
|
+
OPENROUTER_JEV_URL,
|
|
11
|
+
} from '../classifier.mjs';
|
|
12
|
+
|
|
13
|
+
function createServer(handler) {
|
|
14
|
+
const server = http.createServer(handler);
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
server.listen(0, '127.0.0.1', () => {
|
|
17
|
+
const addr = server.address();
|
|
18
|
+
resolve({
|
|
19
|
+
url: `http://127.0.0.1:${addr.port}`,
|
|
20
|
+
close: () => new Promise((res) => server.close(res)),
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
server.on('error', reject);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('classifier module', () => {
|
|
28
|
+
describe('heuristicClassify', () => {
|
|
29
|
+
it('classifies complex tasks to opus', () => {
|
|
30
|
+
assert.equal(heuristicClassify('Design the system architecture for high throughput'), 'opus');
|
|
31
|
+
assert.equal(heuristicClassify('Find race condition in concurrent worker pool'), 'opus');
|
|
32
|
+
assert.equal(heuristicClassify('Conduct a security audit of our auth boundary'), 'opus');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('classifies simple short tasks to haiku', () => {
|
|
36
|
+
assert.equal(heuristicClassify('Fix typo in comment'), 'haiku');
|
|
37
|
+
assert.equal(heuristicClassify('Format json output to string'), 'haiku');
|
|
38
|
+
assert.equal(heuristicClassify('Extract date from this line'), 'haiku');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('defaults general programming tasks to sonnet', () => {
|
|
42
|
+
assert.equal(heuristicClassify('Refactor this helper function to accept options object and write tests'), 'sonnet');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('findJevKey', () => {
|
|
47
|
+
it('resolves TYPESAFE_API_KEY first', () => {
|
|
48
|
+
const res = findJevKey({ TYPESAFE_API_KEY: 'ts_key', JEV_API_KEY: 'jev_key' });
|
|
49
|
+
assert.deepEqual(res, { key: 'ts_key', source: 'typesafe' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('resolves JEV_API_KEY when TYPESAFE_API_KEY is absent', () => {
|
|
53
|
+
const res = findJevKey({ JEV_API_KEY: 'jev_key' });
|
|
54
|
+
assert.deepEqual(res, { key: 'jev_key', source: 'openrouter' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns null on empty env', () => {
|
|
58
|
+
assert.equal(findJevKey({}), null);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('classifyPrompt', () => {
|
|
63
|
+
it('returns heuristic tier with reason no-key when no key is present', async () => {
|
|
64
|
+
const res = await classifyPrompt({
|
|
65
|
+
prompt: 'Design architecture for database',
|
|
66
|
+
env: {},
|
|
67
|
+
});
|
|
68
|
+
assert.equal(res.tier, 'opus');
|
|
69
|
+
assert.equal(res.source, 'heuristic');
|
|
70
|
+
assert.equal(res.reason, 'no-key');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('calls Jev endpoint and parses choice response', async () => {
|
|
74
|
+
let receivedAuth = null;
|
|
75
|
+
let receivedBody = null;
|
|
76
|
+
|
|
77
|
+
const server = await createServer(async (req, res) => {
|
|
78
|
+
receivedAuth = req.headers.authorization;
|
|
79
|
+
const chunks = [];
|
|
80
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
81
|
+
receivedBody = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
82
|
+
|
|
83
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
84
|
+
res.end(
|
|
85
|
+
JSON.stringify({
|
|
86
|
+
model: 'jev-latest',
|
|
87
|
+
answers: {
|
|
88
|
+
recommended_tier: {
|
|
89
|
+
choice: 'haiku',
|
|
90
|
+
confidence: 0.94,
|
|
91
|
+
distribution: { haiku: 0.94, sonnet: 0.05, opus: 0.01 },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const res = await classifyPrompt({
|
|
100
|
+
prompt: 'Translate hello to French',
|
|
101
|
+
apiKey: 'test-ts-key',
|
|
102
|
+
url: server.url,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.equal(res.tier, 'haiku');
|
|
106
|
+
assert.equal(res.confidence, 0.94);
|
|
107
|
+
assert.equal(res.source, 'jev');
|
|
108
|
+
assert.equal(res.model, 'jev-latest');
|
|
109
|
+
assert.equal(receivedAuth, 'Bearer test-ts-key');
|
|
110
|
+
assert.equal(receivedBody.state, 'Translate hello to French');
|
|
111
|
+
assert.ok('recommended_tier' in receivedBody.questions);
|
|
112
|
+
} finally {
|
|
113
|
+
await server.close();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('falls back gracefully to heuristic on HTTP error', async () => {
|
|
118
|
+
const server = await createServer((req, res) => {
|
|
119
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
120
|
+
res.end(JSON.stringify({ error: 'internal error' }));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const res = await classifyPrompt({
|
|
125
|
+
prompt: 'Fix typo',
|
|
126
|
+
apiKey: 'key',
|
|
127
|
+
url: server.url,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
assert.equal(res.tier, 'haiku');
|
|
131
|
+
assert.equal(res.source, 'heuristic');
|
|
132
|
+
assert.equal(res.reason, 'http-500');
|
|
133
|
+
} finally {
|
|
134
|
+
await server.close();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('falls back gracefully to heuristic on timeout', async () => {
|
|
139
|
+
const server = await createServer((req, res) => {
|
|
140
|
+
// Hang indefinitely
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const res = await classifyPrompt({
|
|
145
|
+
prompt: 'Fix typo',
|
|
146
|
+
apiKey: 'key',
|
|
147
|
+
url: server.url,
|
|
148
|
+
timeoutMs: 50,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
assert.equal(res.tier, 'haiku');
|
|
152
|
+
assert.equal(res.source, 'heuristic');
|
|
153
|
+
assert.equal(res.reason, 'timeout');
|
|
154
|
+
} finally {
|
|
155
|
+
await server.close();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('checkSemanticEquivalence', () => {
|
|
161
|
+
it('returns exact-match immediately when strings match', async () => {
|
|
162
|
+
const res = await checkSemanticEquivalence({
|
|
163
|
+
candidate: 'What is Node.js?',
|
|
164
|
+
target: 'What is Node.js?',
|
|
165
|
+
});
|
|
166
|
+
assert.equal(res.equivalent, true);
|
|
167
|
+
assert.equal(res.confidence, 1.0);
|
|
168
|
+
assert.equal(res.reason, 'exact-match');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('bypasses immediately with false when no key is configured', async () => {
|
|
172
|
+
const res = await checkSemanticEquivalence({
|
|
173
|
+
candidate: 'Say hello',
|
|
174
|
+
target: 'Greet me',
|
|
175
|
+
env: {},
|
|
176
|
+
});
|
|
177
|
+
assert.equal(res.equivalent, false);
|
|
178
|
+
assert.equal(res.reason, 'no-key-fallback-bypass');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('evaluates Noul probability from Jev and confirms equivalence >= 0.85', async () => {
|
|
182
|
+
const server = await createServer(async (req, res) => {
|
|
183
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
184
|
+
res.end(
|
|
185
|
+
JSON.stringify({
|
|
186
|
+
model: 'jev-latest',
|
|
187
|
+
answers: {
|
|
188
|
+
is_equivalent: 0.92,
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const res = await checkSemanticEquivalence({
|
|
196
|
+
candidate: 'How do I read a file in Node?',
|
|
197
|
+
target: 'Read file contents in NodeJS',
|
|
198
|
+
apiKey: 'test-key',
|
|
199
|
+
url: server.url,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
assert.equal(res.equivalent, true);
|
|
203
|
+
assert.equal(res.confidence, 0.92);
|
|
204
|
+
assert.equal(res.reason, 'jev-confirmed');
|
|
205
|
+
} finally {
|
|
206
|
+
await server.close();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The interactive Codex TUI talks to a shared app-server daemon that keeps the environment it started
|
|
2
|
+
// with. A change of the Codex route must restart it, or the TUI keeps calling the old destination.
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
// Temporary HOME and state dir before the import, so nothing here can reach the real Codex daemon.
|
|
10
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-codex-daemon-'));
|
|
11
|
+
process.env.HOME = path.join(root, 'home');
|
|
12
|
+
process.env.LLM_SWITCHER_STATE_DIR = path.join(root, 'state');
|
|
13
|
+
delete process.env.CODEX_HOME;
|
|
14
|
+
fs.mkdirSync(process.env.LLM_SWITCHER_STATE_DIR, { recursive: true });
|
|
15
|
+
const { restartCodexDaemon, applyLaunchState, clearLaunchState, emptyToolEnvFiles, STATE_DIR } = await import('../state.mjs');
|
|
16
|
+
test.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
17
|
+
|
|
18
|
+
// A machine where the codex shim routes through `stateDir` and a daemon is running.
|
|
19
|
+
function machine(stateDir) {
|
|
20
|
+
const home = fs.mkdtempSync(path.join(root, 'm-'));
|
|
21
|
+
const codexHome = path.join(home, '.codex');
|
|
22
|
+
const bin = path.join(codexHome, 'packages', 'app-server-daemon', 'current', 'bin', 'codex');
|
|
23
|
+
fs.mkdirSync(path.dirname(bin), { recursive: true });
|
|
24
|
+
fs.writeFileSync(bin, '');
|
|
25
|
+
fs.mkdirSync(path.join(codexHome, 'app-server-control'), { recursive: true });
|
|
26
|
+
fs.writeFileSync(path.join(codexHome, 'app-server-control', 'app-server-control.sock'), '');
|
|
27
|
+
fs.mkdirSync(path.join(home, '.llm-switcher', 'bin'), { recursive: true });
|
|
28
|
+
fs.writeFileSync(path.join(home, '.llm-switcher', 'bin', 'codex'), `#!/usr/bin/env bash\nSWITCHER_DIR="${stateDir}"\n`);
|
|
29
|
+
const calls = [];
|
|
30
|
+
const spawnFn = (cmd, args, opts) => { calls.push({ cmd, args, env: opts.env }); return { unref() {}, on() {} }; };
|
|
31
|
+
return { home, codexHome, bin, calls, spawnFn };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ROUTE = [['HTTPS_PROXY', 'http://127.0.0.1:3457'], ['https_proxy', 'http://127.0.0.1:3457'],
|
|
35
|
+
['NO_PROXY', '127.0.0.1,localhost'], ['no_proxy', '127.0.0.1,localhost'], ['CODEX_CA_CERTIFICATE', '/ca.pem']];
|
|
36
|
+
|
|
37
|
+
test('a route change restarts the running daemon with the new Codex variables', () => {
|
|
38
|
+
const m = machine('/switcher/state');
|
|
39
|
+
const r = restartCodexDaemon(ROUTE, { home: m.home, codexHome: m.codexHome, stateDir: '/switcher/state', spawnFn: m.spawnFn, baseEnv: { PATH: '/bin' } });
|
|
40
|
+
assert.equal(r.restarted, true);
|
|
41
|
+
assert.equal(m.calls.length, 1);
|
|
42
|
+
assert.equal(m.calls[0].cmd, m.bin);
|
|
43
|
+
assert.deepEqual(m.calls[0].args, ['app-server', 'daemon', 'restart']);
|
|
44
|
+
assert.equal(m.calls[0].env.HTTPS_PROXY, 'http://127.0.0.1:3457');
|
|
45
|
+
assert.equal(m.calls[0].env.CODEX_CA_CERTIFICATE, '/ca.pem');
|
|
46
|
+
assert.equal(m.calls[0].env.PATH, '/bin');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('switching Codex off restarts the daemon without the switcher variables, and keeps the user\'s own proxy', () => {
|
|
50
|
+
const m = machine('/switcher/state');
|
|
51
|
+
const baseEnv = { HTTPS_PROXY: 'http://127.0.0.1:3457', https_proxy: 'http://proxy.corp:8080', CODEX_CA_CERTIFICATE: '/ca.pem', NO_PROXY: '127.0.0.1,localhost' };
|
|
52
|
+
restartCodexDaemon([], { home: m.home, codexHome: m.codexHome, stateDir: '/switcher/state', spawnFn: m.spawnFn, baseEnv, ca: '/ca.pem' });
|
|
53
|
+
const env = m.calls[0].env;
|
|
54
|
+
assert.equal(env.HTTPS_PROXY, undefined, 'the loopback interceptor is removed');
|
|
55
|
+
assert.equal(env.CODEX_CA_CERTIFICATE, undefined, 'the switcher CA is removed');
|
|
56
|
+
assert.equal(env.https_proxy, 'http://proxy.corp:8080', 'a proxy the user set is kept');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('no restart when the codex shim routes through another state dir, or no daemon runs', () => {
|
|
60
|
+
const other = machine('/another/install');
|
|
61
|
+
assert.equal(restartCodexDaemon(ROUTE, { home: other.home, codexHome: other.codexHome, stateDir: '/switcher/state', spawnFn: other.spawnFn }).restarted, false);
|
|
62
|
+
assert.equal(other.calls.length, 0);
|
|
63
|
+
|
|
64
|
+
const idle = machine('/switcher/state');
|
|
65
|
+
fs.rmSync(path.join(idle.codexHome, 'app-server-control', 'app-server-control.sock'));
|
|
66
|
+
assert.equal(restartCodexDaemon(ROUTE, { home: idle.home, codexHome: idle.codexHome, stateDir: '/switcher/state', spawnFn: idle.spawnFn }).restarted, false);
|
|
67
|
+
assert.equal(idle.calls.length, 0, 'a daemon that does not run is not started');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('applyLaunchState reports a Codex route change only when env-codex changes', () => {
|
|
71
|
+
assert.ok(STATE_DIR.startsWith(root), 'the test writes only its own launch files');
|
|
72
|
+
const cfg = {
|
|
73
|
+
port: 3456, blindfold: { port: 3457 }, activeProfiles: { claude: null, codex: 'cx' },
|
|
74
|
+
profiles: { cx: { name: 'Cx', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { main: 'm' } } }
|
|
75
|
+
};
|
|
76
|
+
assert.equal(applyLaunchState(cfg, 3456).codexRouteChanged, true, 'first activation');
|
|
77
|
+
assert.equal(applyLaunchState(cfg, 3456).codexRouteChanged, false, 'same route again');
|
|
78
|
+
assert.equal(applyLaunchState({ ...cfg, activeProfiles: { claude: null, codex: null } }, 3456).codexRouteChanged, true, 'switched off');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('switch off, for all tools or for Codex alone, also reports the Codex route change', () => {
|
|
82
|
+
const cfg = {
|
|
83
|
+
port: 3456, blindfold: { port: 3457 }, activeProfiles: { claude: null, codex: 'cx' },
|
|
84
|
+
profiles: { cx: { name: 'Cx', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { main: 'm' } } }
|
|
85
|
+
};
|
|
86
|
+
applyLaunchState(cfg, 3456);
|
|
87
|
+
assert.equal(clearLaunchState(3456).codexRouteChanged, true);
|
|
88
|
+
assert.equal(clearLaunchState(3456).codexRouteChanged, false, 'already off');
|
|
89
|
+
applyLaunchState(cfg, 3456);
|
|
90
|
+
assert.equal(emptyToolEnvFiles('codex').codexRouteChanged, true);
|
|
91
|
+
assert.equal(emptyToolEnvFiles('claude').codexRouteChanged, false);
|
|
92
|
+
});
|
package/tests/formats.test.mjs
CHANGED
|
@@ -802,3 +802,33 @@ test('an empty tool schema is healed, not forwarded as {}', () => {
|
|
|
802
802
|
);
|
|
803
803
|
assert.deepEqual(body.tools[0].function.parameters, { type: 'object', properties: {} });
|
|
804
804
|
});
|
|
805
|
+
|
|
806
|
+
// Structured output: a JSON schema the client asks the answer to follow must reach every upstream.
|
|
807
|
+
const TITLE_SCHEMA = { type: 'object', properties: { title: { type: 'string' } }, required: ['title'], additionalProperties: false };
|
|
808
|
+
|
|
809
|
+
test('structured output: Anthropic output_config.format reaches chat, Anthropic and Vertex upstreams', () => {
|
|
810
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }], tools: [],
|
|
811
|
+
output_config: { format: { type: 'json_schema', schema: TITLE_SCHEMA } } });
|
|
812
|
+
assert.deepEqual(irToChatBody(ir, 'm').response_format,
|
|
813
|
+
{ type: 'json_schema', json_schema: { name: 'response', schema: TITLE_SCHEMA } });
|
|
814
|
+
assert.deepEqual(irToAnthropicBody(ir, 'm').output_config, { format: { type: 'json_schema', schema: TITLE_SCHEMA } });
|
|
815
|
+
const gc = irToVertexBody(ir, 'm').generationConfig;
|
|
816
|
+
assert.equal(gc.responseMimeType, 'application/json');
|
|
817
|
+
assert.deepEqual(gc.responseSchema, toGeminiSchema(TITLE_SCHEMA));
|
|
818
|
+
// The deprecated top-level field still works.
|
|
819
|
+
const old = anthropicToIR({ model: 'x', messages: [{ role: 'user', content: 'hi' }], output_format: { type: 'json_schema', schema: TITLE_SCHEMA } });
|
|
820
|
+
assert.equal(irToChatBody(old, 'm').response_format.json_schema.schema, TITLE_SCHEMA);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
test('structured output: Responses text.format keeps name and strict; JSON mode and plain text', () => {
|
|
824
|
+
const resp = responsesToIR({ model: 'x', input: 'hi', text: { format: { type: 'json_schema', name: 'title', schema: TITLE_SCHEMA, strict: true } } });
|
|
825
|
+
assert.deepEqual(irToChatBody(resp, 'm').response_format,
|
|
826
|
+
{ type: 'json_schema', json_schema: { name: 'title', schema: TITLE_SCHEMA, strict: true } });
|
|
827
|
+
const jm = responsesToIR({ model: 'x', input: 'hi', text: { format: { type: 'json_object' } } });
|
|
828
|
+
assert.deepEqual(irToChatBody(jm, 'm').response_format, { type: 'json_object' });
|
|
829
|
+
assert.equal(irToVertexBody(jm, 'm').generationConfig.responseMimeType, 'application/json');
|
|
830
|
+
assert.equal(irToAnthropicBody(jm, 'm').output_config, undefined);
|
|
831
|
+
const txt = responsesToIR({ model: 'x', input: 'hi', text: { format: { type: 'text' } } });
|
|
832
|
+
assert.equal(irToChatBody(txt, 'm').response_format, undefined);
|
|
833
|
+
assert.equal(irToVertexBody(txt, 'm').generationConfig, undefined);
|
|
834
|
+
});
|
package/tests/helpers.mjs
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
|
|
3
|
-
// Validate an Anthropic event sequence: indexes increase in start order, deltas only target an open block.
|
|
4
|
-
export function assertValidAnthropicEvents(events) {
|
|
5
|
-
const open = new Set();
|
|
6
|
-
const seen = new Set();
|
|
7
|
-
let expectedNext = 0;
|
|
8
|
-
for (const { event, data } of events) {
|
|
9
|
-
if (event === 'content_block_start') {
|
|
10
|
-
assert.equal(data.index, expectedNext, `block index must be sequential (got ${data.index}, want ${expectedNext})`);
|
|
11
|
-
assert.ok(!seen.has(data.index), `index ${data.index} reused`);
|
|
12
|
-
seen.add(data.index);
|
|
13
|
-
open.add(data.index);
|
|
14
|
-
expectedNext++;
|
|
15
|
-
} else if (event === 'content_block_delta') {
|
|
16
|
-
assert.ok(open.has(data.index), `delta for block ${data.index} which is not open`);
|
|
17
|
-
} else if (event === 'content_block_stop') {
|
|
18
|
-
assert.ok(open.has(data.index), `stop for block ${data.index} which is not open`);
|
|
19
|
-
open.delete(data.index);
|
|
20
|
-
} else if (event === 'message_stop') {
|
|
21
|
-
assert.equal(open.size, 0, 'all blocks must be closed before message_stop');
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
// Validate an Anthropic event sequence: indexes increase in start order, deltas only target an open block.
|
|
4
|
+
export function assertValidAnthropicEvents(events) {
|
|
5
|
+
const open = new Set();
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
let expectedNext = 0;
|
|
8
|
+
for (const { event, data } of events) {
|
|
9
|
+
if (event === 'content_block_start') {
|
|
10
|
+
assert.equal(data.index, expectedNext, `block index must be sequential (got ${data.index}, want ${expectedNext})`);
|
|
11
|
+
assert.ok(!seen.has(data.index), `index ${data.index} reused`);
|
|
12
|
+
seen.add(data.index);
|
|
13
|
+
open.add(data.index);
|
|
14
|
+
expectedNext++;
|
|
15
|
+
} else if (event === 'content_block_delta') {
|
|
16
|
+
assert.ok(open.has(data.index), `delta for block ${data.index} which is not open`);
|
|
17
|
+
} else if (event === 'content_block_stop') {
|
|
18
|
+
assert.ok(open.has(data.index), `stop for block ${data.index} which is not open`);
|
|
19
|
+
open.delete(data.index);
|
|
20
|
+
} else if (event === 'message_stop') {
|
|
21
|
+
assert.equal(open.size, 0, 'all blocks must be closed before message_stop');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|