llm-switcher 1.1.11 → 1.2.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/CHANGELOG.md +51 -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/config.example.json +12 -34
  8. package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
  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/docs/response-matrix.json +1130 -1130
  24. package/ensure-ca-bundle.mjs +28 -0
  25. package/formats.mjs +13 -155
  26. package/mcp.mjs +39 -11
  27. package/package.json +1 -1
  28. package/proxy.mjs +92 -27
  29. package/shim.mjs +200 -57
  30. package/skills/llm-switcher/SKILL.md +93 -88
  31. package/state.mjs +1100 -191
  32. package/switch +0 -0
  33. package/switch.cmd +2 -2
  34. package/switch.mjs +228 -53
  35. package/tests/blindfold-e2e.test.mjs +380 -0
  36. package/tests/blindfold-task5.test.mjs +429 -0
  37. package/tests/blindfold.task3.test.mjs +700 -0
  38. package/tests/blindfold.test.mjs +10 -5
  39. package/tests/catalog.test.mjs +147 -0
  40. package/tests/contract-lab.test.mjs +22 -7
  41. package/tests/formats.test.mjs +33 -46
  42. package/tests/gateway.e2e.test.mjs +136 -36
  43. package/tests/helpers.mjs +24 -24
  44. package/tests/lifecycle.test.mjs +16 -10
  45. package/tests/live-optimizer-interop.mjs +205 -205
  46. package/tests/mcp.test.mjs +78 -2
  47. package/tests/real-user-sim.test.mjs +464 -0
  48. package/tests/shim.test.mjs +159 -66
  49. package/tests/state.test.mjs +975 -193
  50. package/tests/switch.test.mjs +446 -2
  51. package/ui.html +61 -154
@@ -0,0 +1,28 @@
1
+ // ============================================================
2
+ // ensure-ca-bundle.mjs — the shim's one call into Node (R2, R4)
3
+ //
4
+ // The shim must point NODE_EXTRA_CA_CERTS at a bundle holding BOTH the user's own CA and the
5
+ // switcher's ca.pem, so trusting the interceptor never costs the user a certificate they
6
+ // already had. Building that bundle means reading files, comparing content byte for byte and
7
+ // renaming atomically — none of which belongs in a shell script, and none at all in cmd.exe.
8
+ //
9
+ // It is a FILE rather than `node -e "..."` on purpose: cmd.exe re-parses quotes and reads %2
10
+ // inside a percent-encoded URL, so a space anywhere in the checkout path would corrupt the
11
+ // program or its arguments.
12
+ //
13
+ // argv: <inherited NODE_EXTRA_CA_CERTS> <switcher ca.pem> <state dir>
14
+ // stdout: the bundle path, and nothing else — the shim compares it against -f before exporting.
15
+ // exit 1 on any failure: the reason is already on stderr, and the shim then keeps the inherited
16
+ // value and exports no path to a file that does not exist.
17
+ // ============================================================
18
+
19
+ import { ensureClaudeCaBundle } from './state.mjs';
20
+
21
+ const [, , inheritedCa, switcherCaPath, stateDir] = process.argv;
22
+ const result = ensureClaudeCaBundle(inheritedCa, switcherCaPath, stateDir);
23
+
24
+ if (result && result.ok && result.path) {
25
+ process.stdout.write(result.path);
26
+ process.exit(0);
27
+ }
28
+ process.exit(1);
package/formats.mjs CHANGED
@@ -23,7 +23,6 @@
23
23
  // }
24
24
  // ============================================================
25
25
 
26
- export const IN_FORMATS = ['anthropic', 'openai-chat', 'responses', 'vertex'];
27
26
  export const OUT_FORMATS = ['openai-chat', 'anthropic', 'vertex'];
28
27
 
29
28
  // ---------------- stop reasons ----------------
@@ -272,7 +271,10 @@ function sanitizeSchemaNode(schema) {
272
271
  }
273
272
  clean[k] = sanitizeSchemaNode(v);
274
273
  }
