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.
- package/CHANGELOG.md +63 -0
- package/README.md +202 -257
- package/README.vi.md +200 -256
- package/blindfold/blindfold.mjs +200 -53
- package/blindfold/make-certs.sh +26 -7
- package/catalog.mjs +246 -0
- package/classifier.mjs +238 -0
- package/config.example.json +12 -34
- package/docs/codex-blindfold.md +28 -17
- package/docs/cross-platform.md +16 -7
- package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
- package/docs/diagrams/ir-healer-pipeline.png +0 -0
- package/docs/diagrams/ir-healer-pipeline.svg +90 -0
- package/docs/diagrams/ir-translation-pipeline.html +14925 -0
- package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
- package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
- package/docs/diagrams/system-architecture.architecture.json +76 -0
- package/docs/diagrams/system-architecture.html +14978 -0
- package/docs/diagrams/system-architecture.svg +5147 -0
- package/docs/diagrams/system-topology.mmd +30 -0
- package/docs/diagrams/system-topology.png +0 -0
- package/docs/diagrams/system-topology.svg +125 -0
- package/ensure-ca-bundle.mjs +28 -0
- package/formats.mjs +43 -156
- package/icons/antigravity.png +0 -0
- package/icons/claude.png +0 -0
- package/icons/codex.png +0 -0
- package/icons/deepseek.png +0 -0
- package/icons/gemini.png +0 -0
- package/icons/github.png +0 -0
- package/icons/groq.png +0 -0
- package/icons/intact.svg +1 -0
- package/icons/ollama.png +0 -0
- package/icons/openai.png +0 -0
- package/icons/openrouter.png +0 -0
- package/icons/qwen.png +0 -0
- package/icons/vertex.png +0 -0
- package/mcp.mjs +39 -11
- package/package.json +1 -1
- package/proxy.mjs +114 -37
- package/shim.mjs +200 -57
- package/skills/llm-switcher/SKILL.md +15 -10
- package/state.mjs +1100 -191
- package/switch.cmd +2 -2
- package/switch.mjs +228 -53
- package/tests/blindfold-e2e.test.mjs +380 -0
- package/tests/blindfold-task5.test.mjs +429 -0
- package/tests/blindfold.task3.test.mjs +700 -0
- package/tests/blindfold.test.mjs +10 -5
- package/tests/catalog.test.mjs +147 -0
- package/tests/classifier.test.mjs +210 -0
- package/tests/contract-lab.test.mjs +22 -7
- package/tests/formats.test.mjs +63 -46
- package/tests/gateway.e2e.test.mjs +136 -36
- package/tests/lifecycle.test.mjs +16 -10
- package/tests/mcp.test.mjs +78 -2
- package/tests/real-user-sim.test.mjs +464 -0
- package/tests/shim.test.mjs +159 -66
- package/tests/state.test.mjs +975 -193
- package/tests/switch.test.mjs +446 -2
- package/ui.html +1710 -1726
package/package.json
CHANGED
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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', '
|
|
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.
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
46
|
-
#
|
|
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
|
-
#
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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) =>
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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) {
|