@nvae/llmswitch 0.2.0 → 0.5.0
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/README.md +111 -197
- package/dist/adapters/claude.js +8 -5
- package/dist/adapters/codex.js +10 -4
- package/dist/bridge/manager.js +241 -146
- package/dist/bridge/runtime.js +199 -0
- package/dist/bridge/server.js +207 -80
- package/dist/bridge/state.js +345 -69
- package/dist/bridge/translate-response.js +205 -80
- package/dist/bridge/transport.js +439 -0
- package/dist/cli.js +7 -1
- package/dist/commands/bridge-cmd.js +15 -13
- package/dist/commands/home-cmd.js +8 -0
- package/dist/commands/launch-cmd.js +18 -0
- package/dist/commands/launch.js +2 -2
- package/dist/commands/prompts.js +157 -116
- package/dist/commands/setup-cmd.js +175 -0
- package/dist/commands/tool.js +18 -33
- package/dist/index.js +0 -0
- package/dist/presets/index.js +10 -1
- package/dist/store/profiles.js +40 -2
- package/dist/types.js +20 -3
- package/dist/utils/detect-format.js +178 -0
- package/dist/utils/fetch-models.js +54 -59
- package/dist/utils/proxy.js +14 -37
- package/dist/utils/version.js +9 -0
- package/package.json +6 -3
package/dist/bridge/manager.js
CHANGED
|
@@ -1,19 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
|
|
5
|
-
import { bridgeBaseUrl, bridgeRootUrl,
|
|
6
|
-
import { DEFAULT_BRIDGE_HOST, DEFAULT_BRIDGE_PORT,
|
|
6
|
+
import { bridgeBaseUrl, bridgeRootUrl, generateBridgeToken, readBridgeState, updateBridgeState, writeBridgeUpstream, } from "./state.js";
|
|
7
|
+
import { DEFAULT_BRIDGE_HOST, DEFAULT_BRIDGE_PORT, hasAnyUpstream, } from "./types.js";
|
|
8
|
+
import { assertBridgeListenerAllowed, formatHostForUrl, parseBridgePort, resolveBridgeListener, } from "./runtime.js";
|
|
7
9
|
import { listenBridge } from "./server.js";
|
|
10
|
+
export class PortOccupiedError extends Error {
|
|
11
|
+
constructor(host, port) {
|
|
12
|
+
super(`端口 ${host}:${port} 已被其他进程占用;为避免误杀,llm-switch 不会自动终止该进程。`);
|
|
13
|
+
this.name = "PortOccupiedError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class LegacyBridgeRunningError extends Error {
|
|
17
|
+
constructor(host, port) {
|
|
18
|
+
super(`检测到旧版 bridge 正在 ${host}:${port} 运行。请先执行 llms bridge stop --legacy 并确认风险,然后重试。`);
|
|
19
|
+
this.name = "LegacyBridgeRunningError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class BridgeControlError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "BridgeControlError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
8
28
|
export function profileNeedsBridge(profile) {
|
|
9
29
|
return profile.apiFormat === "openai-chat";
|
|
10
30
|
}
|
|
11
31
|
export function bridgeToolForCliTool(tool) {
|
|
12
|
-
|
|
13
|
-
return tool;
|
|
14
|
-
return null;
|
|
32
|
+
return tool === "codex" || tool === "claude" ? tool : null;
|
|
15
33
|
}
|
|
16
|
-
export function upstreamFromProfile(profile, tool) {
|
|
34
|
+
export function upstreamFromProfile(profile, tool, clientToken = null) {
|
|
17
35
|
const mode = tool === "claude" ? "chat" : profile.bridgeMode || "chat";
|
|
18
36
|
return {
|
|
19
37
|
baseUrl: normalizeBaseUrlForFormat(profile.apiFormat, profile.baseUrl),
|
|
@@ -23,42 +41,68 @@ export function upstreamFromProfile(profile, tool) {
|
|
|
23
41
|
headers: profile.headers,
|
|
24
42
|
profileName: profile.name,
|
|
25
43
|
updatedAt: new Date().toISOString(),
|
|
44
|
+
clientToken,
|
|
45
|
+
migrationRequired: !clientToken,
|
|
26
46
|
};
|
|
27
47
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
function controlUrl(host, port, path) {
|
|
49
|
+
return `http://${formatHostForUrl(host)}:${port}${path}`;
|
|
50
|
+
}
|
|
51
|
+
export async function probeBridge(host = readBridgeState().listener.advertiseHost, port = readBridgeState().listener.port) {
|
|
52
|
+
const state = readBridgeState();
|
|
53
|
+
const expected = state.instance;
|
|
33
54
|
try {
|
|
34
|
-
const
|
|
55
|
+
const headers = {};
|
|
56
|
+
if (expected?.controlToken) {
|
|
57
|
+
headers["x-llm-switch-control"] = expected.controlToken;
|
|
58
|
+
}
|
|
59
|
+
const response = await fetch(controlUrl(host, port, "/health"), {
|
|
60
|
+
headers,
|
|
35
61
|
signal: AbortSignal.timeout(800),
|
|
36
62
|
});
|
|
37
63
|
let body = null;
|
|
38
64
|
try {
|
|
39
|
-
body = (await
|
|
65
|
+
body = (await response.json());
|
|
40
66
|
}
|
|
41
67
|
catch {
|
|
42
68
|
body = null;
|
|
43
69
|
}
|
|
44
|
-
const
|
|
45
|
-
|
|
70
|
+
const instanceId = typeof body?.instanceId === "string" ? body.instanceId : undefined;
|
|
71
|
+
const authenticated = Boolean(response.ok &&
|
|
72
|
+
expected &&
|
|
73
|
+
instanceId &&
|
|
74
|
+
instanceId === expected.id);
|
|
75
|
+
const legacy = Boolean(response.ok &&
|
|
76
|
+
body?.ok === true &&
|
|
77
|
+
body != null &&
|
|
78
|
+
"upstreams" in body &&
|
|
79
|
+
!("service" in body));
|
|
80
|
+
return {
|
|
81
|
+
reachable: true,
|
|
82
|
+
healthy: authenticated,
|
|
83
|
+
authenticated,
|
|
84
|
+
legacy,
|
|
85
|
+
instanceId,
|
|
86
|
+
};
|
|
46
87
|
}
|
|
47
88
|
catch {
|
|
48
|
-
return {
|
|
89
|
+
return {
|
|
90
|
+
reachable: false,
|
|
91
|
+
healthy: false,
|
|
92
|
+
authenticated: false,
|
|
93
|
+
legacy: false,
|
|
94
|
+
};
|
|
49
95
|
}
|
|
50
96
|
}
|
|
51
|
-
export async function isBridgeAlive(host = readBridgeState().
|
|
97
|
+
export async function isBridgeAlive(host = readBridgeState().listener.advertiseHost, port = readBridgeState().listener.port) {
|
|
52
98
|
return (await probeBridge(host, port)).healthy;
|
|
53
99
|
}
|
|
54
100
|
export function readPid() {
|
|
55
|
-
|
|
56
|
-
if (!existsSync(path))
|
|
57
|
-
return null;
|
|
58
|
-
const n = Number(readFileSync(path, "utf8").trim());
|
|
59
|
-
return Number.isFinite(n) ? n : null;
|
|
101
|
+
return readBridgeState().instance?.pid ?? null;
|
|
60
102
|
}
|
|
61
103
|
export function isPidRunning(pid) {
|
|
104
|
+
if (!Number.isInteger(pid) || pid < 1 || pid > 2_147_483_647)
|
|
105
|
+
return false;
|
|
62
106
|
try {
|
|
63
107
|
process.kill(pid, 0);
|
|
64
108
|
return true;
|
|
@@ -67,65 +111,86 @@ export function isPidRunning(pid) {
|
|
|
67
111
|
return false;
|
|
68
112
|
}
|
|
69
113
|
}
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
114
|
+
function connectionForTool(tool, state, clientToken) {
|
|
115
|
+
return {
|
|
116
|
+
baseUrl: tool === "claude" ? bridgeRootUrl(state) : bridgeBaseUrl(state),
|
|
117
|
+
clientToken,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function desiredListener() {
|
|
121
|
+
const current = readBridgeState();
|
|
122
|
+
const host = process.env.LLM_SWITCH_BRIDGE_HOST || current.listener.bindHost;
|
|
123
|
+
const port = process.env.LLM_SWITCH_BRIDGE_PORT
|
|
124
|
+
? parseBridgePort(process.env.LLM_SWITCH_BRIDGE_PORT)
|
|
125
|
+
: current.listener.port;
|
|
126
|
+
const allowRemote = current.listener.allowRemote;
|
|
127
|
+
return resolveBridgeListener({ host, port, allowRemote });
|
|
74
128
|
}
|
|
75
129
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
130
|
+
* Preflight the listen address before writing an upstream, then configure the
|
|
131
|
+
* side and ensure an authenticated v2 daemon is running.
|
|
78
132
|
*/
|
|
79
133
|
export async function ensureBridgeForProfile(profile, tool) {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const upstreams = readBridgeUpstreams();
|
|
88
|
-
writeBridgeState({
|
|
89
|
-
...state,
|
|
90
|
-
host,
|
|
91
|
-
port,
|
|
92
|
-
upstreams,
|
|
93
|
-
pid: state.pid,
|
|
94
|
-
});
|
|
95
|
-
const probe = await probeBridge(host, port);
|
|
96
|
-
if (probe.healthy) {
|
|
97
|
-
return urlForTool(tool, host, port, state.pid);
|
|
98
|
-
}
|
|
99
|
-
// Stale / incompatible process holding the port (e.g. pre-dual-upstream build).
|
|
100
|
-
if (probe.reachable) {
|
|
101
|
-
await forceStopBridge(host, port);
|
|
134
|
+
const listener = desiredListener();
|
|
135
|
+
const before = await probeBridge(listener.advertiseHost, listener.port);
|
|
136
|
+
if (before.reachable && !before.healthy) {
|
|
137
|
+
if (before.legacy) {
|
|
138
|
+
throw new LegacyBridgeRunningError(listener.bindHost, listener.port);
|
|
139
|
+
}
|
|
140
|
+
throw new PortOccupiedError(listener.bindHost, listener.port);
|
|
102
141
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
142
|
+
const clientToken = generateBridgeToken();
|
|
143
|
+
const upstream = upstreamFromProfile(profile, tool, clientToken);
|
|
144
|
+
const configured = updateBridgeState((state) => ({
|
|
145
|
+
...state,
|
|
146
|
+
listener,
|
|
147
|
+
host: listener.advertiseHost,
|
|
148
|
+
port: listener.port,
|
|
149
|
+
upstreams: { ...state.upstreams, [tool]: upstream },
|
|
150
|
+
}));
|
|
151
|
+
if (!before.healthy) {
|
|
152
|
+
await startBridgeDaemon(listener.bindHost, listener.port, listener.allowRemote);
|
|
153
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
154
|
+
if (await isBridgeAlive(listener.advertiseHost, listener.port))
|
|
155
|
+
break;
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
157
|
+
}
|
|
158
|
+
if (!(await isBridgeAlive(listener.advertiseHost, listener.port))) {
|
|
159
|
+
throw new BridgeControlError(`Bridge 启动超时(${listener.bindHost}:${listener.port})。可手动运行:llms bridge serve`);
|
|
107
160
|
}
|
|
108
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
109
161
|
}
|
|
110
|
-
|
|
162
|
+
return connectionForTool(tool, readBridgeState() || configured, clientToken);
|
|
111
163
|
}
|
|
112
164
|
export async function clearBridgeUpstream(tool) {
|
|
113
165
|
writeBridgeUpstream(tool, null);
|
|
114
|
-
const
|
|
115
|
-
if (!hasAnyUpstream(upstreams)) {
|
|
166
|
+
const state = readBridgeState();
|
|
167
|
+
if (!hasAnyUpstream(state.upstreams) && state.instance) {
|
|
116
168
|
await stopBridge();
|
|
117
169
|
}
|
|
118
170
|
}
|
|
119
|
-
export async function startBridgeDaemon(host = DEFAULT_BRIDGE_HOST, port = DEFAULT_BRIDGE_PORT) {
|
|
120
|
-
|
|
171
|
+
export async function startBridgeDaemon(host = DEFAULT_BRIDGE_HOST, port = DEFAULT_BRIDGE_PORT, allowRemote = false) {
|
|
172
|
+
const listener = resolveBridgeListener({ host, port, allowRemote });
|
|
173
|
+
const probe = await probeBridge(listener.advertiseHost, listener.port);
|
|
174
|
+
if (probe.healthy)
|
|
121
175
|
return readPid() || 0;
|
|
122
|
-
}
|
|
123
|
-
const probe = await probeBridge(host, port);
|
|
124
176
|
if (probe.reachable) {
|
|
125
|
-
|
|
177
|
+
if (probe.legacy)
|
|
178
|
+
throw new LegacyBridgeRunningError(host, port);
|
|
179
|
+
throw new PortOccupiedError(host, port);
|
|
126
180
|
}
|
|
181
|
+
updateBridgeState((state) => ({
|
|
182
|
+
...state,
|
|
183
|
+
listener,
|
|
184
|
+
host: listener.advertiseHost,
|
|
185
|
+
port: listener.port,
|
|
186
|
+
instance: null,
|
|
187
|
+
pid: null,
|
|
188
|
+
}));
|
|
127
189
|
const entry = resolveCliEntry();
|
|
128
|
-
const
|
|
190
|
+
const args = [entry, "bridge", "serve", "--host", host, "--port", String(port)];
|
|
191
|
+
if (allowRemote)
|
|
192
|
+
args.push("--allow-remote");
|
|
193
|
+
const child = spawn(process.execPath, args, {
|
|
129
194
|
detached: true,
|
|
130
195
|
stdio: "ignore",
|
|
131
196
|
env: {
|
|
@@ -135,106 +200,136 @@ export async function startBridgeDaemon(host = DEFAULT_BRIDGE_HOST, port = DEFAU
|
|
|
135
200
|
},
|
|
136
201
|
});
|
|
137
202
|
child.unref();
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const state = readBridgeState();
|
|
142
|
-
writeBridgeState({
|
|
143
|
-
...state,
|
|
144
|
-
host,
|
|
145
|
-
port,
|
|
146
|
-
pid,
|
|
147
|
-
upstreams: state.upstreams,
|
|
148
|
-
});
|
|
149
|
-
return pid;
|
|
150
|
-
}
|
|
151
|
-
/** Stop by recorded pid, then free the listen port if still held. */
|
|
152
|
-
export async function forceStopBridge(host = readBridgeState().host, port = readBridgeState().port) {
|
|
153
|
-
await stopBridge();
|
|
154
|
-
await killListenersOnPort(port);
|
|
155
|
-
// Brief wait so TIME_WAIT / bind release settles.
|
|
156
|
-
for (let i = 0; i < 20; i++) {
|
|
157
|
-
const probe = await probeBridge(host, port);
|
|
158
|
-
if (!probe.reachable)
|
|
159
|
-
return;
|
|
160
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
161
|
-
}
|
|
203
|
+
if (!child.pid)
|
|
204
|
+
throw new BridgeControlError("无法启动 bridge 进程");
|
|
205
|
+
return child.pid;
|
|
162
206
|
}
|
|
163
207
|
export async function stopBridge() {
|
|
164
208
|
const state = readBridgeState();
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
// ignore
|
|
174
|
-
}
|
|
209
|
+
const instance = state.instance;
|
|
210
|
+
if (!instance)
|
|
211
|
+
return false;
|
|
212
|
+
const probe = await probeBridge(state.listener.advertiseHost, state.listener.port);
|
|
213
|
+
if (!probe.authenticated || probe.instanceId !== instance.id) {
|
|
214
|
+
throw new BridgeControlError("无法验证 bridge 实例身份;为避免误杀,未发送任何进程信号。");
|
|
175
215
|
}
|
|
176
|
-
|
|
177
|
-
...state,
|
|
178
|
-
pid: null,
|
|
179
|
-
upstreams: state.upstreams,
|
|
180
|
-
});
|
|
181
|
-
return stopped;
|
|
182
|
-
}
|
|
183
|
-
function killListenersOnPort(port) {
|
|
184
|
-
if (process.platform === "win32")
|
|
185
|
-
return;
|
|
216
|
+
let response;
|
|
186
217
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
// ignore
|
|
197
|
-
}
|
|
198
|
-
}
|
|
218
|
+
response = await fetch(controlUrl(state.listener.advertiseHost, state.listener.port, "/_control/shutdown"), {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: {
|
|
221
|
+
"content-type": "application/json",
|
|
222
|
+
"x-llm-switch-control": instance.controlToken,
|
|
223
|
+
},
|
|
224
|
+
body: JSON.stringify({ instanceId: instance.id }),
|
|
225
|
+
signal: AbortSignal.timeout(2_000),
|
|
226
|
+
});
|
|
199
227
|
}
|
|
200
|
-
catch {
|
|
201
|
-
|
|
228
|
+
catch (error) {
|
|
229
|
+
throw new BridgeControlError(`Bridge 协作关闭失败:${error instanceof Error ? error.message : String(error)}`);
|
|
230
|
+
}
|
|
231
|
+
if (!response.ok) {
|
|
232
|
+
throw new BridgeControlError(`Bridge 拒绝关闭请求(HTTP ${response.status})`);
|
|
233
|
+
}
|
|
234
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
235
|
+
if (readBridgeState().instance?.id !== instance.id)
|
|
236
|
+
return true;
|
|
237
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
202
238
|
}
|
|
239
|
+
throw new BridgeControlError("Bridge 已接受关闭请求,但未在宽限期内清除实例身份");
|
|
203
240
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
241
|
+
/** Compatibility alias: v2 never force-kills a listener. */
|
|
242
|
+
export async function forceStopBridge() {
|
|
243
|
+
await stopBridge();
|
|
244
|
+
}
|
|
245
|
+
export async function runBridgeForeground(host, port, allowRemote = false) {
|
|
246
|
+
assertBridgeListenerAllowed(host, allowRemote);
|
|
247
|
+
const listener = resolveBridgeListener({ host, port, allowRemote });
|
|
248
|
+
const previous = readBridgeState();
|
|
249
|
+
const instance = {
|
|
250
|
+
id: randomUUID(),
|
|
251
|
+
controlToken: generateBridgeToken(),
|
|
252
|
+
pid: process.pid,
|
|
253
|
+
startedAt: new Date().toISOString(),
|
|
254
|
+
};
|
|
255
|
+
updateBridgeState((state) => ({
|
|
207
256
|
...state,
|
|
208
|
-
|
|
209
|
-
|
|
257
|
+
listener,
|
|
258
|
+
host: listener.advertiseHost,
|
|
259
|
+
port: listener.port,
|
|
210
260
|
pid: process.pid,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
261
|
+
instance,
|
|
262
|
+
}));
|
|
263
|
+
let resolveClosed;
|
|
264
|
+
const closed = new Promise((resolve) => {
|
|
265
|
+
resolveClosed = resolve;
|
|
214
266
|
});
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
267
|
+
let server = null;
|
|
268
|
+
let closing = false;
|
|
269
|
+
const clearIdentity = () => {
|
|
270
|
+
updateBridgeState((state) => state.instance?.id === instance.id
|
|
271
|
+
? { ...state, instance: null, pid: null }
|
|
272
|
+
: state);
|
|
218
273
|
};
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
274
|
+
const shutdown = async (requestedId = instance.id) => {
|
|
275
|
+
if (requestedId !== instance.id || closing)
|
|
276
|
+
return;
|
|
277
|
+
closing = true;
|
|
278
|
+
if (server) {
|
|
279
|
+
const forceTimer = setTimeout(() => server?.closeAllConnections(), 30_000);
|
|
280
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
281
|
+
clearTimeout(forceTimer);
|
|
282
|
+
}
|
|
283
|
+
clearIdentity();
|
|
284
|
+
resolveClosed?.();
|
|
285
|
+
};
|
|
286
|
+
try {
|
|
287
|
+
server = await listenBridge(listener.port, listener.bindHost, {
|
|
288
|
+
controlToken: instance.controlToken,
|
|
289
|
+
instanceId: instance.id,
|
|
290
|
+
onShutdown: shutdown,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
try {
|
|
295
|
+
clearIdentity();
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// Preserve the original bind error.
|
|
299
|
+
}
|
|
300
|
+
if (error.code === "EADDRINUSE") {
|
|
301
|
+
throw new PortOccupiedError(listener.bindHost, listener.port);
|
|
302
|
+
}
|
|
303
|
+
// Restore the previous listener only when no newer instance replaced us.
|
|
304
|
+
updateBridgeState((state) => state.instance
|
|
305
|
+
? state
|
|
306
|
+
: {
|
|
307
|
+
...state,
|
|
308
|
+
listener: previous.listener,
|
|
309
|
+
host: previous.listener.advertiseHost,
|
|
310
|
+
port: previous.listener.port,
|
|
311
|
+
});
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
const onSignal = () => {
|
|
315
|
+
void shutdown();
|
|
316
|
+
};
|
|
317
|
+
process.once("SIGINT", onSignal);
|
|
318
|
+
process.once("SIGTERM", onSignal);
|
|
319
|
+
console.error(`llm-switch bridge listening on http://${formatHostForUrl(listener.bindHost)}:${listener.port} (authenticated)`);
|
|
320
|
+
await closed;
|
|
321
|
+
process.removeListener("SIGINT", onSignal);
|
|
322
|
+
process.removeListener("SIGTERM", onSignal);
|
|
223
323
|
}
|
|
224
|
-
/**
|
|
225
|
-
* Prefer the entry currently running this CLI so `bun run ./src/index.ts`
|
|
226
|
-
* respawns the same source tree; published installs use dist/index.js.
|
|
227
|
-
*/
|
|
228
324
|
function resolveCliEntry() {
|
|
229
325
|
const running = process.argv[1];
|
|
230
|
-
if (running && existsSync(running))
|
|
326
|
+
if (running && existsSync(running))
|
|
231
327
|
return running;
|
|
232
|
-
}
|
|
233
328
|
const compiled = fileURLToPath(new URL("../index.js", import.meta.url));
|
|
234
329
|
if (existsSync(compiled))
|
|
235
330
|
return compiled;
|
|
236
331
|
const source = fileURLToPath(new URL("../index.ts", import.meta.url));
|
|
237
332
|
if (existsSync(source))
|
|
238
333
|
return source;
|
|
239
|
-
throw new
|
|
334
|
+
throw new BridgeControlError("无法定位 llmswitch 入口文件");
|
|
240
335
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
export const DEFAULT_BRIDGE_RUNTIME_LIMITS = Object.freeze({
|
|
3
|
+
maxBodyBytes: 16_777_216,
|
|
4
|
+
maxResponseBytes: 33_554_432,
|
|
5
|
+
maxSseFrameBytes: 2_097_152,
|
|
6
|
+
connectTimeoutMs: 30_000,
|
|
7
|
+
idleTimeoutMs: 90_000,
|
|
8
|
+
totalTimeoutMs: 600_000,
|
|
9
|
+
maxConcurrency: 16,
|
|
10
|
+
rateLimitPerMinute: 120,
|
|
11
|
+
});
|
|
12
|
+
const RUNTIME_LIMIT_SPECS = [
|
|
13
|
+
{
|
|
14
|
+
env: "LLM_SWITCH_MAX_BODY_BYTES",
|
|
15
|
+
key: "maxBodyBytes",
|
|
16
|
+
min: 1_024,
|
|
17
|
+
max: 67_108_864,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
env: "LLM_SWITCH_MAX_RESPONSE_BYTES",
|
|
21
|
+
key: "maxResponseBytes",
|
|
22
|
+
min: 1_024,
|
|
23
|
+
max: 134_217_728,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
env: "LLM_SWITCH_MAX_SSE_FRAME_BYTES",
|
|
27
|
+
key: "maxSseFrameBytes",
|
|
28
|
+
min: 1_024,
|
|
29
|
+
max: 16_777_216,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
env: "LLM_SWITCH_CONNECT_TIMEOUT_MS",
|
|
33
|
+
key: "connectTimeoutMs",
|
|
34
|
+
min: 1_000,
|
|
35
|
+
max: 120_000,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
env: "LLM_SWITCH_IDLE_TIMEOUT_MS",
|
|
39
|
+
key: "idleTimeoutMs",
|
|
40
|
+
min: 1_000,
|
|
41
|
+
max: 600_000,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
env: "LLM_SWITCH_TOTAL_TIMEOUT_MS",
|
|
45
|
+
key: "totalTimeoutMs",
|
|
46
|
+
min: 1_000,
|
|
47
|
+
max: 3_600_000,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
env: "LLM_SWITCH_MAX_CONCURRENCY",
|
|
51
|
+
key: "maxConcurrency",
|
|
52
|
+
min: 1,
|
|
53
|
+
max: 128,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
env: "LLM_SWITCH_RATE_LIMIT_PER_MINUTE",
|
|
57
|
+
key: "rateLimitPerMinute",
|
|
58
|
+
min: 1,
|
|
59
|
+
max: 10_000,
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
export function parseBridgeRuntimeLimits(env = process.env) {
|
|
63
|
+
const parsed = { ...DEFAULT_BRIDGE_RUNTIME_LIMITS };
|
|
64
|
+
for (const spec of RUNTIME_LIMIT_SPECS) {
|
|
65
|
+
const raw = env[spec.env];
|
|
66
|
+
if (raw === undefined)
|
|
67
|
+
continue;
|
|
68
|
+
if (!/^\d+$/.test(raw)) {
|
|
69
|
+
throw new Error(`${spec.env} 必须是 ${spec.min}..${spec.max} 范围内的整数`);
|
|
70
|
+
}
|
|
71
|
+
const value = Number(raw);
|
|
72
|
+
if (!Number.isSafeInteger(value) || value < spec.min || value > spec.max) {
|
|
73
|
+
throw new Error(`${spec.env} 必须是 ${spec.min}..${spec.max} 范围内的整数`);
|
|
74
|
+
}
|
|
75
|
+
parsed[spec.key] = value;
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
export function parseBridgePort(value) {
|
|
80
|
+
const raw = typeof value === "number" ? String(value) : value;
|
|
81
|
+
if (!/^\d+$/.test(raw)) {
|
|
82
|
+
throw new Error("Port 必须是 1..65535 范围内的整数");
|
|
83
|
+
}
|
|
84
|
+
const port = Number(raw);
|
|
85
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
86
|
+
throw new Error("Port 必须是 1..65535 范围内的整数");
|
|
87
|
+
}
|
|
88
|
+
return port;
|
|
89
|
+
}
|
|
90
|
+
export function normalizeHost(host) {
|
|
91
|
+
const trimmed = host.trim();
|
|
92
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
93
|
+
return trimmed.slice(1, -1);
|
|
94
|
+
}
|
|
95
|
+
return trimmed;
|
|
96
|
+
}
|
|
97
|
+
function parseIpv4(value) {
|
|
98
|
+
const parts = value.split(".");
|
|
99
|
+
if (parts.length !== 4)
|
|
100
|
+
return null;
|
|
101
|
+
const bytes = [];
|
|
102
|
+
for (const part of parts) {
|
|
103
|
+
if (!/^\d{1,3}$/.test(part))
|
|
104
|
+
return null;
|
|
105
|
+
const byte = Number(part);
|
|
106
|
+
if (byte < 0 || byte > 255)
|
|
107
|
+
return null;
|
|
108
|
+
bytes.push(byte);
|
|
109
|
+
}
|
|
110
|
+
return bytes;
|
|
111
|
+
}
|
|
112
|
+
function parseIpv6Groups(value) {
|
|
113
|
+
let host = value.toLowerCase();
|
|
114
|
+
if (host.includes("%"))
|
|
115
|
+
return null;
|
|
116
|
+
const lastColon = host.lastIndexOf(":");
|
|
117
|
+
const tail = lastColon >= 0 ? host.slice(lastColon + 1) : host;
|
|
118
|
+
if (tail.includes(".")) {
|
|
119
|
+
const ipv4 = parseIpv4(tail);
|
|
120
|
+
if (!ipv4)
|
|
121
|
+
return null;
|
|
122
|
+
const high = ((ipv4[0] ?? 0) << 8) | (ipv4[1] ?? 0);
|
|
123
|
+
const low = ((ipv4[2] ?? 0) << 8) | (ipv4[3] ?? 0);
|
|
124
|
+
host = `${host.slice(0, lastColon + 1)}${high.toString(16)}:${low.toString(16)}`;
|
|
125
|
+
}
|
|
126
|
+
const halves = host.split("::");
|
|
127
|
+
if (halves.length > 2)
|
|
128
|
+
return null;
|
|
129
|
+
const parseHalf = (half) => {
|
|
130
|
+
if (!half)
|
|
131
|
+
return [];
|
|
132
|
+
const groups = [];
|
|
133
|
+
for (const part of half.split(":")) {
|
|
134
|
+
if (!/^[0-9a-f]{1,4}$/.test(part))
|
|
135
|
+
return null;
|
|
136
|
+
groups.push(Number.parseInt(part, 16));
|
|
137
|
+
}
|
|
138
|
+
return groups;
|
|
139
|
+
};
|
|
140
|
+
const left = parseHalf(halves[0] ?? "");
|
|
141
|
+
const right = parseHalf(halves[1] ?? "");
|
|
142
|
+
if (!left || !right)
|
|
143
|
+
return null;
|
|
144
|
+
if (halves.length === 1) {
|
|
145
|
+
return left.length === 8 ? left : null;
|
|
146
|
+
}
|
|
147
|
+
const missing = 8 - left.length - right.length;
|
|
148
|
+
if (missing < 1)
|
|
149
|
+
return null;
|
|
150
|
+
return [...left, ...Array.from({ length: missing }, () => 0), ...right];
|
|
151
|
+
}
|
|
152
|
+
export function isLoopbackHost(host) {
|
|
153
|
+
const normalized = normalizeHost(host).toLowerCase();
|
|
154
|
+
if (normalized === "localhost")
|
|
155
|
+
return true;
|
|
156
|
+
const ipv4 = parseIpv4(normalized);
|
|
157
|
+
if (ipv4)
|
|
158
|
+
return ipv4[0] === 127;
|
|
159
|
+
const groups = parseIpv6Groups(normalized);
|
|
160
|
+
if (!groups)
|
|
161
|
+
return false;
|
|
162
|
+
const isV6Loopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1;
|
|
163
|
+
if (isV6Loopback)
|
|
164
|
+
return true;
|
|
165
|
+
const isMappedIpv4 = groups.slice(0, 5).every((group) => group === 0) &&
|
|
166
|
+
groups[5] === 0xffff;
|
|
167
|
+
return isMappedIpv4 && ((groups[6] ?? 0) >> 8) === 127;
|
|
168
|
+
}
|
|
169
|
+
export function formatHostForUrl(host) {
|
|
170
|
+
const normalized = normalizeHost(host);
|
|
171
|
+
return isIP(normalized) === 6 || normalized.includes(":")
|
|
172
|
+
? `[${normalized}]`
|
|
173
|
+
: normalized;
|
|
174
|
+
}
|
|
175
|
+
export function assertBridgeListenerAllowed(host, allowRemote) {
|
|
176
|
+
if (!isLoopbackHost(host) && !allowRemote) {
|
|
177
|
+
throw new Error(`非回环 bridge 监听地址 ${host || "(empty)"} 必须显式传入 --allow-remote`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export function advertiseHostForBind(host) {
|
|
181
|
+
const normalized = normalizeHost(host);
|
|
182
|
+
if (!normalized || normalized === "0.0.0.0")
|
|
183
|
+
return "127.0.0.1";
|
|
184
|
+
if (normalized === "::")
|
|
185
|
+
return "::1";
|
|
186
|
+
return normalized;
|
|
187
|
+
}
|
|
188
|
+
export function resolveBridgeListener(options) {
|
|
189
|
+
const bindHost = normalizeHost(options.host);
|
|
190
|
+
assertBridgeListenerAllowed(bindHost, options.allowRemote);
|
|
191
|
+
return {
|
|
192
|
+
bindHost,
|
|
193
|
+
advertiseHost: options.advertiseHost
|
|
194
|
+
? normalizeHost(options.advertiseHost)
|
|
195
|
+
: advertiseHostForBind(bindHost),
|
|
196
|
+
port: parseBridgePort(options.port),
|
|
197
|
+
allowRemote: options.allowRemote,
|
|
198
|
+
};
|
|
199
|
+
}
|