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,675 @@
|
|
|
1
|
+
// Unit tests for state.mjs (pure functions only, no flag/env file writes).
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import {
|
|
5
|
+
getActiveMap, setTargetProfile, activateProfile, deactivateProfile, deleteProfile,
|
|
6
|
+
computeLaunchState, findProfileKey, isValidProfileKey, resolvePort, redactConfig, MASKED_KEY,
|
|
7
|
+
modelSlotsForProfile, modelForSlot, primaryModel, codexPublicModel, buildCodexCatalog,
|
|
8
|
+
certCoversHost, blindfoldPreflight, ROOT_DIR, openLog, probeGateway, isSafeModelName
|
|
9
|
+
} from '../state.mjs';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import { execFileSync, execFile as execFileCb } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
const makeCfg = () => ({
|
|
16
|
+
port: 4000,
|
|
17
|
+
activeProfile: 'router',
|
|
18
|
+
profiles: {
|
|
19
|
+
router: { inFormat: 'auto', apiKey: 'sk-1', defaultModels: { opus: 'o', sonnet: 's' }, model1M: { sonnet: true } },
|
|
20
|
+
codexOnly: {
|
|
21
|
+
inFormat: 'responses', apiKey: 'sk-2',
|
|
22
|
+
defaultModels: { main: 'gpt-main', review: 'gpt-review', subagent: 'gpt-sub' },
|
|
23
|
+
model1M: { main: true }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('legacy config without activeProfiles maps every target to activeProfile', () => {
|
|
29
|
+
assert.deepEqual(getActiveMap(makeCfg()), { anthropic: 'router', responses: 'router', 'openai-chat': 'router', vertex: 'router' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('setTargetProfile validates target, existence (no prototype keys) and inFormat', () => {
|
|
33
|
+
const cfg = makeCfg();
|
|
34
|
+
assert.match(setTargetProfile(cfg, '__proto__', 'router'), /Unknown target/);
|
|
35
|
+
assert.match(setTargetProfile(cfg, 'anthropic', 'constructor'), /does not exist/);
|
|
36
|
+
assert.match(setTargetProfile(cfg, 'anthropic', 'codexOnly'), /cannot serve/);
|
|
37
|
+
assert.equal(setTargetProfile(cfg, 'responses', 'codexOnly'), null);
|
|
38
|
+
assert.equal(cfg.activeProfiles.responses, 'codexOnly');
|
|
39
|
+
assert.equal(cfg.activeProfiles.anthropic, 'router');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('activateProfile only assigns compatible targets; deactivateProfile leaves others alone', () => {
|
|
43
|
+
const cfg = makeCfg();
|
|
44
|
+
setTargetProfile(cfg, 'anthropic', null);
|
|
45
|
+
activateProfile(cfg, 'codexOnly');
|
|
46
|
+
assert.deepEqual(cfg.activeProfiles, { anthropic: null, responses: 'codexOnly', 'openai-chat': 'router', vertex: 'router' });
|
|
47
|
+
deactivateProfile(cfg, 'router');
|
|
48
|
+
assert.deepEqual(cfg.activeProfiles, { anthropic: null, responses: 'codexOnly', 'openai-chat': null, vertex: null });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('deleteProfile unassigns targets that pointed at it', () => {
|
|
52
|
+
const cfg = makeCfg();
|
|
53
|
+
activateProfile(cfg, 'codexOnly');
|
|
54
|
+
assert.equal(deleteProfile(cfg, 'codexOnly'), null);
|
|
55
|
+
assert.equal(cfg.activeProfiles.responses, null);
|
|
56
|
+
assert.equal(cfg.activeProfile, 'router');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('computeLaunchState derives flags per target profile, not from one global profile', () => {
|
|
60
|
+
const cfg = makeCfg();
|
|
61
|
+
setTargetProfile(cfg, 'responses', 'codexOnly');
|
|
62
|
+
const st = computeLaunchState(cfg, 4000);
|
|
63
|
+
assert.equal(st.active, true);
|
|
64
|
+
assert.equal(st.claude1M, 'sonnet[1m]');
|
|
65
|
+
assert.equal(st.codex1M, 'gpt-main');
|
|
66
|
+
const env = Object.fromEntries(st.env);
|
|
67
|
+
assert.equal(env.ANTHROPIC_BASE_URL, 'http://127.0.0.1:4000');
|
|
68
|
+
assert.equal(env.LLM_SWITCHER_CODEX_BASE_URL, 'http://127.0.0.1:4000/v1');
|
|
69
|
+
// Upstream IDs stay server-side: these variables carry the names Codex may display.
|
|
70
|
+
assert.equal(env.LLM_SWITCHER_CODEX_MAIN_MODEL, undefined);
|
|
71
|
+
assert.equal(env.LLM_SWITCHER_CODEX_REVIEW_MODEL, undefined);
|
|
72
|
+
assert.equal(env.LLM_SWITCHER_CODEX_SUBAGENT_MODEL, undefined);
|
|
73
|
+
assert.equal(env.LLM_SWITCHER_CODEX_CONTEXT_WINDOW, '1000000');
|
|
74
|
+
assert.equal(env.LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT, '900000');
|
|
75
|
+
assert.equal(env.CODEX_MODEL, undefined, 'unsupported Codex env variables must not be emitted');
|
|
76
|
+
|
|
77
|
+
const off = makeCfg();
|
|
78
|
+
off.activeProfiles = { anthropic: null, responses: null, 'openai-chat': null, vertex: null };
|
|
79
|
+
assert.equal(computeLaunchState(off, 4000).active, false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('Codex profiles use documented role slots (model/review_model/subagent) and read legacy keys', () => {
|
|
83
|
+
const profile = makeCfg().profiles.codexOnly;
|
|
84
|
+
assert.deepEqual(modelSlotsForProfile(profile), ['main', 'review', 'subagent']);
|
|
85
|
+
assert.equal(primaryModel(profile), 'gpt-main');
|
|
86
|
+
assert.equal(modelForSlot(profile, 'subagent'), 'gpt-sub');
|
|
87
|
+
|
|
88
|
+
// Legacy profiles from the custom-key era (fast/fallback) or Claude tiers remain readable.
|
|
89
|
+
const legacy = {
|
|
90
|
+
inFormat: 'responses',
|
|
91
|
+
defaultModels: { opus: 'old-main', sonnet: 'old-review', fast: 'old-sub', fallback: 'old-fb' },
|
|
92
|
+
model1M: { fast: true }
|
|
93
|
+
};
|
|
94
|
+
assert.equal(modelForSlot(legacy, 'main'), 'old-main');
|
|
95
|
+
assert.equal(modelForSlot(legacy, 'review'), 'old-review');
|
|
96
|
+
assert.equal(modelForSlot(legacy, 'subagent'), 'old-sub');
|
|
97
|
+
|
|
98
|
+
const cleared = {
|
|
99
|
+
inFormat: 'responses',
|
|
100
|
+
defaultModels: { main: '', opus: 'old-main', subagent: '', fast: 'old-sub' }
|
|
101
|
+
};
|
|
102
|
+
assert.equal(modelForSlot(cleared, 'main'), '');
|
|
103
|
+
assert.equal(modelForSlot(cleared, 'subagent'), '');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Codex must never learn a switcher-internal name. Everything it can display —
|
|
107
|
+
// the /model picker, review_model, agents.default_subagent_model — is fed from
|
|
108
|
+
// publicModels, so the slot names main/review/subagent stay server-side.
|
|
109
|
+
test('codexPublicModel maps each role to an official name, never to a slot alias', () => {
|
|
110
|
+
const profile = {
|
|
111
|
+
inFormat: 'responses',
|
|
112
|
+
publicModels: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'],
|
|
113
|
+
defaultModels: { main: 'ag/flash', review: 'ag/review', subagent: 'ag/low' }
|
|
114
|
+
};
|
|
115
|
+
assert.equal(codexPublicModel(profile, 'main'), 'gpt-5.6-sol');
|
|
116
|
+
assert.equal(codexPublicModel(profile, 'review'), 'gpt-5.6-terra');
|
|
117
|
+
assert.equal(codexPublicModel(profile, 'subagent'), 'gpt-5.6-luna');
|
|
118
|
+
|
|
119
|
+
// An explicit map wins over positional derivation.
|
|
120
|
+
const explicit = { ...profile, codexRoles: { review: 'codex-auto-review' } };
|
|
121
|
+
assert.equal(codexPublicModel(explicit, 'review'), 'codex-auto-review');
|
|
122
|
+
assert.equal(codexPublicModel(explicit, 'main'), 'gpt-5.6-sol');
|
|
123
|
+
|
|
124
|
+
// No publicModels: emit nothing rather than falling back to a slot alias.
|
|
125
|
+
const bare = { inFormat: 'responses', defaultModels: { main: 'ag/flash', review: 'ag/review' } };
|
|
126
|
+
assert.equal(codexPublicModel(bare, 'main'), '');
|
|
127
|
+
assert.equal(codexPublicModel(bare, 'review'), '');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('computeLaunchState hands Codex official names, not upstream IDs or slot aliases', () => {
|
|
131
|
+
const cfg = makeCfg();
|
|
132
|
+
cfg.profiles.codexOnly.publicModels = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'];
|
|
133
|
+
setTargetProfile(cfg, 'responses', 'codexOnly');
|
|
134
|
+
const env = Object.fromEntries(computeLaunchState(cfg, 4000).env);
|
|
135
|
+
assert.equal(env.LLM_SWITCHER_CODEX_MAIN_MODEL, 'gpt-5.6-sol');
|
|
136
|
+
assert.equal(env.LLM_SWITCHER_CODEX_REVIEW_MODEL, 'gpt-5.6-terra');
|
|
137
|
+
assert.equal(env.LLM_SWITCHER_CODEX_SUBAGENT_MODEL, 'gpt-5.6-luna');
|
|
138
|
+
const dump = JSON.stringify(env);
|
|
139
|
+
assert.ok(!dump.includes('gpt-review'), 'upstream IDs must not reach the CLI');
|
|
140
|
+
assert.ok(!dump.includes('gpt-sub'), 'upstream IDs must not reach the CLI');
|
|
141
|
+
|
|
142
|
+
// Without publicModels the role variables are absent, so the shim adds no
|
|
143
|
+
// --config override and Codex keeps its own official defaults.
|
|
144
|
+
const bare = makeCfg();
|
|
145
|
+
setTargetProfile(bare, 'responses', 'codexOnly');
|
|
146
|
+
const bareEnv = Object.fromEntries(computeLaunchState(bare, 4000).env);
|
|
147
|
+
assert.equal(bareEnv.LLM_SWITCHER_CODEX_REVIEW_MODEL, undefined);
|
|
148
|
+
assert.equal(bareEnv.LLM_SWITCHER_CODEX_SUBAGENT_MODEL, undefined);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Blindfold mode removes the last visible trace: without openai_base_url the CLI
|
|
152
|
+
// stops printing "base URL is overridden" on its own /model screen.
|
|
153
|
+
test('blindfold mode swaps the base URL override for a CONNECT proxy and a CA file', () => {
|
|
154
|
+
const cfg = makeCfg();
|
|
155
|
+
cfg.profiles.codexOnly.publicModels = ['gpt-5.6-sol'];
|
|
156
|
+
cfg.profiles.codexOnly.blindfold = true;
|
|
157
|
+
setTargetProfile(cfg, 'responses', 'codexOnly');
|
|
158
|
+
const env = Object.fromEntries(computeLaunchState(cfg, 4000).env);
|
|
159
|
+
|
|
160
|
+
const codexEnv = Object.fromEntries(computeLaunchState(cfg, 4000).envCodex);
|
|
161
|
+
|
|
162
|
+
assert.equal(env.LLM_SWITCHER_CODEX_BASE_URL, undefined,
|
|
163
|
+
'an openai_base_url override is exactly what makes Codex print the banner');
|
|
164
|
+
// The proxy variables belong to Codex alone. The `claude` shim sources the shared
|
|
165
|
+
// file, and pointing every claude HTTPS call at a Codex-only loopback port breaks
|
|
166
|
+
// claude after any restart that does not re-run `switch`.
|
|
167
|
+
assert.equal(env.HTTPS_PROXY, undefined, 'proxy variables must not reach the shared file');
|
|
168
|
+
assert.equal(env.https_proxy, undefined);
|
|
169
|
+
assert.equal(env.NO_PROXY, undefined);
|
|
170
|
+
assert.equal(codexEnv.HTTPS_PROXY, 'http://127.0.0.1:3457');
|
|
171
|
+
assert.equal(codexEnv.https_proxy, 'http://127.0.0.1:3457');
|
|
172
|
+
assert.match(codexEnv.NO_PROXY, /127\.0\.0\.1/, 'NO_PROXY must travel with HTTPS_PROXY');
|
|
173
|
+
assert.match(codexEnv.CODEX_CA_CERTIFICATE, /blindfold[\\/]certs[\\/]ca\.pem$/);
|
|
174
|
+
// The role names still travel: they are official and carry no URL.
|
|
175
|
+
assert.equal(env.LLM_SWITCHER_CODEX_MAIN_MODEL, 'gpt-5.6-sol');
|
|
176
|
+
|
|
177
|
+
// The launcher needs the port and the CA path as data, so that `switch` can start
|
|
178
|
+
// the interceptor and refuse to activate when the certificate is missing.
|
|
179
|
+
const st = computeLaunchState(cfg, 4000);
|
|
180
|
+
assert.equal(st.blindfold.port, 3457);
|
|
181
|
+
assert.match(st.blindfold.ca, /blindfold[\\/]certs[\\/]ca\.pem$/);
|
|
182
|
+
// ChatGPT sign-in is the common case, so it is the default.
|
|
183
|
+
assert.equal(st.blindfold.host, 'chatgpt.com');
|
|
184
|
+
assert.equal(st.blindfold.prefix, '/backend-api/codex');
|
|
185
|
+
|
|
186
|
+
// An API-key account talks to a different host under a different prefix. The
|
|
187
|
+
// launcher must pass both through, otherwise that account cannot use blindfold.
|
|
188
|
+
const apiKeyCfg = makeCfg();
|
|
189
|
+
Object.assign(apiKeyCfg.profiles.codexOnly, {
|
|
190
|
+
publicModels: ['gpt-5.6-sol'], blindfold: true,
|
|
191
|
+
blindfoldHost: 'api.openai.com', blindfoldPrefix: '/v1'
|
|
192
|
+
});
|
|
193
|
+
setTargetProfile(apiKeyCfg, 'responses', 'codexOnly');
|
|
194
|
+
const apiKeySt = computeLaunchState(apiKeyCfg, 4000);
|
|
195
|
+
assert.equal(apiKeySt.blindfold.host, 'api.openai.com');
|
|
196
|
+
assert.equal(apiKeySt.blindfold.prefix, '/v1');
|
|
197
|
+
|
|
198
|
+
cfg.profiles.codexOnly.blindfoldPort = 4999;
|
|
199
|
+
const movedState = computeLaunchState(cfg, 4000);
|
|
200
|
+
assert.equal(Object.fromEntries(movedState.envCodex).HTTPS_PROXY, 'http://127.0.0.1:4999');
|
|
201
|
+
assert.equal(movedState.blindfold.port, 4999);
|
|
202
|
+
|
|
203
|
+
// Off by default: the documented base URL override stays the normal route.
|
|
204
|
+
const plain = makeCfg();
|
|
205
|
+
setTargetProfile(plain, 'responses', 'codexOnly');
|
|
206
|
+
const plainState = computeLaunchState(plain, 4000);
|
|
207
|
+
const plainEnv = Object.fromEntries(plainState.env);
|
|
208
|
+
assert.equal(plainEnv.LLM_SWITCHER_CODEX_BASE_URL, 'http://127.0.0.1:4000/v1');
|
|
209
|
+
assert.equal(plainEnv.HTTPS_PROXY, undefined);
|
|
210
|
+
assert.equal(plainState.blindfold, null);
|
|
211
|
+
assert.deepEqual(plainState.envCodex, []);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// A model name travels into env.cmd, which cmd.exe executes on every launch, and is
|
|
215
|
+
// then re-expanded unquoted onto the shim's command line. `SET "K=V"` does not contain
|
|
216
|
+
// a bare `&`, and a newline splits the batch file outright.
|
|
217
|
+
test('a model name that could act as a command is dropped, not written', () => {
|
|
218
|
+
const payloads = [
|
|
219
|
+
'a" & echo pwned & rem',
|
|
220
|
+
'good\nSET X=1 & echo pwned',
|
|
221
|
+
'good\r& echo pwned',
|
|
222
|
+
'a & echo pwned',
|
|
223
|
+
'a %PATH% ^b',
|
|
224
|
+
'a|b', 'a>c', 'a<c'
|
|
225
|
+
];
|
|
226
|
+
for (const payload of payloads) {
|
|
227
|
+
const cfg = makeCfg();
|
|
228
|
+
cfg.profiles.codexOnly.publicModels = [payload, 'gpt-5.6-terra', 'gpt-5.6-luna'];
|
|
229
|
+
setTargetProfile(cfg, 'responses', 'codexOnly');
|
|
230
|
+
const env = Object.fromEntries(computeLaunchState(cfg, 4000).env);
|
|
231
|
+
assert.equal(env.LLM_SWITCHER_CODEX_MAIN_MODEL, undefined,
|
|
232
|
+
`must drop an unsafe model name: ${JSON.stringify(payload)}`);
|
|
233
|
+
// A safe sibling is unaffected.
|
|
234
|
+
assert.equal(env.LLM_SWITCHER_CODEX_REVIEW_MODEL, 'gpt-5.6-terra');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Names that real providers use stay legal.
|
|
238
|
+
for (const ok of ['gpt-5.6-sol', 'ag/gemini-3.8-flash', 'claude-haiku-4-5-20251001', 'gpt-4.1_mini', 'a.b:c']) {
|
|
239
|
+
const cfg = makeCfg();
|
|
240
|
+
cfg.profiles.codexOnly.publicModels = [ok];
|
|
241
|
+
setTargetProfile(cfg, 'responses', 'codexOnly');
|
|
242
|
+
assert.equal(Object.fromEntries(computeLaunchState(cfg, 4000).env).LLM_SWITCHER_CODEX_MAIN_MODEL, ok);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('a blank entry in publicModels does not shift the later roles', () => {
|
|
247
|
+
const profile = {
|
|
248
|
+
inFormat: 'responses',
|
|
249
|
+
publicModels: ['gpt-5.6-sol', '', 'gpt-5.6-luna'],
|
|
250
|
+
defaultModels: { main: 'ag/flash', review: 'ag/review', subagent: 'ag/low' }
|
|
251
|
+
};
|
|
252
|
+
assert.equal(codexPublicModel(profile, 'main'), 'gpt-5.6-sol');
|
|
253
|
+
assert.equal(codexPublicModel(profile, 'review'), '', 'a blank slot means no override for that slot');
|
|
254
|
+
assert.equal(codexPublicModel(profile, 'subagent'), 'gpt-5.6-luna', 'position must not shift');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('the generated catalog states the 1M window only for a slot the profile marks 1M', () => {
|
|
258
|
+
const base = {
|
|
259
|
+
inFormat: 'responses',
|
|
260
|
+
defaultModels: { main: 'ag/flash', review: 'ag/review', subagent: 'ag/low' }
|
|
261
|
+
};
|
|
262
|
+
const windowOf = (catalog, slug) => {
|
|
263
|
+
const m = catalog.models.find(x => x.slug === slug);
|
|
264
|
+
return [m.context_window, m.max_context_window];
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const off = buildCodexCatalog({ ...base, publicModels: ['a-model'], model1M: { main: false } });
|
|
268
|
+
assert.deepEqual(windowOf(off, 'a-model'), [272000, 872000],
|
|
269
|
+
'a non-1M slot keeps the official window');
|
|
270
|
+
|
|
271
|
+
const on = buildCodexCatalog({ ...base, publicModels: ['a-model'], model1M: { main: true } });
|
|
272
|
+
assert.deepEqual(windowOf(on, 'a-model'), [1000000, 1000000]);
|
|
273
|
+
|
|
274
|
+
// Two slots publishing the same name deduplicate into one entry. The smaller window
|
|
275
|
+
// wins: overstating it is what makes Codex size a session it cannot fit.
|
|
276
|
+
const mixed = buildCodexCatalog({
|
|
277
|
+
...base,
|
|
278
|
+
publicModels: ['same', 'same', 'other'],
|
|
279
|
+
model1M: { main: true, review: false, subagent: false }
|
|
280
|
+
});
|
|
281
|
+
assert.deepEqual(mixed.models.map(m => m.slug), ['same', 'other']);
|
|
282
|
+
assert.deepEqual(windowOf(mixed, 'same'), [272000, 872000],
|
|
283
|
+
'a name shared by a 1M and a non-1M slot must not claim 1M');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Changing blindfoldHost without rebuilding the leaf gives a TLS failure that reads
|
|
287
|
+
// like a network fault. The launcher compares the two and says what to run instead.
|
|
288
|
+
// The test builds its own leaf: blindfold/certs is gitignored, and a test that returns early
|
|
289
|
+
// on a clean checkout passes with no assertion at all.
|
|
290
|
+
test('certCoversHost reads the leaf subject alternative names', (t) => {
|
|
291
|
+
if (process.platform === 'win32' || !fs.existsSync('/usr/bin/openssl')) return t.skip('needs bash and openssl to build a leaf');
|
|
292
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-leaf-'));
|
|
293
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
294
|
+
execFileSync('bash', [path.join(ROOT_DIR, 'blindfold', 'make-certs.sh'), 'chatgpt.com', path.join(dir, 'certs')], { stdio: 'ignore' });
|
|
295
|
+
const pem = fs.readFileSync(path.join(dir, 'certs', 'leaf.pem'), 'utf8');
|
|
296
|
+
assert.equal(certCoversHost(pem, 'chatgpt.com'), true);
|
|
297
|
+
assert.equal(certCoversHost(pem, 'sub.chatgpt.com'), true, 'the wildcard entry must count');
|
|
298
|
+
assert.equal(certCoversHost(pem, 'api.openai.com'), false);
|
|
299
|
+
assert.equal(certCoversHost(pem, ''), false);
|
|
300
|
+
assert.equal(certCoversHost('not a certificate', 'chatgpt.com'), false);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('buildCodexCatalog lists only official slugs, deduplicated, with no slot alias', () => {
|
|
304
|
+
const profile = {
|
|
305
|
+
inFormat: 'responses',
|
|
306
|
+
publicModels: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-sol'],
|
|
307
|
+
defaultModels: { main: 'ag/flash', review: 'ag/review', subagent: 'ag/low' }
|
|
308
|
+
};
|
|
309
|
+
const catalog = buildCodexCatalog(profile);
|
|
310
|
+
assert.deepEqual(catalog.models.map(m => m.slug), ['gpt-5.6-sol', 'gpt-5.6-terra']);
|
|
311
|
+
assert.ok(catalog.models.every(m => m.display_name === m.slug));
|
|
312
|
+
assert.ok(catalog.models.every(m => m.model_messages?.instructions_template),
|
|
313
|
+
'each entry keeps the official instructions so Codex does not fall back to full context');
|
|
314
|
+
const dump = JSON.stringify(catalog);
|
|
315
|
+
for (const leak of ['"main"', '"review"', '"subagent"', 'ag/', 'llm-switcher']) {
|
|
316
|
+
assert.ok(!dump.includes(leak), `catalog must not leak ${leak}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
assert.equal(buildCodexCatalog({ inFormat: 'responses' }), null,
|
|
320
|
+
'no publicModels means no catalog file, so Codex uses its built-in one');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('openai-chat and vertex profiles use a single default slot with legacy fallback', () => {
|
|
324
|
+
assert.deepEqual(modelSlotsForProfile({ inFormat: 'openai-chat' }), ['default']);
|
|
325
|
+
assert.deepEqual(modelSlotsForProfile({ inFormat: 'vertex' }), ['default']);
|
|
326
|
+
assert.deepEqual(modelSlotsForProfile({ inFormat: 'auto' }),
|
|
327
|
+
['opus', 'sonnet', 'haiku', 'fable']);
|
|
328
|
+
const legacy = { inFormat: 'openai-chat', defaultModels: { sonnet: 's-old' } };
|
|
329
|
+
assert.equal(modelForSlot(legacy, 'default'), 's-old');
|
|
330
|
+
assert.equal(primaryModel(legacy), 's-old');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('computeLaunchState tags [1m] per Claude tier from the profile model1M map, nothing hard-coded', () => {
|
|
334
|
+
const cfg = makeCfg();
|
|
335
|
+
cfg.profiles.router.model1M = { opus: true, sonnet: false, fable: true };
|
|
336
|
+
const env = Object.fromEntries(computeLaunchState(cfg, 4000).env);
|
|
337
|
+
assert.equal(env.ANTHROPIC_MODEL, 'opus[1m]');
|
|
338
|
+
assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, 'opus[1m]');
|
|
339
|
+
assert.equal(env.ANTHROPIC_DEFAULT_FABLE_MODEL, 'fable[1m]');
|
|
340
|
+
assert.equal(env.ANTHROPIC_DEFAULT_SONNET_MODEL, undefined);
|
|
341
|
+
assert.equal(env.ANTHROPIC_DEFAULT_HAIKU_MODEL, undefined);
|
|
342
|
+
assert.equal(env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, undefined);
|
|
343
|
+
|
|
344
|
+
cfg.profiles.router.model1M = {};
|
|
345
|
+
const none = Object.fromEntries(computeLaunchState(cfg, 4000).env);
|
|
346
|
+
assert.ok(!Object.keys(none).some(k => k.startsWith('ANTHROPIC_DEFAULT_') || k === 'ANTHROPIC_MODEL' || k === 'CLAUDE_CODE_AUTO_COMPACT_WINDOW'));
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('helpers: case-insensitive lookup, key validation, port resolution, redaction', () => {
|
|
350
|
+
const cfg = makeCfg();
|
|
351
|
+
assert.equal(findProfileKey(cfg, 'CODEXONLY'), 'codexOnly');
|
|
352
|
+
assert.equal(findProfileKey(cfg, 'nope'), null);
|
|
353
|
+
assert.ok(isValidProfileKey('my-llm.v2'));
|
|
354
|
+
assert.ok(!isValidProfileKey('__proto__'));
|
|
355
|
+
assert.ok(!isValidProfileKey('bad key'));
|
|
356
|
+
|
|
357
|
+
const saved = { a: process.env.LLM_SWITCHER_PORT, b: process.env.PORT };
|
|
358
|
+
delete process.env.LLM_SWITCHER_PORT;
|
|
359
|
+
delete process.env.PORT;
|
|
360
|
+
try {
|
|
361
|
+
assert.equal(resolvePort(['on', '--port', '5000'], cfg), 5000);
|
|
362
|
+
assert.equal(resolvePort(['on'], cfg), 4000);
|
|
363
|
+
process.env.LLM_SWITCHER_PORT = '4500';
|
|
364
|
+
assert.equal(resolvePort(['on'], cfg), 4500);
|
|
365
|
+
// A generic PORT belongs to other tools (dev servers) and must not move the gateway (audit F32).
|
|
366
|
+
delete process.env.LLM_SWITCHER_PORT;
|
|
367
|
+
process.env.PORT = '3000';
|
|
368
|
+
assert.equal(resolvePort(['on'], cfg), 4000);
|
|
369
|
+
delete process.env.PORT;
|
|
370
|
+
} finally {
|
|
371
|
+
if (saved.a === undefined) delete process.env.LLM_SWITCHER_PORT; else process.env.LLM_SWITCHER_PORT = saved.a;
|
|
372
|
+
if (saved.b !== undefined) process.env.PORT = saved.b;
|
|
373
|
+
}
|
|
374
|
+
const red = redactConfig(cfg);
|
|
375
|
+
assert.equal(red.profiles.router.apiKey, MASKED_KEY);
|
|
376
|
+
assert.equal(cfg.profiles.router.apiKey, 'sk-1', 'original config untouched');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// config.json holds every API key. It must be private to the owner even when an earlier
|
|
380
|
+
// version left it world-readable, and the tmp file must never be readable before the rename.
|
|
381
|
+
test('saveConfig writes config.json 0600, also over an existing 0644 file', { skip: process.platform === 'win32' && 'posix modes' }, () => {
|
|
382
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-mode-'));
|
|
383
|
+
try {
|
|
384
|
+
const cfgPath = path.join(dir, 'config.json');
|
|
385
|
+
fs.writeFileSync(cfgPath, '{"profiles":{}}', { mode: 0o644 });
|
|
386
|
+
fs.chmodSync(cfgPath, 0o644);
|
|
387
|
+
const script = `
|
|
388
|
+
import fs from 'node:fs';
|
|
389
|
+
const seen = [];
|
|
390
|
+
const rename = fs.renameSync;
|
|
391
|
+
fs.renameSync = (a, b) => { seen.push((fs.statSync(a).mode & 0o777).toString(8)); return rename(a, b); };
|
|
392
|
+
const s = await import(${JSON.stringify(path.join(ROOT_DIR, 'state.mjs'))});
|
|
393
|
+
s.saveConfig({ profiles: {} });
|
|
394
|
+
console.log(JSON.stringify({ tmp: seen[0], final: (fs.statSync(s.configPath).mode & 0o777).toString(8) }));`;
|
|
395
|
+
const out = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '-e', script], {
|
|
396
|
+
env: { ...process.env, LLM_SWITCHER_CONFIG: cfgPath }, encoding: 'utf8'
|
|
397
|
+
}).trim());
|
|
398
|
+
assert.equal(out.tmp, '600', 'the tmp file is private before it replaces config.json');
|
|
399
|
+
assert.equal(out.final, '600', 'config.json ends private');
|
|
400
|
+
} finally {
|
|
401
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// settings.json belongs to Claude Code and to the user. The switcher may remove only the
|
|
406
|
+
// values it would itself write: its own base URL and the `<tier>[1m]` aliases.
|
|
407
|
+
test('cleanClaudeSettings removes only switcher-written values and keeps the file identity', { skip: process.platform === 'win32' && 'posix symlink' }, () => {
|
|
408
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-claude-'));
|
|
409
|
+
try {
|
|
410
|
+
const real = path.join(dir, 'real-settings.json');
|
|
411
|
+
const userEnv = {
|
|
412
|
+
ANTHROPIC_AUTH_TOKEN: 'tok-user',
|
|
413
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: 'my-opus',
|
|
414
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: 'x',
|
|
415
|
+
ANTHROPIC_BASE_URL: 'https://other.example'
|
|
416
|
+
};
|
|
417
|
+
fs.writeFileSync(real, JSON.stringify({ env: userEnv, theme: 'dark' }, null, 2), { mode: 0o640 });
|
|
418
|
+
fs.chmodSync(real, 0o640);
|
|
419
|
+
fs.symlinkSync(real, path.join(dir, 'settings.json'));
|
|
420
|
+
const run = (port) => JSON.parse(execFileSync(process.execPath, ['--input-type=module', '-e',
|
|
421
|
+
`const s = await import(${JSON.stringify(path.join(ROOT_DIR, 'state.mjs'))}); console.log(JSON.stringify(s.cleanClaudeSettings(${port})));`
|
|
422
|
+
], { env: { ...process.env, CLAUDE_CONFIG_DIR: dir }, encoding: 'utf8' }).trim());
|
|
423
|
+
|
|
424
|
+
const before = fs.readFileSync(real, 'utf8');
|
|
425
|
+
assert.deepEqual(run(3456).removed || [], []);
|
|
426
|
+
assert.equal(fs.readFileSync(real, 'utf8'), before, 'user-owned values leave the file byte-identical');
|
|
427
|
+
|
|
428
|
+
// Switcher-written values are removed; the user's values stay.
|
|
429
|
+
const mixed = { ...userEnv, ANTHROPIC_BASE_URL: 'http://127.0.0.1:3456', ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet[1m]' };
|
|
430
|
+
fs.writeFileSync(real, JSON.stringify({ env: mixed }, null, 2));
|
|
431
|
+
const out = run(3456);
|
|
432
|
+
assert.deepEqual(out.removed.sort(), ['ANTHROPIC_BASE_URL', 'ANTHROPIC_DEFAULT_SONNET_MODEL']);
|
|
433
|
+
const env = JSON.parse(fs.readFileSync(real, 'utf8')).env;
|
|
434
|
+
assert.equal(env.ANTHROPIC_AUTH_TOKEN, 'tok-user');
|
|
435
|
+
assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, 'my-opus');
|
|
436
|
+
assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME, 'x');
|
|
437
|
+
assert.ok(fs.lstatSync(path.join(dir, 'settings.json')).isSymbolicLink(), 'the symlink survives');
|
|
438
|
+
assert.equal((fs.statSync(real).mode & 0o777).toString(8), '640', 'the target keeps its mode');
|
|
439
|
+
|
|
440
|
+
// A local gateway on another port (for example 9router) is not the switcher's.
|
|
441
|
+
fs.writeFileSync(real, JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:20128' } }));
|
|
442
|
+
assert.deepEqual(run(3456).removed || [], []);
|
|
443
|
+
} finally {
|
|
444
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// The dashboard saves every role key, a blank one as '', and a compacted publicModels list.
|
|
449
|
+
// A blank role means "no override"; it must never pick up another role's name by position.
|
|
450
|
+
test('an explicit blank Codex role stays blank and never shifts the other roles', () => {
|
|
451
|
+
const uiShaped = { inFormat: 'responses', codexRoles: { main: '', review: 'gpt-review', subagent: 'gpt-sub' }, publicModels: ['gpt-review', 'gpt-sub'] };
|
|
452
|
+
assert.equal(codexPublicModel(uiShaped, 'main'), '');
|
|
453
|
+
assert.equal(codexPublicModel(uiShaped, 'review'), 'gpt-review');
|
|
454
|
+
assert.equal(codexPublicModel(uiShaped, 'subagent'), 'gpt-sub');
|
|
455
|
+
const e2eFixture = { inFormat: 'responses', publicModels: ['gpt-5.6-sol', 'ag/mock-flash'], codexRoles: { main: 'gpt-5.6-sol', review: '', subagent: '' } };
|
|
456
|
+
assert.equal(codexPublicModel(e2eFixture, 'review'), '', 'no upstream id reaches the CLI');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// /proc/<pid>/cmdline is readable by every account, so `switch ui` must never put the admin
|
|
460
|
+
// token on a command line. It opens a private file that redirects to the dashboard instead.
|
|
461
|
+
test('the dashboard launcher keeps the admin token off the command line', { skip: process.platform === 'win32' && 'posix modes' }, () => {
|
|
462
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-ui-'));
|
|
463
|
+
try {
|
|
464
|
+
const out = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '-e',
|
|
465
|
+
`const s = await import(${JSON.stringify(path.join(ROOT_DIR, 'state.mjs'))});
|
|
466
|
+
const token = s.ensureAdminToken();
|
|
467
|
+
const file = s.writeDashboardLauncher('http://127.0.0.1:4000/ui');
|
|
468
|
+
const fs = await import('node:fs');
|
|
469
|
+
console.log(JSON.stringify({ token, file, mode: (fs.statSync(file).mode & 0o777).toString(8), html: fs.readFileSync(file, 'utf8') }));`
|
|
470
|
+
], { env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(dir, 'config.json') }, encoding: 'utf8' }).trim());
|
|
471
|
+
assert.ok(!out.file.includes(out.token), 'the path that reaches argv holds no token');
|
|
472
|
+
assert.equal(out.mode, '600');
|
|
473
|
+
assert.ok(out.html.includes(`http://127.0.0.1:4000/ui#token=${out.token}`));
|
|
474
|
+
} finally {
|
|
475
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// A leaf from another build does not chain to ca.pem, and a key from another build does not
|
|
480
|
+
// match the leaf: Codex then fails with a TLS error that reads like a network fault (audit F17).
|
|
481
|
+
test('blindfoldPreflight refuses a leaf that does not chain to ca.pem or does not match leaf.key', (t) => {
|
|
482
|
+
if (process.platform === 'win32' || !fs.existsSync('/usr/bin/openssl')) return t.skip('needs bash and openssl');
|
|
483
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-chain-'));
|
|
484
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
485
|
+
const [a, b] = ['a', 'b'].map(n => path.join(dir, n));
|
|
486
|
+
for (const d of [a, b]) execFileSync('bash', [path.join(ROOT_DIR, 'blindfold', 'make-certs.sh'), 'chatgpt.com', d], { stdio: 'ignore' });
|
|
487
|
+
const desired = { ca: path.join(a, 'ca.pem'), host: 'chatgpt.com' };
|
|
488
|
+
assert.equal(blindfoldPreflight(desired), null);
|
|
489
|
+
|
|
490
|
+
const keep = (f) => fs.readFileSync(path.join(a, f));
|
|
491
|
+
const leafPem = keep('leaf.pem');
|
|
492
|
+
fs.copyFileSync(path.join(b, 'leaf.pem'), path.join(a, 'leaf.pem'));
|
|
493
|
+
assert.match(blindfoldPreflight(desired) || '', /not signed by/);
|
|
494
|
+
fs.writeFileSync(path.join(a, 'leaf.pem'), leafPem);
|
|
495
|
+
|
|
496
|
+
fs.copyFileSync(path.join(b, 'leaf.key'), path.join(a, 'leaf.key'));
|
|
497
|
+
assert.match(blindfoldPreflight(desired) || '', /does not match/);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// ca.key signs for every host that Codex trusts it for. The name constraint limits a leaked key
|
|
501
|
+
// to the intercepted host (audit attacker missed-4).
|
|
502
|
+
test('make-certs.sh builds a CA that can sign only for its host', (t) => {
|
|
503
|
+
if (process.platform === 'win32' || !fs.existsSync('/usr/bin/openssl')) return t.skip('needs bash and openssl');
|
|
504
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-nc-'));
|
|
505
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
506
|
+
const certs = path.join(dir, 'certs');
|
|
507
|
+
execFileSync('bash', [path.join(ROOT_DIR, 'blindfold', 'make-certs.sh'), 'chatgpt.com', certs], { stdio: 'ignore' });
|
|
508
|
+
const ossl = (...args) => execFileSync('openssl', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
509
|
+
assert.match(ossl('verify', '-CAfile', path.join(certs, 'ca.pem'), path.join(certs, 'leaf.pem')), /OK/);
|
|
510
|
+
|
|
511
|
+
// A leaf for another host, signed with the same CA key, must fail verification.
|
|
512
|
+
ossl('ecparam', '-name', 'prime256v1', '-genkey', '-noout', '-out', 'evil.key');
|
|
513
|
+
ossl('req', '-new', '-key', 'evil.key', '-subj', '/CN=evil.test', '-out', 'evil.csr');
|
|
514
|
+
fs.writeFileSync(path.join(dir, 'evil.ext'), 'subjectAltName = DNS:evil.test\n');
|
|
515
|
+
ossl('x509', '-req', '-in', 'evil.csr', '-CA', path.join(certs, 'ca.pem'), '-CAkey', path.join(certs, 'ca.key'),
|
|
516
|
+
'-CAcreateserial', '-days', '1', '-extfile', 'evil.ext', '-out', 'evil.pem');
|
|
517
|
+
assert.throws(() => ossl('verify', '-CAfile', path.join(certs, 'ca.pem'), 'evil.pem'), /permitted subtree violation/);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// ---- Launch state, config cache, logs, probes (audit F28, F31, F42, M5, racer "blocked gateway") ----
|
|
521
|
+
|
|
522
|
+
// Paths are computed at import, so each scenario runs state.mjs in a child with its own dirs.
|
|
523
|
+
function runState(env, code) {
|
|
524
|
+
const out = execFileSync(process.execPath, ['--input-type=module', '-e',
|
|
525
|
+
`const s = await import(${JSON.stringify(path.join(ROOT_DIR, 'state.mjs'))}); const fs = await import('node:fs'); const path = await import('node:path');
|
|
526
|
+
const result = await (async () => { ${code} })(); console.log(JSON.stringify(result));`],
|
|
527
|
+
{ env: { ...process.env, LLM_SWITCHER_PORT: '', ...env }, encoding: 'utf8' });
|
|
528
|
+
return JSON.parse(out.trim().split('\n').pop());
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function tmpDirs(t) {
|
|
532
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-state-'));
|
|
533
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
534
|
+
const cfg = path.join(dir, 'config.json');
|
|
535
|
+
fs.writeFileSync(cfg, JSON.stringify(makeCfg()), { mode: 0o600 });
|
|
536
|
+
return { dir, env: { LLM_SWITCHER_CONFIG: cfg, LLM_SWITCHER_STATE_DIR: dir, CLAUDE_CONFIG_DIR: path.join(dir, 'claude') } };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
test('launch files follow LLM_SWITCHER_STATE_DIR, so tests never touch the real ones', (t) => {
|
|
540
|
+
const { dir, env } = tmpDirs(t);
|
|
541
|
+
const r = runState(env, `const st = s.applyLaunchState(s.loadConfig(), 4000); return { active: fs.existsSync(path.join(${JSON.stringify(dir)}, 'active.flag')), flag: s.paths.activeFlag };`);
|
|
542
|
+
assert.equal(r.active, true);
|
|
543
|
+
assert.equal(r.flag, path.join(dir, 'active.flag'));
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('a failed env write leaves the flags alone, so no launcher sources a missing env file', (t) => {
|
|
547
|
+
const { dir, env } = tmpDirs(t);
|
|
548
|
+
fs.mkdirSync(path.join(dir, 'env-codex.sh'));
|
|
549
|
+
const r = runState(env, `const st = s.applyLaunchState(s.loadConfig(), 4000); return { err: st.envWriteError || null, active: fs.existsSync(s.paths.activeFlag), envSh: fs.existsSync(s.paths.envSh) };`);
|
|
550
|
+
assert.ok(r.err, 'the failure is reported');
|
|
551
|
+
assert.equal(r.active, false, 'active.flag is not written when the env files are not');
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test('a haiku-only 1M profile reports its 1M tier and keeps the main session model', () => {
|
|
555
|
+
const cfg = makeCfg();
|
|
556
|
+
cfg.profiles.router.model1M = { haiku: true };
|
|
557
|
+
const st = computeLaunchState(cfg, 4000);
|
|
558
|
+
assert.equal(st.claude1M, null, 'the main session does not move to Haiku');
|
|
559
|
+
assert.deepEqual(st.claude1MTiers, ['haiku']);
|
|
560
|
+
assert.equal(Object.fromEntries(st.env).ANTHROPIC_DEFAULT_HAIKU_MODEL, 'haiku[1m]');
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('loadConfig sees a rewrite that keeps the same mtime', (t) => {
|
|
564
|
+
const { dir, env } = tmpDirs(t);
|
|
565
|
+
const cfgPath = path.join(dir, 'config.json');
|
|
566
|
+
const r = runState(env, `
|
|
567
|
+
const first = s.loadConfig().activeProfile;
|
|
568
|
+
const st = fs.statSync(${JSON.stringify(cfgPath)});
|
|
569
|
+
const next = JSON.parse(fs.readFileSync(${JSON.stringify(cfgPath)}, 'utf8'));
|
|
570
|
+
next.activeProfile = 'codexOnly';
|
|
571
|
+
fs.writeFileSync(${JSON.stringify(cfgPath)} + '.new', JSON.stringify(next));
|
|
572
|
+
fs.renameSync(${JSON.stringify(cfgPath)} + '.new', ${JSON.stringify(cfgPath)});
|
|
573
|
+
fs.utimesSync(${JSON.stringify(cfgPath)}, st.atime, st.mtime);
|
|
574
|
+
return { first, second: s.loadConfig().activeProfile };`);
|
|
575
|
+
assert.equal(r.first, 'router');
|
|
576
|
+
assert.equal(r.second, 'codexOnly');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('openLog rotates a log above the size limit', (t) => {
|
|
580
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-log-'));
|
|
581
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
582
|
+
const file = path.join(dir, 'proxy.log');
|
|
583
|
+
fs.writeFileSync(file, Buffer.alloc(11 * 1024 * 1024, 'a'));
|
|
584
|
+
fs.closeSync(openLog(file));
|
|
585
|
+
assert.equal(fs.statSync(file).size, 0);
|
|
586
|
+
assert.equal(fs.statSync(`${file}.1`).size, 11 * 1024 * 1024);
|
|
587
|
+
if (process.platform !== 'win32') assert.equal((fs.statSync(file).mode & 0o777).toString(8), '600');
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test('a port that accepts but never answers probes as silent, not as foreign', async (t) => {
|
|
591
|
+
const net = await import('node:net');
|
|
592
|
+
const server = net.createServer(() => {});
|
|
593
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
594
|
+
t.after(() => server.close());
|
|
595
|
+
assert.equal(await probeGateway(server.address().port), 'silent');
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// Codex parses an unquoted --config value as TOML first. The Windows shim passes names unquoted, so a
|
|
599
|
+
// name that TOML reads as a number, a boolean or a date would change type (audit N-3 residual).
|
|
600
|
+
test('isSafeModelName refuses names that TOML reads as something other than a string', () => {
|
|
601
|
+
for (const bad of ['1.5', '42', 'true', 'false', 'inf', '-nan', '0x1F', '0o17', '0b101', '1e5', '1_000', '2024-01-01', '07:32:00']) {
|
|
602
|
+
assert.equal(isSafeModelName(bad), false, bad);
|
|
603
|
+
}
|
|
604
|
+
for (const good of ['gpt-5.2', 'o3', 'ag/gemini-3.8-flash', 'claude-opus-4-6', 'qwen3:32b', 'e5-large']) {
|
|
605
|
+
assert.equal(isSafeModelName(good), true, good);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// The CLI and the gateway both write the launch files, one file at a time. Two writers that overlap
|
|
610
|
+
// would leave env.sh from one state and env-codex.sh from another (audit racer-M6).
|
|
611
|
+
test('launch-file writers take turns through a lock, and a dead holder does not block them', async (t) => {
|
|
612
|
+
const { dir, env } = tmpDirs(t);
|
|
613
|
+
const lock = path.join(dir, '.launch.lock');
|
|
614
|
+
// A live holder: this test process. The child must wait until the lock is gone.
|
|
615
|
+
fs.writeFileSync(lock, String(process.pid));
|
|
616
|
+
setTimeout(() => fs.rmSync(lock, { force: true }), 400);
|
|
617
|
+
const started = Date.now();
|
|
618
|
+
const r = await new Promise((resolve, reject) => {
|
|
619
|
+
execFileCb(process.execPath, ['--input-type=module', '-e',
|
|
620
|
+
`const s = await import(${JSON.stringify(path.join(ROOT_DIR, 'state.mjs'))}); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
|
|
621
|
+
{ env: { ...process.env, LLM_SWITCHER_PORT: '', ...env }, encoding: 'utf8' }, (err, out) => (err ? reject(err) : resolve(Number(out.trim()))));
|
|
622
|
+
});
|
|
623
|
+
// Without the lock the write takes a few ms; with it the child waits for the release at 400 ms.
|
|
624
|
+
assert.ok(r >= 100, `the writer waited ${r} ms for the live holder`);
|
|
625
|
+
assert.ok(Date.now() - started >= 380);
|
|
626
|
+
assert.equal(fs.existsSync(lock), false, 'the writer releases the lock');
|
|
627
|
+
// A holder that no longer runs is taken over at once.
|
|
628
|
+
fs.writeFileSync(lock, '999999999');
|
|
629
|
+
const quick = runState(env, `const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); return Date.now() - t0;`);
|
|
630
|
+
assert.ok(quick < 1000, `a stale lock blocked for ${quick} ms`);
|
|
631
|
+
assert.equal(fs.existsSync(lock), false);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// A probe that never settles held the gateway's admin chain for good (follow-up RACER-1): a listener
|
|
635
|
+
// that answers with more than 64 KB, or one that trickles bytes without end.
|
|
636
|
+
test('probes settle within their deadline for an oversized or a never-ending answer', async (t) => {
|
|
637
|
+
const http = await import('node:http');
|
|
638
|
+
const big = http.createServer((req, res) => res.end(Buffer.alloc(100 * 1024, 'x')));
|
|
639
|
+
const trickle = http.createServer((req, res) => {
|
|
640
|
+
res.writeHead(200);
|
|
641
|
+
const timer = setInterval(() => res.write('x'), 500);
|
|
642
|
+
res.on('close', () => clearInterval(timer));
|
|
643
|
+
});
|
|
644
|
+
for (const s of [big, trickle]) await new Promise(r => s.listen(0, '127.0.0.1', r));
|
|
645
|
+
t.after(() => { big.close(); trickle.close(); big.closeAllConnections?.(); trickle.closeAllConnections?.(); });
|
|
646
|
+
for (const [s, want] of [[big, 'foreign'], [trickle, 'silent']]) {
|
|
647
|
+
const started = Date.now();
|
|
648
|
+
const state = await Promise.race([probeGateway(s.address().port), new Promise(r => setTimeout(() => r('pending'), 5000))]);
|
|
649
|
+
assert.equal(state, want);
|
|
650
|
+
assert.ok(Date.now() - started < 3600, `settled after ${Date.now() - started} ms`);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test('isSafeModelName refuses TOML date-times with T and Z', () => {
|
|
655
|
+
for (const bad of ['2024-01-01T00:00:00Z', '1979-05-27T07:32:00', '1979-05-27 07:32:00+07:00', '1979-05-27t07:32:00.5z']) {
|
|
656
|
+
assert.equal(isSafeModelName(bad), false, bad);
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
// An empty lock file is a writer between create and write. Taking it over let two writers in
|
|
661
|
+
// (follow-up RACER-2).
|
|
662
|
+
test('an empty launch lock is held, not taken over', async (t) => {
|
|
663
|
+
const { dir, env } = tmpDirs(t);
|
|
664
|
+
const lock = path.join(dir, '.launch.lock');
|
|
665
|
+
fs.writeFileSync(lock, '');
|
|
666
|
+
setTimeout(() => fs.rmSync(lock, { force: true }), 400);
|
|
667
|
+
const waited = await new Promise((resolve, reject) => {
|
|
668
|
+
execFileCb(process.execPath, ['--input-type=module', '-e',
|
|
669
|
+
`const s = await import(${JSON.stringify(path.join(ROOT_DIR, 'state.mjs'))}); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
|
|
670
|
+
{ env: { ...process.env, LLM_SWITCHER_PORT: '', ...env }, encoding: 'utf8' }, (err, out) => (err ? reject(err) : resolve(Number(out.trim()))));
|
|
671
|
+
});
|
|
672
|
+
// Taken over at once, the write would take a few ms.
|
|
673
|
+
assert.ok(waited >= 100 && waited < 4000, `the writer waited ${waited} ms`);
|
|
674
|
+
assert.equal(fs.existsSync(lock), false);
|
|
675
|
+
});
|