dsh-plugin-litellm-bridge 0.0.0-stage → 2.0.2
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 +165 -2
- package/docs/README.md +716 -0
- package/dsh-plugin-litellm-bridge/README.md +32 -0
- package/dsh-plugin-litellm-bridge/client/index.js +1336 -0
- package/dsh-plugin-litellm-bridge/cordis.patch.yml +27 -0
- package/dsh-plugin-litellm-bridge/icon.svg +12 -0
- package/dsh-plugin-litellm-bridge/lib/assemble-chain.js +193 -0
- package/dsh-plugin-litellm-bridge/lib/config-store.js +290 -0
- package/dsh-plugin-litellm-bridge/lib/gateway-client.js +587 -0
- package/dsh-plugin-litellm-bridge/lib/gateway-supervisor.js +518 -0
- package/dsh-plugin-litellm-bridge/lib/index.js +760 -0
- package/dsh-plugin-litellm-bridge/lib/model-pool.js +248 -0
- package/dsh-plugin-litellm-bridge/lib/probe-log.js +102 -0
- package/dsh-plugin-litellm-bridge/lib/subagent-routing.js +280 -0
- package/dsh-plugin-litellm-bridge/locale/en.json +43 -0
- package/dsh-plugin-litellm-bridge/locale/zh.json +43 -0
- package/dsh-plugin-litellm-bridge/manifest.json +57 -0
- package/package.json +73 -4
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiteLLM 网关生命周期托管
|
|
3
|
+
* ============================================================================
|
|
4
|
+
*
|
|
5
|
+
* 目标:网关跟着 DSH 一起活、一起死,不当常驻后台服务。
|
|
6
|
+
* - DSH 启动 → 插件 apply() 时检查网关;没起就拉起来
|
|
7
|
+
* - DSH 退出 → 插件卸载(ctx.effect 清理)时把网关收掉
|
|
8
|
+
*
|
|
9
|
+
* 【为什么不在外面手动起】
|
|
10
|
+
* 用户明确反馈:网关一直在后台运行没什么作用,还要每次手动重启。
|
|
11
|
+
* 所以改成「插件托管子进程」:进程随 DSH 生灭。
|
|
12
|
+
*
|
|
13
|
+
* 【关键设计】
|
|
14
|
+
* 1. 先探测再启动 —— 如果用户已经手动开了网关(或别的 DSH 实例开着),
|
|
15
|
+
* 绝不重复启动,只记下「不是自己起的」,退出时也不杀它。
|
|
16
|
+
* 这一点很重要:多开 DSH 时互相杀进程会造成莫名其妙的断线。
|
|
17
|
+
* 2. ownChild 标记 —— 只有自己拉起的才负责杀。
|
|
18
|
+
* 3. 优雅终止 → 强杀兜底 —— 先 SIGTERM,超时再 SIGKILL。
|
|
19
|
+
* 4. 不阻塞 DSH 启动 —— 启动放后台跑,健康检查用轮询,最多等 N 秒。
|
|
20
|
+
* 5. 任何异常都不能让插件挂掉 —— 整个模块 fail-soft。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { createConnection } from 'node:net';
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
26
|
+
import { dirname, join, resolve } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
/** 默认网关地址。 */
|
|
30
|
+
export const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:4000';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 从网关地址里拆出 host / port。
|
|
34
|
+
* 解析失败就退回 127.0.0.1:4000。
|
|
35
|
+
*/
|
|
36
|
+
export function parseGatewayUrl(url) {
|
|
37
|
+
try {
|
|
38
|
+
const u = new URL(String(url || DEFAULT_GATEWAY_URL));
|
|
39
|
+
const port = Number(u.port) || (u.protocol === 'https:' ? 443 : 80);
|
|
40
|
+
return { host: u.hostname || '127.0.0.1', port, protocol: u.protocol };
|
|
41
|
+
} catch {
|
|
42
|
+
return { host: '127.0.0.1', port: 4000, protocol: 'http:' };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 探测网关是否已在监听(HTTP 探活,失败再退回 TCP 探测)。 */
|
|
47
|
+
export async function isGatewayAlive(url, timeoutMs = 1500) {
|
|
48
|
+
const base = String(url || DEFAULT_GATEWAY_URL).replace(/\/+$/, '');
|
|
49
|
+
// 1) HTTP 探活
|
|
50
|
+
try {
|
|
51
|
+
const ctrl = new AbortController();
|
|
52
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(`${base}/health/liveliness`, { signal: ctrl.signal });
|
|
55
|
+
if (res.ok) return true;
|
|
56
|
+
} finally {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
/* 落到 TCP 探测 */
|
|
61
|
+
}
|
|
62
|
+
// 2) TCP 探测(网关在启动中时会拒绝 HTTP,但端口已监听)
|
|
63
|
+
const { host, port } = parseGatewayUrl(base);
|
|
64
|
+
return await tcpProbe(host, port, timeoutMs);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 朴素 TCP 连通性探测(不依赖外部包)。 */
|
|
68
|
+
function tcpProbe(host, port, timeoutMs = 1500) {
|
|
69
|
+
return new Promise((resolvePromise) => {
|
|
70
|
+
let done = false;
|
|
71
|
+
let socket;
|
|
72
|
+
const finish = (v) => {
|
|
73
|
+
if (done) return;
|
|
74
|
+
done = true;
|
|
75
|
+
try {
|
|
76
|
+
socket?.destroy?.();
|
|
77
|
+
} catch {
|
|
78
|
+
/* ignore */
|
|
79
|
+
}
|
|
80
|
+
resolvePromise(v);
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
socket = createConnection({ host, port });
|
|
84
|
+
socket.setTimeout(timeoutMs);
|
|
85
|
+
socket.once('connect', () => finish(true));
|
|
86
|
+
socket.once('timeout', () => finish(false));
|
|
87
|
+
socket.once('error', () => finish(false));
|
|
88
|
+
} catch {
|
|
89
|
+
finish(false);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 解析 bash 可执行文件路径。
|
|
96
|
+
*
|
|
97
|
+
* Windows 上 `bash` 通常不在 PATH 里(Git Bash 装在 D:\Git\bin\bash.exe 之类),
|
|
98
|
+
* 直接 spawn('bash') 会 ENOENT。这里按顺序探测常见位置;
|
|
99
|
+
* 非 Windows 直接用 'bash'(在 PATH 里)。
|
|
100
|
+
*/
|
|
101
|
+
export function resolveBash() {
|
|
102
|
+
if (process.platform !== 'win32') return 'bash';
|
|
103
|
+
const candidates = [
|
|
104
|
+
process.env.LITELLM_BRIDGE_BASH,
|
|
105
|
+
'D:/Git/bin/bash.exe',
|
|
106
|
+
'C:/Program Files/Git/bin/bash.exe',
|
|
107
|
+
'C:/Program Files (x86)/Git/bin/bash.exe',
|
|
108
|
+
'D:/Program Files/Git/bin/bash.exe',
|
|
109
|
+
// WSL / 其他
|
|
110
|
+
'C:/Windows/System32/bash.exe',
|
|
111
|
+
];
|
|
112
|
+
for (const c of candidates) {
|
|
113
|
+
try {
|
|
114
|
+
if (c && existsSync(c)) return c;
|
|
115
|
+
} catch {
|
|
116
|
+
/* ignore */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// 兜底:交给 PATH 解析(若真没有,会在 spawn 时报错并被 catch 住)
|
|
120
|
+
return 'bash';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 定位 litellm-gateway 目录。
|
|
125
|
+
*
|
|
126
|
+
* 关键难点:插件被 pnpm 装进 `<profile>/node_modules/dsh-plugin-litellm-bridge`,
|
|
127
|
+
* 而 litellm-gateway/ 是仓库里的**兄弟目录**,从安装位置往上找是找不到的。
|
|
128
|
+
* 所以按以下顺序尝试:
|
|
129
|
+
* 1. 显式配置(config.gatewayDir / LITELLM_BRIDGE_DIR)—— 最可靠
|
|
130
|
+
* 2. 从插件源目录(开发态:repo/dsh-plugin-litellm-bridge/lib)往上找兄弟
|
|
131
|
+
* 3. 从本机 profile 的 pnpm 记录 / 常见仓库路径猜
|
|
132
|
+
* 4. 常见绝对路径兜底
|
|
133
|
+
*/
|
|
134
|
+
export function findGatewayDir(explicitDir) {
|
|
135
|
+
const candidates = [];
|
|
136
|
+
const push = (p) => {
|
|
137
|
+
if (p && !candidates.includes(p)) candidates.push(p);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// 1) 显式指定
|
|
141
|
+
push(explicitDir);
|
|
142
|
+
push(process.env.LITELLM_BRIDGE_DIR);
|
|
143
|
+
|
|
144
|
+
// 2) 开发态:本文件在 <repo>/dsh-plugin-litellm-bridge/lib/
|
|
145
|
+
try {
|
|
146
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
147
|
+
push(resolve(here, '..', '..', 'litellm-gateway'));
|
|
148
|
+
} catch {
|
|
149
|
+
/* ignore */
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3) 安装态:pnpm 用 file: 链接时,node_modules 里可能是 symlink,
|
|
153
|
+
// realpath 会指回仓库;这里用 fileURLToPath 已解析,再补一层向上探测。
|
|
154
|
+
try {
|
|
155
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
156
|
+
let cur = here;
|
|
157
|
+
for (let i = 0; i < 6; i++) {
|
|
158
|
+
cur = resolve(cur, '..');
|
|
159
|
+
push(join(cur, 'litellm-gateway'));
|
|
160
|
+
push(join(cur, 'dsh-fallback-auto-pool-arch', 'litellm-gateway'));
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
/* ignore */
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 4) 常见绝对路径兜底
|
|
167
|
+
push('D:/AI sandbox/dsh-sandbox/dsh-fallback-auto-pool-arch/litellm-gateway');
|
|
168
|
+
|
|
169
|
+
for (const c of candidates) {
|
|
170
|
+
try {
|
|
171
|
+
if (existsSync(join(c, 'start-gateway.sh'))) return c;
|
|
172
|
+
} catch {
|
|
173
|
+
/* ignore */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 网关托管器。
|
|
181
|
+
*
|
|
182
|
+
* @param {object} opts
|
|
183
|
+
* @param {string} opts.gatewayUrl 网关地址(用于探活与端口)
|
|
184
|
+
* @param {string} [opts.envFile] .env 路径
|
|
185
|
+
* @param {object} [opts.logger] { info, warn, debug }
|
|
186
|
+
* @param {number} [opts.startTimeoutMs] 等待网关就绪的上限
|
|
187
|
+
*/
|
|
188
|
+
export class GatewaySupervisor {
|
|
189
|
+
constructor({ gatewayUrl, envFile, gatewayDir, logger, startTimeoutMs = 45000, onLog } = {}) {
|
|
190
|
+
this.gatewayUrl = gatewayUrl || DEFAULT_GATEWAY_URL;
|
|
191
|
+
this.envFile = envFile || '';
|
|
192
|
+
this.gatewayDir = gatewayDir || '';
|
|
193
|
+
this.logger = logger || {};
|
|
194
|
+
this.startTimeoutMs = startTimeoutMs;
|
|
195
|
+
/**
|
|
196
|
+
* 【需求7】异常退出告警回调。
|
|
197
|
+
* 网关进程意外死掉时,必须能在插件的日志面板里看到 —— 否则用户只会
|
|
198
|
+
* 觉得「降级没生效」,却不知道是网关根本没在跑。
|
|
199
|
+
*/
|
|
200
|
+
this.onLog = typeof onLog === 'function' ? onLog : null;
|
|
201
|
+
|
|
202
|
+
/** 自己拉起的子进程;为 null 表示没管过或不是自己起的 */
|
|
203
|
+
this.child = null;
|
|
204
|
+
/** 是否拥有生命周期(决定退出时要不要杀) */
|
|
205
|
+
this.owns = false;
|
|
206
|
+
/** 启动失败原因(供 UI 显示) */
|
|
207
|
+
this.lastError = null;
|
|
208
|
+
this.starting = false;
|
|
209
|
+
this._disposed = false;
|
|
210
|
+
/** 【需求7】生命周期状态:stopped | starting | running | error */
|
|
211
|
+
this.state = 'stopped';
|
|
212
|
+
/** 最近一次异常退出信息(UI 直接展示) */
|
|
213
|
+
this.lastExit = null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
_log(lvl, msg) {
|
|
217
|
+
try {
|
|
218
|
+
this.logger?.[lvl]?.(`[litellm-bridge/gateway] ${msg}`);
|
|
219
|
+
} catch { /* ignore */ }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 写一条「网关生命周期」事件到插件日志面板(需求7)。 */
|
|
223
|
+
_event(level, message) {
|
|
224
|
+
try {
|
|
225
|
+
this.onLog?.({ level, message, at: new Date().toISOString() });
|
|
226
|
+
} catch { /* 日志失败绝不影响主流程 */ }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 确保网关可用:
|
|
231
|
+
* - 活着 → 什么都不做,owns=false
|
|
232
|
+
* - 没活 → 拉起,owns=true
|
|
233
|
+
* 任何失败都只记录、不抛。
|
|
234
|
+
*/
|
|
235
|
+
async ensureRunning() {
|
|
236
|
+
if (this._disposed) return { running: false, started: false, error: 'supervisor disposed' };
|
|
237
|
+
if (this.starting) return { running: false, started: false, error: 'already starting' };
|
|
238
|
+
|
|
239
|
+
// 1) 先看是不是已经活着
|
|
240
|
+
if (await isGatewayAlive(this.gatewayUrl)) {
|
|
241
|
+
this._log('info', `网关已在线(外部启动,不接管生命周期): ${this.gatewayUrl}`);
|
|
242
|
+
this.owns = false;
|
|
243
|
+
this.state = 'running';
|
|
244
|
+
this._event('info', '网关已在线(外部启动,本插件不接管其生命周期)');
|
|
245
|
+
return { running: true, started: false, owned: false };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 2) 需要拉起
|
|
249
|
+
const dir = findGatewayDir(this.gatewayDir);
|
|
250
|
+
if (!dir) {
|
|
251
|
+
this.lastError = '找不到 litellm-gateway 目录(缺 start-gateway.sh)';
|
|
252
|
+
this.state = 'error';
|
|
253
|
+
this._log('warn', this.lastError);
|
|
254
|
+
this._event('error', this.lastError);
|
|
255
|
+
return { running: false, started: false, error: this.lastError };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const { port } = parseGatewayUrl(this.gatewayUrl);
|
|
259
|
+
this.starting = true;
|
|
260
|
+
this.state = 'starting';
|
|
261
|
+
this._event('info', `正在启动网关(端口 ${port})…`);
|
|
262
|
+
try {
|
|
263
|
+
const child = spawn(
|
|
264
|
+
resolveBash(),
|
|
265
|
+
[
|
|
266
|
+
join(dir, 'start-gateway.sh'),
|
|
267
|
+
'--port', String(port),
|
|
268
|
+
],
|
|
269
|
+
{
|
|
270
|
+
cwd: dir,
|
|
271
|
+
env: {
|
|
272
|
+
...process.env,
|
|
273
|
+
...(this.envFile ? { LITELLM_BRIDGE_ENV: this.envFile } : {}),
|
|
274
|
+
PYTHONUTF8: '1',
|
|
275
|
+
PYTHONIOENCODING: 'utf-8',
|
|
276
|
+
},
|
|
277
|
+
// 脱离父进程组:DSH 崩溃时网关也能被我们显式收掉,而不是跟着乱死
|
|
278
|
+
detached: false,
|
|
279
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
280
|
+
shell: false,
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
this.child = child;
|
|
285
|
+
this.owns = true;
|
|
286
|
+
|
|
287
|
+
// 把网关日志转进 DSH 日志。
|
|
288
|
+
// 【注意】LiteLLM 用 --detailed_debug 时会狂刷 DEBUG 行(每条都是 JSON),
|
|
289
|
+
// 直接转发会把 DSH 日志淹掉。所以:
|
|
290
|
+
// - 只取每个 chunk 的前若干行
|
|
291
|
+
// - 跳过 DEBUG 级与明显的噪音行
|
|
292
|
+
// - 单行截断
|
|
293
|
+
const isNoise = (line) =>
|
|
294
|
+
/"level":\s*"DEBUG"/.test(line) ||
|
|
295
|
+
/checking potential_model_names/.test(line) ||
|
|
296
|
+
/added\/updated model=/.test(line) ||
|
|
297
|
+
/^\s*$/.test(line);
|
|
298
|
+
let relayed = 0;
|
|
299
|
+
const relay = (stream, lvl) => {
|
|
300
|
+
stream?.setEncoding?.('utf8');
|
|
301
|
+
stream?.on?.('data', (buf) => {
|
|
302
|
+
if (relayed > 200) return; // 硬上限,防止极端情况刷屏
|
|
303
|
+
const text = String(buf).trim();
|
|
304
|
+
if (!text) return;
|
|
305
|
+
for (const line of text.split('\n').slice(0, 4)) {
|
|
306
|
+
if (isNoise(line)) continue;
|
|
307
|
+
relayed += 1;
|
|
308
|
+
this._log(lvl, line.slice(0, 240));
|
|
309
|
+
if (relayed > 200) break;
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
relay(child.stdout, 'debug');
|
|
314
|
+
relay(child.stderr, 'warn');
|
|
315
|
+
|
|
316
|
+
child.once('exit', (code, signal) => {
|
|
317
|
+
this.child = null;
|
|
318
|
+
this.owns = false;
|
|
319
|
+
const abnormal = !this._disposed && (code !== 0 && code !== null);
|
|
320
|
+
this.lastExit = { code, signal, at: new Date().toISOString(), abnormal };
|
|
321
|
+
if (this._disposed) {
|
|
322
|
+
this.state = 'stopped';
|
|
323
|
+
this._log('info', `网关进程已退出 code=${code} signal=${signal}(插件卸载,正常)`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (abnormal) {
|
|
327
|
+
// 【需求7】异常退出必须告警:否则用户只会觉得「降级没生效」
|
|
328
|
+
this.state = 'error';
|
|
329
|
+
this.lastError = `网关异常退出(code=${code}${signal ? `, signal=${signal}` : ''})`;
|
|
330
|
+
this._log('warn', this.lastError);
|
|
331
|
+
this._event('error', this.lastError);
|
|
332
|
+
} else {
|
|
333
|
+
this.state = 'stopped';
|
|
334
|
+
this._log('info', `网关进程退出 code=${code} signal=${signal}`);
|
|
335
|
+
this._event('warn', `网关进程已停止(code=${code})`);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
child.once('error', (err) => {
|
|
339
|
+
this.lastError = `启动失败: ${err?.message || err}`;
|
|
340
|
+
this.state = 'error';
|
|
341
|
+
this._log('warn', this.lastError);
|
|
342
|
+
this._event('error', this.lastError);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// 3) 等就绪(轮询)
|
|
346
|
+
const deadline = Date.now() + this.startTimeoutMs;
|
|
347
|
+
while (Date.now() < deadline) {
|
|
348
|
+
if (await isGatewayAlive(this.gatewayUrl, 1200)) {
|
|
349
|
+
this._log('info', `网关已就绪: ${this.gatewayUrl}`);
|
|
350
|
+
this.state = 'running';
|
|
351
|
+
this.lastError = null;
|
|
352
|
+
this._event('info', '网关已就绪(由本插件托管,退出 DSH 时自动关闭)');
|
|
353
|
+
return { running: true, started: true, owned: true };
|
|
354
|
+
}
|
|
355
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
this.lastError = `网关在 ${Math.round(this.startTimeoutMs / 1000)}s 内未就绪`;
|
|
359
|
+
this.state = 'error';
|
|
360
|
+
this._log('warn', this.lastError);
|
|
361
|
+
this._event('error', this.lastError);
|
|
362
|
+
return { running: false, started: true, owned: true, error: this.lastError };
|
|
363
|
+
} catch (err) {
|
|
364
|
+
this.lastError = String(err?.message || err);
|
|
365
|
+
this.state = 'error';
|
|
366
|
+
this._log('warn', `拉起网关失败: ${this.lastError}`);
|
|
367
|
+
this._event('error', `拉起网关失败: ${this.lastError}`);
|
|
368
|
+
return { running: false, started: false, error: this.lastError };
|
|
369
|
+
} finally {
|
|
370
|
+
this.starting = false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 【需求3】重启网关以加载新下发的 config.yaml。
|
|
376
|
+
*
|
|
377
|
+
* 为什么要重启而不是热重载:本机 LiteLLM 1.108.3 实测 /config/reload 是 404,
|
|
378
|
+
* /config/update 需要 PostgreSQL(SQLite 硬失败)。而 start-gateway.sh
|
|
379
|
+
* 里已有「config.yaml 已存在则跳过生成」的逻辑 —— 所以我们直接写
|
|
380
|
+
* config.yaml,再重启进程即可让新链路生效,无需任何数据库。
|
|
381
|
+
*/
|
|
382
|
+
async restart() {
|
|
383
|
+
const wasOwned = this.owns;
|
|
384
|
+
const killed = await this.stop({ force: true });
|
|
385
|
+
if (!killed.stopped && !killed.reason) {
|
|
386
|
+
// 停不掉就别硬起,避免端口冲突造成「看起来在跑其实没跑」
|
|
387
|
+
return { running: false, restarted: false, error: killed.error || '停止网关失败' };
|
|
388
|
+
}
|
|
389
|
+
// stop() 会把 _disposed 置 true,重启前必须复位
|
|
390
|
+
this._disposed = false;
|
|
391
|
+
this.state = 'stopped';
|
|
392
|
+
this._event('info', wasOwned ? '网关已停止,准备以新配置重启…' : '网关未运行,直接以新配置启动…');
|
|
393
|
+
const r = await this.ensureRunning();
|
|
394
|
+
return { ...r, restarted: Boolean(r.running) };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 停止网关(只在「自己起的」时候动手)。
|
|
399
|
+
*
|
|
400
|
+
* 【关键坑 · 实测两轮才解决】
|
|
401
|
+
* start-gateway.sh 是 bash 脚本,末尾 `exec litellm ...` —— 真正监听端口的是
|
|
402
|
+
* Python 进程。而 kill(bash) / taskkill /T 都**杀不掉**它:
|
|
403
|
+
* - bash 已经被 exec 替换成 litellm,父子关系断裂
|
|
404
|
+
* - Windows 上 taskkill /T 只顺着当前手里的 PID 树走,抓不到
|
|
405
|
+
* 实测结果:stop() 返回 stopped:true 但端口仍占着。
|
|
406
|
+
*
|
|
407
|
+
* 所以最终方案:**按端口反查占用进程并结束**。
|
|
408
|
+
* Windows → netstat -ano 找 LISTENING 的 PID → taskkill /F /PID
|
|
409
|
+
* POSIX → lsof -ti:<port> 或 fuser
|
|
410
|
+
* 这是唯一可靠的办法(网关自己占着端口,按端口找必然命中)。
|
|
411
|
+
*/
|
|
412
|
+
async stop({ force = false } = {}) {
|
|
413
|
+
this._disposed = true;
|
|
414
|
+
const child = this.child;
|
|
415
|
+
if (!child || !this.owns) {
|
|
416
|
+
this._log('debug', '网关不是本实例启动的,退出时不接管');
|
|
417
|
+
this.state = 'stopped';
|
|
418
|
+
return { stopped: false, reason: 'not-owned' };
|
|
419
|
+
}
|
|
420
|
+
const pid = child.pid;
|
|
421
|
+
const { port } = parseGatewayUrl(this.gatewayUrl);
|
|
422
|
+
try {
|
|
423
|
+
this._log('info', `正在停止网关(port=${port}, shellPid=${pid})…`);
|
|
424
|
+
|
|
425
|
+
// 1) 先给壳一个 SIGTERM(快速路径)
|
|
426
|
+
try { child.kill('SIGTERM'); } catch { /* ignore */ }
|
|
427
|
+
|
|
428
|
+
// 2) 按端口反查真正占用的进程并结束(关键步骤)
|
|
429
|
+
await this._killByPort(port);
|
|
430
|
+
|
|
431
|
+
// 3) 轮询等端口释放
|
|
432
|
+
let free = await this._waitPortFree(force ? 8000 : 15000);
|
|
433
|
+
if (!free) {
|
|
434
|
+
this._log('warn', '端口仍被占用,二次强制清理');
|
|
435
|
+
await this._killByPort(port, true);
|
|
436
|
+
free = await this._waitPortFree(5000);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
this.child = null;
|
|
440
|
+
this.owns = false;
|
|
441
|
+
const stillAlive = await isGatewayAlive(this.gatewayUrl, 800);
|
|
442
|
+
this._log(free && !stillAlive ? 'info' : 'warn',
|
|
443
|
+
free && !stillAlive ? '网关已停止' : '网关仍在运行(请手动检查)');
|
|
444
|
+
return { stopped: free && !stillAlive, stillAlive };
|
|
445
|
+
} catch (err) {
|
|
446
|
+
this._log('warn', `停止网关失败: ${err?.message || err}`);
|
|
447
|
+
return { stopped: false, error: String(err?.message || err) };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Windows: netstat 找监听该端口的 PID 并 taskkill;POSIX: lsof/fuser。 */
|
|
452
|
+
async _killByPort(port, force = false) {
|
|
453
|
+
const run = (cmd, args) =>
|
|
454
|
+
new Promise((resolve) => {
|
|
455
|
+
try {
|
|
456
|
+
const p = spawn(cmd, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
457
|
+
let out = '';
|
|
458
|
+
p.stdout?.setEncoding?.('utf8');
|
|
459
|
+
p.stdout?.on?.('data', (d) => { out += d; });
|
|
460
|
+
p.once('exit', () => resolve(out));
|
|
461
|
+
p.once('error', () => resolve(''));
|
|
462
|
+
setTimeout(() => resolve(out), 4000);
|
|
463
|
+
} catch {
|
|
464
|
+
resolve('');
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
if (process.platform === 'win32') {
|
|
469
|
+
const out = await run('netstat', ['-ano', '-p', 'TCP']);
|
|
470
|
+
const pids = new Set();
|
|
471
|
+
for (const line of out.split('\n')) {
|
|
472
|
+
// 形如: TCP 0.0.0.0:4000 0.0.0.0:0 LISTENING 12345
|
|
473
|
+
if (!/LISTENING/i.test(line)) continue;
|
|
474
|
+
if (!new RegExp(`[.:]${port}\\s`).test(line)) continue;
|
|
475
|
+
const m = line.trim().match(/(\d+)\s*$/);
|
|
476
|
+
if (m) pids.add(m[1]);
|
|
477
|
+
}
|
|
478
|
+
for (const p of pids) {
|
|
479
|
+
this._log('debug', `结束占用 ${port} 的进程 pid=${p}`);
|
|
480
|
+
await run('taskkill', ['/PID', p, '/T', '/F']);
|
|
481
|
+
}
|
|
482
|
+
return pids.size;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// POSIX
|
|
486
|
+
const lsof = await run('lsof', ['-ti', `:${port}`]);
|
|
487
|
+
const pids = lsof.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
488
|
+
for (const p of pids) {
|
|
489
|
+
this._log('debug', `结束占用 ${port} 的进程 pid=${p}`);
|
|
490
|
+
await run('kill', [force ? '-9' : '-15', p]);
|
|
491
|
+
}
|
|
492
|
+
return pids.length;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** 轮询直到端口不再响应(网关真的停了)。 */
|
|
496
|
+
async _waitPortFree(timeoutMs) {
|
|
497
|
+
const deadline = Date.now() + timeoutMs;
|
|
498
|
+
while (Date.now() < deadline) {
|
|
499
|
+
if (!(await isGatewayAlive(this.gatewayUrl, 800))) return true;
|
|
500
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
501
|
+
}
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** 当前状态(给 UI 用)。 */
|
|
506
|
+
status() {
|
|
507
|
+
return {
|
|
508
|
+
url: this.gatewayUrl,
|
|
509
|
+
owned: this.owns,
|
|
510
|
+
running: Boolean(this.child) && !this.child.killed,
|
|
511
|
+
starting: this.starting,
|
|
512
|
+
lastError: this.lastError,
|
|
513
|
+
/** 【需求7】stopped | starting | running | error */
|
|
514
|
+
state: this.state,
|
|
515
|
+
lastExit: this.lastExit,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
}
|