llm-switcher 1.2.2 → 1.2.4

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.2.4
4
+
5
+ - **Codex blindfold starts on a new machine:** A switch to a Codex profile stopped with "ca.pem is missing" on every new data directory. The certificates came only from `make-certs.sh`, which needs bash and OpenSSL. Now the switcher builds the same set with Node's crypto before it does any other check. It builds a new leaf when the leaf is missing, does not cover the three hosts, or does not match its key. It keeps a CA that still works, because Codex already trusts it.
6
+ - **1M context follows the real window:** The model list in the dashboard now reads the token limits that the gateway gives (`context_length`, `max_input_tokens`, `max_output_tokens`). When a model has a known window of less than 1M, the **1M context** box of its slot is cleared and locked. The reason shows under the field. When the gateway gives no window, the box stays free and shows a warning when it is ticked.
7
+ - **Tests:** A suite run no longer leaves an interceptor process running. Two tests that failed only on a loaded machine now pass: one waited for a lock that was already gone, and one lost its port to a test file that ran at the same time.
8
+
9
+ ## Release 1.2.3
10
+
11
+ - **Update notice:** The dashboard sidebar shows the running version. When npm has a newer release, a notice shows the update command with a copy button. The new command `switch version` prints the same information. The switcher asks the npm registry at most once in 12 hours. Without an answer, no notice shows.
12
+ - **Codex daemon follows the route:** The interactive Codex TUI talks to a shared `codex app-server` daemon, and that daemon keeps the environment that it started with. As a result, the TUI bypassed the gateway after a switch. Now each change of the Codex route restarts the running daemon with the new variables. When no daemon runs, nothing starts.
13
+ - **API keys stay with their host:** A catalog refresh sent the key of the active profile to the official Anthropic and OpenAI model lists. Now a key goes only to the host of its own profile.
14
+ - **Real model catalog:** The catalog reads the model list that Codex and Claude Code keep on disk for the signed-in account. A failed refresh no longer marks the tool version as done, so the next request tries again.
15
+ - **Dashboard saves:** Every write action reported a failure after it succeeded, because the page read `ok` and the server sends `success`. Enter in a field no longer saves a half-edited profile. A text selection that ends outside the dialog no longer closes it.
16
+ - **Model slots:** Each slot is a searchable list of the provider models. The list loads when you open the Model Slots tab and sends the saved key. A profile without `outFormat` keeps it empty after a save.
17
+ - **Other fixes:** `make-certs.sh` works when the path contains a dot. `switch doctor` warns again about a Codex profile without `publicModels`. Three dashboard controls have an accessible name.
18
+ - **Tests:** The tests use the 1.2 configuration schema, never read the certificates of the checkout, and never reach the real Codex daemon, npm registry, or `claude` binary.
19
+
3
20
  ## Release 1.2.2
4
21
 
5
22
  - **Structured output reaches every upstream:** A request that asks for JSON that matches a schema now keeps that schema. Before this release, the gateway did not read `output_config.format` (Claude Code) or `text.format` (Codex), so the provider got a free-text request. The schema now goes to the provider as `response_format` (OpenAI Chat), `output_config.format` (Anthropic), or `responseMimeType` with `responseSchema` (Gemini and Vertex). A request for JSON without a schema reaches Anthropic as plain text, because Anthropic has no JSON mode without a schema.
package/README.md CHANGED
@@ -120,7 +120,7 @@ LLM Switcher acts as a transparent man-in-the-middle without ever touching clien
120
120
 
121
121
  > #### 🔒 CA Security & Origin: Where does the CA come from and how safe is it?
122
122
  >
123
- > - **100% Locally Minted:** The CA certificate (`ca.pem`) and private key (`ca.key`) are generated entirely on your own machine using your local OpenSSL (`blindfold/make-certs.sh`). No keys are downloaded from the internet, and the private key is stored locally with strict `0600` permissions.
123
+ > - **100% Locally Minted:** The CA certificate (`ca.pem`) and private key (`ca.key`) are generated entirely on your own machine. The switcher builds them itself with Node's crypto the first time a tool is switched on, and again when the leaf is missing or out of date. It keeps a CA that still works, because Codex already trusts it. `blindfold/make-certs.sh` builds the same set with OpenSSL. No keys are downloaded from the internet, and the private key is stored locally with strict `0600` permissions.
124
124
  > - **Zero OS Trust Store Tampering:** Unlike tools like Charles or Fiddler, LLM Switcher **NEVER installs anything into your system or OS root certificate store** (no Windows Certificate Store, no macOS Keychain, no Linux `/etc/ssl/certs`). It requires **zero Administrator or sudo privileges**.
125
125
  > - **Process-Scoped Trust Only:** The certificate is loaded ephemerally into the memory of `claude` (via `NODE_EXTRA_CA_CERTS`) and `codex` (via `CODEX_CA_CERTIFICATE`). Your browsers, banking apps, git, and other terminal sessions never trust this CA.
126
126
  > - **Cryptographic Name Constraints:** The CA is minted with explicit X.509 `nameConstraints` strictly permitting only three domains: `api.anthropic.com`, `api.openai.com`, and `chatgpt.com`. Even if the local private key were compromised, standard TLS verifiers will reject it for any other domain (Google, GitHub, your bank).
