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.
Files changed (51) hide show
  1. package/.gitattributes +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +587 -0
  4. package/README.vi.md +585 -0
  5. package/blindfold/blindfold.mjs +633 -0
  6. package/blindfold/make-certs.sh +88 -0
  7. package/blindfold/wsframe.mjs +176 -0
  8. package/codex-catalog-template.json +1 -0
  9. package/config.example.json +84 -0
  10. package/contract-exclusions.json +41 -0
  11. package/contract.mjs +561 -0
  12. package/docs/LLM-RESPONSE-MATRIX.md +165 -0
  13. package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -0
  14. package/docs/codex-blindfold.md +214 -0
  15. package/docs/cross-platform.md +136 -0
  16. package/docs/diagrams/blindfold-request-routing.html +14972 -0
  17. package/docs/diagrams/blindfold-request-routing.sequence.json +175 -0
  18. package/docs/diagrams/blindfold-switch-lifecycle.html +14958 -0
  19. package/docs/diagrams/blindfold-switch-lifecycle.lifecycle.json +159 -0
  20. package/docs/diagrams/codex-model-name-resolution.html +15005 -0
  21. package/docs/diagrams/codex-model-name-resolution.workflow.json +71 -0
  22. package/docs/response-matrix.json +1131 -0
  23. package/formats.mjs +2308 -0
  24. package/mcp.mjs +340 -0
  25. package/package.json +36 -0
  26. package/proxy.mjs +1743 -0
  27. package/service.mjs +132 -0
  28. package/shim.mjs +292 -0
  29. package/skills/llm-switcher/SKILL.md +88 -0
  30. package/state.mjs +978 -0
  31. package/switch +5 -0
  32. package/switch.cmd +2 -0
  33. package/switch.mjs +930 -0
  34. package/tests/blindfold.test.mjs +307 -0
  35. package/tests/blindfold.wire.test.mjs +170 -0
  36. package/tests/contract/run.test.mjs +214 -0
  37. package/tests/contract-check.test.mjs +458 -0
  38. package/tests/contract-lab.test.mjs +755 -0
  39. package/tests/datadir.test.mjs +37 -0
  40. package/tests/formats.test.mjs +794 -0
  41. package/tests/gateway.e2e.test.mjs +999 -0
  42. package/tests/helpers.mjs +24 -0
  43. package/tests/lifecycle.test.mjs +416 -0
  44. package/tests/live-optimizer-interop.mjs +205 -0
  45. package/tests/mcp.test.mjs +91 -0
  46. package/tests/service.test.mjs +69 -0
  47. package/tests/shim.test.mjs +228 -0
  48. package/tests/state.test.mjs +675 -0
  49. package/tests/switch.test.mjs +156 -0
  50. package/tests/wsframe.test.mjs +154 -0
  51. package/ui.html +2234 -0
