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.
- package/CHANGELOG.md +51 -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/config.example.json +12 -34
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
- 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/docs/response-matrix.json +1130 -1130
- package/ensure-ca-bundle.mjs +28 -0
- package/formats.mjs +13 -155
- package/mcp.mjs +39 -11
- package/package.json +1 -1
- package/proxy.mjs +92 -27
- package/shim.mjs +200 -57
- package/skills/llm-switcher/SKILL.md +93 -88
- package/state.mjs +1100 -191
- package/switch +0 -0
- 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/contract-lab.test.mjs +22 -7
- package/tests/formats.test.mjs +33 -46
- package/tests/gateway.e2e.test.mjs +136 -36
- package/tests/helpers.mjs +24 -24
- package/tests/lifecycle.test.mjs +16 -10
- package/tests/live-optimizer-interop.mjs +205 -205
- 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 +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
|
-
|
|
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
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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: '
|
|
108
|
-
enum:
|
|
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
|
|
141
|
-
` - Claude Code (/v1/messages)
|
|
142
|
-
` - Codex CLI (/v1/responses)
|
|
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
|
-
|
|
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
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}"`;
|
|
@@ -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
|
-
|
|
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(
|
|
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 =
|
|
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', '
|
|
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.
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
});
|