llm-switcher 1.2.3 → 1.2.5

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,18 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.2.5
4
+
5
+ - **Dashboard controls:** The dashboard controls use MIT elements from Uiverse.io galaxy as a base. The CSS gives the name of each author.
6
+ - **Switch and checkbox:** The switch knob moves with a short spring and stretches while you press it. When a switch is off, a ring keeps its track visible. A checkbox shows a tick that grows when you select it.
7
+ - **Feedback:** A toast shows a status dot and a bar for the time until it closes. A styled tooltip replaces the browser tooltip for each item that has a title. Test Connection and Sync Models show a spinner while they run. The dialog fades in.
8
+ - **Accessibility:** A switch and a checkbox show a 2px outline when they have keyboard focus. When the system asks for reduced motion, only the spinner moves.
9
+
10
+ ## Release 1.2.4
11
+
12
+ - **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.
13
+ - **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.
14
+ - **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.
15
+
3
16
  ## Release 1.2.3
4
17
 
5
18
  - **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.5",
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
@@ -472,8 +472,10 @@
472
472
  text-decoration: none;
473
473
  font-size: 0.84rem;
474
474
  font-weight: 500;
475
- transition: filter 0.15s, background 0.15s, color 0.15s, border-color 0.15s;
475
+ transition: filter 0.15s, background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
476
476
  }
477
+ /* Press feedback after Uiverse.io ItsEnigma143 (MIT). */
478
+ button:active:not(:disabled), .btn:active:not(:disabled) { transform: scale(0.97); }
477
479
  button.primary, .btn.btn-primary {
478
480
  background: var(--accent-strong);
479
481
  color: var(--accent-fg);
@@ -503,6 +505,21 @@
503
505
  button.icon { min-height: 28px; min-width: 28px; padding: 0.2rem; background: transparent; color: var(--muted); border-radius: 6px; }
504
506
  button.icon:hover { color: var(--fg); background: var(--card2); }
505
507
  button:disabled, .btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
508
+ /* A busy button keeps its full colour: the spinner, not the fade, says it works. */
509
+ button[aria-busy="true"]:disabled { opacity: 1; }
510
+ /* Spinner after Uiverse.io Cybercom682 (MIT): a ring with one open side. */
511
+ button[aria-busy="true"]::before {
512
+ content: "";
513
+ width: 14px;
514
+ height: 14px;
515
+ flex: none;
516
+ box-sizing: border-box;
517
+ border-radius: 50%;
518
+ border: 2px solid currentColor;
519
+ border-right-color: transparent;
520
+ animation: spin 0.7s linear infinite;
521
+ }
522
+ @keyframes spin { to { transform: rotate(1turn); } }
506
523
  .btn-sm { min-height: 26px; padding: 0.2rem 0.6rem; font-size: 0.78rem; border-radius: 6px; }
507
524
 
508
525
  /* Inputs */
@@ -516,7 +533,9 @@
516
533
  min-height: 36px;
517
534
  width: 100%;
518
535
  font-size: 0.85rem;
536
+ transition: border-color 0.15s, box-shadow 0.15s;
519
537
  }
538
+ input:hover:not(:focus):not(:disabled), select:hover:not(:focus):not(:disabled), textarea:hover:not(:focus):not(:disabled) { border-color: var(--faint); }
520
539
  input:focus, select:focus, textarea:focus, button:focus-visible, a:focus-visible {
521
540
  outline: none;
522
541
  border-color: var(--accent);
@@ -530,6 +549,11 @@
530
549
 
531
550
  /* Model slot combobox: one listbox per slot, provider prefix dimmed, match in the accent. */
532
551
  .combo { position: relative; }
552
+ .slot-1m-note { font-size: 0.72rem; color: var(--faint); }
553
+ /* Kept in the page when empty: a live region added later is often not read. */
554
+ .slot-1m-note:empty { margin-top: -0.25rem; }
555
+ .slot-1m-note.warn { color: var(--warn); }
556
+ .slot-1m-note.err { color: var(--err); }
533
557
  .combo > input { padding-right: 2.25rem; font-family: var(--font-mono); font-size: 0.8rem; }
534
558
  .combo-toggle {
535
559
  position: absolute; right: 4px; top: 4px; width: 28px; height: 28px;
@@ -562,12 +586,50 @@
562
586
  @media (prefers-reduced-motion: reduce) { .combo-toggle svg { transition: none; } }
563
587
 
564
588
  /* Switch */
565
- .switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; }
589
+ /* Switch after Uiverse.io iSweat-exe (MIT); the knob stretches while pressed.
590
+ The inset ring keeps the off track visible: --line alone nearly vanishes on a card. */
591
+ .switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; vertical-align: middle; }
566
592
  .switch input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; min-height: 0; }
