dsh-plugin-litellm-bridge 0.0.0-stage → 2.0.2

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.
@@ -0,0 +1,248 @@
1
+ /**
2
+ * DSH 模型池读取
3
+ * ============================================================================
4
+ *
5
+ * 【架构修正需求 2】插件的模型下拉数据源 = DSH【设置 → 模型】页面里
6
+ * 已配置完成的供应商与模型(NIM / Agnes / 自定义 OpenAI 协议供应商等)。
7
+ *
8
+ * 三条铁律:
9
+ * 1. 插件**绝不**向 LiteLLM 网关拉取模型清单(models/sync 已删除);
10
+ * 2. 供应商凭证(api-key、api 地址)只在 DSH 原生模型页面维护,
11
+ * 原生页面的【获取可用模型】负责向供应商拉目录 —— 插件不接管;
12
+ * 3. 只做「读取 + 归一化」,绝不写任何供应商配置。
13
+ *
14
+ * 【宿主契约(已 cordis_inspect 核对)】
15
+ * ctx.llm.listProviders(): LlmProviderInfo[] — 供应商目录
16
+ * ctx.llm.listModels(provider): Promise<LlmModelInfo[]> — 某供应商的模型
17
+ * ctx.sessionController.modelCatalog(): Promise<ModelCatalog> — 会话模型选择器用的目录
18
+ *
19
+ * 设计:三个来源按优先级短路,全部 fail-soft(取不到就返回空池 + 明确原因),
20
+ * 绝不让「模型服务缺一个方法」把插件 UI 拖崩 —— 这正是记忆里
21
+ * 「可选链 ?. 会让方法不存在静默变成空数据」那条坑的反面:
22
+ * 这里**显式**探测、**显式**报告原因,而不是静默。
23
+ */
24
+
25
+ /**
26
+ * 归一化一条模型记录。
27
+ * 不同来源字段名不一(id / model / modelId / name),统一成 UI 需要的形状。
28
+ */
29
+ function normalizeModel(raw, provider) {
30
+ if (typeof raw === 'string') {
31
+ return raw ? { id: raw, name: raw, provider, configured: true, meta: null } : null;
32
+ }
33
+ if (!raw || typeof raw !== 'object') return null;
34
+ const id = String(raw.id ?? raw.model ?? raw.modelId ?? raw.name ?? '').trim();
35
+ if (!id) return null;
36
+ return {
37
+ id,
38
+ name: String(raw.label ?? raw.displayName ?? raw.name ?? id),
39
+ provider,
40
+ // DSH 的「已配置完成」= 该供应商在【设置-模型】里有可用凭证。
41
+ // 模型目录里出现即代表供应商已配置,否则拉不到模型。
42
+ configured: raw.configured !== false,
43
+ meta: raw.contextWindow != null ? { contextWindow: raw.contextWindow } : null,
44
+ };
45
+ }
46
+
47
+ /** 归一化一条供应商记录。 */
48
+ function normalizeProvider(raw) {
49
+ if (typeof raw === 'string') {
50
+ return raw ? { id: raw, name: raw, kind: null, configured: true, baseUrl: null, authEnv: [] } : null;
51
+ }
52
+ if (!raw || typeof raw !== 'object') return null;
53
+ const id = String(raw.id ?? raw.provider ?? raw.name ?? raw.key ?? '').trim();
54
+ if (!id) return null;
55
+ return {
56
+ id,
57
+ name: String(raw.displayName ?? raw.label ?? raw.name ?? id),
58
+ kind: raw.kind ?? raw.type ?? null,
59
+ configured: raw.configured !== false,
60
+ /**
61
+ * 【关键】LlmConfigurableProvider 带 defaultBaseURL / baseURLEnv / nativeAuth,
62
+ * LlmProviderInfo 只有 {id, name}。网关要能真的调到上游,必须知道 api_base,
63
+ * 所以这里把能拿到的都记下来;拿不到就让上层明确报错,绝不猜。
64
+ */
65
+ baseUrl: raw.defaultBaseURL ?? raw.baseURL ?? null,
66
+ baseUrlEnv: Array.isArray(raw.baseURLEnv) ? raw.baseURLEnv : [],
67
+ authEnv: Array.isArray(raw.nativeAuth?.env) ? raw.nativeAuth.env : [],
68
+ settingsNs: raw.settingsNs ?? null,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * 从 sessionController.modelCatalog() 的返回里抽模型清单。
74
+ * ModelCatalog 的具体形状随版本变化,这里做多形状嗅探,取不到就返回 null。
75
+ */
76
+ function modelsFromCatalog(catalog) {
77
+ if (!catalog || typeof catalog !== 'object') return null;
78
+ const out = [];
79
+ const visit = (node, providerHint) => {
80
+ if (!node) return;
81
+ if (Array.isArray(node)) {
82
+ for (const n of node) visit(n, providerHint);
83
+ return;
84
+ }
85
+ if (typeof node !== 'object') return;
86
+ // 形状 A:{ providers: [ { id, models: [...] } ] }
87
+ if (Array.isArray(node.providers)) {
88
+ for (const p of node.providers) visit(p, normalizeProvider(p)?.id || providerHint);
89
+ return;
90
+ }
91
+ // 形状 B:{ models: [...] } 或 { providers: { pid: { models: [...] } } }
92
+ if (Array.isArray(node.models)) {
93
+ for (const m of node.models) {
94
+ const n = normalizeModel(m, providerHint);
95
+ if (n) out.push(n);
96
+ }
97
+ return;
98
+ }
99
+ if (node.models && typeof node.models === 'object') {
100
+ const pid = providerHint || normalizeProvider(node)?.id || null;
101
+ for (const m of Object.values(node.models)) {
102
+ const n = normalizeModel(m, pid);
103
+ if (n) out.push(n);
104
+ }
105
+ return;
106
+ }
107
+ // 形状 C:Map/对象里直接挂模型:{ [id]: {...} }
108
+ const selfAsProvider = normalizeProvider(node);
109
+ const n = normalizeModel(node, providerHint || selfAsProvider?.id || null);
110
+ if (n) out.push(n);
111
+ };
112
+ visit(catalog, null);
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * 汇总模型池(多来源短路)。
118
+ *
119
+ * @param {object} ctx 宿主上下文
120
+ * @param {object} [opts]
121
+ * @param {AbortSignal} [opts.signal]
122
+ * @returns {Promise<{providers:Array, models:Array, source:string|null, reason:string|null}>}
123
+ */
124
+ export async function collectModelPool(ctx, { signal } = {}) {
125
+ const providers = [];
126
+ const models = [];
127
+ let source = null;
128
+ let reason = null;
129
+
130
+ const llm = (() => {
131
+ try { return typeof ctx?.get === 'function' ? ctx.get('llm') : ctx?.llm; } catch { return undefined; }
132
+ })();
133
+ const sessionController = (() => {
134
+ try { return typeof ctx?.get === 'function' ? ctx.get('sessionController') : ctx?.sessionController; } catch { return undefined; }
135
+ })();
136
+
137
+ // ---- 来源 1:llm.listProviders() + llm.listModels(provider) -------------
138
+ if (llm && typeof llm.listProviders === 'function') {
139
+ try {
140
+ // 1a) 先读 configurable providers —— 这里有 baseURL / env,是供应链的关键
141
+ const configurable = [];
142
+ if (typeof llm.listConfigurableProviders === 'function') {
143
+ try {
144
+ for (const p of (await llm.listConfigurableProviders()) || []) {
145
+ const np = normalizeProvider(p);
146
+ if (np) configurable.push(np);
147
+ }
148
+ } catch {
149
+ /* 目录不可用不影响主流程:只是拿不到 baseURL */
150
+ }
151
+ }
152
+
153
+ const rawProviders = await llm.listProviders();
154
+ for (const p of rawProviders || []) {
155
+ const np = normalizeProvider(p);
156
+ if (!np) continue;
157
+ // 若能对上 configurable 目录,合并 baseURL / env 信息
158
+ const extra = configurable.find((c) => c.id === np.id || c.settingsNs === np.id);
159
+ providers.push(extra ? { ...extra, ...np, baseUrl: extra.baseUrl, baseUrlEnv: extra.baseUrlEnv, authEnv: extra.authEnv } : np);
160
+ }
161
+ // configurable 里有、但还没注册 adapter 的也算「DSH 里配置过的供应商」
162
+ for (const c of configurable) {
163
+ if (!providers.some((p) => p.id === c.id)) providers.push(c);
164
+ }
165
+
166
+ if (providers.length && typeof llm.listModels === 'function') {
167
+ for (const p of providers) {
168
+ if (signal?.aborted) break;
169
+ try {
170
+ const rows = await llm.listModels(p.id);
171
+ for (const r of rows || []) {
172
+ const nm = normalizeModel(r, p.id);
173
+ if (nm) models.push(nm);
174
+ }
175
+ } catch {
176
+ /* 单个供应商拉不到模型不影响其它供应商 */
177
+ }
178
+ }
179
+ if (models.length) source = 'dsh.llm';
180
+ } else if (providers.length && typeof llm.listModels !== 'function') {
181
+ reason = 'llm.listModels 不可用(inspect 已知该方法,但运行时未挂载)';
182
+ }
183
+ } catch (err) {
184
+ reason = `llm.listProviders 失败: ${String(err?.message || err).slice(0, 200)}`;
185
+ }
186
+ } else if (!llm) {
187
+ reason = 'llm 服务未挂载(inject 未包含 llm)';
188
+ } else {
189
+ reason = 'llm.listProviders 不可用';
190
+ }
191
+
192
+ // ---- 来源 2:sessionController.modelCatalog() --------------------------
193
+ if (!models.length && sessionController && typeof sessionController.modelCatalog === 'function') {
194
+ try {
195
+ const catalog = await sessionController.modelCatalog();
196
+ const rows = modelsFromCatalog(catalog) || [];
197
+ for (const r of rows) {
198
+ if (!models.some((m) => m.id === r.id && m.provider === r.provider)) models.push(r);
199
+ }
200
+ if (rows.length) {
201
+ source = source || 'dsh.sessionController.modelCatalog';
202
+ reason = null;
203
+ // 目录若带供应商信息,补齐 providers 列表
204
+ for (const r of rows) {
205
+ if (r.provider && !providers.some((p) => p.id === r.provider)) {
206
+ providers.push({ id: r.provider, name: r.provider, kind: null, configured: true });
207
+ }
208
+ }
209
+ } else if (!reason) {
210
+ reason = 'modelCatalog 返回了数据但无法解析出模型';
211
+ }
212
+ } catch (err) {
213
+ reason = reason || `modelCatalog 失败: ${String(err?.message || err).slice(0, 200)}`;
214
+ }
215
+ }
216
+
217
+ // 去重(同 id 同 provider 只留一条),按 provider → id 排序,UI 稳定
218
+ const seen = new Set();
219
+ const deduped = [];
220
+ for (const m of models) {
221
+ const key = `${m.provider || ''}::${m.id}`;
222
+ if (seen.has(key)) continue;
223
+ seen.add(key);
224
+ deduped.push(m);
225
+ }
226
+ deduped.sort((a, b) => String(a.provider || '').localeCompare(String(b.provider || '')) || a.id.localeCompare(b.id));
227
+ providers.sort((a, b) => a.id.localeCompare(b.id));
228
+
229
+ return { providers, models: deduped, source, reason };
230
+ }
231
+
232
+ /**
233
+ * 从「选中的降级链」里取出去重后的模型 id 列表(primary + fallbacks)。
234
+ *
235
+ * profile.routes 结构:{ [pid]: { agents: { root: {primary, fallbacks, timeoutMs}, ... } } }
236
+ */
237
+ export function chainModelIds(routes, profileId, agentType) {
238
+ const agents = routes?.[profileId]?.agents;
239
+ const cfg = agents?.[agentType];
240
+ if (!cfg) return [];
241
+ const ids = [];
242
+ if (cfg.primary) ids.push(String(cfg.primary).trim());
243
+ for (const f of cfg.fallbacks || []) {
244
+ const v = String(f || '').trim();
245
+ if (v) ids.push(v);
246
+ }
247
+ return [...new Set(ids.filter(Boolean))];
248
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * 本地探针日志环形缓冲
3
+ * ============================================================================
4
+ *
5
+ * 网关的 /spend/logs 需要 PostgreSQL,纯 config.yaml 模式下拿不到任何日志。
6
+ * 但「批量连通性测试」的失败原因必须能看到 —— 所以插件自己记一份。
7
+ *
8
+ * 设计:
9
+ * - 纯内存环形缓冲,容量固定(默认 50,与日志面板策略一致),不会膨胀;
10
+ * - 只记**探针失败**与批量测试的汇总,不记成功请求(避免刷屏);
11
+ * - 与网关日志合并展示时带 `source: 'local'` 标记,UI 可以区分来源;
12
+ * - 结构对齐 gateway-client 的 normalizeLogs 输出,UI 复用同一套渲染。
13
+ *
14
+ * 注意:这里绝不记录任何密钥或请求体内容,只记模型名、错误摘要、耗时。
15
+ */
16
+
17
+ export class ProbeLog {
18
+ constructor(limit = 50) {
19
+ this.limit = Math.max(1, Math.min(500, Number(limit) || 50));
20
+ this._rows = [];
21
+ this._seq = 0;
22
+ }
23
+
24
+ /** 记录一条探针结果。只保留失败项,成功项用于统计用不落这里。 */
25
+ add({ model, ok, latencyMs, error, agentType, profile, batchId }) {
26
+ if (ok) return null;
27
+ const row = {
28
+ id: `local-${++this._seq}`,
29
+ source: 'local',
30
+ time: new Date().toISOString(),
31
+ model: model || '-',
32
+ modelGroup: null,
33
+ agentType: agentType || '-',
34
+ profile: profile || '-',
35
+ status: 'failure',
36
+ // 明确标记:这是插件侧探针失败,不是网关调用日志
37
+ error: String(error || '未知错误').slice(0, 300),
38
+ latencyMs: Number.isFinite(latencyMs) ? latencyMs : null,
39
+ tokens: null,
40
+ spend: null,
41
+ // 探针失败天然意味着「该模型这条链路不可用」,归为 fallback 候选失效
42
+ fallback: 'unknown',
43
+ batchId: batchId || null,
44
+ };
45
+ this._rows.unshift(row);
46
+ if (this._rows.length > this.limit) this._rows.length = this.limit;
47
+ return row;
48
+ }
49
+
50
+ /**
51
+ * 【需求7】记录一条网关生命周期事件(启动 / 就绪 / 异常退出 / 已停止)。
52
+ *
53
+ * 这类事件与探针失败是不同性质的东西,但都要在日志面板里能看到 ——
54
+ * 否则用户只会觉得「降级没生效」,实际是网关根本没在跑。
55
+ * 用 source='gateway' 区分来源,status 按 level 映射颜色:
56
+ * error → failure(红);warn → failure 之外的 warn 语义;info → normal
57
+ */
58
+ addGatewayEvent({ level = 'info', message = '', at } = {}) {
59
+ const row = {
60
+ id: `gw-${++this._seq}`,
61
+ source: 'gateway',
62
+ time: at || new Date().toISOString(),
63
+ model: 'LiteLLM 网关',
64
+ modelGroup: null,
65
+ agentType: '-',
66
+ profile: '-',
67
+ // 只有 error 才染红;warn 用 fallback=yes 走橙色,info 是普通行
68
+ status: level === 'error' ? 'failure' : 'success',
69
+ error: level === 'error' || level === 'warn' ? String(message).slice(0, 300) : null,
70
+ latencyMs: null,
71
+ tokens: null,
72
+ spend: null,
73
+ fallback: level === 'warn' ? 'yes' : 'unknown',
74
+ batchId: null,
75
+ // UI 用它渲染「运行中 / 已停止 / 异常」这类标签
76
+ gatewayLevel: level,
77
+ message: String(message).slice(0, 300),
78
+ };
79
+ this._rows.unshift(row);
80
+ if (this._rows.length > this.limit) this._rows.length = this.limit;
81
+ return row;
82
+ }
83
+
84
+ /** 批量测试开始时清掉上一批的本地记录,避免堆积。 */
85
+ clearBatch(batchId) {
86
+ if (!batchId) return;
87
+ this._rows = this._rows.filter((r) => r.batchId !== batchId);
88
+ }
89
+
90
+ clearAll() {
91
+ this._rows = [];
92
+ }
93
+
94
+ list(limit) {
95
+ const n = Math.max(1, Math.min(this.limit, Number(limit) || this.limit));
96
+ return this._rows.slice(0, n);
97
+ }
98
+
99
+ get size() {
100
+ return this._rows.length;
101
+ }
102
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * 子代理路由覆盖
3
+ * ============================================================================
4
+ *
5
+ * 【需求】
6
+ * 会话选中的供应商为 Trae / WorkBuddy 时:
7
+ * - root 主 Agent 保留原供应商(继续消耗 Trae/WorkBuddy 积分);
8
+ * - 所有 spawn 出来的子 Agent 强制覆盖为 LitellM-Gateway,
9
+ * 自动读会话 profile 并注入 header,走网关降级池。
10
+ * 会话选中的供应商为 LitellM-Gateway 时:
11
+ * - 主 Agent、子 Agent 全部走网关链路(不做任何覆盖)。
12
+ *
13
+ * 【为什么用 internal/get 而不是事件】
14
+ * DSH 里**没有** `agent/spawn` 事件(已核对宿主全部约 85 个事件)。
15
+ * 而且「子代理用什么模型」是在子会话创建**之前**定下来的,
16
+ * 任何事后事件都改不了。
17
+ *
18
+ * 真正的 hook 是 cordis 的「服务读取 waterfall」:
19
+ * ctx.on('internal/get', (readCtx, name, error, next) => ...)
20
+ * 当有人读 `subagents` 服务时,我们返回一个**原型委托包装**,
21
+ * 它的 startActivation() 在委派给真服务之前改写 spec.request.agentOptions。
22
+ * 原服务对象**从不被修改**,其他读者透明拿到包装。
23
+ *
24
+ * 这个做法与 dsh-llm-fallbacks 的 subagent seam 完全同构
25
+ * (见其 dist/index.js 的 installSubagentSeam / wrapService)。
26
+ *
27
+ * 【为什么不用 llm/stream】
28
+ * llm/stream 拿到的 GenerateOptions 是**深冻结**的(改会抛异常),
29
+ * 而且它在语义上是「读」,不是「选路由」。用 internal/get 在源头改
30
+ * 才是干净的做法。
31
+ */
32
+
33
+ /** 触发「子代理强制走网关」的供应商(大小写不敏感)。 */
34
+ export const NATIVE_PROVIDERS = ['trae', 'workbuddy'];
35
+
36
+ /** 子代理被覆盖到的供应商名。需与网关在 DSH 里的 provider 路由名一致。 */
37
+ export const GATEWAY_PROVIDER = 'litellm-gateway';
38
+
39
+ /** 网关里对应各 agent 类型的「对外模型名」,与 config.yaml.template 对齐。 */
40
+ export const GATEWAY_MODEL_BY_AGENT = {
41
+ root: 'root-primary',
42
+ coder: 'coder-primary',
43
+ research: 'research-primary',
44
+ };
45
+
46
+ /** 供应商名归一化:去掉区域/账号后缀,统一小写比较。 */
47
+ export function normalizeProvider(name) {
48
+ return String(name || '').trim().toLowerCase();
49
+ }
50
+
51
+ /**
52
+ * 该供应商是否属于「原生积分供应商」——即需要把子代理改道网关的那些。
53
+ * 用 includes 而非全等,以兼容 `trae`、`trae-cn`、`workbuddy`、`workbuddy-cn` 等写法。
54
+ */
55
+ export function isNativeProvider(name) {
56
+ const n = normalizeProvider(name);
57
+ if (!n) return false;
58
+ return NATIVE_PROVIDERS.some((p) => n === p || n.startsWith(`${p}-`) || n.startsWith(`${p}_`));
59
+ }
60
+
61
+ /** 是否已经是网关供应商(这种情况一律不覆盖)。 */
62
+ export function isGatewayProvider(name) {
63
+ const n = normalizeProvider(name);
64
+ return n === GATEWAY_PROVIDER || n === 'litellm' || n.startsWith('litellm-');
65
+ }
66
+
67
+ /**
68
+ * 从会话日志里推断「本会话选中的供应商」。
69
+ *
70
+ * 优先用已经解析好的选择(调用方传入),其次读会话事件里的模型选择记录。
71
+ * 读不到就返回 undefined —— 此时**不做任何覆盖**(fail-open 到原生行为),
72
+ * 因为「猜错了供应商」比「不覆盖」危险得多。
73
+ */
74
+ export function detectSessionProvider(session, readEvent) {
75
+ try {
76
+ // 1) 会话头里可能直接带
77
+ const direct =
78
+ session?.header?.model?.provider ||
79
+ session?.modelSelection?.provider ||
80
+ session?.selection?.provider;
81
+ if (direct) return String(direct);
82
+
83
+ // 2) 扫会话事件里最后一次模型选择
84
+ if (typeof readEvent === 'function') {
85
+ const last = readEvent();
86
+ const p = last?.data?.provider || last?.data?.selection?.provider || last?.data?.model?.provider;
87
+ if (p) return String(p);
88
+ }
89
+ } catch {
90
+ /* 探测失败不致命:返回 undefined 表示不覆盖 */
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ /**
96
+ * 根据 spec 判断这是不是「子代理派发」,并解析出父会话 id。
97
+ * 主 Agent 自己发起的请求不会走 startActivation,所以走到这里的基本都是子代理;
98
+ * 但仍显式校验 parent 存在,避免误伤。
99
+ */
100
+ export function parentSessionIdOf(spec) {
101
+ const id = spec?.request?.parent?.session?.id ?? spec?.request?.parent?.id;
102
+ return typeof id === 'string' && id !== '' ? id : undefined;
103
+ }
104
+
105
+ /**
106
+ * 决定这一次子代理派发要不要覆盖,覆盖成什么。
107
+ * 纯函数,方便单测;不碰任何服务。
108
+ *
109
+ * @returns {{override:false, reason:string} | {override:true, reason:string, provider:string, model:string, agentType:string}}
110
+ */
111
+ export function decideOverride({ spec, sessionProvider, profile, agentType, natives, gateway }) {
112
+ const nativeList = Array.isArray(natives) && natives.length ? natives : NATIVE_PROVIDERS;
113
+ const gatewayName = gateway || GATEWAY_PROVIDER;
114
+
115
+ // 已经显式指定了路由的派发不动它(尊重调用方意图)
116
+ const explicitProvider = spec?.request?.agentOptions?.provider;
117
+
118
+ const n = normalizeProvider(sessionProvider);
119
+ const isNative = n
120
+ ? nativeList.some((p) => {
121
+ const np = normalizeProvider(p);
122
+ return n === np || n.startsWith(`${np}-`) || n.startsWith(`${np}_`);
123
+ })
124
+ : false;
125
+ const isGateway = n === normalizeProvider(gatewayName) || n === 'litellm' || n.startsWith('litellm-');
126
+
127
+ if (isGateway) {
128
+ return { override: false, reason: '会话已在网关供应商,主/子代理都走网关,无需覆盖' };
129
+ }
130
+
131
+ if (!isNative) {
132
+ return {
133
+ override: false,
134
+ reason: n
135
+ ? `会话供应商 "${sessionProvider}" 不在覆盖名单(${nativeList.join('/')})`
136
+ : '无法确定会话供应商,保持原生行为',
137
+ };
138
+ }
139
+
140
+ // 到这里说明:会话是 Trae/WorkBuddy,子代理要改道网关
141
+ const type = agentType && GATEWAY_MODEL_BY_AGENT[agentType] ? agentType : 'root';
142
+ return {
143
+ override: true,
144
+ reason: `会话供应商 "${sessionProvider}" 属原生积分池,子代理改道网关`,
145
+ provider: gatewayName,
146
+ model: GATEWAY_MODEL_BY_AGENT[type],
147
+ agentType: type,
148
+ // 覆盖前显式带上原 provider,仅用于日志
149
+ fromProvider: explicitProvider || sessionProvider,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * 安装服务读取 waterfall,包装 `subagents` 服务。
155
+ *
156
+ * @param {object} ctx cordis 上下文
157
+ * @param {object} options
158
+ * @param {(parentSessionId:string)=>string|undefined} options.providerFor
159
+ * 给定父会话 id,返回该会话选中的供应商
160
+ * @param {(parentSessionId:string)=>string|undefined} options.profileFor
161
+ * 给定父会话 id,返回该会话绑定的 profile
162
+ * @param {(agentType:string)=>string|undefined} options.modelFor
163
+ * 给定 agent 类型,返回网关侧模型名(默认用 GATEWAY_MODEL_BY_AGENT)
164
+ * @param {(msg:string)=>void} [options.debug]
165
+ * @returns {{dispose:()=>void, stats:()=>object}}
166
+ */
167
+ export function installSubagentRouting(ctx, options = {}) {
168
+ const debug = (m) => {
169
+ try {
170
+ options.debug?.(m);
171
+ } catch {
172
+ /* 日志失败绝不影响主流程 */
173
+ }
174
+ };
175
+
176
+ // 允许 profile config 覆盖名单与目标供应商
177
+ const natives = Array.isArray(options.nativeProviders) && options.nativeProviders.length
178
+ ? options.nativeProviders.map(normalizeProvider)
179
+ : NATIVE_PROVIDERS;
180
+ const gateway = options.gatewayProvider ? normalizeProvider(options.gatewayProvider) : GATEWAY_PROVIDER;
181
+
182
+ const stats = { evaluated: 0, overridden: 0, skipped: 0, degraded: 0 };
183
+ const wrappers = new WeakMap();
184
+ const disposers = [];
185
+
186
+ const wrapService = (value) => {
187
+ if (typeof value !== 'object' || value === null) return value;
188
+ if (typeof value.startActivation !== 'function') {
189
+ debug('[litellm-bridge] subagents 服务不是 activation-capable runtime,跳过包装');
190
+ return value;
191
+ }
192
+ const cached = wrappers.get(value);
193
+ if (cached !== undefined) return cached;
194
+
195
+ // 原型委托:原对象一个字节都不改,其他读者透明拿到包装
196
+ const wrapper = Object.create(value);
197
+ wrapper.startActivation = function startActivation(spec) {
198
+ try {
199
+ const parentSessionId = parentSessionIdOf(spec);
200
+ const sessionProvider = parentSessionId ? options.providerFor?.(parentSessionId) : undefined;
201
+ const profile = parentSessionId ? options.profileFor?.(parentSessionId) : undefined;
202
+ const agentType = options.agentTypeFor?.(spec) || 'root';
203
+
204
+ stats.evaluated += 1;
205
+ const verdict = decideOverride({
206
+ spec, sessionProvider, profile, agentType,
207
+ natives, gateway,
208
+ });
209
+
210
+ if (!verdict.override) {
211
+ stats.skipped += 1;
212
+ debug(`[litellm-bridge] 子代理不改道:${verdict.reason}`);
213
+ return value.startActivation(spec);
214
+ }
215
+
216
+ const model = options.modelFor?.(verdict.agentType, parentSessionId) || verdict.model;
217
+ const agentOptions = spec?.request?.agentOptions;
218
+
219
+ // 改写路由:只换 provider/model,其余字段原样保留
220
+ const nextSpec = {
221
+ ...spec,
222
+ request: {
223
+ ...spec.request,
224
+ agentOptions: {
225
+ ...agentOptions,
226
+ provider: verdict.provider,
227
+ model,
228
+ },
229
+ },
230
+ };
231
+
232
+ stats.overridden += 1;
233
+ debug(
234
+ `[litellm-bridge] 子代理改道网关:${verdict.fromProvider} -> ${verdict.provider}/${model}` +
235
+ `${profile ? `(profile=${profile})` : ''}`,
236
+ );
237
+ return value.startActivation(nextSpec);
238
+ } catch (err) {
239
+ // 任何异常都退回原生行为:绝不因为我们的改写让子代理起不来
240
+ stats.degraded += 1;
241
+ debug(`[litellm-bridge] 子代理改道失败,退回原生路径:${err?.message || err}`);
242
+ return value.startActivation(spec);
243
+ }
244
+ };
245
+
246
+ wrappers.set(value, wrapper);
247
+ return wrapper;
248
+ };
249
+
250
+ const onRead = (_readCtx, name, _error, next) => {
251
+ const value = next();
252
+ if (name !== 'subagents') return value;
253
+ try {
254
+ return wrapService(value);
255
+ } catch (err) {
256
+ stats.degraded += 1;
257
+ debug(`[litellm-bridge] 包装 subagents 失败,返回原服务:${err?.message || err}`);
258
+ return value;
259
+ }
260
+ };
261
+
262
+ try {
263
+ disposers.push(ctx.on('internal/get', onRead));
264
+ } catch (err) {
265
+ debug(`[litellm-bridge] 无法注册 internal/get 监听:${err?.message || err}`);
266
+ }
267
+
268
+ const dispose = () => {
269
+ for (const withdraw of disposers.splice(0)) {
270
+ try {
271
+ withdraw();
272
+ } catch {
273
+ /* 清理失败忽略 */
274
+ }
275
+ }
276
+ };
277
+
278
+ debug('[litellm-bridge] 子代理路由覆盖已安装(服务读取 waterfall)');
279
+ return { dispose, stats: () => ({ ...stats }) };
280
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "meta": {
3
+ "title": "LiteLLM Gateway Bridge",
4
+ "description": "Configure the LiteLLM gateway URL, compose fallback routes per agent type, inspect gateway call logs, and bind a session profile. The plugin holds no routing/retry/fallback logic — the external gateway owns all of it."
5
+ },
6
+ "tabs": {
7
+ "gateway": "Gateway",
8
+ "routes": "Routes",
9
+ "logs": "Logs",
10
+ "session": "Session"
11
+ },
12
+ "gateway": {
13
+ "url": "Gateway URL",
14
+ "masterKey": "Gateway master admin key",
15
+ "fromEnv": "From .env environment variable",
16
+ "test": "Test connection",
17
+ "save": "Save"
18
+ },
19
+ "routes": {
20
+ "primary": "Primary model",
21
+ "timeout": "Timeout (ms)",
22
+ "fallbacks": "Fallback models (drag to reorder; order is priority)",
23
+ "saveAndReload": "Save and hot-reload",
24
+ "save": "Save only",
25
+ "preview": "Preview YAML"
26
+ },
27
+ "logs": {
28
+ "fallbackHint": "Amber = fallback triggered",
29
+ "failedHint": "Red = whole chain failed",
30
+ "refresh": "Refresh"
31
+ },
32
+ "session": {
33
+ "bindProfile": "Profile bound to this session",
34
+ "saveToSession": "Save to session variable",
35
+ "probe": "Run probe test",
36
+ "recentDrops": "Recent fallbacks in this session",
37
+ "subagentRouting": "Sub-agent routing override",
38
+ "currentProvider": "Current provider",
39
+ "rerouteToGateway": "Sub-agents will be rerouted to gateway",
40
+ "allViaGateway": "Everything via gateway",
41
+ "noOverride": "No override"
42
+ }
43
+ }