llm-switcher 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +16 -0
- package/LICENSE +21 -0
- package/README.md +587 -0
- package/README.vi.md +585 -0
- package/blindfold/blindfold.mjs +633 -0
- package/blindfold/make-certs.sh +88 -0
- package/blindfold/wsframe.mjs +176 -0
- package/codex-catalog-template.json +1 -0
- package/config.example.json +84 -0
- package/contract-exclusions.json +41 -0
- package/contract.mjs +561 -0
- package/docs/LLM-RESPONSE-MATRIX.md +165 -0
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -0
- package/docs/codex-blindfold.md +214 -0
- package/docs/cross-platform.md +136 -0
- package/docs/diagrams/blindfold-request-routing.html +14972 -0
- package/docs/diagrams/blindfold-request-routing.sequence.json +175 -0
- package/docs/diagrams/blindfold-switch-lifecycle.html +14958 -0
- package/docs/diagrams/blindfold-switch-lifecycle.lifecycle.json +159 -0
- package/docs/diagrams/codex-model-name-resolution.html +15005 -0
- package/docs/diagrams/codex-model-name-resolution.workflow.json +71 -0
- package/docs/response-matrix.json +1131 -0
- package/formats.mjs +2308 -0
- package/mcp.mjs +340 -0
- package/package.json +36 -0
- package/proxy.mjs +1743 -0
- package/service.mjs +132 -0
- package/shim.mjs +292 -0
- package/skills/llm-switcher/SKILL.md +88 -0
- package/state.mjs +978 -0
- package/switch +5 -0
- package/switch.cmd +2 -0
- package/switch.mjs +930 -0
- package/tests/blindfold.test.mjs +307 -0
- package/tests/blindfold.wire.test.mjs +170 -0
- package/tests/contract/run.test.mjs +214 -0
- package/tests/contract-check.test.mjs +458 -0
- package/tests/contract-lab.test.mjs +755 -0
- package/tests/datadir.test.mjs +37 -0
- package/tests/formats.test.mjs +794 -0
- package/tests/gateway.e2e.test.mjs +999 -0
- package/tests/helpers.mjs +24 -0
- package/tests/lifecycle.test.mjs +416 -0
- package/tests/live-optimizer-interop.mjs +205 -0
- package/tests/mcp.test.mjs +91 -0
- package/tests/service.test.mjs +69 -0
- package/tests/shim.test.mjs +228 -0
- package/tests/state.test.mjs +675 -0
- package/tests/switch.test.mjs +156 -0
- package/tests/wsframe.test.mjs +154 -0
- package/ui.html +2234 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// MCP server and process audit tests (audit F39, F40, N-7, F46). mcp.mjs runs as a stdio child.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { routeEvidence } from '../shim.mjs';
|
|
11
|
+
|
|
12
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
|
+
|
|
14
|
+
const freePort = () => new Promise(r => {
|
|
15
|
+
const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => r(port)); });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function mcpCall(t, env, calls) {
|
|
19
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-mcp-'));
|
|
20
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
21
|
+
const cfgPath = path.join(dir, 'config.json');
|
|
22
|
+
fs.writeFileSync(cfgPath, JSON.stringify({ port: await freePort(), activeProfiles: {}, profiles: {} }), { mode: 0o600 });
|
|
23
|
+
const claudeDir = path.join(dir, 'claude');
|
|
24
|
+
fs.mkdirSync(claudeDir);
|
|
25
|
+
if (env.SETTINGS !== undefined) fs.writeFileSync(path.join(claudeDir, 'settings.json'), env.SETTINGS);
|
|
26
|
+
const child = spawn(process.execPath, [path.join(ROOT, 'mcp.mjs')], {
|
|
27
|
+
env: { ...process.env, LLM_SWITCHER_CONFIG: cfgPath, LLM_SWITCHER_STATE_DIR: dir, CLAUDE_CONFIG_DIR: claudeDir, LLM_SWITCHER_PORT: '', ANTHROPIC_BASE_URL: '', ...env.vars }
|
|
28
|
+
});
|
|
29
|
+
let out = '';
|
|
30
|
+
child.stdout.on('data', d => { out += d; });
|
|
31
|
+
const requests = calls.map((c, i) => JSON.stringify({ jsonrpc: '2.0', id: i + 1, ...c })).join('\n') + '\n';
|
|
32
|
+
child.stdin.write(requests);
|
|
33
|
+
for (let i = 0; i < 100 && out.split('\n').filter(Boolean).length < calls.length; i++) await new Promise(r => setTimeout(r, 50));
|
|
34
|
+
child.kill();
|
|
35
|
+
return out.split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tool = (name, args = {}) => ({ method: 'tools/call', params: { name, arguments: args } });
|
|
39
|
+
const text = (reply) => reply.result.content[0].text;
|
|
40
|
+
|
|
41
|
+
test('MCP initialize reports the package version', async (t) => {
|
|
42
|
+
const [init] = await mcpCall(t, {}, [{ method: 'initialize', params: { protocolVersion: '2025-06-18' } }]);
|
|
43
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
44
|
+
assert.equal(init.result.serverInfo.version, pkg.version);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('MCP recent logs says the gateway is unreachable instead of "no requests"', async (t) => {
|
|
48
|
+
const [r] = await mcpCall(t, {}, [tool('switcher_recent_logs')]);
|
|
49
|
+
assert.match(text(r), /unreachable/i);
|
|
50
|
+
assert.equal(r.result.isError, true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('MCP audit checks the host, not a substring, and reports an unreadable settings.json', async (t) => {
|
|
54
|
+
const [evil] = await mcpCall(t, { vars: { ANTHROPIC_BASE_URL: 'https://localhost.evil.test/v1' } }, [tool('switcher_audit')]);
|
|
55
|
+
assert.match(text(evil), /\[ALERT\] ANTHROPIC_BASE_URL/);
|
|
56
|
+
const [openai] = await mcpCall(t, { vars: { OPENAI_BASE_URL: 'https://api.example.test/v1' } }, [tool('switcher_audit')]);
|
|
57
|
+
assert.match(text(openai), /\[ALERT\] OPENAI_BASE_URL/);
|
|
58
|
+
const [broken] = await mcpCall(t, { SETTINGS: '{ not json' }, [tool('switcher_audit')]);
|
|
59
|
+
assert.match(text(broken), /ACTION NEEDED/);
|
|
60
|
+
assert.match(text(broken), /settings\.json .*does not parse/);
|
|
61
|
+
const [verbose] = await mcpCall(t, { vars: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:3456' } }, [tool('switcher_audit', { verbose: true })]);
|
|
62
|
+
assert.match(text(verbose), /ANTHROPIC_BASE_URL=http:\/\/127\.0\.0\.1:3456/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('routeEvidence reads the right evidence for each CLI and says when it cannot read the environment', () => {
|
|
66
|
+
assert.equal(routeEvidence('claude', 'claude --resume HOME=/h ANTHROPIC_BASE_URL=http://127.0.0.1:3456'), true);
|
|
67
|
+
assert.equal(routeEvidence('claude', 'claude HOME=/h PATH=/bin'), false);
|
|
68
|
+
// Codex through the gateway, through the blindfold interceptor, or through the shim's override.
|
|
69
|
+
assert.equal(routeEvidence('codex', 'codex HOME=/h LLM_SWITCHER_CODEX_BASE_URL=http://127.0.0.1:3456/v1'), true);
|
|
70
|
+
assert.equal(routeEvidence('codex', 'codex HOME=/h HTTPS_PROXY=http://127.0.0.1:3457'), true);
|
|
71
|
+
assert.equal(routeEvidence('codex', 'codex --config openai_base_url=http://127.0.0.1:3456/v1 HOME=/h'), true);
|
|
72
|
+
assert.equal(routeEvidence('codex', 'codex HOME=/h ANTHROPIC_BASE_URL=http://127.0.0.1:3456'), false);
|
|
73
|
+
// macOS ps prints no environment for most processes: that is unknown, not a bypass.
|
|
74
|
+
assert.equal(routeEvidence('claude', 'claude --resume'), null);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// A proxy URL often carries a user and a password. The audit output goes into the agent's context.
|
|
78
|
+
test('MCP audit never prints the credentials inside a URL', async (t) => {
|
|
79
|
+
const [r] = await mcpCall(t, { vars: { HTTPS_PROXY: 'http://u:s3cret@127.0.0.1:1', ANTHROPIC_BASE_URL: 'https://user:pw-secret@evil.example/v1' } }, [tool('switcher_audit', { verbose: true })]);
|
|
80
|
+
const out = text(r);
|
|
81
|
+
assert.ok(!out.includes('s3cret') && !out.includes('pw-secret'), out);
|
|
82
|
+
assert.match(out, /HTTPS_PROXY=http:\/\/127\.0\.0\.1:1/);
|
|
83
|
+
assert.match(out, /\[ALERT\] ANTHROPIC_BASE_URL="https:\/\/evil\.example\/v1"/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('routeEvidence does not take the shim --config arguments for an environment', () => {
|
|
87
|
+
assert.equal(routeEvidence('codex', '/usr/local/bin/codex --config model_catalog_json=/s/model-catalog.json --config model="gpt-5.5"'), null);
|
|
88
|
+
assert.equal(routeEvidence('codex', '/usr/local/bin/codex -c model="gpt-5.5" exec'), null);
|
|
89
|
+
// The override on the command line is proof enough, with or without a readable environment.
|
|
90
|
+
assert.equal(routeEvidence('codex', '/usr/local/bin/codex --config openai_base_url=http://127.0.0.1:3456/v1 exec'), true);
|
|
91
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Unit tests for service.mjs: the text of each OS service definition, and how the CLI reads the
|
|
2
|
+
// port back (audit N-5, LC-2, LC-3). Windows itself is not available here; these tests pin what
|
|
3
|
+
// the XML says, not how Task Scheduler runs it.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import {
|
|
11
|
+
systemdUnit, launchdPlist, scheduledTaskXml, portFromServiceText, decodeConsoleText, serviceEnv, writeServiceFile
|
|
12
|
+
} from '../service.mjs';
|
|
13
|
+
|
|
14
|
+
const HAS_XMLLINT = (() => { try { execFileSync('xmllint', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } })();
|
|
15
|
+
|
|
16
|
+
test('serviceEnv passes only the variables that change where the gateway reads and writes', () => {
|
|
17
|
+
const env = serviceEnv({ CLAUDE_CONFIG_DIR: '/c', LLM_SWITCHER_CONFIG: '/cfg.json', LLM_SWITCHER_BLINDFOLD_CERTS: '', HOME: '/h', PATH: '/bin' });
|
|
18
|
+
assert.deepEqual(env, [['CLAUDE_CONFIG_DIR', '/c'], ['LLM_SWITCHER_CONFIG', '/cfg.json']]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('systemd unit quotes the command and carries the environment', () => {
|
|
22
|
+
const text = systemdUnit({ nodeBin: '/opt/node dir/node', script: '/srv/llm switcher/proxy.mjs', port: 4000, env: [['CLAUDE_CONFIG_DIR', '/home/a b/.claude']] });
|
|
23
|
+
assert.match(text, /^ExecStart="\/opt\/node dir\/node" "\/srv\/llm switcher\/proxy.mjs" --port 4000$/m);
|
|
24
|
+
assert.match(text, /^Environment="CLAUDE_CONFIG_DIR=\/home\/a b\/.claude"$/m);
|
|
25
|
+
assert.equal(portFromServiceText(text), 4000);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const plist = () => launchdPlist({ nodeBin: '/n', script: '/a&b/proxy.mjs', port: 4001, logPath: '/l<og>', env: [['CLAUDE_CONFIG_DIR', '/x&y']] });
|
|
29
|
+
const taskXml = () => scheduledTaskXml({ nodeBin: 'C:\\Program Files\\nodejs\\node.exe', script: 'C:\\Users\\A & B\\llm-switcher\\proxy.mjs', port: 4002, userId: 'PC\\a&b' });
|
|
30
|
+
|
|
31
|
+
test('the plist and the task XML are well-formed', { skip: !HAS_XMLLINT && 'needs xmllint' }, () => {
|
|
32
|
+
execFileSync('xmllint', ['--noout', '-'], { input: plist() });
|
|
33
|
+
execFileSync('xmllint', ['--noout', '-'], { input: taskXml().replace('encoding="UTF-16"', 'encoding="UTF-8"') });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('launchd plist escapes values and carries the environment', () => {
|
|
37
|
+
const text = plist();
|
|
38
|
+
assert.match(text, /<key>EnvironmentVariables<\/key>\s*<dict>\s*<key>CLAUDE_CONFIG_DIR<\/key>\s*<string>\/x&y<\/string>/);
|
|
39
|
+
assert.equal(portFromServiceText(text), 4001);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('Windows task XML keeps the command and its arguments apart and has no run-time limit', () => {
|
|
43
|
+
const xml = taskXml();
|
|
44
|
+
assert.match(xml, /<Command>C:\\Program Files\\nodejs\\node.exe<\/Command>/);
|
|
45
|
+
assert.match(xml, /<Arguments>"C:\\Users\\A & B\\llm-switcher\\proxy.mjs" --port 4002<\/Arguments>/);
|
|
46
|
+
assert.match(xml, /<ExecutionTimeLimit>PT0S<\/ExecutionTimeLimit>/);
|
|
47
|
+
assert.match(xml, /<LogonTrigger>\s*<Enabled>true<\/Enabled>\s*<UserId>PC\\a&b<\/UserId>/);
|
|
48
|
+
assert.equal(portFromServiceText(xml), 4002);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('the port is read back from UTF-16 console output, as schtasks /Query /XML prints it', () => {
|
|
52
|
+
const xml = scheduledTaskXml({ nodeBin: 'node.exe', script: 'proxy.mjs', port: 4567, userId: 'u' });
|
|
53
|
+
assert.equal(portFromServiceText(decodeConsoleText(Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(xml, 'utf16le')]))), 4567);
|
|
54
|
+
assert.equal(portFromServiceText(decodeConsoleText(Buffer.from(xml, 'utf16le'))), 4567);
|
|
55
|
+
assert.equal(portFromServiceText(decodeConsoleText(Buffer.from(xml, 'utf8'))), 4567);
|
|
56
|
+
assert.equal(portFromServiceText('no port here'), null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('writeServiceFile keeps a hand-edited definition as .bak before it replaces it', (t) => {
|
|
60
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-svc-'));
|
|
61
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
62
|
+
const file = path.join(dir, 'sub', 'llm-switcher.service');
|
|
63
|
+
assert.equal(writeServiceFile(file, 'one'), null, 'a new file needs no backup');
|
|
64
|
+
assert.equal(writeServiceFile(file, 'one'), null, 'the same content needs no backup');
|
|
65
|
+
fs.writeFileSync(file, 'hand edit');
|
|
66
|
+
assert.equal(writeServiceFile(file, 'two'), `${file}.bak`);
|
|
67
|
+
assert.equal(fs.readFileSync(`${file}.bak`, 'utf8'), 'hand edit');
|
|
68
|
+
assert.equal(fs.readFileSync(file, 'utf8'), 'two');
|
|
69
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Tests for shim.mjs — auto-inject env mechanism for resume sessions.
|
|
2
|
+
//
|
|
3
|
+
// Real-world failure (2026-09-18): a `claude` session launched from a shell
|
|
4
|
+
// that never sourced env.sh had no ANTHROPIC_BASE_URL, so it called api.anthropic.com directly,
|
|
5
|
+
// bypassing the gateway. The shim must plug exactly that hole without breaking the normal case.
|
|
6
|
+
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
17
|
+
|
|
18
|
+
// The shims read the launch files from the state dir. A private one keeps the checkout's files untouched.
|
|
19
|
+
const STATE = fs.mkdtempSync(path.join(os.tmpdir(), 'shimstate-'));
|
|
20
|
+
process.env.LLM_SWITCHER_STATE_DIR = STATE;
|
|
21
|
+
|
|
22
|
+
const { SHIM_DIR, SHIMMED, pathExportLine, suggestedRcFiles, shimStatus, renderShim } =
|
|
23
|
+
await import(pathToFileURL(path.join(ROOT, 'shim.mjs')).href);
|
|
24
|
+
|
|
25
|
+
// The behavioural tests run the CURRENT template, rendered into a temp dir. Running the copy
|
|
26
|
+
// installed in SHIM_DIR tested whatever an earlier release wrote there, or skipped.
|
|
27
|
+
const RENDER_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'shimrender-'));
|
|
28
|
+
if (process.platform !== 'win32') {
|
|
29
|
+
for (const name of SHIMMED) {
|
|
30
|
+
fs.writeFileSync(path.join(RENDER_DIR, name), renderShim(name, 'linux'));
|
|
31
|
+
fs.chmodSync(path.join(RENDER_DIR, name), 0o755);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
test.after(() => {
|
|
35
|
+
fs.rmSync(RENDER_DIR, { recursive: true, force: true });
|
|
36
|
+
fs.rmSync(STATE, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Run the shim with a fake PATH: fakeDir holds a mocked "real" binary. `files` are launch files to
|
|
40
|
+
// write into the state dir first; every other launch file is removed.
|
|
41
|
+
function runShim(name, args, { active, fakeDir, extraPath = '', env = {}, files = {} }) {
|
|
42
|
+
for (const f of fs.readdirSync(STATE)) fs.rmSync(path.join(STATE, f), { force: true });
|
|
43
|
+
if (active) {
|
|
44
|
+
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
45
|
+
fs.writeFileSync(path.join(STATE, 'env.sh'), "export ANTHROPIC_BASE_URL='http://127.0.0.1:3456'\n");
|
|
46
|
+
}
|
|
47
|
+
for (const [f, content] of Object.entries(files)) fs.writeFileSync(path.join(STATE, f), content);
|
|
48
|
+
const PATH_ = [RENDER_DIR, fakeDir, extraPath || '/usr/bin:/bin'].filter(Boolean).join(':');
|
|
49
|
+
return execFileSync(path.join(RENDER_DIR, name), args, {
|
|
50
|
+
encoding: 'utf8', env: { ...process.env, ...env, PATH: PATH_ }, timeout: 15000
|
|
51
|
+
}).trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A fake binary that prints every argument on its own line, so the test sees argument boundaries.
|
|
55
|
+
function makeArgvBin(name) {
|
|
56
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimargv-'));
|
|
57
|
+
fs.writeFileSync(path.join(dir, name), '#!/usr/bin/env bash\necho "HTTPS_PROXY=${HTTPS_PROXY:-NONE}"\nprintf \'%s\\n\' "$@"\n');
|
|
58
|
+
fs.chmodSync(path.join(dir, name), 0o755);
|
|
59
|
+
return dir;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeFakeBin(name) {
|
|
63
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimtest-'));
|
|
64
|
+
fs.writeFileSync(path.join(dir, name),
|
|
65
|
+
'#!/usr/bin/env bash\necho "BASE=${ANTHROPIC_BASE_URL:-NONE} ARGS=$*"\n');
|
|
66
|
+
fs.chmodSync(path.join(dir, name), 0o755);
|
|
67
|
+
return dir;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test('shim injects gateway env when the gateway is ON (the --resume fix)', (t) => {
|
|
71
|
+
if (process.platform === 'win32') return t.skip('posix only');
|
|
72
|
+
const fake = makeFakeBin('claude');
|
|
73
|
+
const out = runShim('claude', ['--resume', 'abc'], { active: true, fakeDir: fake });
|
|
74
|
+
assert.match(out, /BASE=http:\/\/127\.0\.0\.1:3456/, 'env must be injected');
|
|
75
|
+
assert.match(out, /ARGS=--resume abc/, 'arguments must be preserved');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('shim stays transparent when the gateway is OFF', (t) => {
|
|
79
|
+
if (process.platform === 'win32') return t.skip('posix only');
|
|
80
|
+
const fake = makeFakeBin('claude');
|
|
81
|
+
const out = runShim('claude', ['--resume'], { active: false, fakeDir: fake });
|
|
82
|
+
assert.match(out, /BASE=NONE/, 'gateway off must not force route');
|
|
83
|
+
assert.match(out, /ARGS=--resume/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('shim never recurses into itself', (t) => {
|
|
87
|
+
if (process.platform === 'win32') return t.skip('posix only');
|
|
88
|
+
const fake = makeFakeBin('claude');
|
|
89
|
+
// If the shim called itself, the command would hang until timeout and throw.
|
|
90
|
+
const out = runShim('claude', ['x'], { active: true, fakeDir: fake });
|
|
91
|
+
assert.match(out, /ARGS=x/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('shim fails loudly (127) when the real binary is missing', (t) => {
|
|
95
|
+
if (process.platform === 'win32') return t.skip('posix only');
|
|
96
|
+
const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'shimempty-'));
|
|
97
|
+
assert.throws(
|
|
98
|
+
() => runShim('claude', [], { active: true, fakeDir: empty }),
|
|
99
|
+
(err) => err.status === 127,
|
|
100
|
+
'missing real binary must report clear error, not fail silently'
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('helpers report PATH guidance and shim wiring', () => {
|
|
105
|
+
assert.ok(pathExportLine().includes(SHIM_DIR));
|
|
106
|
+
assert.ok(suggestedRcFiles('linux').length > 0);
|
|
107
|
+
const st = shimStatus();
|
|
108
|
+
assert.equal(st.dir, SHIM_DIR);
|
|
109
|
+
assert.deepEqual(st.shims.map(s => s.name), SHIMMED);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Behaviour on POSIX: the shim, run against the launch files, hands Codex exactly these arguments.
|
|
113
|
+
test('Codex shim passes the gateway, the catalog and quoted role names from the launch files', (t) => {
|
|
114
|
+
if (process.platform === 'win32') return t.skip('posix only');
|
|
115
|
+
const env = [
|
|
116
|
+
"export LLM_SWITCHER_CODEX_BASE_URL='http://127.0.0.1:3456/v1'",
|
|
117
|
+
"export LLM_SWITCHER_CODEX_MAIN_MODEL='gpt-5.6-sol'",
|
|
118
|
+
"export LLM_SWITCHER_CODEX_REVIEW_MODEL='gpt-5.2'",
|
|
119
|
+
"export LLM_SWITCHER_CODEX_SUBAGENT_MODEL='gpt-5.6-luna'",
|
|
120
|
+
"export LLM_SWITCHER_CODEX_CONTEXT_WINDOW='1000000'",
|
|
121
|
+
"export LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT='900000'"
|
|
122
|
+
].join('\n') + '\n';
|
|
123
|
+
const out = runShim('codex', ['exec', 'hi'], {
|
|
124
|
+
fakeDir: makeArgvBin('codex'),
|
|
125
|
+
files: { 'active.flag': 'active', 'env.sh': env, 'env-codex.sh': "export HTTPS_PROXY='http://127.0.0.1:3457'\n", 'model-catalog.json': '{}' }
|
|
126
|
+
}).split('\n');
|
|
127
|
+
assert.equal(out[0], 'HTTPS_PROXY=http://127.0.0.1:3457');
|
|
128
|
+
assert.deepEqual(out.slice(1), [
|
|
129
|
+
'--config', 'openai_base_url=http://127.0.0.1:3456/v1',
|
|
130
|
+
'--config', `model_catalog_json=${path.join(STATE, 'model-catalog.json')}`,
|
|
131
|
+
'--config', 'model="gpt-5.6-sol"',
|
|
132
|
+
'--config', 'review_model="gpt-5.2"',
|
|
133
|
+
'--config', 'agents.default_subagent_model="gpt-5.6-luna"',
|
|
134
|
+
'--config', 'model_context_window=1000000',
|
|
135
|
+
'--config', 'model_auto_compact_token_limit=900000',
|
|
136
|
+
'exec', 'hi'
|
|
137
|
+
]);
|
|
138
|
+
// With the gateway off, Codex gets its own arguments only and no proxy.
|
|
139
|
+
const off = runShim('codex', ['exec'], { fakeDir: makeArgvBin('codex'), files: { 'env.sh': env, 'env-codex.sh': "export HTTPS_PROXY='http://127.0.0.1:3457'\n" } }).split('\n');
|
|
140
|
+
assert.deepEqual(off, ['HTTPS_PROXY=NONE', 'exec']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('Claude shim never loads the Codex-only file or passes Codex overrides', (t) => {
|
|
144
|
+
if (process.platform === 'win32') return t.skip('posix only');
|
|
145
|
+
const out = runShim('claude', ['--resume'], {
|
|
146
|
+
fakeDir: makeArgvBin('claude'),
|
|
147
|
+
files: { 'active.flag': 'active', 'env.sh': "export LLM_SWITCHER_CODEX_MAIN_MODEL='gpt-x'\n", 'env-codex.sh': "export HTTPS_PROXY='http://127.0.0.1:3457'\n" }
|
|
148
|
+
}).split('\n');
|
|
149
|
+
assert.deepEqual(out, ['HTTPS_PROXY=NONE', '--resume']);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Windows cannot run here, so the Windows template is checked as text.
|
|
153
|
+
test('Windows Codex shim names every documented config override', () => {
|
|
154
|
+
for (const platform of ['win32']) {
|
|
155
|
+
const body = renderShim('codex', platform);
|
|
156
|
+
assert.match(body, /openai_base_url/);
|
|
157
|
+
// The main model stays official (from the user's config.toml): forcing the
|
|
158
|
+
// internal "main" alias here used to make first-party IDs bypass the proxy.
|
|
159
|
+
assert.doesNotMatch(body, /--config model=main/);
|
|
160
|
+
assert.match(body, /review_model/);
|
|
161
|
+
assert.match(body, /agents\.default_subagent_model/);
|
|
162
|
+
assert.match(body, /model_context_window/);
|
|
163
|
+
assert.match(body, /model_auto_compact_token_limit/);
|
|
164
|
+
assert.match(body, /model_catalog_json/);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// The /model picker reads the local catalog file and the --config values, never
|
|
169
|
+
// /v1/models. A literal `review_model=review` therefore printed the switcher's
|
|
170
|
+
// own slot names straight into the Codex UI (found 2026-09-20).
|
|
171
|
+
test('Windows Codex shim passes official role names through, never a hard-coded slot alias', () => {
|
|
172
|
+
for (const platform of ['win32']) {
|
|
173
|
+
const body = renderShim('codex', platform);
|
|
174
|
+
assert.doesNotMatch(body, /review_model=["']?review["']?\s/, 'slot alias must not be hard-coded');
|
|
175
|
+
assert.doesNotMatch(body, /default_subagent_model=["']?subagent["']?\s/, 'slot alias must not be hard-coded');
|
|
176
|
+
assert.match(body, /review_model=.*LLM_SWITCHER_CODEX_REVIEW_MODEL/);
|
|
177
|
+
assert.match(body, /default_subagent_model=.*LLM_SWITCHER_CODEX_SUBAGENT_MODEL/);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Blindfold's HTTPS_PROXY belongs to Codex alone. The claude shim sources the shared
|
|
182
|
+
// env file, so the proxy variables live in a Codex-only file that only this shim reads.
|
|
183
|
+
test('only the Windows Codex shim loads the Codex-only environment file', () => {
|
|
184
|
+
for (const platform of ['win32']) {
|
|
185
|
+
assert.match(renderShim('codex', platform), /env-codex\.(cmd|sh)/);
|
|
186
|
+
assert.doesNotMatch(renderShim('claude', platform), /env-codex/);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('Windows Claude shim does not receive Codex config overrides', () => {
|
|
191
|
+
assert.doesNotMatch(renderShim('claude', 'win32'), /CODEX_SWITCHER_ARGS/);
|
|
192
|
+
assert.doesNotMatch(renderShim('claude', 'win32'), /model_catalog_json/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// README promises that the main role reaches Codex as the `model` override.
|
|
196
|
+
test('Windows Codex shim passes the main model as --config model', () => {
|
|
197
|
+
assert.match(renderShim('codex', 'win32'), /if defined LLM_SWITCHER_CODEX_MAIN_MODEL set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config model=/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// setx truncates at 1024 characters and writes the merged system+user PATH into the user key;
|
|
201
|
+
// in PowerShell %PATH% is not expanded at all. The Windows advice must use neither.
|
|
202
|
+
test('Windows PATH advice never uses setx or %PATH%, and names no POSIX rc file', () => {
|
|
203
|
+
const line = pathExportLine('win32');
|
|
204
|
+
assert.ok(!/setx/i.test(line), line);
|
|
205
|
+
assert.ok(!line.includes('%PATH%'), line);
|
|
206
|
+
assert.match(line, /SetEnvironmentVariable\('Path'/);
|
|
207
|
+
assert.match(line, /'User'\)/, 'only the User-scope Path changes');
|
|
208
|
+
assert.ok(line.includes(SHIM_DIR));
|
|
209
|
+
assert.deepEqual(suggestedRcFiles('win32'), []);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// cmd.exe `if exist` is only sure to work with the native backslash path; the TOML value keeps '/'.
|
|
213
|
+
test('the Windows shim tests the catalog with a native path', () => {
|
|
214
|
+
const body = renderShim('codex', 'win32');
|
|
215
|
+
assert.match(body, /if exist "%SWITCHER_DIR%\\model-catalog\.json"/);
|
|
216
|
+
assert.match(body, /--config model_catalog_json=[^\r\n]*\/model-catalog\.json/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// The shim runs whatever env.sh says. It must not source a file, or a directory, that another account owns.
|
|
220
|
+
test('the POSIX shims source launch files only when this account owns them', () => {
|
|
221
|
+
for (const name of SHIMMED) {
|
|
222
|
+
const body = renderShim(name, 'linux');
|
|
223
|
+
for (const line of body.split('\n').filter(l => /^\s*\. "\$SWITCHER_DIR\//.test(l))) {
|
|
224
|
+
const file = line.trim().slice(3, -1);
|
|
225
|
+
assert.match(body, new RegExp(`\\[ -O "\\$SWITCHER_DIR" \\] && \\[ -O "${file.replace(/[$/.]/g, (c) => `\\${c}`)}" \\]`), `${name}: ${file}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
});
|