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 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 using your local OpenSSL (`blindfold/make-certs.sh`). No keys are downloaded from the internet, and the private key is stored locally with strict `0600` permissions.
123
+ > - **100% Locally Minted:** The CA certificate (`ca.pem`) and private key (`ca.key`) are generated entirely on your own machine. The switcher builds them itself with Node's crypto the first time a tool is switched on, and again when the leaf is missing or out of date. It keeps a CA that still works, because Codex already trusts it. `blindfold/make-certs.sh` builds the same set with OpenSSL. No keys are downloaded from the internet, and the private key is stored locally with strict `0600` permissions.
124
124
  > - **Zero OS Trust Store Tampering:** Unlike tools like Charles or Fiddler, LLM Switcher **NEVER installs anything into your system or OS root certificate store** (no Windows Certificate Store, no macOS Keychain, no Linux `/etc/ssl/certs`). It requires **zero Administrator or sudo privileges**.
125
125
  > - **Process-Scoped Trust Only:** The certificate is loaded ephemerally into the memory of `claude` (via `NODE_EXTRA_CA_CERTS`) and `codex` (via `CODEX_CA_CERTIFICATE`). Your browsers, banking apps, git, and other terminal sessions never trust this CA.
126
126
  > - **Cryptographic Name Constraints:** The CA is minted with explicit X.509 `nameConstraints` strictly permitting only three domains: `api.anthropic.com`, `api.openai.com`, and `chatgpt.com`. Even if the local private key were compromised, standard TLS verifiers will reject it for any other domain (Google, GitHub, your bank).
@@ -279,9 +279,8 @@ base URL is overridden to http://127.0.0.1:3456/v1. Selecting models may not be
279
279
  Blindfold mode removes that line. Codex keeps its official endpoint, and the switcher intercepts the network hop instead. It needs no administrator rights, no certificate in a system trust store, and no change to `~/.codex/config.toml`.
280
280
 
281
281
  ```bash
282
- bash "$(npm root -g)/llm-switcher/blindfold/make-certs.sh" # once; a checkout runs blindfold/make-certs.sh
283
282
  # the port is already top-level in config.json: "blindfold": { "port": 3457 }
284
- switch codex <profile> # the gateway starts the interceptor
283
+ switch codex <profile> # builds the certificates when they are missing, then the gateway starts the interceptor
285
284
  ```
286
285
 
287
286
  One interceptor serves both tools. It routes by the host of the CONNECT request and by the path,
@@ -312,6 +311,10 @@ Read [📖 `docs/codex-blindfold.md`](docs/codex-blindfold.md) before you turn i
312
311
  or an auto-compact limit, and it writes no model name into your environment. Claude Code sizes
313
312
  its own session from the window of the model you pick. A backend whose window is smaller than
314
313
  that model can overflow in a long session. `model1M` now only decides what `/v1/models` reports.
314
+ The dashboard reads each model's window from the gateway's model list (`context_length` or
315
+ `max_input_tokens`, as intact and OpenRouter give them). When the window is known to be under
316
+ 1M, the slot's **1M context** box is cleared and locked. When the list gives no window, the box
317
+ stays free and shows a warning when it is ticked.
315
318
  - **Codex needs certificates once.** Blindfold mode is what keeps Codex on its official endpoint,
316
319
  and it needs a private CA plus a leaf naming the three hosts above. Skip it and Codex shows the
317
320
  `base URL is overridden` line on its `/model` screen instead.
