dsh-plugin-litellm-bridge 2.1.1 → 2.1.3

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
@@ -1,4 +1,4 @@
1
- # dsh-fallback-auto-pool-arch
1
+ # dsh-plugin-litellm-bridge
2
2
 
3
3
  DSH + LiteLLM 网关桥接方案。
4
4
 
@@ -76,7 +76,7 @@ DSH + LiteLLM 网关桥接方案。
76
76
  `dsh-plugin-litellm-bridge/` 里的实现),所以可以直接按 git 装:
77
77
 
78
78
  ```
79
- git+https://github.com/heixiua/dsh-fallback-auto-pool-arch.git#<commit-sha>
79
+ git+https://github.com/heixiua/dsh-plugin-litellm-bridge.git#<commit-sha>
80
80
  ```
81
81
 
82
82
  在插件市场里粘贴这个地址即可。**建议带上 commit sha**(而不是 `#main`),
@@ -87,7 +87,7 @@ git+https://github.com/heixiua/dsh-fallback-auto-pool-arch.git#<commit-sha>
87
87
  ```bash
88
88
  # 完全退出 DSH(全部 6 个 DeepSeek Harness.exe 进程)后
89
89
  cd "D:\AI agent cache\dsh\profiles\desktop"
90
- pnpm add "file:D:/AI sandbox/dsh-sandbox/dsh-fallback-auto-pool-arch"
90
+ pnpm add "file:D:/AI sandbox/dsh-sandbox/dsh-plugin-litellm-bridge"
91
91
  ```
92
92
 
93
93
  > ⚠ 装完立刻检查 `pnpm-workspace.yaml` 的 `allowBuilds` 里有没有
@@ -97,7 +97,7 @@ pnpm add "file:D:/AI sandbox/dsh-sandbox/dsh-fallback-auto-pool-arch"
97
97
  ### 方式 C:从子目录装(旧方式,仍支持)
98
98
 
99
99
  ```bash
100
- pnpm add "file:D:/AI sandbox/dsh-sandbox/dsh-fallback-auto-pool-arch/dsh-plugin-litellm-bridge"
100
+ pnpm add "file:D:/AI sandbox/dsh-sandbox/dsh-plugin-litellm-bridge/dsh-plugin-litellm-bridge"
101
101
  ```
102
102
 
103
103
  子目录里保留了完整、可独立安装的 `package.json`,与根目录指向同一份实现
package/docs/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-fallback-auto-pool-arch
1
+ # dsh-plugin-litellm-bridge
2
2
 
3
3
  DSH 插件 **litellm-bridge**:一个 LiteLLM 网关桥接面板。
4
4
 
@@ -94,7 +94,7 @@ DSH 插件 **litellm-bridge**:一个 LiteLLM 网关桥接面板。
94
94
  ## 3. 仓库结构
95
95
 
96
96
  ```
97
- dsh-fallback-auto-pool-arch/
97
+ dsh-plugin-litellm-bridge/
98
98
  ├── dsh-plugin-litellm-bridge/ # ① DSH 桥接插件本体
99
99
  │ ├── package.json # dsh.bundle.patch + dsh.client 声明
100
100
  │ ├── cordis.patch.yml # 挂进 profile 组合树的补丁
@@ -273,7 +273,7 @@ Copy-Item "$p\cordis.patch.yml" "$p\cordis.patch.yml.bak" -Force
273
273
 
274
274
  # 3) 以 file: 依赖安装(路径必须绝对)
275
275
  Set-Location $p
276
- pnpm add "file:D:/AI sandbox/dsh-sandbox/dsh-fallback-auto-pool-arch/dsh-plugin-litellm-bridge"
276
+ pnpm add "file:D:/AI sandbox/dsh-sandbox/dsh-plugin-litellm-bridge/dsh-plugin-litellm-bridge"
277
277
 
278
278
  # 4) 确认包体完整(文件数 > 0,且 package.json 存在)
279
279
  Get-ChildItem "$p\node_modules\dsh-plugin-litellm-bridge" -Recurse | Measure-Object
@@ -622,14 +622,14 @@ python litellm-gateway/test-probe.py --model root-primary
622
622
 
623
623
  ## 9. 推送到 GitHub
624
624
 
625
- 仓库地址:<https://github.com/heixiua/dsh-fallback-auto-pool-arch>
625
+ 仓库地址:<https://github.com/heixiua/dsh-plugin-litellm-bridge>
626
626
 
627
627
  ```bash
