llm-switcher 1.1.11 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +202 -257
  3. package/README.vi.md +200 -256
  4. package/blindfold/blindfold.mjs +200 -53
  5. package/blindfold/make-certs.sh +26 -7
  6. package/catalog.mjs +246 -0
  7. package/classifier.mjs +238 -0
  8. package/config.example.json +12 -34
  9. package/docs/codex-blindfold.md +28 -17
  10. package/docs/cross-platform.md +16 -7
  11. package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
  12. package/docs/diagrams/ir-healer-pipeline.png +0 -0
  13. package/docs/diagrams/ir-healer-pipeline.svg +90 -0
  14. package/docs/diagrams/ir-translation-pipeline.html +14925 -0
  15. package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
  16. package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
  17. package/docs/diagrams/system-architecture.architecture.json +76 -0
  18. package/docs/diagrams/system-architecture.html +14978 -0
  19. package/docs/diagrams/system-architecture.svg +5147 -0
  20. package/docs/diagrams/system-topology.mmd +30 -0
  21. package/docs/diagrams/system-topology.png +0 -0
  22. package/docs/diagrams/system-topology.svg +125 -0
  23. package/ensure-ca-bundle.mjs +28 -0
  24. package/formats.mjs +43 -156
  25. package/icons/antigravity.png +0 -0
  26. package/icons/claude.png +0 -0
  27. package/icons/codex.png +0 -0
  28. package/icons/deepseek.png +0 -0
  29. package/icons/gemini.png +0 -0
  30. package/icons/github.png +0 -0
  31. package/icons/groq.png +0 -0
  32. package/icons/intact.svg +1 -0
  33. package/icons/ollama.png +0 -0
  34. package/icons/openai.png +0 -0
  35. package/icons/openrouter.png +0 -0
  36. package/icons/qwen.png +0 -0
  37. package/icons/vertex.png +0 -0
  38. package/mcp.mjs +39 -11
  39. package/package.json +1 -1
  40. package/proxy.mjs +114 -37
  41. package/shim.mjs +200 -57
  42. package/skills/llm-switcher/SKILL.md +15 -10
  43. package/state.mjs +1100 -191
  44. package/switch.cmd +2 -2
  45. package/switch.mjs +228 -53
  46. package/tests/blindfold-e2e.test.mjs +380 -0
  47. package/tests/blindfold-task5.test.mjs +429 -0
  48. package/tests/blindfold.task3.test.mjs +700 -0
  49. package/tests/blindfold.test.mjs +10 -5
  50. package/tests/catalog.test.mjs +147 -0
  51. package/tests/classifier.test.mjs +210 -0
  52. package/tests/contract-lab.test.mjs +22 -7
  53. package/tests/formats.test.mjs +63 -46
  54. package/tests/gateway.e2e.test.mjs +136 -36
  55. package/tests/lifecycle.test.mjs +16 -10
  56. package/tests/mcp.test.mjs +78 -2
  57. package/tests/real-user-sim.test.mjs +464 -0
  58. package/tests/shim.test.mjs +159 -66
  59. package/tests/state.test.mjs +975 -193
  60. package/tests/switch.test.mjs +446 -2
  61. package/ui.html +1710 -1726