@@ -279,9 +279,8 @@ base URL is overridden to http://127.0.0.1:3456/v1. Selecting models may not be
279
279
  Blindfold mode removes that line. Codex keeps its official endpoint, and the switcher intercepts the network hop instead. It needs no administrator rights, no certificate in a system trust store, and no change to `~/.codex/config.toml`.
280
280
 
281
281
  ```bash
282
- bash "$(npm root -g)/llm-switcher/blindfold/make-certs.sh" # once; a checkout runs blindfold/make-certs.sh
283
282
  # the port is already top-level in config.json: "blindfold": { "port": 3457 }
284
- switch codex <profile> # the gateway starts the interceptor
283
+ switch codex <profile> # builds the certificates when they are missing, then the gateway starts the interceptor
285
284
  ```
286
285
 
287
286
  One interceptor serves both tools. It routes by the host of the CONNECT request and by the path,
@@ -312,6 +311,10 @@ Read [📖 `docs/codex-blindfold.md`](docs/codex-blindfold.md) before you turn i
312
311
  or an auto-compact limit, and it writes no model name into your environment. Claude Code sizes
313
312
  its own session from the window of the model you pick. A backend whose window is smaller than
314
313
  that model can overflow in a long session. `model1M` now only decides what `/v1/models` reports.
314
+ The dashboard reads each model's window from the gateway's model list (`context_length` or
315
+ `max_input_tokens`, as intact and OpenRouter give them). When the window is known to be under
316
+ 1M, the slot's **1M context** box is cleared and locked. When the list gives no window, the box
317
+ stays free and shows a warning when it is ticked.
315
318
  - **Codex needs certificates once.** Blindfold mode is what keeps Codex on its official endpoint,
316
319
  and it needs a private CA plus a leaf naming the three hosts above. Skip it and Codex shows the
317
320
  `base URL is overridden` line on its `/model` screen instead.
