llm-switcher 1.3.1 → 1.3.3
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/.github/workflows/test.yml +31 -0
- package/CHANGELOG.md +14 -0
- package/README.md +22 -3
- package/README.vi.md +21 -3
- package/agy-relay-lib.mjs +86 -0
- package/agy-relay.mjs +142 -0
- package/blindfold/make-certs.sh +2 -1
- package/package.json +1 -1
- package/proxy.mjs +100 -3
- package/shim.mjs +24 -2
- package/state.mjs +32 -0
- package/switch.mjs +10 -3
- package/tests/agy-relay.test.mjs +316 -0
- package/tests/agy.e2e.test.mjs +124 -7
- package/tests/agy.unit.test.mjs +3 -0
- package/tests/cdp.mjs +31 -6
- package/tests/dashboard.e2e.test.mjs +6 -2
- package/tests/gateway-secret.test.mjs +124 -0
- package/tests/gateway.e2e.test.mjs +2 -0
- package/tests/make-certs.test.mjs +14 -0
- package/tests/shim.test.mjs +5 -2
- package/tests/switch.test.mjs +19 -0
- package/tests/update.e2e.test.mjs +8 -1
- package/tests/ws-client.mjs +46 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# The shims and the blindfold certificates have one code path per platform, and a test for a
|
|
2
|
+
# platform skips itself everywhere else. Only a run on each platform proves all of them.
|
|
3
|
+
name: test
|
|
4
|
+
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
pull_request:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
name: Node ${{ matrix.node }} on ${{ matrix.os }}
|
|
13
|
+
runs-on: ${{ matrix.os }}
|
|
14
|
+
# A local run takes about 2 minutes. A hang must fail the job, not hold it for 6 hours.
|
|
15
|
+
timeout-minutes: 20
|
|
16
|
+
strategy:
|
|
17
|
+
fail-fast: false
|
|
18
|
+
matrix:
|
|
19
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
20
|
+
node: [22]
|
|
21
|
+
include:
|
|
22
|
+
# The lowest version that package.json accepts.
|
|
23
|
+
- os: ubuntu-latest
|
|
24
|
+
node: 18
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v7
|
|
27
|
+
- uses: actions/setup-node@v7
|
|
28
|
+
with:
|
|
29
|
+
node-version: ${{ matrix.node }}
|
|
30
|
+
package-manager-cache: false
|
|
31
|
+
- run: npm test
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.3.3
|
|
4
|
+
|
|
5
|
+
- **agy runs behind a relay:** A routed agy no longer gets the gateway port. The shim starts it behind `agy-relay.mjs`, which holds its own loopback port for as long as agy runs. For each connection, the relay asks the gateway for its identity proof on the same socket and sends agy's request only after the proof holds. Before, the shim checked the gateway only at launch, so a gateway that stopped during an agy session left its port free for another program to take, with agy's Google token sent to it. If the proof fails now, agy gets a 502 and the token stays in the relay.
|
|
6
|
+
- **A relay key that no page serves:** The relay accepts only `relayProof`, an HMAC keyed by `gateway.secret`, a random file next to `admin.token`. The gateway makes it at startup and keeps no copy. The dashboard page carries `admin.token`, so a proof keyed by it could be made by any program that loads the page. A gateway older than the relay gets a 502 that says to restart it.
|
|
7
|
+
- **A `.cmd` agy gets its arguments unchanged:** The relay starts a `.cmd` or `.bat` agy through `%SystemRoot%\System32\cmd.exe /e:ON /v:OFF /d /c` with the quoting of the Rust standard library since CVE-2024-24576. `%` cannot expand a variable, and `&` or `|` cannot start a second command. A line break, or a command line over 8191 characters, stops the relay before agy starts, with a hint to pipe the prompt on stdin.
|
|
8
|
+
- **`switch off` and `switch port` leave another instance alone:** They stopped or rewrote the installed service even when it ran the gateway of another port. A second instance (`LLM_SWITCHER_PORT`) then stopped the first one. The service now counts only for the port its definition names. The test suite triggered the same fault and stopped the real gateway of the machine.
|
|
9
|
+
|
|
10
|
+
## Release 1.3.2
|
|
11
|
+
|
|
12
|
+
- **Codex over WebSocket crosses Bifrost:** Codex uses its WebSocket transport by default. The gateway now sends each WebSocket turn to intact `/v1/responses` as one HTTP request with the whole conversation, and sends each event back as one frame. Before, these turns took the convert route. A stream that stops before `response.completed` ends the turn with `response.failed`.
|
|
13
|
+
- **All Codex clients cross Bifrost:** `codex exec` sends `codex_exec/` in its `User-Agent`, not `codex_cli_rs/`. The gateway takes `codex_cli_rs`, `codex_exec`, `codex_vscode` and `codex_sdk_ts` as one client. Before, `codex exec` never crossed Bifrost.
|
|
14
|
+
- **`make-certs.sh` and a Windows path:** The script read a lone argument such as `C:\Users\me\certs` as a host name and ignored it, so it rebuilt the certificates of the checkout instead. A running interceptor then served a leaf from the old CA, and Codex failed with `workspace routing discovery failed`. A backslash now marks a path.
|
|
15
|
+
- **Tests:** `npm test` no longer rebuilds the certificates of the checkout. The suite runs on Linux, macOS and Windows in CI, and on Node 18.
|
|
16
|
+
|
|
3
17
|
## Release 1.3.1
|
|
4
18
|
|
|
5
19
|
- **agy note:** `switch plugin status` now names agy 1.2.14. This release of agy also loads `~/.gemini/antigravity-cli/hooks.json` but does not run its hooks (measured 2026-10-03), so the shim toast is still the notice for agy.
|
package/README.md
CHANGED
|
@@ -337,6 +337,19 @@ gateway for its identity proof before it gives agy the variable. If the port doe
|
|
|
337
337
|
the proof of this install within 3 seconds, the shim removes the variable, prints one line, and agy
|
|
338
338
|
uses its official endpoint.
|
|
339
339
|
|
|
340
|
+
The gateway can also stop while agy runs (`switch off`, a crash, an update). Its port is then free,
|
|
341
|
+
and another program can take it. So a routed agy never talks to the gateway port directly. The shim
|
|
342
|
+
starts agy behind a relay (`agy-relay.mjs`) that holds its own loopback port for as long as agy
|
|
343
|
+
runs. For each connection from agy, the relay asks the gateway for its identity proof on the same
|
|
344
|
+
socket, and sends the request on that socket only after the proof holds. If the proof fails, agy
|
|
345
|
+
gets a 502, and its token stays in the relay.
|
|
346
|
+
|
|
347
|
+
The relay accepts only a proof keyed by `gateway.secret`, a random key next to `admin.token`. No
|
|
348
|
+
page and no route of the gateway serves this key. The dashboard page carries `admin.token`, so a
|
|
349
|
+
proof keyed by `admin.token` could be made by any program that loads the page. If the running
|
|
350
|
+
gateway is older than the relay, agy gets a 502 that says to restart it (`switch off`, then
|
|
351
|
+
`switch on`).
|
|
352
|
+
|
|
340
353
|
### Blindfold mode (optional)
|
|
341
354
|
|
|
342
355
|
A base URL override makes Codex print one line on its own `/model` screen:
|
|
@@ -480,11 +493,17 @@ Bifrost has no setting. The gateway turns it on for each request when two condit
|
|
|
480
493
|
| Tool | `bifrost_ua` | Path on intact |
|
|
481
494
|
| --- | --- | --- |
|
|
482
495
|
| Claude Code | `claude-cli/` | `/v1/messages` |
|
|
483
|
-
| Codex (HTTP) | `codex_cli_rs/` | `/v1/responses` |
|
|
496
|
+
| Codex (HTTP and WebSocket) | `codex_cli_rs/` | `/v1/responses` |
|
|
484
497
|
| Antigravity CLI | `antigravity/cli/` | `/v1/v1internal:streamGenerateContent`, `/v1/v1internal:generateContent` |
|
|
485
498
|
|
|
486
|
-
For agy, intact also writes the project of the chosen account into the request.
|
|
487
|
-
|
|
499
|
+
For agy, intact also writes the project of the chosen account into the request.
|
|
500
|
+
|
|
501
|
+
Codex names its client in the `User-Agent`: `codex_cli_rs` for the terminal UI, `codex_exec` for
|
|
502
|
+
`codex exec`, `codex_vscode` and `codex_sdk_ts`. The gateway takes all four as the Codex client.
|
|
503
|
+
|
|
504
|
+
Codex over its WebSocket transport crosses Bifrost too. intact keeps no WebSocket state, so the
|
|
505
|
+
gateway sends each turn to `/v1/responses` as one HTTP request with the whole conversation. Each
|
|
506
|
+
event of the answer goes back to Codex as one WebSocket frame, unchanged.
|
|
488
507
|
|
|
489
508
|
The gateway keeps the answer from intact for 10 minutes for each model. Only a 2xx answer is kept that long. If intact does not answer, or answers with an error (a 404 too), the gateway uses the normal route. It asks again after 30 seconds (`LLM_SWITCHER_BIFROST_RETRY_MS`), and it prints one line for each series of failures.
|
|
490
509
|
|
package/README.vi.md
CHANGED
|
@@ -337,6 +337,18 @@ agy gửi token này dạng rõ tới `CLOUD_CODE_URL`. Vì vậy shim `agy` h
|
|
|
337
337
|
trước khi giao biến này cho agy. Nếu cổng không trả lời đúng bằng chứng của bản cài này trong 3 giây,
|
|
338
338
|
shim gỡ biến, in một dòng thông báo, và agy dùng endpoint chính thức.
|
|
339
339
|
|
|
340
|
+
Gateway cũng có thể dừng trong lúc agy đang chạy (`switch off`, crash, cập nhật). Khi đó cổng của nó
|
|
341
|
+
bỏ trống, và một chương trình khác có thể chiếm cổng. Vì vậy agy đã route không bao giờ nói chuyện
|
|
342
|
+
trực tiếp với cổng gateway. Shim chạy agy sau một relay (`agy-relay.mjs`) giữ cổng loopback riêng
|
|
343
|
+
suốt thời gian agy chạy. Với mỗi kết nối của agy, relay hỏi gateway bằng chứng danh tính trên chính
|
|
344
|
+
socket đó, và chỉ gửi request trên socket ấy khi bằng chứng đúng. Nếu sai, agy nhận 502, và token
|
|
345
|
+
vẫn nằm lại trong relay.
|
|
346
|
+
|
|
347
|
+
Relay chỉ chấp nhận bằng chứng ký bằng `gateway.secret`, một khóa ngẫu nhiên nằm cạnh `admin.token`.
|
|
348
|
+
Không trang và không route nào của gateway trả về khóa này. Trang dashboard chứa `admin.token`, nên
|
|
349
|
+
bằng chứng ký bằng `admin.token` thì chương trình nào tải trang cũng làm giả được. Nếu gateway đang
|
|
350
|
+
chạy cũ hơn relay, agy nhận 502 kèm hướng dẫn khởi động lại (`switch off`, rồi `switch on`).
|
|
351
|
+
|
|
340
352
|
### Chế độ blindfold (tùy chọn)
|
|
341
353
|
|
|
342
354
|
Khi có override base URL, Codex in một dòng ngay trên màn `/model` của nó:
|
|
@@ -475,11 +487,17 @@ Bifrost không có cấu hình. Gateway tự bật Bifrost cho từng request kh
|
|
|
475
487
|
| Công cụ | `bifrost_ua` | Path trên intact |
|
|
476
488
|
| --- | --- | --- |
|
|
477
489
|
| Claude Code | `claude-cli/` | `/v1/messages` |
|
|
478
|
-
| Codex (HTTP) | `codex_cli_rs/` | `/v1/responses` |
|
|
490
|
+
| Codex (HTTP và WebSocket) | `codex_cli_rs/` | `/v1/responses` |
|
|
479
491
|
| Antigravity CLI | `antigravity/cli/` | `/v1/v1internal:streamGenerateContent`, `/v1/v1internal:generateContent` |
|
|
480
492
|
|
|
481
|
-
Với agy, intact ghi thêm project của tài khoản được chọn vào request.
|
|
482
|
-
|
|
493
|
+
Với agy, intact ghi thêm project của tài khoản được chọn vào request.
|
|
494
|
+
|
|
495
|
+
Codex ghi tên client của nó trong `User-Agent`: `codex_cli_rs` cho giao diện terminal, `codex_exec`
|
|
496
|
+
cho `codex exec`, `codex_vscode` và `codex_sdk_ts`. Gateway coi cả bốn là client Codex.
|
|
497
|
+
|
|
498
|
+
Codex qua WebSocket cũng đi Bifrost. intact không giữ trạng thái WebSocket, nên gateway gửi mỗi lượt
|
|
499
|
+
tới `/v1/responses` thành một request HTTP chứa toàn bộ hội thoại. Mỗi event của câu trả lời quay về
|
|
500
|
+
Codex thành một frame WebSocket, giữ nguyên.
|
|
483
501
|
|
|
484
502
|
Gateway giữ câu trả lời của intact 10 phút cho mỗi model. Chỉ câu trả lời 2xx được giữ lâu như vậy. Nếu intact không trả lời, hoặc trả lỗi (kể cả 404), gateway dùng đường thường. Gateway hỏi lại sau 30 giây (`LLM_SWITCHER_BIFROST_RETRY_MS`) và in một dòng cho mỗi chuỗi lỗi.
|
|
485
503
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Pure helpers of agy-relay.mjs, kept apart so tests can import them without starting a relay.
|
|
2
|
+
|
|
3
|
+
// The switcher writes 127.0.0.1 only. localhost can resolve to ::1, which no proof covers.
|
|
4
|
+
export function gatewayPort(raw) {
|
|
5
|
+
let u;
|
|
6
|
+
try { u = new URL(raw); } catch { return 0; }
|
|
7
|
+
if (u.protocol !== 'http:' || u.hostname !== '127.0.0.1') return 0;
|
|
8
|
+
const port = Number(u.port);
|
|
9
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// One complete HTTP/1.1 answer at the start of `buf`: { status, text, rest }, or null while it is
|
|
13
|
+
// still incomplete. An answer with no length cannot share its socket, so it counts as a failure.
|
|
14
|
+
export function readAnswer(buf) {
|
|
15
|
+
const end = buf.indexOf('\r\n\r\n');
|
|
16
|
+
if (end < 0) return null;
|
|
17
|
+
const lines = buf.subarray(0, end).toString('latin1').split('\r\n');
|
|
18
|
+
const status = Number(lines[0].split(' ')[1]);
|
|
19
|
+
const headers = {};
|
|
20
|
+
for (const line of lines.slice(1)) {
|
|
21
|
+
const i = line.indexOf(':');
|
|
22
|
+
if (i > 0) headers[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();
|
|
23
|
+
}
|
|
24
|
+
const rest = buf.subarray(end + 4);
|
|
25
|
+
if (/close/i.test(headers.connection || '')) return { status: 0, text: '', rest };
|
|
26
|
+
if (headers['content-length'] !== undefined) {
|
|
27
|
+
const n = Number(headers['content-length']);
|
|
28
|
+
if (!Number.isInteger(n) || n < 0) return { status: 0, text: '', rest };
|
|
29
|
+
if (rest.length < n) return null;
|
|
30
|
+
return { status, text: rest.subarray(0, n).toString('utf8'), rest: rest.subarray(n) };
|
|
31
|
+
}
|
|
32
|
+
if (/chunked/i.test(headers['transfer-encoding'] || '')) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
let off = 0;
|
|
35
|
+
for (;;) {
|
|
36
|
+
const lineEnd = rest.indexOf('\r\n', off);
|
|
37
|
+
if (lineEnd < 0) return null;
|
|
38
|
+
const size = parseInt(rest.subarray(off, lineEnd).toString('latin1'), 16);
|
|
39
|
+
if (!Number.isInteger(size) || size < 0) return { status: 0, text: '', rest };
|
|
40
|
+
if (size === 0) {
|
|
41
|
+
if (rest.length < lineEnd + 4) return null;
|
|
42
|
+
return { status, text: Buffer.concat(parts).toString('utf8'), rest: rest.subarray(lineEnd + 4) };
|
|
43
|
+
}
|
|
44
|
+
if (rest.length < lineEnd + 2 + size + 2) return null;
|
|
45
|
+
parts.push(rest.subarray(lineEnd + 2, lineEnd + 2 + size));
|
|
46
|
+
off = lineEnd + 2 + size + 2;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { status: 0, text: '', rest };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The longest string cmd.exe takes after /c.
|
|
53
|
+
export const CMD_LIMIT = 8191;
|
|
54
|
+
const SAFE = /^[A-Za-z0-9#$*+\-./:?@\\_]$/;
|
|
55
|
+
|
|
56
|
+
// One argument for a batch file, as the Rust standard library builds it since CVE-2024-24576
|
|
57
|
+
// (make_bat_command_line): `"` becomes `""`, and `%` becomes `%%cd:~,%`, which expands to `%`, so
|
|
58
|
+
// cmd.exe never expands a %name% or %name:x=y% that came from the argument.
|
|
59
|
+
function batArg(arg) {
|
|
60
|
+
const quote = arg === '' || arg.endsWith('\\') || [...arg].some(c => !SAFE.test(c));
|
|
61
|
+
let out = quote ? '"' : '';
|
|
62
|
+
let backslashes = 0;
|
|
63
|
+
for (const c of arg) {
|
|
64
|
+
if (c === '\\') {
|
|
65
|
+
backslashes++;
|
|
66
|
+
} else {
|
|
67
|
+
if (c === '"') out += `${'\\'.repeat(backslashes)}"`;
|
|
68
|
+
else if (c === '%') out += '%%cd:~,';
|
|
69
|
+
backslashes = 0;
|
|
70
|
+
}
|
|
71
|
+
out += c;
|
|
72
|
+
}
|
|
73
|
+
if (quote) out += `${'\\'.repeat(backslashes)}"`;
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The cmd.exe arguments that run a .cmd/.bat with `args` unchanged: { args } or { error }.
|
|
78
|
+
export function batCommandLine(script, args) {
|
|
79
|
+
if (script.includes('"') || script.endsWith('\\')) return { error: `the path of agy (${script}) cannot hold " or end with \\` };
|
|
80
|
+
for (const a of args) {
|
|
81
|
+
if (/[\r\n]/.test(a)) return { error: 'an argument holds a line break, which a .cmd or .bat agy cannot receive. Pipe the prompt on stdin instead.' };
|
|
82
|
+
}
|
|
83
|
+
const inner = `"${[`"${script.replace(/%/g, '%%cd:~,%')}"`, ...args.map(batArg)].join(' ')}"`;
|
|
84
|
+
if (inner.length > CMD_LIMIT) return { error: `the command line is longer than ${CMD_LIMIT} characters, the limit of cmd.exe. Pipe the prompt on stdin instead.` };
|
|
85
|
+
return { args: ['/e:ON', '/v:OFF', '/d', '/c', inner] };
|
|
86
|
+
}
|
package/agy-relay.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs agy behind a relay that holds its own loopback port for exactly as long as agy runs.
|
|
3
|
+
//
|
|
4
|
+
// agy sends its Google token in clear text to CLOUD_CODE_URL. The shim checks the gateway once,
|
|
5
|
+
// at launch. A gateway that stops later frees its port, and another program can take it. So agy
|
|
6
|
+
// talks only to this relay: for each connection it asks the gateway for its relay proof on the
|
|
7
|
+
// socket it will use, and sends agy's bytes only after that proof holds.
|
|
8
|
+
//
|
|
9
|
+
// Usage: node agy-relay.mjs <gateway-url> <agy> [args...]
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { relayProof } from './state.mjs';
|
|
15
|
+
import { gatewayPort, readAnswer, batCommandLine } from './agy-relay-lib.mjs';
|
|
16
|
+
|
|
17
|
+
// The proof of a live gateway takes milliseconds; a squatter gains nothing from a longer wait.
|
|
18
|
+
const PROOF_MS = 3000;
|
|
19
|
+
const MAX_PROBE_ANSWER = 64 * 1024;
|
|
20
|
+
|
|
21
|
+
// The gateway on a new socket: { sock } when it proved itself with the relay key (state.mjs),
|
|
22
|
+
// otherwise { old: true } for a gateway built before relayProof, or {} for anything else. Only the
|
|
23
|
+
// first case gets agy's bytes, on this same socket, so no other process can answer them.
|
|
24
|
+
function provenSocket(port) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
27
|
+
const sock = net.connect(port, '127.0.0.1');
|
|
28
|
+
let buf = Buffer.alloc(0);
|
|
29
|
+
let settled = false;
|
|
30
|
+
const finish = (result) => {
|
|
31
|
+
if (settled) return;
|
|
32
|
+
settled = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
sock.off('data', onData);
|
|
35
|
+
sock.off('error', onFail);
|
|
36
|
+
sock.off('close', onFail);
|
|
37
|
+
if (result.sock) return resolve(result);
|
|
38
|
+
sock.destroy();
|
|
39
|
+
resolve(result);
|
|
40
|
+
};
|
|
41
|
+
const onFail = () => finish({});
|
|
42
|
+
const onData = (chunk) => {
|
|
43
|
+
buf = Buffer.concat([buf, chunk]);
|
|
44
|
+
if (buf.length > MAX_PROBE_ANSWER) return finish({});
|
|
45
|
+
const answer = readAnswer(buf);
|
|
46
|
+
if (!answer) return;
|
|
47
|
+
// Nothing may follow the probe answer: those bytes would reach agy as its own answer.
|
|
48
|
+
if (answer.status !== 200 || answer.rest.length) return finish({});
|
|
49
|
+
let body;
|
|
50
|
+
try { body = JSON.parse(answer.text); } catch { return finish({}); }
|
|
51
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return finish({});
|
|
52
|
+
if (body.proxy !== 'llm-switcher' || body.port !== port) return finish({});
|
|
53
|
+
// An absent field, never a falsy one: relayProof '' must still fail the check below.
|
|
54
|
+
if (!Object.hasOwn(body, 'relayProof')) return finish({ old: true });
|
|
55
|
+
const expected = relayProof(nonce, { port, pid: body.pid });
|
|
56
|
+
finish(Boolean(expected) && body.relayProof === expected ? { sock } : {});
|
|
57
|
+
};
|
|
58
|
+
const timer = setTimeout(onFail, PROOF_MS);
|
|
59
|
+
sock.on('data', onData);
|
|
60
|
+
sock.on('error', onFail);
|
|
61
|
+
sock.on('close', onFail);
|
|
62
|
+
sock.write(`GET /health?challenge=${nonce} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: keep-alive\r\n\r\n`);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// agy shows a Code Assist error, and its request stays in this process.
|
|
67
|
+
function refusal(message) {
|
|
68
|
+
const body = JSON.stringify({ error: { code: 502, status: 'UNAVAILABLE', message } });
|
|
69
|
+
return `HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`;
|
|
70
|
+
}
|
|
71
|
+
const NOT_PROVED = 'LLM Switcher: the gateway did not prove its identity, so this request was not sent. Run `switch on`, or `switch off agy`.';
|
|
72
|
+
const OLD_GATEWAY = 'LLM Switcher: the running gateway is older than this relay. Restart it: `switch off`, then `switch on`.';
|
|
73
|
+
|
|
74
|
+
function startRelay(port) {
|
|
75
|
+
// pauseOnConnect: agy's bytes stay unread until the gateway on this connection has proved itself.
|
|
76
|
+
const server = net.createServer({ pauseOnConnect: true }, async (client) => {
|
|
77
|
+
client.on('error', () => client.destroy());
|
|
78
|
+
const { sock: up, old } = await provenSocket(port);
|
|
79
|
+
if (client.destroyed) return up?.destroy();
|
|
80
|
+
if (!up) {
|
|
81
|
+
client.end(refusal(old ? OLD_GATEWAY : NOT_PROVED));
|
|
82
|
+
client.resume(); // read and drop the request; nothing of it leaves this process
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
up.on('error', () => client.destroy());
|
|
86
|
+
client.on('close', () => up.destroy());
|
|
87
|
+
up.on('close', () => client.destroy());
|
|
88
|
+
client.pipe(up);
|
|
89
|
+
up.pipe(client);
|
|
90
|
+
client.resume();
|
|
91
|
+
});
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
server.once('error', reject);
|
|
94
|
+
server.listen(0, '127.0.0.1', () => resolve(server));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A .cmd/.bat cannot start without cmd.exe. It is always the one in System32: a bare name would be
|
|
99
|
+
// looked up in the current directory first.
|
|
100
|
+
function launch(file, args, env) {
|
|
101
|
+
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(file)) {
|
|
102
|
+
const line = batCommandLine(file, args);
|
|
103
|
+
if (line.error) {
|
|
104
|
+
console.error(`[llm-switcher] cannot start agy: ${line.error}`);
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
const cmd = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe');
|
|
108
|
+
return spawn(cmd, line.args, { stdio: 'inherit', env, windowsVerbatimArguments: true });
|
|
109
|
+
}
|
|
110
|
+
return spawn(file, args, { stdio: 'inherit', env });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const [gatewayUrl, agy, ...args] = process.argv.slice(2);
|
|
114
|
+
if (!agy) {
|
|
115
|
+
console.error('Usage: node agy-relay.mjs <gateway-url> <agy> [args...]');
|
|
116
|
+
process.exit(2);
|
|
117
|
+
}
|
|
118
|
+
if (args.some(a => a.includes('\0'))) {
|
|
119
|
+
console.error('[llm-switcher] cannot start agy: an argument holds a NUL character.');
|
|
120
|
+
process.exit(2);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const env = { ...process.env };
|
|
124
|
+
delete env.CLOUD_CODE_URL;
|
|
125
|
+
const port = gatewayPort(gatewayUrl);
|
|
126
|
+
if (port) {
|
|
127
|
+
const server = await startRelay(port);
|
|
128
|
+
env.CLOUD_CODE_URL = `http://127.0.0.1:${server.address().port}`;
|
|
129
|
+
} else {
|
|
130
|
+
console.error(`[llm-switcher] ${String(gatewayUrl)} is not the address of this switcher's gateway; agy uses its official endpoint.`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const child = launch(agy, args, env);
|
|
134
|
+
// Ctrl+C reaches agy and this relay together. agy decides what it means; the relay ends with agy.
|
|
135
|
+
process.on('SIGINT', () => {});
|
|
136
|
+
if (process.platform === 'win32') process.on('SIGBREAK', () => {});
|
|
137
|
+
process.on('SIGTERM', () => child.kill());
|
|
138
|
+
child.on('error', (err) => {
|
|
139
|
+
console.error(`[llm-switcher] cannot start agy (${agy}): ${err.message}`);
|
|
140
|
+
process.exit(127);
|
|
141
|
+
});
|
|
142
|
+
child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
|
package/blindfold/make-certs.sh
CHANGED
|
@@ -35,10 +35,11 @@ elif [ -d "$ROOT/.git" ]; then DEFAULT_OUT="$ROOT/blindfold/certs"
|
|
|
35
35
|
else DEFAULT_OUT="$HOME/.llm-switcher/blindfold/certs"; fi
|
|
36
36
|
# Older commands read `make-certs.sh <host> <out-dir>`. The host list is fixed now, so the host is
|
|
37
37
|
# ignored; two arguments keep those commands working. A lone path-like argument is the directory.
|
|
38
|
+
# A Windows path (C:\...\certs) has no forward slash, so a backslash also marks a path.
|
|
38
39
|
OUT_ARG=""
|
|
39
40
|
if [ "$#" -ge 2 ]; then OUT_ARG="$2"
|
|
40
41
|
elif [ "$#" -eq 1 ]; then
|
|
41
|
-
case "$1" in
|
|
42
|
+
case "$1" in */*|*\\*|.*) OUT_ARG="$1" ;; *) : ;; esac
|
|
42
43
|
fi
|
|
43
44
|
OUT_DIR="${OUT_ARG:-$DEFAULT_OUT}"
|
|
44
45
|
CA_DAYS=3650
|
package/package.json
CHANGED
package/proxy.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
getActiveMap, setTargetProfile, activateProfile, deactivateProfile, deactivateAll, deleteProfile,
|
|
23
23
|
isProfileActive, profileAcceptsTarget, applyLaunchState, readLaunchFlags, redactConfig, MASKED_KEY,
|
|
24
24
|
modelForSlot, primaryModel, codexPublicModel, isSafeModelName, parsePort, CODEX_MODEL_SLOTS,
|
|
25
|
-
ensureAdminToken, identityProof, reconcileBlindfold, checkBlindfoldTarget,
|
|
25
|
+
ensureAdminToken, ensureGatewaySecret, relayProof, identityProof, reconcileBlindfold, checkBlindfoldTarget,
|
|
26
26
|
codexModelEntry, smallestWindows, publicModelWindows, model1MForSlot,
|
|
27
27
|
contractLabSettings, STATE_DIR, stopRecordedBlindfold
|
|
28
28
|
} from './state.mjs';
|
|
@@ -116,6 +116,8 @@ function checkRequestOrigin(req) {
|
|
|
116
116
|
// The Host/Origin guard stops browser pages only. A local process that is not the owner must
|
|
117
117
|
// also present the token from admin.token (mode 0600) to use /api/*.
|
|
118
118
|
const ADMIN_TOKEN = Buffer.from(ensureAdminToken());
|
|
119
|
+
// The relay key. The gateway keeps no copy: relayProof() reads the file for each proof.
|
|
120
|
+
ensureGatewaySecret();
|
|
119
121
|
|
|
120
122
|
// `switch contract-probe` names the trace id of the exchange it drives, so it can print the id
|
|
121
123
|
// before intact holds it. Only a process that can read admin.token is believed, and the marker
|
|
@@ -548,11 +550,17 @@ async function bifrostUAFor(profile, model) {
|
|
|
548
550
|
return ua;
|
|
549
551
|
}
|
|
550
552
|
|
|
553
|
+
// Codex names its originator in the User-Agent: codex_cli_rs (TUI), codex_exec (`codex exec`),
|
|
554
|
+
// codex_vscode, codex_sdk_ts. intact names one of them; each one is the same client.
|
|
555
|
+
const BIFROST_UA_FAMILIES = [['codex_cli_rs/', 'codex_exec/', 'codex_vscode/', 'codex_sdk_ts/']];
|
|
556
|
+
|
|
551
557
|
async function crossesBifrost(req, profile, model) {
|
|
552
558
|
const clientUA = String(req.headers['user-agent'] || '');
|
|
553
559
|
if (!clientUA) return false;
|
|
554
560
|
const ua = await bifrostUAFor(profile, model);
|
|
555
|
-
|
|
561
|
+
if (!ua) return false;
|
|
562
|
+
const family = BIFROST_UA_FAMILIES.find(f => f.includes(ua)) || [ua];
|
|
563
|
+
return family.some(prefix => clientUA.startsWith(prefix));
|
|
556
564
|
}
|
|
557
565
|
|
|
558
566
|
// Every client header except its own credentials, the switcher's control headers and hop-by-hop.
|
|
@@ -1309,7 +1317,9 @@ async function route(req, res) {
|
|
|
1309
1317
|
proxy: 'llm-switcher',
|
|
1310
1318
|
...(challenge ? {
|
|
1311
1319
|
pid: process.pid,
|
|
1312
|
-
proof: identityProof(challenge, { role: 'gateway', port: PORT, pid: process.pid }, ADMIN_TOKEN.toString())
|
|
1320
|
+
proof: identityProof(challenge, { role: 'gateway', port: PORT, pid: process.pid }, ADMIN_TOKEN.toString()),
|
|
1321
|
+
// The only proof agy-relay.mjs accepts: its key is not on the dashboard page.
|
|
1322
|
+
relayProof: relayProof(challenge, { port: PORT, pid: process.pid })
|
|
1313
1323
|
} : {}),
|
|
1314
1324
|
port: PORT,
|
|
1315
1325
|
configLoaded: Boolean(loadConfig()),
|
|
@@ -1848,6 +1858,88 @@ function geminiSafeTools(tools) {
|
|
|
1848
1858
|
});
|
|
1849
1859
|
}
|
|
1850
1860
|
|
|
1861
|
+
// Bifrost on the WS transport. intact speaks HTTP and keeps no WS state, so the turn goes to
|
|
1862
|
+
// /responses as one streamed request with the whole conversation, and each SSE event goes back to
|
|
1863
|
+
// Codex as one text frame, unchanged.
|
|
1864
|
+
async function wsBifrostTurn({ socket, payload, req, ac, history, input, profile, profileKey, mappedModel }) {
|
|
1865
|
+
const started = Date.now();
|
|
1866
|
+
const log = (extra) => logInspection({
|
|
1867
|
+
clientFormat: 'responses-ws', outFormat: 'bifrost', profile: profileKey, model: mappedModel, stream: true,
|
|
1868
|
+
requestPreview: '(bifrost)', duration: Date.now() - started, tokens: { prompt: 0, completion: 0 }, ...extra
|
|
1869
|
+
});
|
|
1870
|
+
const headers = bifrostHeaders(req, profile);
|
|
1871
|
+
for (const h of Object.keys(headers)) if (h === 'upgrade' || h.startsWith('sec-websocket-')) delete headers[h];
|
|
1872
|
+
headers['content-type'] = 'application/json';
|
|
1873
|
+
headers.accept = 'text/event-stream';
|
|
1874
|
+
// `type` names the WS frame (response.create); it is not a field of a Responses request.
|
|
1875
|
+
const { type: _type, ...body } = payload;
|
|
1876
|
+
|
|
1877
|
+
let upstreamRes;
|
|
1878
|
+
try {
|
|
1879
|
+
upstreamRes = await fetch(bifrostURL(profile, 'responses', req), {
|
|
1880
|
+
method: 'POST', headers, body: JSON.stringify({ ...body, model: mappedModel, stream: true }), signal: ac.signal
|
|
1881
|
+
});
|
|
1882
|
+
} catch (err) {
|
|
1883
|
+
if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
|
|
1884
|
+
sendWsFailed(socket, mappedModel, `Bifrost forward error: ${err.cause?.message || err.message}`, 502);
|
|
1885
|
+
return log({ status: 502, error: err.message });
|
|
1886
|
+
}
|
|
1887
|
+
if (!upstreamRes.ok) {
|
|
1888
|
+
const errText = await upstreamRes.text().catch(() => '');
|
|
1889
|
+
sendWsFailed(socket, mappedModel, extractUpstreamMessage(errText) || `Upstream HTTP ${upstreamRes.status}`, upstreamRes.status);
|
|
1890
|
+
return log({ status: upstreamRes.status, error: errText.slice(0, 300) });
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
let completed = null;
|
|
1894
|
+
// With store:false the backend sends response.completed with an empty output; the items of the
|
|
1895
|
+
// turn come only in response.output_item.done, and the next turn must replay them.
|
|
1896
|
+
const items = [];
|
|
1897
|
+
let ended = false; // a response.completed or response.failed reached Codex
|
|
1898
|
+
let streamError = null;
|
|
1899
|
+
const decoder = new TextDecoder();
|
|
1900
|
+
let pending = '';
|
|
1901
|
+
const reader = upstreamRes.body.getReader();
|
|
1902
|
+
try {
|
|
1903
|
+
while (true) {
|
|
1904
|
+
const { done, value } = await reader.read();
|
|
1905
|
+
if (done) break;
|
|
1906
|
+
pending += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
|
1907
|
+
let cut;
|
|
1908
|
+
while ((cut = pending.indexOf('\n\n')) >= 0) {
|
|
1909
|
+
const data = pending.slice(0, cut).split('\n').filter(l => l.startsWith('data:')).map(l => l.slice(5).trimStart()).join('\n');
|
|
1910
|
+
pending = pending.slice(cut + 2);
|
|
1911
|
+
if (!data || data === '[DONE]') continue;
|
|
1912
|
+
let event;
|
|
1913
|
+
try { event = JSON.parse(data); } catch { continue; }
|
|
1914
|
+
if (event.type === 'response.output_item.done' && event.item) items.push(event.item);
|
|
1915
|
+
if (event.type === 'response.completed') completed = event.response;
|
|
1916
|
+
if (event.type === 'response.completed' || event.type === 'response.failed') ended = true;
|
|
1917
|
+
if (!socket.writable) continue;
|
|
1918
|
+
socket.write(encodeWsFrame(data));
|
|
1919
|
+
await drained(socket, ac.signal);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
} catch (err) {
|
|
1923
|
+
if (!ac.signal.aborted) streamError = `stream interrupted: ${err.cause?.message || err.message}`;
|
|
1924
|
+
}
|
|
1925
|
+
if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected mid-stream' });
|
|
1926
|
+
// Codex ends a turn only on a terminal event: a stream cut before one must still end the turn.
|
|
1927
|
+
if (!ended) {
|
|
1928
|
+
streamError ||= 'the Bifrost stream ended before response.completed';
|
|
1929
|
+
sendWsFailed(socket, mappedModel, streamError, 502);
|
|
1930
|
+
}
|
|
1931
|
+
if (completed) {
|
|
1932
|
+
const output = Array.isArray(completed.output) && completed.output.length ? completed.output : items;
|
|
1933
|
+
rememberWsTurn(history, { ...completed, output }, input);
|
|
1934
|
+
}
|
|
1935
|
+
const usage = completed?.usage || {};
|
|
1936
|
+
log({
|
|
1937
|
+
status: streamError ? 502 : 200,
|
|
1938
|
+
tokens: { prompt: usage.input_tokens || 0, completion: usage.output_tokens || 0 },
|
|
1939
|
+
...(streamError ? { error: streamError } : {})
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1851
1943
|
// One turn of the Codex WS transport. The socket loop runs turns one at a time and owns `ac`.
|
|
1852
1944
|
async function handleWsResponseCreate(socket, payload, req, ac, history = null) {
|
|
1853
1945
|
const clientFormat = 'responses';
|
|
@@ -1877,6 +1969,11 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
|
|
|
1877
1969
|
const { previous_response_id: _prev, ...rest } = payload;
|
|
1878
1970
|
payload = { ...rest, input };
|
|
1879
1971
|
|
|
1972
|
+
const bifrostModel = mapModel(payload.model || '', profile, clientFormat);
|
|
1973
|
+
if (await crossesBifrost(req, profile, bifrostModel)) {
|
|
1974
|
+
return wsBifrostTurn({ socket, payload, req, ac, history, input, profile, profileKey, mappedModel: bifrostModel });
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1880
1977
|
let ir;
|
|
1881
1978
|
try {
|
|
1882
1979
|
ir = parseToIR('responses', payload);
|
package/shim.mjs
CHANGED
|
@@ -40,6 +40,9 @@ const ROUTE_NOTIFIER = path.join(path.dirname(fileURLToPath(import.meta.url)), '
|
|
|
40
40
|
// agy sends its Google token in clear text to CLOUD_CODE_URL. Its shim asks this helper for the
|
|
41
41
|
// gateway's identity proof first, so a process that took the port never receives the token.
|
|
42
42
|
const GATEWAY_CHECK = path.join(path.dirname(fileURLToPath(import.meta.url)), 'verify-gateway.mjs');
|
|
43
|
+
// The launch check covers the start only. A routed agy then runs behind this relay, which proves
|
|
44
|
+
// the gateway on every connection, so a port freed later never receives the token.
|
|
45
|
+
const AGY_RELAY = path.join(path.dirname(fileURLToPath(import.meta.url)), 'agy-relay.mjs');
|
|
43
46
|
|
|
44
47
|
// CLIs to wrap. `claude` is the most important case (--resume); codex and agy have their own files.
|
|
45
48
|
export const SHIMMED = ['claude', 'codex', 'agy'];
|
|
@@ -191,6 +194,13 @@ if [ -z "$REAL" ]; then
|
|
|
191
194
|
exit 127
|
|
192
195
|
fi
|
|
193
196
|
|
|
197
|
+
${name !== 'agy' ? '' : `# A routed agy talks only to its relay, which lives as long as agy (agy-relay.mjs).
|
|
198
|
+
if [ "$TOOL_ACTIVE" = "1" ] && [ -n "\${CLOUD_CODE_URL:-}" ]; then
|
|
199
|
+
GATEWAY_URL="$CLOUD_CODE_URL"
|
|
200
|
+
unset CLOUD_CODE_URL
|
|
201
|
+
exec node "${AGY_RELAY}" "$GATEWAY_URL" "$REAL" "$@"
|
|
202
|
+
fi
|
|
203
|
+
`}
|
|
194
204
|
# No override of any kind: the tool keeps its own configuration and its own model names (F3, R1).
|
|
195
205
|
exec "$REAL" "$@"
|
|
196
206
|
`;
|
|
@@ -266,14 +276,26 @@ for /f "tokens=1 delims==" %%V in ('set LLM_SWITCHER_CODEX_ 2^>nul ^| findstr /b
|
|
|
266
276
|
${caBlock}
|
|
267
277
|
|
|
268
278
|
REM Find the real binary: drop this shim's own directory so it never calls itself.
|
|
269
|
-
|
|
279
|
+
${name === 'agy' ? `set "REAL_BIN="
|
|
280
|
+
for /f "delims=" %%i in ('where ${name}.cmd 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do if not defined REAL_BIN set "REAL_BIN=%%i"
|
|
281
|
+
if not defined REAL_BIN for /f "delims=" %%i in ('where ${name}.exe 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do if not defined REAL_BIN set "REAL_BIN=%%i"
|
|
282
|
+
if not defined REAL_BIN goto :NOT_FOUND
|
|
283
|
+
REM A routed agy talks only to its relay, which lives as long as agy (agy-relay.mjs).
|
|
284
|
+
if not "%TOOL_ACTIVE%"=="1" goto :RUN_PLAIN
|
|
285
|
+
if not defined CLOUD_CODE_URL goto :RUN_PLAIN
|
|
286
|
+
node "${AGY_RELAY}" "%CLOUD_CODE_URL%" "%REAL_BIN%" %*
|
|
287
|
+
exit /b
|
|
288
|
+
:RUN_PLAIN
|
|
289
|
+
call "%REAL_BIN%" %*
|
|
290
|
+
exit /b
|
|
291
|
+
:NOT_FOUND` : `for /f "delims=" %%i in ('where ${name}.cmd 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do (
|
|
270
292
|
call "%%i" %*
|
|
271
293
|
exit /b
|
|
272
294
|
)
|
|
273
295
|
for /f "delims=" %%i in ('where ${name}.exe 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do (
|
|
274
296
|
call "%%i" %*
|
|
275
297
|
exit /b
|
|
276
|
-
)
|
|
298
|
+
)`}
|
|
277
299
|
echo [llm-switcher] cannot find the real '${name}' on PATH.>&2
|
|
278
300
|
exit /b 127
|
|
279
301
|
|
package/state.mjs
CHANGED
|
@@ -107,6 +107,38 @@ export function ensureAdminToken() {
|
|
|
107
107
|
return token;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// The key of the proof that agy-relay.mjs checks. Random, never derived from admin.token: the
|
|
111
|
+
// dashboard page carries admin.token, so a proof keyed by it can be made by anyone who loads /ui.
|
|
112
|
+
export const gatewaySecretPath = path.join(path.dirname(configPath), 'gateway.secret');
|
|
113
|
+
|
|
114
|
+
export function readGatewaySecret() {
|
|
115
|
+
try {
|
|
116
|
+
return fs.readFileSync(gatewaySecretPath, 'utf8').trim() || null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A good secret is kept. A missing, empty or blank file is replaced through a rename, so a damaged
|
|
123
|
+
// file never leaves agy without a key. Throws when it cannot write; never leaves a temporary file.
|
|
124
|
+
export function ensureGatewaySecret() {
|
|
125
|
+
if (readGatewaySecret()) return;
|
|
126
|
+
const tmp = `${gatewaySecretPath}.${process.pid}.tmp`;
|
|
127
|
+
try {
|
|
128
|
+
fs.rmSync(tmp, { force: true });
|
|
129
|
+
fs.writeFileSync(tmp, crypto.randomBytes(32).toString('hex'), { mode: 0o600, flag: 'wx' });
|
|
130
|
+
fs.renameSync(tmp, gatewaySecretPath);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function relayProof(nonce, { port, pid }, secret = readGatewaySecret()) {
|
|
138
|
+
if (!secret) return '';
|
|
139
|
+
return crypto.createHmac('sha256', secret).update(['relay', port, pid, nonce].join('|')).digest('hex');
|
|
140
|
+
}
|
|
141
|
+
|
|
110
142
|
// `switch ui` must not put the token on a command line: /proc/<pid>/cmdline is readable by every
|
|
111
143
|
// account. It opens this private file instead, which redirects to the dashboard with the token.
|
|
112
144
|
export function writeDashboardLauncher(url) {
|