dsh-plugin-litellm-bridge 2.1.0 → 2.1.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/dsh-plugin-litellm-bridge/client/index.js +26 -158
- package/dsh-plugin-litellm-bridge/lib/assemble-chain.js +22 -0
- package/dsh-plugin-litellm-bridge/lib/index.js +40 -13
- package/dsh-plugin-litellm-bridge/lib/model-pool.js +45 -0
- package/dsh-plugin-litellm-bridge/lib/patch-providers.js +184 -0
- package/package.json +1 -1
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
* 保存 → 组装 yaml → 调网关热重载
|
|
15
15
|
* 3. 日志面板 拉网关调用日志,按 agent-type / profile 筛选;
|
|
16
16
|
* 橙色高亮触发 fallback,红色标记全链路失败;默认留最近 50 条
|
|
17
|
-
* 4. 当前会话 会话绑定 profile 下拉(持久化到会话变量);
|
|
18
17
|
* 本会话最近降级记录;一键探针测试
|
|
19
18
|
*/
|
|
20
19
|
|
|
@@ -793,7 +792,7 @@ window.__ModuleLoader__.load({
|
|
|
793
792
|
),
|
|
794
793
|
h('div', { style: { ...S.hint, marginTop: -6, marginBottom: 14 } },
|
|
795
794
|
'一个 profile = 一套降级方案。可以配多套(例如「稳定优先」「省钱优先」),' +
|
|
796
|
-
'
|
|
795
|
+
'再到会话标题旁的按钮里选用哪一套。'),
|
|
797
796
|
|
|
798
797
|
showNew && h('div', { style: { ...S.row, marginBottom: 14 } },
|
|
799
798
|
h('input', {
|
|
@@ -1169,148 +1168,6 @@ window.__ModuleLoader__.load({
|
|
|
1169
1168
|
}
|
|
1170
1169
|
|
|
1171
1170
|
// -----------------------------------------------------------------------
|
|
1172
|
-
// Tab 4:当前会话
|
|
1173
|
-
// -----------------------------------------------------------------------
|
|
1174
|
-
function SessionTab({ api, ctx, state, setState }) {
|
|
1175
|
-
const profileIds = state?.profileIds || Object.keys(state?.routes || {});
|
|
1176
|
-
const [profile, setProfile] = useState('');
|
|
1177
|
-
const [saved, setSaved] = useState(null);
|
|
1178
|
-
const [busy, setBusy] = useState(false);
|
|
1179
|
-
const [probe, setProbe] = useState(null);
|
|
1180
|
-
const [drops, setDrops] = useState([]);
|
|
1181
|
-
|
|
1182
|
-
// 当前会话 id:尽量从宿主会话上下文取;取不到就用 URL 里的会话标记
|
|
1183
|
-
// 当前会话 id:尽量从宿主会话上下文取;取不到就用 URL 里的会话标记。
|
|
1184
|
-
// 【注意】依赖数组里不能放 ctx / api —— 它们是每次渲染新建的对象,
|
|
1185
|
-
// 放进去会触发无限重渲染(表现为整页空白)。
|
|
1186
|
-
const sessionId = useMemo(() => {
|
|
1187
|
-
try {
|
|
1188
|
-
const fromCtx = ctx?.session?.id || ctx?.sessionId;
|
|
1189
|
-
if (fromCtx) return String(fromCtx);
|
|
1190
|
-
const loc = globalThis.location;
|
|
1191
|
-
const hay = String(loc?.hash || '') + String(loc?.search || '');
|
|
1192
|
-
const m = hay.match(/session[=/]([A-Za-z0-9_-]+)/i);
|
|
1193
|
-
return m ? m[1] : 'current';
|
|
1194
|
-
} catch {
|
|
1195
|
-
return 'current';
|
|
1196
|
-
}
|
|
1197
|
-
}, []);
|
|
1198
|
-
|
|
1199
|
-
useEffect(() => {
|
|
1200
|
-
api('session/getProfile', { sessionId }).then((r) => {
|
|
1201
|
-
if (r.ok) setProfile(r.data || '');
|
|
1202
|
-
});
|
|
1203
|
-
}, [api, sessionId]);
|
|
1204
|
-
|
|
1205
|
-
// 本会话最近降级记录:从日志里按 profile 过滤出 fallback 行
|
|
1206
|
-
const loadDrops = useCallback(async () => {
|
|
1207
|
-
const r = await api('logs/fetch', { limit: 50 });
|
|
1208
|
-
if (r.ok) setDrops((r.data || []).filter((l) => l.fallback === 'yes' || l.status === 'failure').slice(0, 10));
|
|
1209
|
-
}, [api]);
|
|
1210
|
-
useEffect(() => { loadDrops(); }, [loadDrops]);
|
|
1211
|
-
|
|
1212
|
-
// 子代理路由覆盖状态
|
|
1213
|
-
const [rt, setRt] = useState(null);
|
|
1214
|
-
useEffect(() => {
|
|
1215
|
-
api('routing/status', { sessionId }).then((r) => { if (r.ok) setRt(r.data); });
|
|
1216
|
-
}, [api, sessionId]);
|
|
1217
|
-
|
|
1218
|
-
const save = async () => {
|
|
1219
|
-
setBusy(true);
|
|
1220
|
-
const r = await api('session/setProfile', { sessionId, profile });
|
|
1221
|
-
setSaved(r.ok ? { kind: 'ok', text: `已绑定 ${profile || '(已清空)'},存入会话变量` } : { kind: 'err', text: r.error });
|
|
1222
|
-
setBusy(false);
|
|
1223
|
-
};
|
|
1224
|
-
|
|
1225
|
-
const runProbe = async () => {
|
|
1226
|
-
setBusy(true); setProbe(null);
|
|
1227
|
-
const agentType = 'root';
|
|
1228
|
-
// 【需求4】探针测的是当前 profile 里选中的链头模型,不是写死的名字
|
|
1229
|
-
const r = await api('probe/run', { agentType, profileId: profile || profileIds[0] });
|
|
1230
|
-
setProbe(r.ok
|
|
1231
|
-
? { kind: 'ok', text: `探针通过(${r.latencyMs}ms)· 回复 ${JSON.stringify(r.data.reply)} · 上游模型 ${r.data.modelUsed}` }
|
|
1232
|
-
: { kind: 'err', text: `探针失败:${r.error}` });
|
|
1233
|
-
setBusy(false);
|
|
1234
|
-
};
|
|
1235
|
-
|
|
1236
|
-
return h('div', null,
|
|
1237
|
-
h('div', { style: S.card },
|
|
1238
|
-
h('div', { style: S.field },
|
|
1239
|
-
h('label', { style: S.label }, '本会话绑定的 Profile(降级链方案)'),
|
|
1240
|
-
h('select', {
|
|
1241
|
-
style: S.input, value: profile, onChange: (e) => setProfile(e.target.value),
|
|
1242
|
-
},
|
|
1243
|
-
h('option', { value: '' }, '(不绑定)'),
|
|
1244
|
-
profileIds.map((p) => h('option', { key: p, value: p }, safeText(p))),
|
|
1245
|
-
),
|
|
1246
|
-
h('div', { style: S.hint },
|
|
1247
|
-
'绑定后:本会话的 root 请求按该 profile 的 root 链走;spawn 的子 Agent 按同 profile 的 coder/research 链走。'),
|
|
1248
|
-
),
|
|
1249
|
-
h('div', { style: S.row },
|
|
1250
|
-
h('button', { style: S.btn('primary'), onClick: save, disabled: busy }, '保存到会话变量'),
|
|
1251
|
-
h('button', { style: S.btn(), onClick: runProbe, disabled: busy }, busy ? '测试中…' : '一键探针测试'),
|
|
1252
|
-
),
|
|
1253
|
-
h('div', { style: { ...S.mono, color: T.labelSecondary, marginTop: 8, fontSize: 11 } },
|
|
1254
|
-
`会话变量键:litellm-bridge/session-profile · sessionId=${sessionId}`),
|
|
1255
|
-
saved && h('div', {
|
|
1256
|
-
style: { marginTop: 9, fontSize: 12, color: saved.kind === 'ok' ? T.success : T.error },
|
|
1257
|
-
}, saved.text),
|
|
1258
|
-
probe && h('div', {
|
|
1259
|
-
style: { marginTop: 9, fontSize: 12, color: probe.kind === 'ok' ? T.success : T.error },
|
|
1260
|
-
}, probe.text),
|
|
1261
|
-
),
|
|
1262
|
-
|
|
1263
|
-
rt && h('div', { style: S.card },
|
|
1264
|
-
h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '子代理路由覆盖'),
|
|
1265
|
-
(() => {
|
|
1266
|
-
const sp = rt.sessionProvider;
|
|
1267
|
-
const isNative = sp && rt.nativeProviders.some((p) => sp.toLowerCase() === p || sp.toLowerCase().startsWith(p + '-'));
|
|
1268
|
-
const isGw = sp && sp.toLowerCase().startsWith('litellm');
|
|
1269
|
-
const verdict = !sp ? { t: '未知', c: S.warn, d: '尚未观察到本会话的模型请求,暂不覆盖(fail-open)' }
|
|
1270
|
-
: isGw ? { t: '全部走网关', c: S.ok, d: '主 Agent 与子 Agent 均走网关链路,不做覆盖' }
|
|
1271
|
-
: isNative ? { t: '子代理将改道网关', c: S.warn, d: `主 Agent 保留 ${sp}(消耗积分);子 Agent 强制走 ${rt.gatewayProvider}` }
|
|
1272
|
-
: { t: '不覆盖', c: S.ok, d: `供应商 ${sp} 不在覆盖名单` };
|
|
1273
|
-
return h('div', null,
|
|
1274
|
-
h('div', { style: { marginBottom: 6 } },
|
|
1275
|
-
h('span', { style: S.badge() }, `当前供应商: ${sp || '未知'}`),
|
|
1276
|
-
h('span', { style: { color: verdict.c, fontWeight: 600 } }, verdict.t),
|
|
1277
|
-
),
|
|
1278
|
-
h('div', { style: { fontSize: 12, opacity: 0.75, marginBottom: 6 } }, verdict.d),
|
|
1279
|
-
h('div', { style: { ...S.mono, fontSize: 11, opacity: 0.55 } },
|
|
1280
|
-
`覆盖名单: ${rt.nativeProviders.join(' / ')} · 目标: ${rt.gatewayProvider}`),
|
|
1281
|
-
h('div', { style: { ...S.mono, fontSize: 11, opacity: 0.55, marginTop: 3 } },
|
|
1282
|
-
`已评估 ${rt.stats.evaluated} 次 · 改道 ${rt.stats.overridden} 次 · 跳过 ${rt.stats.skipped} 次 · 降级 ${rt.stats.degraded} 次`),
|
|
1283
|
-
);
|
|
1284
|
-
})(),
|
|
1285
|
-
),
|
|
1286
|
-
|
|
1287
|
-
h('div', { style: S.card },
|
|
1288
|
-
h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '本会话最近降级记录'),
|
|
1289
|
-
drops.length === 0
|
|
1290
|
-
? h('div', { style: { opacity: 0.5, fontSize: 12 } }, '暂无降级记录(或网关日志不含该字段)')
|
|
1291
|
-
: drops.map((d, i) => h('div', { key: i, style: S.logRow(d.status === 'failure' ? 'failed' : 'fallback') },
|
|
1292
|
-
h('span', { style: { ...S.mono } }, d.model),
|
|
1293
|
-
h('span', { style: { ...S.mono, opacity: 0.6, marginLeft: 8 } },
|
|
1294
|
-
d.fallback === 'yes' ? '触发降级' : '失败'),
|
|
1295
|
-
d.error && h('div', { style: { ...S.mono, fontSize: 11, color: S.err, marginTop: 3 } }, d.error),
|
|
1296
|
-
)),
|
|
1297
|
-
),
|
|
1298
|
-
|
|
1299
|
-
h('div', { style: { ...S.mono, opacity: 0.5, fontSize: 11.5, lineHeight: 1.7 } },
|
|
1300
|
-
h('div', null, '联动说明:本插件读写会话 profile 变量 + 子代理路由覆盖;'),
|
|
1301
|
-
h('div', null, 'dsh-plugin-header-inject 读取该变量并注入 x-agent-type / x-session-profile。'),
|
|
1302
|
-
h('div', null, '两插件通过会话变量解耦,互不依赖。'),
|
|
1303
|
-
),
|
|
1304
|
-
);
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
/**
|
|
1308
|
-
* 创建**稳定引用**的 api 函数(同一个插件实例只创建一次)。
|
|
1309
|
-
*
|
|
1310
|
-
* 为什么必须要稳定:各 Tab 的 useEffect 依赖数组里都有 [api]。若 api 每次
|
|
1311
|
-
* 渲染都变,就会 effect→setState→重渲染→新 api→再 effect 无限循环,
|
|
1312
|
-
* React 表现为组件反复重挂载、页面空白。
|
|
1313
|
-
*/
|
|
1314
1171
|
const apiCache = new WeakMap();
|
|
1315
1172
|
function createStableApi(ctx) {
|
|
1316
1173
|
const hit = apiCache.get(ctx);
|
|
@@ -1323,10 +1180,12 @@ window.__ModuleLoader__.load({
|
|
|
1323
1180
|
// -----------------------------------------------------------------------
|
|
1324
1181
|
// 【会话级入口】标题旁的小按钮 —— 直接给当前会话选降级链(profile)
|
|
1325
1182
|
//
|
|
1326
|
-
// 槽位:conversation.session.header.actions
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1183
|
+
// 槽位:conversation.session.header.actions(会话标题旁那排胶囊)
|
|
1184
|
+
// —— 与 dsh-llm-fallbacks 的 SubagentRoleBadge 同槽位,实测渲染正常
|
|
1185
|
+
// (用户截图里的「未绑定」按钮就出现在这排)。
|
|
1329
1186
|
//
|
|
1187
|
+
// 为什么不用 conversation.input.right:上一版挪过去后从未同步安装,
|
|
1188
|
+
// 属于未验证的倒退;本槽位已有实证可显示,保持不动。
|
|
1330
1189
|
// 为什么不做进右下角模型选择器:
|
|
1331
1190
|
// 那个槽位是 conversation.input.model(single,shadows-shipped-ui),
|
|
1332
1191
|
// 注册即替换掉官方选择器,得不偿失。
|
|
@@ -1491,7 +1350,7 @@ window.__ModuleLoader__.load({
|
|
|
1491
1350
|
// 主面板 —— 对齐 dsh-llm-fallbacks 的官方设置页语言:
|
|
1492
1351
|
// 扁平页面(无卡片壳)· 顶部标签导航 · 页内分区 h2 + 每区独立 Save/Discard
|
|
1493
1352
|
// -----------------------------------------------------------------------
|
|
1494
|
-
function BridgePanel({ api
|
|
1353
|
+
function BridgePanel({ api }) {
|
|
1495
1354
|
const [tab, setTab] = useState('gateway');
|
|
1496
1355
|
const [state, setState] = useState(null);
|
|
1497
1356
|
|
|
@@ -1503,7 +1362,6 @@ window.__ModuleLoader__.load({
|
|
|
1503
1362
|
['gateway', '网关设置'],
|
|
1504
1363
|
['routes', '路由配置'],
|
|
1505
1364
|
['logs', '日志面板'],
|
|
1506
|
-
['session', '当前会话'],
|
|
1507
1365
|
];
|
|
1508
1366
|
|
|
1509
1367
|
return h('div', { style: S.page },
|
|
@@ -1518,7 +1376,6 @@ window.__ModuleLoader__.load({
|
|
|
1518
1376
|
tab === 'gateway' && h(Guard, { title: '网关设置' }, () => h(GatewayTab, { api, state, setState })),
|
|
1519
1377
|
tab === 'routes' && h(Guard, { title: '路由配置' }, () => h(RouteTab, { api, state, setState })),
|
|
1520
1378
|
tab === 'logs' && h(Guard, { title: '日志面板' }, () => h(LogsTab, { api, state })),
|
|
1521
|
-
tab === 'session' && h(Guard, { title: '当前会话' }, () => h(SessionTab, { api, ctx, state, setState })),
|
|
1522
1379
|
),
|
|
1523
1380
|
);
|
|
1524
1381
|
}
|
|
@@ -1562,7 +1419,7 @@ window.__ModuleLoader__.load({
|
|
|
1562
1419
|
// 会导致「effect -> setState -> 重渲染 -> 新 api -> 再跑 effect」死循环,
|
|
1563
1420
|
// 表现就是整页空白。
|
|
1564
1421
|
const api = createStableApi(ctx);
|
|
1565
|
-
const Panel = () => h(BridgePanel, { api
|
|
1422
|
+
const Panel = () => h(BridgePanel, { api });
|
|
1566
1423
|
|
|
1567
1424
|
// 【必须用 ctx.effect 包住注册】
|
|
1568
1425
|
// client 侧模板要求 apply 通过 ctx.effect 注册每个资源并返回清理函数。
|
|
@@ -1587,19 +1444,30 @@ window.__ModuleLoader__.load({
|
|
|
1587
1444
|
});
|
|
1588
1445
|
|
|
1589
1446
|
// ---- (2) 会话标题旁的小按钮:直接为当前会话选降级链 ----
|
|
1590
|
-
//
|
|
1447
|
+
//
|
|
1448
|
+
// 【为什么改成命名组件】
|
|
1449
|
+
// 原来传的是内联箭头函数 (props) => h(...)。
|
|
1450
|
+
// 内联函数每次 inject 都是**新的函数引用**,React 会视为不同组件类型
|
|
1451
|
+
// 而卸载重挂 —— 轻则不显示,重则状态丢失。
|
|
1452
|
+
// settings.section 传的是命名组件 Panel(能正常显示),这里照同一写法。
|
|
1453
|
+
//
|
|
1454
|
+
// 【order 从 -5 改为 0】
|
|
1455
|
+
// 负 order 会排到列表最前,可能被容器左边缘裁掉
|
|
1456
|
+
//(该 slot 文档写明「overflow is clipped and faded before the utilities」)。
|
|
1457
|
+
// 用 0 让它排在默认位置。
|
|
1458
|
+
const HeaderAction = (props) => h(SessionProfileButton, {
|
|
1459
|
+
api,
|
|
1460
|
+
sessionId: props?.sessionId ?? props?.session?.id ?? 'current',
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1591
1463
|
const disposeHeader = ctx.slots.inject('conversation.session.header.actions', () => {
|
|
1592
1464
|
return ctx.slots.register(
|
|
1593
1465
|
{
|
|
1594
1466
|
name: 'conversation.session.header.actions',
|
|
1595
1467
|
id: NS,
|
|
1596
|
-
order:
|
|
1597
|
-
label: () => '降级链',
|
|
1468
|
+
order: 4,
|
|
1598
1469
|
},
|
|
1599
|
-
|
|
1600
|
-
api,
|
|
1601
|
-
sessionId: props?.sessionId ?? props?.session?.id ?? 'current',
|
|
1602
|
-
}),
|
|
1470
|
+
HeaderAction,
|
|
1603
1471
|
);
|
|
1604
1472
|
});
|
|
1605
1473
|
|
|
@@ -243,6 +243,28 @@ export function assembleChainConfig(state, pool = null) {
|
|
|
243
243
|
push(' forward_client_headers_to_llm_api: true # pass x-agent-type / x-session-profile through');
|
|
244
244
|
push('');
|
|
245
245
|
|
|
246
|
+
// ---- 4. 请求体大小上限 ------------------------------------------------
|
|
247
|
+
//
|
|
248
|
+
// 【为什么必须配】
|
|
249
|
+
// 用户实测两个错误:
|
|
250
|
+
// 413 {"detail":{"error":{"message":"Request body is 6483286 bytes,
|
|
251
|
+
// over the 5242880 byte limit","code":"request_too_large"}}}
|
|
252
|
+
// 502 Bad Gateway (nginx)
|
|
253
|
+
//
|
|
254
|
+
// 第一个是 LiteLLM 自己的限制:general_settings.max_request_size_mb
|
|
255
|
+
// 默认 5MB(5242880 字节)。长对话 + 图片附件很容易超。
|
|
256
|
+
// 官方生产建议 32MB。
|
|
257
|
+
//
|
|
258
|
+
// 第二个 502 来自前置 nginx:请求体过大时 nginx 直接断开,
|
|
259
|
+
// 表现为 502 而不是 413。LiteLLM 侧放宽后仍需 nginx 侧配合
|
|
260
|
+
// (client_max_body_size),这一项插件无法代配,会在 UI 提示。
|
|
261
|
+
push('general_settings:');
|
|
262
|
+
push(' # Allow large requests (long conversations + image attachments).');
|
|
263
|
+
push(' # Default is 5MB; the plugin raises it to 32MB (LiteLLM production guidance).');
|
|
264
|
+
push(' max_request_size_mb: 32');
|
|
265
|
+
push(' max_response_size_mb: 32');
|
|
266
|
+
push('');
|
|
267
|
+
|
|
246
268
|
return {
|
|
247
269
|
yaml: lines.join('\n'),
|
|
248
270
|
warnings: [...new Set(warnings)],
|
|
@@ -226,6 +226,25 @@ export function apply(ctx, config = {}) {
|
|
|
226
226
|
return proceed();
|
|
227
227
|
});
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* 给定 agent 类型与会话,算出它实际会用的链头名。
|
|
231
|
+
*
|
|
232
|
+
* 抽成独立函数的原因:子代理改道(modelFor)与 UI 的「这条会话走哪条链」
|
|
233
|
+
* 必须用**同一套逻辑**,否则显示的和实际跑的可能不一致 —— 那比不显示更糟。
|
|
234
|
+
*/
|
|
235
|
+
function chainHeadFor(agentType, parentSessionId) {
|
|
236
|
+
try {
|
|
237
|
+
const profileId = parentSessionId ? getSessionProfile(ctx, parentSessionId) : '';
|
|
238
|
+
const routes = store.get().routes;
|
|
239
|
+
const pid = routes[profileId] ? profileId : Object.keys(routes)[0] || DEFAULT_PROFILE_ID;
|
|
240
|
+
const cfg = routes?.[pid]?.agents?.[agentType];
|
|
241
|
+
if (cfg?.primary) return `${agentType}-${pid}-primary`;
|
|
242
|
+
return `${agentType}-${pid}-primary`;
|
|
243
|
+
} catch {
|
|
244
|
+
return `${agentType}-${DEFAULT_PROFILE_ID}-primary`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
229
248
|
// -------------------------------------------------------------------------
|
|
230
249
|
// 子代理路由覆盖(Trae/WorkBuddy 会话的子代理强制改道网关)
|
|
231
250
|
// -------------------------------------------------------------------------
|
|
@@ -244,19 +263,9 @@ export function apply(ctx, config = {}) {
|
|
|
244
263
|
* 链的顺序与超时可在 UI 单独配置,与 root 链完全独立。
|
|
245
264
|
*
|
|
246
265
|
* 返回链头名(<at>-<pid>-primary),由网关按 fallbacks 执行后续降级。
|
|
266
|
+
* 注意:这里与 UI 的 chainHeadFor 用同一套逻辑,保证「显示的就是实际跑的」。
|
|
247
267
|
*/
|
|
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
|
-
},
|
|
268
|
+
modelFor: (agentType, parentSessionId) => chainHeadFor(agentType, parentSessionId),
|
|
260
269
|
debug: (m) => ctx.logger?.debug?.(m),
|
|
261
270
|
});
|
|
262
271
|
ctx.effect?.(() => () => routing.dispose());
|
|
@@ -606,7 +615,21 @@ export function apply(ctx, config = {}) {
|
|
|
606
615
|
}
|
|
607
616
|
|
|
608
617
|
// ---- 子代理路由覆盖状态 ----
|
|
609
|
-
case 'routing/status':
|
|
618
|
+
case 'routing/status': {
|
|
619
|
+
// 把「已观测到的会话」一并返回,方便 UI 显示真实 sessionId
|
|
620
|
+
// 并让用户确认「我这条会话到底走哪条链」。
|
|
621
|
+
const known = [];
|
|
622
|
+
for (const [sid, provider] of sessionProviders.entries()) {
|
|
623
|
+
known.push({
|
|
624
|
+
sessionId: sid,
|
|
625
|
+
provider,
|
|
626
|
+
profile: getSessionProfile(ctx, sid) || '',
|
|
627
|
+
// 该会话实际会用的链头(子代理被改道时用)
|
|
628
|
+
chainHeads: Object.fromEntries(
|
|
629
|
+
AGENT_TYPES.map((at) => [at, chainHeadFor(at, sid)]),
|
|
630
|
+
),
|
|
631
|
+
});
|
|
632
|
+
}
|
|
610
633
|
return {
|
|
611
634
|
ok: true,
|
|
612
635
|
data: {
|
|
@@ -614,8 +637,12 @@ export function apply(ctx, config = {}) {
|
|
|
614
637
|
gatewayProvider: routing.gatewayProvider || GATEWAY_PROVIDER,
|
|
615
638
|
stats: routing.stats(),
|
|
616
639
|
sessionProvider: args.sessionId ? getSessionProvider(String(args.sessionId)) || null : null,
|
|
640
|
+
sessionProfile: args.sessionId ? getSessionProfile(ctx, String(args.sessionId)) || '' : '',
|
|
641
|
+
knownSessions: known,
|
|
642
|
+
defaultProfile: Object.keys(store.get().routes)[0] || DEFAULT_PROFILE_ID,
|
|
617
643
|
},
|
|
618
644
|
};
|
|
645
|
+
}
|
|
619
646
|
|
|
620
647
|
default:
|
|
621
648
|
return { ok: false, error: `未知操作: ${action}` };
|
|
@@ -163,6 +163,51 @@ export async function collectModelPool(ctx, { signal } = {}) {
|
|
|
163
163
|
if (!providers.some((p) => p.id === c.id)) providers.push(c);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// ---------------------------------------------------------------
|
|
167
|
+
// 【关键补齐】用户自定义 provider 的 baseURL / 凭证
|
|
168
|
+
//
|
|
169
|
+
// llm.listConfigurableProviders() 只对**内置目录** provider 返回
|
|
170
|
+
// defaultBaseURL / nativeAuth(settingsNs = 'llm-pi-ai')。
|
|
171
|
+
// 用户在【设置-模型】里自己加的(agnes / agnes-backup / amd ...)
|
|
172
|
+
// 返回的是 baseUrl: null、authEnv: []、settingsNs: null。
|
|
173
|
+
//
|
|
174
|
+
// 而这些信息真实存在于 profile 的 cordis.patch.yml,所以这里
|
|
175
|
+
// 直接读该文件补齐。不补齐的话,下发网关时会报
|
|
176
|
+
// 「供应商 X 缺少 API 地址(baseURL)」—— 用户明确反馈过这条。
|
|
177
|
+
// ---------------------------------------------------------------
|
|
178
|
+
try {
|
|
179
|
+
const { loadPatchProviders } = await import('./patch-providers.js');
|
|
180
|
+
const patchMap = await loadPatchProviders();
|
|
181
|
+
if (patchMap.size) {
|
|
182
|
+
for (const p of providers) {
|
|
183
|
+
const hit = patchMap.get(p.id);
|
|
184
|
+
if (!hit) continue;
|
|
185
|
+
if (!p.baseUrl && hit.baseURL) p.baseUrl = hit.baseURL;
|
|
186
|
+
if ((!p.authEnv || !p.authEnv.length) && hit.apiKeyEnv) p.authEnv = [hit.apiKeyEnv];
|
|
187
|
+
if (!p.displayName && hit.displayName) p.displayName = hit.displayName;
|
|
188
|
+
p.baseUrlSource = p.baseUrl ? (hit.baseURL === p.baseUrl ? 'patch' : 'configurable') : 'missing';
|
|
189
|
+
}
|
|
190
|
+
// patch 里有、但 llm 服务完全没列出来的 provider 也补上
|
|
191
|
+
for (const [id, hit] of patchMap) {
|
|
192
|
+
if (providers.some((p) => p.id === id)) continue;
|
|
193
|
+
if (!hit.baseURL) continue;
|
|
194
|
+
providers.push({
|
|
195
|
+
id,
|
|
196
|
+
name: hit.displayName || id,
|
|
197
|
+
kind: null,
|
|
198
|
+
configured: true,
|
|
199
|
+
baseUrl: hit.baseURL,
|
|
200
|
+
baseUrlEnv: [],
|
|
201
|
+
authEnv: hit.apiKeyEnv ? [hit.apiKeyEnv] : [],
|
|
202
|
+
settingsNs: null,
|
|
203
|
+
baseUrlSource: 'patch',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
/* 读 patch 失败不影响主流程:只是少一层 baseURL 兜底 */
|
|
209
|
+
}
|
|
210
|
+
|
|
166
211
|
if (providers.length && typeof llm.listModels === 'function') {
|
|
167
212
|
for (const p of providers) {
|
|
168
213
|
if (signal?.aborted) break;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 DSH profile 的 cordis.patch.yml 读取用户自定义 provider 配置
|
|
3
|
+
* ============================================================================
|
|
4
|
+
*
|
|
5
|
+
* 【为什么必须读这个文件】
|
|
6
|
+
* DSH 的 `llm.listConfigurableProviders()` 只对**内置目录 provider**
|
|
7
|
+
*(anthropic / deepseek / ant-ling 等,settingsNs = 'llm-pi-ai')返回
|
|
8
|
+
* defaultBaseURL 与 nativeAuth。
|
|
9
|
+
*
|
|
10
|
+
* 用户自己在【设置-模型】里加的 provider(agnes / agnes-backup / amd /
|
|
11
|
+
* siliconflow ...)在 llm 服务里只有 { id, name },**baseUrl 与 apiKeyEnv
|
|
12
|
+
* 全是空的**(settingsNs = null)。
|
|
13
|
+
*
|
|
14
|
+
* 实测证据:
|
|
15
|
+
* {"id":"agnes","name":"Agnes 3.0 Flash","baseUrl":null,"authEnv":[],
|
|
16
|
+
* "settingsNs":null} ← 用户自定义,拿不到
|
|
17
|
+
* {"id":"ant-ling","baseUrl":"https://api.ant-ling.com/v1",
|
|
18
|
+
* "authEnv":["ANT_LING_API_KEY"],"settingsNs":"llm-pi-ai"} ← 内置,有
|
|
19
|
+
*
|
|
20
|
+
* 而这些信息**确实存在于** profile 的 cordis.patch.yml:
|
|
21
|
+
* - id: llm-pi-ai
|
|
22
|
+
* config:
|
|
23
|
+
* providers:
|
|
24
|
+
* agnes:
|
|
25
|
+
* baseURL: https://apihub.agnes-ai.com/v1
|
|
26
|
+
* apiKeyEnv: AGNES_API_KEY
|
|
27
|
+
* models: [...]
|
|
28
|
+
*
|
|
29
|
+
* 所以本模块直接解析该 YAML 的 providers 段,补齐 llm 服务缺失的部分。
|
|
30
|
+
*
|
|
31
|
+
* 【设计约束】
|
|
32
|
+
* - **只读**:绝不写这个文件(那是用户的 profile 配置,写坏了 DSH 起不来)
|
|
33
|
+
* - 解析失败必须 fail-soft:返回空表,让上层走原有降级路径
|
|
34
|
+
* - 不引入 YAML 依赖:只解析我们需要的固定结构(缩进稳定)
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { readFile } from 'node:fs/promises';
|
|
38
|
+
import { existsSync } from 'node:fs';
|
|
39
|
+
import { join } from 'node:path';
|
|
40
|
+
|
|
41
|
+
/** 定位 profile 的 cordis.patch.yml。 */
|
|
42
|
+
export function profilePatchPath() {
|
|
43
|
+
const home = process.env.DSH_HOME || process.env.DSH_PROFILE_DIR || '';
|
|
44
|
+
const candidates = [];
|
|
45
|
+
|
|
46
|
+
if (process.env.DSH_PROFILE_DIR) {
|
|
47
|
+
candidates.push(join(process.env.DSH_PROFILE_DIR, 'cordis.patch.yml'));
|
|
48
|
+
}
|
|
49
|
+
if (home) {
|
|
50
|
+
// DSH_HOME 可能是 .dsh 根,也可能是 profile 目录
|
|
51
|
+
candidates.push(join(home, 'cordis.patch.yml'));
|
|
52
|
+
candidates.push(join(home, 'profiles', 'desktop', 'cordis.patch.yml'));
|
|
53
|
+
}
|
|
54
|
+
// 兜底:从 node_modules 位置反推 profile 目录
|
|
55
|
+
// <profile>/node_modules/dsh-plugin-litellm-bridge/lib/... → <profile>
|
|
56
|
+
try {
|
|
57
|
+
const here = new URL('.', import.meta.url).pathname;
|
|
58
|
+
const idx = here.replace(/\\/g, '/').indexOf('/node_modules/');
|
|
59
|
+
if (idx > 0) {
|
|
60
|
+
const profileRoot = here.slice(0, idx);
|
|
61
|
+
candidates.push(join(profileRoot, 'cordis.patch.yml'));
|
|
62
|
+
}
|
|
63
|
+
} catch { /* ignore */ }
|
|
64
|
+
|
|
65
|
+
for (const c of candidates) {
|
|
66
|
+
try {
|
|
67
|
+
if (c && existsSync(c)) return c;
|
|
68
|
+
} catch { /* ignore */ }
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 解析 YAML 里的 providers 段。
|
|
75
|
+
*
|
|
76
|
+
* 只认这个固定结构(DSH 自己生成的,缩进稳定):
|
|
77
|
+
*
|
|
78
|
+
* providers:
|
|
79
|
+
* <providerId>:
|
|
80
|
+
* baseURL: https://...
|
|
81
|
+
* apiKeyEnv: SOME_API_KEY
|
|
82
|
+
* displayName: 显示名
|
|
83
|
+
* models:
|
|
84
|
+
* - id: model-id
|
|
85
|
+
* name: 显示名
|
|
86
|
+
*
|
|
87
|
+
* 返回 Map<providerId, { baseURL, apiKeyEnv, displayName, models }>
|
|
88
|
+
*/
|
|
89
|
+
export function parseProvidersFromPatch(text) {
|
|
90
|
+
const out = new Map();
|
|
91
|
+
if (typeof text !== 'string' || !text) return out;
|
|
92
|
+
|
|
93
|
+
const lines = text.split('\n');
|
|
94
|
+
/** 当前所在的 providers: 块的缩进 */
|
|
95
|
+
let providersIndent = -1;
|
|
96
|
+
let cur = null;
|
|
97
|
+
/** 当前 provider 的字段缩进(provider 行的缩进 + 2) */
|
|
98
|
+
let fieldIndent = -1;
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < lines.length; i++) {
|
|
101
|
+
const raw = lines[i];
|
|
102
|
+
const line = raw.replace(/\r$/, '');
|
|
103
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
104
|
+
|
|
105
|
+
const indent = line.match(/^\s*/)[0].length;
|
|
106
|
+
|
|
107
|
+
// 进入/离开 providers: 块
|
|
108
|
+
if (/^\s*providers:\s*$/.test(line)) {
|
|
109
|
+
providersIndent = indent;
|
|
110
|
+
cur = null;
|
|
111
|
+
fieldIndent = -1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (providersIndent >= 0 && indent <= providersIndent) {
|
|
115
|
+
// 回到同级或更外层,说明 providers 块结束
|
|
116
|
+
providersIndent = -1;
|
|
117
|
+
cur = null;
|
|
118
|
+
fieldIndent = -1;
|
|
119
|
+
}
|
|
120
|
+
if (providersIndent < 0) continue;
|
|
121
|
+
|
|
122
|
+
// provider 定义行:比 providers 深一层,且以 `key:` 结尾
|
|
123
|
+
const mProvider = line.match(/^(\s+)([A-Za-z0-9._-]+):\s*$/);
|
|
124
|
+
if (mProvider && indent === providersIndent + 2) {
|
|
125
|
+
cur = {
|
|
126
|
+
id: mProvider[2],
|
|
127
|
+
baseURL: null,
|
|
128
|
+
apiKeyEnv: null,
|
|
129
|
+
displayName: null,
|
|
130
|
+
models: [],
|
|
131
|
+
settingsNs: null,
|
|
132
|
+
};
|
|
133
|
+
fieldIndent = indent + 2;
|
|
134
|
+
out.set(cur.id, cur);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!cur) continue;
|
|
138
|
+
|
|
139
|
+
// 【关键】只解析 provider **直属层级**的标量字段。
|
|
140
|
+
// 之前漏了这个判断,导致 models 列表项里的 `name:` 行
|
|
141
|
+
// (缩进更深)把 provider 的 displayName 覆盖成了模型名。
|
|
142
|
+
// 实测:agnes.displayName 被写成 "agnes-image-2.5-flash"。
|
|
143
|
+
if (indent === fieldIndent) {
|
|
144
|
+
const mScalar = line.match(/^\s+([A-Za-z_][\w]*):\s*(.+?)\s*$/);
|
|
145
|
+
if (mScalar) {
|
|
146
|
+
const key = mScalar[1];
|
|
147
|
+
let val = mScalar[2];
|
|
148
|
+
val = val.replace(/\s+#.*$/, '').trim();
|
|
149
|
+
val = val.replace(/^['"]|['"]$/g, '');
|
|
150
|
+
if (key === 'baseURL' || key === 'baseUrl' || key === 'api_base') cur.baseURL = val;
|
|
151
|
+
else if (key === 'apiKeyEnv' || key === 'api_key_env') cur.apiKeyEnv = val;
|
|
152
|
+
else if (key === 'displayName') cur.displayName = val;
|
|
153
|
+
else if (key === 'settingsNs') cur.settingsNs = val;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// models 列表项:`- id: xxx`(缩进比字段更深)
|
|
159
|
+
if (indent > fieldIndent) {
|
|
160
|
+
const mModelId = line.match(/^\s+-\s+id:\s*(.+?)\s*$/);
|
|
161
|
+
if (mModelId) {
|
|
162
|
+
const id = mModelId[1].replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '');
|
|
163
|
+
if (id) cur.models.push({ id, name: id });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 读取并解析 profile 的 providers 配置。
|
|
173
|
+
* @returns {Promise<Map<string, object>>} 失败返回空 Map(fail-soft)
|
|
174
|
+
*/
|
|
175
|
+
export async function loadPatchProviders() {
|
|
176
|
+
const path = profilePatchPath();
|
|
177
|
+
if (!path) return new Map();
|
|
178
|
+
try {
|
|
179
|
+
const text = await readFile(path, 'utf8');
|
|
180
|
+
return parseProvidersFromPatch(text);
|
|
181
|
+
} catch {
|
|
182
|
+
return new Map();
|
|
183
|
+
}
|
|
184
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-litellm-bridge",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"description": "LiteLLM gateway bridge for DSH: assemble root / coder / research fallback chains from the DSH model pool and hand the order to a LiteLLM gateway that executes retries and fallbacks. The plugin performs no routing itself.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|