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 +17 -0
- package/README.md +7 -3
- package/README.vi.md +3 -3
- package/blindfold/certs.mjs +156 -0
- package/blindfold/make-certs.sh +3 -1
- package/catalog.mjs +132 -42
- package/docs/codex-blindfold.md +6 -4
- package/mcp.mjs +2 -2
- package/package.json +1 -1
- package/proxy.mjs +32 -12
- package/scripts/run-tests.mjs +3 -1
- package/state.mjs +88 -6
- package/switch.mjs +34 -9
- package/tests/blindfold-e2e.test.mjs +2 -2
- package/tests/blindfold-task5.test.mjs +1 -1
- package/tests/blindfold.task3.test.mjs +2 -2
- package/tests/blindfold.wire.test.mjs +3 -1
- package/tests/catalog.test.mjs +117 -19
- package/tests/certs.test.mjs +86 -0
- package/tests/codex-daemon.test.mjs +92 -0
- package/tests/gateway.e2e.test.mjs +36 -0
- package/tests/lifecycle.test.mjs +90 -57
- package/tests/make-certs.test.mjs +22 -0
- package/tests/real-user-sim.test.mjs +3 -0
- package/tests/shim.test.mjs +8 -3
- package/tests/state.test.mjs +8 -3
- package/tests/switch.test.mjs +30 -24
- package/tests/ui.test.mjs +110 -0
- package/tests/version.test.mjs +78 -0
- package/ui.html +325 -82
- package/version.mjs +53 -0
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
|
|
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>
|
|
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
|
|
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>
|
|
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
|
+
}
|
package/blindfold/make-certs.sh
CHANGED
|
@@ -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, {
|
|
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:
|
|
153
|
-
fetchToolModels('codex', { apiKey:
|
|
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
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
const lastVersion = toolEntry.lastSeenVersion || '';
|
|
293
|
+
if (version === (cache[tool]?.lastSeenVersion || '')) return;
|
|
213
294
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
cache[tool] =
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
}
|
package/docs/codex-blindfold.md
CHANGED
|
@@ -72,12 +72,14 @@ You need:
|
|
|
72
72
|
|
|
73
73
|
## Procedure
|
|
74
74
|
|
|
75
|
-
### 1.
|
|
75
|
+
### 1. The certificates
|
|
76
76
|
|
|
77
|
-
|
|
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
|
|
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
|
|
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 {
|
|
146
|
+
const { syncLocalCatalog, refreshCatalog } = await import('./catalog.mjs');
|
|
147
147
|
if (args?.refresh) {
|
|
148
148
|
await refreshCatalog(STATE_DIR);
|
|
149
149
|
}
|
|
150
|
-
const cache =
|
|
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
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,
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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,
|
|
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
|
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -15,5 +15,7 @@ const files = [];
|
|
|
15
15
|
}
|
|
16
16
|
})(path.join(root, 'tests'));
|
|
17
17
|
|
|
18
|
-
|
|
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);
|