567
- .switch span { position: absolute; inset: 0; border-radius: 999px; background: var(--line); transition: background 0.15s; pointer-events: none; }
568
- .switch span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #ffffff; transition: transform 0.15s; }
569
- .switch input:checked + span { background: var(--accent-strong); }
593
+ .switch span { position: absolute; inset: 0; border-radius: 999px; background: var(--line); box-shadow: inset 0 0 0 1.5px var(--faint); transition: background-color 0.25s, box-shadow 0.25s; pointer-events: none; }
594
+ .switch span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 999px; background: #ffffff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 0.25s cubic-bezier(0.3, 1.4, 0.6, 1), width 0.15s; }
595
+ .switch input:active + span::after { width: 20px; }
596
+ .switch input:checked + span { background: var(--accent-strong); box-shadow: none; }
570
597
  .switch input:checked + span::after { transform: translateX(16px); }
598
+ .switch input:checked:active + span::after { transform: translateX(12px); }
599
+ .switch input:focus-visible + span, input[type="checkbox"]:not(.switch input):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
600
+
601
+ /* Checkbox after Uiverse.io adamgiebl (MIT): the tick scales in.
602
+ !important beats the inline sizes on the log and 1M boxes. */
603
+ input[type="checkbox"]:not(.switch input) {
604
+ appearance: none;
605
+ width: 16px !important;
606
+ height: 16px !important;
607
+ min-height: 0 !important;
608
+ padding: 0;
609
+ margin: 0;
610
+ flex: none;
611
+ border: 1.5px solid var(--faint);
612
+ border-radius: 4px;
613
+ background: var(--bg);
614
+ cursor: pointer;
615
+ display: inline-grid;
616
+ place-content: center;
617
+ vertical-align: middle;
618
+ transition: background-color 0.15s, border-color 0.15s;
619
+ }
620
+ input[type="checkbox"]:not(.switch input)::before {
621
+ content: "";
622
+ width: 4px;
623
+ height: 8px;
624
+ margin-top: -2px;
625
+ border: solid var(--accent-fg);
626
+ border-width: 0 2px 2px 0;
627
+ transform: rotate(45deg) scale(0);
628
+ transition: transform 0.15s cubic-bezier(0.3, 1.4, 0.6, 1);
629
+ }
630
+ input[type="checkbox"]:not(.switch input):checked { background: var(--accent-strong); border-color: var(--accent-strong); }
631
+ input[type="checkbox"]:not(.switch input):checked::before { transform: rotate(45deg) scale(1); }
632
+ input[type="checkbox"]:not(.switch input):disabled { opacity: 0.45; cursor: default; }
571
633
 
572
634
  /* Modal */
573
635
  .overlay {
@@ -579,7 +641,11 @@
579
641
  place-items: center;
580
642
  padding: 1rem;
581
643
  z-index: 50;
644
+ opacity: 1;
645
+ transition: opacity 0.18s;
582
646
  }
647
+ .overlay .modal { transition: transform 0.18s cubic-bezier(0.2, 0.9, 0.3, 1.2); }
648
+ @starting-style { .overlay { opacity: 0; } .overlay .modal { transform: scale(0.96) translateY(8px); } }
583
649
  .modal {
584
650
  background: var(--card);
585
651
  border: 1px solid var(--line);
@@ -670,6 +736,7 @@
670
736
  }
671
737
 
672
738
  /* Toast */