628
- cd dsh-fallback-auto-pool-arch
628
+ cd dsh-plugin-litellm-bridge
629
629
 
630
630
  git init
631
631
  git branch -M main
632
- git remote add origin https://github.com/heixiua/dsh-fallback-auto-pool-arch.git
632
+ git remote add origin https://github.com/heixiua/dsh-plugin-litellm-bridge.git
633
633
 
634
634
  # 提交前务必确认没有密钥混进来
635
635
  git status --short
@@ -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,241 +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
1183
- //
1184
- // 【重要】settings.section 是 **root scope**(全局设置页),它不属于
1185
- // 任何一个会话,所以这里**拿不到真实 sessionId**。
1186
- // 之前静默用 'current' 兜底,导致用户以为绑定成功了,
1187
- // 实际写进了一个名为 current 的假会话,真实会话读不到 —— 这是错的。
1188
- //
1189
- // 现在的策略:
1190
- // 1) 仍尝试从 ctx / URL 拿(某些宿主版本可能给)
1191
- // 2) 拿不到就标记 isFallback=true,UI 明确提示「去会话标题旁选」
1192
- // 3) 不再假装成功
1193
- const sessionInfo = useMemo(() => {
1194
- try {
1195
- const fromCtx = ctx?.session?.id || ctx?.sessionId;
1196
- if (fromCtx) return { id: String(fromCtx), isFallback: false };
1197
- const loc = globalThis.location;
1198
- const hay = String(loc?.hash || '') + String(loc?.search || '');
1199
- const m = hay.match(/session[=/]([A-Za-z0-9-]{8,})/i);
1200
- if (m) return { id: m[1], isFallback: false };
1201
- } catch { /* 落到 fallback */ }
1202
- return { id: 'current', isFallback: true };
1203
- }, []);
1204
- const sessionId = sessionInfo.id;
1205
- const sessionIdIsFallback = sessionInfo.isFallback;
1206
-
1207
- useEffect(() => {
1208
- api('session/getProfile', { sessionId }).then((r) => {
1209
- if (r.ok) setProfile(r.data || '');
1210
- });
1211
- }, [api, sessionId]);
1212
-
1213
- // 本会话最近降级记录:从日志里按 profile 过滤出 fallback 行
1214
- const loadDrops = useCallback(async () => {
1215
- const r = await api('logs/fetch', { limit: 50 });
1216
- if (r.ok) setDrops((r.data || []).filter((l) => l.fallback === 'yes' || l.status === 'failure').slice(0, 10));
1217
- }, [api]);
1218
- useEffect(() => { loadDrops(); }, [loadDrops]);
1219
-
1220
- // 子代理路由覆盖状态
1221
- const [rt, setRt] = useState(null);
1222
- useEffect(() => {
1223
- api('routing/status', { sessionId }).then((r) => { if (r.ok) setRt(r.data); });
1224
- }, [api, sessionId]);
1225
-
1226
- const save = async () => {
1227
- setBusy(true);
1228
- const r = await api('session/setProfile', { sessionId, profile });
1229
- setSaved(r.ok ? { kind: 'ok', text: `已绑定 ${profile || '(已清空)'},存入会话变量` } : { kind: 'err', text: r.error });
1230
- setBusy(false);
1231
- };
1232
-
1233
- const runProbe = async () => {
1234
- setBusy(true); setProbe(null);
1235
- const agentType = 'root';
1236
- // 【需求4】探针测的是当前 profile 里选中的链头模型,不是写死的名字
1237
- const r = await api('probe/run', { agentType, profileId: profile || profileIds[0] });
1238
- setProbe(r.ok
1239
- ? { kind: 'ok', text: `探针通过(${r.latencyMs}ms)· 回复 ${JSON.stringify(r.data.reply)} · 上游模型 ${r.data.modelUsed}` }
1240
- : { kind: 'err', text: `探针失败:${r.error}` });
1241
- setBusy(false);
1242
- };
1243
-
1244
- return h('div', null,
1245
- // 【关键提示】拿不到真实 sessionId 时,必须明确告诉用户去哪操作,
1246
- // 而不是让人以为在这里选完就生效了。
1247
- sessionIdIsFallback && h('div', { style: S.notice('warn') },
1248
- h('div', { style: { fontWeight: 600, marginBottom: 4 } },
1249
- '设置页绑定不到「某一个会话」'),
1250
- h('div', null,
1251
- '这里是全局设置页,不属于任何具体会话,所以无法确定"当前会话"是哪一个。'),
1252
- h('div', { style: { marginTop: 6, fontWeight: 600 } },
1253
- '→ 请用会话标题旁的那个小按钮来切换本会话的降级链。'),
1254
- h('div', { style: { marginTop: 4, opacity: 0.8 } },
1255
- '(在主界面对话标题右边,显示当前 profile 名,点开即可选)'),
1256
- ),
1257
-
1258
- h('div', { style: S.card },
1259
- h('div', { style: S.fieldRow },
1260
- h('div', { style: { flex: '1 1 220px' } },
1261
- h('label', { style: S.label }, '本会话绑定的 Profile(降级链方案)'),
1262
- h('select', {
1263
- style: S.input, value: profile, onChange: (e) => setProfile(e.target.value),
1264
- },
1265
- h('option', { value: '' }, '(不绑定)'),
1266
- profileIds.map((p) => h('option', { key: p, value: p }, safeText(p))),
1267
- ),
1268
- ),
1269
- h('div', { style: { flex: '0 0 160px' } },
1270
- h('label', { style: S.label }, '会话标识'),
1271
- h('div', {
1272
- style: {
1273
- ...S.input, display: 'flex', alignItems: 'center', gap: 6,
1274
- ...S.mono, fontSize: 11,
1275
- color: sessionIdIsFallback ? T.warn : T.labelPrimary,
1276
- },
1277
- },
1278
- sessionIdIsFallback
1279
- ? h('span', null, '(设置页无会话上下文)')
1280
- : h('span', { style: { wordBreak: 'break-all' } }, String(sessionId).slice(0, 20)),
1281
- ),
1282
- ),
1283
- ),
1284
- h('div', { style: S.hint },
1285
- '绑定后:本会话的 root 请求按该 profile 的 root 链走;' +
1286
- 'spawn 的子 Agent 按同 profile 的 coder/research 链走。'),
1287
- h('div', { style: { ...S.row, marginTop: 10 } },
1288
- h('button', {
1289
- style: S.btn('primary'), onClick: save,
1290
- disabled: busy || sessionIdIsFallback,
1291
- }, sessionIdIsFallback ? '请用会话标题旁的按钮' : '保存到会话变量'),
1292
- h('button', { style: S.btn(), onClick: runProbe, disabled: busy },
1293
- busy ? '测试中…' : '一键探针测试'),
1294
- ),
1295
- h('div', { style: { ...S.mono, color: T.labelSecondary, marginTop: 8, fontSize: 11 } },
1296
- `会话变量键:litellm-bridge/session-profile · sessionId=${sessionId}`),
1297
- saved && h('div', {
1298
- style: { marginTop: 9, fontSize: 12, color: saved.kind === 'ok' ? T.success : T.error },
1299
- }, safeText(saved.text)),
1300
- probe && h('div', {
1301
- style: { marginTop: 9, fontSize: 12, color: probe.kind === 'ok' ? T.success : T.error },
1302
- }, safeText(probe.text)),
1303
- ),
1304
-
1305
- // 【新增】这条会话实际会用哪条链 —— 让「有没有生效」可见、可验证
1306
- rt && h('div', { style: S.card },
1307
- h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '这条会话实际走哪条链'),
1308
- (() => {
1309
- const effProfile = rt.sessionProfile || rt.defaultProfile || '(默认)';
1310
- const heads = rt.chainHeads || {};
1311
- return h('div', null,
1312
- h('div', { style: { ...S.row, marginBottom: 8 } },
1313
- h('span', { style: S.badge(T.brand, tint(T.brand, 14)) },
1314
- `profile: ${safeText(effProfile)}`),
1315
- !rt.sessionProfile && h('span', { style: S.badge(T.warn, tint(T.warn, 14)) },
1316
- '未绑定,用默认 profile'),
1317
- ),
1318
- h('div', {
1319
- style: {
1320
- ...S.mono, fontSize: 11.5, lineHeight: 1.9,
1321
- background: T.bgLayer1, border: `1px solid ${T.borderL1}`,
1322
- borderRadius: 6, padding: '8px 10px',
1323
- },
1324
- },
1325
- h('div', null, `root → ${safeText(heads.root, '—')}`),
1326
- h('div', null, `coder → ${safeText(heads.coder, '—')}`),
1327
- h('div', null, `research → ${safeText(heads.research, '—')}`),
1328
- ),
1329
- h('div', { style: { ...S.hint, marginTop: 6 } },
1330
- '子 Agent 被改道到网关时,请求的 model 就是上面 coder / research 对应的链头;' +
1331
- '网关再按该链配置的 fallback 顺序重试。'),
1332
- );
1333
- })(),
1334
- ),
1335
-
1336
- // 已知会话列表(用于确认绑定是否真的写到了正确会话)
1337
- rt && Array.isArray(rt.knownSessions) && rt.knownSessions.length > 0 &&
1338
- h('div', { style: S.card },
1339
- h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '已观测到的会话'),
1340
- h('div', { style: { ...S.hint, marginBottom: 8 } },
1341
- '这些会话实际发出过模型请求,插件据此记录了它们的供应商与绑定。'),
1342
- rt.knownSessions.slice(0, 10).map((s, i) => h('div', {
1343
- key: i,
1344
- style: {
1345
- ...S.mono, fontSize: 11, padding: '5px 8px', marginBottom: 3,
1346
- borderRadius: 4, background: T.bgLayer1, border: `1px solid ${T.borderL1}`,
1347
- wordBreak: 'break-all',
1348
- },
1349
- },
1350
- h('span', { style: { opacity: 0.6 } }, `${String(s.sessionId).slice(0, 24)} `),
1351
- h('span', { style: S.badge() }, safeText(s.provider, '?')),
1352
- s.profile && h('span', { style: S.badge(T.brand, tint(T.brand, 12)) }, safeText(s.profile)),
1353
- )),
1354
- ),
1355
-
1356
- rt && h('div', { style: S.card },
1357
- h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '子代理路由覆盖'),
1358
- (() => {
1359
- const sp = rt.sessionProvider;
1360
- const isNative = sp && rt.nativeProviders.some((p) => sp.toLowerCase() === p || sp.toLowerCase().startsWith(p + '-'));
1361
- const isGw = sp && sp.toLowerCase().startsWith('litellm');
1362
- const verdict = !sp ? { t: '未知', c: S.warn, d: '尚未观察到本会话的模型请求,暂不覆盖(fail-open)' }
1363
- : isGw ? { t: '全部走网关', c: S.ok, d: '主 Agent 与子 Agent 均走网关链路,不做覆盖' }
1364
- : isNative ? { t: '子代理将改道网关', c: S.warn, d: `主 Agent 保留 ${sp}(消耗积分);子 Agent 强制走 ${rt.gatewayProvider}` }
1365
- : { t: '不覆盖', c: S.ok, d: `供应商 ${sp} 不在覆盖名单` };
1366
- return h('div', null,
1367
- h('div', { style: { marginBottom: 6 } },
1368
- h('span', { style: S.badge() }, `当前供应商: ${safeText(sp, '未知')}`),
1369
- h('span', { style: { color: verdict.c, fontWeight: 600 } }, verdict.t),
1370
- ),
1371
- h('div', { style: { fontSize: 12, opacity: 0.75, marginBottom: 6 } }, verdict.d),
1372
- h('div', { style: { ...S.mono, fontSize: 11, opacity: 0.55 } },
1373
- `覆盖名单: ${rt.nativeProviders.join(' / ')} · 目标: ${rt.gatewayProvider}`),
1374
- h('div', { style: { ...S.mono, fontSize: 11, opacity: 0.55, marginTop: 3 } },
1375
- `已评估 ${rt.stats.evaluated} 次 · 改道 ${rt.stats.overridden} 次 · 跳过 ${rt.stats.skipped} 次 · 降级 ${rt.stats.degraded} 次`),
1376
- );
1377
- })(),
1378
- ),
1379
-
1380
- h('div', { style: S.card },
1381
- h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '本会话最近降级记录'),
1382
- drops.length === 0
1383
- ? h('div', { style: { opacity: 0.5, fontSize: 12 } }, '暂无降级记录(或网关日志不含该字段)')
1384
- : drops.map((d, i) => h('div', { key: i, style: S.logRow(d.status === 'failure' ? 'failed' : 'fallback') },
1385
- h('span', { style: { ...S.mono } }, d.model),
1386
- h('span', { style: { ...S.mono, opacity: 0.6, marginLeft: 8 } },
1387
- d.fallback === 'yes' ? '触发降级' : '失败'),
1388
- d.error && h('div', { style: { ...S.mono, fontSize: 11, color: S.err, marginTop: 3 } }, d.error),
1389
- )),
1390
- ),
1391
-
1392
- h('div', { style: { ...S.mono, opacity: 0.5, fontSize: 11.5, lineHeight: 1.7 } },
1393
- h('div', null, '联动说明:本插件读写会话 profile 变量 + 子代理路由覆盖;'),
1394
- h('div', null, 'dsh-plugin-header-inject 读取该变量并注入 x-agent-type / x-session-profile。'),
1395
- h('div', null, '两插件通过会话变量解耦,互不依赖。'),
1396
- ),
1397
- );
1398
- }
1399
-
1400
- /**
1401
- * 创建**稳定引用**的 api 函数(同一个插件实例只创建一次)。
1402
- *
1403
- * 为什么必须要稳定:各 Tab 的 useEffect 依赖数组里都有 [api]。若 api 每次
1404
- * 渲染都变,就会 effect→setState→重渲染→新 api→再 effect 无限循环,
1405
- * React 表现为组件反复重挂载、页面空白。
1406
- */
1407
1171
  const apiCache = new WeakMap();
