llm-switcher 1.1.11 → 1.2.2

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 (61) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +202 -257
  3. package/README.vi.md +200 -256
  4. package/blindfold/blindfold.mjs +200 -53
  5. package/blindfold/make-certs.sh +26 -7
  6. package/catalog.mjs +246 -0
  7. package/classifier.mjs +238 -0
  8. package/config.example.json +12 -34
  9. package/docs/codex-blindfold.md +28 -17
  10. package/docs/cross-platform.md +16 -7
  11. package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
  12. package/docs/diagrams/ir-healer-pipeline.png +0 -0
  13. package/docs/diagrams/ir-healer-pipeline.svg +90 -0
  14. package/docs/diagrams/ir-translation-pipeline.html +14925 -0
  15. package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
  16. package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
  17. package/docs/diagrams/system-architecture.architecture.json +76 -0
  18. package/docs/diagrams/system-architecture.html +14978 -0
  19. package/docs/diagrams/system-architecture.svg +5147 -0
  20. package/docs/diagrams/system-topology.mmd +30 -0
  21. package/docs/diagrams/system-topology.png +0 -0
  22. package/docs/diagrams/system-topology.svg +125 -0
  23. package/ensure-ca-bundle.mjs +28 -0
  24. package/formats.mjs +43 -156
  25. package/icons/antigravity.png +0 -0
  26. package/icons/claude.png +0 -0
  27. package/icons/codex.png +0 -0
  28. package/icons/deepseek.png +0 -0
  29. package/icons/gemini.png +0 -0
  30. package/icons/github.png +0 -0
  31. package/icons/groq.png +0 -0
  32. package/icons/intact.svg +1 -0
  33. package/icons/ollama.png +0 -0
  34. package/icons/openai.png +0 -0
  35. package/icons/openrouter.png +0 -0
  36. package/icons/qwen.png +0 -0
  37. package/icons/vertex.png +0 -0
  38. package/mcp.mjs +39 -11
  39. package/package.json +1 -1
  40. package/proxy.mjs +114 -37
  41. package/shim.mjs +200 -57
  42. package/skills/llm-switcher/SKILL.md +15 -10
  43. package/state.mjs +1100 -191
  44. package/switch.cmd +2 -2
  45. package/switch.mjs +228 -53
  46. package/tests/blindfold-e2e.test.mjs +380 -0
  47. package/tests/blindfold-task5.test.mjs +429 -0
  48. package/tests/blindfold.task3.test.mjs +700 -0
  49. package/tests/blindfold.test.mjs +10 -5
  50. package/tests/catalog.test.mjs +147 -0
  51. package/tests/classifier.test.mjs +210 -0
  52. package/tests/contract-lab.test.mjs +22 -7
  53. package/tests/formats.test.mjs +63 -46
  54. package/tests/gateway.e2e.test.mjs +136 -36
  55. package/tests/lifecycle.test.mjs +16 -10
  56. package/tests/mcp.test.mjs +78 -2
  57. package/tests/real-user-sim.test.mjs +464 -0
  58. package/tests/shim.test.mjs +159 -66
  59. package/tests/state.test.mjs +975 -193
  60. package/tests/switch.test.mjs +446 -2
  61. package/ui.html +1710 -1726
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.1.11",
3
+ "version": "1.2.2",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code, Codex, OpenAI and Gemini clients",
5
5
  "keywords": [
6
6
  "llm",
package/proxy.mjs CHANGED
@@ -7,21 +7,23 @@ import { promisify } from 'node:util';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { createFrameReader } from './blindfold/wsframe.mjs';
9
9
  import {
10
- OUT_FORMATS, IN_FORMATS, parseToIR, emitUpstreamBody, createUpstreamNormalizer, createCollector,
10
+ OUT_FORMATS, parseToIR, emitUpstreamBody, createUpstreamNormalizer, createCollector,
11
11
  createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES,
12
12
  toGeminiSchema, isAntigravityModel,
13
13
  createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
14
14
  buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage
15
15
  } from './formats.mjs';
16
16
  import {
17
- TARGETS, configPath, loadConfig, getConfigLoadError, saveConfig, resolvePort, hasProfile, isValidProfileKey,
17
+ TOOLS, configPath, loadConfig, getConfigLoadError, saveConfig, resolvePort, hasProfile, isValidProfileKey,
18
+ getMigrationError, getMigrationCollision,
18
19
  getActiveMap, setTargetProfile, activateProfile, deactivateProfile, deactivateAll, deleteProfile,
19
20
  isProfileActive, profileAcceptsTarget, applyLaunchState, readLaunchFlags, redactConfig, MASKED_KEY,
20
21
  modelForSlot, primaryModel, codexPublicModel, isSafeModelName, parsePort, CODEX_MODEL_SLOTS,
21
22
  ensureAdminToken, identityProof, reconcileBlindfold, checkBlindfoldTarget,
22
- codexModelEntry, smallestWindows, publicModelWindows, model1MForSlot, computeLaunchState,
23
- contractLabSettings
23
+ codexModelEntry, smallestWindows, publicModelWindows, model1MForSlot,
24
+ contractLabSettings, STATE_DIR
24
25
  } from './state.mjs';
26
+ import { classifyCodexRole, classifyClaudeTier, loadCatalogCache, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
25
27
  import { createContractLab, createHalfTap, tapClientWrites, capText, capJson, toolVersionFromUA, finishHalf, PROBE_HEADER, TRACE_ID_RE } from './contract.mjs';
26
28
 
27
29
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -198,6 +200,9 @@ async function readJsonBody(req, limit = MAX_API_BODY_SIZE) {
198
200
  // ----------------------------------------------------
199
201
  // Profile & model resolution
200
202
  // ----------------------------------------------------
203
+ // R5: the only two input protocols, each owned by one tool.
204
+ const CLIENT_FORMAT_TOOL = { anthropic: 'claude', responses: 'codex' };
205
+
201
206
  function getActiveProfile(clientFormat, req) {
202
207
  const cfg = loadConfig();
203
208
  if (!cfg) return { cfg: null, profileKey: null, profile: null };
@@ -214,9 +219,12 @@ function getActiveProfile(clientFormat, req) {
214
219
  return { cfg, profileKey: reqProfile, profile: null, error: `Profile "${reqProfile}" requested via x-llm-profile/?profile= does not exist` };
215
220
  }
216
221
 
217
- // Look up the active profile by CLI target (clientFormat: anthropic | responses | openai-chat | vertex).
218
- // Deleted profile / disabled target counts as OFF, never silently fall through to another profile (different API key!).
219
- const key = clientFormat ? getActiveMap(cfg)[clientFormat] : (cfg.activeProfile || null);
222
+ // R4: resolve by TOOL, not by the protocol the request arrived in. Only two client formats
223
+ // exist anymore — anthropic is Claude Code's, responses is Codex's — and each has exactly one
224
+ // pointer, activeProfiles.claude and activeProfiles.codex. A deleted profile or a disabled tool
225
+ // counts as OFF and never falls through to the other tool's profile (different API key!).
226
+ const key = clientFormat ? getActiveMap(cfg)[CLIENT_FORMAT_TOOL[clientFormat]] ?? null
227
+ : (cfg.activeProfile || null);
220
228
  if (!key || !hasProfile(cfg, key)) return { cfg, profileKey: key || null, profile: null };
221
229
  return { cfg, profileKey: key, profile: cfg.profiles[key] };
222
230
  }
@@ -266,12 +274,12 @@ function mapModel(requestedModel, profile, clientFormat) {
266
274
  // retired gpt-5.3-codex) have no credentials behind 9Router -> fail closed to the main slot
267
275
  // instead of passing through to an upstream 404. Provider-prefixed names (ag/..., cf/...)
268
276
  // are preserved by the early return above.
269
- if (/^(gpt|o\d|codex)([-/]|$)/i.test(clean)) return modelForSlot(profile, 'main') || clean;
277
+ if (/^(gpt|o\d|codex)([-/]|$)/i.test(clean)) {
278
+ const autoRole = classifyCodexRole(clean);
279
+ return (autoRole !== 'main' ? modelForSlot(profile, autoRole) : null) || modelForSlot(profile, 'main') || clean;
280
+ }
270
281
  return clean || requestedModel;
271
282
  }
272
- if (clientFormat === 'openai-chat' || clientFormat === 'vertex') {
273
- if (m === 'default' || m === 'main' || !clean) return modelForSlot(profile, 'default') || clean;
274
- }
275
283
  if (m.includes('fable')) return modelForSlot(profile, 'fable') || clean;
276
284
  if (m.includes('opus')) return modelForSlot(profile, 'opus') || clean;
277
285
  if (m.includes('haiku')) return modelForSlot(profile, 'haiku') || clean;
@@ -974,7 +982,15 @@ function validateProfileInput(p) {
974
982
  for (const k of ['name', 'baseURL', 'optimizerURL']) {
975
983
  if (typeof p[k] === 'string' && CONTROL_CHARS.test(p[k])) return `${k} must not contain control characters`;
976
984
  }
977
- if (p.inFormat && p.inFormat !== 'auto' && !IN_FORMATS.includes(p.inFormat)) return `Invalid inFormat "${p.inFormat}"`;
985
+ // R6 / R7(c): a profile declares exactly one tool. An old-format field must never be written back.
986
+ if (p.inFormat !== undefined) return 'inFormat is not supported; use "tool": "claude" or "tool": "codex"';
987
+ if (p.blindfold !== undefined) return 'blindfold per-profile setting is deprecated';
988
+ if (p.blindfoldPort !== undefined) return 'blindfoldPort per-profile setting is deprecated';
989
+ if (p.blindfoldHost !== undefined) return 'blindfoldHost per-profile setting is deprecated';
990
+ if (p.blindfoldPrefix !== undefined) return 'blindfoldPrefix per-profile setting is deprecated';
991
+ if (p.tool !== undefined && p.tool !== null && !TOOLS.includes(p.tool)) {
992
+ return `Invalid tool "${p.tool}". Valid: ${TOOLS.join(', ')}`;
993
+ }
978
994
  if (p.outFormat && !OUT_FORMATS.includes(p.outFormat)) return `Invalid outFormat "${p.outFormat}"`;
979
995
  if (p.mode && !VALID_MODES.includes(p.mode)) return `Invalid mode "${p.mode}"`;
980
996
  if (p.thinkingMode && !THINKING_MODES.includes(p.thinkingMode)) return `Invalid thinkingMode "${p.thinkingMode}"`;
@@ -1002,15 +1018,6 @@ function validateProfileInput(p) {
1002
1018
  if (name !== '' && !isSafeModelName(name)) return `Invalid codexRoles.${slot} value "${name}"`;
1003
1019
  }
1004
1020
  }
1005
- if (p.blindfoldPort !== undefined && p.blindfoldPort !== '' && !parsePort(p.blindfoldPort)) {
1006
- return `Invalid blindfoldPort "${p.blindfoldPort}"`;
1007
- }
1008
- if (p.blindfoldHost !== undefined && p.blindfoldHost !== '' && !/^[A-Za-z0-9.-]{1,253}$/.test(p.blindfoldHost)) {
1009
- return `Invalid blindfoldHost "${p.blindfoldHost}"`;
1010
- }
1011
- if (p.blindfoldPrefix !== undefined && p.blindfoldPrefix !== '' && !/^\/[A-Za-z0-9._~/-]{0,200}$/.test(p.blindfoldPrefix)) {
1012
- return `Invalid blindfoldPrefix "${p.blindfoldPrefix}"`;
1013
- }
1014
1021
  return null;
1015
1022
  }
1016
1023
 
@@ -1080,7 +1087,9 @@ async function fetchModels(body, cfg) {
1080
1087
 
1081
1088
  // Vertex/Gemini: /v1beta/models/{m}:{action} (Gemini API) and
1082
1089
  // /v1/projects/{p}/locations/{l}/publishers/{pub}/models/{m}:{action} (Vertex AI SDK).
1083
- const VERTEX_ROUTE = /^\/(?:v1|v1beta|v1beta1)\/(?:projects\/[^/]+\/locations\/[^/]+\/publishers\/[^/]+\/)?models\/([^/:]+):(generateContent|streamGenerateContent)$/;
1090
+ // R4/Codex: model mapping for the responses input only. Claude Code's mapping is the tier
1091
+ // substring chain at the bottom of mapModel. No alias (`main`, `opus[1m]`) ever leaves this
1092
+ // function: the `[1m]` suffix is stripped before anything is looked up.
1084
1093
 
1085
1094
  // ----------------------------------------------------
1086
1095
  // Router
@@ -1100,11 +1109,32 @@ async function route(req, res) {
1100
1109
  res.writeHead(204, {
1101
1110
  'Access-Control-Allow-Origin': req.headers.origin || `http://127.0.0.1:${PORT}`,
1102
1111
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1103
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
1112
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-llm-switcher-token'
1104
1113
  });
1105
1114
  return res.end();
1106
1115
  }
1107
1116
 
1117
+ // Serve static tool icons (cached, immutable, strictly image/png and image/svg+xml)
1118
+ if (method === 'GET' && pathname.startsWith('/icons/')) {
1119
+ const iconName = path.basename(pathname);
1120
+ if (!/\.(png|svg)$/i.test(iconName)) {
1121
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1122
+ return res.end('Icon not found');
1123
+ }
1124
+ const iconPath = path.join(__dirname, 'icons', iconName);
1125
+ if (fs.existsSync(iconPath)) {
1126
+ const ext = path.extname(iconName).toLowerCase();
1127
+ const contentType = ext === '.svg' ? 'image/svg+xml' : 'image/png';
1128
+ res.writeHead(200, {
1129
+ 'Content-Type': contentType,
1130
+ 'Cache-Control': 'public, max-age=604800, immutable'
1131
+ });
1132
+ return fs.createReadStream(iconPath).pipe(res);
1133
+ }
1134
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1135
+ return res.end('Icon not found');
1136
+ }
1137
+
1108
1138
  // Serve Web UI (no-cache: always serve the latest version after file edits)
1109
1139
  if (method === 'GET' && (pathname === '/' || pathname === '/ui')) {
1110
1140
  if (fs.existsSync(uiHtmlPath)) {
@@ -1123,7 +1153,7 @@ async function route(req, res) {
1123
1153
 
1124
1154
  // Health check
1125
1155
  if (method === 'GET' && pathname === '/health') {
1126
- const { profileKey, profile } = getFirstActiveProfile(TARGETS, req);
1156
+ const { profileKey, profile } = getFirstActiveProfile(['responses', 'anthropic'], req);
1127
1157
  // ?challenge=<nonce> lets the CLI tell this gateway from a process that replays a /health body.
1128
1158
  const challenge = parsedUrl.searchParams.get('challenge');
1129
1159
  return sendJson(res, 200, {
@@ -1151,6 +1181,7 @@ async function route(req, res) {
1151
1181
  // advertised: Codex sends them in-request and mapModel resolves them server-side.
1152
1182
  // Without publicModels, fall back to the deduplicated mapped upstream IDs.
1153
1183
  if (method === 'GET' && (pathname === '/v1/models' || pathname === '/models')) {
1184
+ checkVersionAndRefresh(req.headers['anthropic-version'] ? 'claude' : 'codex', req.headers, STATE_DIR);
1154
1185
  const { ids, windows } = servedModels(req);
1155
1186
  const created = Math.floor(Date.now() / 1000);
1156
1187
  return sendJson(res, 200, {
@@ -1183,9 +1214,7 @@ async function route(req, res) {
1183
1214
  return handleCountTokens(req, res, buf);
1184
1215
  }
1185
1216
 
1186
- // Client endpoints, one per input protocol (auto-detected by path).
1187
1217
  let clientFormat = null;
1188
- const vmatch = method === 'POST' ? pathname.match(VERTEX_ROUTE) : null;
1189
1218
 
1190
1219
  // Codex CLI sends GET /v1/responses (and /v1/responses/{id}) to fetch model
1191
1220
  // metadata and retrieve previous responses. The gateway is stateless, so
@@ -1206,18 +1235,18 @@ async function route(req, res) {
1206
1235
 
1207
1236
  if (method === 'POST') {
1208
1237
  if (pathname === '/v1/messages' || pathname === '/messages') clientFormat = 'anthropic';
1209
- else if (pathname === '/v1/chat/completions' || pathname === '/chat/completions') clientFormat = 'openai-chat';
1210
1238
  else if (pathname === '/v1/responses' || pathname === '/responses') clientFormat = 'responses';
1211
- else if (vmatch) clientFormat = 'vertex';
1212
1239
  }
1213
1240
  if (clientFormat) {
1241
+ if (clientFormat === 'anthropic') checkVersionAndRefresh('claude', req.headers, STATE_DIR);
1242
+ else if (clientFormat === 'responses') checkVersionAndRefresh('codex', req.headers, STATE_DIR);
1214
1243
  let buf;
1215
1244
  try {
1216
1245
  buf = await readBody(req, MAX_BODY_SIZE);
1217
1246
  } catch (err) {
1218
1247
  return sendClientError(res, clientFormat, err.status || 400, err.message);
1219
1248
  }
1220
- const opts = vmatch ? { vertexModel: decodeURIComponent(vmatch[1]), vertexStream: vmatch[2] === 'streamGenerateContent' } : {};
1249
+ const opts = {};
1221
1250
  return handleConvert(clientFormat, req, res, buf, opts);
1222
1251
  }
1223
1252
 
@@ -1228,7 +1257,7 @@ async function route(req, res) {
1228
1257
  // The names /v1/models serves, and the window of each. Official names when the profile publishes
1229
1258
  // them, otherwise the mapped upstream IDs; each window follows model1M of its slot.
1230
1259
  function servedModels(req) {
1231
- const { profile } = getFirstActiveProfile(['responses', 'openai-chat', 'anthropic', 'vertex'], req);
1260
+ const { profile } = getFirstActiveProfile(['responses', 'anthropic'], req);
1232
1261
  if (Array.isArray(profile?.publicModels) && profile.publicModels.length) {
1233
1262
  return { ids: [...new Set(profile.publicModels.filter(Boolean))], windows: publicModelWindows(profile) };
1234
1263
  }
@@ -1247,9 +1276,8 @@ async function routeApi(req, res, method, pathname) {
1247
1276
  activeProfile: cfg.activeProfile || null,
1248
1277
  activeProfiles,
1249
1278
  revision: configRevision(cfg),
1250
- claude1MTiers: computeLaunchState(cfg, PORT).claude1MTiers,
1251
1279
  ...readLaunchFlags(),
1252
- claudeBaseURL: activeProfiles.anthropic ? `http://127.0.0.1:${PORT} (injected via launcher)` : '(none / official)',
1280
+ claudeBaseURL: activeProfiles.claude ? `http://127.0.0.1:${PORT} (injected via launcher)` : '(none / official)',
1253
1281
  config: redactConfig(cfg)
1254
1282
  });
1255
1283
  }
@@ -1259,6 +1287,11 @@ async function routeApi(req, res, method, pathname) {
1259
1287
  return sendJson(res, 200, { logs: requestLogs.slice().reverse() });
1260
1288
  }
1261
1289
 
1290
+ // GET /api/catalog (Dynamic Model Discovery)
1291
+ if (method === 'GET' && pathname === '/api/catalog') {
1292
+ return sendJson(res, 200, loadCatalogCache(STATE_DIR));
1293
+ }
1294
+
1262
1295
  if (method !== 'POST') {
1263
1296
  req.resume();
1264
1297
  return sendJson(res, 404, { error: `Not found: ${method} ${pathname}` });
@@ -1271,6 +1304,19 @@ async function routeApi(req, res, method, pathname) {
1271
1304
  return sendJson(res, err.status || 400, { error: err.message });
1272
1305
  }
1273
1306
 
1307
+ // POST /api/catalog/refresh
1308
+ if (method === 'POST' && pathname === '/api/catalog/refresh') {
1309
+ const active = getActiveMap(requireConfig(res) || {});
1310
+ const cfg = requireConfig(res) || {};
1311
+ const claudeProfile = active.claude ? cfg.profiles?.[active.claude] : null;
1312
+ const codexProfile = active.codex ? cfg.profiles?.[active.codex] : null;
1313
+ const updated = await refreshCatalog(STATE_DIR, {
1314
+ claudeKey: claudeProfile?.apiKey,
1315
+ codexKey: codexProfile?.apiKey
1316
+ });
1317
+ return sendJson(res, 200, { success: true, catalog: updated });
1318
+ }
1319
+
1274
1320
  // POST /api/logs/clear
1275
1321
  if (pathname === '/api/logs/clear') {
1276
1322
  requestLogs.length = 0;
@@ -1292,6 +1338,12 @@ async function routeConfigApi(res, method, pathname, body) {
1292
1338
  }
1293
1339
 
1294
1340
  // POST /api/switch { target?, profile? | null, deactivate? }
1341
+ // R7: a migration collision means config.json is in a shape this build refuses to rewrite.
1342
+ // Every route that writes it answers the same way, and the bytes on disk stay as they are.
1343
+ if (getMigrationCollision() && ['/api/switch', '/api/toggle', '/api/save-profile', '/api/delete-profile'].includes(pathname)) {
1344
+ return sendJson(res, 409, { error: 'Migration collision', clashingKeys: getMigrationCollision().clashingKeys });
1345
+ }
1346
+
1295
1347
  if (pathname === '/api/switch') {
1296
1348
  let err = null;
1297
1349
  if (body.deactivate) {
@@ -1313,7 +1365,7 @@ async function routeConfigApi(res, method, pathname, body) {
1313
1365
  const map = getActiveMap(cfg);
1314
1366
  let err = null;
1315
1367
  if (body.target) {
1316
- if (!TARGETS.includes(body.target)) return sendJson(res, 400, { error: `Unknown target "${body.target}"` });
1368
+ if (!TOOLS.includes(body.target)) return sendJson(res, 400, { error: `Unknown target "${body.target}"` });
1317
1369
  let key = null;
1318
1370
  if (body.enabled) {
1319
1371
  const candidates = [map[body.target], cfg.activeProfile, ...Object.keys(cfg.profiles)];
@@ -1361,7 +1413,7 @@ async function routeConfigApi(res, method, pathname, body) {
1361
1413
  const map = getActiveMap(cfg);
1362
1414
  cfg.activeProfiles = map;
1363
1415
  let unassigned = false;
1364
- for (const t of TARGETS) {
1416
+ for (const t of TOOLS) {
1365
1417
  if (map[t] === key && !profileAcceptsTarget(merged, t)) {
1366
1418
  map[t] = null;
1367
1419
  unassigned = true;
@@ -1369,7 +1421,16 @@ async function routeConfigApi(res, method, pathname, body) {
1369
1421
  }
1370
1422
 
1371
1423
  // Profile is active (or was just unassigned from a target) -> refresh 1M flags / env files.
1372
- const applied = isProfileActive(cfg, key) || unassigned ? await commit(cfg, baseRevision) : (saveConfig(cfg), { revision: configRevision(cfg) });
1424
+ let applied;
1425
+ try {
1426
+ applied = isProfileActive(cfg, key) || unassigned ? await commit(cfg, baseRevision) : (saveConfig(cfg), { revision: configRevision(cfg) });
1427
+ } catch (err) {
1428
+ // saveConfig refuses while a migration collision or migration error blocks writes (R7).
1429
+ if (getMigrationError() || getMigrationCollision()) {
1430
+ return sendJson(res, 409, { error: err.message });
1431
+ }
1432
+ throw err;
1433
+ }
1373
1434
  return sendJson(res, applied.status || (applied.success === false ? 502 : 200), { success: true, ...applied });
1374
1435
  }
1375
1436
 
@@ -1677,6 +1738,8 @@ server.on('upgrade', (req, socket) => {
1677
1738
  return;
1678
1739
  }
1679
1740
 
1741
+ checkVersionAndRefresh('codex', req.headers, STATE_DIR);
1742
+
1680
1743
  const key = req.headers['sec-websocket-key'];
1681
1744
  if (!key) {
1682
1745
  socket.destroy();
@@ -1788,12 +1851,26 @@ server.on('error', (err) => {
1788
1851
  if (!loadConfig()) {
1789
1852
  console.warn(`[llm-switcher:WARN] Could not load ${configPath}: ${getConfigLoadError()?.message}. Copy config.example.json to config.json.`);
1790
1853
  }
1854
+ // R7: a migration that could not complete blocks every write. Say so at startup instead of
1855
+ // failing later, on the first save, with an error nobody connects back to this.
1856
+ if (getMigrationError()) {
1857
+ console.error(`[llm-switcher:ERROR] ${getMigrationError().message}`);
1858
+ }
1859
+ if (getMigrationCollision()) {
1860
+ console.error(`[llm-switcher:ERROR] Configuration migration collision: ${JSON.stringify(getMigrationCollision().clashingKeys)}. Fix config.json; it is not being rewritten.`);
1861
+ }
1791
1862
 
1792
1863
  server.listen(PORT, '127.0.0.1', () => {
1793
1864
  // A service start or a restart on a new port finds env-codex.* already pointing at the interceptor.
1794
1865
  const cfg = loadConfig();
1795
- if (cfg && !getConfigLoadError()) serialized(() => reconcile(cfg));
1866
+ if (getMigrationCollision()) {
1867
+ console.error('[llm-switcher] Skipping reconcile: a configuration migration collision blocks writes.');
1868
+ } else if (cfg && !getConfigLoadError()) {
1869
+ serialized(() => reconcile(cfg));
1870
+ }
1796
1871
  console.log(`[llm-switcher] Server running on http://127.0.0.1:${PORT}`);
1797
1872
  console.log(`[llm-switcher] Web UI available at: http://127.0.0.1:${PORT}/ui`);
1798
- console.log(`[llm-switcher] Endpoints: /v1/messages (anthropic) | /v1/chat/completions (openai) | /v1/responses (codex) | /v1beta/models/* (vertex)`);
1873
+ console.log(`[llm-switcher] Endpoints: /v1/messages (Claude Code) | /v1/responses (Codex)`);
1874
+ // Asynchronously discover latest tool models in the background without blocking startup
1875
+ refreshCatalog(STATE_DIR).catch(() => {});
1799
1876
  });
package/shim.mjs CHANGED
@@ -26,33 +26,121 @@ import fs from 'node:fs';
26
26
  import path from 'node:path';
27
27
  import os from 'node:os';
28
28
  import { execFileSync } from 'node:child_process';
29
+ import { fileURLToPath } from 'node:url';
29
30
  import { paths, STATE_DIR } from './state.mjs';
30
31
 
31
32
  export const SHIM_DIR = path.join(os.homedir(), '.llm-switcher', 'bin');
32
33
 
33
- // Local model catalog: generated by applyLaunchState from the active profile's
34
- // publicModels, so it holds official OpenAI slugs only. It gives Codex the metadata
35
- // for the names it requests (otherwise the CLI falls back to full-context mode) and
36
- // it is what the /model picker renders — the picker never calls /v1/models.
37
- // Forward slashes for the TOML value. The Windows `if exist` test uses the native path instead.
38
- export const CODE_X_CATALOG_PATH = paths.codexCatalog.replace(/\\/g, '/');
34
+ // The shims hand the CA work to Node: reading files, comparing content and renaming atomically
35
+ // has no place in a shell script, and none at all in cmd.exe. It is a FILE rather than
36
+ // `node -e`, so neither cmd.exe quoting nor URL encoding can corrupt it: pathToFileURL turns a
37
+ // space in the checkout into %20, which cmd.exe then reads as the (undefined) argument %2.
38
+ const STATE_HELPER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ensure-ca-bundle.mjs');
39
39
 
40
40
  // CLIs to wrap. `claude` is the most important case (--resume), codex included for completeness.
41
41
  export const SHIMMED = ['claude', 'codex'];
42
42
 
43
- const POSIX_TEMPLATE = (name) => `#!/usr/bin/env bash
43
+ // One tool, one env file. Sourcing the shared one is how one tool used to capture the other.
44
+ const envFileOf = (name) => (name === 'codex' ? 'env-codex.sh' : 'env-claude.sh');
45
+
46
+ const POSIX_TEMPLATE = (name) => {
47
+ const envFile = envFileOf(name);
48
+ const caBlock = name === 'codex' ? '' : `
49
+ # R2: one bundle holds the user's own CA and the switcher CA, so trusting the interceptor never
50
+ # costs a certificate the user already had. Decided here, at launch, because only now is it known
51
+ # whether the user brought one — and never while this tool is off.
52
+ if [ "$TOOL_ACTIVE" = "1" ]; then
53
+ if [ -z "$INHERITED_CA" ]; then
54
+ if [ -f "$SWITCHER_DIR/blindfold/certs/ca.pem" ]; then
55
+ export NODE_EXTRA_CA_CERTS="$SWITCHER_DIR/blindfold/certs/ca.pem"
56
+ else
57
+ echo "[llm-switcher] $SWITCHER_DIR/blindfold/certs/ca.pem not found; NODE_EXTRA_CA_CERTS kept as inherited." >&2
58
+ fi
59
+ else
60
+ CA_BUNDLE="$(node "$STATE_HELPER" "$INHERITED_CA" "$SWITCHER_DIR/blindfold/certs/ca.pem" "$SWITCHER_DIR")" || CA_BUNDLE=""
61
+ if [ -n "$CA_BUNDLE" ] && [ -f "$CA_BUNDLE" ]; then
62
+ export NODE_EXTRA_CA_CERTS="$CA_BUNDLE"
63
+ fi
64
+ fi
65
+ fi`;
66
+ return `#!/usr/bin/env bash
44
67
  # Auto-generated by LLM Switcher — DO NOT EDIT.
45
- # Load the gateway env then run the real "${name}", even when the shell hasn't sourced env.sh
46
- # (e.g.: claude --resume reopening an old session in a clean terminal).
68
+ # Hand THIS tool its own gateway env, scrub what an older switcher left behind, then run the real
69
+ # "${name}", from any shell — including one that never sourced anything (claude --resume).
47
70
  SWITCHER_DIR="${STATE_DIR}"
71
+ STATE_HELPER="${STATE_HELPER}"
48
72
  SHIM_DIR="\${BASH_SOURCE%/*}"
49
73
 
50
- # Only load env when the gateway is on; when off, let the CLI run as-is. The shim runs what env.sh says,
51
- # so the directory and the file must belong to this account: another one could have created them.
52
- if [ -O "$SWITCHER_DIR" ] && [ -O "$SWITCHER_DIR/env.sh" ] && [ -f "$SWITCHER_DIR/active.flag" ]; then
53
- . "$SWITCHER_DIR/env.sh"
74
+ # Captured BEFORE the tool's env file is sourced: the CA decision must see the value this shell
75
+ # already had, not one this run is about to write.
76
+ INHERITED_CA="\${NODE_EXTRA_CA_CERTS:-}"
77
+
78
+ # Only this tool's own file, only while the gateway is on, and only files this account owns:
79
+ # another account could have created the directory or the file. Non-empty means the tool runs;
80
+ # an empty file means it is off, and then nothing is sourced at all.
81
+ if [ -O "$SWITCHER_DIR" ] && [ -O "$SWITCHER_DIR/${envFile}" ] && [ -f "$SWITCHER_DIR/active.flag" ] && [ -s "$SWITCHER_DIR/${envFile}" ]; then
82
+ . "$SWITCHER_DIR/${envFile}"
83
+ TOOL_ACTIVE=1
84
+ else
85
+ TOOL_ACTIVE=0
86
+ fi
87
+
88
+ # R8: a variable is removed ONLY when its value is one an older switcher wrote. The user's own
89
+ # proxy, base URL, model or LLM_SWITCHER_* setting is theirs and is kept.
90
+ GW_PORT="$( { [ -O "$SWITCHER_DIR/gateway.port" ] && tr -d '[:space:]' < "$SWITCHER_DIR/gateway.port"; } 2>/dev/null )"
91
+ case "$GW_PORT" in ''|*[!0-9]*) GW_PORT=3456 ;; esac
92
+ PORTS="$GW_PORT 3456"
93
+ [ -n "$LLM_SWITCHER_PORT" ] && PORTS="$PORTS $LLM_SWITCHER_PORT"
94
+
95
+ # Every loopback shape the switcher ever wrote: host, optional /v1, optional trailing slash.
96
+ SWITCHER_URLS=""
97
+ for _p in $PORTS; do
98
+ for _h in 127.0.0.1 localhost '[::1]'; do
99
+ SWITCHER_URLS="$SWITCHER_URLS $_h:$_p $_h:$_p/ $_h:$_p/v1 $_h:$_p/v1/"
100
+ done
101
+ done
102
+
103
+ _u="\${ANTHROPIC_BASE_URL:-}"
104
+ if [ -n "$_u" ]; then
105
+ case " $SWITCHER_URLS " in
106
+ *" \${_u#http://} "*) unset ANTHROPIC_BASE_URL ;;
107
+ esac
108
+ fi
109
+ _u="\${OPENAI_BASE_URL:-}"
110
+ if [ -n "$_u" ]; then
111
+ case " $SWITCHER_URLS " in
112
+ *" \${_u#http://} "*) unset OPENAI_BASE_URL ;;
113
+ esac
54
114
  fi
55
115
 
116
+ # The suffix the old switcher forced on every tier. Nothing else matches: the user's own
117
+ # ANTHROPIC_MODEL=opus, or a real model id, is theirs.
118
+ case "\${ANTHROPIC_MODEL:-}" in
119
+ opus\\[1m\\]|sonnet\\[1m\\]|haiku\\[1m\\]|fable\\[1m\\]) unset ANTHROPIC_MODEL ;;
120
+ esac
121
+ tier_is_stale() {
122
+ [ -n "$1" ] || return 1
123
+ case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
124
+ "$2"\\[1m\\]) return 0 ;;
125
+ *) return 1 ;;
126
+ esac
127
+ }
128
+ if tier_is_stale "$ANTHROPIC_DEFAULT_OPUS_MODEL" opus; then unset ANTHROPIC_DEFAULT_OPUS_MODEL; fi
129
+ if tier_is_stale "$ANTHROPIC_DEFAULT_SONNET_MODEL" sonnet; then unset ANTHROPIC_DEFAULT_SONNET_MODEL; fi
130
+ if tier_is_stale "$ANTHROPIC_DEFAULT_HAIKU_MODEL" haiku; then unset ANTHROPIC_DEFAULT_HAIKU_MODEL; fi
131
+ if tier_is_stale "$ANTHROPIC_DEFAULT_FABLE_MODEL" fable; then unset ANTHROPIC_DEFAULT_FABLE_MODEL; fi
132
+
133
+ [ "\${CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT:-}" = "1" ] && unset CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT
134
+ [ "\${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-}" = "900000" ] && unset CLAUDE_CODE_AUTO_COMPACT_WINDOW
135
+ [ "\${OPENAI_MAX_CONTEXT_TOKENS:-}" = "1000000" ] && unset OPENAI_MAX_CONTEXT_TOKENS
136
+
137
+ # LLM_SWITCHER_CODEX_* was always a shim input, never the user's. Every OTHER LLM_SWITCHER_*
138
+ # name is a user setting and is kept.
139
+ for _v in $(compgen -v LLM_SWITCHER_CODEX_ 2>/dev/null); do
140
+ unset "$_v"
141
+ done
142
+ ${caBlock}
143
+
56
144
  # Find the real binary: drop the shim directory from PATH so it doesn't call itself.
57
145
  CLEAN_PATH=""
58
146
  IFS=':'
@@ -72,65 +160,120 @@ if [ -z "$REAL" ]; then
72
160
  exit 127
73
161
  fi
74
162
 
75
- ${name === 'codex' ? `# Codex-only variables (blindfold proxy + CA). The claude shim must not load these.
76
- if [ -O "$SWITCHER_DIR" ] && [ -O "$SWITCHER_DIR/env-codex.sh" ] && [ -f "$SWITCHER_DIR/active.flag" ]; then
77
- . "$SWITCHER_DIR/env-codex.sh"
78
- fi
79
-
80
- CODEX_SWITCHER_ARGS=()
81
- if [ -n "\${LLM_SWITCHER_CODEX_BASE_URL:-}" ]; then
82
- CODEX_SWITCHER_ARGS+=(--config "openai_base_url=\${LLM_SWITCHER_CODEX_BASE_URL}")
83
- fi
84
- if [ -O "${CODE_X_CATALOG_PATH}" ]; then
85
- CODEX_SWITCHER_ARGS+=(--config "model_catalog_json=${CODE_X_CATALOG_PATH}")
86
- fi
87
- if [ -n "\${LLM_SWITCHER_CODEX_MAIN_MODEL:-}" ]; then
88
- CODEX_SWITCHER_ARGS+=(--config "model=\\"\${LLM_SWITCHER_CODEX_MAIN_MODEL}\\"")
89
- fi
90
- if [ -n "\${LLM_SWITCHER_CODEX_REVIEW_MODEL:-}" ]; then
91
- CODEX_SWITCHER_ARGS+=(--config "review_model=\\"\${LLM_SWITCHER_CODEX_REVIEW_MODEL}\\"")
92
- fi
93
- if [ -n "\${LLM_SWITCHER_CODEX_SUBAGENT_MODEL:-}" ]; then
94
- CODEX_SWITCHER_ARGS+=(--config "agents.default_subagent_model=\\"\${LLM_SWITCHER_CODEX_SUBAGENT_MODEL}\\"")
95
- fi
96
- if [ -n "\${LLM_SWITCHER_CODEX_CONTEXT_WINDOW:-}" ]; then
97
- CODEX_SWITCHER_ARGS+=(--config "model_context_window=\${LLM_SWITCHER_CODEX_CONTEXT_WINDOW}")
98
- fi
99
- if [ -n "\${LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT:-}" ]; then
100
- CODEX_SWITCHER_ARGS+=(--config "model_auto_compact_token_limit=\${LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT}")
101
- fi
102
-
103
- exec "$REAL" "\${CODEX_SWITCHER_ARGS[@]}" "$@"` : `exec "$REAL" "$@"`}
163
+ # No override of any kind: the tool keeps its own configuration and its own model names (F3, R1).
164
+ exec "$REAL" "$@"
104
165
  `;
166
+ };
105
167
 
106
- const WINDOWS_TEMPLATE = (name) => `@echo off
168
+ const WINDOWS_TEMPLATE = (name) => {
169
+ const envFile = envFileOf(name).replace(/\.sh$/, '.cmd');
170
+ const caBlock = name === 'codex' ? '' : `
171
+ REM R2: one bundle holds the user's own CA and the switcher CA, so trusting the interceptor
172
+ REM never costs a certificate the user already had. Decided here, at launch, because only now
173
+ REM is it known whether the user brought one - and never while this tool is off.
174
+ if not "%TOOL_ACTIVE%"=="1" goto :CA_DONE
175
+ if defined INHERITED_CA goto :CA_BUNDLE
176
+ if exist "%SWITCHER_DIR%\\blindfold\\certs\\ca.pem" set "NODE_EXTRA_CA_CERTS=%SWITCHER_DIR%\\blindfold\\certs\\ca.pem"
177
+ if not exist "%SWITCHER_DIR%\\blindfold\\certs\\ca.pem" echo [llm-switcher] %SWITCHER_DIR%\\blindfold\\certs\\ca.pem not found; NODE_EXTRA_CA_CERTS kept as inherited. 1>&2
178
+ goto :CA_DONE
179
+ :CA_BUNDLE
180
+ set "CA_BUNDLE="
181
+ for /f "delims=" %%B in ('node "%STATE_HELPER%" "%INHERITED_CA%" "%SWITCHER_DIR%\\blindfold\\certs\\ca.pem" "%SWITCHER_DIR%" 2^>nul') do set "CA_BUNDLE=%%B"
182
+ if not defined CA_BUNDLE goto :CA_DONE
183
+ if not exist "%CA_BUNDLE%" goto :CA_DONE
184
+ set "NODE_EXTRA_CA_CERTS=%CA_BUNDLE%"
185
+ :CA_DONE`;
186
+ return `@echo off
107
187
  REM Auto-generated by LLM Switcher - DO NOT EDIT.
188
+ setlocal
108
189
  set "SWITCHER_DIR=${STATE_DIR}"
109
- if exist "%SWITCHER_DIR%\\active.flag" if exist "%SWITCHER_DIR%\\env.cmd" call "%SWITCHER_DIR%\\env.cmd"
110
- ${name === 'codex' ? `REM Codex-only variables (blindfold proxy + CA). The claude shim must not load these.
111
- if exist "%SWITCHER_DIR%\\active.flag" if exist "%SWITCHER_DIR%\\env-codex.cmd" call "%SWITCHER_DIR%\\env-codex.cmd"
112
- set "CODEX_SWITCHER_ARGS="
113
- if defined LLM_SWITCHER_CODEX_BASE_URL set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config openai_base_url=%LLM_SWITCHER_CODEX_BASE_URL%"
114
- if exist "%SWITCHER_DIR%\\model-catalog.json" set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config model_catalog_json=${CODE_X_CATALOG_PATH}"
115
- if defined LLM_SWITCHER_CODEX_MAIN_MODEL set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config model=%LLM_SWITCHER_CODEX_MAIN_MODEL%"
116
- if defined LLM_SWITCHER_CODEX_REVIEW_MODEL set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config review_model=%LLM_SWITCHER_CODEX_REVIEW_MODEL%"
117
- if defined LLM_SWITCHER_CODEX_SUBAGENT_MODEL set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config agents.default_subagent_model=%LLM_SWITCHER_CODEX_SUBAGENT_MODEL%"
118
- if defined LLM_SWITCHER_CODEX_CONTEXT_WINDOW set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config model_context_window=%LLM_SWITCHER_CODEX_CONTEXT_WINDOW%"
119
- if defined LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config model_auto_compact_token_limit=%LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT%"` : ''}
190
+ ${name === 'codex' ? '' : `set "STATE_HELPER=${STATE_HELPER}"
191
+
192
+ REM Captured BEFORE the tool's env file runs: the CA decision must see the value this shell
193
+ REM already had, not one this run is about to write.
194
+ set "INHERITED_CA=%NODE_EXTRA_CA_CERTS%"
195
+ `}
196
+
197
+ REM Only this tool's own file, only while the gateway is on, and only a non-empty one: an empty
198
+ REM file means this tool is off. The shared env.cmd is never called (F5).
199
+ set "TOOL_ACTIVE=0"
200
+ if exist "%SWITCHER_DIR%\\active.flag" if exist "%SWITCHER_DIR%\\${envFile}" (
201
+ for %%A in ("%SWITCHER_DIR%\\${envFile}") do if %%~zA GTR 0 set "TOOL_ACTIVE=1"
202
+ )
203
+ if "%TOOL_ACTIVE%"=="1" call "%SWITCHER_DIR%\\${envFile}"
204
+
205
+ REM R8: a variable is removed ONLY when its value is one an older switcher wrote. The user's own
206
+ REM proxy, base URL, model or LLM_SWITCHER_* setting is theirs and is kept.
207
+ set "_GWPORT="
208
+ if exist "%SWITCHER_DIR%\\gateway.port" for /f "usebackq delims=" %%P in ("%SWITCHER_DIR%\\gateway.port") do set "_GWPORT=%%P"
209
+ if not defined _GWPORT set "_GWPORT=3456"
210
+ if defined ANTHROPIC_BASE_URL call :SCRUB_URL ANTHROPIC_BASE_URL "%ANTHROPIC_BASE_URL%"
211
+ if defined OPENAI_BASE_URL call :SCRUB_URL OPENAI_BASE_URL "%OPENAI_BASE_URL%"
212
+ if /i "%ANTHROPIC_MODEL%"=="opus[1m]" set "ANTHROPIC_MODEL="
213
+ if /i "%ANTHROPIC_MODEL%"=="sonnet[1m]" set "ANTHROPIC_MODEL="
214
+ if /i "%ANTHROPIC_MODEL%"=="haiku[1m]" set "ANTHROPIC_MODEL="
215
+ if /i "%ANTHROPIC_MODEL%"=="fable[1m]" set "ANTHROPIC_MODEL="
216
+ if defined ANTHROPIC_DEFAULT_OPUS_MODEL call :SCRUB_TIER ANTHROPIC_DEFAULT_OPUS_MODEL "%ANTHROPIC_DEFAULT_OPUS_MODEL%" opus
217
+ if defined ANTHROPIC_DEFAULT_SONNET_MODEL call :SCRUB_TIER ANTHROPIC_DEFAULT_SONNET_MODEL "%ANTHROPIC_DEFAULT_SONNET_MODEL%" sonnet
218
+ if defined ANTHROPIC_DEFAULT_HAIKU_MODEL call :SCRUB_TIER ANTHROPIC_DEFAULT_HAIKU_MODEL "%ANTHROPIC_DEFAULT_HAIKU_MODEL%" haiku
219
+ if defined ANTHROPIC_DEFAULT_FABLE_MODEL call :SCRUB_TIER ANTHROPIC_DEFAULT_FABLE_MODEL "%ANTHROPIC_DEFAULT_FABLE_MODEL%" fable
220
+ if "%CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT%"=="1" set "CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT="
221
+ if "%CLAUDE_CODE_AUTO_COMPACT_WINDOW%"=="900000" set "CLAUDE_CODE_AUTO_COMPACT_WINDOW="
222
+ if "%OPENAI_MAX_CONTEXT_TOKENS%"=="1000000" set "OPENAI_MAX_CONTEXT_TOKENS="
223
+ for /f "tokens=1 delims==" %%V in ('set LLM_SWITCHER_CODEX_ 2^>nul ^| findstr /b /c:"LLM_SWITCHER_CODEX_"') do set "%%V="
224
+ ${caBlock}
225
+
226
+ REM Find the real binary: drop this shim's own directory so it never calls itself.
120
227
  for /f "delims=" %%i in ('where ${name}.cmd 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do (
121
- call "%%i" ${name === 'codex' ? '%CODEX_SWITCHER_ARGS% ' : ''}%*
228
+ call "%%i" %*
122
229
  exit /b
123
230
  )
124
231
  for /f "delims=" %%i in ('where ${name}.exe 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do (
125
- call "%%i" ${name === 'codex' ? '%CODEX_SWITCHER_ARGS% ' : ''}%*
232
+ call "%%i" %*
126
233
  exit /b
127
234
  )
128
235
  echo [llm-switcher] cannot find the real '${name}' on PATH.>&2
129
236
  exit /b 127
237
+
238
+ REM ---- R8 subroutines ----------------------------------------------------
239
+
240
+ REM %1 = variable name, %2 = its value. Unsets %1 only for a loopback gateway URL on a port this
241
+ REM switcher has used: the recorded one, the default 3456, or LLM_SWITCHER_PORT.
242
+ :SCRUB_URL
243
+ set "_T=%~2"
244
+ if /i "%_T:~0,7%"=="http://" set "_T=%_T:~7%"
245
+ set "_R="
246
+ if /i "%_T:~0,10%"=="127.0.0.1:" set "_R=%_T:~10%"
247
+ if /i "%_T:~0,10%"=="localhost:" set "_R=%_T:~10%"
248
+ if /i "%_T:~0,6%"=="[::1]:" set "_R=%_T:~6%"
249
+ if not defined _R goto :eof
250
+ if /i "%_R:~-4%"=="/v1/" set "_R=%_R:~0,-4%"
251
+ if /i "%_R:~-3%"=="/v1" set "_R=%_R:~0,-3%"
252
+ if "%_R:~-1%"=="/" set "_R=%_R:~0,-1%"
253
+ if not defined _R goto :eof
254
+ if not "%_R%"=="" for /f "delims=0123456789" %%X in ("%_R%") do goto :eof
255
+ if "%_R%"=="%_GWPORT%" goto :DROP_URL
256
+ if "%_R%"=="3456" goto :DROP_URL
257
+ if defined LLM_SWITCHER_PORT if "%_R%"=="%LLM_SWITCHER_PORT%" goto :DROP_URL
258
+ goto :eof
259
+ :DROP_URL
260
+ set "%~1="
261
+ goto :eof
262
+
263
+ REM %1 = variable name, %2 = its value, %3 = the lowercase tier it must still match. A value of
264
+ REM another tier (ANTHROPIC_DEFAULT_OPUS_MODEL=sonnet[1m]) is not the switcher's and is kept.
265
+ :SCRUB_TIER
266
+ if /i "%~2"=="%~3[1m]" set "%~1="
267
+ goto :eof
130
268
  `;
269
+ };
131
270
 
132
271
  export function renderShim(name, platform = process.platform) {
133
- return platform === 'win32' ? WINDOWS_TEMPLATE(name) : POSIX_TEMPLATE(name);
272
+ if (platform !== 'win32') return POSIX_TEMPLATE(name);
273
+ // cmd.exe finds a subroutine by scanning line starts, and on a file written with bare LF
274
+ // it drifts: a label near the end is reported as "cannot find the batch label". CRLF is
275
+ // what every .cmd on disk already is, and it is what cmd.exe expects.
276
+ return WINDOWS_TEMPLATE(name).replace(/\r?\n/g, '\r\n');
134
277
  }
135
278
 
136
279
  function shimPath(name) {