@nvae/llmswitch 0.2.0 → 0.4.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 +1 -1
- 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/commands/bridge-cmd.js +13 -11
- package/dist/commands/prompts.js +47 -51
- package/dist/store/profiles.js +2 -2
- package/dist/types.js +20 -3
- package/dist/utils/fetch-models.js +45 -56
- package/dist/utils/proxy.js +14 -37
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -284,7 +284,7 @@ API Key 会以**明文**保存在本机 profile、目标工具认证文件或 br
|
|
|
284
284
|
- Anthropic 转换主要覆盖文本、工具调用和工具结果,图片等非文本内容可能无法完整保留。
|
|
285
285
|
- Codex 的 Completions 模式是降级路径:对话会被展平为 prompt,复杂工具调用或 agent 流程可能表现不佳。
|
|
286
286
|
- Codex Chat 模式仅在上游返回 HTTP `404` 或 `405` 时回退到 Completions;认证、网络及其他错误不会触发回退。
|
|
287
|
-
-
|
|
287
|
+
- 请求中的托管 `web_search` 会转换为普通 Chat function;若上游自行搜索并以完整 `<web_search>...</web_search>` 块返回结果,bridge 会将其恢复为 Responses `web_search_call`,并保留去标签后的结果正文。
|
|
288
288
|
- 自动发现模型需要 API Key 和兼容的 `/models` 端点;失败时需手动输入模型。
|
|
289
289
|
|
|
290
290
|
## 参与贡献
|
package/dist/adapters/claude.js
CHANGED
|
@@ -35,14 +35,14 @@ function stripManagedEnv(env) {
|
|
|
35
35
|
}
|
|
36
36
|
return next;
|
|
37
37
|
}
|
|
38
|
-
export function buildClaudeSettings(existing, profile, effectiveBaseUrl) {
|
|
38
|
+
export function buildClaudeSettings(existing, profile, effectiveBaseUrl, effectiveApiKey = profile.apiKey) {
|
|
39
39
|
assertCompatible("claude", profile.apiFormat);
|
|
40
40
|
const env = stripManagedEnv({ ...(existing.env || {}) });
|
|
41
41
|
clearProxyEnvKeys(env);
|
|
42
42
|
const needsBridge = profileNeedsBridge(profile);
|
|
43
43
|
env.ANTHROPIC_BASE_URL = effectiveBaseUrl || profile.baseUrl;
|
|
44
|
-
if (
|
|
45
|
-
env.ANTHROPIC_AUTH_TOKEN =
|
|
44
|
+
if (effectiveApiKey) {
|
|
45
|
+
env.ANTHROPIC_AUTH_TOKEN = effectiveApiKey;
|
|
46
46
|
}
|
|
47
47
|
env.ANTHROPIC_MODEL = profile.models.default;
|
|
48
48
|
env.ANTHROPIC_DEFAULT_SONNET_MODEL = profile.models.default;
|
|
@@ -75,15 +75,18 @@ export async function applyClaudeProfile(profile) {
|
|
|
75
75
|
const existing = readClaudeSettings(configPath);
|
|
76
76
|
const backupPath = backupFile(configPath, getBackupsDir("claude"), "settings");
|
|
77
77
|
let effectiveBaseUrl = profile.baseUrl;
|
|
78
|
+
let effectiveApiKey = profile.apiKey;
|
|
78
79
|
let bridgeNote = "";
|
|
79
80
|
if (profileNeedsBridge(profile)) {
|
|
80
|
-
|
|
81
|
+
const connection = await ensureBridgeForProfile(profile, "claude");
|
|
82
|
+
effectiveBaseUrl = connection.baseUrl;
|
|
83
|
+
effectiveApiKey = connection.clientToken;
|
|
81
84
|
bridgeNote = `已启动本地 Anthropic↔Chat 适配桥 → ${effectiveBaseUrl}(上游 ${profile.baseUrl})。`;
|
|
82
85
|
}
|
|
83
86
|
else {
|
|
84
87
|
await clearBridgeUpstream("claude");
|
|
85
88
|
}
|
|
86
|
-
const next = buildClaudeSettings(existing, profile, effectiveBaseUrl);
|
|
89
|
+
const next = buildClaudeSettings(existing, profile, effectiveBaseUrl, effectiveApiKey);
|
|
87
90
|
atomicWriteFile(configPath, JSON.stringify(next, null, 2) + "\n");
|
|
88
91
|
setActiveProfile("claude", profile.name);
|
|
89
92
|
return {
|
package/dist/adapters/codex.js
CHANGED
|
@@ -48,7 +48,7 @@ export function buildCodexConfig(existing, profile, effectiveBaseUrl) {
|
|
|
48
48
|
model_providers: providers,
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
-
export function buildCodexEnvFile(existingContent, profile) {
|
|
51
|
+
export function buildCodexEnvFile(existingContent, profile, effectiveApiKey = profile.apiKey || "llm-switch-bridge") {
|
|
52
52
|
const lines = existingContent ? existingContent.split(/\r?\n/) : [];
|
|
53
53
|
const map = new Map();
|
|
54
54
|
const order = [];
|
|
@@ -75,7 +75,7 @@ export function buildCodexEnvFile(existingContent, profile) {
|
|
|
75
75
|
// When bridging, Codex talks to local bridge; key can be placeholder.
|
|
76
76
|
// Bridge uses upstream.apiKey from its own config. Still write real key
|
|
77
77
|
// so native responses profiles keep working.
|
|
78
|
-
map.set(keyName,
|
|
78
|
+
map.set(keyName, effectiveApiKey);
|
|
79
79
|
const proxyKeys = [
|
|
80
80
|
"HTTP_PROXY",
|
|
81
81
|
"HTTPS_PROXY",
|
|
@@ -124,11 +124,17 @@ export async function applyCodexProfile(profile) {
|
|
|
124
124
|
assertCompatible("codex", profile.apiFormat);
|
|
125
125
|
ensureDir(getCodexHome());
|
|
126
126
|
let effectiveBaseUrl = normalizeBaseUrlForFormat(profile.apiFormat, profile.baseUrl);
|
|
127
|
+
let effectiveApiKey = profile.apiKey || "llm-switch-bridge";
|
|
127
128
|
let bridgeNote = "";
|
|
128
129
|
if (profileNeedsBridge(profile)) {
|
|
129
|
-
|
|
130
|
+
const connection = await ensureBridgeForProfile(profile, "codex");
|
|
131
|
+
effectiveBaseUrl = connection.baseUrl.replace(/\/+$/, "");
|
|
132
|
+
effectiveApiKey = connection.clientToken;
|
|
130
133
|
bridgeNote = `已启动本地 Responses 适配桥 → ${effectiveBaseUrl}(上游 ${normalizeBaseUrlForFormat(profile.apiFormat, profile.baseUrl)},模式 ${profile.bridgeMode || "chat"})。`;
|
|
131
134
|
}
|
|
135
|
+
else {
|
|
136
|
+
await clearBridgeUpstream("codex");
|
|
137
|
+
}
|
|
132
138
|
const configPath = getCodexConfigPath();
|
|
133
139
|
const envPath = getCodexEnvPath();
|
|
134
140
|
const existing = readCodexConfig(configPath);
|
|
@@ -137,7 +143,7 @@ export async function applyCodexProfile(profile) {
|
|
|
137
143
|
const next = buildCodexConfig(existing, profile, effectiveBaseUrl);
|
|
138
144
|
atomicWriteFile(configPath, stringify(next) + "\n");
|
|
139
145
|
const prevEnv = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
140
|
-
atomicWriteFile(envPath, buildCodexEnvFile(prevEnv, profile));
|
|
146
|
+
atomicWriteFile(envPath, buildCodexEnvFile(prevEnv, profile, effectiveApiKey));
|
|
141
147
|
setActiveProfile("codex", profile.name);
|
|
142
148
|
return {
|
|
143
149
|
tool: "codex",
|
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
|
}
|