dsh-plugin-litellm-bridge 2.0.8 → 2.1.0

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.
@@ -637,24 +637,24 @@ window.__ModuleLoader__.load({
637
637
  return next;
638
638
  });
639
639
 
640
- // ---- 拖拽排序:fallback 列表顺序即降级优先级 ----
640
+ // ---- 拖拽排序:整条链(首选 + 备选)的先后顺序即降级优先级 ----
641
641
  const onDragStart = (at, idx) => { dragRef.current = { at, idx }; };
642
- const onDrop = (at, idx) => {
642
+ /**
643
+ * 拖拽落点:把 src 位置的模型插到 target 位置。
644
+ * 操作对象是**整条链**(primary 是第 0 位),
645
+ * 所以拖到第 1 位就等于「换首选」,不需要额外按钮。
646
+ */
647
+ const onDropChain = (at, idx) => {
643
648
  const src = dragRef.current;
649
+ dragRef.current = null;
644
650
  if (!src || src.at !== at || src.idx === idx) return;
645
- const list = [...prof.agents[at].fallbacks];
651
+ const cfg = prof?.agents?.[at];
652
+ if (!cfg) return;
653
+ const list = [cfg.primary, ...(cfg.fallbacks || [])].filter(Boolean);
654
+ if (src.idx < 0 || src.idx >= list.length) return;
646
655
  const [moved] = list.splice(src.idx, 1);
647
656
  list.splice(idx, 0, moved);
648
- setAgent(at, { fallbacks: list });
649
- dragRef.current = null;
650
- };
651
-
652
- /** 把某个模型从「备选」提升为「首选」,原首选下沉为第一备选。 */
653
- const promote = (at, idx) => {
654
- const cfg = prof.agents[at];
655
- const target = cfg.fallbacks[idx];
656
- const rest = cfg.fallbacks.filter((_, i) => i !== idx);
657
- setAgent(at, { primary: target, fallbacks: cfg.primary ? [cfg.primary, ...rest] : rest });
657
+ setAgent(at, { primary: list[0] || '', fallbacks: list.slice(1) });
658
658
  };
659
659
 
660
660
  const saveSection = async (at) => {
@@ -725,7 +725,51 @@ window.__ModuleLoader__.load({
725
725
 
726
726
  const allIds = (pool.models || []).map((x) => x.id);
727
727
 
728
+ // ---------------------------------------------------------------------
729
+ // 【关键】链成员的唯一标识必须是「供应商 + 模型」,不能只用模型名。
730
+ //
731
+ // 实测模型池里有大量同名模型分布在不同供应商/账号:
732
+ // agnes-3.0-flash → agnes / agnes-backup / llm-pi-ai (3 个不同账号)
733
+ // DeepSeek-V4.1-Flash → amd / trae / workbuddy (3 个不同渠道)
734
+ // GLM-5.3-Flash → amd / trae
735
+ //
736
+ // 这些是**不同凭证、不同配额**的独立路由,恰恰是降级最该利用的
737
+ //(1 号账号限流就换 2 号账号的同一个模型)。
738
+ // 若按模型名去重,选了 agnes 的 agnes-3.0-flash 后就再也选不到
739
+ // agnes-backup 的同名模型 —— 把最核心的备份场景堵死了。
740
+ //
741
+ // 所以链里存 "供应商::模型" 复合键;写进网关配置时再拆开。
742
+ // ---------------------------------------------------------------------
743
+ const keyOf = (provider, id) => `${provider}::${id}`;
744
+ const parseKey = (k) => {
745
+ const i = String(k).indexOf('::');
746
+ return i < 0 ? { provider: '', id: String(k) } : {
747
+ provider: String(k).slice(0, i),
748
+ id: String(k).slice(i + 2),
749
+ };
750
+ };
751
+ /** 把池里的 (provider,id) 转成展示用文字 */
752
+ const labelOf = (provider, id) => (provider ? `${provider} / ${id}` : id);
753
+
728
754
  return h('div', null,
755
+ // ---- 使用引导:第一次进来的人 3 秒看懂这页在做什么 ----
756
+ h('div', {
757
+ style: {
758
+ padding: '10px 12px', marginBottom: 18, borderRadius: 6,
759
+ background: tint(T.brand, 6), border: `1px solid ${tint(T.brand, 25)}`,
760
+ fontSize: 12, lineHeight: 1.7, color: T.labelPrimary,
761
+ },
762
+ },
763
+ h('div', { style: { fontWeight: 600, marginBottom: 4 } }, '这页在做什么'),
764
+ h('div', null,
765
+ '给三类 Agent 各配一条「降级链」:先试第 1 个模型,失败就换第 2 个,再失败换第 3 个。',
766
+ ),
767
+ h('div', { style: { marginTop: 4 } },
768
+ '配好后点最下方「保存并下发到网关」,网关就会按这个顺序执行重试。'),
769
+ h('div', { style: { marginTop: 6, opacity: 0.75 } },
770
+ '提示:不用每条链都配。只配 root 也能用;coder / research 留空表示沿用 DSH 原来的行为。'),
771
+ ),
772
+
729
773
  // ---- Profile 分组:选择 / 新建 / 删除 ----
730
774
  h('div', { style: S.h2Row },
731
775
  h('h2', { style: S.h2 }, 'Profile 分组'),
@@ -747,6 +791,9 @@ window.__ModuleLoader__.load({
747
791
  }, '删除当前'),
748
792
  ),
749
793
  ),
794
+ h('div', { style: { ...S.hint, marginTop: -6, marginBottom: 14 } },
795
+ '一个 profile = 一套降级方案。可以配多套(例如「稳定优先」「省钱优先」),' +
796
+ '再到「当前会话」里选用哪一套。'),
750
797
 
751
798
  showNew && h('div', { style: { ...S.row, marginBottom: 14 } },
752
799
  h('input', {
@@ -774,18 +821,113 @@ window.__ModuleLoader__.load({
774
821
  // 直接读 cfg.primary 会抛 TypeError,进而让整页崩掉。
775
822
  const cfg = prof?.agents?.[at]
776
823
  || { label: at, primary: '', timeoutMs: 600000, fallbacks: [] };
824
+
825
+ // 链里存的是 "供应商::模型" 复合键;兼容旧数据(纯模型名)
826
+ const chainKeys = [cfg.primary, ...(cfg.fallbacks || [])]
827
+ .filter(Boolean)
828
+ .map((v) => {
829
+ const s = String(v);
830
+ if (s.includes('::')) return s;
831
+ // 旧数据只有模型名:在池里找它的供应商,找不到就留空供应商
832
+ const hit = (pool.models || []).find((m) => m.id === s);
833
+ return hit ? keyOf(hit.provider, hit.id) : keyOf('', s);
834
+ });
835
+ const chain = chainKeys;
777
836
  const isSub = at !== 'root';
778
- const groupLabel = at === 'root' ? 'root · 主代理降级链'
779
- : at === 'coder' ? 'coder · 子代理降级链(Coder)'
780
- : 'research · 子代理降级链(Research)';
781
- const chainLen = [cfg.primary, ...(cfg.fallbacks || [])].filter(Boolean).length;
782
837
 
783
- return h('div', { key: at },
838
+ // 每个角色的说明:用「人话」讲清楚它管什么
839
+ const meta = at === 'root'
840
+ ? {
841
+ title: 'root · 主代理',
842
+ desc: '你自己直接对话的那个 Agent。它出问题时按下面的顺序换模型重试。',
843
+ hint: '通常放最稳、上下文最大的模型。',
844
+ }
845
+ : at === 'coder'
846
+ ? {
847
+ title: 'coder · 子代理(写代码)',
848
+ desc: 'Agent 里 spawn 出来的「写代码」子任务用它,和 root 完全独立。',
849
+ hint: '可放长输出、便宜的模型。',
850
+ }
851
+ : {
852
+ title: 'research · 子代理(调研)',
853
+ desc: 'Agent 里 spawn 出来的「调研/检索/分析」子任务用它。',
854
+ hint: '可放推理强或便宜的模型。',
855
+ };
856
+
857
+ /** 修改整条链(统一入口,避免 primary/fallbacks 两个来源打架) */
858
+ const setChain = (nextList) => {
859
+ const list = nextList.filter(Boolean);
860
+ setAgent(at, { primary: list[0] || '', fallbacks: list.slice(1) });
861
+ };
862
+ const moveUp = (i) => {
863
+ if (i <= 0) return;
864
+ const l = [...chain];
865
+ [l[i - 1], l[i]] = [l[i], l[i - 1]];
866
+ setChain(l);
867
+ };
868
+ const removeAt = (i) => setChain(chain.filter((_, j) => j !== i));
869
+ /** 添加一个 (provider, model) —— 复合键去重,同名不同账号可以共存 */
870
+ const addModel = (provider, id) => {
871
+ if (!id) return;
872
+ const k = keyOf(provider, id);
873
+ if (chain.includes(k)) return;
874
+ setChain([...chain, k]);
875
+ };
876
+
877
+ /** 一个可拖拽的链节点 */
878
+ const row = (key, i) => {
879
+ const { provider, id } = parseKey(key);
880
+ return h('div', {
881
+ key: `${at}-${key}-${i}`,
882
+ draggable: true,
883
+ onDragStart: () => onDragStart(at, i),
884
+ onDragOver: (e) => e.preventDefault(),
885
+ onDrop: () => onDropChain(at, i),
886
+ style: {
887
+ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
888
+ marginBottom: 4, borderRadius: 6, cursor: 'grab',
889
+ background: i === 0 ? tint(T.success, 7) : T.bgLayer2,
890
+ border: `1px solid ${i === 0 ? tint(T.success, 30) : T.borderL1}`,
891
+ },
892
+ },
893
+ h('span', { style: { opacity: 0.4, ...S.mono } }, '⠿'),
894
+ h('span', {
895
+ style: {
896
+ ...S.mono, fontSize: 11, width: 20, textAlign: 'center',
897
+ flex: '0 0 20px', fontWeight: 600,
898
+ color: i === 0 ? T.success : T.labelSecondary,
899
+ },
900
+ }, String(i + 1)),
901
+ // 【关键】必须把供应商显式展示出来,
902
+ // 否则「agnes 的 agnes-3.0-flash」和「agnes-backup 的 agnes-3.0-flash」
903
+ // 在列表里长得一模一样,用户根本分不清哪个是哪个账号。
904
+ provider
905
+ ? h('span', { style: S.badge(T.brand, tint(T.brand, 12)), title: `账号/供应商:${provider}` },
906
+ safeText(provider))
907
+ : h('span', { style: S.badge(T.warn, tint(T.warn, 14)) }, '供应商未知'),
908
+ h('span', { style: { flex: '1 1 auto', ...S.mono, wordBreak: 'break-all' } }, safeText(id)),
909
+ i === 0
910
+ ? h('span', { style: S.badge(T.success, tint(T.success, 16)) }, '先用这个')
911
+ : h('span', { style: S.badge() }, i === 1 ? '失败后换它' : `${i + 1} 顺位`),
912
+ i > 0 && h('button', {
913
+ style: { ...S.btnSm, height: 22, fontSize: 11 },
914
+ onClick: () => moveUp(i),
915
+ title: '往前挪一位(往前 = 更优先)',
916
+ }, '↑'),
917
+ h('button', {
918
+ style: { ...S.btnSm, height: 22, fontSize: 11, color: T.error, borderColor: tint(T.error, 35) },
919
+ onClick: () => removeAt(i),
920
+ }, '删'),
921
+ );
922
+ };
923
+
924
+ return h('div', { key: at, style: { marginBottom: 22 } },
925
+ // 标题行
784
926
  h('div', { style: S.h2Row },
785
- h('h2', { style: S.h2 }, safeText(groupLabel)),
927
+ h('h2', { style: S.h2 }, safeText(meta.title)),
786
928
  isSub && h('span', { style: S.badge() }, '仅子 Agent 使用'),
787
929
  h('span', { style: { ...S.mono, fontSize: 11, color: T.labelSecondary } },
788
- chainLen ? `链长 ${chainLen}` : '未配置'),
930
+ chain.length ? `${chain.length} 个模型` : '还没配'),
789
931
  h('div', { style: S.sectionActions },
790
932
  h('button', {
791
933
  style: S.btnSm,
@@ -794,97 +936,100 @@ window.__ModuleLoader__.load({
794
936
  next[pid].agents[at] = JSON.parse(JSON.stringify(state.routes[pid].agents[at]));
795
937
  return next;
796
938
  }),
797
- }, 'Discard'),
939
+ }, '撤销修改'),
798
940
  h('button', {
799
941
  style: { ...S.btn('primary'), height: 26, padding: '0 10px', fontSize: 12 },
800
942
  disabled: busy,
801
943
  onClick: () => saveSection(at),
802
- }, 'Save'),
944
+ }, '保存这条链'),
803
945
  ),
804
946
  ),
805
947
 
806
- h('div', { style: S.fieldRow },
807
- h('div', { style: { flex: '1 1 260px' } },
808
- h('label', { style: S.label }, '首选模型(来自 DSH 模型池)'),
809
- h('select', {
810
- style: S.input,
811
- value: cfg.primary || '',
812
- onChange: (e) => setAgent(at, { primary: e.target.value }),
813
- },
814
- h('option', { value: '' }, '— 请选择 —'),
815
- byProvider.map(([prov, rows]) => h('optgroup', { key: prov, label: prov },
816
- rows.map((m) => h('option', { key: `${prov}:${m.id}`, value: m.id },
817
- m.name && m.name !== m.id ? `${m.id} · ${m.name}` : m.id)),
818
- )),
948
+ // 说明(讲清楚这一链管什么)
949
+ h('div', { style: { ...S.hint, marginTop: -4, marginBottom: 10 } },
950
+ safeText(meta.desc),
951
+ h('span', { style: { opacity: 0.7 } }, ` 建议:${safeText(meta.hint)}`)),
952
+
953
+ // 完整降级序列(首选 + 备选合在一起看)
954
+ chain.length === 0
955
+ ? h('div', {
956
+ style: {
957
+ padding: '14px 12px', marginBottom: 8, borderRadius: 6,
958
+ background: T.bgLayer1, border: `1px dashed ${T.borderL2}`,
959
+ fontSize: 12, color: T.labelSecondary, textAlign: 'center',
960
+ },
961
+ }, '还没有选模型。从下面的下拉框加第一个 —— 它就是这个链的首选。')
962
+ : h('div', { style: { marginBottom: 8 } },
963
+ h('div', { style: { ...S.label } }, '降级顺序(从上到下 = 先试这个,不行再试下一个)'),
964
+ chain.map((key, i) => row(key, i)),
819
965
  ),
820
- ),
821
- h('div', { style: { flex: '0 0 150px' } },
822
- h('label', { style: S.label }, '超时(毫秒)'),
823
- h('input', {
824
- style: S.input, type: 'number', value: cfg.timeoutMs,
825
- onChange: (e) => setAgent(at, { timeoutMs: Number(e.target.value) }),
826
- }),
827
- ),
828
- ),
829
966
 
830
- // ---- fallback 链:拖拽排序 ----
831
- h('div', { style: S.field },
832
- h('label', { style: S.label }, 'fallback 降级序列(拖拽排序,顺序即网关执行的降级优先级)'),
833
- (cfg.fallbacks || []).length === 0
834
- ? h('div', { style: { ...S.mono, fontSize: 11.5, color: T.labelSecondary, padding: '5px 0' } }, '(无备选)')
835
- : cfg.fallbacks.map((f, i) => h('div', {
836
- key: `${f}-${i}`, draggable: true,
837
- onDragStart: () => onDragStart(at, i),
838
- onDragOver: (e) => e.preventDefault(),
839
- onDrop: () => onDrop(at, i),
840
- style: {
841
- display: 'flex', alignItems: 'center', gap: 8, padding: '5px 9px',
842
- marginBottom: 3, borderRadius: 5, cursor: 'grab',
843
- background: T.bgLayer2, border: `1px solid ${T.borderL1}`,
844
- },
845
- },
846
- h('span', { style: { opacity: 0.45, ...S.mono } }, '⠿'),
847
- h('span', { style: S.badge() }, `第 ${i + 2} 顺位`),
848
- h('span', { style: { flex: 1, ...S.mono, wordBreak: 'break-all' } }, f),
849
- h('button', { style: S.btnSm, onClick: () => promote(at, i), title: '提升为首选' }, '↑ 首选'),
850
- h('button', {
851
- style: S.btnSm,
852
- onClick: () => setAgent(at, { fallbacks: cfg.fallbacks.filter((_, j) => j !== i) }),
853
- }, '移除'),
854
- )),
855
-
856
- // 追加备选:下拉选 DSH 模型池里的模型
857
- h('div', { style: { ...S.row, marginTop: 7 } },
858
- h('select', {
859
- style: { ...S.inputAuto, flex: '1 1 260px' },
860
- value: '',
861
- onChange: (e) => {
862
- const v = e.target.value;
863
- if (!v) return;
864
- const cur = cfg.fallbacks || [];
865
- if (v === cfg.primary || cur.includes(v)) return;
866
- setAgent(at, { fallbacks: [...cur, v] });
867
- e.target.value = '';
868
- },
967
+ // 追加模型
968
+ //
969
+ // 【去重规则】用 "供应商::模型" 复合键,**不是** 模型名。
970
+ // 这样 agnes 与 agnes-backup 的同名模型可以同时出现在链里 ——
971
+ // 它们本来就是两个账号、两份配额,互为备份正是降级的意义。
972
+ h('div', { style: { ...S.row, marginBottom: 10 } },
973
+ h('select', {
974
+ style: { ...S.inputAuto, flex: '1 1 300px' },
975
+ value: '',
976
+ onChange: (e) => {
977
+ const raw = e.target.value;
978
+ e.target.value = '';
979
+ if (!raw) return;
980
+ const { provider, id } = parseKey(raw);
981
+ addModel(provider, id);
869
982
  },
870
- h('option', { value: '' }, '+ 添加备选模型(从 DSH 模型池选择)'),
871
- byProvider.map(([prov, rows]) => h('optgroup', { key: prov, label: prov },
872
- rows.filter((m) => m.id !== cfg.primary).map((m) =>
873
- h('option', { key: `${prov}:${m.id}`, value: m.id }, m.id)),
874
- )),
875
- ),
983
+ },
984
+ h('option', { value: '' },
985
+ chain.length ? '+ 再加一个备选模型' : '+ 选择这个链的第一个模型'),
986
+ byProvider.map(([prov, provRows]) => h('optgroup', { key: prov, label: prov },
987
+ provRows
988
+ .filter((m) => !chain.includes(keyOf(prov, m.id)))
989
+ .map((m) => {
990
+ // 同一供应商下有重名时加后缀,避免浏览器无法区分
991
+ const dup = provRows.filter((x) => x.id === m.id).length > 1;
992
+ return h('option', { key: keyOf(prov, m.id), value: keyOf(prov, m.id) },
993
+ dup ? `${m.id}(${prov})` : m.id);
994
+ }),
995
+ )),
876
996
  ),
877
- allIds.length === 0 && h('div', { style: { ...S.hint, color: T.warn } },
878
- '模型池为空:请先到【设置 → 模型】里配置供应商,并用【获取可用模型】拉取模型目录。'),
879
997
  ),
998
+
999
+ // 超时(放在最后,因为它多数情况不用改)
1000
+ h('div', { style: { ...S.row } },
1001
+ h('span', { style: { ...S.label, marginBottom: 0 } }, '单次请求超时'),
1002
+ h('input', {
1003
+ style: { ...S.inputAuto, width: 120 },
1004
+ type: 'number',
1005
+ value: cfg.timeoutMs,
1006
+ onChange: (e) => setAgent(at, { timeoutMs: Number(e.target.value) }),
1007
+ }),
1008
+ h('span', { style: { ...S.mono, fontSize: 11, color: T.labelSecondary } }, '毫秒'),
1009
+ h('span', { style: { ...S.mono, fontSize: 11, color: T.labelSecondary } },
1010
+ `(约 ${Math.round((cfg.timeoutMs || 0) / 60000)} 分钟)`),
1011
+ ),
1012
+
1013
+ allIds.length === 0 && h('div', { style: { ...S.hint, color: T.warn } },
1014
+ '模型池为空:请先到【设置 → 模型】里配置供应商,并用【获取可用模型】拉取模型目录。'),
880
1015
  );
881
1016
  }),
882
1017
 
883
1018
  // ---- 下发区 ----
884
- h('div', { style: { ...S.row, marginTop: 16 } },
885
- h('button', { style: S.btn('primary'), onClick: applyChains, disabled: busy },
886
- busy ? '处理中…' : '保存并下发到网关'),
887
- h('button', { style: S.btn(), onClick: preview, disabled: busy }, '预览 YAML'),
1019
+ h('div', {
1020
+ style: {
1021
+ marginTop: 22, paddingTop: 16, borderTop: `1px solid ${T.borderL1}`,
1022
+ },
1023
+ },
1024
+ h('div', { style: { ...S.label } }, '最后一步'),
1025
+ h('div', { style: { ...S.hint, marginBottom: 10 } },
1026
+ '「保存并下发」会把上面三条链写成网关配置并重启网关,让它按这个顺序执行。' +
1027
+ '只保存不下发的话,网关不会生效。'),
1028
+ h('div', { style: { ...S.row } },
1029
+ h('button', { style: S.btn('primary'), onClick: applyChains, disabled: busy },
1030
+ busy ? '处理中…' : '保存并下发到网关'),
1031
+ h('button', { style: S.btn(), onClick: preview, disabled: busy }, '先看看生成的配置'),
1032
+ ),
888
1033
  ),
889
1034
 
890
1035
  msg && h('div', {
@@ -61,39 +61,66 @@ export function assembleChainConfig(state, pool = null) {
61
61
  }
62
62
 
63
63
  const warnings = [];
64
- /** 真实上游:model id → { name, provider, baseUrl, keyEnv, timeoutSec } */
64
+ /** 真实上游:复合键 → { name, providerId, baseUrl, keyEnv, timeoutSec } */
65
65
  const upstreams = new Map();
66
66
  /** 链:{ pid, at, head, chain, timeoutMs } */
67
67
  const chains = [];
68
68
 
69
+ /**
70
+ * 解析链成员标识。
71
+ *
72
+ * 【复合键】界面存的是 "供应商::模型",因为同一个模型名可能挂在多个
73
+ * 供应商下(实测 agnes-3.0-flash 同时存在于 agnes / agnes-backup / llm-pi-ai;
74
+ * DeepSeek-V4.1-Flash 同时存在于 amd / trae / workbuddy)。
75
+ * 它们是不同账号、不同配额,互为备份正是降级的核心用途,
76
+ * 所以必须区分到「供应商级」而不是「模型名级」。
77
+ *
78
+ * 【兼容旧数据】早期版本只存纯模型名,这里回退到按池里第一个匹配解析。
79
+ */
80
+ const parseMember = (member) => {
81
+ const s = String(member || '');
82
+ const i = s.indexOf('::');
83
+ if (i >= 0) {
84
+ return { provider: s.slice(0, i), model: s.slice(i + 2), key: s };
85
+ }
86
+ // 旧数据:纯模型名 → 找池里第一个归属供应商
87
+ const provider = providerOfModel.get(s) || '';
88
+ return { provider, model: s, key: provider ? `${provider}::${s}` : s };
89
+ };
90
+
69
91
  // ---- 1. 收集所有链与上游 ------------------------------------------------
70
92
  for (const pid of Object.keys(routes)) {
71
93
  const agents = routes[pid]?.agents || {};
72
94
  for (const at of Object.keys(agents)) {
73
95
  const cfg = agents[at];
74
96
  if (!cfg?.primary) continue;
75
- const chain = [cfg.primary, ...(cfg.fallbacks || [])].filter(Boolean);
76
- if (!chain.length) continue;
97
+ const members = [cfg.primary, ...(cfg.fallbacks || [])].filter(Boolean);
98
+ if (!members.length) continue;
99
+
100
+ const parsed = members.map(parseMember);
77
101
 
78
102
  chains.push({
79
103
  pid,
80
104
  at,
81
105
  head: `${at}-${pid}-primary`,
82
- chain,
106
+ // 链在配置里用**供应商限定的实名**引用,避免同名串味
107
+ chain: parsed.map((p) => p.model),
108
+ members: parsed,
83
109
  timeoutMs: cfg.timeoutMs || 600000,
84
110
  });
85
111
 
86
- for (const modelId of chain) {
87
- if (upstreams.has(modelId)) continue;
88
- const providerId = providerOfModel.get(modelId) || '';
89
- const prov = providerMap.get(providerId);
112
+ for (const member of parsed) {
113
+ const { provider: providerId, model: modelId, key } = member;
114
+ if (upstreams.has(key)) continue;
115
+
90
116
  if (!providerId) {
91
117
  warnings.push(
92
- `模型 ${modelId} 不在 DSH 模型池里(可能已被删除或改了名)。` +
118
+ `模型 ${modelId} 找不到归属供应商(可能不在 DSH 模型池里)。` +
93
119
  '请到【设置-模型】确认,或在「路由配置」里重新选择。',
94
120
  );
95
121
  continue;
96
122
  }
123
+ const prov = providerMap.get(providerId);
97
124
  if (!prov?.baseUrl) {
98
125
  warnings.push(
99
126
  `供应商 ${providerId} 缺少 API 地址(baseURL),模型 ${modelId} 无法下发。` +
@@ -101,7 +128,9 @@ export function assembleChainConfig(state, pool = null) {
101
128
  );
102
129
  continue;
103
130
  }
104
- upstreams.set(modelId, {
131
+
132
+ upstreams.set(key, {
133
+ key,
105
134
  name: modelId,
106
135
  providerId,
107
136
  prefix: protocolPrefix(prov),
@@ -117,6 +146,31 @@ export function assembleChainConfig(state, pool = null) {
117
146
  const lines = [];
118
147
  const push = (s = '') => lines.push(s);
119
148
 
149
+ /**
150
+ * 给每个上游分配一个**在 model_list 内唯一**的 model_name。
151
+ *
152
+ * 【为什么需要改】同名模型可能来自不同供应商账号:
153
+ * agnes::agnes-3.0-flash (账号 1)
154
+ * agnes-backup::agnes-3.0-flash (账号 2)
155
+ * llm-pi-ai::agnes-3.0-flash (平台通道)
156
+ * LiteLLM 的 model_list 按 model_name 索引,两个同名条目会互相覆盖,
157
+ * 导致降级时永远只能命中其中一个 —— 备份账号形同虚设。
158
+ *
159
+ * 规则:模型名在全表唯一时保持原样(可读性最好);
160
+ * 一旦重名,就追加 @供应商 后缀区分。
161
+ */
162
+ const nameCount = new Map();
163
+ for (const u of upstreams.values()) {
164
+ nameCount.set(u.name, (nameCount.get(u.name) || 0) + 1);
165
+ }
166
+ for (const u of upstreams.values()) {
167
+ u.litellmName = (nameCount.get(u.name) || 0) > 1
168
+ ? `${u.name}@${u.providerId}`
169
+ : u.name;
170
+ }
171
+ /** 复合键 → 实际写进 YAML 的 model_name */
172
+ const yamlNameOf = (key) => upstreams.get(key)?.litellmName || null;
173
+
120
174
  push('# ============================================================================');
121
175
  push('# Generated by dsh-plugin-litellm-bridge - profile fallback chains');
122
176
  push(`# Generated at: ${new Date().toISOString()}`);
@@ -125,12 +179,13 @@ export function assembleChainConfig(state, pool = null) {
125
179
  push('# This file only mirrors those upstreams so the gateway can execute the');
126
180
  push('# fallback order. The plugin itself performs no routing / retry / fallback.');
127
181
  push('# api_key entries are os.environ references - never plaintext.');
182
+ push('# Entries with the same model but different accounts get a @provider suffix.');
128
183
  push('#');
129
184
 
130
185
  push('model_list:');
131
186
  for (const u of upstreams.values()) {
132
- push(` # ${u.providerId}`);
133
- push(` - model_name: ${u.name}`);
187
+ push(` # provider: ${u.providerId}${u.litellmName !== u.name ? ` | model: ${u.name}` : ''}`);
188
+ push(` - model_name: ${u.litellmName}`);
134
189
  push(' litellm_params:');
135
190
  push(` model: ${u.prefix}/${u.name}`);
136
191
  push(` api_base: ${u.baseUrl}`);
@@ -139,9 +194,9 @@ export function assembleChainConfig(state, pool = null) {
139
194
  }
140
195
  // 链头:逻辑名 → 链首模型的真实上游
141
196
  for (const c of chains) {
142
- const first = upstreams.get(c.chain[0]);
197
+ const firstKey = c.members?.[0]?.key;
198
+ const first = firstKey ? upstreams.get(firstKey) : null;
143
199
  if (!first) continue;
144
- if (upstreams.has(c.head)) continue;
145
200
  push(` # chain head: ${c.pid} / ${c.at}`);
146
201
  push(` - model_name: ${c.head}`);
147
202
  push(' litellm_params:');
@@ -153,20 +208,34 @@ export function assembleChainConfig(state, pool = null) {
153
208
  push('');
154
209
 
155
210
  // ---- 3. 降级顺序(网关执行)--------------------------------------------
211
+ // 注意:fallbacks 里必须用**YAML 中的实名**(带 @provider 后缀的那个),
212
+ // 不能用裸模型名,否则同名上游会串。
156
213
  push('litellm_settings:');
157
214
  push(' # Fallback order is assembled by the plugin, executed by the gateway.');
158
- const multi = chains.filter((c) => c.chain.length >= 2 && upstreams.has(c.chain[0]));
215
+ const multi = chains.filter((c) => (c.members || []).length >= 2);
159
216
  push(' fallbacks:');
160
217
  if (!multi.length) {
161
218
  push(' []');
162
219
  } else {
163
- for (const c of multi) push(` - ${c.head}: [${c.chain.slice(1).map((m) => `"${m}"`).join(', ')}]`);
220
+ for (const c of multi) {
221
+ const rest = c.members.slice(1)
222
+ .map((m) => yamlNameOf(m.key))
223
+ .filter(Boolean)
224
+ .map((n) => `"${n}"`);
225
+ if (!rest.length) continue;
226
+ push(` - ${c.head}: [${rest.join(', ')}]`);
227
+ }
164
228
  }
165
229
  push(' context_window_fallbacks:');
166
230
  if (!multi.length) {
167
231
  push(' []');
168
232
  } else {
169
- for (const c of multi) push(` - ${c.head}: ["${c.chain[c.chain.length - 1]}"]`);
233
+ for (const c of multi) {
234
+ const last = c.members[c.members.length - 1];
235
+ const n = yamlNameOf(last.key);
236
+ if (!n) continue;
237
+ push(` - ${c.head}: ["${n}"]`);
238
+ }
170
239
  }
171
240
  push(' num_retries: 2');
172
241
  push(' request_timeout: 900');
@@ -93,6 +93,34 @@ export function statePath() {
93
93
  return join(stateDir(), 'state.json');
94
94
  }
95
95
 
96
+ /**
97
+ * 旧版遗留的「假模型名」。
98
+ *
99
+ * 插件 1.x 时代网关自己维护 model_list,写死过一批别名
100
+ * (root-primary / coder-fallback-1 之类)。架构修正后模型池改由
101
+ * DSH【设置-模型】提供,这些名字**在真实模型池里一个都不存在**。
102
+ *
103
+ * 用户机器上的 state.json 会带着它们,表现就是:
104
+ * 首选模型下拉显示「— 请选择 —」,但下面 fallback 里挂着一串
105
+ * 永远连不上的假名字 —— 这就是「看不懂怎么配」的直接来源。
106
+ *
107
+ * 这里做一个自动迁移:加载状态时把它们清掉,让界面回到干净骨架。
108
+ */
109
+ const LEGACY_MODEL_RE = /^(root|coder|research)-(primary|fallback-\d+|profile-\w+)$/;
110
+
111
+ function isLegacyModelName(name) {
112
+ return typeof name === 'string' && LEGACY_MODEL_RE.test(name.trim());
113
+ }
114
+
115
+ /** 清理一条链里所有旧版假模型名,返回清理后的链与是否发生变更。 */
116
+ function stripLegacyModels(chain) {
117
+ const before = JSON.stringify(chain);
118
+ const primary = isLegacyModelName(chain.primary) ? '' : chain.primary;
119
+ const fallbacks = (chain.fallbacks || []).filter((f) => !isLegacyModelName(f));
120
+ const after = JSON.stringify({ ...chain, primary, fallbacks });
121
+ return { chain: { ...chain, primary, fallbacks }, changed: before !== after };
122
+ }
123
+
96
124
  /** 归一化一条 agent 链(容忍旧版本 / 残缺字段)。 */
97
125
  function mergeAgentChain(src, base) {
98
126
  const out = { ...base };
@@ -128,6 +156,9 @@ function mergeState(base, loaded) {
128
156
  };
129
157
  for (const at of AGENT_TYPES) {
130
158
  out.agents[at] = mergeAgentChain(prof?.agents?.[at], skeleton.agents[at]);
159
+ // 自动清理旧版遗留的假模型名(root-primary 之类)
160
+ const { chain } = stripLegacyModels(out.agents[at]);
161
+ out.agents[at] = chain;
131
162
  }
132
163
  merged.routes[pid] = out;
133
164
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-litellm-bridge",
3
- "version": "2.0.8",
3
+ "version": "2.1.0",
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",