275
- if (!clean.type && clean.properties) {
274
+ // An empty schema carries no shape, and Vertex reads that as a missing `type`.
275
+ // Heal it the way a missing schema is healed — but only when nothing else is here,
276
+ // so an `anyOf`/`$ref` shape is never handed a type it did not ask for.
277
+ if (!clean.type && (clean.properties || Object.keys(clean).length === 0)) {
276
278
  clean.type = 'object';
277
279
  }
278
280
  if (clean.type === 'object' && !clean.properties) {
@@ -455,73 +457,6 @@ function applyAllowedTools(ir, mode, list, nameOf) {
455
457
  ir.toolChoice = ir.tools.length ? (mode === 'required' ? 'required' : 'auto') : null;
456
458
  }
457
459
 
458
- function chatToIR(payload) {
459
- const ir = baseIR();
460
- ir.model = payload.model || '';
461
- ir.stream = payload.stream === true;
462
-
463
- if (Array.isArray(payload.messages)) {
464
- for (const m of payload.messages) {
465
- const role = m.role;
466
- if (role === 'system' || role === 'developer') {
467
- const t = typeof m.content === 'string' ? m.content : asText(m.content);
468
- if (t) ir.system = ir.system ? `${ir.system}\n\n${t}` : t;
469
- continue;
470
- }
471
- if (role === 'tool' || role === 'function') {
472
- ir.messages.push({
473
- role: 'tool', toolCallId: m.tool_call_id || m.id,
474
- name: m.name, content: typeof m.content === 'string' ? m.content : asText(m.content)
475
- });
476
- continue;
477
- }
478
- const out = { role: role === 'assistant' ? 'assistant' : 'user' };
479
- if (typeof m.content === 'string' || Array.isArray(m.content)) out.content = m.content;
480
- else if (m.content != null) out.content = asText(m.content);
481
- if (Array.isArray(m.tool_calls)) {
482
- out.toolCalls = m.tool_calls.map((tc, i) => ({
483
- id: tc.id || null,
484
- name: tc.function?.name || tc.name || '',
485
- args: parseArgs(tc.function?.arguments ?? tc.function?.args ?? tc.args)
486
- })).filter(t => t.name);
487
- }
488
- if (out.content !== undefined || out.toolCalls) ir.messages.push(out);
489
- }
490
- }
491
-
492
- const toolDefs = payload.tools;
493
- if (Array.isArray(toolDefs) && toolDefs.length) {
494
- ir.tools = toolDefs.map(t => {
495
- const fn = t.function || t;
496
- return { name: fn.name, description: fn.description || '', parameters: sanitizeJsonSchema(fn.parameters || fn.input_schema || {}) };
497
- }).filter(t => t.name);
498
- }
499
-
500
- const tc = payload.tool_choice;
501
- if (typeof tc === 'string') ir.toolChoice = tc;
502
- else if (tc?.type === 'function' && tc.function?.name) ir.toolChoice = { name: tc.function.name };
503
- else if (tc?.type === 'allowed_tools') applyAllowedTools(ir, tc.allowed_tools?.mode, tc.allowed_tools?.tools, t => t?.function?.name);
504
-
505
- ir.params.maxTokens = payload.max_tokens ?? payload.max_completion_tokens ?? null;
506
- if (typeof payload.temperature === 'number') ir.params.temperature = payload.temperature;
507
- if (typeof payload.top_p === 'number') ir.params.topP = payload.top_p;
508
- if (typeof payload.presence_penalty === 'number') ir.params.presencePenalty = payload.presence_penalty;
509
- if (typeof payload.frequency_penalty === 'number') ir.params.frequencyPenalty = payload.frequency_penalty;
510
- if (payload.stop !== undefined && payload.stop !== null) ir.params.stop = stopList(payload.stop);
511
- if (payload.parallel_tool_calls === false) ir.params.parallelToolCalls = false;
512
-
513
- if (payload.thinking && typeof payload.thinking === 'object') {
514
- ir.thinking = thinkingFromAnthropicParam(payload.thinking, payload.reasoning_effort);
515
- } else if (typeof payload.reasoning_effort === 'string') {
516
- ir.thinking = isNoReasoningEffort(payload.reasoning_effort)
517
- ? { type: 'disabled' }
518
- : { type: 'enabled', budget: effortToBudget(payload.reasoning_effort), effort: payload.reasoning_effort };
519
- } else if (payload.reasoning && typeof payload.reasoning === 'object') {
520
- ir.thinking = thinkingFromReasoningParam(payload.reasoning);
521
- }
522
- return ir;
523
- }
524
-
525
460
  // OpenAI/OpenRouter `reasoning` object -> IR thinking.
526
461
  function thinkingFromReasoningParam(r) {
527
462
  if (!r || typeof r !== 'object') return null;
@@ -712,95 +647,16 @@ function responsesToIR(payload) {
712
647
  return ir;
713
648
  }
714
649
 
715
- // Vertex generateContent -> IR.
716
- function vertexToIR(payload) {
717
- const ir = baseIR();
718
- ir.model = payload.model || '';
719
- ir.stream = false; // proxy overrides per the :streamGenerateContent endpoint
720
-
721
- const sys = payload.systemInstruction?.parts;
722
- if (Array.isArray(sys)) {
723
- ir.system = sys.map(p => p.text || '').filter(Boolean).join('\n\n');
724
- } else if (typeof payload.system_instruction === 'string') {
725
- ir.system = payload.system_instruction;
726
- }
727
-
728
- // Vertex has no tool call ids: auto-generate ids for functionCalls then match functionResponses by name (FIFO),
729
- // so upstream OpenAI/Anthropic gets the correct tool_call <-> tool result pairs instead of "healing" them into text.
730
- const pendingByName = new Map();
731
- let callSeq = 0;
732
-
733
- if (Array.isArray(payload.contents)) {
734
- for (const c of payload.contents) {
735
- const parts = Array.isArray(c?.parts) ? c.parts : [];
736
- const texts = [];
737
- const toolCalls = [];
738
- for (const p of parts) {
739
- if (!p || typeof p !== 'object') continue;
740
- if (typeof p.text === 'string' && p.text && p.thought !== true) texts.push(p.text);
741
- if (p.functionCall) {
742
- const name = p.functionCall.name || '';
743
- const id = p.functionCall.id || `call_vtx_${callSeq++}_${name}`;
744
- if (!pendingByName.has(name)) pendingByName.set(name, []);
745
- pendingByName.get(name).push(id);
746
- const sig = p.thoughtSignature || p.thought_signature || null;
747
- rememberToolSignature(id, sig);
748
- toolCalls.push({ id, name, args: p.functionCall.args ?? {}, sig });
749
- }
750
- if (p.functionResponse) {
751
- const fr = p.functionResponse;
752
- const name = fr.name || '';
753
- const queue = pendingByName.get(name);
754
- const id = fr.id || (queue && queue.length ? queue.shift() : `call_vtx_orphan_${name}`);
755
- ir.messages.push({
756
- role: 'tool', toolCallId: id, name: name || null,
757
- content: typeof fr.response === 'string' ? fr.response : JSON.stringify(fr.response ?? '')
758
- });
759
- }
760
- }
761
- if (c.role === 'model') {
762
- const out = { role: 'assistant' };
763
- if (texts.length) out.content = texts.join('');
764
- if (toolCalls.length) out.toolCalls = toolCalls;
765
- if (out.content !== undefined || out.toolCalls) ir.messages.push(out);
766
- } else if (c.role === 'function') {
767
- // functionResponse already pushed above
768
- } else {
769
- if (texts.length) ir.messages.push({ role: 'user', content: texts.join('') });
770
- }
771
- }
772
- }
773
-
774
- const fns = payload.tools?.flatMap(t => t.functionDeclarations || t.function_declarations || []);
775
- if (Array.isArray(fns) && fns.length) {
776
- ir.tools = fns.filter(f => f.name).map(f => ({
777
- name: f.name, description: f.description || '',
778
- parameters: sanitizeJsonSchema(f.parameters || {})
779
- }));
780
- }
781
-
782
- const gc = payload.generationConfig || payload.generation_config || {};
783
- if (typeof gc.maxOutputTokens === 'number') ir.params.maxTokens = gc.maxOutputTokens;
784
- if (typeof gc.temperature === 'number') ir.params.temperature = gc.temperature;
785
- if (typeof gc.topP === 'number') ir.params.topP = gc.topP;
786
- if (Array.isArray(gc.stopSequences)) ir.params.stop = stopList(gc.stopSequences);
787
- const th = gc.thinkingConfig || gc.thinking_config;
788
- if (th && typeof th === 'object') {
789
- if (th.thinkingBudget === 0) ir.thinking = { type: 'disabled' };
790
- else if (th.thinkingBudget > 0) ir.thinking = { type: 'enabled', budget: clampBudget(th.thinkingBudget) };
791
- else if (th.thinkingLevel) ir.thinking = { type: 'enabled', budget: effortToBudget(th.thinkingLevel), effort: th.thinkingLevel };
792
- else if (th.includeThoughts) ir.thinking = { type: 'enabled', budget: 2048 };
793
- }
794
- return ir;
795
- }
796
-
650
+ // R5: two inputs, one per tool — `anthropic` for Claude Code, `responses` for Codex. The
651
+ // openai-chat and vertex routes were removed with their parsers; a format that reaches here
652
+ // anyway is an error, never a silent fallback to another protocol (a different API key!).
653
+ // The UPSTREAM formats are a different axis and are untouched: emitUpstreamBody still speaks
654
+ // openai-chat, anthropic and vertex to whatever provider the profile points at.
797
655
  function parseToIR(clientFormat, payload) {
798
656
  switch (clientFormat) {
799
657
  case 'anthropic': return anthropicToIR(payload);
800
- case 'openai-chat': return chatToIR(payload);
801
658
  case 'responses': return responsesToIR(payload);
802
- case 'vertex': return vertexToIR(payload);
803
- default: throw new Error(`Unknown client format: ${clientFormat}`);
659
+ default: throw new Error(`Unsupported input format: ${clientFormat}. Only anthropic (Claude Code) and responses (Codex) accept requests.`);
804
660
  }
805
661
  }
806
662
 
@@ -1255,6 +1111,8 @@ function toGeminiSchema(schema, root, seen) {
1255
1111
  return schema === 'object' ? { type: 'object', properties: {} } : { type: schema };
1256
1112
  }
1257
1113
  if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema;
1114
+ // The same hole as the string shorthand above: an empty schema has no type Vertex can read.
1115
+ if (Object.keys(schema).length === 0) return { type: 'object', properties: {} };
1258
1116
  root = root || schema;
1259
1117
  seen = seen || new Set();
1260
1118
  if (seen.has(schema)) return {};
@@ -2305,7 +2163,7 @@ export {
2305
2163
  smartReasoning, smartText, smartToolCalls, smartUsage, smartFinish,
2306
2164
  firstChoice, smartDelta, sanitizeJsonSchema, toGeminiSchema, splitParts,
2307
2165
  budgetToEffort, effortToBudget, clampBudget, parseArgs, stringifyArgs,
2308
- anthropicToIR, chatToIR, responsesToIR, vertexToIR, parseToIR,
2166
+ anthropicToIR, responsesToIR, parseToIR,
2309
2167
  healToolPairs, healAnthropicPayload, rememberToolSignature, lookupToolSignature, estimateTokens, irToChatBody, irToAnthropicBody, irToVertexBody, emitUpstreamBody,
2310
2168
  normalizeUpstream, createUpstreamNormalizer, createCollector,
2311
2169
  createThinkTagSplitter, splitThinkTags,
package/mcp.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  // ============================================================
12
12
 
13
13
  import fs from 'node:fs';
14
- import { claudeSettingsPath, loadConfig as loadSharedConfig, resolvePort, readAdminToken, TARGETS } from './state.mjs';
14
+ import { claudeSettingsPath, loadConfig as loadSharedConfig, resolvePort, readAdminToken, STATE_DIR } from './state.mjs';
15
15
 
16
16
  const VERSION = (() => {
17
17
  try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version; } catch { return '0.0.0'; }
@@ -80,7 +80,7 @@ async function postSwitch(port, target, profile) {
80
80
  const TOOLS = [
81
81
  {
82
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.',
83
+ description: 'Get live status of LLM Switcher gateway: port, the two tool targets (Claude Code, Codex), and the active profile of each.',
84
84
  inputSchema: {
85
85
  type: 'object',
86
86
  properties: {}
@@ -98,14 +98,14 @@ const TOOLS = [
98
98
  },
99
99
  {
100
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.',
101
+ description: 'Programmatically change the active profile for one tool: Claude Code (claude) or Codex (codex).',
102
102
  inputSchema: {
103
103
  type: 'object',
104
104
  properties: {
105
105
  target: {
106
106
  type: 'string',
107
- description: 'CLI target to switch: "anthropic" (Claude Code), "responses" (Codex), "openai-chat", or "vertex"',
108
- enum: TARGETS
107
+ description: 'Tool to switch: "claude" (Claude Code) or "codex" (Codex)',
108
+ enum: ['claude', 'codex']
109
109
  },
110
110
  profile: {
111
111
  type: 'string',
@@ -124,6 +124,17 @@ const TOOLS = [
124
124
  limit: { type: 'number', description: 'Maximum number of recent logs to return (default: 5)' }
125
125
  }
126
126
  }
127
+ },
128
+ {
129
+ name: 'switcher_models',
130
+ description: 'Get the latest model catalog for Claude Code and Codex, including discovered upstream models and slot assignments.',
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: {
134
+ tool: { type: 'string', enum: ['claude', 'codex'], description: 'Optional: filter by tool ("claude" or "codex")' },
135
+ refresh: { type: 'boolean', description: 'Query official upstream endpoints for new models before returning' }
136
+ }
137
+ }
127
138
  }
128
139
  ];
129
140
 
@@ -131,17 +142,29 @@ async function handleToolCall(name, args) {
131
142
  const cfg = loadConfig();
132
143
  const port = getMcpPort();
133
144
 
145
+ if (name === 'switcher_models') {
146
+ const { loadCatalogCache, refreshCatalog } = await import('./catalog.mjs');
147
+ if (args?.refresh) {
148
+ await refreshCatalog(STATE_DIR);
149
+ }
150
+ const cache = loadCatalogCache(STATE_DIR);
151
+ const t = args?.tool?.toLowerCase();
152
+ const res = {};
153
+ if (!t || t === 'claude') res.claude = cache.claude;
154
+ if (!t || t === 'codex') res.codex = cache.codex;
155
+ res.updatedAt = cache.updatedAt;
156
+ return { content: [{ type: 'text', text: JSON.stringify(res, null, 2) }] };
157
+ }
158
+
134
159
  if (name === 'switcher_status') {
135
160
  const live = await fetchStatus(port);
136
161
  const activeMap = live?.activeProfiles || cfg.activeProfiles || {};
137
162
  const text = [
138
163
  '=== LLM Switcher Gateway Status ===',
139
164
  `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'}]`,
165
+ `Active Profiles by tool:`,
166
+ ` - Claude Code (/v1/messages) : [${activeMap.claude || 'OFF'}]`,
167
+ ` - Codex CLI (/v1/responses) : [${activeMap.codex || 'OFF'}]`,
145
168
  '',
146
169
  `Available Profiles in config: ${Object.keys(cfg.profiles || {}).join(', ')}`,
147
170
  `Dashboard Web UI: http://127.0.0.1:${port}/ui`
@@ -220,7 +243,12 @@ async function handleToolCall(name, args) {
220
243
  if (res.success) {
221
244
  return { content: [{ type: 'text', text: `Successfully set ${target || 'global'} active profile to "${profile || 'OFF'}".` }] };
222
245
  }
223
- return { content: [{ type: 'text', text: `Switch failed: ${res.error || 'Unknown error'}` }], isError: true };
246
+ // A 409 answer carries the clashing keys. Without them the agent cannot tell which two
247
+ // pointers of config.json disagree, and the only advice left is "go read the file".
248
+ const detail = Array.isArray(res.clashingKeys) && res.clashingKeys.length
249
+ ? ` Clashing keys: ${res.clashingKeys.join(', ')}.`
250
+ : '';
251
+ return { content: [{ type: 'text', text: `Switch failed: ${res.error || 'Unknown error'}.${detail}` }], isError: true };
224
252
  } catch (err) {
225
253
  return { content: [{ type: 'text', text: `Gateway connection failed: ${err.message}` }], isError: true };
226
254
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.1.11",
3
+ "version": "1.2.0",
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}"`;
@@ -1080,7 +1096,9 @@ async function fetchModels(body, cfg) {
1080
1096
 
1081
1097
  // Vertex/Gemini: /v1beta/models/{m}:{action} (Gemini API) and
1082
1098
  // /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)$/;
1099
+ // R4/Codex: model mapping for the responses input only. Claude Code's mapping is the tier
1100
+ // substring chain at the bottom of mapModel. No alias (`main`, `opus[1m]`) ever leaves this
1101
+ // function: the `[1m]` suffix is stripped before anything is looked up.
1084
1102
 
1085
1103
  // ----------------------------------------------------
1086
1104
  // Router
@@ -1123,7 +1141,7 @@ async function route(req, res) {
1123
1141
 
1124
1142
  // Health check
1125
1143
  if (method === 'GET' && pathname === '/health') {
1126
- const { profileKey, profile } = getFirstActiveProfile(TARGETS, req);
1144
+ const { profileKey, profile } = getFirstActiveProfile(['responses', 'anthropic'], req);
1127
1145
  // ?challenge=<nonce> lets the CLI tell this gateway from a process that replays a /health body.
1128
1146
  const challenge = parsedUrl.searchParams.get('challenge');
1129
1147
  return sendJson(res, 200, {
@@ -1151,6 +1169,7 @@ async function route(req, res) {
1151
1169
  // advertised: Codex sends them in-request and mapModel resolves them server-side.
1152
1170
  // Without publicModels, fall back to the deduplicated mapped upstream IDs.
1153
1171
  if (method === 'GET' && (pathname === '/v1/models' || pathname === '/models')) {
1172
+ checkVersionAndRefresh(req.headers['anthropic-version'] ? 'claude' : 'codex', req.headers, STATE_DIR);
1154
1173
  const { ids, windows } = servedModels(req);
1155
1174
  const created = Math.floor(Date.now() / 1000);
1156
1175
  return sendJson(res, 200, {
@@ -1183,9 +1202,7 @@ async function route(req, res) {
1183
1202
  return handleCountTokens(req, res, buf);
1184
1203
  }
1185
1204
 
1186
- // Client endpoints, one per input protocol (auto-detected by path).
1187
1205
  let clientFormat = null;
1188
- const vmatch = method === 'POST' ? pathname.match(VERTEX_ROUTE) : null;
1189
1206
 
1190
1207
  // Codex CLI sends GET /v1/responses (and /v1/responses/{id}) to fetch model
1191
1208
  // metadata and retrieve previous responses. The gateway is stateless, so
@@ -1206,18 +1223,18 @@ async function route(req, res) {
1206
1223
 
1207
1224
  if (method === 'POST') {
1208
1225
  if (pathname === '/v1/messages' || pathname === '/messages') clientFormat = 'anthropic';
1209
- else if (pathname === '/v1/chat/completions' || pathname === '/chat/completions') clientFormat = 'openai-chat';
1210
1226
  else if (pathname === '/v1/responses' || pathname === '/responses') clientFormat = 'responses';
1211
- else if (vmatch) clientFormat = 'vertex';
1212
1227
  }
1213
1228
  if (clientFormat) {
1229
+ if (clientFormat === 'anthropic') checkVersionAndRefresh('claude', req.headers, STATE_DIR);
1230
+ else if (clientFormat === 'responses') checkVersionAndRefresh('codex', req.headers, STATE_DIR);
1214
1231
  let buf;
1215
1232
  try {
1216
1233
  buf = await readBody(req, MAX_BODY_SIZE);
1217
1234
  } catch (err) {
1218
1235
  return sendClientError(res, clientFormat, err.status || 400, err.message);
1219
1236
  }
1220
- const opts = vmatch ? { vertexModel: decodeURIComponent(vmatch[1]), vertexStream: vmatch[2] === 'streamGenerateContent' } : {};
1237
+ const opts = {};
1221
1238
  return handleConvert(clientFormat, req, res, buf, opts);
1222
1239
  }
1223
1240
 
@@ -1228,7 +1245,7 @@ async function route(req, res) {
1228
1245
  // The names /v1/models serves, and the window of each. Official names when the profile publishes
1229
1246
  // them, otherwise the mapped upstream IDs; each window follows model1M of its slot.
1230
1247
  function servedModels(req) {
1231
- const { profile } = getFirstActiveProfile(['responses', 'openai-chat', 'anthropic', 'vertex'], req);
1248
+ const { profile } = getFirstActiveProfile(['responses', 'anthropic'], req);
1232
1249
  if (Array.isArray(profile?.publicModels) && profile.publicModels.length) {
1233
1250
  return { ids: [...new Set(profile.publicModels.filter(Boolean))], windows: publicModelWindows(profile) };
1234
1251
  }
@@ -1247,9 +1264,8 @@ async function routeApi(req, res, method, pathname) {
1247
1264
  activeProfile: cfg.activeProfile || null,
1248
1265
  activeProfiles,
1249
1266
  revision: configRevision(cfg),
1250
- claude1MTiers: computeLaunchState(cfg, PORT).claude1MTiers,
1251
1267
  ...readLaunchFlags(),
1252
- claudeBaseURL: activeProfiles.anthropic ? `http://127.0.0.1:${PORT} (injected via launcher)` : '(none / official)',
1268
+ claudeBaseURL: activeProfiles.claude ? `http://127.0.0.1:${PORT} (injected via launcher)` : '(none / official)',
1253
1269
  config: redactConfig(cfg)
1254
1270
  });
1255
1271
  }
@@ -1271,6 +1287,24 @@ async function routeApi(req, res, method, pathname) {
1271
1287
  return sendJson(res, err.status || 400, { error: err.message });
1272
1288
  }
1273
1289
 
1290
+ // GET /api/catalog
1291
+ if (method === 'GET' && pathname === '/api/catalog') {
1292
+ return sendJson(res, 200, loadCatalogCache(STATE_DIR));
1293
+ }
1294
+
1295
+ // POST /api/catalog/refresh
1296
+ if (method === 'POST' && pathname === '/api/catalog/refresh') {
1297
+ const active = getActiveMap(requireConfig(res) || {});
1298
+ const cfg = requireConfig(res) || {};
1299
+ const claudeProfile = active.claude ? cfg.profiles?.[active.claude] : null;
1300
+ const codexProfile = active.codex ? cfg.profiles?.[active.codex] : null;
1301
+ const updated = await refreshCatalog(STATE_DIR, {
1302
+ claudeKey: claudeProfile?.apiKey,
1303
+ codexKey: codexProfile?.apiKey
1304
+ });
1305
+ return sendJson(res, 200, { success: true, catalog: updated });
1306
+ }
1307
+
1274
1308
  // POST /api/logs/clear
1275
1309
  if (pathname === '/api/logs/clear') {
1276
1310
  requestLogs.length = 0;
@@ -1292,6 +1326,12 @@ async function routeConfigApi(res, method, pathname, body) {
1292
1326
  }
1293
1327
 
1294
1328
  // POST /api/switch { target?, profile? | null, deactivate? }
1329
+ // R7: a migration collision means config.json is in a shape this build refuses to rewrite.
1330
+ // Every route that writes it answers the same way, and the bytes on disk stay as they are.
1331
+ if (getMigrationCollision() && ['/api/switch', '/api/toggle', '/api/save-profile', '/api/delete-profile'].includes(pathname)) {
1332
+ return sendJson(res, 409, { error: 'Migration collision', clashingKeys: getMigrationCollision().clashingKeys });
1333
+ }
1334
+
1295
1335
  if (pathname === '/api/switch') {
1296
1336
  let err = null;
1297
1337
  if (body.deactivate) {
@@ -1313,7 +1353,7 @@ async function routeConfigApi(res, method, pathname, body) {
1313
1353
  const map = getActiveMap(cfg);
1314
1354
  let err = null;
1315
1355
  if (body.target) {
1316
- if (!TARGETS.includes(body.target)) return sendJson(res, 400, { error: `Unknown target "${body.target}"` });
1356
+ if (!TOOLS.includes(body.target)) return sendJson(res, 400, { error: `Unknown target "${body.target}"` });
1317
1357
  let key = null;
1318
1358
  if (body.enabled) {
1319
1359
  const candidates = [map[body.target], cfg.activeProfile, ...Object.keys(cfg.profiles)];
@@ -1361,7 +1401,7 @@ async function routeConfigApi(res, method, pathname, body) {
1361
1401
  const map = getActiveMap(cfg);
1362
1402
  cfg.activeProfiles = map;
1363
1403
  let unassigned = false;
1364
- for (const t of TARGETS) {
1404
+ for (const t of TOOLS) {
1365
1405
  if (map[t] === key && !profileAcceptsTarget(merged, t)) {
1366
1406
  map[t] = null;
1367
1407
  unassigned = true;
@@ -1369,7 +1409,16 @@ async function routeConfigApi(res, method, pathname, body) {
1369
1409
  }
1370
1410
 
1371
1411
  // 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) });
1412
+ let applied;
1413
+ try {
1414
+ applied = isProfileActive(cfg, key) || unassigned ? await commit(cfg, baseRevision) : (saveConfig(cfg), { revision: configRevision(cfg) });
1415
+ } catch (err) {
1416
+ // saveConfig refuses while a migration collision or migration error blocks writes (R7).
1417
+ if (getMigrationError() || getMigrationCollision()) {
1418
+ return sendJson(res, 409, { error: err.message });
1419
+ }
1420
+ throw err;
1421
+ }
1373
1422
  return sendJson(res, applied.status || (applied.success === false ? 502 : 200), { success: true, ...applied });
1374
1423
  }
1375
1424
 
@@ -1677,6 +1726,8 @@ server.on('upgrade', (req, socket) => {
1677
1726
  return;
1678
1727
  }
1679
1728
 
1729
+ checkVersionAndRefresh('codex', req.headers, STATE_DIR);
1730
+
1680
1731
  const key = req.headers['sec-websocket-key'];
1681
1732
  if (!key) {
1682
1733
  socket.destroy();
@@ -1788,12 +1839,26 @@ server.on('error', (err) => {
1788
1839
  if (!loadConfig()) {
1789
1840
  console.warn(`[llm-switcher:WARN] Could not load ${configPath}: ${getConfigLoadError()?.message}. Copy config.example.json to config.json.`);
1790
1841
  }
1842
+ // R7: a migration that could not complete blocks every write. Say so at startup instead of
1843
+ // failing later, on the first save, with an error nobody connects back to this.
1844
+ if (getMigrationError()) {
1845
+ console.error(`[llm-switcher:ERROR] ${getMigrationError().message}`);
1846
+ }
1847
+ if (getMigrationCollision()) {
1848
+ console.error(`[llm-switcher:ERROR] Configuration migration collision: ${JSON.stringify(getMigrationCollision().clashingKeys)}. Fix config.json; it is not being rewritten.`);
1849
+ }
1791
1850
 
1792
1851
  server.listen(PORT, '127.0.0.1', () => {
1793
1852
  // A service start or a restart on a new port finds env-codex.* already pointing at the interceptor.
1794
1853
  const cfg = loadConfig();
1795
- if (cfg && !getConfigLoadError()) serialized(() => reconcile(cfg));
1854
+ if (getMigrationCollision()) {
1855
+ console.error('[llm-switcher] Skipping reconcile: a configuration migration collision blocks writes.');
1856
+ } else if (cfg && !getConfigLoadError()) {
1857
+ serialized(() => reconcile(cfg));
1858
+ }
1796
1859
  console.log(`[llm-switcher] Server running on http://127.0.0.1:${PORT}`);
1797
1860
  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)`);
1861
+ console.log(`[llm-switcher] Endpoints: /v1/messages (Claude Code) | /v1/responses (Codex)`);
1862
+ // Asynchronously discover latest tool models in the background without blocking startup
1863
+ refreshCatalog(STATE_DIR).catch(() => {});
1799
1864
  });