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,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-litellm-bridge —— 宿主半
|
|
3
|
+
* ============================================================================
|
|
4
|
+
*
|
|
5
|
+
* 【定位】LiteLLM 网关桥接面板。插件**不含任何路由 / 重试 / 降级执行逻辑**;
|
|
6
|
+
* 降级动作 100% 由 LiteLLM 网关按 config.yaml 执行。插件只做四件事:
|
|
7
|
+
* 读配置、写配置、下发降级链、发探针。
|
|
8
|
+
*
|
|
9
|
+
* 【架构修正要点(本轮)】
|
|
10
|
+
* 1. 模型清单**不再**从网关同步(models/sync 已删除)。数据源 = DSH
|
|
11
|
+
* 【设置-模型】的模型池(lib/model-pool.js 读取 llm / sessionController)。
|
|
12
|
+
* 2. 路由配置负责「新建 profile + root/coder/research 三条链 + 拖拽排序 +
|
|
13
|
+
* 超时」,保存后组装成 config.yaml 下发给网关,网关执行 fallback 调度。
|
|
14
|
+
* 3. 一键探针测的是**当前 profile 里选中的那组模型**,不是网关里的模型。
|
|
15
|
+
* 4. 网关生命周期跟随 DSH;进程异常退出写进日志面板(需求7)。
|
|
16
|
+
*
|
|
17
|
+
* 【契约】宿主半导出形状(照 dsh-llm-fallbacks):
|
|
18
|
+
* export function apply(ctx, config)
|
|
19
|
+
* export const name / provide / Config
|
|
20
|
+
*
|
|
21
|
+
* 【RPC 通道 · 实测踩过的坑】
|
|
22
|
+
* 最初把路由注册成 `kind:'prefix', path:'/api/litellm-bridge'`,客户端用
|
|
23
|
+
* connection.rpc.call('/api', ...),实测报 `transport for ... HTTP 404`。
|
|
24
|
+
* 根因:`/api/**` 是 dsh-api-gateway 的 typert RPC 分发领地,我们注册
|
|
25
|
+
* 在它下面的路由永远匹配不到。对照真实插件:
|
|
26
|
+
* dsh-connect-trae → /plugins/dsh-connect-trae/usage
|
|
27
|
+
* dsh-qq-plugin → /.dsh-qq-plugin
|
|
28
|
+
* 都**刻意避开** /api 前缀。所以这里改用插件命名空间:
|
|
29
|
+
* /plugins/dsh-plugin-litellm-bridge
|
|
30
|
+
* 客户端直接 fetch 这个路径(带浏览器会话 cookie 鉴权)。
|
|
31
|
+
*
|
|
32
|
+
* 【密钥】master key 只在网关侧 .env,由启动脚本注入环境变量。
|
|
33
|
+
* 插件从不上传、不落盘、不回显;界面只显示「来自 .env 环境变量」。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
37
|
+
import { writeFile, readFile } from 'node:fs/promises';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
import { GatewaySupervisor, isGatewayAlive, findGatewayDir } from './gateway-supervisor.js';
|
|
40
|
+
import {
|
|
41
|
+
ConfigStore,
|
|
42
|
+
AGENT_TYPES,
|
|
43
|
+
DEFAULT_PROFILE_ID,
|
|
44
|
+
isValidProfileId,
|
|
45
|
+
} from './config-store.js';
|
|
46
|
+
import { assembleChainConfig } from './assemble-chain.js';
|
|
47
|
+
import { collectModelPool, chainModelIds } from './model-pool.js';
|
|
48
|
+
import { GatewayClient } from './gateway-client.js';
|
|
49
|
+
import { ProbeLog } from './probe-log.js';
|
|
50
|
+
import {
|
|
51
|
+
installSubagentRouting,
|
|
52
|
+
GATEWAY_PROVIDER,
|
|
53
|
+
NATIVE_PROVIDERS,
|
|
54
|
+
} from './subagent-routing.js';
|
|
55
|
+
|
|
56
|
+
export const name = 'litellm-bridge';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 【不再导出 provide】
|
|
60
|
+
*
|
|
61
|
+
* 历史:这里曾写 `export const provide = ['litellm-bridge', 'litellmBridge']`,
|
|
62
|
+
* 配合 apply 里的 `ctx.set('litellmBridge', {...})` 把服务暴露给 header-inject。
|
|
63
|
+
* 实测在 DSH 里整插件激活失败:
|
|
64
|
+
* cannot set property 'litellmBridge' without provide
|
|
65
|
+
* 即 cordis 判定 provide 里没有这个 key(数组形式不被认可为多个服务)。
|
|
66
|
+
*
|
|
67
|
+
* 现在两个插件改为**纯事件解耦**(litellm-bridge/session-profile-changed 等),
|
|
68
|
+
* 不再有跨插件服务依赖,所以既不导出 provide,也不调用 ctx.set。
|
|
69
|
+
* 这也和 dsh-connect-trae / dsh-qq-plugin / dsh-infinite-canvas 的做法一致。
|
|
70
|
+
*
|
|
71
|
+
* 保留 name 导出(Loader 用它做显示名与去重)。
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 注入宿主能力 —— **一个都不声明为必需**。
|
|
76
|
+
*
|
|
77
|
+
* 【为什么是空数组 · 这是「设置页空白」的真凶】
|
|
78
|
+
* cordis 的语义:**声明了却拿不到的服务会让整个插件 pending/failed**。
|
|
79
|
+
*
|
|
80
|
+
* 之前写成 `['webServer', 'connection', 'llm', 'sessionController']`,
|
|
81
|
+
* 只要其中任何一个在这个 profile 的组合里缺失,Host 半就完全不挂载:
|
|
82
|
+
* · UI 设置页只剩一个齿轮图标,点进去空白
|
|
83
|
+
* · 所有 RPC 不通(没有 apply 就没有路由注册)
|
|
84
|
+
*
|
|
85
|
+
* 对照能正常工作的 dsh-qq-plugin,它对这点写得很清楚:
|
|
86
|
+
* 「声明了拿不到的服务会让插件 pending/failed,那是最难查的一类问题。」
|
|
87
|
+
*
|
|
88
|
+
* 本插件全部能力都可以降级使用:
|
|
89
|
+
* - webServer 缺 → UI 读不到数据,但插件本身仍加载(日志里明说)
|
|
90
|
+
* - llm 缺 → 模型池为空,UI 提示去【设置-模型】配置
|
|
91
|
+
* - sessionController缺 → 模型池少一条兜底路径
|
|
92
|
+
* - connection 缺 → 根本没用到(RPC 走自建 HTTP 路由)
|
|
93
|
+
* 所以一个都不声明,全部用 ctx.get 探测 —— **宁可降级,绝不整体失败**。
|
|
94
|
+
*/
|
|
95
|
+
export const inject = [];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 需要探测的可选服务清单(给排障日志用,不参与 cordis 的依赖判定)。
|
|
99
|
+
*/
|
|
100
|
+
export const OPTIONAL_SERVICES = ['webServer', 'llm', 'sessionController', 'connection'];
|
|
101
|
+
|
|
102
|
+
/** Loader 条目 Config —— 必须是 Schemastery。 */
|
|
103
|
+
export const Config = Schema.object({
|
|
104
|
+
gatewayUrl: Schema.string().default('http://127.0.0.1:4000'),
|
|
105
|
+
timeoutMs: Schema.natural().min(1000).max(900000).default(30000),
|
|
106
|
+
logLimit: Schema.natural().min(1).max(500).default(50),
|
|
107
|
+
reloadEndpoint: Schema.string().default(''),
|
|
108
|
+
nativeProviders: Schema.array(Schema.string()).default(['trae', 'workbuddy']),
|
|
109
|
+
gatewayProvider: Schema.string().default('litellm-gateway'),
|
|
110
|
+
autoStartGateway: Schema.boolean().default(true),
|
|
111
|
+
gatewayStartTimeoutMs: Schema.natural().min(3000).max(300000).default(45000),
|
|
112
|
+
gatewayDir: Schema.string().default(''),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** 会话 profile 变量的键名。header 注入插件读的就是它。 */
|
|
116
|
+
export const SESSION_PROFILE_KEY = 'litellm-bridge/session-profile';
|
|
117
|
+
|
|
118
|
+
/** 插件 HTTP 路由前缀(**刻意避开 /api**,见文件头注释)。 */
|
|
119
|
+
export const ROUTE_BASE = '/plugins/dsh-plugin-litellm-bridge';
|
|
120
|
+
|
|
121
|
+
export function apply(ctx, config = {}) {
|
|
122
|
+
const store = new ConfigStore();
|
|
123
|
+
const probeLog = new ProbeLog(config.logLimit || 50);
|
|
124
|
+
|
|
125
|
+
// -------------------------------------------------------------------------
|
|
126
|
+
// 启动诊断:把「哪些可选服务真的可用」打进日志。
|
|
127
|
+
// 出问题时这一行能立刻区分「插件没挂载」与「挂载了但缺服务」。
|
|
128
|
+
// -------------------------------------------------------------------------
|
|
129
|
+
try {
|
|
130
|
+
const probe = OPTIONAL_SERVICES.map((n) => {
|
|
131
|
+
let ok = false;
|
|
132
|
+
try { ok = typeof ctx.get === 'function' ? ctx.get(n) !== undefined : ctx[n] !== undefined; } catch { ok = false; }
|
|
133
|
+
return `${n}:${ok ? 'OK' : 'missing'}`;
|
|
134
|
+
}).join(' ');
|
|
135
|
+
ctx.logger?.info?.(`[litellm-bridge] apply 进入。服务探测 → ${probe}`);
|
|
136
|
+
} catch { /* 诊断失败不影响加载 */ }
|
|
137
|
+
|
|
138
|
+
/** UI 配置的超时(每次 RPC 前刷新,供探针使用)。 */
|
|
139
|
+
let uiTimeoutMs = Number(config.timeoutMs) || 30000;
|
|
140
|
+
|
|
141
|
+
// -------------------------------------------------------------------------
|
|
142
|
+
// 网关生命周期托管(需求7)
|
|
143
|
+
//
|
|
144
|
+
// 网关跟着 DSH 生灭:
|
|
145
|
+
// - DSH 启动 → 没起就拉起来;已在跑(用户手动 / 别的实例)→ 只识别不接管
|
|
146
|
+
// - DSH 退出 → 只关自己拉起的那个
|
|
147
|
+
// - 进程异常退出 → 写进日志面板(下面 onLog 回调)
|
|
148
|
+
// -------------------------------------------------------------------------
|
|
149
|
+
const supervisor = new GatewaySupervisor({
|
|
150
|
+
gatewayUrl: config.gatewayUrl,
|
|
151
|
+
envFile: process.env.LITELLM_BRIDGE_ENV || '',
|
|
152
|
+
gatewayDir: config.gatewayDir || '',
|
|
153
|
+
logger: ctx.logger,
|
|
154
|
+
startTimeoutMs: Number(config.gatewayStartTimeoutMs) || 45000,
|
|
155
|
+
// 【需求7】网关生命周期事件 → 日志面板
|
|
156
|
+
onLog: ({ level, message, at }) => {
|
|
157
|
+
probeLog.addGatewayEvent({ level, message, at });
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/** master key 只从环境读,绝不落盘。 */
|
|
162
|
+
const gateway = new GatewayClient({
|
|
163
|
+
baseUrl: config.gatewayUrl || 'http://127.0.0.1:4000',
|
|
164
|
+
apiKey: process.env.LITELLM_MASTER_KEY || '',
|
|
165
|
+
timeoutMs: uiTimeoutMs,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
let ready = null;
|
|
169
|
+
function ensureReady() {
|
|
170
|
+
if (!ready) {
|
|
171
|
+
ready = (async () => {
|
|
172
|
+
await store.load();
|
|
173
|
+
const s = store.get();
|
|
174
|
+
uiTimeoutMs = s.gateway.timeoutMs || uiTimeoutMs;
|
|
175
|
+
gateway.update({
|
|
176
|
+
baseUrl: config.gatewayUrl || s.gateway.url,
|
|
177
|
+
apiKey: process.env.LITELLM_MASTER_KEY || '',
|
|
178
|
+
timeoutMs: uiTimeoutMs,
|
|
179
|
+
});
|
|
180
|
+
return s;
|
|
181
|
+
})();
|
|
182
|
+
}
|
|
183
|
+
return ready;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// -------------------------------------------------------------------------
|
|
187
|
+
// 会话 profile 变量
|
|
188
|
+
// -------------------------------------------------------------------------
|
|
189
|
+
const memoryProfiles = new Map();
|
|
190
|
+
const sessionProviders = new Map();
|
|
191
|
+
|
|
192
|
+
function getSessionProfile(_ctx, sessionId) {
|
|
193
|
+
if (sessionId && memoryProfiles.has(sessionId)) return memoryProfiles.get(sessionId);
|
|
194
|
+
return '';
|
|
195
|
+
}
|
|
196
|
+
function setSessionProfile(_ctx, sessionId, value) {
|
|
197
|
+
if (!sessionId) return false;
|
|
198
|
+
if (value) memoryProfiles.set(sessionId, value);
|
|
199
|
+
else memoryProfiles.delete(sessionId);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
function getSessionProvider(sessionId) {
|
|
203
|
+
return sessionId ? sessionProviders.get(sessionId) : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 观察真实出站请求,记录每个会话在用的供应商(只读不改)
|
|
207
|
+
ctx.on?.('llm/stream', function observeProvider(options, proceed) {
|
|
208
|
+
try {
|
|
209
|
+
if (options?.sessionId && options?.provider) {
|
|
210
|
+
const sid = String(options.sessionId);
|
|
211
|
+
const provider = String(options.provider);
|
|
212
|
+
const prev = sessionProviders.get(sid);
|
|
213
|
+
sessionProviders.set(sid, provider);
|
|
214
|
+
// 【解耦广播】header-inject 靠这个事件做本地镜像,
|
|
215
|
+
// 从而不需要跨插件服务就能判断「本会话是否走网关」。
|
|
216
|
+
if (prev !== provider) {
|
|
217
|
+
ctx.emit?.('litellm-bridge/session-provider-changed', {
|
|
218
|
+
sessionId: sid,
|
|
219
|
+
provider,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
/* 记录失败绝不影响请求 */
|
|
225
|
+
}
|
|
226
|
+
return proceed();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// -------------------------------------------------------------------------
|
|
230
|
+
// 子代理路由覆盖(Trae/WorkBuddy 会话的子代理强制改道网关)
|
|
231
|
+
// -------------------------------------------------------------------------
|
|
232
|
+
const routing = installSubagentRouting(ctx, {
|
|
233
|
+
nativeProviders: config.nativeProviders,
|
|
234
|
+
gatewayProvider: config.gatewayProvider,
|
|
235
|
+
providerFor: (parentSessionId) => getSessionProvider(parentSessionId),
|
|
236
|
+
profileFor: (parentSessionId) => getSessionProfile(ctx, parentSessionId),
|
|
237
|
+
agentTypeFor: (spec) => {
|
|
238
|
+
const label = String(spec?.request?.agentOptions?.role || spec?.label || '').toLowerCase();
|
|
239
|
+
if (label.includes('research') || label.includes('scout')) return 'research';
|
|
240
|
+
return 'coder';
|
|
241
|
+
},
|
|
242
|
+
/**
|
|
243
|
+
* 【需求8】子 Agent 走哪条链 = 该会话 profile 下 coder / research 链的头。
|
|
244
|
+
* 链的顺序与超时可在 UI 单独配置,与 root 链完全独立。
|
|
245
|
+
*
|
|
246
|
+
* 返回链头名(<at>-<pid>-primary),由网关按 fallbacks 执行后续降级。
|
|
247
|
+
*/
|
|
248
|
+
modelFor: (agentType, parentSessionId) => {
|
|
249
|
+
try {
|
|
250
|
+
const profileId = parentSessionId ? getSessionProfile(ctx, parentSessionId) : '';
|
|
251
|
+
const routes = store.get().routes;
|
|
252
|
+
const pid = routes[profileId] ? profileId : Object.keys(routes)[0];
|
|
253
|
+
const cfg = routes?.[pid]?.agents?.[agentType];
|
|
254
|
+
if (cfg?.primary) return `${agentType}-${pid}-primary`;
|
|
255
|
+
} catch {
|
|
256
|
+
/* 读配置失败退回默认链头 */
|
|
257
|
+
}
|
|
258
|
+
return `${agentType}-${DEFAULT_PROFILE_ID}-primary`;
|
|
259
|
+
},
|
|
260
|
+
debug: (m) => ctx.logger?.debug?.(m),
|
|
261
|
+
});
|
|
262
|
+
ctx.effect?.(() => () => routing.dispose());
|
|
263
|
+
|
|
264
|
+
// -------------------------------------------------------------------------
|
|
265
|
+
// 组装 + 下发降级链(需求3)
|
|
266
|
+
// -------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/** 生成 config.yaml 文本(需带模型池:要拿到供应商 baseURL / 凭证 env)。 */
|
|
269
|
+
async function buildYaml() {
|
|
270
|
+
const pool = await collectModelPool(ctx);
|
|
271
|
+
return assembleChainConfig(store.get(), pool);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 把 config.yaml 落盘到网关目录,然后重启网关使其生效。
|
|
276
|
+
*
|
|
277
|
+
* 为什么不用热重载:本机 LiteLLM 1.108.3 的 /config/reload 实测 404,
|
|
278
|
+
* /config/update 需要 PostgreSQL(SQLite 硬失败)。而 start-gateway.sh
|
|
279
|
+
* 已有「config.yaml 存在则跳过生成」逻辑 → 写文件 + 重启即可生效。
|
|
280
|
+
*/
|
|
281
|
+
async function applyChains({ restart = true } = {}) {
|
|
282
|
+
const { yaml, warnings, upstreams, chains } = await buildYaml();
|
|
283
|
+
|
|
284
|
+
// 有警告就先不落盘:生成一条「看起来正常、实际调不通」的配置,
|
|
285
|
+
// 比直接报错更难排查(网关会启动成功,但每次请求都失败)。
|
|
286
|
+
if (!upstreams.length) {
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
yaml,
|
|
290
|
+
warnings,
|
|
291
|
+
written: false,
|
|
292
|
+
restarted: false,
|
|
293
|
+
error: warnings[0] || '没有任何可用模型:请先在【设置-模型】里配置供应商,并在「路由配置」里选好模型。',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const dir = findGatewayDir(config.gatewayDir || '');
|
|
298
|
+
if (!dir) {
|
|
299
|
+
return {
|
|
300
|
+
ok: false, yaml, warnings, upstreams, written: false, restarted: false,
|
|
301
|
+
error: '找不到 litellm-gateway 目录,无法下发配置(可在插件 Config 里设置 gatewayDir)',
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const target = join(dir, 'config.yaml');
|
|
305
|
+
try {
|
|
306
|
+
// 纯 ASCII + 无 BOM:LiteLLM 用平台默认编码读 YAML(中文 Windows 是 GBK)
|
|
307
|
+
await writeFile(target, yaml, { encoding: 'utf8' });
|
|
308
|
+
} catch (err) {
|
|
309
|
+
return {
|
|
310
|
+
ok: false, yaml, warnings, written: false, restarted: false,
|
|
311
|
+
error: `写入 config.yaml 失败: ${err?.message || err}`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!restart) {
|
|
316
|
+
return {
|
|
317
|
+
ok: true, yaml, warnings, upstreams, chains,
|
|
318
|
+
written: true, restarted: false, configPath: target,
|
|
319
|
+
message: '配置已写入,未重启网关',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const r = await supervisor.restart();
|
|
323
|
+
return {
|
|
324
|
+
ok: Boolean(r.running),
|
|
325
|
+
yaml, warnings, upstreams, chains,
|
|
326
|
+
written: true,
|
|
327
|
+
restarted: Boolean(r.running),
|
|
328
|
+
configPath: target,
|
|
329
|
+
message: r.running
|
|
330
|
+
? `降级链已下发(${chains.length} 条链 / ${upstreams.length} 个模型),网关已按新配置重启`
|
|
331
|
+
: `配置已写入,但网关未能启动: ${r.error || '未知原因'}`,
|
|
332
|
+
gateway: supervisor.status(),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** 统一 RPC 处理:永远返回结构化结果,绝不让异常穿透到 UI。 */
|
|
337
|
+
async function handle(action, args = {}) {
|
|
338
|
+
await ensureReady();
|
|
339
|
+
|
|
340
|
+
switch (action) {
|
|
341
|
+
// ---- 网关设置 ----
|
|
342
|
+
case 'gateway/get':
|
|
343
|
+
return { ok: true, data: store.get() };
|
|
344
|
+
|
|
345
|
+
case 'gateway/save': {
|
|
346
|
+
const next = await store.updateGateway(args);
|
|
347
|
+
uiTimeoutMs = next.gateway.timeoutMs;
|
|
348
|
+
gateway.update({
|
|
349
|
+
baseUrl: next.gateway.url,
|
|
350
|
+
timeoutMs: next.gateway.timeoutMs,
|
|
351
|
+
apiKey: process.env.LITELLM_MASTER_KEY || '',
|
|
352
|
+
});
|
|
353
|
+
return { ok: true, data: next };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
case 'gateway/test':
|
|
357
|
+
if (typeof args?.url === 'string' && args.url.trim()) {
|
|
358
|
+
gateway.update({ baseUrl: args.url.trim() });
|
|
359
|
+
}
|
|
360
|
+
{
|
|
361
|
+
const result = await gateway.testConnection();
|
|
362
|
+
await store.markProbe(result.ok);
|
|
363
|
+
return { ok: true, data: result };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
case 'gateway/keyStatus':
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
data: {
|
|
370
|
+
hasKey: gateway.hasKey,
|
|
371
|
+
source: '来自 .env 环境变量',
|
|
372
|
+
envVar: 'LITELLM_MASTER_KEY',
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// ---- 网关生命周期(需求7)----
|
|
377
|
+
case 'gateway/lifecycle': {
|
|
378
|
+
const alive = await isGatewayAlive(store.get().gateway.url);
|
|
379
|
+
const st = supervisor.status();
|
|
380
|
+
// 状态口径:以「进程托管状态 + 端口探活」共同决定,避免只看其一
|
|
381
|
+
const state = st.state === 'error' ? 'error' : alive ? 'running' : st.starting ? 'starting' : 'stopped';
|
|
382
|
+
return {
|
|
383
|
+
ok: true,
|
|
384
|
+
data: {
|
|
385
|
+
...st,
|
|
386
|
+
alive,
|
|
387
|
+
state,
|
|
388
|
+
stateLabel: state === 'running' ? '运行中' : state === 'starting' ? '启动中' : state === 'error' ? '异常' : '已停止',
|
|
389
|
+
autoStart: config.autoStartGateway !== false,
|
|
390
|
+
ownership: st.owned
|
|
391
|
+
? '由本插件启动,退出 DSH 时自动关闭'
|
|
392
|
+
: alive
|
|
393
|
+
? '外部已在运行,本插件不接管(退出时不会关它)'
|
|
394
|
+
: '未运行',
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
case 'gateway/start': {
|
|
400
|
+
const r = await supervisor.ensureRunning();
|
|
401
|
+
return { ok: Boolean(r.running), data: { ...supervisor.status(), ...r } };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
case 'gateway/stop': {
|
|
405
|
+
const r = await supervisor.stop();
|
|
406
|
+
return { ok: true, data: { ...supervisor.status(), ...r } };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---- 【需求2】DSH 模型池 ----
|
|
410
|
+
case 'models/pool': {
|
|
411
|
+
const pool = await collectModelPool(ctx);
|
|
412
|
+
return {
|
|
413
|
+
ok: true,
|
|
414
|
+
data: {
|
|
415
|
+
...pool,
|
|
416
|
+
// 明确告诉 UI 数据来源,便于排障时一眼看出用的是哪个入口
|
|
417
|
+
hint: pool.models.length
|
|
418
|
+
? `来自 DSH 设置-模型(${pool.models.length} 个模型 / ${pool.providers.length} 个供应商)`
|
|
419
|
+
: '未读取到 DSH 模型池,请先在【设置-模型】里配置供应商并获取可用模型',
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---- 【需求3】路由配置 ----
|
|
425
|
+
case 'routes/get':
|
|
426
|
+
return { ok: true, data: store.get().routes, profileIds: store.get().profileIds };
|
|
427
|
+
|
|
428
|
+
case 'routes/createProfile': {
|
|
429
|
+
const s = await store.createProfile(args.id, args.label);
|
|
430
|
+
return { ok: true, data: s.routes, profileIds: s.profileIds };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
case 'routes/renameProfile': {
|
|
434
|
+
const s = await store.renameProfile(args.id, args.label);
|
|
435
|
+
return { ok: true, data: s.routes, profileIds: s.profileIds };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
case 'routes/deleteProfile': {
|
|
439
|
+
const s = await store.deleteProfile(args.id);
|
|
440
|
+
return { ok: true, data: s.routes, profileIds: s.profileIds };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
case 'routes/saveRoute': {
|
|
444
|
+
const s = await store.updateRoute(args.profileId, args.agentType, args.patch || {});
|
|
445
|
+
return { ok: true, data: s.routes, profileIds: s.profileIds };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
case 'routes/replaceProfile': {
|
|
449
|
+
const s = await store.replaceProfile(args.profileId, args.agents || {});
|
|
450
|
+
return { ok: true, data: s.routes, profileIds: s.profileIds };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
case 'routes/reset': {
|
|
454
|
+
const s = await store.resetRoutes();
|
|
455
|
+
return { ok: true, data: s.routes, profileIds: s.profileIds };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
case 'routes/preview': {
|
|
459
|
+
const r = await buildYaml();
|
|
460
|
+
return {
|
|
461
|
+
ok: true,
|
|
462
|
+
data: {
|
|
463
|
+
yaml: r.yaml,
|
|
464
|
+
warnings: r.warnings,
|
|
465
|
+
chains: r.chains,
|
|
466
|
+
upstreams: r.upstreams.map(({ name, providerId, baseUrl, prefix }) => ({
|
|
467
|
+
name, providerId, baseUrl, prefix,
|
|
468
|
+
})),
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---- 【需求3】保存并下发降级链给网关 ----
|
|
474
|
+
case 'routes/apply': {
|
|
475
|
+
// 允许「边保存边下发」:UI 可先传 agents 再 apply
|
|
476
|
+
if (args?.profileId && args?.agents) {
|
|
477
|
+
await store.replaceProfile(args.profileId, args.agents);
|
|
478
|
+
}
|
|
479
|
+
const r = await applyChains({ restart: args?.restart !== false });
|
|
480
|
+
return { ok: r.ok, data: r, error: r.ok ? undefined : r.error };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ---- 【需求4】一键探针:测当前 profile 选中的那组模型 ----
|
|
484
|
+
case 'probe/profile': {
|
|
485
|
+
const routes = store.get().routes;
|
|
486
|
+
const pid = routes[args.profileId] ? args.profileId : Object.keys(routes)[0];
|
|
487
|
+
const agentType = AGENT_TYPES.includes(args.agentType) ? args.agentType : 'root';
|
|
488
|
+
const models = chainModelIds(routes, pid, agentType);
|
|
489
|
+
if (!models.length) {
|
|
490
|
+
return {
|
|
491
|
+
ok: false,
|
|
492
|
+
error: `profile ${pid} 的 ${agentType} 链还没有选模型。请先在「路由配置」里从 DSH 模型池选好模型。`,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
const batchId = `probe-${Date.now()}`;
|
|
496
|
+
const res = await gateway.probeBatch({
|
|
497
|
+
models,
|
|
498
|
+
agentType,
|
|
499
|
+
profile: pid,
|
|
500
|
+
concurrency: 2,
|
|
501
|
+
timeoutMs: Math.min(120000, Math.max(15000, Number(args.timeoutMs) || 60000)),
|
|
502
|
+
onResult: (entry) => {
|
|
503
|
+
if (!entry.ok) {
|
|
504
|
+
probeLog.add({
|
|
505
|
+
model: entry.model,
|
|
506
|
+
ok: false,
|
|
507
|
+
latencyMs: entry.latencyMs,
|
|
508
|
+
error: entry.error,
|
|
509
|
+
agentType,
|
|
510
|
+
profile: pid,
|
|
511
|
+
batchId,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
517
|
+
return {
|
|
518
|
+
ok: true,
|
|
519
|
+
data: {
|
|
520
|
+
...res.data,
|
|
521
|
+
batchId,
|
|
522
|
+
profile: pid,
|
|
523
|
+
agentType,
|
|
524
|
+
chain: models,
|
|
525
|
+
// 按链路顺序展示,让用户看到「第 1 顺位 / 第 2 顺位」
|
|
526
|
+
ordered: res.data.results.map((r) => ({ ...r, position: models.indexOf(r.model) + 1 })),
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// 单模型探针(保留:会话 Tab 的「一键探针」用链头)
|
|
532
|
+
case 'probe/run': {
|
|
533
|
+
const routes = store.get().routes;
|
|
534
|
+
const pid = routes[args.profileId] ? args.profileId : Object.keys(routes)[0];
|
|
535
|
+
const agentType = AGENT_TYPES.includes(args.agentType) ? args.agentType : 'root';
|
|
536
|
+
const chain = chainModelIds(routes, pid, agentType);
|
|
537
|
+
const model = args.model || chain[0];
|
|
538
|
+
if (!model) {
|
|
539
|
+
return { ok: false, error: '当前 profile 该链未选模型,无法探针' };
|
|
540
|
+
}
|
|
541
|
+
const res = await gateway.probe({ model, agentType, profile: pid });
|
|
542
|
+
return res.ok
|
|
543
|
+
? { ok: true, data: res.data, latencyMs: res.latencyMs }
|
|
544
|
+
: { ok: false, error: res.error, latencyMs: res.latencyMs, emptyReply: res.emptyReply };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ---- 日志面板(需求5 + 需求7 网关状态)----
|
|
548
|
+
case 'logs/fetch': {
|
|
549
|
+
const limit = args.limit || store.get().gateway.logLimit || 50;
|
|
550
|
+
const res = await gateway.fetchLogs({ limit });
|
|
551
|
+
const local = probeLog.list(limit);
|
|
552
|
+
const gw = supervisor.status();
|
|
553
|
+
const alive = await isGatewayAlive(store.get().gateway.url);
|
|
554
|
+
if (!res.ok) {
|
|
555
|
+
return {
|
|
556
|
+
ok: true,
|
|
557
|
+
data: local,
|
|
558
|
+
gatewayError: res.error,
|
|
559
|
+
needsDatabase: Boolean(res.needsDatabase),
|
|
560
|
+
localOnly: true,
|
|
561
|
+
gateway: {
|
|
562
|
+
state: gw.state === 'error' ? 'error' : alive ? 'running' : 'stopped',
|
|
563
|
+
alive,
|
|
564
|
+
owned: gw.owned,
|
|
565
|
+
lastError: gw.lastError,
|
|
566
|
+
lastExit: gw.lastExit,
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
const merged = [...(res.data || []), ...local]
|
|
571
|
+
.sort((a, b) => (Date.parse(b.time || 0) || 0) - (Date.parse(a.time || 0) || 0))
|
|
572
|
+
.slice(0, limit);
|
|
573
|
+
return {
|
|
574
|
+
ok: true,
|
|
575
|
+
data: merged,
|
|
576
|
+
gateway: {
|
|
577
|
+
state: gw.state === 'error' ? 'error' : alive ? 'running' : 'stopped',
|
|
578
|
+
alive,
|
|
579
|
+
owned: gw.owned,
|
|
580
|
+
lastError: gw.lastError,
|
|
581
|
+
lastExit: gw.lastExit,
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
case 'logs/clearLocal':
|
|
587
|
+
probeLog.clearAll();
|
|
588
|
+
return { ok: true, data: { cleared: true } };
|
|
589
|
+
|
|
590
|
+
// ---- 会话 profile ----
|
|
591
|
+
case 'session/getProfile':
|
|
592
|
+
return { ok: true, data: getSessionProfile(ctx, args.sessionId) };
|
|
593
|
+
|
|
594
|
+
case 'session/setProfile': {
|
|
595
|
+
const value = String(args.profile || '').trim();
|
|
596
|
+
// 绑定的 profile 必须真实存在,否则会话会指向一条不存在的链
|
|
597
|
+
if (value && !store.get().routes[value]) {
|
|
598
|
+
return { ok: false, error: `profile 不存在:${value}` };
|
|
599
|
+
}
|
|
600
|
+
const written = setSessionProfile(ctx, args.sessionId, value);
|
|
601
|
+
ctx.emit?.('litellm-bridge/session-profile-changed', {
|
|
602
|
+
sessionId: args.sessionId,
|
|
603
|
+
profile: value,
|
|
604
|
+
});
|
|
605
|
+
return { ok: true, data: { profile: value, persisted: written } };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ---- 子代理路由覆盖状态 ----
|
|
609
|
+
case 'routing/status':
|
|
610
|
+
return {
|
|
611
|
+
ok: true,
|
|
612
|
+
data: {
|
|
613
|
+
nativeProviders: routing.nativeProviders || NATIVE_PROVIDERS,
|
|
614
|
+
gatewayProvider: routing.gatewayProvider || GATEWAY_PROVIDER,
|
|
615
|
+
stats: routing.stats(),
|
|
616
|
+
sessionProvider: args.sessionId ? getSessionProvider(String(args.sessionId)) || null : null,
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
default:
|
|
621
|
+
return { ok: false, error: `未知操作: ${action}` };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// -------------------------------------------------------------------------
|
|
626
|
+
// HTTP 路由(客户端 RPC 入口)
|
|
627
|
+
//
|
|
628
|
+
// 前缀刻意不是 /api(那是 dsh-api-gateway 的领地,实测 404)。
|
|
629
|
+
// -------------------------------------------------------------------------
|
|
630
|
+
const webServer = (() => {
|
|
631
|
+
try {
|
|
632
|
+
return typeof ctx.get === 'function' ? ctx.get('webServer') : ctx.webServer;
|
|
633
|
+
} catch {
|
|
634
|
+
return undefined;
|
|
635
|
+
}
|
|
636
|
+
})();
|
|
637
|
+
|
|
638
|
+
if (webServer && typeof webServer.register === 'function') {
|
|
639
|
+
ctx.effect?.(() =>
|
|
640
|
+
webServer.register({
|
|
641
|
+
kind: 'prefix',
|
|
642
|
+
path: ROUTE_BASE,
|
|
643
|
+
handler: async (req, res) => {
|
|
644
|
+
const send = (status, payload) => {
|
|
645
|
+
try {
|
|
646
|
+
const body = JSON.stringify(payload);
|
|
647
|
+
res.writeHead(status, {
|
|
648
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
649
|
+
'Cache-Control': 'no-store',
|
|
650
|
+
});
|
|
651
|
+
res.end(body);
|
|
652
|
+
} catch (err) {
|
|
653
|
+
try { res.destroy?.(err); } catch { /* ignore */ }
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
if (req.method !== 'POST') {
|
|
659
|
+
res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json' });
|
|
660
|
+
res.end(JSON.stringify({ ok: false, error: '只接受 POST' }));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const urlPath = String(req.url || '').split('?')[0];
|
|
665
|
+
const tail = urlPath.startsWith(ROUTE_BASE)
|
|
666
|
+
? urlPath.slice(ROUTE_BASE.length).replace(/^\/+/, '')
|
|
667
|
+
: '';
|
|
668
|
+
|
|
669
|
+
const chunks = [];
|
|
670
|
+
let size = 0;
|
|
671
|
+
for await (const chunk of req) {
|
|
672
|
+
size += chunk.length;
|
|
673
|
+
if (size > 1024 * 1024) {
|
|
674
|
+
send(413, { ok: false, error: '请求体过大' });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
chunks.push(chunk);
|
|
678
|
+
}
|
|
679
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
680
|
+
let payload = {};
|
|
681
|
+
try { payload = raw ? JSON.parse(raw) : {}; } catch { payload = {}; }
|
|
682
|
+
|
|
683
|
+
let action = tail;
|
|
684
|
+
if (!action && payload?.action) action = String(payload.action);
|
|
685
|
+
const args = payload?.args ?? payload ?? {};
|
|
686
|
+
send(200, await handle(action, args));
|
|
687
|
+
} catch (err) {
|
|
688
|
+
send(200, { ok: false, error: String(err?.message || err) });
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
}),
|
|
692
|
+
);
|
|
693
|
+
ctx.logger?.info?.(`[litellm-bridge] 已注册 RPC 路由 ${ROUTE_BASE}`);
|
|
694
|
+
} else {
|
|
695
|
+
ctx.logger?.warn?.('[litellm-bridge] webServer 不可用,UI 将无法访问宿主能力');
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// -------------------------------------------------------------------------
|
|
699
|
+
// 与 header-inject 解耦:**只用事件,不用跨插件服务**
|
|
700
|
+
//
|
|
701
|
+
// 【为什么改掉了 ctx.set('litellmBridge')】
|
|
702
|
+
// cordis 对 ctx.set 有强校验:目标 key 必须在插件导出的 `provide` 里,
|
|
703
|
+
// 否则整个插件在激活阶段直接失败,报
|
|
704
|
+
// cannot set property 'litellmBridge' without provide
|
|
705
|
+
// 实测(本机 DSH):`provide = ['litellm-bridge', 'litellmBridge']` 仍被拒,
|
|
706
|
+
// 而对照 dsh-connect-trae / dsh-qq-plugin / dsh-infinite-canvas 等能正常
|
|
707
|
+
// 工作的插件,**没有一个用 provide + ctx.set 对外暴露服务**。
|
|
708
|
+
//
|
|
709
|
+
// 而 header-inject 侧本来就写好了事件镜像兜底(它 on 这个事件做本地缓存),
|
|
710
|
+
// 所以去掉服务暴露不会降低功能,反而少了一个跨插件耦合点。
|
|
711
|
+
//
|
|
712
|
+
// 事件名保持 `litellm-bridge/session-profile-changed` 不变 ——
|
|
713
|
+
// header-inject 已经在监听它,改了会静默失效。
|
|
714
|
+
// -------------------------------------------------------------------------
|
|
715
|
+
/** 当前状态快照(header-inject 需要时通过事件拿到,也可自行缓存)。 */
|
|
716
|
+
function statusSnapshot() {
|
|
717
|
+
return {
|
|
718
|
+
gatewayProvider: config.gatewayProvider || GATEWAY_PROVIDER,
|
|
719
|
+
nativeProviders: config.nativeProviders || NATIVE_PROVIDERS,
|
|
720
|
+
gateway: supervisor.status(),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// 启动时广播一次完整状态,让 header-inject 无需轮询即可缓存
|
|
725
|
+
void ensureReady().then(() => {
|
|
726
|
+
try {
|
|
727
|
+
ctx.emit?.('litellm-bridge/ready', statusSnapshot());
|
|
728
|
+
} catch { /* 广播失败不影响插件本身 */ }
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// -------------------------------------------------------------------------
|
|
732
|
+
// 生命周期:不阻塞激活,后台拉起网关
|
|
733
|
+
// -------------------------------------------------------------------------
|
|
734
|
+
void ensureReady().then(() => {
|
|
735
|
+
if (config.autoStartGateway === false) {
|
|
736
|
+
ctx.logger?.info?.('[litellm-bridge] autoStartGateway=false,只连接不托管网关');
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
void supervisor.ensureRunning().then((r) => {
|
|
740
|
+
if (r.running) {
|
|
741
|
+
ctx.logger?.info?.(
|
|
742
|
+
`[litellm-bridge] 网关就绪 ${supervisor.gatewayUrl}(${r.started ? '由本插件启动,退出时自动关闭' : '外部已在运行,不接管'})`,
|
|
743
|
+
);
|
|
744
|
+
} else {
|
|
745
|
+
ctx.logger?.warn?.(`[litellm-bridge] 网关未就绪: ${r.error || '未知原因'}`);
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// 插件卸载(DSH 退出 / 禁用)时停掉自己启动的网关
|
|
751
|
+
ctx.effect?.(() => async () => {
|
|
752
|
+
try { await supervisor.stop(); } catch { /* 清理失败不影响 DSH 退出 */ }
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
void ensureReady().then(() => {
|
|
756
|
+
ctx.logger?.info?.(
|
|
757
|
+
`[litellm-bridge] 就绪。网关=${gateway.baseUrl} key=${gateway.hasKey ? '来自 .env 环境变量' : '未配置'}`,
|
|
758
|
+
);
|
|
759
|
+
});
|
|
760
|
+
}
|