739
+ /* Toast after Uiverse.io alexruix (MIT): it slides in; the bar shows how long it stays. */
673
740
  .toast {
674
741
  position: fixed;
675
742
  bottom: 1.25rem;
@@ -679,15 +746,50 @@
679
746
  color: var(--fg);
680
747
  border: 1px solid var(--line);
681
748
  box-shadow: var(--shadow);
682
- padding: 0.55rem 1.1rem;
749
+ padding: 0.6rem 1.1rem 0.6rem 0.85rem;
683
750
  border-radius: 10px;
684
751
  font-size: 0.85rem;
685
752
  z-index: 60;
686
753
  display: none;
687
- animation: fadeIn 0.15s ease-out;
754
+ align-items: center;
755
+ gap: 0.6rem;
756
+ overflow: hidden;
757
+ max-width: 90vw;
758
+ animation: fadeIn 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.2);
688
759
  }
760
+ .toast::before { content: ""; width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
761
+ .toast::after { content: ""; position: absolute; left: 0; bottom: 0; width: 100%; height: 2px; background: var(--accent); transform-origin: left; animation: toastbar 3.2s linear forwards; }
689
762
  .toast.err { border-color: var(--err); color: var(--err); }
690
- @keyframes fadeIn { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } }
763
+ .toast.err::before { background: var(--err); box-shadow: 0 0 0 3px var(--err-soft); }
764
+ .toast.err::after { background: var(--err); }
765
+ @keyframes fadeIn { from { opacity: 0; transform: translate(-50%, 12px); } to { opacity: 1; transform: translate(-50%, 0); } }
766
+ @keyframes toastbar { to { transform: scaleX(0); } }
767
+ /* Tooltip after Uiverse.io elijahgummer (MIT): a short fade and rise that
768
+ replaces the native title bubble. */
769
+ .tip {
770
+ position: fixed;
771
+ z-index: 70;
772
+ pointer-events: none;
773
+ background: var(--fg);
774
+ color: var(--bg);
775
+ font-size: 0.72rem;
776
+ font-weight: 500;
777
+ padding: 0.3rem 0.55rem;
778
+ border-radius: 6px;
779
+ white-space: nowrap;
780
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
781
+ opacity: 1;
782
+ translate: 0 0;
783
+ transition: opacity 0.15s, translate 0.2s cubic-bezier(0.3, 1.4, 0.6, 1), display 0.15s;
784
+ transition-behavior: allow-discrete;
785
+ }
786
+ .tip[hidden] { display: none; opacity: 0; translate: 0 4px; }
787
+ @starting-style { .tip { opacity: 0; translate: 0 4px; } }
788
+ @media (prefers-reduced-motion: reduce) {
789
+ *, ::before, ::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
790
+ button[aria-busy="true"]::before { animation-duration: 1.5s !important; }
791
+ .toast::after { animation-duration: 3.2s !important; }
792
+ }
691
793
 
692
794
  /* Test Result Box in Modal */
693
795
  .test-result-box {
@@ -1112,7 +1214,8 @@
1112
1214
  </div>
1113
1215
  </div>
1114
1216
 
1115
- <div id="toast" class="toast"></div>
1217
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
1218
+ <div id="tip" class="tip" role="tooltip" hidden></div>
1116
1219
 
1117
1220
  <script>
1118
1221
  // State & Globals
@@ -1138,6 +1241,7 @@
1138
1241
  let formLoadedKey = null;
1139
1242
  let formDirty = false;
1140
1243
  let loadedModels = [];
1244
+ let loadedLimits = {}; // model id -> { context, input, output }, as the gateway lists them
1141
1245
  let currentProfileBuffer = null;
1142
1246
  let renderedSlotFormat = 'auto';
1143
1247
  let logRefreshTimer = null;
@@ -1184,11 +1288,40 @@
1184
1288
  if (!toast) return;
1185
1289
  toast.textContent = msg;
1186
1290
  toast.className = 'toast' + (isErr ? ' err' : '');
1187
- toast.style.display = 'block';
1291
+ // Hiding first and reading a layout value restarts the entry animation and the timer bar.
1292
+ toast.style.display = 'none';
1293
+ void toast.offsetWidth;
1294
+ toast.style.display = 'flex';
1188
1295
  clearTimeout(toastTimeout);
1189
1296
  toastTimeout = setTimeout(() => { toast.style.display = 'none'; }, 3200);
1190
1297
  }
1191
1298
 
