llm-switcher 1.2.3 → 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 +6 -0
- package/README.md +6 -3
- package/README.vi.md +3 -3
- package/blindfold/certs.mjs +156 -0
- package/docs/codex-blindfold.md +6 -4
- package/package.json +1 -1
- package/proxy.mjs +24 -6
- package/state.mjs +30 -2
- package/switch.mjs +6 -5
- package/tests/certs.test.mjs +86 -0
- package/tests/gateway.e2e.test.mjs +36 -0
- package/tests/lifecycle.test.mjs +47 -26
- package/tests/state.test.mjs +8 -3
- package/tests/ui.test.mjs +20 -0
- package/ui.html +44 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## Release 1.2.3
|
|
4
10
|
|
|
5
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.
|
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.
|
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/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/package.json
CHANGED
package/proxy.mjs
CHANGED
|
@@ -1078,12 +1078,30 @@ async function fetchModels(body, cfg) {
|
|
|
1078
1078
|
const r = await fetch(`${baseURL}/models`, { headers, signal: upstreamTimeout(15000) });
|
|
1079
1079
|
if (!r.ok) return { status: 200, json: { ok: false, status: r.status, error: (await r.text()).slice(0, 2000) } };
|
|
1080
1080
|
const data = await r.json();
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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;
|
|
1087
1105
|
}
|
|
1088
1106
|
|
|
1089
1107
|
// Vertex/Gemini: /v1beta/models/{m}:{action} (Gemini API) and
|
package/state.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import crypto from 'node:crypto';
|
|
|
12
12
|
import http from 'node:http';
|
|
13
13
|
import { spawn, execFileSync } from 'node:child_process';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { buildCerts, writeLeaf } from './blindfold/certs.mjs';
|
|
15
16
|
|
|
16
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
|
|
@@ -1743,6 +1744,32 @@ export async function stopRecordedBlindfold() {
|
|
|
1743
1744
|
return { ok: stopped, ...(stopped ? {} : { error: `the interceptor on port ${prev.port} did not stop` }) };
|
|
1744
1745
|
}
|
|
1745
1746
|
|
|
1747
|
+
// A CA that still works is kept when the leaf is rebuilt: Codex already trusts it.
|
|
1748
|
+
function caUsable(dir) {
|
|
1749
|
+
try {
|
|
1750
|
+
const ca = new crypto.X509Certificate(fs.readFileSync(path.join(dir, 'ca.pem')));
|
|
1751
|
+
return ca.ca && Date.parse(ca.validTo) > Date.now() + 86400000
|
|
1752
|
+
&& ca.checkPrivateKey(crypto.createPrivateKey(fs.readFileSync(path.join(dir, 'ca.key'))));
|
|
1753
|
+
} catch {
|
|
1754
|
+
return false;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/** Builds the certificates when they are missing or unusable, then returns what the preflight
|
|
1759
|
+
* says of them: null when the interceptor can start. */
|
|
1760
|
+
export function ensureBlindfoldCerts(desired) {
|
|
1761
|
+
const problem = blindfoldPreflight(desired);
|
|
1762
|
+
if (!problem) return null;
|
|
1763
|
+
const dir = path.dirname(desired.ca);
|
|
1764
|
+
try {
|
|
1765
|
+
if (caUsable(dir)) writeLeaf(dir, INTERCEPT_HOSTS);
|
|
1766
|
+
else buildCerts(dir, INTERCEPT_HOSTS);
|
|
1767
|
+
} catch (err) {
|
|
1768
|
+
return `${problem} Building them failed: ${err.message}`;
|
|
1769
|
+
}
|
|
1770
|
+
return blindfoldPreflight(desired);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1746
1773
|
/** null when the interceptor can start, otherwise the reason and the command that fixes it. */
|
|
1747
1774
|
export function blindfoldPreflight(desired) {
|
|
1748
1775
|
const certDir = path.dirname(desired.ca);
|
|
@@ -1810,11 +1837,12 @@ function spawnBlindfold(desired, gatewayPort) {
|
|
|
1810
1837
|
return child.pid;
|
|
1811
1838
|
}
|
|
1812
1839
|
|
|
1813
|
-
/** null when the interceptor that cfg asks for can run, otherwise the reason.
|
|
1840
|
+
/** null when the interceptor that cfg asks for can run, otherwise the reason. Builds missing or
|
|
1841
|
+
* outdated certificates on the way; changes nothing else. */
|
|
1814
1842
|
export async function checkBlindfoldTarget(cfg, gatewayPort) {
|
|
1815
1843
|
const desired = computeLaunchState(cfg, gatewayPort).blindfold;
|
|
1816
1844
|
if (!desired) return null;
|
|
1817
|
-
const problem =
|
|
1845
|
+
const problem = ensureBlindfoldCerts(desired);
|
|
1818
1846
|
if (problem) return problem;
|
|
1819
1847
|
const held = (await probeBlindfold(desired.port)).state;
|
|
1820
1848
|
if (held === 'foreign') return `Port ${desired.port} is held by another process, not by this switcher's interceptor.`;
|
package/switch.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
resolvePort, parsePort, findProfileKey, getActiveMap, setTargetProfile, activateProfile, deactivateAll,
|
|
9
9
|
applyLaunchState, clearLaunchState, computeLaunchState,
|
|
10
10
|
modelSlotsForProfile, modelForSlot, model1MForSlot, readAdminToken, adminTokenPath, openLog,
|
|
11
|
-
probeGateway, probeBlindfold,
|
|
11
|
+
probeGateway, probeBlindfold, ensureBlindfoldCerts, stopRecordedBlindfold, writeDashboardLauncher,
|
|
12
12
|
contractLabSettings, codexPublicModelsWarning,
|
|
13
13
|
getMigrationError, getMigrationCollision, getLastLoadError,
|
|
14
14
|
casClearToolPointers, emptyToolEnvFiles, syncInterceptorTools
|
|
@@ -362,10 +362,11 @@ function planSwitch(base, key, cliTarget) {
|
|
|
362
362
|
|
|
363
363
|
function refuseBlindfoldProblem(planned, port) {
|
|
364
364
|
const bf = computeLaunchState(planned, port).blindfold;
|
|
365
|
-
|
|
365
|
+
// A missing or outdated certificate is built here; only a set that cannot be built stops the switch.
|
|
366
|
+
const problem = bf && ensureBlindfoldCerts(bf);
|
|
366
367
|
if (!problem) return bf;
|
|
367
368
|
console.error(`[Error] ${problem}`);
|
|
368
|
-
console.error('
|
|
369
|
+
console.error(' Nothing was changed.');
|
|
369
370
|
process.exit(1);
|
|
370
371
|
}
|
|
371
372
|
|
|
@@ -959,14 +960,14 @@ async function runDoctor() {
|
|
|
959
960
|
const leafPath = path.join(path.dirname(paths.blindfoldCA), 'leaf.pem');
|
|
960
961
|
if (!fs.existsSync(leafPath)) {
|
|
961
962
|
warn(`[WARN] No interceptor certificate at ${leafPath}: neither tool can be intercepted.`);
|
|
962
|
-
console.log(' Fix: bash blindfold/make-certs.sh');
|
|
963
|
+
console.log(' Fix: switch a tool on; the switcher builds the certificate. Or run bash blindfold/make-certs.sh');
|
|
963
964
|
} else {
|
|
964
965
|
try {
|
|
965
966
|
const pem = fs.readFileSync(leafPath, 'utf8');
|
|
966
967
|
const missing = ['api.anthropic.com', 'api.openai.com', 'chatgpt.com'].filter(h => !certCoversHost(pem, h));
|
|
967
968
|
if (missing.length) {
|
|
968
969
|
warn(`[WARN] The interceptor certificate does not cover ${missing.join(', ')}.`);
|
|
969
|
-
console.log(' Fix: bash blindfold/make-certs.sh
|
|
970
|
+
console.log(' Fix: switch a tool on; the switcher rebuilds the certificate on the same CA. Or run bash blindfold/make-certs.sh');
|
|
970
971
|
} else {
|
|
971
972
|
console.log('[PASS] Interceptor certificate covers api.anthropic.com, api.openai.com and chatgpt.com.');
|
|
972
973
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import tls from 'node:tls';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { buildCerts, signLeaf } from '../blindfold/certs.mjs';
|
|
10
|
+
import { blindfoldPreflight, ensureBlindfoldCerts, INTERCEPT_HOSTS } from '../state.mjs';
|
|
11
|
+
|
|
12
|
+
const HAS_OPENSSL = (() => { try { execFileSync('openssl', ['version'], { stdio: 'ignore' }); return true; } catch { return false; } })();
|
|
13
|
+
|
|
14
|
+
function tmp(t) {
|
|
15
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-certs-'));
|
|
16
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
17
|
+
return path.join(dir, 'certs');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const desiredFor = (dir) => ({ ca: path.join(dir, 'ca.pem') });
|
|
21
|
+
|
|
22
|
+
test('buildCerts writes a CA and a leaf that the preflight accepts, private to the owner', (t) => {
|
|
23
|
+
const dir = tmp(t);
|
|
24
|
+
buildCerts(dir, INTERCEPT_HOSTS);
|
|
25
|
+
for (const f of ['ca.pem', 'ca.key', 'leaf.pem', 'leaf.key']) {
|
|
26
|
+
assert.ok(fs.existsSync(path.join(dir, f)), f);
|
|
27
|
+
if (process.platform !== 'win32') assert.equal(fs.statSync(path.join(dir, f)).mode & 0o777, 0o600, f);
|
|
28
|
+
}
|
|
29
|
+
if (process.platform !== 'win32') assert.equal(fs.statSync(dir).mode & 0o777, 0o700);
|
|
30
|
+
assert.equal(blindfoldPreflight(desiredFor(dir)), null);
|
|
31
|
+
const ca = new crypto.X509Certificate(fs.readFileSync(path.join(dir, 'ca.pem')));
|
|
32
|
+
assert.equal(ca.ca, true);
|
|
33
|
+
const leaf = new crypto.X509Certificate(fs.readFileSync(path.join(dir, 'leaf.pem')));
|
|
34
|
+
assert.equal(leaf.ca, false);
|
|
35
|
+
for (const h of INTERCEPT_HOSTS) assert.equal(leaf.checkHost(h), h);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a TLS client that trusts only the CA completes a handshake for every host', async (t) => {
|
|
39
|
+
const dir = tmp(t);
|
|
40
|
+
buildCerts(dir, INTERCEPT_HOSTS);
|
|
41
|
+
const server = tls.createServer({ key: fs.readFileSync(path.join(dir, 'leaf.key')), cert: fs.readFileSync(path.join(dir, 'leaf.pem')) }, s => s.end('ok'));
|
|
42
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
43
|
+
t.after(() => server.close());
|
|
44
|
+
for (const host of INTERCEPT_HOSTS) {
|
|
45
|
+
const got = await new Promise((resolve, reject) => {
|
|
46
|
+
const s = tls.connect({ port: server.address().port, host: '127.0.0.1', servername: host, ca: fs.readFileSync(path.join(dir, 'ca.pem')) }, () => {
|
|
47
|
+
s.once('data', d => { resolve(String(d)); s.end(); });
|
|
48
|
+
});
|
|
49
|
+
s.on('error', reject);
|
|
50
|
+
});
|
|
51
|
+
assert.equal(got, 'ok', host);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('openssl verifies the leaf, and the name constraints refuse a leaf for another host', { skip: !HAS_OPENSSL && 'openssl' }, (t) => {
|
|
56
|
+
const dir = tmp(t);
|
|
57
|
+
buildCerts(dir, INTERCEPT_HOSTS);
|
|
58
|
+
const out = execFileSync('openssl', ['verify', '-CAfile', path.join(dir, 'ca.pem'), path.join(dir, 'leaf.pem')], { encoding: 'utf8' });
|
|
59
|
+
assert.match(out, /OK/);
|
|
60
|
+
// A leaked ca.key signs for any name; a client that obeys the constraints must refuse it.
|
|
61
|
+
const evil = path.join(dir, 'evil.pem');
|
|
62
|
+
fs.writeFileSync(evil, signLeaf(dir, ['evil.example']).cert);
|
|
63
|
+
assert.throws(() => execFileSync('openssl', ['verify', '-CAfile', path.join(dir, 'ca.pem'), evil], { stdio: 'pipe' }), /permitted subtree violation/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('ensureBlindfoldCerts builds a missing set, rebuilds a missing leaf on the same CA, and leaves a good set alone', (t) => {
|
|
67
|
+
const dir = tmp(t);
|
|
68
|
+
assert.match(blindfoldPreflight(desiredFor(dir)), /ca\.pem is missing/);
|
|
69
|
+
assert.equal(ensureBlindfoldCerts(desiredFor(dir)), null);
|
|
70
|
+
const ca = fs.readFileSync(path.join(dir, 'ca.pem'), 'utf8');
|
|
71
|
+
const leaf = fs.readFileSync(path.join(dir, 'leaf.pem'), 'utf8');
|
|
72
|
+
|
|
73
|
+
assert.equal(ensureBlindfoldCerts(desiredFor(dir)), null);
|
|
74
|
+
assert.equal(fs.readFileSync(path.join(dir, 'leaf.pem'), 'utf8'), leaf, 'a good set is not rebuilt');
|
|
75
|
+
|
|
76
|
+
fs.rmSync(path.join(dir, 'leaf.pem'));
|
|
77
|
+
assert.equal(ensureBlindfoldCerts(desiredFor(dir)), null);
|
|
78
|
+
assert.equal(fs.readFileSync(path.join(dir, 'ca.pem'), 'utf8'), ca, 'the CA Codex already trusts is kept');
|
|
79
|
+
assert.notEqual(fs.readFileSync(path.join(dir, 'leaf.pem'), 'utf8'), leaf);
|
|
80
|
+
|
|
81
|
+
// A leaf from an older version covers one host only.
|
|
82
|
+
fs.writeFileSync(path.join(dir, 'leaf.pem'), signLeaf(dir, ['api.openai.com']).cert);
|
|
83
|
+
assert.match(blindfoldPreflight(desiredFor(dir)), /does not cover/);
|
|
84
|
+
assert.equal(ensureBlindfoldCerts(desiredFor(dir)), null);
|
|
85
|
+
assert.equal(fs.readFileSync(path.join(dir, 'ca.pem'), 'utf8'), ca);
|
|
86
|
+
});
|
|
@@ -265,6 +265,12 @@ after(() => {
|
|
|
265
265
|
fakeInterceptor?.kill();
|
|
266
266
|
proxy?.kill();
|
|
267
267
|
upstream?.close();
|
|
268
|
+
// A test that changes the tool set makes the gateway start a real interceptor. It is detached on
|
|
269
|
+
// purpose, so it outlives the gateway; stop the one the gateway recorded before its state goes.
|
|
270
|
+
try {
|
|
271
|
+
const { pid } = JSON.parse(fs.readFileSync(path.join(tmpDir, 'blindfold.json'), 'utf8'));
|
|
272
|
+
if (Number.isInteger(pid) && pid !== fakeInterceptor?.pid) process.kill(pid);
|
|
273
|
+
} catch {}
|
|
268
274
|
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
269
275
|
});
|
|
270
276
|
|
|
@@ -659,6 +665,36 @@ test('Security: the admin API refuses a caller without the token and changes not
|
|
|
659
665
|
}
|
|
660
666
|
});
|
|
661
667
|
|
|
668
|
+
// The model list carries each model's token limits, as intact and OpenRouter give them, so the
|
|
669
|
+
// 1M context box can be locked for a model known to be smaller.
|
|
670
|
+
test('Browse Models returns the token limits the gateway lists', async () => {
|
|
671
|
+
const sink = http.createServer((req, res) => {
|
|
672
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
673
|
+
res.end(JSON.stringify({ data: [
|
|
674
|
+
{ id: 'big', context_length: 1048576, max_output_tokens: 65536 },
|
|
675
|
+
{ id: 'small', context_length: 200000 },
|
|
676
|
+
{ id: 'or', top_provider: { context_length: 262144, max_completion_tokens: 32768 } },
|
|
677
|
+
{ id: 'in', max_input_tokens: 1000000 },
|
|
678
|
+
{ id: 'bare' }
|
|
679
|
+
] }));
|
|
680
|
+
});
|
|
681
|
+
await new Promise(r => sink.listen(0, '127.0.0.1', r));
|
|
682
|
+
try {
|
|
683
|
+
const r = await post('/api/fetch-models', { baseURL: `http://127.0.0.1:${sink.address().port}` });
|
|
684
|
+
const data = await r.json();
|
|
685
|
+
assert.equal(data.ok, true);
|
|
686
|
+
assert.deepEqual(data.models, ['bare', 'big', 'in', 'or', 'small']);
|
|
687
|
+
assert.deepEqual(data.limits, {
|
|
688
|
+
big: { context: 1048576, output: 65536 },
|
|
689
|
+
small: { context: 200000 },
|
|
690
|
+
or: { context: 262144, output: 32768 },
|
|
691
|
+
in: { input: 1000000 }
|
|
692
|
+
});
|
|
693
|
+
} finally {
|
|
694
|
+
sink.close();
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
662
698
|
test('Admin API: keys are redacted and invalid switch input is rejected', async () => {
|
|
663
699
|
const status = await (await fetch(url('/api/status'), { headers: withToken('/api/status', {}) })).json();
|
|
664
700
|
assert.ok(!JSON.stringify(status).includes('sk-secret'));
|
package/tests/lifecycle.test.mjs
CHANGED
|
@@ -167,40 +167,53 @@ test('switch on refuses a foreign gateway port and changes nothing', { skip: !PO
|
|
|
167
167
|
}
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
// A missing or outdated certificate set is built before anything else is checked, so turning a
|
|
171
|
+
// Codex profile on never stops at the certificates. A squatter on the interceptor port keeps the
|
|
172
|
+
// switch refused here, which shows the build ran first and that nothing else changed.
|
|
173
|
+
test('switch codex <blindfold profile> builds missing certificates and a missing leaf.key, and refuses a foreign interceptor port, changing nothing else', { skip: !HAS_OPENSSL && 'posix + openssl' }, async () => {
|
|
171
174
|
const cases = [];
|
|
175
|
+
const squatter = net.createServer(s => s.end('HTTP/1.1 200 OK\r\n\r\nnot the switcher'));
|
|
176
|
+
const bfPort = await new Promise(r => squatter.listen(0, '127.0.0.1', () => r(squatter.address().port)));
|
|
172
177
|
try {
|
|
173
178
|
// 1. no certificates at all
|
|
174
179
|
const a = makeWorkspace(); cases.push(a);
|
|
175
|
-
writeConfig(a, await freePort(), await freePort());
|
|
176
180
|
// 2. leaf.key missing
|
|
177
181
|
const b = makeWorkspace({ certs: true }); cases.push(b);
|
|
178
182
|
fs.rmSync(path.join(b.certDir, 'leaf.key'));
|
|
179
|
-
|
|
180
|
-
// 3. valid certificates
|
|
183
|
+
const caBefore = fs.readFileSync(path.join(b.certDir, 'ca.pem'), 'utf8');
|
|
184
|
+
// 3. valid certificates
|
|
181
185
|
const c = makeWorkspace({ certs: true }); cases.push(c);
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
assert.deepEqual(snapshotLaunchFiles(ws.dir), launch, 'no launcher file changed');
|
|
195
|
-
}
|
|
196
|
-
} finally {
|
|
197
|
-
squatter.close();
|
|
186
|
+
for (const ws of cases) writeConfig(ws, await freePort(), bfPort);
|
|
187
|
+
for (const ws of cases) {
|
|
188
|
+
const before = fs.readFileSync(ws.cfgPath);
|
|
189
|
+
const launch = snapshotLaunchFiles(ws.dir);
|
|
190
|
+
const r = await runSwitch(ws, ['codex', 'bf']);
|
|
191
|
+
assert.notEqual(r.status, 0, r.stdout);
|
|
192
|
+
assert.match(r.stderr, /held by another process/);
|
|
193
|
+
assert.doesNotMatch(r.stderr, /missing/);
|
|
194
|
+
assert.equal(await probe(ws, `s.blindfoldPreflight({ ca: ${JSON.stringify(path.join(ws.certDir, 'ca.pem'))} })`), null, 'the certificates are usable now');
|
|
195
|
+
assert.deepEqual(fs.readFileSync(ws.cfgPath), before, 'config.json is byte-identical');
|
|
196
|
+
assert.ok(!fs.existsSync(path.join(ws.dir, 'blindfold.json')));
|
|
197
|
+
assert.deepEqual(snapshotLaunchFiles(ws.dir), launch, 'no launcher file changed');
|
|
198
198
|
}
|
|
199
|
+
assert.equal(fs.readFileSync(path.join(b.certDir, 'ca.pem'), 'utf8'), caBefore, 'a working CA is kept: Codex already trusts it');
|
|
199
200
|
} finally {
|
|
201
|
+
squatter.close();
|
|
200
202
|
for (const ws of cases) fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
201
203
|
}
|
|
202
204
|
});
|
|
203
205
|
|
|
206
|
+
test('the gateway check builds a missing certificate set instead of refusing the change', { skip: !POSIX && 'posix' }, async () => {
|
|
207
|
+
const ws = makeWorkspace();
|
|
208
|
+
try {
|
|
209
|
+
writeConfig(ws, await freePort(), await freePort(), 'bf');
|
|
210
|
+
assert.equal(await probe(ws, `s.checkBlindfoldTarget(s.loadConfig(), s.loadConfig().port)`), null);
|
|
211
|
+
for (const f of ['ca.pem', 'ca.key', 'leaf.pem', 'leaf.key']) assert.ok(fs.existsSync(path.join(ws.certDir, f)), f);
|
|
212
|
+
} finally {
|
|
213
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
204
217
|
test('the gateway owns the interceptor: dashboard changes, a lost interceptor and a gateway restart are reconciled', { skip: !HAS_OPENSSL && 'posix + openssl' }, async () => {
|
|
205
218
|
const ws = makeWorkspace({ certs: true });
|
|
206
219
|
const gwPort = await freePort();
|
|
@@ -300,12 +313,20 @@ test('concurrent admin changes: a refused change never reaches disk, and no acce
|
|
|
300
313
|
assert.equal(onDisk.activeProfiles.codex, null, 'the refused switch is not saved by the other request');
|
|
301
314
|
assert.equal(onDisk.profiles.plain.name, 'Renamed');
|
|
302
315
|
|
|
303
|
-
// A usable interceptor port, and Codex on, for the second half.
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
316
|
+
// A usable interceptor port, and Codex on, for the second half. Other test files run at the
|
|
317
|
+
// same time and can bind a port between freePort() and the gateway's check; the gateway then
|
|
318
|
+
// rightly refuses it, so a refusal for a taken port picks another one.
|
|
319
|
+
let on;
|
|
320
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
321
|
+
bfPort = await freePort();
|
|
322
|
+
onDisk.blindfold = { port: bfPort };
|
|
323
|
+
fs.writeFileSync(ws.cfgPath, JSON.stringify(onDisk, null, 2), { mode: 0o600 });
|
|
324
|
+
on = await post('/api/switch', { target: 'codex', profile: 'bf' });
|
|
325
|
+
if (on.status === 200) break;
|
|
326
|
+
const why = await on.clone().text();
|
|
327
|
+
if (!/held by another process|does not answer/.test(why)) break;
|
|
328
|
+
onDisk = JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8'));
|
|
329
|
+
}
|
|
309
330
|
assert.equal(on.status, 200, await on.text());
|
|
310
331
|
await waitFor(async () => (await probe(ws, `s.probeBlindfold(${bfPort})`)).state === 'ours');
|
|
311
332
|
const postProfile = (body) => post('/api/save-profile', body);
|
package/tests/state.test.mjs
CHANGED
|
@@ -653,13 +653,18 @@ test('isSafeModelName refuses TOML date-times with T and Z', () => {
|
|
|
653
653
|
test('an empty launch lock is held, not taken over', async (t) => {
|
|
654
654
|
const { dir, env } = tmpDirs(t);
|
|
655
655
|
const lock = path.join(dir, '.launch.lock');
|
|
656
|
+
const started = path.join(dir, 'writer.started');
|
|
656
657
|
fs.writeFileSync(lock, '');
|
|
657
|
-
|
|
658
|
-
const waited = await new Promise((resolve, reject) => {
|
|
658
|
+
const done = new Promise((resolve, reject) => {
|
|
659
659
|
execFileCb(process.execPath, ['--input-type=module', '-e',
|
|
660
|
-
`const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
|
|
660
|
+
`const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); (await import('node:fs')).writeFileSync(${JSON.stringify(started)}, ''); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
|
|
661
661
|
{ env: { ...process.env, LLM_SWITCHER_PORT: '', ...env }, encoding: 'utf8' }, (err, out) => (err ? reject(err) : resolve(Number(out.trim()))));
|
|
662
662
|
});
|
|
663
|
+
// The 400 ms count from the writer's start: a slow start under a loaded suite must not find the
|
|
664
|
+
// lock already gone.
|
|
665
|
+
for (let i = 0; i < 1000 && !fs.existsSync(started); i++) await new Promise(r => setTimeout(r, 10));
|
|
666
|
+
setTimeout(() => fs.rmSync(lock, { force: true }), 400);
|
|
667
|
+
const waited = await done;
|
|
663
668
|
// Taken over at once, the write would take a few ms.
|
|
664
669
|
assert.ok(waited >= 100 && waited < 4000, `the writer waited ${waited} ms`);
|
|
665
670
|
assert.equal(fs.existsSync(lock), false);
|
package/tests/ui.test.mjs
CHANGED
|
@@ -88,3 +88,23 @@ test('a profile without outFormat keeps auto routing through a save', () => {
|
|
|
88
88
|
assert.match(html, /<select id="p-outformat">\s*<option value="">/);
|
|
89
89
|
assert.match(html, /getElementById\('p-outformat'\)\.value = p\.outFormat \|\| '';/);
|
|
90
90
|
});
|
|
91
|
+
|
|
92
|
+
// The 1M box is locked only when the model's window is known and under 1M. An unknown window
|
|
93
|
+
// only warns: many gateways list no limits at all.
|
|
94
|
+
test('contextVerdict locks only a window known to be under 1M', () => {
|
|
95
|
+
const verdict = new Function(functionBody('contextVerdict') + '\n }\n return contextVerdict;')();
|
|
96
|
+
assert.equal(verdict(undefined).kind, 'unknown');
|
|
97
|
+
assert.equal(verdict({ output: 32000 }).kind, 'unknown');
|
|
98
|
+
assert.equal(verdict({ context: 200000 }).kind, 'small');
|
|
99
|
+
assert.equal(verdict({ context: 200000 }).window, 200000);
|
|
100
|
+
assert.equal(verdict({ input: 1000000 }).kind, 'ok');
|
|
101
|
+
assert.equal(verdict({ context: 1048576 }).kind, 'ok');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('each 1M box names its slot and points at its note', () => {
|
|
105
|
+
const slots = functionBody('renderModelSlots');
|
|
106
|
+
assert.match(slots, /aria-label="1M context for \$\{escapeHtml\(s\.name\)\}"/);
|
|
107
|
+
assert.match(slots, /aria-describedby="p-\$\{s\.id\}-1m-note"/);
|
|
108
|
+
assert.match(slots, /class="slot-1m-note" id="p-\$\{s\.id\}-1m-note" role="status"/);
|
|
109
|
+
assert.doesNotMatch(html, /\.slot-1m-note:empty \{ display: none/);
|
|
110
|
+
});
|
package/ui.html
CHANGED
|
@@ -530,6 +530,11 @@
|
|
|
530
530
|
|
|
531
531
|
/* Model slot combobox: one listbox per slot, provider prefix dimmed, match in the accent. */
|
|
532
532
|
.combo { position: relative; }
|
|
533
|
+
.slot-1m-note { font-size: 0.72rem; color: var(--faint); }
|
|
534
|
+
/* Kept in the page when empty: a live region added later is often not read. */
|
|
535
|
+
.slot-1m-note:empty { margin-top: -0.25rem; }
|
|
536
|
+
.slot-1m-note.warn { color: var(--warn); }
|
|
537
|
+
.slot-1m-note.err { color: var(--err); }
|
|
533
538
|
.combo > input { padding-right: 2.25rem; font-family: var(--font-mono); font-size: 0.8rem; }
|
|
534
539
|
.combo-toggle {
|
|
535
540
|
position: absolute; right: 4px; top: 4px; width: 28px; height: 28px;
|
|
@@ -1138,6 +1143,7 @@
|
|
|
1138
1143
|
let formLoadedKey = null;
|
|
1139
1144
|
let formDirty = false;
|
|
1140
1145
|
let loadedModels = [];
|
|
1146
|
+
let loadedLimits = {}; // model id -> { context, input, output }, as the gateway lists them
|
|
1141
1147
|
let currentProfileBuffer = null;
|
|
1142
1148
|
let renderedSlotFormat = 'auto';
|
|
1143
1149
|
let logRefreshTimer = null;
|
|
@@ -1436,17 +1442,18 @@
|
|
|
1436
1442
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
1437
1443
|
<label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
|
|
1438
1444
|
<label style="display:flex; align-items:center; gap:0.35rem; font-size:0.75rem; color:var(--accent); cursor:pointer;">
|
|
1439
|
-
<input type="checkbox" id="p-${s.id}-1m" style="width:13px; height:13px; min-height:0;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}>
|
|
1445
|
+
<input type="checkbox" id="p-${s.id}-1m" aria-label="1M context for ${escapeHtml(s.name)}" aria-describedby="p-${s.id}-1m-note" style="width:13px; height:13px; min-height:0;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}>
|
|
1440
1446
|
1M context
|
|
1441
1447
|
</label>
|
|
1442
1448
|
</div>
|
|
1443
1449
|
<div class="combo">
|
|
1444
|
-
<input type="text" id="p-${s.id}" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="p-${s.id}-list" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
|
|
1450
|
+
<input type="text" id="p-${s.id}" oninput="update1mWarnings()" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="p-${s.id}-list" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
|
|
1445
1451
|
<button type="button" class="combo-toggle" tabindex="-1" aria-label="Show models for ${escapeHtml(s.name)}">
|
|
1446
1452
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg>
|
|
1447
1453
|
</button>
|
|
1448
1454
|
<ul class="combo-list" id="p-${s.id}-list" role="listbox" aria-label="${escapeHtml(s.name)} models" hidden></ul>
|
|
1449
1455
|
</div>
|
|
1456
|
+
<div class="slot-1m-note" id="p-${s.id}-1m-note" role="status" aria-live="polite"></div>
|
|
1450
1457
|
`;
|
|
1451
1458
|
container.appendChild(slotRow);
|
|
1452
1459
|
wireCombo(slotRow.querySelector('.combo'));
|
|
@@ -1482,12 +1489,43 @@
|
|
|
1482
1489
|
return { defaultModels, model1M };
|
|
1483
1490
|
}
|
|
1484
1491
|
|
|
1492
|
+
// A window known to be under 1M locks the box; an unknown one only warns, since many
|
|
1493
|
+
// gateways list no limits at all.
|
|
1494
|
+
function contextVerdict(l) {
|
|
1495
|
+
const window = (l && (l.context || l.input)) || 0;
|
|
1496
|
+
if (!window) return { kind: 'unknown', window: 0 };
|
|
1497
|
+
return { kind: window >= 1000000 ? 'ok' : 'small', window };
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// 128000 reads as 128K and 262144 as 256K: a round decimal count keeps base 1000.
|
|
1501
|
+
function fmtTokens(n) {
|
|
1502
|
+
const b = n % 1000 === 0 ? 1000 : 1024;
|
|
1503
|
+
return n >= b * b ? +(n / b / b).toFixed(1) + 'M' : n >= b ? Math.round(n / b) + 'K' : String(n);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1485
1506
|
function update1mWarnings() {
|
|
1486
1507
|
const cfg = getActiveSlotConfig(renderedSlotFormat);
|
|
1487
1508
|
let any1m = false;
|
|
1488
1509
|
for (const s of cfg.slots) {
|
|
1489
1510
|
const chk = document.getElementById('p-' + s.id + '-1m');
|
|
1490
|
-
|
|
1511
|
+
const note = document.getElementById('p-' + s.id + '-1m-note');
|
|
1512
|
+
const model = document.getElementById('p-' + s.id)?.value.trim() || '';
|
|
1513
|
+
if (!chk) continue;
|
|
1514
|
+
const v = contextVerdict(model ? loadedLimits[model] : null);
|
|
1515
|
+
const locked = v.kind === 'small';
|
|
1516
|
+
if (locked) chk.checked = false;
|
|
1517
|
+
chk.disabled = locked;
|
|
1518
|
+
chk.closest('label').style.opacity = locked ? '0.5' : '';
|
|
1519
|
+
chk.closest('label').style.cursor = locked ? 'not-allowed' : 'pointer';
|
|
1520
|
+
if (note) {
|
|
1521
|
+
let text = '', cls = '';
|
|
1522
|
+
if (locked) { text = `Context ${fmtTokens(v.window)} is under 1M, so 1M context is off for this model.`; cls = 'err'; }
|
|
1523
|
+
else if (v.kind === 'ok') text = `Context ${fmtTokens(v.window)}.`;
|
|
1524
|
+
else if (chk.checked && model) { text = 'The gateway does not list this model\'s context. Make sure it takes 1M tokens.'; cls = 'warn'; }
|
|
1525
|
+
note.textContent = text;
|
|
1526
|
+
note.className = 'slot-1m-note' + (cls ? ' ' + cls : '');
|
|
1527
|
+
}
|
|
1528
|
+
if (chk.checked) any1m = true;
|
|
1491
1529
|
}
|
|
1492
1530
|
const warnCallout = document.getElementById('warn-1m-callout');
|
|
1493
1531
|
if (warnCallout) warnCallout.style.display = any1m ? 'inline-flex' : 'none';
|
|
@@ -2058,6 +2096,7 @@
|
|
|
2058
2096
|
if (request !== modelsRequest) return; // a newer load started while this one waited
|
|
2059
2097
|
if (data.ok && Array.isArray(data.models)) {
|
|
2060
2098
|
loadedModels = data.models;
|
|
2099
|
+
loadedLimits = data.limits || {};
|
|
2061
2100
|
modelsLoadedFor = source;
|
|
2062
2101
|
setModelsStatus('ready', loadedModels.length + (loadedModels.length === 1 ? ' model' : ' models'), 'Reload');
|
|
2063
2102
|
} else {
|
|
@@ -2072,6 +2111,7 @@
|
|
|
2072
2111
|
setModelsStatus('error', 'Models not loaded: ' + err.message, 'Retry');
|
|
2073
2112
|
}
|
|
2074
2113
|
document.querySelectorAll('.combo[data-open]').forEach(renderComboOptions);
|
|
2114
|
+
update1mWarnings();
|
|
2075
2115
|
}
|
|
2076
2116
|
|
|
2077
2117
|
// Slot combobox (ARIA combobox with a listbox). Typing filters; the arrow opens the full list.
|
|
@@ -2188,6 +2228,7 @@
|
|
|
2188
2228
|
if (!model) return;
|
|
2189
2229
|
combo.querySelector('input').value = model;
|
|
2190
2230
|
closeCombo(combo);
|
|
2231
|
+
update1mWarnings();
|
|
2191
2232
|
}
|
|
2192
2233
|
|
|
2193
2234
|
function onComboKeydown(e) {
|