dsh-plugin-litellm-bridge 2.0.2 → 2.0.4

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.
@@ -30,7 +30,32 @@ window.__ModuleLoader__.load({
30
30
 
31
31
  const NS = 'litellm-bridge';
32
32
  const AGENT_TYPES = ['root', 'coder', 'research'];
33
- const PROFILE_IDS = ['profile-a', 'profile-b'];
33
+
34
+ // -----------------------------------------------------------------------
35
+ // 【渲染安全】把任意值转成可安全放进 React 子节点的字符串。
36
+ //
37
+ // 为什么必须有:React 直接渲染对象会抛
38
+ // "Objects are not valid as a React child"
39
+ // 而我们的数据全部来自宿主 RPC —— 错误对象、undefined、嵌套结构都可能出现。
40
+ // 一次未捕获的渲染异常 = 整页空白(用户看到的就是这个)。
41
+ // -----------------------------------------------------------------------
42
+ function safeText(v, fallback = '') {
43
+ if (v === null || v === undefined) return fallback;
44
+ if (typeof v === 'string') return v;
45
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
46
+ if (v instanceof Error) return v.message || String(v);
47
+ try {
48
+ const s = JSON.stringify(v);
49
+ return typeof s === 'string' ? s : fallback;
50
+ } catch {
51
+ return String(v) || fallback;
52
+ }
53
+ }
54
+ /** 数字兜底:非有限数显示 0,避免 "undefined/undefined"。 */
55
+ function num(v) {
56
+ const n = Number(v);
57
+ return Number.isFinite(n) ? n : 0;
58
+ }
34
59
 
35
60
  // -----------------------------------------------------------------------
36
61
  // 样式(内联,避免依赖外部样式表)
@@ -254,7 +279,7 @@ window.__ModuleLoader__.load({
254
279
  },
255
280
  }),
256
281
  h('span', { style: { fontWeight: 600 } }, 'LiteLLM 网关'),
257
- h('span', { style: S.badge(tone === 'ok' ? T.success : tone === 'error' ? T.error : T.warn) }, label),
282
+ h('span', { style: S.badge(tone === 'ok' ? T.success : tone === 'error' ? T.error : T.warn) }, safeText(label)),
258
283
  st?.owned && h('span', { style: S.badge(T.brand, tint(T.brand, 14)) }, '本插件托管'),
259
284
  !st?.owned && st?.alive && h('span', { style: S.badge() }, '外部运行 · 不接管'),
260
285
  h('span', { style: { marginLeft: 'auto', display: 'flex', gap: 6 } },
@@ -265,11 +290,18 @@ window.__ModuleLoader__.load({
265
290
  ),
266
291
  ),
267
292
  h('div', { style: { padding: '0 12px 10px', ...S.mono, fontSize: 11, color: T.labelSecondary, lineHeight: 1.7 } },
268
- h('div', null, `地址:${st?.url || '—'}`),
269
- h('div', null, `托管策略:${st?.ownership || '—'}${st?.autoStart === false ? '(autoStartGateway 已关闭)' : ''}`),
270
- st?.lastError && h('div', { style: { color: T.error } }, `最近错误:${st.lastError}`),
293
+ // 【必须 safeText】这些值来自宿主 RPC,可能是对象 / undefined / Error。
294
+ // 直接插进子节点会触发 React #62(Objects are not valid as a React child),
295
+ // 整个设置页白屏 —— 实测「点测试连通性后白屏」就是这个原因之一。
296
+ h('div', null, `地址:${safeText(st?.url, '—')}`),
297
+ h('div', null,
298
+ `托管策略:${safeText(st?.ownership, '—')}` +
299
+ (st?.autoStart === false ? '(autoStartGateway 已关闭)' : '')),
300
+ st?.lastError && h('div', { style: { color: T.error } },
301
+ `最近错误:${safeText(st.lastError)}`),
271
302
  !st?.lastError && st?.lastExit?.abnormal &&
272
- h('div', { style: { color: T.error } }, `异常退出记录:code=${st.lastExit.code}`),
303
+ h('div', { style: { color: T.error } },
304
+ `异常退出记录:code=${safeText(st.lastExit.code, '?')}`),
273
305
  ),
274
306
  );
275
307
  }
