@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 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
- - 托管 `web_search` 会转换为普通客户端函数,不会自动获得上游的托管搜索能力。
287
+ - 请求中的托管 `web_search` 会转换为普通 Chat function;若上游自行搜索并以完整 `<web_search>...</web_search>` 块返回结果,bridge 会将其恢复为 Responses `web_search_call`,并保留去标签后的结果正文。
288
288
  - 自动发现模型需要 API Key 和兼容的 `/models` 端点;失败时需手动输入模型。
289
289
 
290
290
  ## 参与贡献
@@ -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 (profile.apiKey) {
45
- env.ANTHROPIC_AUTH_TOKEN = profile.apiKey;
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
- effectiveBaseUrl = await ensureBridgeForProfile(profile, "claude");
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 {
@@ -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, profile.apiKey || "llm-switch-bridge");
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
- effectiveBaseUrl = (await ensureBridgeForProfile(profile, "codex")).replace(/\/+$/, "");
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",
@@ -1,19 +1,37 @@
1
- import { execFileSync, spawn } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
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, getBridgePidPath, readBridgeState, readBridgeUpstreams, writeBridgeState, writeBridgeUpstream, } from "./state.js";
6
- import { DEFAULT_BRIDGE_HOST, DEFAULT_BRIDGE_PORT, emptyUpstreams, hasAnyUpstream, } from "./types.js";
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
- if (tool === "codex" || tool === "claude")
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
- * Probe local bridge. Old builds may return 503 without `upstreams` — treat as
30
- * reachable but not healthy so callers can restart.
31
- */
32
- export async function probeBridge(host = readBridgeState().host, port = readBridgeState().port) {
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 res = await fetch(`http://${host}:${port}/health`, {
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 res.json());
65
+ body = (await response.json());
40
66
  }
41
67
  catch {
42
68
  body = null;
43
69
  }
44
- const healthy = res.ok && body?.ok === true && body != null && "upstreams" in body;
45
- return { reachable: true, healthy };
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 { reachable: false, healthy: false };
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().host, port = readBridgeState().port) {
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
- const path = getBridgePidPath();
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 urlForTool(tool, host, port, pid) {
71
- const upstreams = readBridgeUpstreams();
72
- const state = { host, port, upstreams, pid };
73
- return tool === "claude" ? bridgeRootUrl(state) : bridgeBaseUrl(state);
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
- * Configure per-tool upstream and ensure local bridge is listening.
77
- * Returns Codex base (`…/v1`) or Claude root (`…` without /v1).
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 upstream = upstreamFromProfile(profile, tool);
81
- writeBridgeUpstream(tool, upstream);
82
- const state = readBridgeState();
83
- const host = state.host || DEFAULT_BRIDGE_HOST;
84
- const port = Number(process.env.LLM_SWITCH_BRIDGE_PORT) ||
85
- state.port ||
86
- DEFAULT_BRIDGE_PORT;
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
- await startBridgeDaemon(host, port);
104
- for (let i = 0; i < 50; i++) {
105
- if (await isBridgeAlive(host, port)) {
106
- return urlForTool(tool, host, port, readPid());
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
- throw new Error(`Bridge 启动超时(${host}:${port})。可手动运行:llms bridge serve`);
162
+ return connectionForTool(tool, readBridgeState() || configured, clientToken);
111
163
  }
112
164
  export async function clearBridgeUpstream(tool) {
113
165
  writeBridgeUpstream(tool, null);
114
- const upstreams = readBridgeUpstreams();
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
- if (await isBridgeAlive(host, port)) {
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
- await forceStopBridge(host, port);
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 child = spawn(process.execPath, [entry, "bridge", "serve", "--host", host, "--port", String(port)], {
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
- const pid = child.pid;
139
- if (!pid)
140
- throw new Error("无法启动 bridge 进程");
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 pid = state.pid || readPid();
166
- let stopped = false;
167
- if (pid && isPidRunning(pid)) {
168
- try {
169
- process.kill(pid, "SIGTERM");
170
- stopped = true;
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
- writeBridgeState({
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
- const out = execFileSync("lsof", ["-ti", `tcp:${port}`, `-sTCP:LISTEN`], { encoding: "utf8" });
188
- for (const line of out.split(/\n/)) {
189
- const pid = Number(line.trim());
190
- if (!pid || pid === process.pid)
191
- continue;
192
- try {
193
- process.kill(pid, "SIGTERM");
194
- }
195
- catch {
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
- // lsof miss / no listeners ignore
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
- export async function runBridgeForeground(host, port) {
205
- const state = readBridgeState();
206
- writeBridgeState({
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
- host,
209
- port,
257
+ listener,
258
+ host: listener.advertiseHost,
259
+ port: listener.port,
210
260
  pid: process.pid,
211
- upstreams: state.upstreams.codex || state.upstreams.claude
212
- ? state.upstreams
213
- : readBridgeUpstreams() || emptyUpstreams(),
261
+ instance,
262
+ }));
263
+ let resolveClosed;
264
+ const closed = new Promise((resolve) => {
265
+ resolveClosed = resolve;
214
266
  });
215
- const server = await listenBridge(port, host);
216
- const shutdown = () => {
217
- server.close(() => process.exit(0));
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
- process.on("SIGINT", shutdown);
220
- process.on("SIGTERM", shutdown);
221
- console.error(`llm-switch bridge listening on http://${host}:${port} (POST /v1/responses · POST /v1/messages → upstream chat/completions)`);
222
- await new Promise(() => undefined);
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 Error("无法定位 llmswitch 入口文件");
334
+ throw new BridgeControlError("无法定位 llmswitch 入口文件");
240
335
  }