@@ -0,0 +1,30 @@
1
+ flowchart LR
2
+ subgraph Clients[" Dev Clients & Coding CLIs "]
3
+ CC["Claude Code CLI\n(/v1/messages)"]
4
+ CDX["OpenAI Codex CLI\n(/v1/responses)"]
5
+ end
6
+
7
+ subgraph Optimizers[" Optional Middle-Layer "]
8
+ OPT["Token Optimizers\n(Headroom / RTK)"]
9
+ end
10
+
11
+ subgraph Gateway[" LLM Switcher Edge Gateway (:3456) "]
12
+ ROUTER["Edge Router\n(Zero-Mutation)"]
13
+ HEALER["Healer Engine\n(Auto-Fix Schemas)"]
14
+ IR["Bi-Directional IR\n(Event Stream Synth)"]
15
+ ROUTER --> HEALER --> IR
16
+ end
17
+
18
+ subgraph Upstreams[" Upstream Providers "]
19
+ INTACT["intact Gateway\n(Recommended Pooler)"]
20
+ OTHER["9Router / Vertex / Other"]
21
+ end
22
+
23
+ CC -->|direct /v1/messages| ROUTER
24
+ CDX -->|direct /v1/responses| ROUTER
25
+ CC -.->|prune prompt| OPT
26
+ CDX -.->|prune prompt| OPT
27
+ OPT -->|forward :3456| ROUTER
28
+
29
+ IR -->|contract & pool| INTACT
30
+ IR -->|standard call| OTHER
@@ -0,0 +1,125 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1712.757 298.51666666666665" width="1712.757" height="298.51666666666665" style="--bg:#1a1b26;--fg:#a9b1d6;--line:#3d59a1;--accent:#7aa2f7;--muted:#565f89;background:var(--bg)">
2
+ <style>
3
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;display=swap');
4
+ text { font-family: 'Inter', system-ui, sans-serif; }
5
+ svg {
6
+ /* Derived from --bg and --fg (overridable via --line, --accent, etc.) */
7
+ --_text: var(--fg);
8
+ --_text-sec: var(--muted, color-mix(in srgb, var(--fg) 60%, var(--bg)));
9
+ --_text-muted: var(--muted, color-mix(in srgb, var(--fg) 40%, var(--bg)));
10
+ --_text-faint: color-mix(in srgb, var(--fg) 25%, var(--bg));
11
+ --_line: var(--line, color-mix(in srgb, var(--fg) 50%, var(--bg)));
12
+ --_arrow: var(--accent, color-mix(in srgb, var(--fg) 85%, var(--bg)));
13
+ --_node-fill: var(--surface, color-mix(in srgb, var(--fg) 3%, var(--bg)));
14
+ --_node-stroke: var(--border, color-mix(in srgb, var(--fg) 20%, var(--bg)));
15
+ --_group-fill: var(--bg);
16
+ --_group-hdr: color-mix(in srgb, var(--fg) 5%, var(--bg));
17
+ --_inner-stroke: color-mix(in srgb, var(--fg) 12%, var(--bg));
18
+ --_key-badge: color-mix(in srgb, var(--fg) 10%, var(--bg));
19
+ }
20
+ </style>
21
+ <defs>
22
+ <marker id="arrowhead" markerWidth="8" markerHeight="5" refX="7" refY="2.5" orient="auto">
23
+ <polygon points="0 0, 8 2.5, 0 5" fill="var(--_arrow)" stroke="var(--_arrow)" stroke-width="0.75" stroke-linejoin="round" />
24
+ </marker>
25
+ <marker id="arrowhead-start" markerWidth="8" markerHeight="5" refX="1" refY="2.5" orient="auto-start-reverse">
26
+ <polygon points="8 0, 0 2.5, 8 5" fill="var(--_arrow)" stroke="var(--_arrow)" stroke-width="0.75" stroke-linejoin="round" />
27
+ </marker>
28
+ </defs>
29
+ <g class="subgraph" data-id="Clients" data-label=" Dev Clients &amp; Coding CLIs ">
30
+ <rect x="40" y="40" width="180.654" height="191.60000000000002" rx="0" ry="0" fill="var(--_group-fill)" stroke="var(--_node-stroke)" stroke-width="1" />
31
+ <rect x="40" y="40" width="180.654" height="28" rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="1" />
32
+ <text x="52" y="54" font-size="12" font-weight="600" fill="var(--_text-sec)" dy="4.199999999999999"> Dev Clients &amp; Coding CLIs </text>
33
+ </g>
34
+ <g class="subgraph" data-id="Optimizers" data-label=" Optional Middle-Layer ">
35
+ <rect x="384.674" y="90.56666666666666" width="176.949" height="113.80000000000001" rx="0" ry="0" fill="var(--_group-fill)" stroke="var(--_node-stroke)" stroke-width="1" />
36
+ <rect x="384.674" y="90.56666666666666" width="176.949" height="28" rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="1" />
37
+ <text x="396.674" y="104.56666666666666" font-size="12" font-weight="600" fill="var(--_text-sec)" dy="4.199999999999999"> Optional Middle-Layer </text>
38
+ </g>
39
+ <g class="subgraph" data-id="Gateway" data-label=" LLM Switcher Edge Gateway (:3456) ">
40
+ <rect x="728.0190000000001" y="92.55000000000001" width="563.89" height="113.80000000000001" rx="0" ry="0" fill="var(--_group-fill)" stroke="var(--_node-stroke)" stroke-width="1" />
41
+ <rect x="728.0190000000001" y="92.55000000000001" width="563.89" height="28" rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="1" />
42
+ <text x="740.0190000000001" y="106.55000000000001" font-size="12" font-weight="600" fill="var(--_text-sec)" dy="4.199999999999999"> LLM Switcher Edge Gateway (:3456) </text>
43
+ </g>
44
+ <g class="subgraph" data-id="Upstreams" data-label=" Upstream Providers ">
45
+ <rect x="1462.4630000000002" y="48.45" width="210.29399999999998" height="174.70000000000002" rx="0" ry="0" fill="var(--_group-fill)" stroke="var(--_node-stroke)" stroke-width="1" />
46
+ <rect x="1462.4630000000002" y="48.45" width="210.29399999999998" height="28" rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="1" />
47
+ <text x="1474.4630000000002" y="62.45" font-size="12" font-weight="600" fill="var(--_text-sec)" dy="4.199999999999999"> Upstream Providers </text>
48
+ </g>
49
+ <polyline class="edge" data-from="CC" data-to="ROUTER" data-style="solid" data-arrow-start="false" data-arrow-end="true" data-label="direct /v1/messages" points="200.208,101.93333333333334 366.674,101.93333333333334 366.674,56.26666666666667 710.0190000000001,56.26666666666667 710.0190000000001,150 744.0190000000001,150" fill="none" stroke="var(--_line)" stroke-width="1" marker-end="url(#arrowhead)" />
50
+ <polyline class="edge" data-from="CDX" data-to="ROUTER" data-style="solid" data-arrow-start="false" data-arrow-end="true" data-label="direct /v1/responses" points="209.1,197.66666666666669 366.674,197.66666666666669 366.674,239.36666666666667 710.0190000000001,239.36666666666667 710.0190000000001,176.9 744.0190000000001,176.9" fill="none" stroke="var(--_line)" stroke-width="1" marker-end="url(#arrowhead)" />
51
+ <polyline class="edge" data-from="CC" data-to="OPT" data-style="dotted" data-arrow-start="false" data-arrow-end="true" data-label="prune prompt" points="200.208,119.86666666666667 254.654,119.86666666666667 254.654,144.81666666666666 366.674,144.81666666666666 366.674,152.5 400.674,152.5" fill="none" stroke="var(--_line)" stroke-width="1" stroke-dasharray="4 4" marker-end="url(#arrowhead)" />
52
+ <polyline class="edge" data-from="CDX" data-to="OPT" data-style="dotted" data-arrow-start="false" data-arrow-end="true" data-label="prune prompt" points="209.1,179.73333333333335 254.654,179.73333333333335 254.654,178.11666666666667 366.674,178.11666666666667 366.674,170.43333333333334 400.674,170.43333333333334" fill="none" stroke="var(--_line)" stroke-width="1" stroke-dasharray="4 4" marker-end="url(#arrowhead)" />
53
+ <polyline class="edge" data-from="OPT" data-to="ROUTER" data-style="solid" data-arrow-start="false" data-arrow-end="true" data-label="forward :3456" points="545.623,161.46666666666667 710.019,161.4666666666667 710.019,163.45000000000002 744.0190000000001,163.45000000000002" fill="none" stroke="var(--_line)" stroke-width="1" marker-end="url(#arrowhead)" />
54
+ <polyline class="edge" data-from="IR" data-to="INTACT" data-style="solid" data-arrow-start="false" data-arrow-end="true" data-label="contract &amp; pool" points="1275.909,172.41666666666669 1325.909,172.41666666666669 1325.909,180.10000000000002 1444.4630000000002,180.10000000000002 1444.4630000000002,180.25 1478.4630000000002,180.25000000000003" fill="none" stroke="var(--_line)" stroke-width="1" marker-end="url(#arrowhead)" />
55
+ <polyline class="edge" data-from="IR" data-to="OTHER" data-style="solid" data-arrow-start="false" data-arrow-end="true" data-label="standard call" points="1275.909,154.48333333333335 1333.631,154.48333333333335 1333.631,128.925 1444.4630000000002,128.925 1444.4630000000002,110.9 1478.4630000000002,110.90000000000002" fill="none" stroke="var(--_line)" stroke-width="1" marker-end="url(#arrowhead)" />
56
+ <polyline class="edge" data-from="ROUTER" data-to="HEALER" data-style="solid" data-arrow-start="false" data-arrow-end="true" points="876.3710000000001,163.45000000000002 916.3710000000001,163.45000000000002" fill="none" stroke="var(--_line)" stroke-width="1" marker-end="url(#arrowhead)" />
57
+ <polyline class="edge" data-from="HEALER" data-to="IR" data-style="solid" data-arrow-start="false" data-arrow-end="true" points="1072.4350000000002,163.45000000000002 1112.4350000000002,163.45000000000002" fill="none" stroke="var(--_line)" stroke-width="1" marker-end="url(#arrowhead)" />
58
+ <g class="edge-label" data-from="CC" data-to="ROUTER" data-label="direct /v1/messages">
59
+ <rect x="592.2990000000001" y="40.26666666666667" width="109.72000000000001" height="30.3" rx="2" ry="2" fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="1" />
60
+ <text x="647.1590000000001" y="55.41666666666667" text-anchor="middle" font-size="11" font-weight="400" fill="var(--_text-sec)" dy="3.8499999999999996">direct /v1/messages</text>
61
+ </g>
62
+ <g class="edge-label" data-from="CDX" data-to="ROUTER" data-label="direct /v1/responses">
63
+ <rect x="588.7350000000001" y="223.36666666666667" width="113.28400000000002" height="30.3" rx="2" ry="2" fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="1" />
64
+ <text x="645.3770000000002" y="238.51666666666668" text-anchor="middle" font-size="11" font-weight="400" fill="var(--_text-sec)" dy="3.8499999999999996">direct /v1/responses</text>
65
+ </g>
66
+ <g class="edge-label" data-from="CC" data-to="OPT" data-label="prune prompt">
67
+ <rect x="262.654" y="128.81666666666666" width="80.02000000000001" height="30.3" rx="2" ry="2" fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="1" />
68
+ <text x="302.664" y="143.96666666666667" text-anchor="middle" font-size="11" font-weight="400" fill="var(--_text-sec)" dy="3.8499999999999996">prune prompt</text>
69
+ </g>
70
+ <g class="edge-label" data-from="CDX" data-to="OPT" data-label="prune prompt">
71
+ <rect x="262.654" y="162.11666666666667" width="80.02000000000001" height="30.3" rx="2" ry="2" fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="1" />
72
+ <text x="302.664" y="177.26666666666668" text-anchor="middle" font-size="11" font-weight="400" fill="var(--_text-sec)" dy="3.8499999999999996">prune prompt</text>
73
+ </g>
74
+ <g class="edge-label" data-from="OPT" data-to="ROUTER" data-label="forward :3456">
75
+ <rect x="603.623" y="145.46666666666667" width="82.39600000000002" height="30.3" rx="2" ry="2" fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="1" />
76
+ <text x="644.821" y="160.61666666666667" text-anchor="middle" font-size="11" font-weight="400" fill="var(--_text-sec)" dy="3.8499999999999996">forward :3456</text>
77
+ </g>
78
+ <g class="edge-label" data-from="IR" data-to="INTACT" data-label="contract &amp; pool">
79
+ <rect x="1333.909" y="164.10000000000002" width="86.55400000000002" height="30.3" rx="2" ry="2" fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="1" />
80
+ <text x="1377.1860000000001" y="179.25000000000003" text-anchor="middle" font-size="11" font-weight="400" fill="var(--_text-sec)" dy="3.8499999999999996">contract &amp; pool</text>
81
+ </g>
82
+ <g class="edge-label" data-from="IR" data-to="OTHER" data-label="standard call">
83
+ <rect x="1341.631" y="112.92500000000001" width="78.83200000000001" height="30.3" rx="2" ry="2" fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="1" />
84
+ <text x="1381.047" y="128.07500000000002" text-anchor="middle" font-size="11" font-weight="400" fill="var(--_text-sec)" dy="3.8499999999999996">standard call</text>
85
+ </g>
86
+ <g class="node" data-id="CC" data-label="Claude Code CLI
87
+ (/v1/messages)" data-shape="rectangle">
88
+ <rect x="60.446" y="84" width="139.762" height="53.800000000000004" rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="0.75" />
89
+ <text x="130.327" y="110.9" text-anchor="middle" font-size="13" font-weight="500" fill="var(--_text)"><tspan x="130.327" dy="-3.9000000000000012">Claude Code CLI</tspan><tspan x="130.327" dy="16.900000000000002">(/v1/messages)</tspan></text>
90
+ </g>
91
+ <g class="node" data-id="CDX" data-label="OpenAI Codex CLI
92
+ (/v1/responses)" data-shape="rectangle">
93
+ <rect x="60.446" y="161.8" width="148.654" height="53.800000000000004" rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="0.75" />
94
+ <text x="134.773" y="188.70000000000002" text-anchor="middle" font-size="13" font-weight="500" fill="var(--_text)"><tspan x="134.773" dy="-3.9000000000000012">OpenAI Codex CLI</tspan><tspan x="134.773" dy="16.900000000000002">(/v1/responses)</tspan></text>
95
+ </g>
96
+ <g class="node" data-id="OPT" data-label="Token Optimizers
97
+ (Headroom / RTK)" data-shape="rectangle">
98
+ <rect x="400.674" y="134.56666666666666" width="144.949" height="53.800000000000004" rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="0.75" />
99
+ <text x="473.1485" y="161.46666666666667" text-anchor="middle" font-size="13" font-weight="500" fill="var(--_text)"><tspan x="473.1485" dy="-3.9000000000000012">Token Optimizers</tspan><tspan x="473.1485" dy="16.900000000000002">(Headroom / RTK)</tspan></text>
100
+ </g>
101
+ <g class="node" data-id="ROUTER" data-label="Edge Router
102
+ (Zero-Mutation)" data-shape="rectangle">
103
+ <rect x="744.0190000000001" y="136.55" width="132.352" height="53.800000000000004" rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="0.75" />
104
+ <text x="810.1950000000002" y="163.45000000000002" text-anchor="middle" font-size="13" font-weight="500" fill="var(--_text)"><tspan x="810.1950000000002" dy="-3.9000000000000012">Edge Router</tspan><tspan x="810.1950000000002" dy="16.900000000000002">(Zero-Mutation)</tspan></text>
105
+ </g>
106
+ <g class="node" data-id="HEALER" data-label="Healer Engine
107
+ (Auto-Fix Schemas)" data-shape="rectangle">
108
+ <rect x="916.3710000000001" y="136.55" width="156.064" height="53.800000000000004" rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="0.75" />
109
+ <text x="994.4030000000001" y="163.45000000000002" text-anchor="middle" font-size="13" font-weight="500" fill="var(--_text)"><tspan x="994.4030000000001" dy="-3.9000000000000012">Healer Engine</tspan><tspan x="994.4030000000001" dy="16.900000000000002">(Auto-Fix Schemas)</tspan></text>
110
+ </g>
111
+ <g class="node" data-id="IR" data-label="Bi-Directional IR
112
+ (Event Stream Synth)" data-shape="rectangle">
113
+ <rect x="1112.4350000000002" y="136.55" width="163.474" height="53.800000000000004" rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="0.75" />
114
+ <text x="1194.1720000000003" y="163.45000000000002" text-anchor="middle" font-size="13" font-weight="500" fill="var(--_text)"><tspan x="1194.1720000000003" dy="-3.9000000000000012">Bi-Directional IR</tspan><tspan x="1194.1720000000003" dy="16.900000000000002">(Event Stream Synth)</tspan></text>
115
+ </g>
116
+ <g class="node" data-id="INTACT" data-label="intact Gateway
117
+ (Recommended Pooler)" data-shape="rectangle">
118
+ <rect x="1478.4630000000002" y="153.35000000000002" width="177.55299999999997" height="53.800000000000004" rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="0.75" />
119
+ <text x="1567.2395000000001" y="180.25000000000003" text-anchor="middle" font-size="13" font-weight="500" fill="var(--_text)"><tspan x="1567.2395000000001" dy="-3.9000000000000012">intact Gateway</tspan><tspan x="1567.2395000000001" dy="16.900000000000002">(Recommended Pooler)</tspan></text>
120
+ </g>
121
+ <g class="node" data-id="OTHER" data-label="9Router / Vertex / Other" data-shape="rectangle">
122
+ <rect x="1478.4630000000002" y="92.45" width="178.29399999999998" height="36.900000000000006" rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="0.75" />
123
+ <text x="1567.6100000000001" y="110.9" text-anchor="middle" font-size="13" font-weight="500" fill="var(--_text)" dy="4.55">9Router / Vertex / Other</text>
124
+ </g>
125
+ </svg>
@@ -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
@@ -19,11 +19,11 @@
19
19
  // params: { maxTokens, temperature, topP, topK, stop[],