1299
+ // The title moves to data-tip on first hover, so the native bubble does not show as well.
1300
+ // An input keeps its title: the browser shows it in the validation message.
1301
+ const tip = document.getElementById('tip');
1302
+ function showTip(e) {
1303
+ const n = e.target.closest && e.target.closest('[title]:not(input), [data-tip]');
1304
+ if (!n) return;
1305
+ if (n.title) {
1306
+ n.dataset.tip = n.title;
1307
+ n.removeAttribute('title');
1308
+ if (!n.hasAttribute('aria-label')) n.setAttribute(n.textContent.trim() ? 'aria-description' : 'aria-label', n.dataset.tip);
1309
+ }
1310
+ tip.textContent = n.dataset.tip;
1311
+ tip.hidden = false;
1312
+ const r = n.getBoundingClientRect(), w = tip.offsetWidth, h = tip.offsetHeight;
1313
+ tip.style.left = Math.max(8, Math.min(r.left + r.width / 2 - w / 2, innerWidth - w - 8)) + 'px';
1314
+ tip.style.top = (r.top - h - 8 < 8 ? r.bottom + 8 : r.top - h - 8) + 'px';
1315
+ }
1316
+ const hideTip = () => { tip.hidden = true; };
1317
+ document.addEventListener('pointerover', showTip);
1318
+ document.addEventListener('focusin', showTip);
1319
+ document.addEventListener('pointerout', e => {
1320
+ const n = e.target.closest && e.target.closest('[data-tip]');
1321
+ if (n && !n.contains(e.relatedTarget)) hideTip();
1322
+ });
1323
+ for (const ev of ['focusout', 'click', 'scroll']) addEventListener(ev, hideTip, true);
1324
+
1192
1325
  // Modal Control
1193
1326
  function openProfileModal(isNew = false) {
1194
1327
  const modal = document.getElementById('profile-modal-overlay');
@@ -1436,17 +1569,18 @@
1436
1569
  <div style="display:flex; justify-content:space-between; align-items:center;">
1437
1570
  <label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
1438
1571
  <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' : ''}>
1572
+ <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
1573
  1M context
1441
1574
  </label>
1442
1575
  </div>
1443
1576
  <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)}">
1577
+ <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
1578
  <button type="button" class="combo-toggle" tabindex="-1" aria-label="Show models for ${escapeHtml(s.name)}">
1446
1579
  <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
1580
  </button>
1448
1581
  <ul class="combo-list" id="p-${s.id}-list" role="listbox" aria-label="${escapeHtml(s.name)} models" hidden></ul>
1449
1582
  </div>