@@ -313,7 +345,7 @@ window.__ModuleLoader__.load({
313
345
  h('select', {
314
346
  style: { ...S.inputAuto, minWidth: 110 },
315
347
  value: pid, onChange: (e) => setPid(e.target.value),
316
- }, ...(profileIds || []).map((p) => h('option', { key: p, value: p }, p))),
348
+ }, ...(profileIds || []).map((p) => h('option', { key: p, value: p }, safeText(p)))),
317
349
  h('select', {
318
350
  style: { ...S.inputAuto, minWidth: 110 },
319
351
  value: agentType, onChange: (e) => setAgentType(e.target.value),
@@ -348,7 +380,7 @@ window.__ModuleLoader__.load({
348
380
  },
349
381
  h('span', { style: S.badge() }, String(i + 1)),
350
382
  i === 0 && h('span', { style: S.badge(T.brand, tint(T.brand, 14)) }, '首选'),
351
- h('span', { style: { ...S.mono, flex: 1, wordBreak: 'break-all' } }, m),
383
+ h('span', { style: { ...S.mono, flex: 1, wordBreak: 'break-all' } }, safeText(m)),
352
384
  row && h('span', { style: { ...S.mono, fontSize: 11, color: row.ok ? T.success : T.error } },
353
385
  row.ok ? '✓' : '✗'),
354
386
  row && row.latencyMs != null && h('span', {
@@ -361,7 +393,7 @@ window.__ModuleLoader__.load({
361
393
  }),
362
394
  ),
363
395
 
364
- err && h('div', { style: S.notice('error') }, err),
396
+ err && h('div', { style: S.notice('error') }, safeText(err)),
365
397
 
366
398
  result && h('div', null,
367
399
  h('div', { style: { ...S.row, marginBottom: 8 } },
@@ -432,19 +464,28 @@ window.__ModuleLoader__.load({
432
464
 
433
465
  test && h('div', { style: S.card },
434
466
  h('div', { style: { marginBottom: 8, fontWeight: 600 } },
435
- test.ok ? h('span', { style: S.ok }, `✓ 连通正常(${test.passed}/${test.total})`)
436
- : h('span', { style: S.err }, `✗ 连通异常(${test.passed}/${test.total})`),
467
+ test.ok
468
+ ? h('span', { style: S.ok },
469
+ `✓ 连通正常(${num(test.passed)}/${num(test.total)})`)
470
+ : h('span', { style: S.err },
471
+ `✗ 连通异常(${num(test.passed)}/${num(test.total)})`),
437
472
  ),
438
- (test.steps || []).map((s, i) => h('div', {
439
- key: i, style: { display: 'flex', gap: 8, padding: '4px 0', alignItems: 'baseline' },
473
+ // 【防御】steps 里任何字段都可能是非字符串(对象/undefined/错误实例)。
474
+ // 直接把对象塞进 React 子节点会抛 "Objects are not valid as a React child",
475
+ // 整个页面变空白 —— 这正是「点测试连通性后白屏」的直接原因。
476
+ (Array.isArray(test.steps) ? test.steps : []).map((s, i) => h('div', {
477
+ key: i, style: { display: 'flex', gap: 8, padding: '4px 0', alignItems: 'baseline', flexWrap: 'wrap' },
440
478
  },
441
- h('span', { style: s.ok ? S.ok : S.err }, s.ok ? '✓' : '✗'),
442
- h('span', { style: { minWidth: 110 } }, s.name),
443
- h('span', { style: { ...S.mono, opacity: 0.6 } }, s.path),
444
- h('span', { style: { ...S.mono, opacity: 0.85, marginLeft: 'auto', textAlign: 'right' } }, s.detail),
479
+ h('span', { style: s?.ok ? S.ok : S.err }, s?.ok ? '✓' : '✗'),
480
+ h('span', { style: { minWidth: 110 } }, safeText(s?.name)),
481
+ h('span', { style: { ...S.mono, opacity: 0.6 } }, safeText(s?.path)),
482
+ h('span', { style: { ...S.mono, opacity: 0.85, marginLeft: 'auto', textAlign: 'right', wordBreak: 'break-all' } },
483
+ safeText(s?.detail)),
445
484
  )),
485
+ !Array.isArray(test.steps) && safeText(test.error) &&
486
+ h('div', { style: S.notice('error') }, safeText(test.error)),
446
487
  h('div', { style: { marginTop: 8, ...S.mono, opacity: 0.55 } },
447
- `key 来源:${test.keySource || '—'}`),
488
+ `key 来源:${safeText(test.keySource, '—')}`),
448
489
  ),
449
490
 
450
491
  state?.statePath && h('div', { style: { ...S.mono, opacity: 0.45, marginTop: 4 } },
@@ -483,12 +524,33 @@ window.__ModuleLoader__.load({
483
524
  const [poolBusy, setPoolBusy] = useState(false);
484
525
  const [newId, setNewId] = useState('');
485
526
  const [showNew, setShowNew] = useState(false);
527
+ /** 【必须】加载失败要显示原因,否则永远停在「加载中…」 */
528
+ const [loadErr, setLoadErr] = useState(null);
486
529
  const dragRef = useRef(null);
487
530
 
488
531
  useEffect(() => {
489
532
  if (state?.routes) setDraft(JSON.parse(JSON.stringify(state.routes)));
490
533
  }, [state?.routes]);
491
534
 
535
+ // 【兜底加载】不依赖父组件的 state。
536
+ // 之前 draft 只由 state.routes 驱动:一旦 gateway/get 失败(例如路由没注册),
537
+ // state 永远是 null,这一页就永久停在「加载中…」——用户看到的正是这个。
538
+ // 现在自己拉一次 routes/get,成功就填 draft,失败就显示错误。
539
+ useEffect(() => {
540
+ let alive = true;
541
+ (async () => {
542
+ const r = await api('routes/get');
543
+ if (!alive) return;
544
+ if (r.ok && r.data) {
545
+ setDraft(JSON.parse(JSON.stringify(r.data)));
546
+ setLoadErr(null);
547
+ } else {
548
+ setLoadErr(r.error || '宿主没有返回 profile 配置');
549
+ }
550
+ })();
551
+ return () => { alive = false; };
552
+ }, [api]);
553
+
492
554
  // 【需求2】模型池来自 DSH 设置-模型
493
555
  const loadPool = useCallback(async () => {
494
556
  setPoolBusy(true);
@@ -508,7 +570,20 @@ window.__ModuleLoader__.load({
508
570
  }
509
571
  }, [draft, pid]);
510
572
 
511
- if (!draft) return h('div', { style: { opacity: 0.6 } }, '加载中…');
573
+ // 加载失败时给出可读原因 + 重试,而不是无限「加载中…」
574
+ if (!draft) {
575
+ return h('div', null,
576
+ loadErr
577
+ ? h('div', { style: S.notice('error') },
578
+ h('div', { style: { fontWeight: 600, marginBottom: 4 } }, '读取路由配置失败'),
579
+ h('div', { style: { ...S.mono, fontSize: 11.5, wordBreak: 'break-all' } }, String(loadErr)),
580
+ h('div', { style: { ...S.hint, marginTop: 6 } },
581
+ '这通常意味着插件宿主半没有响应该请求。请重启 DSH 后再试;' +
582
+ '若持续出现,请把这条错误发给开发者。'),
583
+ )
584
+ : h('div', { style: { opacity: 0.6 } }, '加载中…'),
585
+ );
586
+ }
512
587
  const prof = draft[pid];
513
588
  if (!prof) return h('div', { style: { opacity: 0.6 } }, '没有可用的 profile');
514
589
 
@@ -672,7 +747,7 @@ window.__ModuleLoader__.load({
672
747
 
673
748
  return h('div', { key: at },
674
749
  h('div', { style: S.h2Row },
675
- h('h2', { style: S.h2 }, groupLabel),
750
+ h('h2', { style: S.h2 }, safeText(groupLabel)),
676
751
  isSub && h('span', { style: S.badge() }, '仅子 Agent 使用'),
677
752
  h('span', { style: { ...S.mono, fontSize: 11, color: T.labelSecondary } },
678
753
  chainLen ? `链长 ${chainLen}` : '未配置'),
@@ -863,11 +938,11 @@ window.__ModuleLoader__.load({
863
938
  h('div', { style: { ...S.row, marginBottom: 10 } },
864
939
  h('select', { style: { ...S.input, width: 'auto' }, value: fAgent, onChange: (e) => setFAgent(e.target.value) },
865
940
  h('option', { value: '' }, '全部 agent-type'),
866
- agents.map((a) => h('option', { key: a, value: a }, a)),
941
+ agents.map((a) => h('option', { key: a, value: a }, safeText(a))),
867
942
  ),
868
943
  h('select', { style: { ...S.input, width: 'auto' }, value: fProfile, onChange: (e) => setFProfile(e.target.value) },
869
944
  h('option', { value: '' }, '全部 profile'),
870
- profiles.map((p) => h('option', { key: p, value: p }, p)),
945
+ profiles.map((p) => h('option', { key: p, value: p }, safeText(p))),
871
946
  ),
872
947
  h('span', { style: { marginLeft: 'auto', ...S.mono, fontSize: 11, color: T.labelSecondary } },
873
948
  `显示 ${shown.length} / 最多 ${limit} 条`),
@@ -896,9 +971,9 @@ window.__ModuleLoader__.load({
896
971
  l.time ? String(l.time).replace('T', ' ').slice(0, 19) : '—'),
897
972
  isGwEvent
898
973
  ? h('span', { style: S.badge(T.brand, tint(T.brand, 14)) }, 'gateway')
899
- : h('span', { style: S.badge() }, l.agentType),
900
- !isGwEvent && h('span', { style: S.badge() }, l.profile),
901
- h('span', { style: { ...S.mono, fontWeight: 600 } }, l.model),
974
+ : h('span', { style: S.badge() }, safeText(l.agentType, '-')),
975
+ !isGwEvent && h('span', { style: S.badge() }, safeText(l.profile, '-')),
976
+ h('span', { style: { ...S.mono, fontWeight: 600 } }, safeText(l.model)),
902
977
  l.source === 'local' && !isGwEvent && h('span', { style: S.badge(T.brand, tint(T.brand, 14)) }, 'local'),
903
978
  k === 'fallback' && h('span', { style: S.badge(T.warn, tint(T.warn, 14)) }, 'FALLBACK'),
904
979
  k === 'failed' && h('span', { style: S.badge(T.error, tint(T.error, 14)) }, '失败'),
@@ -986,7 +1061,7 @@ window.__ModuleLoader__.load({
986
1061
  style: S.input, value: profile, onChange: (e) => setProfile(e.target.value),
987
1062
  },
988
1063
  h('option', { value: '' }, '(不绑定)'),
989
- profileIds.map((p) => h('option', { key: p, value: p }, p)),
1064
+ profileIds.map((p) => h('option', { key: p, value: p }, safeText(p))),
990
1065
  ),
991
1066
  h('div', { style: S.hint },
992
1067
  '绑定后:本会话的 root 请求按该 profile 的 root 链走;spawn 的子 Agent 按同 profile 的 coder/research 链走。'),
@@ -626,73 +626,112 @@ export function apply(ctx, config = {}) {
626
626
  // HTTP 路由(客户端 RPC 入口)
627
627
  //
628
628
  // 前缀刻意不是 /api(那是 dsh-api-gateway 的领地,实测 404)。
629
+ //
630
+ // 【致命坑 · 必须用 ctx.inject 延迟注册】
631
+ // 之前写成:
632
+ // const webServer = ctx.get('webServer'); // 一次性同步取值
633
+ // if (webServer) webServer.register({...})
634
+ // 因为 inject 已清空(避免 pending/failed),apply 可能在 webServer
635
+ // 就绪**之前**就跑完 —— 此时 ctx.get 返回 undefined,
636
+ // 整个 if 被跳过,路由**静默**没有注册。
637
+ // 现象:UI 全部能渲染(client 半独立生效),但每一次 RPC 都失败:
638
+ // · 网关状态卡的地址/托管策略显示 "—"
639
+ // · 一键探针的 profile 下拉为空
640
+ // · 路由配置卡在"加载中…"
641
+ // 实测指纹:GET /plugins/dsh-plugin-litellm-bridge → 404,
642
+ // POST → 405(DSH 对未知路径 POST 的默认应答)
643
+ // 对照能正常工作的 dsh-connect-trae:
644
+ // ctx.inject(["webServer"], (webCtx) => registerRoute(webCtx, ...))
645
+ // —— 它把注册**推迟到 webServer 就绪时**执行,这是正确写法。
629
646
  // -------------------------------------------------------------------------
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
-
647
+ const registerRoute = (webCtx) => {
648
+ const dispose = webCtx.webServer.register({
649
+ kind: 'prefix',
650
+ path: ROUTE_BASE,
651
+ handler: async (req, res) => {
652
+ const send = (status, payload) => {
657
653
  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
- }
654
+ const body = JSON.stringify(payload);
655
+ res.writeHead(status, {
656
+ 'Content-Type': 'application/json; charset=utf-8',
657
+ 'Cache-Control': 'no-store',
658
+ });
659
+ res.end(body);
660
+ } catch (err) {
661
+ try { res.destroy?.(err); } catch { /* ignore */ }
662
+ }
663
+ };
664
+
665
+ try {
666
+ if (req.method !== 'POST') {
667
+ res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json' });
668
+ res.end(JSON.stringify({ ok: false, error: '只接受 POST' }));
669
+ return;
670
+ }
663
671
 
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);
672
+ const urlPath = String(req.url || '').split('?')[0];
673
+ const tail = urlPath.startsWith(ROUTE_BASE)
674
+ ? urlPath.slice(ROUTE_BASE.length).replace(/^\/+/, '')
675
+ : '';
676
+
677
+ const chunks = [];
678
+ let size = 0;
679
+ for await (const chunk of req) {
680
+ size += chunk.length;
681
+ if (size > 1024 * 1024) {
682
+ send(413, { ok: false, error: '请求体过大' });
683
+ return;
678
684
  }
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) });
685
+ chunks.push(chunk);
689
686
  }
690
- },
691
- }),
692
- );
687
+ const raw = Buffer.concat(chunks).toString('utf8');
688
+ let payload = {};
689
+ try { payload = raw ? JSON.parse(raw) : {}; } catch { payload = {}; }
690
+
691
+ let action = tail;
692
+ if (!action && payload?.action) action = String(payload.action);
693
+ const args = payload?.args ?? payload ?? {};
694
+ send(200, await handle(action, args));
695
+ } catch (err) {
696
+ send(200, { ok: false, error: String(err?.message || err) });
697
+ }
698
+ },
699
+ });
693
700
  ctx.logger?.info?.(`[litellm-bridge] 已注册 RPC 路由 ${ROUTE_BASE}`);
694
- } else {
695
- ctx.logger?.warn?.('[litellm-bridge] webServer 不可用,UI 将无法访问宿主能力');
701
+ return dispose;
702
+ };
703
+
704
+ // 首选:等服务就绪后注册(正确语义)
705
+ let routeRegistered = false;
706
+ if (typeof ctx.inject === 'function') {
707
+ try {
708
+ ctx.inject(['webServer'], (webCtx) => {
709
+ if (routeRegistered) return () => {};
710
+ routeRegistered = true;
711
+ const dispose = registerRoute(webCtx);
712
+ return () => { try { dispose?.(); } catch { /* ignore */ } };
713
+ });
714
+ } catch (err) {
715
+ ctx.logger?.warn?.(`[litellm-bridge] ctx.inject 注册失败: ${err?.message || err}`);
716
+ }
717
+ }
718
+
719
+ // 兜底:个别宿主版本里 inject 回调不会触发(或 webServer 早已就绪),
720
+ // 那就同步注册一次。routeRegistered 保证只有一条路径生效。
721
+ if (!routeRegistered) {
722
+ let ws;
723
+ try {
724
+ ws = typeof ctx.get === 'function' ? ctx.get('webServer') : ctx.webServer;
725
+ } catch { ws = undefined; }
726
+ if (ws && typeof ws.register === 'function') {
727
+ routeRegistered = true;
728
+ ctx.effect?.(() => registerRoute({ webServer: ws }));
729
+ }
730
+ }
731
+ if (!routeRegistered) {
732
+ ctx.logger?.warn?.(
733
+ '[litellm-bridge] webServer 尚未就绪,已通过 ctx.inject 等待其回调;届时会自动注册路由',
734
+ );
696
735
  }
697
736
 
698
737
  // -------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-litellm-bridge",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
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",