@@ -401,6 +404,7 @@ Add the server to your MCP configuration (for example `opencode.jsonc`, `claude_
401
404
  ```bash
402
405
  switch ui # Open the Web UI dashboard in your browser
403
406
  switch status # Display status for all active CLI targets
407
+ switch version # Show the version and tell you when npm has a newer one
404
408
  switch doctor # Audit environment, settings & routing
405
409
  switch on [profile] # Start the gateway and activate a profile
406
410
  switch <profile> # Activate a profile for both tools
package/README.vi.md CHANGED
@@ -120,7 +120,7 @@ LLM Switcher hoạt động như một lớp trung gian mạng trong suốt (tra
120
120
 
121
121
  > #### 🔒 Độ An Toàn & Nguồn Gốc Chứng Chỉ CA: CA từ đâu ra và an toàn thế nào?
122
122
  >
123
- > - **Tự sinh 100% tại máy cục bộ:** File chứng chỉ (`ca.pem`) và private key (`ca.key`) được sinh trực tiếp trên chính máy tính của bạn bằng OpenSSL nội bộ (`blindfold/make-certs.sh`). Tuyệt đối không tải bất kỳ chứng chỉ nào từ internet về, private key được lưu với quyền bảo mật nghiêm ngặt `0600`.
123
+ > - **Tự sinh 100% tại máy cục bộ:** File chứng chỉ (`ca.pem`) và private key (`ca.key`) được sinh trực tiếp trên chính máy tính của bạn. Switcher tự tạo chúng bằng crypto của Node ở lần đầu bật một công cụ, và tạo lại khi leaf bị thiếu hoặc đã cũ. CA còn dùng được thì được giữ nguyên, vì Codex đã tin nó. `blindfold/make-certs.sh` tạo đúng bộ đó bằng OpenSSL. Tuyệt đối không tải bất kỳ chứng chỉ nào từ internet về, private key được lưu với quyền bảo mật nghiêm ngặt `0600`.
124
124
  > - **Không can thiệp vào System Trust Store của hệ điều hành:** Khác với các công cụ bắt proxy như Charles hay Fiddler, LLM Switcher **tuyệt đối KHÔNG cài đặt chứng chỉ vào OS Root Store** (không đụng vào Windows Certificate Manager, macOS Keychain hay Linux `/etc/ssl/certs`). Bạn **không cần quyền Administrator hay sudo**.
125
125
  > - **Chỉ tin cậy trong phạm vi tiến trình (Process-Scoped):** Chứng chỉ CA chỉ được nạp tạm thời vào bộ nhớ của `claude` (qua `NODE_EXTRA_CA_CERTS`) và `codex` (qua `CODEX_CA_CERTIFICATE`). Trình duyệt web (Chrome, Edge), ứng dụng ngân hàng, git và các app khác trên máy hoàn toàn không biết và không tin cậy chứng chỉ này.
126
126
  > - **Giới hạn tên miền bằng mật mã học (Name Constraints):** Chứng chỉ CA được cấu hình thuộc tính X.509 `nameConstraints` bắt buộc, chỉ cho phép ký duy nhất cho 3 domain: `api.anthropic.com`, `api.openai.com`, và `chatgpt.com`. Dù có ai đánh cắp được private key, các trình xác thực TLS chuẩn sẽ lập tức từ chối chứng chỉ này đối với mọi trang web khác (Google, GitHub, ngân hàng...).
@@ -281,9 +281,8 @@ base URL is overridden to http://127.0.0.1:3456/v1. Selecting models may not be
281
281
  Blindfold xóa dòng đó. Codex giữ nguyên endpoint chính thức, switcher chặn ở tầng mạng. Không cần quyền admin, không cài chứng chỉ vào system trust store, không sửa `~/.codex/config.toml`.
282
282
 
283
283
  ```bash
284
- bash "$(npm root -g)/llm-switcher/blindfold/make-certs.sh" # chạy một lần; bản checkout chạy blindfold/make-certs.sh
285
284
  # cổng đã nằm sẵn ở cấp cao nhất của config.json: "blindfold": { "port": 3457 }
286
- switch codex <profile> # gateway khởi động interceptor
285
+ switch codex <profile> # tự tạo chứng chỉ khi còn thiếu, rồi gateway khởi động interceptor
287
286
  ```
288
287
 
289
288
  Một interceptor phục vụ cả hai công cụ. Nó định tuyến theo host của request CONNECT và theo path,
@@ -402,6 +401,7 @@ Thêm server vào cấu hình MCP (ví dụ `opencode.jsonc`, `claude_desktop_co
402
401
  ```bash
403
402
  switch ui # Mở giao diện Web UI trên trình duyệt
404
403
  switch status # Xem trạng thái kích hoạt của tất cả các CLI
404
+ switch version # Xem version đang chạy và báo khi npm có bản mới
405
405
  switch doctor # Quét & thanh tra toàn bộ môi trường, settings và định tuyến
406
406
  switch on [profile] # Khởi động gateway và kích hoạt một profile
407
407
  switch <profile> # Kích hoạt một profile cho cả hai công cụ
@@ -0,0 +1,156 @@
1
+ // Builds the interceptor's private CA and leaf in Node alone, the same certificates as
2
+ // make-certs.sh but with no bash and no openssl, so the gateway can build a missing set itself.
3
+ // Node reads X.509 but cannot write it, so the certificates are encoded here as DER.
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ const CA_DAYS = 3650;
9
+ const LEAF_DAYS = 825;
10
+
11
+ // ---- DER
12
+ function len(n) {
13
+ if (n < 0x80) return Buffer.from([n]);
14
+ const bytes = [];
15
+ for (let v = n; v > 0; v >>= 8) bytes.unshift(v & 0xff);
16
+ return Buffer.from([0x80 | bytes.length, ...bytes]);
17
+ }
18
+ const tlv = (tag, ...parts) => { const body = Buffer.concat(parts); return Buffer.concat([Buffer.from([tag]), len(body.length), body]); };
19
+ const seq = (...parts) => tlv(0x30, ...parts);
20
+ const set = (...parts) => tlv(0x31, ...parts);
21
+ const bool = (v) => tlv(0x01, Buffer.from([v ? 0xff : 0]));
22
+ const octets = (buf) => tlv(0x04, buf);
23
+ const utf8 = (s) => tlv(0x0c, Buffer.from(s, 'utf8'));
24
+ const bits = (buf, unused = 0) => tlv(0x03, Buffer.from([unused]), buf);
25
+ const dnsName = (host) => tlv(0x82, Buffer.from(host, 'ascii')); // GeneralName [2] IA5String
26
+ function int(buf) {
27
+ let b = Buffer.from(buf);
28
+ while (b.length > 1 && b[0] === 0 && !(b[1] & 0x80)) b = b.subarray(1);
29
+ if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b]);
30
+ return tlv(0x02, b);
31
+ }
32
+ function oid(dotted) {
33
+ const [a, b, ...rest] = dotted.split('.').map(Number);
34
+ const out = [40 * a + b];
35
+ for (const n of rest) {
36
+ const chunk = [n & 0x7f];
37
+ for (let v = n >> 7; v > 0; v >>= 7) chunk.unshift(0x80 | (v & 0x7f));
38
+ out.push(...chunk);
39
+ }
40
+ return tlv(0x06, Buffer.from(out));
41
+ }
42
+ function time(d) {
43
+ const p = (n) => String(n).padStart(2, '0');
44
+ const y = d.getUTCFullYear();
45
+ const s = `${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
46
+ // UTCTime until 2049, GeneralizedTime after, as RFC 5280 requires.
47
+ return y < 2050 ? tlv(0x17, Buffer.from(String(y).slice(2) + s)) : tlv(0x18, Buffer.from(String(y) + s));
48
+ }
49
+
50
+ const ECDSA_SHA256 = seq(oid('1.2.840.10045.4.3.2'));
51
+ const name = (cn) => seq(set(seq(oid('2.5.4.3'), utf8(cn))));
52
+ const ext = (id, critical, value) => seq(oid(id), ...(critical ? [bool(true)] : []), octets(value));
53
+
54
+ function keyId(publicKey) {
55
+ // The key identifier is the SHA-1 of the subjectPublicKey bits (RFC 5280, method 1).
56
+ const spki = publicKey.export({ type: 'spki', format: 'der' });
57
+ return crypto.createHash('sha1').update(spki.subarray(spki.length - 65)).digest();
58
+ }
59
+
60
+ function certificate({ subject, issuer, publicKey, signingKey, days, extensions }) {
61
+ const notBefore = new Date(Date.now() - 60 * 60 * 1000); // an hour back, for a skewed clock
62
+ const notAfter = new Date(notBefore.getTime() + days * 86400000);
63
+ const serial = crypto.randomBytes(16);
64
+ serial[0] &= 0x7f;
65
+ const tbs = seq(
66
+ tlv(0xa0, int([2])), // v3
67
+ int(serial),
68
+ ECDSA_SHA256,
69
+ name(issuer),
70
+ seq(time(notBefore), time(notAfter)),
71
+ name(subject),
72
+ publicKey.export({ type: 'spki', format: 'der' }),
73
+ tlv(0xa3, seq(...extensions))
74
+ );
75
+ const sig = crypto.sign('sha256', tbs, signingKey);
76
+ const der = seq(tbs, ECDSA_SHA256, bits(sig));
77
+ return `-----BEGIN CERTIFICATE-----\n${der.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END CERTIFICATE-----\n`;
78
+ }
79
+
80
+ const newKey = () => crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
81
+ const pemKey = (k) => k.export({ type: 'sec1', format: 'pem' });
82
+
83
+ /** A leaf for `hosts`, signed by the CA in `dir`. Returns the PEM strings; writes nothing. */
84
+ export function signLeaf(dir, hosts) {
85
+ const caKey = crypto.createPrivateKey(fs.readFileSync(path.join(dir, 'ca.key')));
86
+ const caCert = new crypto.X509Certificate(fs.readFileSync(path.join(dir, 'ca.pem')));
87
+ const { publicKey, privateKey } = newKey();
88
+ const cert = certificate({
89
+ subject: 'llm-switcher',
90
+ issuer: 'LLM Switcher Local CA',
91
+ publicKey,
92
+ signingKey: caKey,
93
+ days: LEAF_DAYS,
94
+ extensions: [
95
+ ext('2.5.29.19', true, seq()), // basicConstraints CA:FALSE
96
+ ext('2.5.29.15', true, bits(Buffer.from([0xa0]), 5)), // digitalSignature, keyEncipherment
97
+ ext('2.5.29.37', false, seq(oid('1.3.6.1.5.5.7.3.1'))), // extendedKeyUsage serverAuth
98
+ ext('2.5.29.17', false, seq(...hosts.map(dnsName))), // subjectAltName
99
+ ext('2.5.29.14', false, octets(keyId(publicKey))),
100
+ ext('2.5.29.35', false, seq(tlv(0x80, keyId(caCert.publicKey))))
101
+ ]
102
+ });
103
+ return { cert, key: pemKey(privateKey) };
104
+ }
105
+
106
+ function writePrivate(dir, files) {
107
+ // Write each file beside its target and rename it into place: a failed run keeps the last set,
108
+ // and a rename replaces a planted symlink instead of writing through it.
109
+ const staged = [];
110
+ for (const [f, body] of Object.entries(files)) {
111
+ const tmpFile = path.join(dir, `.${f}.${process.pid}.tmp`);
112
+ fs.writeFileSync(tmpFile, body, { mode: 0o600, flag: 'wx' });
113
+ staged.push([tmpFile, path.join(dir, f)]);
114
+ }
115
+ for (const [from, to] of staged) fs.renameSync(from, to);
116
+ }
117
+
118
+ function privateDir(dir) {
119
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
120
+ if (process.platform !== 'win32') {
121
+ const st = fs.lstatSync(dir);
122
+ if (!st.isDirectory() || st.uid !== process.getuid()) {
123
+ throw new Error(`${dir} is not a directory you own; the CA key cannot go there`);
124
+ }
125
+ fs.chmodSync(dir, 0o700);
126
+ }
127
+ }
128
+
129
+ /** A new CA and leaf in `dir`, the set make-certs.sh builds. */
130
+ export function buildCerts(dir, hosts) {
131
+ privateDir(dir);
132
+ const { publicKey, privateKey } = newKey();
133
+ const caPem = certificate({
134
+ subject: 'LLM Switcher Local CA',
135
+ issuer: 'LLM Switcher Local CA',
136
+ publicKey,
137
+ signingKey: privateKey,
138
+ days: CA_DAYS,
139
+ extensions: [
140
+ ext('2.5.29.19', true, seq(bool(true), int([0]))), // CA:TRUE, pathlen:0
141
+ ext('2.5.29.15', true, bits(Buffer.from([0x06]), 1)), // keyCertSign, cRLSign
142
+ ext('2.5.29.14', false, octets(keyId(publicKey))),
143
+ // A leaked ca.key can then sign only for the host table.
144
+ ext('2.5.29.30', true, seq(tlv(0xa0, ...hosts.map(h => seq(dnsName(h))))))
145
+ ]
146
+ });
147
+ writePrivate(dir, { 'ca.key': pemKey(privateKey), 'ca.pem': caPem });
148
+ writeLeaf(dir, hosts);
149
+ }
150
+
151
+ /** A new leaf in `dir` on the CA already there. */
152
+ export function writeLeaf(dir, hosts) {
153
+ privateDir(dir);
154
+ const { cert, key } = signLeaf(dir, hosts);
155
+ writePrivate(dir, { 'leaf.key': key, 'leaf.pem': cert });
156
+ }
@@ -103,8 +103,10 @@ openssl req -x509 -new -key "$WORK/ca.key" -sha256 -days "$CA_DAYS" \
103
103
 
104
104
  openssl ecparam -name prime256v1 -genkey -noout -out "$WORK/leaf.key"
105
105
  openssl req -new -key "$WORK/leaf.key" -config "$WORK/leaf.cnf" -out "$WORK/leaf.csr"
106
+ # An explicit -CAserial: LibreSSL derives the default name by cutting the CA path at its first dot,
107
+ # so a home directory such as /Users/first.last sent the serial file to /Users/first.srl.
106
108
  openssl x509 -req -in "$WORK/leaf.csr" \
107
- -CA "$WORK/ca.pem" -CAkey "$WORK/ca.key" -CAcreateserial \
109
+ -CA "$WORK/ca.pem" -CAkey "$WORK/ca.key" -CAcreateserial -CAserial "$WORK/ca.srl" \
108
110
  -days "$LEAF_DAYS" -sha256 -extfile "$WORK/leaf.ext" \
109
111
  -out "$WORK/leaf.pem"
110
112
 
package/catalog.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  // ============================================================
8
8
 
9
9
  import fs from 'node:fs';
10
+ import os from 'node:os';
10
11
  import path from 'node:path';
11
12
 
12
13
  export const OFFICIAL_MODEL_URLS = {
@@ -69,6 +70,70 @@ export function loadCatalogCache(stateDir) {
69
70
  };
70
71
  }
71
72
 
73
+ function defaultSources() {
74
+ return {
75
+ codexHome: process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
76
+ claudeDir: process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Reads the model list that the tool itself keeps on disk for the signed-in account, so no key and
82
+ * no network call is needed. Codex writes models_cache.json; Claude Code writes cache/model-catalog.
83
+ * Returns null when the tool has no list.
84
+ */
85
+ export function readLocalToolModels(tool, sources = defaultSources()) {
86
+ try {
87
+ if (tool === 'codex') {
88
+ const d = JSON.parse(fs.readFileSync(path.join(sources.codexHome, 'models_cache.json'), 'utf8'));
89
+ const models = (Array.isArray(d?.models) ? d.models : [])
90
+ .filter(m => m && typeof m.slug === 'string' && m.slug)
91
+ .map(m => ({ id: m.slug, role: classifyCodexRole(m.slug), ...(Number.isInteger(m.context_window) ? { contextWindow: m.context_window } : {}) }));
92
+ return models.length ? { models, version: String(d.client_version || '') } : null;
93
+ }
94
+ if (tool === 'claude') {
95
+ // One file per account or surface; the newest fetch wins.
96
+ const dir = path.join(sources.claudeDir, 'cache', 'model-catalog');
97
+ let newest = null;
98
+ for (const f of fs.readdirSync(dir)) {
99
+ if (!f.endsWith('.json')) continue;
100
+ try {
101
+ const d = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
102
+ const list = d?.catalog?.config?.models;
103
+ if (Array.isArray(list) && list.length && (!newest || (d.fetchedAt || 0) > newest.fetchedAt)) newest = { fetchedAt: d.fetchedAt || 0, list };
104
+ } catch {}
105
+ }
106
+ const models = (newest?.list || [])
107
+ .filter(m => m && typeof m.id === 'string' && m.id)
108
+ .map(m => ({ id: m.id, display_name: m.name || m.id, tier: classifyClaudeTier(m.id) }));
109
+ return models.length ? { models, version: '' } : null;
110
+ }
111
+ } catch {}
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Copies the tools' own lists into the catalog when they changed. A tool can update and rewrite its
117
+ * list without any request through the gateway, so readers of the catalog call this first.
118
+ */
119
+ export function syncLocalCatalog(stateDir, sources = defaultSources()) {
120
+ const cache = loadCatalogCache(stateDir);
121
+ let changed = false;
122
+ for (const tool of ['claude', 'codex']) {
123
+ const local = readLocalToolModels(tool, sources);
124
+ if (!local) continue;
125
+ const ids = (cache[tool]?.models || []).map(m => m.id).join('\n');
126
+ if (ids === local.models.map(m => m.id).join('\n') && cache[tool]?.source === 'local') continue;
127
+ cache[tool] = { ...cache[tool], models: local.models, source: 'local' };
128
+ changed = true;
129
+ }
130
+ if (changed) {
131
+ cache.updatedAt = Date.now();
132
+ saveCatalogCache(stateDir, cache);
133
+ }
134
+ return cache;
135
+ }
136
+
72
137
  /** Atomically writes the model catalog cache to disk */
73
138
  export function saveCatalogCache(stateDir, catalog) {
74
139
  const p = catalogCachePath(stateDir);
@@ -141,19 +206,33 @@ export async function fetchToolModels(tool, { url, apiKey, timeout = 3000 } = {}
141
206
  }
142
207
  }
143
208
 
209
+ // A profile key is a credential for that profile's baseURL. Send it to the official model list only
210
+ // when the profile itself points at the official host, never to a third party.
211
+ function officialKey(profile, tool) {
212
+ try {
213
+ if (profile?.apiKey && new URL(profile.baseURL).host === new URL(OFFICIAL_MODEL_URLS[tool]).host) return profile.apiKey;
214
+ } catch {}
215
+ return undefined;
216
+ }
217
+
144
218
  /**
145
219
  * Refreshes the local catalog cache with models from both official endpoints
146
220
  * and saves to the state directory.
147
221
  */
148
- export async function refreshCatalog(stateDir, { claudeKey, codexKey } = {}) {
222
+ export async function refreshCatalog(stateDir, { claudeProfile, codexProfile, sources = defaultSources() } = {}) {
149
223
  const cache = loadCatalogCache(stateDir);
224
+ const local = { claude: readLocalToolModels('claude', sources), codex: readLocalToolModels('codex', sources) };
150
225
 
226
+ // A local list is the list of the account itself; the built-in names are guesses and are not added.
151
227
  const [claudeRes, codexRes] = await Promise.all([
152
- fetchToolModels('claude', { apiKey: claudeKey }),
153
- fetchToolModels('codex', { apiKey: codexKey })
228
+ local.claude || fetchToolModels('claude', { apiKey: officialKey(claudeProfile, 'claude') }),
229
+ local.codex || fetchToolModels('codex', { apiKey: officialKey(codexProfile, 'codex') })
154
230
  ]);
231
+ for (const tool of ['claude', 'codex']) {
232
+ if (local[tool]) cache[tool] = { ...cache[tool], models: local[tool].models, source: 'local' };
233
+ }
155
234
 
156
- if (claudeRes.ok && claudeRes.models.length > 0) {
235
+ if (!local.claude && claudeRes.ok && claudeRes.models.length > 0) {
157
236
  const existing = new Set(claudeRes.models.map(m => m.id));
158
237
  // Keep any baseline models that might be absent from the API
159
238
  for (const [tier, ids] of Object.entries(BASELINE_MODELS.claude)) {
@@ -161,17 +240,17 @@ export async function refreshCatalog(stateDir, { claudeKey, codexKey } = {}) {
161
240
  if (!existing.has(id)) claudeRes.models.push({ id, tier });
162
241
  }
163
242
  }
164
- cache.claude = { models: claudeRes.models };
243
+ cache.claude = { ...cache.claude, models: claudeRes.models, source: 'official' };
165
244
  }
166
245
 
167
- if (codexRes.ok && codexRes.models.length > 0) {
246
+ if (!local.codex && codexRes.ok && codexRes.models.length > 0) {
168
247
  const existing = new Set(codexRes.models.map(m => m.id));
169
248
  for (const [role, ids] of Object.entries(BASELINE_MODELS.codex)) {
170
249
  for (const id of ids) {
171
250
  if (!existing.has(id)) codexRes.models.push({ id, role });
172
251
  }
173
252
  }
174
- cache.codex = { models: codexRes.models };
253
+ cache.codex = { ...cache.codex, models: codexRes.models, source: 'official' };
175
254
  }
176
255
 
177
256
  cache.updatedAt = Date.now();
@@ -199,48 +278,59 @@ const refreshingTools = new Set();
199
278
 
200
279
  /**
201
280
  * Version-triggered auto-poll:
202
- * When a request arrives with a new tool version not yet seen in cache,
203
- * immediately records the new version and kicks off an asynchronous background refresh.
204
- * Subsequent requests with the same version do zero network calls.
281
+ * When a request arrives with a tool version that has no refreshed catalog yet, the catalog is
282
+ * refreshed from the tool's own list on disk, or else from the official list in the background.
283
+ * The version is recorded only after a refresh succeeds, so a failed refresh is tried again.
205
284
  */
206
- export function checkVersionAndRefresh(tool, headers, stateDir, apiKey) {
285
+ const RETRY_AFTER_MS = 10 * 60 * 1000;
286
+ const failedAttempts = new Map();
287
+
288
+ export function checkVersionAndRefresh(tool, headers, stateDir, apiKey, sources = defaultSources()) {
207
289
  const version = detectToolVersion(headers, tool);
208
290
  if (!version) return;
209
291
 
210
292
  const cache = loadCatalogCache(stateDir);
211
- const toolEntry = cache[tool] || {};
212
- const lastVersion = toolEntry.lastSeenVersion || '';
293
+ if (version === (cache[tool]?.lastSeenVersion || '')) return;
213
294
 
214
- if (version !== lastVersion) {
215
- toolEntry.lastSeenVersion = version;
216
- cache[tool] = toolEntry;
295
+ const local = readLocalToolModels(tool, sources);
296
+ if (local) {
297
+ cache[tool] = { ...cache[tool], lastSeenVersion: version, models: local.models, source: 'local' };
298
+ cache.updatedAt = Date.now();
217
299
  saveCatalogCache(stateDir, cache);
300
+ console.log(`[llm-switcher:catalog] Detected ${tool} ${version} -> model catalog read from the tool (${local.models.length} models)`);
301
+ return;
302
+ }
303
+
304
+ // Without a local list every request would call the network again; wait between failed attempts.
305
+ const failed = failedAttempts.get(tool);
306
+ if (failed && failed.version === version && Date.now() - failed.at < RETRY_AFTER_MS) return;
307
+ if (refreshingTools.has(tool)) return;
218
308
 
219
- if (!refreshingTools.has(tool)) {
220
- refreshingTools.add(tool);
221
- fetchToolModels(tool, { apiKey })
222
- .then(res => {
223
- if (res.ok && res.models.length > 0) {
224
- const fresh = loadCatalogCache(stateDir);
225
- const existing = new Set(res.models.map(m => m.id));
226
- const baseline = BASELINE_MODELS[tool] || {};
227
- for (const [, ids] of Object.entries(baseline)) {
228
- for (const id of ids) {
229
- if (!existing.has(id)) {
230
- res.models.push({ id, ...(tool === 'claude' ? { tier: classifyClaudeTier(id) } : { role: classifyCodexRole(id) }) });
231
- }
232
- }
233
- }
234
- fresh[tool] = { lastSeenVersion: version, models: res.models };
235
- fresh.updatedAt = Date.now();
236
- saveCatalogCache(stateDir, fresh);
237
- console.log(`[llm-switcher:catalog] Detected ${tool} version update to ${version} -> refreshed model catalog (${res.models.length} models)`);
309
+ refreshingTools.add(tool);
310
+ fetchToolModels(tool, { apiKey })
311
+ .then(res => {
312
+ if (!res.ok || res.models.length === 0) {
313
+ failedAttempts.set(tool, { version, at: Date.now() });
314
+ console.log(`[llm-switcher:catalog] ${tool} ${version}: no local model list, official list failed (${res.error || 'empty'})`);
315
+ return;
316
+ }
317
+ const fresh = loadCatalogCache(stateDir);
318
+ const existing = new Set(res.models.map(m => m.id));
319
+ const baseline = BASELINE_MODELS[tool] || {};
320
+ for (const [, ids] of Object.entries(baseline)) {
321
+ for (const id of ids) {
322
+ if (!existing.has(id)) {
323
+ res.models.push({ id, ...(tool === 'claude' ? { tier: classifyClaudeTier(id) } : { role: classifyCodexRole(id) }) });
238
324
  }
239
- })
240
- .catch(() => {})
241
- .finally(() => {
242
- refreshingTools.delete(tool);
243
- });
244
- }
245
- }
325
+ }
326
+ }
327
+ fresh[tool] = { ...fresh[tool], lastSeenVersion: version, models: res.models, source: 'official' };
328
+ fresh.updatedAt = Date.now();
329
+ saveCatalogCache(stateDir, fresh);
330
+ console.log(`[llm-switcher:catalog] Detected ${tool} version update to ${version} -> refreshed model catalog (${res.models.length} models)`);
331
+ })
332
+ .catch(() => {})
333
+ .finally(() => {
334
+ refreshingTools.delete(tool);
335
+ });
246
336
  }
@@ -72,12 +72,14 @@ You need:
72
72
 
73
73
  ## Procedure
74
74
 
75
- ### 1. Build the certificates
75
+ ### 1. The certificates
76
76
 
77
- Run this command in the repository root:
77
+ You do not have to build them. When a tool is switched on and the certificates are missing, the switcher builds them with Node's crypto. It builds a new leaf when the leaf is missing, does not cover the three hosts, or does not match its key. It keeps a CA that still works, because Codex already trusts that CA. The set is the one that `make-certs.sh` builds: an EC P-256 CA with name constraints, and a leaf with `serverAuth` for `api.anthropic.com`, `api.openai.com` and `chatgpt.com`.
78
+
79
+ To build the set yourself with OpenSSL, run this command in the repository root:
78
80
 
79
81
  ```bash
80
- bash blindfold/make-certs.sh chatgpt.com
82
+ bash blindfold/make-certs.sh
81
83
  ```
82
84
 
83
85
  The script writes `blindfold/certs/ca.pem` and `blindfold/certs/leaf.pem`. It prints the extended key usage and the subject alternative name of the leaf. Make sure that the output contains `TLS Web Server Authentication`.
@@ -212,7 +214,7 @@ The switcher writes `LLM_SWITCHER_CODEX_BASE_URL` again, the gateway stops the i
212
214
 
213
215
  Read this section before you turn blindfold mode on.
214
216
 
215
- **The CA is trusted for every host that Codex contacts.** `CODEX_CA_CERTIFICATE` adds this authority to the trust store that Codex uses for all of its HTTPS calls. A CA that `make-certs.sh` builds now carries name constraints: it can sign only for `api.anthropic.com`, `api.openai.com` and `chatgpt.com`. A client that obeys name constraints refuses any other certificate from this CA. OpenSSL, rustls and the macOS and Windows verifiers obey them. A CA that an older version built has no constraint. Anybody who can read its `ca.key` can forge a certificate for any host. Run `make-certs.sh` again to replace it, and keep `blindfold/certs/` private in both cases.
217
+ **The CA is trusted for every host that Codex contacts.** `CODEX_CA_CERTIFICATE` adds this authority to the trust store that Codex uses for all of its HTTPS calls. A CA that the switcher or `make-certs.sh` builds carries name constraints: it can sign only for `api.anthropic.com`, `api.openai.com` and `chatgpt.com`. A client that obeys name constraints refuses any other certificate from this CA. OpenSSL, rustls and the macOS and Windows verifiers obey them. A CA that an older version built has no constraint. Anybody who can read its `ca.key` can forge a certificate for any host. Run `make-certs.sh` again to replace it, and keep `blindfold/certs/` private in both cases.
216
218
 
217
219
  **All traffic to the intercepted host is decrypted by this process.** That includes sign-in and token refresh, because the CONNECT for the whole host is terminated locally. Requests outside the Codex API path are re-originated to the real host over a new TLS session; they are forwarded, not tunneled. The `--verbose` flag prints the method and path of every such request.
218
220
 
package/mcp.mjs CHANGED
@@ -143,11 +143,11 @@ async function handleToolCall(name, args) {
143
143
  const port = getMcpPort();
144
144
 
145
145
  if (name === 'switcher_models') {
146
- const { loadCatalogCache, refreshCatalog } = await import('./catalog.mjs');
146
+ const { syncLocalCatalog, refreshCatalog } = await import('./catalog.mjs');
147
147
  if (args?.refresh) {
148
148
  await refreshCatalog(STATE_DIR);
149
149
  }
150
- const cache = loadCatalogCache(STATE_DIR);
150
+ const cache = syncLocalCatalog(STATE_DIR);
151
151
  const t = args?.tool?.toLowerCase();
152
152
  const res = {};
153
153
  if (!t || t === 'claude') res.claude = cache.claude;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
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
@@ -23,7 +23,8 @@ import {
23
23
  codexModelEntry, smallestWindows, publicModelWindows, model1MForSlot,
24
24
  contractLabSettings, STATE_DIR
25
25
  } from './state.mjs';
26
- import { classifyCodexRole, classifyClaudeTier, loadCatalogCache, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
26
+ import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
27
+ import { checkForUpdate } from './version.mjs';
27
28
  import { createContractLab, createHalfTap, tapClientWrites, capText, capJson, toolVersionFromUA, finishHalf, PROBE_HEADER, TRACE_ID_RE } from './contract.mjs';
28
29
 
29
30
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -1077,12 +1078,30 @@ async function fetchModels(body, cfg) {
1077
1078
  const r = await fetch(`${baseURL}/models`, { headers, signal: upstreamTimeout(15000) });
1078
1079
  if (!r.ok) return { status: 200, json: { ok: false, status: r.status, error: (await r.text()).slice(0, 2000) } };
1079
1080
  const data = await r.json();
1080
- let list = [];
1081
- if (Array.isArray(data.data)) list = data.data.map(m => (typeof m === 'string' ? m : m.id));
1082
- else if (Array.isArray(data)) list = data.map(m => (typeof m === 'string' ? m : m.id));
1083
- else if (Array.isArray(data.models)) list = data.models.map(m => (typeof m === 'string' ? m : (m.id || m.name)));
1084
- list = [...new Set(list.filter(Boolean).map(id => String(id).replace(/^models\//, '')))].sort();
1085
- return { status: 200, json: { ok: true, models: list } };
1081
+ const entries = Array.isArray(data.data) ? data.data : Array.isArray(data) ? data : Array.isArray(data.models) ? data.models : [];
1082
+ const idOf = m => String(typeof m === 'string' ? m : (m?.id || m?.name || '')).replace(/^models\//, '');
1083
+ const list = [...new Set(entries.map(idOf).filter(Boolean))].sort();
1084
+ const limits = {};
1085
+ for (const m of entries) {
1086
+ const l = modelLimits(m);
1087
+ if (l) limits[idOf(m)] = l;
1088
+ }
1089
+ return { status: 200, json: { ok: true, models: list, limits } };
1090
+ }
1091
+
1092
+ // A listed model's token limits, as intact and OpenRouter write them. Null when the list gives none.
1093
+ function modelLimits(m) {
1094
+ if (!m || typeof m !== 'object') return null;
1095
+ const n = v => (Number.isFinite(v) && v > 0 ? v : 0);
1096
+ const top = m.top_provider && typeof m.top_provider === 'object' ? m.top_provider : {};
1097
+ const out = {};
1098
+ const context = n(m.context_length) || n(top.context_length) || n(m.context_window);
1099
+ const input = n(m.max_input_tokens);
1100
+ const output = n(m.max_output_tokens) || n(top.max_completion_tokens);
1101
+ if (context) out.context = context;
1102
+ if (input) out.input = input;
1103
+ if (output) out.output = output;
1104
+ return Object.keys(out).length ? out : null;
1086
1105
  }
1087
1106
 
1088
1107
  // Vertex/Gemini: /v1beta/models/{m}:{action} (Gemini API) and
@@ -1287,9 +1306,13 @@ async function routeApi(req, res, method, pathname) {
1287
1306
  return sendJson(res, 200, { logs: requestLogs.slice().reverse() });
1288
1307
  }
1289
1308
 
1309
+ if (method === 'GET' && pathname === '/api/version') {
1310
+ return sendJson(res, 200, await checkForUpdate({ stateDir: STATE_DIR }));
1311
+ }
1312
+
1290
1313
  // GET /api/catalog (Dynamic Model Discovery)
1291
1314
  if (method === 'GET' && pathname === '/api/catalog') {
1292
- return sendJson(res, 200, loadCatalogCache(STATE_DIR));
1315
+ return sendJson(res, 200, syncLocalCatalog(STATE_DIR));
1293
1316
  }
1294
1317
 
1295
1318
  if (method !== 'POST') {
@@ -1310,10 +1333,7 @@ async function routeApi(req, res, method, pathname) {
1310
1333
  const cfg = requireConfig(res) || {};
1311
1334
  const claudeProfile = active.claude ? cfg.profiles?.[active.claude] : null;
1312
1335
  const codexProfile = active.codex ? cfg.profiles?.[active.codex] : null;
1313
- const updated = await refreshCatalog(STATE_DIR, {
1314
- claudeKey: claudeProfile?.apiKey,
1315
- codexKey: codexProfile?.apiKey
1316
- });
1336
+ const updated = await refreshCatalog(STATE_DIR, { claudeProfile, codexProfile });
1317
1337
  return sendJson(res, 200, { success: true, catalog: updated });
1318
1338
  }
1319
1339
 
@@ -15,5 +15,7 @@ const files = [];
15
15
  }
16
16
  })(path.join(root, 'tests'));
17
17
 
18
- const r = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], { cwd: root, stdio: 'inherit' });
18
+ // A version check must never reach the real npm registry.
19
+ const env = { ...process.env, LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' };
20
+ const r = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], { cwd: root, stdio: 'inherit', env });
19
21
  process.exit(r.status ?? 1);