package/README.vi.md CHANGED
@@ -120,7 +120,7 @@ LLM Switcher hoạt động như một lớp trung gian mạng trong suốt (tra
120
120
 
121
121
  > #### 🔒 Độ An Toàn & Nguồn Gốc Chứng Chỉ CA: CA từ đâu ra và an toàn thế nào?
122
122
  >
123
- > - **Tự sinh 100% tại máy cục bộ:** File chứng chỉ (`ca.pem`) và private key (`ca.key`) được sinh trực tiếp trên chính máy tính của bạn bằng OpenSSL nội bộ (`blindfold/make-certs.sh`). Tuyệt đối không tải bất kỳ chứng chỉ nào từ internet về, private key được lưu với quyền bảo mật nghiêm ngặt `0600`.
123
+ > - **Tự sinh 100% tại máy cục bộ:** File chứng chỉ (`ca.pem`) và private key (`ca.key`) được sinh trực tiếp trên chính máy tính của bạn. Switcher tự tạo chúng bằng crypto của Node ở lần đầu bật một công cụ, và tạo lại khi leaf bị thiếu hoặc đã cũ. CA còn dùng được thì được giữ nguyên, vì Codex đã tin nó. `blindfold/make-certs.sh` tạo đúng bộ đó bằng OpenSSL. Tuyệt đối không tải bất kỳ chứng chỉ nào từ internet về, private key được lưu với quyền bảo mật nghiêm ngặt `0600`.
124
124
  > - **Không can thiệp vào System Trust Store của hệ điều hành:** Khác với các công cụ bắt proxy như Charles hay Fiddler, LLM Switcher **tuyệt đối KHÔNG cài đặt chứng chỉ vào OS Root Store** (không đụng vào Windows Certificate Manager, macOS Keychain hay Linux `/etc/ssl/certs`). Bạn **không cần quyền Administrator hay sudo**.
125
125
  > - **Chỉ tin cậy trong phạm vi tiến trình (Process-Scoped):** Chứng chỉ CA chỉ được nạp tạm thời vào bộ nhớ của `claude` (qua `NODE_EXTRA_CA_CERTS`) và `codex` (qua `CODEX_CA_CERTIFICATE`). Trình duyệt web (Chrome, Edge), ứng dụng ngân hàng, git và các app khác trên máy hoàn toàn không biết và không tin cậy chứng chỉ này.
126
126
  > - **Giới hạn tên miền bằng mật mã học (Name Constraints):** Chứng chỉ CA được cấu hình thuộc tính X.509 `nameConstraints` bắt buộc, chỉ cho phép ký duy nhất cho 3 domain: `api.anthropic.com`, `api.openai.com`, và `chatgpt.com`. Dù có ai đánh cắp được private key, các trình xác thực TLS chuẩn sẽ lập tức từ chối chứng chỉ này đối với mọi trang web khác (Google, GitHub, ngân hàng...).
@@ -281,9 +281,8 @@ base URL is overridden to http://127.0.0.1:3456/v1. Selecting models may not be
281
281
  Blindfold xóa dòng đó. Codex giữ nguyên endpoint chính thức, switcher chặn ở tầng mạng. Không cần quyền admin, không cài chứng chỉ vào system trust store, không sửa `~/.codex/config.toml`.
282
282
 
283
283
  ```bash
284
- bash "$(npm root -g)/llm-switcher/blindfold/make-certs.sh" # chạy một lần; bản checkout chạy blindfold/make-certs.sh
285
284
  # cổng đã nằm sẵn ở cấp cao nhất của config.json: "blindfold": { "port": 3457 }
286
- switch codex <profile> # gateway khởi động interceptor
285
+ switch codex <profile> # tự tạo chứng chỉ khi còn thiếu, rồi gateway khởi động interceptor
287
286
  ```
288
287
 
289
288
  Một interceptor phục vụ cả hai công cụ. Nó định tuyến theo host của request CONNECT và theo path,
@@ -402,6 +401,7 @@ Thêm server vào cấu hình MCP (ví dụ `opencode.jsonc`, `claude_desktop_co
402
401
  ```bash
403
402
  switch ui # Mở giao diện Web UI trên trình duyệt
404
403
  switch status # Xem trạng thái kích hoạt của tất cả các CLI
404
+ switch version # Xem version đang chạy và báo khi npm có bản mới
405
405
  switch doctor # Quét & thanh tra toàn bộ môi trường, settings và định tuyến
406
406
  switch on [profile] # Khởi động gateway và kích hoạt một profile
407
407
  switch <profile> # Kích hoạt một profile cho cả hai công cụ
@@ -0,0 +1,156 @@
1
+ // Builds the interceptor's private CA and leaf in Node alone, the same certificates as
2
+ // make-certs.sh but with no bash and no openssl, so the gateway can build a missing set itself.
3
+ // Node reads X.509 but cannot write it, so the certificates are encoded here as DER.
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ const CA_DAYS = 3650;
9
+ const LEAF_DAYS = 825;
10
+
11
+ // ---- DER
12
+ function len(n) {
13
+ if (n < 0x80) return Buffer.from([n]);
14
+ const bytes = [];
15
+ for (let v = n; v > 0; v >>= 8) bytes.unshift(v & 0xff);
16
+ return Buffer.from([0x80 | bytes.length, ...bytes]);
17
+ }
18
+ const tlv = (tag, ...parts) => { const body = Buffer.concat(parts); return Buffer.concat([Buffer.from([tag]), len(body.length), body]); };
19
+ const seq = (...parts) => tlv(0x30, ...parts);
20
+ const set = (...parts) => tlv(0x31, ...parts);
21
+ const bool = (v) => tlv(0x01, Buffer.from([v ? 0xff : 0]));
22
+ const octets = (buf) => tlv(0x04, buf);
23
+ const utf8 = (s) => tlv(0x0c, Buffer.from(s, 'utf8'));
24
+ const bits = (buf, unused = 0) => tlv(0x03, Buffer.from([unused]), buf);
25
+ const dnsName = (host) => tlv(0x82, Buffer.from(host, 'ascii')); // GeneralName [2] IA5String
26
+ function int(buf) {
27
+ let b = Buffer.from(buf);
28
+ while (b.length > 1 && b[0] === 0 && !(b[1] & 0x80)) b = b.subarray(1);
29
+ if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b]);
30
+ return tlv(0x02, b);
31
+ }
32
+ function oid(dotted) {
33
+ const [a, b, ...rest] = dotted.split('.').map(Number);
34
+ const out = [40 * a + b];
35
+ for (const n of rest) {
36
+ const chunk = [n & 0x7f];
37
+ for (let v = n >> 7; v > 0; v >>= 7) chunk.unshift(0x80 | (v & 0x7f));
38
+ out.push(...chunk);
39
+ }
40
+ return tlv(0x06, Buffer.from(out));
41
+ }
42
+ function time(d) {
43
+ const p = (n) => String(n).padStart(2, '0');
44
+ const y = d.getUTCFullYear();
45
+ const s = `${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
46
+ // UTCTime until 2049, GeneralizedTime after, as RFC 5280 requires.
47
+ return y < 2050 ? tlv(0x17, Buffer.from(String(y).slice(2) + s)) : tlv(0x18, Buffer.from(String(y) + s));
48
+ }
49
+
50
+ const ECDSA_SHA256 = seq(oid('1.2.840.10045.4.3.2'));
51
+ const name = (cn) => seq(set(seq(oid('2.5.4.3'), utf8(cn))));
52
+ const ext = (id, critical, value) => seq(oid(id), ...(critical ? [bool(true)] : []), octets(value));
53
+
54
+ function keyId(publicKey) {
55
+ // The key identifier is the SHA-1 of the subjectPublicKey bits (RFC 5280, method 1).
56
+ const spki = publicKey.export({ type: 'spki', format: 'der' });
57
+ return crypto.createHash('sha1').update(spki.subarray(spki.length - 65)).digest();
58
+ }
59
+
60
+ function certificate({ subject, issuer, publicKey, signingKey, days, extensions }) {
61
+ const notBefore = new Date(Date.now() - 60 * 60 * 1000); // an hour back, for a skewed clock
62
+ const notAfter = new Date(notBefore.getTime() + days * 86400000);
63
+ const serial = crypto.randomBytes(16);
64
+ serial[0] &= 0x7f;
65
+ const tbs = seq(
66
+ tlv(0xa0, int([2])), // v3
67
+ int(serial),
68
+ ECDSA_SHA256,
69
+ name(issuer),
70
+ seq(time(notBefore), time(notAfter)),
71
+ name(subject),
72
+ publicKey.export({ type: 'spki', format: 'der' }),
73
+ tlv(0xa3, seq(...extensions))
74
+ );
75
+ const sig = crypto.sign('sha256', tbs, signingKey);
76
+ const der = seq(tbs, ECDSA_SHA256, bits(sig));
77
+ return `-----BEGIN CERTIFICATE-----\n${der.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END CERTIFICATE-----\n`;
78
+ }
79
+
80
+ const newKey = () => crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
81
+ const pemKey = (k) => k.export({ type: 'sec1', format: 'pem' });
82
+
83
+ /** A leaf for `hosts`, signed by the CA in `dir`. Returns the PEM strings; writes nothing. */
84
+ export function signLeaf(dir, hosts) {
85
+ const caKey = crypto.createPrivateKey(fs.readFileSync(path.join(dir, 'ca.key')));
86
+ const caCert = new crypto.X509Certificate(fs.readFileSync(path.join(dir, 'ca.pem')));
87
+ const { publicKey, privateKey } = newKey();
88
+ const cert = certificate({
89
+ subject: 'llm-switcher',
90
+ issuer: 'LLM Switcher Local CA',
91
+ publicKey,
92
+ signingKey: caKey,
93
+ days: LEAF_DAYS,
94
+ extensions: [
95
+ ext('2.5.29.19', true, seq()), // basicConstraints CA:FALSE
96
+ ext('2.5.29.15', true, bits(Buffer.from([0xa0]), 5)), // digitalSignature, keyEncipherment
97
+ ext('2.5.29.37', false, seq(oid('1.3.6.1.5.5.7.3.1'))), // extendedKeyUsage serverAuth
98
+ ext('2.5.29.17', false, seq(...hosts.map(dnsName))), // subjectAltName
99
+ ext('2.5.29.14', false, octets(keyId(publicKey))),
100
+ ext('2.5.29.35', false, seq(tlv(0x80, keyId(caCert.publicKey))))
101
+ ]
102
+ });
103
+ return { cert, key: pemKey(privateKey) };
104
+ }
105
+
106
+ function writePrivate(dir, files) {
107
+ // Write each file beside its target and rename it into place: a failed run keeps the last set,
108
+ // and a rename replaces a planted symlink instead of writing through it.
109
+ const staged = [];
110
+ for (const [f, body] of Object.entries(files)) {
111
+ const tmpFile = path.join(dir, `.${f}.${process.pid}.tmp`);
112
+ fs.writeFileSync(tmpFile, body, { mode: 0o600, flag: 'wx' });
113
+ staged.push([tmpFile, path.join(dir, f)]);
114
+ }
115
+ for (const [from, to] of staged) fs.renameSync(from, to);
116
+ }
117
+
118
+ function privateDir(dir) {
119
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
120
+ if (process.platform !== 'win32') {
121
+ const st = fs.lstatSync(dir);
122
+ if (!st.isDirectory() || st.uid !== process.getuid()) {
123
+ throw new Error(`${dir} is not a directory you own; the CA key cannot go there`);
124
+ }
125
+ fs.chmodSync(dir, 0o700);
126
+ }
127
+ }
128
+
129
+ /** A new CA and leaf in `dir`, the set make-certs.sh builds. */
130
+ export function buildCerts(dir, hosts) {
131
+ privateDir(dir);
132
+ const { publicKey, privateKey } = newKey();
133
+ const caPem = certificate({
134
+ subject: 'LLM Switcher Local CA',
135
+ issuer: 'LLM Switcher Local CA',
136
+ publicKey,
137
+ signingKey: privateKey,
138
+ days: CA_DAYS,
139
+ extensions: [
140
+ ext('2.5.29.19', true, seq(bool(true), int([0]))), // CA:TRUE, pathlen:0
141
+ ext('2.5.29.15', true, bits(Buffer.from([0x06]), 1)), // keyCertSign, cRLSign
142
+ ext('2.5.29.14', false, octets(keyId(publicKey))),
143
+ // A leaked ca.key can then sign only for the host table.
144
+ ext('2.5.29.30', true, seq(tlv(0xa0, ...hosts.map(h => seq(dnsName(h))))))
145
+ ]
146
+ });
147
+ writePrivate(dir, { 'ca.key': pemKey(privateKey), 'ca.pem': caPem });
148
+ writeLeaf(dir, hosts);
149
+ }
150
+
151
+ /** A new leaf in `dir` on the CA already there. */
152
+ export function writeLeaf(dir, hosts) {
153
+ privateDir(dir);
154
+ const { cert, key } = signLeaf(dir, hosts);
155
+ writePrivate(dir, { 'leaf.key': key, 'leaf.pem': cert });
156
+ }
@@ -72,12 +72,14 @@ You need:
72
72
 
73
73
  ## Procedure
74
74
 
75
- ### 1. Build the certificates
75
+ ### 1. The certificates
76
76
 
77
- Run this command in the repository root:
77
+ You do not have to build them. When a tool is switched on and the certificates are missing, the switcher builds them with Node's crypto. It builds a new leaf when the leaf is missing, does not cover the three hosts, or does not match its key. It keeps a CA that still works, because Codex already trusts that CA. The set is the one that `make-certs.sh` builds: an EC P-256 CA with name constraints, and a leaf with `serverAuth` for `api.anthropic.com`, `api.openai.com` and `chatgpt.com`.
78
+
79
+ To build the set yourself with OpenSSL, run this command in the repository root:
78
80
 
79
81
  ```bash
80
- bash blindfold/make-certs.sh chatgpt.com
82
+ bash blindfold/make-certs.sh
81
83
  ```
82
84
 
83
85
  The script writes `blindfold/certs/ca.pem` and `blindfold/certs/leaf.pem`. It prints the extended key usage and the subject alternative name of the leaf. Make sure that the output contains `TLS Web Server Authentication`.
@@ -212,7 +214,7 @@ The switcher writes `LLM_SWITCHER_CODEX_BASE_URL` again, the gateway stops the i
212
214
 
213
215
  Read this section before you turn blindfold mode on.
214
216
 
215
- **The CA is trusted for every host that Codex contacts.** `CODEX_CA_CERTIFICATE` adds this authority to the trust store that Codex uses for all of its HTTPS calls. A CA that `make-certs.sh` builds now carries name constraints: it can sign only for `api.anthropic.com`, `api.openai.com` and `chatgpt.com`. A client that obeys name constraints refuses any other certificate from this CA. OpenSSL, rustls and the macOS and Windows verifiers obey them. A CA that an older version built has no constraint. Anybody who can read its `ca.key` can forge a certificate for any host. Run `make-certs.sh` again to replace it, and keep `blindfold/certs/` private in both cases.
217
+ **The CA is trusted for every host that Codex contacts.** `CODEX_CA_CERTIFICATE` adds this authority to the trust store that Codex uses for all of its HTTPS calls. A CA that the switcher or `make-certs.sh` builds carries name constraints: it can sign only for `api.anthropic.com`, `api.openai.com` and `chatgpt.com`. A client that obeys name constraints refuses any other certificate from this CA. OpenSSL, rustls and the macOS and Windows verifiers obey them. A CA that an older version built has no constraint. Anybody who can read its `ca.key` can forge a certificate for any host. Run `make-certs.sh` again to replace it, and keep `blindfold/certs/` private in both cases.
216
218
 
217
219
  **All traffic to the intercepted host is decrypted by this process.** That includes sign-in and token refresh, because the CONNECT for the whole host is terminated locally. Requests outside the Codex API path are re-originated to the real host over a new TLS session; they are forwarded, not tunneled. The `--verbose` flag prints the method and path of every such request.
218
220
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code, Codex, OpenAI and Gemini clients",
5
5
  "keywords": [
6
6
  "llm",
package/proxy.mjs CHANGED
@@ -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
- let list = [];
1082
- if (Array.isArray(data.data)) list = data.data.map(m => (typeof m === 'string' ? m : m.id));
1083
- else if (Array.isArray(data)) list = data.map(m => (typeof m === 'string' ? m : m.id));
1084
- else if (Array.isArray(data.models)) list = data.models.map(m => (typeof m === 'string' ? m : (m.id || m.name)));
1085
- list = [...new Set(list.filter(Boolean).map(id => String(id).replace(/^models\//, '')))].sort();
1086
- return { status: 200, json: { ok: true, models: list } };
1081
+ const entries = Array.isArray(data.data) ? data.data : Array.isArray(data) ? data : Array.isArray(data.models) ? data.models : [];
1082
+ const idOf = m => String(typeof m === 'string' ? m : (m?.id || m?.name || '')).replace(/^models\//, '');
1083
+ const list = [...new Set(entries.map(idOf).filter(Boolean))].sort();
1084
+ const limits = {};
1085
+ for (const m of entries) {
1086
+ const l = modelLimits(m);
1087
+ if (l) limits[idOf(m)] = l;
1088
+ }
1089
+ return { status: 200, json: { ok: true, models: list, limits } };
1090
+ }
1091
+
1092
+ // A listed model's token limits, as intact and OpenRouter write them. Null when the list gives none.
1093
+ function modelLimits(m) {
1094
+ if (!m || typeof m !== 'object') return null;
1095
+ const n = v => (Number.isFinite(v) && v > 0 ? v : 0);
1096
+ const top = m.top_provider && typeof m.top_provider === 'object' ? m.top_provider : {};
1097
+ const out = {};
1098
+ const context = n(m.context_length) || n(top.context_length) || n(m.context_window);
1099
+ const input = n(m.max_input_tokens);
1100
+ const output = n(m.max_output_tokens) || n(top.max_completion_tokens);
1101
+ if (context) out.context = context;
1102
+ if (input) out.input = input;
1103
+ if (output) out.output = output;
1104
+ return Object.keys(out).length ? out : null;
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. No side effects. */
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 = blindfoldPreflight(desired);
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, blindfoldPreflight, stopRecordedBlindfold, writeDashboardLauncher,
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
- const problem = bf && blindfoldPreflight(bf);
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(' Fix the certificate with: bash blindfold/make-certs.sh - it names all three hosts this switcher routes. Nothing was changed.');
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 (it names all three hosts this switcher routes)');
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'));
@@ -167,40 +167,53 @@ test('switch on refuses a foreign gateway port and changes nothing', { skip: !PO
167
167
  }
168
168
  });
169
169
 
170
- test('switch codex <blindfold profile> refuses missing certificates, a missing leaf.key and a foreign interceptor port, changing nothing', { skip: !HAS_OPENSSL && 'posix + openssl' }, async () => {
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
- writeConfig(b, await freePort(), await freePort());
180
- // 3. valid certificates, but another process holds the interceptor port
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 squatter = net.createServer(s => s.end('HTTP/1.1 200 OK\r\n\r\nnot the switcher'));
183
- const bfPort = await new Promise(r => squatter.listen(0, '127.0.0.1', () => r(squatter.address().port)));
184
- writeConfig(c, await freePort(), bfPort);
185
- try {
186
- for (const [ws, why] of [[a, /missing/], [b, /leaf\.key is missing/], [c, /held by another process/]]) {
187
- const before = fs.readFileSync(ws.cfgPath);
188
- const launch = snapshotLaunchFiles(ws.dir);
189
- const r = await runSwitch(ws, ['codex', 'bf']);
190
- assert.notEqual(r.status, 0, r.stdout);
191
- assert.match(r.stderr, why);
192
- assert.deepEqual(fs.readFileSync(ws.cfgPath), before, 'config.json is byte-identical');
193
- assert.ok(!fs.existsSync(path.join(ws.dir, 'blindfold.json')));
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. Chosen only now: other test files
304
- // run at the same time, and a port picked seconds earlier can be taken in between.
305
- bfPort = await freePort();
306
- onDisk.blindfold = { port: bfPort };
307
- fs.writeFileSync(ws.cfgPath, JSON.stringify(onDisk, null, 2), { mode: 0o600 });
308
- const on = await post('/api/switch', { target: 'codex', profile: 'bf' });
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);
@@ -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
- setTimeout(() => fs.rmSync(lock, { force: true }), 400);
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
- if (chk && chk.checked) any1m = true;
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) {