dsh-plugin-litellm-bridge 2.0.1 → 2.0.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.
@@ -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
  // 样式(内联,避免依赖外部样式表)
@@ -432,19 +457,28 @@ window.__ModuleLoader__.load({
432
457
 
433
458
  test && h('div', { style: S.card },
434
459
  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})`),
460
+ test.ok
461
+ ? h('span', { style: S.ok },
462
+ `✓ 连通正常(${num(test.passed)}/${num(test.total)})`)
463
+ : h('span', { style: S.err },
464
+ `✗ 连通异常(${num(test.passed)}/${num(test.total)})`),
437
465
  ),
438
- (test.steps || []).map((s, i) => h('div', {
439
- key: i, style: { display: 'flex', gap: 8, padding: '4px 0', alignItems: 'baseline' },
466
+ // 【防御】steps 里任何字段都可能是非字符串(对象/undefined/错误实例)。
467
+ // 直接把对象塞进 React 子节点会抛 "Objects are not valid as a React child",
468
+ // 整个页面变空白 —— 这正是「点测试连通性后白屏」的直接原因。
469
+ (Array.isArray(test.steps) ? test.steps : []).map((s, i) => h('div', {
470
+ key: i, style: { display: 'flex', gap: 8, padding: '4px 0', alignItems: 'baseline', flexWrap: 'wrap' },
440
471
  },
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),
472
+ h('span', { style: s?.ok ? S.ok : S.err }, s?.ok ? '✓' : '✗'),
473
+ h('span', { style: { minWidth: 110 } }, safeText(s?.name)),
474
+ h('span', { style: { ...S.mono, opacity: 0.6 } }, safeText(s?.path)),
475
+ h('span', { style: { ...S.mono, opacity: 0.85, marginLeft: 'auto', textAlign: 'right', wordBreak: 'break-all' } },
476
+ safeText(s?.detail)),
445
477
  )),
478
+ !Array.isArray(test.steps) && safeText(test.error) &&
479
+ h('div', { style: S.notice('error') }, safeText(test.error)),
446
480
  h('div', { style: { marginTop: 8, ...S.mono, opacity: 0.55 } },
447
- `key 来源:${test.keySource || '—'}`),
481
+ `key 来源:${safeText(test.keySource, '—')}`),
448
482
  ),
449
483
 
450
484
  state?.statePath && h('div', { style: { ...S.mono, opacity: 0.45, marginTop: 4 } },
@@ -483,12 +517,33 @@ window.__ModuleLoader__.load({
483
517
  const [poolBusy, setPoolBusy] = useState(false);
484
518
  const [newId, setNewId] = useState('');
485
519
  const [showNew, setShowNew] = useState(false);
520
+ /** 【必须】加载失败要显示原因,否则永远停在「加载中…」 */
521
+ const [loadErr, setLoadErr] = useState(null);
486
522
  const dragRef = useRef(null);
487
523
 
488
524
  useEffect(() => {
489
525
  if (state?.routes) setDraft(JSON.parse(JSON.stringify(state.routes)));
490
526
  }, [state?.routes]);
491
527
 
528
+ // 【兜底加载】不依赖父组件的 state。
529
+ // 之前 draft 只由 state.routes 驱动:一旦 gateway/get 失败(例如路由没注册),
530
+ // state 永远是 null,这一页就永久停在「加载中…」——用户看到的正是这个。
531
+ // 现在自己拉一次 routes/get,成功就填 draft,失败就显示错误。
532
+ useEffect(() => {
533
+ let alive = true;
534
+ (async () => {
535
+ const r = await api('routes/get');
536
+ if (!alive) return;
537
+ if (r.ok && r.data) {
538
+ setDraft(JSON.parse(JSON.stringify(r.data)));
539
+ setLoadErr(null);
540
+ } else {
541
+ setLoadErr(r.error || '宿主没有返回 profile 配置');
542
+ }
543
+ })();
544
+ return () => { alive = false; };
545
+ }, [api]);
546
+
492
547
  // 【需求2】模型池来自 DSH 设置-模型
493
548
  const loadPool = useCallback(async () => {
494
549
  setPoolBusy(true);
@@ -508,7 +563,20 @@ window.__ModuleLoader__.load({
508
563
  }
509
564
  }, [draft, pid]);
510
565
 
511
- if (!draft) return h('div', { style: { opacity: 0.6 } }, '加载中…');
566
+ // 加载失败时给出可读原因 + 重试,而不是无限「加载中…」
567
+ if (!draft) {
568
+ return h('div', null,
569
+ loadErr
570
+ ? h('div', { style: S.notice('error') },
571
+ h('div', { style: { fontWeight: 600, marginBottom: 4 } }, '读取路由配置失败'),
572
+ h('div', { style: { ...S.mono, fontSize: 11.5, wordBreak: 'break-all' } }, String(loadErr)),
573
+ h('div', { style: { ...S.hint, marginTop: 6 } },
574
+ '这通常意味着插件宿主半没有响应该请求。请重启 DSH 后再试;' +
575
+ '若持续出现,请把这条错误发给开发者。'),
576
+ )
577
+ : h('div', { style: { opacity: 0.6 } }, '加载中…'),
578
+ );
579
+ }
512
580
  const prof = draft[pid];
513
581
  if (!prof) return h('div', { style: { opacity: 0.6 } }, '没有可用的 profile');
514
582
 
@@ -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.1",
3
+ "version": "2.0.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",