package/mcp.mjs ADDED
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================
3
+ // mcp.mjs — Model Context Protocol (MCP) Server for LLM Switcher
4
+ // Zero-dependency, pure Node.js stdio JSON-RPC 2.0 server.
5
+ //
6
+ // Tools exposed to AI Coding Agents (Claude Code, Cursor, Codex, Opencode):
7
+ // 1. switcher_status : Read active profiles & multi-CLI status
8
+ // 2. switcher_audit : Audit environment to ensure tools route through Switcher
9
+ // 3. switcher_switch_profile : Programmatically switch active profile per CLI
10
+ // 4. switcher_recent_logs : Inspect recent request logs & thinking traces
11
+ // ============================================================
12
+
13
+ import fs from 'node:fs';
14
+ import { claudeSettingsPath, loadConfig as loadSharedConfig, resolvePort, readAdminToken, TARGETS } from './state.mjs';
15
+
16
+ const VERSION = (() => {
17
+ try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version; } catch { return '0.0.0'; }
18
+ })();
19
+
20
+ // The audit text goes into the calling agent's context, and a proxy URL often carries a password.
21
+ function withoutCredentials(value) {
22
+ try {
23
+ const u = new URL(value);
24
+ if (!u.username && !u.password) return value;
25
+ u.username = '';
26
+ u.password = '';
27
+ return u.toString().replace(/\/$/, value.endsWith('/') ? '/' : '');
28
+ } catch {
29
+ return value;
30
+ }
31
+ }
32
+
33
+ // A substring test would accept http://localhost.evil.test; the host itself must be loopback.
34
+ function isLoopbackURL(value) {
35
+ try {
36
+ return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(value).hostname);
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ // /api/* requires the per-install token that the gateway writes next to config.json.
43
+ const adminHeaders = (extra = {}) => ({ 'x-llm-switcher-token': readAdminToken() || '', ...extra });
44
+
45
+ function loadConfig() {
46
+ return loadSharedConfig() || { port: 3456, activeProfile: '', profiles: {} };
47
+ }
48
+
49
+ function getMcpPort() {
50
+ return resolvePort(process.argv.slice(2), loadSharedConfig());
51
+ }
52
+
53
+ async function fetchStatus(port) {
54
+ try {
55
+ const r = await fetch(`http://127.0.0.1:${port}/api/status`, { headers: adminHeaders(), signal: AbortSignal.timeout(1500) });
56
+ if (r.ok) return await r.json();
57
+ } catch {}
58
+ return null;
59
+ }
60
+
61
+ /** The logs, or null when the gateway does not answer. */
62
+ async function fetchLogs(port) {
63
+ try {
64
+ const r = await fetch(`http://127.0.0.1:${port}/api/logs`, { headers: adminHeaders(), signal: AbortSignal.timeout(1500) });
65
+ if (r.ok) return (await r.json()).logs || [];
66
+ } catch {}
67
+ return null;
68
+ }
69
+
70
+ async function postSwitch(port, target, profile) {
71
+ const r = await fetch(`http://127.0.0.1:${port}/api/switch`, {
72
+ method: 'POST',
73
+ headers: adminHeaders({ 'Content-Type': 'application/json' }),
74
+ body: JSON.stringify({ target, profile: profile || null }),
75
+ signal: AbortSignal.timeout(5000)
76
+ });
77
+ return await r.json();
78
+ }
79
+
80
+ const TOOLS = [
81
+ {
82
+ name: 'switcher_status',
83
+ description: 'Get live status of LLM Switcher gateway: port, active multi-CLI targets (Claude Code, Codex, OpenAI, Vertex), and 1M context flags.',
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {}
87
+ }
88
+ },
89
+ {
90
+ name: 'switcher_audit',
91
+ description: 'Audit workstation environment to ensure token compression tools (Headroom, RTK, Ponytail) and CLI endpoints route through LLM Switcher (:3456) instead of making rogue direct outbound calls.',
92
+ inputSchema: {
93
+ type: 'object',
94
+ properties: {
95
+ verbose: { type: 'boolean', description: 'Include detailed environment variable checks' }
96
+ }
97
+ }
98
+ },
99
+ {
100
+ name: 'switcher_switch_profile',
101
+ description: 'Programmatically change the active profile for a specific CLI target (e.g. Claude Code, Codex, OpenAI, Vertex) or globally.',
102
+ inputSchema: {
103
+ type: 'object',
104
+ properties: {
105
+ target: {
106
+ type: 'string',
107
+ description: 'CLI target to switch: "anthropic" (Claude Code), "responses" (Codex), "openai-chat", or "vertex"',
108
+ enum: TARGETS
109
+ },
110
+ profile: {
111
+ type: 'string',
112
+ description: 'Profile key to activate (from config.json), or empty/null to deactivate this target'
113
+ }
114
+ },
115
+ required: ['profile']
116
+ }
117
+ },
118
+ {
119
+ name: 'switcher_recent_logs',
120
+ description: 'Inspect the last N requests handled by LLM Switcher: check latency, prompt preview, token counts, and whether thinking blocks were successfully extracted.',
121
+ inputSchema: {
122
+ type: 'object',
123
+ properties: {
124
+ limit: { type: 'number', description: 'Maximum number of recent logs to return (default: 5)' }
125
+ }
126
+ }
127
+ }
128
+ ];
129
+
130
+ async function handleToolCall(name, args) {
131
+ const cfg = loadConfig();
132
+ const port = getMcpPort();
133
+
134
+ if (name === 'switcher_status') {
135
+ const live = await fetchStatus(port);
136
+ const activeMap = live?.activeProfiles || cfg.activeProfiles || {};
137
+ const text = [
138
+ '=== LLM Switcher Gateway Status ===',
139
+ `Service Running: ${live ? `YES (http://127.0.0.1:${port})` : 'NO / UNREACHABLE'}`,
140
+ `Active Profiles by CLI:`,
141
+ ` - Claude Code (/v1/messages) : [${activeMap.anthropic || 'OFF'}] ${live?.claude1MTiers?.length ? `• 1M Context ACTIVE (${live.claude1MTiers.join(', ')})` : ''}`,
142
+ ` - Codex CLI (/v1/responses) : [${activeMap.responses || 'OFF'}] ${live?.isCodex1MActive ? '• 1M Context ACTIVE' : ''}`,
143
+ ` - OpenAI Chat (/v1/chat/completions): [${activeMap['openai-chat'] || 'OFF'}]`,
144
+ ` - Vertex (/v1beta/models/*) : [${activeMap.vertex || 'OFF'}]`,
145
+ '',
146
+ `Available Profiles in config: ${Object.keys(cfg.profiles || {}).join(', ')}`,
147
+ `Dashboard Web UI: http://127.0.0.1:${port}/ui`
148
+ ].join('\n');
149
+ return { content: [{ type: 'text', text }] };
150
+ }
151
+
152
+ if (name === 'switcher_audit') {
153
+ const live = await fetchStatus(port);
154
+ const findings = [];
155
+ let isClean = true;
156
+
157
+ // 1. Check the proxy port
158
+ if (!live) {
159
+ findings.push(`[CRITICAL] LLM Switcher service is NOT running on port ${port}. Run 'node switch.mjs on' to start it.`);
160
+ isClean = false;
161
+ } else {
162
+ findings.push(`[PASS] LLM Switcher edge gateway is running on http://127.0.0.1:${port}.`);
163
+ }
164
+
165
+ // 2. Check whether Claude Code's settings.json was dirtied by another tool
166
+ if (fs.existsSync(claudeSettingsPath)) {
167
+ try {
168
+ const s = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf8'));
169
+ if (s.env?.ANTHROPIC_BASE_URL) {
170
+ findings.push(`[WARNING] ~/.claude/settings.json has hardcoded ANTHROPIC_BASE_URL="${s.env.ANTHROPIC_BASE_URL}". This can trigger warning banners in Claude Code. The switcher removes this value only when it points at its own port; otherwise edit settings.json yourself if you want the launcher flags to apply.`);
171
+ isClean = false;
172
+ } else {
173
+ findings.push(`[PASS] ~/.claude/settings.json is clean (zero-mutation compliant).`);
174
+ }
175
+ } catch (err) {
176
+ findings.push(`[WARNING] ${claudeSettingsPath} does not parse: ${err.message}. Claude Code may ignore it.`);
177
+ isClean = false;
178
+ }
179
+ }
180
+
181
+ // 3. The base URL variables this agent process runs with. Codex takes its URL as a --config
182
+ // override from the shim, so it has no variable to check here.
183
+ for (const name of ['ANTHROPIC_BASE_URL', 'OPENAI_BASE_URL']) {
184
+ const raw = process.env[name];
185
+ if (!raw) continue;
186
+ const value = withoutCredentials(raw);
187
+ if (isLoopbackURL(raw)) {
188
+ findings.push(`[PASS] ${name} points to a local endpoint: ${value}`);
189
+ } else {
190
+ findings.push(`[ALERT] ${name}="${value}" points to an external endpoint! It should point to LLM Switcher (http://127.0.0.1:${port}) or your local optimizer proxy.`);
191
+ isClean = false;
192
+ }
193
+ }
194
+ if (args?.verbose) {
195
+ findings.push('');
196
+ findings.push('--- Routing variables of this process ---');
197
+ const names = Object.keys(process.env).filter(k => /^(ANTHROPIC_BASE_URL|OPENAI_BASE_URL|HTTPS?_PROXY|https?_proxy|NO_PROXY|no_proxy|CODEX_CA_CERTIFICATE|LLM_SWITCHER_[A-Z0-9_]+)$/.test(k)).sort();
198
+ for (const k of names) findings.push(`${k}=${withoutCredentials(process.env[k])}`);
199
+ if (!names.length) findings.push('(none set)');
200
+ }
201
+
202
+ // 4. Layering guidance for compression tools
203
+ findings.push('');
204
+ findings.push('--- Guideline for Token Optimizers (Headroom, RTK, Ponytail) ---');
205
+ findings.push(`If a token compressor is used, ensure its upstream target is configured to http://127.0.0.1:${port}.`);
206
+ findings.push('LLM Switcher will act as the final outbound gatekeeper to heal schemas, unlock 1M context, and preserve thinking traces.');
207
+
208
+ const summary = isClean ? 'VERDICT: HEALTHY & PROPERLY ROUTED' : 'VERDICT: ACTION NEEDED';
209
+ return { content: [{ type: 'text', text: `=== LLM Switcher Audit (${summary}) ===\n\n` + findings.join('\n') }] };
210
+ }
211
+
212
+ if (name === 'switcher_switch_profile') {
213
+ const { target, profile } = args || {};
214
+ // No target and no profile means deactivating the WHOLE gateway; require the agent to state its intent explicitly.
215
+ if (!target && !profile) {
216
+ return { content: [{ type: 'text', text: 'Refusing to deactivate all targets implicitly: pass a "target" to turn off one CLI, or a "profile" to activate.' }], isError: true };
217
+ }
218
+ try {
219
+ const res = await postSwitch(port, target || null, profile || null);
220
+ if (res.success) {
221
+ return { content: [{ type: 'text', text: `Successfully set ${target || 'global'} active profile to "${profile || 'OFF'}".` }] };
222
+ }
223
+ return { content: [{ type: 'text', text: `Switch failed: ${res.error || 'Unknown error'}` }], isError: true };
224
+ } catch (err) {
225
+ return { content: [{ type: 'text', text: `Gateway connection failed: ${err.message}` }], isError: true };
226
+ }
227
+ }
228
+
229
+ if (name === 'switcher_recent_logs') {
230
+ const limit = Math.min(20, Math.max(1, Number(args?.limit) || 5));
231
+ const logs = await fetchLogs(port);
232
+ if (!logs) {
233
+ return { content: [{ type: 'text', text: `The LLM Switcher gateway is unreachable on port ${port}, so no logs can be read. Run 'switch on' to start it.` }], isError: true };
234
+ }
235
+ const slice = logs.slice(0, limit);
236
+
237
+ if (!slice.length) {
238
+ return { content: [{ type: 'text', text: 'No requests recorded yet in LLM Switcher ring buffer.' }] };
239
+ }
240
+
241
+ const rendered = slice.map((l, i) => {
242
+ return [
243
+ `#${i + 1} [${l.timestamp}] ${l.status} (${l.duration}ms) — ${l.clientFormat} ➔ ${l.outFormat}`,
244
+ ` Model: ${l.model}`,
245
+ ` Tokens: prompt=${l.tokens?.prompt || 0} | output=${l.tokens?.completion || 0} | thinking=${l.thinkingChars || 0} chars`,
246
+ ` Prompt: ${l.requestPreview ? l.requestPreview.slice(0, 150) : '(empty)'}`,
247
+ ` Output: ${l.responsePreview ? l.responsePreview.slice(0, 150) : (l.error ? 'ERROR: ' + l.error : '(streaming/direct)')}`
248
+ ].join('\n');
249
+ }).join('\n\n');
250
+
251
+ return { content: [{ type: 'text', text: `=== Last ${slice.length} Requests Handled by Gateway ===\n\n` + rendered }] };
252
+ }
253
+
254
+ throw new Error(`Unknown tool: ${name}`);
255
+ }
256
+
257
+ const SUPPORTED_PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'];
258
+
259
+ // JSON-RPC 2.0 stdio framing
260
+ function send(obj) {
261
+ const json = JSON.stringify(obj);
262
+ process.stdout.write(json + '\n');
263
+ }
264
+
265
+ let buffer = '';
266
+ process.stdin.setEncoding('utf8');
267
+ process.stdin.on('data', async (chunk) => {
268
+ buffer += chunk;
269
+ const lines = buffer.split('\n');
270
+ buffer = lines.pop();
271
+
272
+ for (const line of lines) {
273
+ const trimmed = line.trim();
274
+ if (!trimmed) continue;
275
+ // Skip the Content-Length header if the host sends LSP-style framing
276
+ if (trimmed.startsWith('Content-Length:')) continue;
277
+ let req;
278
+ try {
279
+ req = JSON.parse(trimmed);
280
+ } catch {
281
+ continue;
282
+ }
283
+
284
+ const { id, method, params } = req;
285
+
286
+ if (method === 'initialize') {
287
+ send({
288
+ jsonrpc: '2.0',
289
+ id,
290
+ result: {
291
+ protocolVersion: SUPPORTED_PROTOCOLS.includes(params?.protocolVersion) ? params.protocolVersion : SUPPORTED_PROTOCOLS[0],
292
+ capabilities: { tools: {} },
293
+ serverInfo: { name: 'llm-switcher', version: VERSION }
294
+ }
295
+ });
296
+ continue;
297
+ }
298
+
299
+ if (method === 'ping') {
300
+ send({ jsonrpc: '2.0', id, result: {} });
301
+ continue;
302
+ }
303
+
304
+ if (typeof method === 'string' && method.startsWith('notifications/')) {
305
+ continue;
306
+ }
307
+
308
+ if (method === 'tools/list') {
309
+ send({
310
+ jsonrpc: '2.0',
311
+ id,
312
+ result: { tools: TOOLS }
313
+ });
314
+ continue;
315
+ }
316
+
317
+ if (method === 'tools/call') {
318
+ try {
319
+ const result = await handleToolCall(params?.name, params?.arguments);
320
+ send({ jsonrpc: '2.0', id, result });
321
+ } catch (err) {
322
+ send({
323
+ jsonrpc: '2.0',
324
+ id,
325
+ result: { content: [{ type: 'text', text: err.message }], isError: true }
326
+ });
327
+ }
328
+ continue;
329
+ }
330
+
331
+ // Unhandled method
332
+ if (id !== undefined) {
333
+ send({
334
+ jsonrpc: '2.0',
335
+ id,
336
+ error: { code: -32601, message: `Method not found: ${method}` }
337
+ });
338
+ }
339
+ }
340
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "llm-switcher",
3
+ "version": "1.1.0",
4
+ "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code, Codex, OpenAI and Gemini clients",
5
+ "keywords": [
6
+ "llm",
7
+ "gateway",
8
+ "proxy",
9
+ "claude-code",
10
+ "codex",
11
+ "openai",
12
+ "gemini",
13
+ "anthropic"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/louisphamdev/llm-switcher.git"
18
+ },
19
+ "homepage": "https://github.com/louisphamdev/llm-switcher#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/louisphamdev/llm-switcher/issues"
22
+ },
23
+ "type": "module",
24
+ "license": "MIT",
25
+ "bin": {
26
+ "switch": "switch.mjs"
27
+ },
28
+ "engines": {
29
+ "node": ">=18.17"
30
+ },
31
+ "scripts": {
32
+ "start": "node proxy.mjs",
33
+ "test": "node --test",
34
+ "test:live": "node tests/live-optimizer-interop.mjs"
35
+ }
36
+ }