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.
Files changed (48) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -0
  3. package/blindfold/make-certs.sh +3 -1
  4. package/catalog.mjs +132 -42
  5. package/classifier.mjs +238 -0
  6. package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
  7. package/docs/response-matrix.json +1130 -1130
  8. package/formats.mjs +30 -1
  9. package/icons/antigravity.png +0 -0
  10. package/icons/claude.png +0 -0
  11. package/icons/codex.png +0 -0
  12. package/icons/deepseek.png +0 -0
  13. package/icons/gemini.png +0 -0
  14. package/icons/github.png +0 -0
  15. package/icons/groq.png +0 -0
  16. package/icons/intact.svg +1 -0
  17. package/icons/ollama.png +0 -0
  18. package/icons/openai.png +0 -0
  19. package/icons/openrouter.png +0 -0
  20. package/icons/qwen.png +0 -0
  21. package/icons/vertex.png +0 -0
  22. package/mcp.mjs +2 -2
  23. package/package.json +1 -1
  24. package/proxy.mjs +34 -20
  25. package/scripts/run-tests.mjs +3 -1
  26. package/skills/llm-switcher/SKILL.md +93 -93
  27. package/state.mjs +58 -4
  28. package/switch +0 -0
  29. package/switch.mjs +28 -4
  30. package/tests/blindfold-e2e.test.mjs +2 -2
  31. package/tests/blindfold-task5.test.mjs +1 -1
  32. package/tests/blindfold.task3.test.mjs +2 -2
  33. package/tests/blindfold.wire.test.mjs +3 -1
  34. package/tests/catalog.test.mjs +117 -19
  35. package/tests/classifier.test.mjs +210 -0
  36. package/tests/codex-daemon.test.mjs +92 -0
  37. package/tests/formats.test.mjs +30 -0
  38. package/tests/helpers.mjs +24 -24
  39. package/tests/lifecycle.test.mjs +49 -37
  40. package/tests/live-optimizer-interop.mjs +205 -205
  41. package/tests/make-certs.test.mjs +22 -0
  42. package/tests/real-user-sim.test.mjs +3 -0
  43. package/tests/shim.test.mjs +8 -3
  44. package/tests/switch.test.mjs +30 -24
  45. package/tests/ui.test.mjs +90 -0
  46. package/tests/version.test.mjs +78 -0
  47. package/ui.html +1895 -1616
  48. 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')], { stdio: ['ignore', 'pipe', 'pipe'] });
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; });
@@ -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
- test('checkVersionAndRefresh records new version and triggers update only on version bumps', async () => {
128
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-bump-test-'));
129
- try {
130
- // Initial request from version 1.1.0
131
- checkVersionAndRefresh('claude', { 'user-agent': 'claude-cli/1.1.0' }, tmp);
132
- const cat1 = loadCatalogCache(tmp);
133
- assert.equal(cat1.claude.lastSeenVersion, '1.1.0');
134
-
135
- // Repeated request from the same version 1.1.0 -> no change, no duplicate trigger
136
- checkVersionAndRefresh('claude', { 'user-agent': 'claude-cli/1.1.0' }, tmp);
137
- const cat2 = loadCatalogCache(tmp);
138
- assert.equal(cat2.claude.lastSeenVersion, '1.1.0');
139
-
140
- // Tool updates to version 1.2.0 -> version bump detected and saved
141
- checkVersionAndRefresh('claude', { 'user-agent': 'claude-cli/1.2.0' }, tmp);
142
- const cat3 = loadCatalogCache(tmp);
143
- assert.equal(cat3.claude.lastSeenVersion, '1.2.0');
144
- } finally {
145
- fs.rmSync(tmp, { recursive: true, force: true });
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
+ });
@@ -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
+ }