20
20
  // presencePenalty?, frequencyPenalty? },
21
21
  // thinking: { type:'enabled'|'disabled'|'adaptive', budget?, effort? } | null,
22
+ // responseFormat: { type:'json_object' } | { type:'json_schema', name?, schema, strict? } | null,
22
23
  // stream: bool
23
24
  // }
24
25
  // ============================================================
25
26
 
26
- export const IN_FORMATS = ['anthropic', 'openai-chat', 'responses', 'vertex'];
27
27
  export const OUT_FORMATS = ['openai-chat', 'anthropic', 'vertex'];
28
28
 
29
29
  // ---------------- stop reasons ----------------
@@ -272,7 +272,10 @@ function sanitizeSchemaNode(schema) {
272
272
  }
273
273
  clean[k] = sanitizeSchemaNode(v);
274
274
  }
275
- if (!clean.type && clean.properties) {
275
+ // An empty schema carries no shape, and Vertex reads that as a missing `type`.
276
+ // Heal it the way a missing schema is healed — but only when nothing else is here,
277
+ // so an `anyOf`/`$ref` shape is never handed a type it did not ask for.
278
+ if (!clean.type && (clean.properties || Object.keys(clean).length === 0)) {
276
279
  clean.type = 'object';
277
280
  }
278
281
  if (clean.type === 'object' && !clean.properties) {
@@ -325,10 +328,23 @@ function baseIR() {
325
328
  return {
326
329
  model: '', system: '', messages: [], tools: [], toolChoice: null,
327
330
  params: { maxTokens: null, temperature: null, topP: null, topK: null, stop: [] },
328
- thinking: null, stream: false
331
+ thinking: null, responseFormat: null, stream: false
329
332
  };
330
333
  }
331
334
 
335
+ // Structured output (Anthropic output_config.format, Responses text.format) -> IR responseFormat.
336
+ // `text` asks for nothing.
337
+ function responseFormatIR(f) {
338
+ if (!f || typeof f !== 'object') return null;
339
+ if (f.type === 'json_object') return { type: 'json_object' };
340
+ if (f.type !== 'json_schema') return null;
341
+ if (!f.schema || typeof f.schema !== 'object') return { type: 'json_object' };
342
+ const out = { type: 'json_schema', schema: f.schema };
343
+ if (typeof f.name === 'string' && f.name) out.name = f.name;
344
+ if (typeof f.strict === 'boolean') out.strict = f.strict;
345
+ return out;
346
+ }
347
+
332
348
  // Anthropic Messages API -> IR (logic ported from the old transformAnthropicToOpenAI).
333
349
  function anthropicToIR(payload) {
334
350
  const ir = baseIR();
@@ -421,6 +437,7 @@ function anthropicToIR(payload) {
421
437
  if (Array.isArray(payload.stop_sequences)) ir.params.stop = stopList(payload.stop_sequences);
422
438
 
423
439
  ir.thinking = thinkingFromAnthropicParam(payload.thinking, payload.output_config?.effort);
440
+ ir.responseFormat = responseFormatIR(payload.output_config?.format || payload.output_format);
424
441
  return ir;
425
442
  }
426
443
 
@@ -455,73 +472,6 @@ function applyAllowedTools(ir, mode, list, nameOf) {
455
472
  ir.toolChoice = ir.tools.length ? (mode === 'required' ? 'required' : 'auto') : null;
456
473
  }
457
474
 
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
475
  // OpenAI/OpenRouter `reasoning` object -> IR thinking.
526
476
  function thinkingFromReasoningParam(r) {
527
477
  if (!r || typeof r !== 'object') return null;
@@ -709,98 +659,20 @@ function responsesToIR(payload) {
709
659
  if (payload.reasoning && typeof payload.reasoning === 'object') {
710
660
  ir.thinking = thinkingFromReasoningParam(payload.reasoning);
711
661
  }
662
+ ir.responseFormat = responseFormatIR(payload.text?.format);
712
663
  return ir;
713
664
  }
714
665
 
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
-
666
+ // R5: two inputs, one per tool — `anthropic` for Claude Code, `responses` for Codex. The
667
+ // openai-chat and vertex routes were removed with their parsers; a format that reaches here
668
+ // anyway is an error, never a silent fallback to another protocol (a different API key!).
669
+ // The UPSTREAM formats are a different axis and are untouched: emitUpstreamBody still speaks
670
+ // openai-chat, anthropic and vertex to whatever provider the profile points at.
797
671
  function parseToIR(clientFormat, payload) {
798
672
  switch (clientFormat) {
799
673
  case 'anthropic': return anthropicToIR(payload);
800
- case 'openai-chat': return chatToIR(payload);
801
674
  case 'responses': return responsesToIR(payload);
802
- case 'vertex': return vertexToIR(payload);
803
- default: throw new Error(`Unknown client format: ${clientFormat}`);
675
+ default: throw new Error(`Unsupported input format: ${clientFormat}. Only anthropic (Claude Code) and responses (Codex) accept requests.`);
804
676
  }
805
677
  }
806
678
 
@@ -966,6 +838,13 @@ function irToChatBody(ir, model, opts = {}) {
966
838
  if (typeof ir.params.frequencyPenalty === 'number') body.frequency_penalty = ir.params.frequencyPenalty;
967
839
  if (ir.params.stop.length) body.stop = ir.params.stop;
968
840
  if (ir.params.parallelToolCalls === false && body.tools) body.parallel_tool_calls = false;
841
+ if (ir.responseFormat?.type === 'json_schema') {
842
+ const js = { name: ir.responseFormat.name || 'response', schema: ir.responseFormat.schema };
843
+ if (typeof ir.responseFormat.strict === 'boolean') js.strict = ir.responseFormat.strict;
844
+ body.response_format = { type: 'json_schema', json_schema: js };
845
+ } else if (ir.responseFormat?.type === 'json_object') {
846
+ body.response_format = { type: 'json_object' };
847
+ }
969
848
 
970
849
  if (wantsThinking && mode === 'native') {
971
850
  body.reasoning_effort = ir.thinking?.effort && !['max', 'xhigh'].includes(ir.thinking.effort)
@@ -1085,6 +964,8 @@ function irToAnthropicBody(ir, model) {
1085
964
  if (typeof ir.params.topP === 'number') body.top_p = ir.params.topP;
1086
965
  if (typeof ir.params.topK === 'number') body.top_k = ir.params.topK;
1087
966
  if (ir.params.stop.length) body.stop_sequences = ir.params.stop;
967
+ // Anthropic has no JSON mode without a schema.
968
+ if (ir.responseFormat?.type === 'json_schema') body.output_config = { format: { type: 'json_schema', schema: ir.responseFormat.schema } };
1088
969
  if (ir.thinking && ir.thinking.type === 'adaptive') {
1089
970
  body.thinking = { type: 'adaptive' };
1090
971
  } else if (ir.thinking && ir.thinking.type !== 'disabled' && body.max_tokens > 1024) {
@@ -1206,6 +1087,10 @@ function irToVertexBody(ir, model) {
1206
1087
  gc.thinkingConfig.thinkingBudget = clampBudget(ir.thinking.budget ?? (ir.thinking.effort ? effortToBudget(ir.thinking.effort) : 2048));
1207
1088
  }
1208
1089
  }
1090
+ if (ir.responseFormat) {
1091
+ gc.responseMimeType = 'application/json';
1092
+ if (ir.responseFormat.type === 'json_schema') gc.responseSchema = toGeminiSchema(ir.responseFormat.schema);
1093
+ }
1209
1094
  if (Object.keys(gc).length) body.generationConfig = gc;
1210
1095
  return body;
1211
1096
  }
@@ -1255,6 +1140,8 @@ function toGeminiSchema(schema, root, seen) {
1255
1140
  return schema === 'object' ? { type: 'object', properties: {} } : { type: schema };
1256
1141
  }
1257
1142
  if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema;
1143
+ // The same hole as the string shorthand above: an empty schema has no type Vertex can read.
1144
+ if (Object.keys(schema).length === 0) return { type: 'object', properties: {} };
1258
1145
  root = root || schema;
1259
1146
  seen = seen || new Set();
1260
1147
  if (seen.has(schema)) return {};
@@ -2305,7 +2192,7 @@ export {
2305
2192
  smartReasoning, smartText, smartToolCalls, smartUsage, smartFinish,
2306
2193
  firstChoice, smartDelta, sanitizeJsonSchema, toGeminiSchema, splitParts,
2307
2194
  budgetToEffort, effortToBudget, clampBudget, parseArgs, stringifyArgs,
2308
- anthropicToIR, chatToIR, responsesToIR, vertexToIR, parseToIR,
2195
+ anthropicToIR, responsesToIR, parseToIR,
2309
2196
  healToolPairs, healAnthropicPayload, rememberToolSignature, lookupToolSignature, estimateTokens, irToChatBody, irToAnthropicBody, irToVertexBody, emitUpstreamBody,
2310
2197
  normalizeUpstream, createUpstreamNormalizer, createCollector,
2311
2198
  createThinkTagSplitter, splitThinkTags,
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/icons/groq.png ADDED
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7c6cf6"/><stop offset="1" stop-color="#2dd4bf"/></linearGradient></defs><rect width="32" height="32" rx="9" fill="url(#lg)"/><path d="M16 6.5 24 9.6v6.1c0 5-3.4 8.6-8 10-4.6-1.4-8-5-8-10V9.6Z" fill="none" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/><path d="m12.4 16.2 2.6 2.6 4.8-5" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
Binary file
Binary file
Binary file
package/icons/qwen.png ADDED
Binary file
Binary file
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
  }