1583
+ <div class="slot-1m-note" id="p-${s.id}-1m-note" role="status" aria-live="polite"></div>
1450
1584
  `;
1451
1585
  container.appendChild(slotRow);
1452
1586
  wireCombo(slotRow.querySelector('.combo'));
@@ -1482,12 +1616,43 @@
1482
1616
  return { defaultModels, model1M };
1483
1617
  }
1484
1618
 
1619
+ // A window known to be under 1M locks the box; an unknown one only warns, since many
1620
+ // gateways list no limits at all.
1621
+ function contextVerdict(l) {
1622
+ const window = (l && (l.context || l.input)) || 0;
1623
+ if (!window) return { kind: 'unknown', window: 0 };
1624
+ return { kind: window >= 1000000 ? 'ok' : 'small', window };
1625
+ }
1626
+
1627
+ // 128000 reads as 128K and 262144 as 256K: a round decimal count keeps base 1000.
1628
+ function fmtTokens(n) {
1629
+ const b = n % 1000 === 0 ? 1000 : 1024;
1630
+ return n >= b * b ? +(n / b / b).toFixed(1) + 'M' : n >= b ? Math.round(n / b) + 'K' : String(n);
1631
+ }
1632
+
1485
1633
  function update1mWarnings() {
1486
1634
  const cfg = getActiveSlotConfig(renderedSlotFormat);
1487
1635
  let any1m = false;
1488
1636
  for (const s of cfg.slots) {
1489
1637
  const chk = document.getElementById('p-' + s.id + '-1m');
1490
- if (chk && chk.checked) any1m = true;
1638
+ const note = document.getElementById('p-' + s.id + '-1m-note');
1639
+ const model = document.getElementById('p-' + s.id)?.value.trim() || '';
1640
+ if (!chk) continue;
1641
+ const v = contextVerdict(model ? loadedLimits[model] : null);
1642
+ const locked = v.kind === 'small';
1643
+ if (locked) chk.checked = false;
1644
+ chk.disabled = locked;
1645
+ chk.closest('label').style.opacity = locked ? '0.5' : '';
1646
+ chk.closest('label').style.cursor = locked ? 'not-allowed' : 'pointer';
1647
+ if (note) {
1648
+ let text = '', cls = '';
1649
+ if (locked) { text = `Context ${fmtTokens(v.window)} is under 1M, so 1M context is off for this model.`; cls = 'err'; }
1650
+ else if (v.kind === 'ok') text = `Context ${fmtTokens(v.window)}.`;
1651
+ else if (chk.checked && model) { text = 'The gateway does not list this model\'s context. Make sure it takes 1M tokens.'; cls = 'warn'; }
1652
+ note.textContent = text;
1653
+ note.className = 'slot-1m-note' + (cls ? ' ' + cls : '');
1654
+ }
1655
+ if (chk.checked) any1m = true;
1491
1656
  }
1492
1657
  const warnCallout = document.getElementById('warn-1m-callout');
1493
1658
  if (warnCallout) warnCallout.style.display = any1m ? 'inline-flex' : 'none';
@@ -1952,6 +2117,7 @@
1952
2117
 
1953
2118
  btn.innerText = 'Testing...';
1954
2119
  btn.disabled = true;
2120
+ btn.setAttribute('aria-busy', 'true');
1955
2121
  box.style.display = 'none';
1956
2122
 
1957
2123
  try {
@@ -2002,6 +2168,7 @@
2002
2168
  } finally {
2003
2169
  btn.innerText = 'Test Connection';
2004
2170
  btn.disabled = false;
2171
+ btn.removeAttribute('aria-busy');
2005
2172
  }
2006
2173
  }
2007
2174
 
@@ -2058,6 +2225,7 @@
2058
2225
  if (request !== modelsRequest) return; // a newer load started while this one waited
2059
2226
  if (data.ok && Array.isArray(data.models)) {
2060
2227
  loadedModels = data.models;
2228
+ loadedLimits = data.limits || {};
2061
2229
  modelsLoadedFor = source;
2062
2230
  setModelsStatus('ready', loadedModels.length + (loadedModels.length === 1 ? ' model' : ' models'), 'Reload');
2063
2231
  } else {
@@ -2072,6 +2240,7 @@
2072
2240
  setModelsStatus('error', 'Models not loaded: ' + err.message, 'Retry');
2073
2241
  }
2074
2242
  document.querySelectorAll('.combo[data-open]').forEach(renderComboOptions);
2243
+ update1mWarnings();
2075
2244
  }
2076
2245
 
2077
2246
  // Slot combobox (ARIA combobox with a listbox). Typing filters; the arrow opens the full list.
@@ -2188,6 +2357,7 @@
2188
2357
  if (!model) return;
2189
2358
  combo.querySelector('input').value = model;
2190
2359
  closeCombo(combo);
2360
+ update1mWarnings();
2191
2361
  }
2192
2362
 
2193
2363
  function onComboKeydown(e) {
@@ -2283,7 +2453,7 @@
2283
2453
 
2284
2454
  async function refreshCatalogUpstream() {
2285
2455
  const btn = document.getElementById('btn-sync-catalog');
2286
- if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; }
2456
+ if (btn) { btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Syncing...'; }
2287
2457
  showToast('Refreshing model catalog from upstream...');
2288
2458
  try {
2289
2459
  const res = await api('/api/catalog/refresh', { method: 'POST' });
@@ -2297,7 +2467,7 @@
2297
2467
  } catch (err) {
2298
2468
  showToast('Network error syncing catalog', true);
2299
2469
  } finally {
2300
- if (btn) { btn.disabled = false; btn.textContent = '⚡ Sync Models with Upstream'; }
2470
+ if (btn) { btn.disabled = false; btn.removeAttribute('aria-busy'); btn.textContent = '⚡ Sync Models with Upstream'; }
2301
2471
  }
2302
2472
  }
2303
2473