dsh-plugin-litellm-bridge 2.0.9 → 2.1.1
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.
|
@@ -725,6 +725,32 @@ 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,
|
|
729
755
|
// ---- 使用引导:第一次进来的人 3 秒看懂这页在做什么 ----
|
|
730
756
|
h('div', {
|
|
@@ -796,7 +822,17 @@ window.__ModuleLoader__.load({
|
|
|
796
822
|
const cfg = prof?.agents?.[at]
|
|
797
823
|
|| { label: at, primary: '', timeoutMs: 600000, fallbacks: [] };
|
|
798
824
|
|
|
799
|
-
|
|
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;
|
|
800
836
|
const isSub = at !== 'root';
|
|
801
837
|
|
|
802
838
|
// 每个角色的说明:用「人话」讲清楚它管什么
|
|
@@ -830,47 +866,60 @@ window.__ModuleLoader__.load({
|
|
|
830
866
|
setChain(l);
|
|
831
867
|
};
|
|
832
868
|
const removeAt = (i) => setChain(chain.filter((_, j) => j !== i));
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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]);
|
|
836
875
|
};
|
|
837
876
|
|
|
838
877
|
/** 一个可拖拽的链节点 */
|
|
839
|
-
const row = (
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
marginBottom: 4, borderRadius: 6, cursor: 'grab',
|
|
848
|
-
background: i === 0 ? tint(T.success, 7) : T.bgLayer2,
|
|
849
|
-
border: `1px solid ${i === 0 ? tint(T.success, 30) : T.borderL1}`,
|
|
850
|
-
},
|
|
851
|
-
},
|
|
852
|
-
h('span', { style: { opacity: 0.4, ...S.mono } }, '⠿'),
|
|
853
|
-
h('span', {
|
|
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),
|
|
854
886
|
style: {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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}`,
|
|
858
891
|
},
|
|
859
|
-
},
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
+
};
|
|
874
923
|
|
|
875
924
|
return h('div', { key: at, style: { marginBottom: 22 } },
|
|
876
925
|
// 标题行
|
|
@@ -912,21 +961,37 @@ window.__ModuleLoader__.load({
|
|
|
912
961
|
}, '还没有选模型。从下面的下拉框加第一个 —— 它就是这个链的首选。')
|
|
913
962
|
: h('div', { style: { marginBottom: 8 } },
|
|
914
963
|
h('div', { style: { ...S.label } }, '降级顺序(从上到下 = 先试这个,不行再试下一个)'),
|
|
915
|
-
chain.map((
|
|
964
|
+
chain.map((key, i) => row(key, i)),
|
|
916
965
|
),
|
|
917
966
|
|
|
918
967
|
// 追加模型
|
|
968
|
+
//
|
|
969
|
+
// 【去重规则】用 "供应商::模型" 复合键,**不是** 模型名。
|
|
970
|
+
// 这样 agnes 与 agnes-backup 的同名模型可以同时出现在链里 ——
|
|
971
|
+
// 它们本来就是两个账号、两份配额,互为备份正是降级的意义。
|
|
919
972
|
h('div', { style: { ...S.row, marginBottom: 10 } },
|
|
920
973
|
h('select', {
|
|
921
974
|
style: { ...S.inputAuto, flex: '1 1 300px' },
|
|
922
975
|
value: '',
|
|
923
|
-
onChange: (e) => {
|
|
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);
|
|
982
|
+
},
|
|
924
983
|
},
|
|
925
984
|
h('option', { value: '' },
|
|
926
985
|
chain.length ? '+ 再加一个备选模型' : '+ 选择这个链的第一个模型'),
|
|
927
986
|
byProvider.map(([prov, provRows]) => h('optgroup', { key: prov, label: prov },
|
|
928
|
-
provRows
|
|
929
|
-
|
|
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
|
+
}),
|
|
930
995
|
)),
|
|
931
996
|
),
|
|
932
997
|
),
|
|
@@ -1114,22 +1179,30 @@ window.__ModuleLoader__.load({
|
|
|
1114
1179
|
const [probe, setProbe] = useState(null);
|
|
1115
1180
|
const [drops, setDrops] = useState([]);
|
|
1116
1181
|
|
|
1117
|
-
// 当前会话 id
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
|
|
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(() => {
|
|
1122
1194
|
try {
|
|
1123
1195
|
const fromCtx = ctx?.session?.id || ctx?.sessionId;
|
|
1124
|
-
if (fromCtx) return String(fromCtx);
|
|
1196
|
+
if (fromCtx) return { id: String(fromCtx), isFallback: false };
|
|
1125
1197
|
const loc = globalThis.location;
|
|
1126
1198
|
const hay = String(loc?.hash || '') + String(loc?.search || '');
|
|
1127
|
-
const m = hay.match(/session[=/]([A-Za-z0-
|
|
1128
|
-
|
|
1129
|
-
} catch {
|
|
1130
|
-
|
|
1131
|
-
}
|
|
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 };
|
|
1132
1203
|
}, []);
|
|
1204
|
+
const sessionId = sessionInfo.id;
|
|
1205
|
+
const sessionIdIsFallback = sessionInfo.isFallback;
|
|
1133
1206
|
|
|
1134
1207
|
useEffect(() => {
|
|
1135
1208
|
api('session/getProfile', { sessionId }).then((r) => {
|
|
@@ -1169,32 +1242,117 @@ window.__ModuleLoader__.load({
|
|
|
1169
1242
|
};
|
|
1170
1243
|
|
|
1171
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
|
+
|
|
1172
1258
|
h('div', { style: S.card },
|
|
1173
|
-
h('div', { style: S.
|
|
1174
|
-
h('
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
+
),
|
|
1180
1282
|
),
|
|
1181
|
-
h('div', { style: S.hint },
|
|
1182
|
-
'绑定后:本会话的 root 请求按该 profile 的 root 链走;spawn 的子 Agent 按同 profile 的 coder/research 链走。'),
|
|
1183
1283
|
),
|
|
1184
|
-
h('div', { style: S.
|
|
1185
|
-
|
|
1186
|
-
|
|
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 ? '测试中…' : '一键探针测试'),
|
|
1187
1294
|
),
|
|
1188
1295
|
h('div', { style: { ...S.mono, color: T.labelSecondary, marginTop: 8, fontSize: 11 } },
|
|
1189
1296
|
`会话变量键:litellm-bridge/session-profile · sessionId=${sessionId}`),
|
|
1190
1297
|
saved && h('div', {
|
|
1191
1298
|
style: { marginTop: 9, fontSize: 12, color: saved.kind === 'ok' ? T.success : T.error },
|
|
1192
|
-
}, saved.text),
|
|
1299
|
+
}, safeText(saved.text)),
|
|
1193
1300
|
probe && h('div', {
|
|
1194
1301
|
style: { marginTop: 9, fontSize: 12, color: probe.kind === 'ok' ? T.success : T.error },
|
|
1195
|
-
}, probe.text),
|
|
1302
|
+
}, safeText(probe.text)),
|
|
1196
1303
|
),
|
|
1197
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
|
+
|
|
1198
1356
|
rt && h('div', { style: S.card },
|
|
1199
1357
|
h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '子代理路由覆盖'),
|
|
1200
1358
|
(() => {
|
|
@@ -1207,7 +1365,7 @@ window.__ModuleLoader__.load({
|
|
|
1207
1365
|
: { t: '不覆盖', c: S.ok, d: `供应商 ${sp} 不在覆盖名单` };
|
|
1208
1366
|
return h('div', null,
|
|
1209
1367
|
h('div', { style: { marginBottom: 6 } },
|
|
1210
|
-
h('span', { style: S.badge() }, `当前供应商: ${sp
|
|
1368
|
+
h('span', { style: S.badge() }, `当前供应商: ${safeText(sp, '未知')}`),
|
|
1211
1369
|
h('span', { style: { color: verdict.c, fontWeight: 600 } }, verdict.t),
|
|
1212
1370
|
),
|
|
1213
1371
|
h('div', { style: { fontSize: 12, opacity: 0.75, marginBottom: 6 } }, verdict.d),
|
|
@@ -61,39 +61,66 @@ export function assembleChainConfig(state, pool = null) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const warnings = [];
|
|
64
|
-
/**
|
|
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
|
|
76
|
-
if (!
|
|
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
|
-
|
|
106
|
+
// 链在配置里用**供应商限定的实名**引用,避免同名串味
|
|
107
|
+
chain: parsed.map((p) => p.model),
|
|
108
|
+
members: parsed,
|
|
83
109
|
timeoutMs: cfg.timeoutMs || 600000,
|
|
84
110
|
});
|
|
85
111
|
|
|
86
|
-
for (const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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}
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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)
|
|
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)
|
|
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');
|
|
@@ -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}` };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-litellm-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
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",
|