1408
1172
  function createStableApi(ctx) {
1409
1173
  const hit = apiCache.get(ctx);
@@ -1416,10 +1180,12 @@ window.__ModuleLoader__.load({
1416
1180
  // -----------------------------------------------------------------------
1417
1181
  // 【会话级入口】标题旁的小按钮 —— 直接给当前会话选降级链(profile)
1418
1182
  //
1419
- // 槽位:conversation.session.header.actions(list,scope: session)
1420
- // ownerProps 为空;组件通过标准 props 拿到 sessionId。
1421
- // 这是 dsh-llm-fallbacks 同款做法,replaceRisk: none,不动官方 UI。
1183
+ // 槽位:conversation.session.header.actions(会话标题旁那排胶囊)
1184
+ // —— 与 dsh-llm-fallbacks 的 SubagentRoleBadge 同槽位,实测渲染正常
1185
+ // (用户截图里的「未绑定」按钮就出现在这排)。
1422
1186
  //
1187
+ // 为什么不用 conversation.input.right:上一版挪过去后从未同步安装,
1188
+ // 属于未验证的倒退;本槽位已有实证可显示,保持不动。
1423
1189
  // 为什么不做进右下角模型选择器:
1424
1190
  // 那个槽位是 conversation.input.model(single,shadows-shipped-ui),
1425
1191
  // 注册即替换掉官方选择器,得不偿失。
@@ -1584,7 +1350,7 @@ window.__ModuleLoader__.load({
1584
1350
  // 主面板 —— 对齐 dsh-llm-fallbacks 的官方设置页语言:
1585
1351
  // 扁平页面(无卡片壳)· 顶部标签导航 · 页内分区 h2 + 每区独立 Save/Discard
1586
1352
  // -----------------------------------------------------------------------
1587
- function BridgePanel({ api, ctx }) {
1353
+ function BridgePanel({ api }) {
1588
1354
  const [tab, setTab] = useState('gateway');
1589
1355
  const [state, setState] = useState(null);
1590
1356
 
@@ -1596,7 +1362,6 @@ window.__ModuleLoader__.load({
1596
1362
  ['gateway', '网关设置'],
1597
1363
  ['routes', '路由配置'],
1598
1364
  ['logs', '日志面板'],
1599
- ['session', '当前会话'],
1600
1365
  ];
1601
1366
 
1602
1367
  return h('div', { style: S.page },
@@ -1611,7 +1376,6 @@ window.__ModuleLoader__.load({
1611
1376
  tab === 'gateway' && h(Guard, { title: '网关设置' }, () => h(GatewayTab, { api, state, setState })),
1612
1377
  tab === 'routes' && h(Guard, { title: '路由配置' }, () => h(RouteTab, { api, state, setState })),
1613
1378
  tab === 'logs' && h(Guard, { title: '日志面板' }, () => h(LogsTab, { api, state })),
1614
- tab === 'session' && h(Guard, { title: '当前会话' }, () => h(SessionTab, { api, ctx, state, setState })),
1615
1379
  ),
1616
1380
  );
1617
1381
  }
@@ -1655,7 +1419,7 @@ window.__ModuleLoader__.load({
1655
1419
  // 会导致「effect -> setState -> 重渲染 -> 新 api -> 再跑 effect」死循环,
1656
1420
  // 表现就是整页空白。
1657
1421
  const api = createStableApi(ctx);
1658
- const Panel = () => h(BridgePanel, { api, ctx });
1422
+ const Panel = () => h(BridgePanel, { api });
1659
1423
 
1660
1424
  // 【必须用 ctx.effect 包住注册】
1661
1425
  // client 侧模板要求 apply 通过 ctx.effect 注册每个资源并返回清理函数。
@@ -1680,19 +1444,30 @@ window.__ModuleLoader__.load({
1680
1444
  });
1681
1445
 
1682
1446
  // ---- (2) 会话标题旁的小按钮:直接为当前会话选降级链 ----
1683
- // 槽位是 list + scope:session,组件通过标准 props 拿到 sessionId。
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
+
1684
1463
  const disposeHeader = ctx.slots.inject('conversation.session.header.actions', () => {
1685
1464
  return ctx.slots.register(
1686
1465
  {
1687
1466
  name: 'conversation.session.header.actions',
1688
1467
  id: NS,
1689
- order: -5,
1690
- label: () => '降级链',
1468
+ order: 4,
1691
1469
  },
1692
- (props) => h(SessionProfileButton, {
1693
- api,
1694
- sessionId: props?.sessionId ?? props?.session?.id ?? 'current',
1695
- }),
1470
+ HeaderAction,
1696
1471
  );
1697
1472
  });
1698
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)],
@@ -25,6 +25,7 @@ import { createConnection } from 'node:net';
25
25
  import { existsSync } from 'node:fs';
26
26
  import { dirname, join, resolve } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
+ import { tmpdir } from 'node:os';
28
29
 
29
30
  /** 默认网关地址。 */
30
31
  export const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:4000';
@@ -157,14 +158,14 @@ export function findGatewayDir(explicitDir) {
157
158
  for (let i = 0; i < 6; i++) {
158
159
  cur = resolve(cur, '..');
159
160
  push(join(cur, 'litellm-gateway'));
160
- push(join(cur, 'dsh-fallback-auto-pool-arch', 'litellm-gateway'));
161
+ push(join(cur, 'dsh-plugin-litellm-bridge', 'litellm-gateway'));
161
162
  }
162
163
  } catch {
163
164
  /* ignore */
164
165
  }
165
166
 
166
167
  // 4) 常见绝对路径兜底
167
- push('D:/AI sandbox/dsh-sandbox/dsh-fallback-auto-pool-arch/litellm-gateway');
168
+ push('D:/AI sandbox/dsh-sandbox/dsh-plugin-litellm-bridge/litellm-gateway');
168
169
 
169
170
  for (const c of candidates) {
170
171
  try {
@@ -267,7 +268,13 @@ export class GatewaySupervisor {
267
268
  '--port', String(port),
268
269
  ],
269
270
  {
270
- cwd: dir,
271
+ // 【关键】cwd 绝不能是网关目录 / 工作区目录。
272
+ // 旧版本传 cwd: dir,配合 start-gateway.sh 里的 `cd "$SCRIPT_DIR"`,
273
+ // 会让网关进程把 CWD 钉在工作区里;Windows 随即拒绝对整个工作区目录树
274
+ // 做 rename/move(EPERM),用户改文件夹名时看到「被占用」。
275
+ // 脚本内所有路径都是 $SCRIPT_DIR 绝对形式,不需要任何特定 CWD;
276
+ // 用系统临时目录既安全又不会锁住工作区。
277
+ cwd: tmpdir(),
271
278
  env: {
272
279
  ...process.env,
273
280
  ...(this.envFile ? { LITELLM_BRIDGE_ENV: this.envFile } : {}),
@@ -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,222 @@
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
+ import { homedir } from 'node:os';
41
+ import { fileURLToPath } from 'node:url';
42
+
43
+ /**
44
+ * 定位 profile 的 cordis.patch.yml。
45
+ *
46
+ * 【曾经的致命 bug · 2026-11-10 修复】
47
+ * 旧代码用 `new URL('.', import.meta.url).pathname` 反推模块目录。这在 Windows 上
48
+ * 返回的是 **URL 形态**的路径,而不是文件系统路径:
49
+ *
50
+ * new URL('.', import.meta.url).pathname
51
+ * → "/D:/AI%20agent%20cache/dsh/profiles/desktop/node_modules/.../lib/"
52
+ * ^^^^ 前导斜杠 ^^^^ 空格被百分号编码
53
+ *
54
+ * existsSync("/D:/AI%20agent...") 恒为 false(msys 风格盘符 + %20 都不合法),
55
+ * 于是本函数在 **DSH 宿主进程里永远返回 null**,patchMap 恒空,baseURL 一条都补不上,
56
+ * 用户持续看到「供应商 agnes 缺少 API 地址」。
57
+ *
58
+ * 为什么我早期的自测没发现:当时是用 shell 里带着 `DSH_PROFILE_DIR` 环境变量跑的,
59
+ * 命中了第 1 个候选分支就提前 return,兜底分支的 bug 被掩盖。
60
+ *
61
+ * 修法:一律走 `fileURLToPath()`(它负责 decodeURIComponent + 盘符规范化),
62
+ * 并把「用户主目录下的 .dsh」作为独立候选 —— 不依赖任何可选环境变量。
63
+ */
64
+ export function profilePatchPath() {
65
+ const candidates = [];
66
+
67
+ // 1) 显式 profile 目录(部分宿主版本会注入)
68
+ const profileDir = process.env.DSH_PROFILE_DIR;
69
+ if (profileDir) candidates.push(join(profileDir, 'cordis.patch.yml'));
70
+
71
+ // 2) DSH_HOME —— 可能是 .dsh 根,也可能直接是 profile 目录
72
+ const home = process.env.DSH_HOME;
73
+ if (home) {
74
+ candidates.push(join(home, 'cordis.patch.yml'));
75
+ candidates.push(join(home, 'profiles', 'desktop', 'cordis.patch.yml'));
76
+ const profileName = process.env.DSH_PROFILE;
77
+ if (profileName) candidates.push(join(home, 'profiles', profileName, 'cordis.patch.yml'));
78
+ }
79
+
80
+ // 3) 从本模块位置反推:<profile>/node_modules/<pkg>/<pkg>/lib/patch-providers.js
81
+ // 必须用 fileURLToPath —— 见上方 bug 说明。
82
+ try {
83
+ const here = fileURLToPath(new URL('.', import.meta.url)).replace(/\\/g, '/');
84
+ let idx = here.indexOf('/node_modules/');
85
+ while (idx > 0) {
86
+ const root = here.slice(0, idx);
87
+ candidates.push(join(root, 'cordis.patch.yml'));
88
+ candidates.push(join(root, 'profiles', 'desktop', 'cordis.patch.yml'));
89
+ idx = here.indexOf('/node_modules/', idx + 1);
90
+ }
91
+ } catch { /* ignore */ }
92
+
93
+ // 4) 用户主目录兜底:只依赖 homedir(),不依赖任何可选环境变量
94
+ try {
95
+ const h = homedir();
96
+ candidates.push(join(h, '.dsh', 'profiles', 'desktop', 'cordis.patch.yml'));
97
+ const profileName = process.env.DSH_PROFILE;
98
+ if (profileName) {
99
+ candidates.push(join(h, '.dsh', 'profiles', profileName, 'cordis.patch.yml'));
100
+ }
101
+ } catch { /* ignore */ }
102
+
103
+ for (const c of candidates) {
104
+ try {
105
+ if (c && existsSync(c)) return c;
106
+ } catch { /* ignore */ }
107
+ }
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * 解析 YAML 里的 providers 段。
113
+ *
114
+ * 只认这个固定结构(DSH 自己生成的,缩进稳定):
115
+ *
116
+ * providers:
117
+ * <providerId>:
118
+ * baseURL: https://...
119
+ * apiKeyEnv: SOME_API_KEY
120
+ * displayName: 显示名
121
+ * models:
122
+ * - id: model-id
123
+ * name: 显示名
124
+ *
125
+ * 返回 Map<providerId, { baseURL, apiKeyEnv, displayName, models }>
126
+ */
127
+ export function parseProvidersFromPatch(text) {
128
+ const out = new Map();
129
+ if (typeof text !== 'string' || !text) return out;
130
+
131
+ const lines = text.split('\n');
132
+ /** 当前所在的 providers: 块的缩进 */
133
+ let providersIndent = -1;
134
+ let cur = null;
135
+ /** 当前 provider 的字段缩进(provider 行的缩进 + 2) */
136
+ let fieldIndent = -1;
137
+
138
+ for (let i = 0; i < lines.length; i++) {
139
+ const raw = lines[i];
140
+ const line = raw.replace(/\r$/, '');
141
+ if (!line.trim() || line.trim().startsWith('#')) continue;
142
+
143
+ const indent = line.match(/^\s*/)[0].length;
144
+
145
+ // 进入/离开 providers: 块
146
+ if (/^\s*providers:\s*$/.test(line)) {
147
+ providersIndent = indent;
148
+ cur = null;
149
+ fieldIndent = -1;
150
+ continue;
151
+ }
152
+ if (providersIndent >= 0 && indent <= providersIndent) {
153
+ // 回到同级或更外层,说明 providers 块结束
154
+ providersIndent = -1;
155
+ cur = null;
156
+ fieldIndent = -1;
157
+ }
158
+ if (providersIndent < 0) continue;
159
+
160
+ // provider 定义行:比 providers 深一层,且以 `key:` 结尾
161
+ const mProvider = line.match(/^(\s+)([A-Za-z0-9._-]+):\s*$/);
162
+ if (mProvider && indent === providersIndent + 2) {
163
+ cur = {
164
+ id: mProvider[2],
165
+ baseURL: null,
166
+ apiKeyEnv: null,
167
+ displayName: null,
168
+ models: [],
169
+ settingsNs: null,
170
+ };
171
+ fieldIndent = indent + 2;
172
+ out.set(cur.id, cur);
173
+ continue;
174
+ }
175
+ if (!cur) continue;
176
+
177
+ // 【关键】只解析 provider **直属层级**的标量字段。
178
+ // 之前漏了这个判断,导致 models 列表项里的 `name:` 行
179
+ // (缩进更深)把 provider 的 displayName 覆盖成了模型名。
180
+ // 实测:agnes.displayName 被写成 "agnes-image-2.5-flash"。
181
+ if (indent === fieldIndent) {
182
+ const mScalar = line.match(/^\s+([A-Za-z_][\w]*):\s*(.+?)\s*$/);
183
+ if (mScalar) {
184
+ const key = mScalar[1];
185
+ let val = mScalar[2];
186
+ val = val.replace(/\s+#.*$/, '').trim();
187
+ val = val.replace(/^['"]|['"]$/g, '');
188
+ if (key === 'baseURL' || key === 'baseUrl' || key === 'api_base') cur.baseURL = val;
189
+ else if (key === 'apiKeyEnv' || key === 'api_key_env') cur.apiKeyEnv = val;
190
+ else if (key === 'displayName') cur.displayName = val;
191
+ else if (key === 'settingsNs') cur.settingsNs = val;
192
+ continue;
193
+ }
194
+ }
195
+
196
+ // models 列表项:`- id: xxx`(缩进比字段更深)
197
+ if (indent > fieldIndent) {
198
+ const mModelId = line.match(/^\s+-\s+id:\s*(.+?)\s*$/);
199
+ if (mModelId) {
200
+ const id = mModelId[1].replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '');
201
+ if (id) cur.models.push({ id, name: id });
202
+ }
203
+ }
204
+ }
205
+
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * 读取并解析 profile 的 providers 配置。
211
+ * @returns {Promise<Map<string, object>>} 失败返回空 Map(fail-soft)
212
+ */
213
+ export async function loadPatchProviders() {
214
+ const path = profilePatchPath();
215
+ if (!path) return new Map();
216
+ try {
217
+ const text = await readFile(path, 'utf8');
218
+ return parseProvidersFromPatch(text);
219
+ } catch {
220
+ return new Map();
221
+ }
222
+ }
@@ -53,5 +53,5 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "repository": "https://github.com/heixiua/dsh-fallback-auto-pool-arch"
56
+ "repository": "https://github.com/heixiua/dsh-plugin-litellm-bridge"
57
57
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-litellm-bridge",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
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",
@@ -16,12 +16,12 @@
16
16
  "author": "heixiua",
17
17
  "repository": {
18
18
  "type": "git",
19
- "url": "git+https://github.com/heixiua/dsh-fallback-auto-pool-arch.git"
19
+ "url": "git+https://github.com/heixiua/dsh-plugin-litellm-bridge.git"
20
20
  },
21
21
  "bugs": {
22
- "url": "https://github.com/heixiua/dsh-fallback-auto-pool-arch/issues"
22
+ "url": "https://github.com/heixiua/dsh-plugin-litellm-bridge/issues"
23
23
  },
24
- "homepage": "https://github.com/heixiua/dsh-fallback-auto-pool-arch#readme",
24
+ "homepage": "https://github.com/heixiua/dsh-plugin-litellm-bridge#readme",
25
25
  "main": "./dsh-plugin-litellm-bridge/lib/index.js",
26
26
  "icon": "./dsh-plugin-litellm-bridge/icon.svg",
27
27
  "exports": {