dsh-plugin-litellm-bridge 2.1.0 → 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.
@@ -1179,22 +1179,30 @@ window.__ModuleLoader__.load({
1179
1179
  const [probe, setProbe] = useState(null);
1180
1180
  const [drops, setDrops] = useState([]);
1181
1181
 
1182
- // 当前会话 id:尽量从宿主会话上下文取;取不到就用 URL 里的会话标记
1183
- // 当前会话 id:尽量从宿主会话上下文取;取不到就用 URL 里的会话标记。
1184
- // 【注意】依赖数组里不能放 ctx / api —— 它们是每次渲染新建的对象,
1185
- // 放进去会触发无限重渲染(表现为整页空白)。
1186
- const sessionId = useMemo(() => {
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(() => {
1187
1194
  try {
1188
1195
  const fromCtx = ctx?.session?.id || ctx?.sessionId;
1189
- if (fromCtx) return String(fromCtx);
1196
+ if (fromCtx) return { id: String(fromCtx), isFallback: false };
1190
1197
  const loc = globalThis.location;
1191
1198
  const hay = String(loc?.hash || '') + String(loc?.search || '');
1192
- const m = hay.match(/session[=/]([A-Za-z0-9_-]+)/i);
1193
- return m ? m[1] : 'current';
1194
- } catch {
1195
- return 'current';
1196
- }
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 };
1197
1203
  }, []);
1204
+ const sessionId = sessionInfo.id;
1205
+ const sessionIdIsFallback = sessionInfo.isFallback;
1198
1206
 
1199
1207
  useEffect(() => {
1200
1208
  api('session/getProfile', { sessionId }).then((r) => {
@@ -1234,32 +1242,117 @@ window.__ModuleLoader__.load({
1234
1242
  };
1235
1243
 
1236
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
+
1237
1258
  h('div', { style: S.card },
1238
- h('div', { style: S.field },
1239
- h('label', { style: S.label }, '本会话绑定的 Profile(降级链方案)'),
1240
- h('select', {
1241
- style: S.input, value: profile, onChange: (e) => setProfile(e.target.value),
1242
- },
1243
- h('option', { value: '' }, '(不绑定)'),
1244
- profileIds.map((p) => h('option', { key: p, value: p }, safeText(p))),
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
+ ),
1245
1282
  ),
1246
- h('div', { style: S.hint },
1247
- '绑定后:本会话的 root 请求按该 profile 的 root 链走;spawn 的子 Agent 按同 profile 的 coder/research 链走。'),
1248
1283
  ),
1249
- h('div', { style: S.row },
1250
- h('button', { style: S.btn('primary'), onClick: save, disabled: busy }, '保存到会话变量'),
1251
- h('button', { style: S.btn(), onClick: runProbe, disabled: busy }, busy ? '测试中…' : '一键探针测试'),
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 ? '测试中…' : '一键探针测试'),
1252
1294
  ),
1253
1295
  h('div', { style: { ...S.mono, color: T.labelSecondary, marginTop: 8, fontSize: 11 } },
1254
1296
  `会话变量键:litellm-bridge/session-profile · sessionId=${sessionId}`),
1255
1297
  saved && h('div', {
1256
1298
  style: { marginTop: 9, fontSize: 12, color: saved.kind === 'ok' ? T.success : T.error },
1257
- }, saved.text),
1299
+ }, safeText(saved.text)),
1258
1300
  probe && h('div', {
1259
1301
  style: { marginTop: 9, fontSize: 12, color: probe.kind === 'ok' ? T.success : T.error },
1260
- }, probe.text),
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
+ })(),
1261
1334
  ),
1262
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
+
1263
1356
  rt && h('div', { style: S.card },
1264
1357
  h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '子代理路由覆盖'),
1265
1358
  (() => {
@@ -1272,7 +1365,7 @@ window.__ModuleLoader__.load({
1272
1365
  : { t: '不覆盖', c: S.ok, d: `供应商 ${sp} 不在覆盖名单` };
1273
1366
  return h('div', null,
1274
1367
  h('div', { style: { marginBottom: 6 } },
1275
- h('span', { style: S.badge() }, `当前供应商: ${sp || '未知'}`),
1368
+ h('span', { style: S.badge() }, `当前供应商: ${safeText(sp, '未知')}`),
1276
1369
  h('span', { style: { color: verdict.c, fontWeight: 600 } }, verdict.t),
1277
1370
  ),
1278
1371
  h('div', { style: { fontSize: 12, opacity: 0.75, marginBottom: 6 } }, verdict.d),
@@ -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.1.0",
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",