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.
- package/README.md +165 -2
- package/docs/README.md +716 -0
- package/dsh-plugin-litellm-bridge/README.md +32 -0
- package/dsh-plugin-litellm-bridge/client/index.js +1336 -0
- package/dsh-plugin-litellm-bridge/cordis.patch.yml +27 -0
- package/dsh-plugin-litellm-bridge/icon.svg +12 -0
- package/dsh-plugin-litellm-bridge/lib/assemble-chain.js +193 -0
- package/dsh-plugin-litellm-bridge/lib/config-store.js +290 -0
- package/dsh-plugin-litellm-bridge/lib/gateway-client.js +587 -0
- package/dsh-plugin-litellm-bridge/lib/gateway-supervisor.js +518 -0
- package/dsh-plugin-litellm-bridge/lib/index.js +760 -0
- package/dsh-plugin-litellm-bridge/lib/model-pool.js +248 -0
- package/dsh-plugin-litellm-bridge/lib/probe-log.js +102 -0
- package/dsh-plugin-litellm-bridge/lib/subagent-routing.js +280 -0
- package/dsh-plugin-litellm-bridge/locale/en.json +43 -0
- package/dsh-plugin-litellm-bridge/locale/zh.json +43 -0
- package/dsh-plugin-litellm-bridge/manifest.json +57 -0
- package/package.json +73 -4
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiteLLM 网关 REST 客户端
|
|
3
|
+
* ============================================================================
|
|
4
|
+
*
|
|
5
|
+
* 【设计铁律】
|
|
6
|
+
* 本文件只做「通过 LiteLLM 官方 REST API 与网关通信」这一件事。
|
|
7
|
+
* 绝不实现路由、重试、降级、冷却、模型选择 —— 那些 100% 由网关负责。
|
|
8
|
+
* 这里没有 retry 循环,没有 fallback 链,没有模型优先级判断。
|
|
9
|
+
*
|
|
10
|
+
* 【密钥纪律】
|
|
11
|
+
* master key 只存在于内存中的 config 对象里,来自网关侧 .env。
|
|
12
|
+
* 本模块绝不读取 .env,绝不把 key 写进日志或错误消息。
|
|
13
|
+
* redact() 会把任何疑似密钥串替换成掩码后再对外暴露。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
17
|
+
|
|
18
|
+
/** 把密钥从任意文本里抹掉,避免它出现在错误消息 / 日志里。 */
|
|
19
|
+
export function redact(text, secret) {
|
|
20
|
+
let out = String(text ?? '');
|
|
21
|
+
if (secret && secret.length >= 8) {
|
|
22
|
+
out = out.split(secret).join('sk-***REDACTED***');
|
|
23
|
+
}
|
|
24
|
+
// 兜底:抹掉任何 sk- 开头的长串
|
|
25
|
+
return out.replace(/\b(sk-[A-Za-z0-9_\-]{8,})/g, 'sk-***REDACTED***');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 统一的「结果」形状,让 UI 永远拿到结构一致的对象而不是异常。 */
|
|
29
|
+
function ok(data, extra = {}) {
|
|
30
|
+
return { ok: true, data, ...extra };
|
|
31
|
+
}
|
|
32
|
+
function fail(error, extra = {}) {
|
|
33
|
+
return { ok: false, error, ...extra };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class GatewayClient {
|
|
37
|
+
/**
|
|
38
|
+
* 密钥用真正的私有字段(#),不是 `_apiKey` 约定:
|
|
39
|
+
* - JSON.stringify(client) / console.log(client) / 展开运算符
|
|
40
|
+
* 都不会带出密钥;
|
|
41
|
+
* - 只有类内部方法读得到。
|
|
42
|
+
* 这是「UI 绝不回显明文密钥」在代码层的硬保证,而不是靠自觉。
|
|
43
|
+
*/
|
|
44
|
+
#apiKey = '';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} opts
|
|
48
|
+
* @param {string} opts.baseUrl 网关地址,如 http://127.0.0.1:4000
|
|
49
|
+
* @param {string} opts.apiKey master admin key(来自 .env,仅存内存)
|
|
50
|
+
* @param {number} opts.timeoutMs
|
|
51
|
+
*/
|
|
52
|
+
constructor({ baseUrl, apiKey, timeoutMs = 30000 } = {}) {
|
|
53
|
+
this.baseUrl = String(baseUrl || '').replace(/\/+$/, '');
|
|
54
|
+
this.#apiKey = apiKey || '';
|
|
55
|
+
this.timeoutMs = timeoutMs;
|
|
56
|
+
/** 探测到的可用热重载端点(缓存,避免每次保存都全量探测) */
|
|
57
|
+
this._reloadEndpoint = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
update({ baseUrl, apiKey, timeoutMs } = {}) {
|
|
61
|
+
if (baseUrl !== undefined) this.baseUrl = String(baseUrl || '').replace(/\/+$/, '');
|
|
62
|
+
if (apiKey !== undefined) this.#apiKey = apiKey || '';
|
|
63
|
+
if (timeoutMs !== undefined) this.timeoutMs = timeoutMs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 仅供类内部使用;外部拿不到明文。 */
|
|
67
|
+
get _secret() {
|
|
68
|
+
return this.#apiKey;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get configured() {
|
|
72
|
+
return Boolean(this.baseUrl);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** master key 是否已从网关侧 .env 注入(只回答「有/没有」,绝不回显值)。 */
|
|
76
|
+
get hasKey() {
|
|
77
|
+
return Boolean(this.#apiKey);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_url(path) {
|
|
81
|
+
return `${this.baseUrl}${path}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async _fetch(path, { method = 'GET', body, headers = {}, auth = true, timeoutMs } = {}) {
|
|
85
|
+
if (!this.baseUrl) return fail('网关地址未配置');
|
|
86
|
+
const ctrl = new AbortController();
|
|
87
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs ?? this.timeoutMs);
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(this._url(path), {
|
|
90
|
+
method,
|
|
91
|
+
headers: {
|
|
92
|
+
...JSON_HEADERS,
|
|
93
|
+
...(auth && this.#apiKey ? { Authorization: `Bearer ${this.#apiKey}` } : {}),
|
|
94
|
+
...headers,
|
|
95
|
+
},
|
|
96
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
97
|
+
signal: ctrl.signal,
|
|
98
|
+
});
|
|
99
|
+
const text = await res.text();
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = text ? JSON.parse(text) : null;
|
|
103
|
+
} catch {
|
|
104
|
+
parsed = text;
|
|
105
|
+
}
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
return fail(
|
|
108
|
+
redact(
|
|
109
|
+
`HTTP ${res.status} ${res.statusText}: ${
|
|
110
|
+
typeof parsed === 'string' ? parsed : JSON.stringify(parsed)
|
|
111
|
+
}`,
|
|
112
|
+
this.#apiKey,
|
|
113
|
+
),
|
|
114
|
+
{ status: res.status },
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return ok(parsed, { status: res.status });
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const msg = err?.name === 'AbortError' ? `请求超时(${timeoutMs ?? this.timeoutMs}ms)` : String(err?.message || err);
|
|
120
|
+
return fail(redact(msg, this.#apiKey));
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// -------------------------------------------------------------------------
|
|
127
|
+
// 1. 连通性
|
|
128
|
+
// -------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 测试连通性。分三层,逐层给出诊断,不把「服务活着」和「key 有效」混为一谈:
|
|
132
|
+
* 1) liveliness 进程活着吗(无需鉴权)
|
|
133
|
+
* 2) readiness 能接流量吗 + 数据库状态(无需鉴权)
|
|
134
|
+
* 3) /v1/models master admin key 有效吗(需鉴权)
|
|
135
|
+
*/
|
|
136
|
+
async testConnection() {
|
|
137
|
+
const steps = [];
|
|
138
|
+
|
|
139
|
+
const live = await this._fetch('/health/liveliness', { auth: false, timeoutMs: 10000 });
|
|
140
|
+
steps.push({
|
|
141
|
+
name: '网关存活',
|
|
142
|
+
path: '/health/liveliness',
|
|
143
|
+
ok: live.ok,
|
|
144
|
+
detail: live.ok ? String(live.data).slice(0, 80) : live.error,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const ready = await this._fetch('/health/readiness', { auth: false, timeoutMs: 10000 });
|
|
148
|
+
steps.push({
|
|
149
|
+
name: '网关就绪',
|
|
150
|
+
path: '/health/readiness',
|
|
151
|
+
ok: ready.ok,
|
|
152
|
+
detail: ready.ok
|
|
153
|
+
? `db=${ready.data?.db ?? '未配置'}`
|
|
154
|
+
: ready.error,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
let modelCount = null;
|
|
158
|
+
if (this.hasKey) {
|
|
159
|
+
const models = await this._fetch('/v1/models');
|
|
160
|
+
modelCount = models.ok ? (models.data?.data || []).length : null;
|
|
161
|
+
steps.push({
|
|
162
|
+
name: 'master admin key',
|
|
163
|
+
path: '/v1/models',
|
|
164
|
+
ok: models.ok,
|
|
165
|
+
detail: models.ok ? `可见 ${modelCount} 个模型` : models.error,
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
steps.push({
|
|
169
|
+
name: 'master admin key',
|
|
170
|
+
path: '/v1/models',
|
|
171
|
+
ok: false,
|
|
172
|
+
detail: '未检测到网关 master key(应在网关侧 .env 的 LITELLM_MASTER_KEY 中)',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const passed = steps.filter((s) => s.ok).length;
|
|
177
|
+
return {
|
|
178
|
+
ok: steps[0].ok && steps[1].ok && (this.hasKey ? steps[2].ok : false),
|
|
179
|
+
steps,
|
|
180
|
+
passed,
|
|
181
|
+
total: steps.length,
|
|
182
|
+
modelCount,
|
|
183
|
+
// 只报告「key 是否存在」,绝不回显
|
|
184
|
+
keySource: this.hasKey ? '来自 .env 环境变量' : '未配置',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// -------------------------------------------------------------------------
|
|
189
|
+
// 2. 模型清单(用于 UI 里挑首选/备选模型)
|
|
190
|
+
// -------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/** GET /v1/models —— 公开的对外模型名列表。 */
|
|
193
|
+
async listModels() {
|
|
194
|
+
const res = await this._fetch('/v1/models');
|
|
195
|
+
if (!res.ok) return res;
|
|
196
|
+
const ids = (res.data?.data || []).map((m) => m?.id).filter(Boolean);
|
|
197
|
+
return ok(ids);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** GET /v1/model/info —— 带元信息(上下文窗口等),部分版本才有。 */
|
|
201
|
+
async listModelInfo() {
|
|
202
|
+
const res = await this._fetch('/v1/model/info');
|
|
203
|
+
if (!res.ok) return res;
|
|
204
|
+
return ok(res.data?.data || []);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 【已废弃 · 架构修正】从网关拉取模型清单。
|
|
209
|
+
*
|
|
210
|
+
* 旧版用它给 UI 喂模型下拉(需求9)。架构修正后模型数据源改为
|
|
211
|
+
* **DSH【设置-模型】**(见 lib/model-pool.js),插件不再向网关索要模型,
|
|
212
|
+
* 该接口也随之从宿主半移除(models/sync 已删),否则 UI 一直报 404。
|
|
213
|
+
*
|
|
214
|
+
* 保留本方法仅为「排障时手工看看网关里到底注册了什么」——
|
|
215
|
+
* 它已不再是任何 UI 路径的数据源,也没有任何调用者,
|
|
216
|
+
* model-sync-batch.test.mjs 仍在验证它的解析逻辑(防止误改坏)。
|
|
217
|
+
*/
|
|
218
|
+
async syncModels() {
|
|
219
|
+
const [infoRes, listRes] = await Promise.all([
|
|
220
|
+
this.listModelInfo(),
|
|
221
|
+
this.listModels(),
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
const byName = new Map();
|
|
225
|
+
|
|
226
|
+
// 1) 优先用 /v1/model/info 的丰富信息
|
|
227
|
+
if (infoRes.ok && Array.isArray(infoRes.data)) {
|
|
228
|
+
for (const d of infoRes.data) {
|
|
229
|
+
const name = d?.model_name || d?.model_info?.id;
|
|
230
|
+
if (!name) continue;
|
|
231
|
+
const p = d?.litellm_params || {};
|
|
232
|
+
const upstream = String(p.model || '');
|
|
233
|
+
byName.set(name, {
|
|
234
|
+
name,
|
|
235
|
+
upstream,
|
|
236
|
+
apiBase: p.api_base || null,
|
|
237
|
+
// 派生一个 "provider" 标签,UI 可分组显示
|
|
238
|
+
provider: guessProvider(upstream, p.api_base),
|
|
239
|
+
// 空模型名是明显的坏配置(${VAR} 未解析),标记出来让人看见
|
|
240
|
+
broken: !upstream || upstream === 'openai/' || /\/$/.test(upstream),
|
|
241
|
+
timeout: p.timeout ?? null,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 2) 补齐 /v1/models 里有、info 里没有的
|
|
247
|
+
if (listRes.ok && Array.isArray(listRes.data)) {
|
|
248
|
+
for (const name of listRes.data) {
|
|
249
|
+
if (!byName.has(name)) {
|
|
250
|
+
byName.set(name, {
|
|
251
|
+
name, upstream: '', apiBase: null, provider: 'unknown', broken: false, timeout: null,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const models = [...byName.values()];
|
|
258
|
+
return ok({
|
|
259
|
+
models,
|
|
260
|
+
count: models.length,
|
|
261
|
+
// 供 UI 直接喂给 datalist
|
|
262
|
+
names: models.map((m) => m.name),
|
|
263
|
+
broken: models.filter((m) => m.broken).map((m) => m.name),
|
|
264
|
+
sources: {
|
|
265
|
+
modelInfo: infoRes.ok ? infoRes.data.length : null,
|
|
266
|
+
models: listRes.ok ? listRes.data.length : null,
|
|
267
|
+
},
|
|
268
|
+
error: infoRes.ok || listRes.ok ? null : infoRes.error || listRes.error,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 【需求10】批量连通性测试:对指定模型列表发起轻量探针。
|
|
274
|
+
*
|
|
275
|
+
* 默认并发 2、单模型超时 60s —— 这两个值来自实测,不是拍脑袋:
|
|
276
|
+
* - 全串行 11 个模型要 398s(上游 agnes/zhipu 单次就要 20–90s);
|
|
277
|
+
* - 并发 3 + 30s 超时反而更糟:慢模型互相争抢,11 个里 7 个因超时误判失败,
|
|
278
|
+
* 还触发上游 429。
|
|
279
|
+
* - 并发 2 + 60s 是「总时长可接受」与「不误判」的平衡点。
|
|
280
|
+
* 两者都可以由调用方(UI)覆盖,方便按自己的上游速度调。
|
|
281
|
+
*
|
|
282
|
+
* @param {object} opts
|
|
283
|
+
* @param {string[]} opts.models 要测的模型名列表
|
|
284
|
+
* @param {number} [opts.concurrency] 并发度(默认 2,上限 8)
|
|
285
|
+
* @param {number} [opts.timeoutMs] 单个模型超时(默认 60s)
|
|
286
|
+
* @param {(r:object)=>void} [opts.onResult] 每测完一个回调(UI 可增量渲染)
|
|
287
|
+
* @param {AbortSignal} [opts.signal] 供「停止」按钮中断
|
|
288
|
+
*/
|
|
289
|
+
async probeBatch({
|
|
290
|
+
models = [],
|
|
291
|
+
agentType = 'root',
|
|
292
|
+
profile = 'profile-a',
|
|
293
|
+
concurrency = 2,
|
|
294
|
+
timeoutMs = 60000,
|
|
295
|
+
onResult,
|
|
296
|
+
signal,
|
|
297
|
+
} = {}) {
|
|
298
|
+
const results = [];
|
|
299
|
+
let aborted = false;
|
|
300
|
+
const width = Math.max(1, Math.min(8, Number(concurrency) || 2));
|
|
301
|
+
let cursor = 0;
|
|
302
|
+
|
|
303
|
+
const runOne = async (model) => {
|
|
304
|
+
const started = Date.now();
|
|
305
|
+
try {
|
|
306
|
+
const res = await this.probe({ model, agentType, profile, timeoutMs });
|
|
307
|
+
return res.ok
|
|
308
|
+
? {
|
|
309
|
+
model, ok: true,
|
|
310
|
+
latencyMs: res.latencyMs ?? (Date.now() - started),
|
|
311
|
+
reply: String(res.data?.reply ?? '').slice(0, 80),
|
|
312
|
+
modelUsed: res.data?.modelUsed ?? model,
|
|
313
|
+
error: null,
|
|
314
|
+
}
|
|
315
|
+
: {
|
|
316
|
+
model, ok: false,
|
|
317
|
+
latencyMs: res.latencyMs ?? (Date.now() - started),
|
|
318
|
+
reply: null, modelUsed: null,
|
|
319
|
+
error: String(res.error || '未知错误'),
|
|
320
|
+
};
|
|
321
|
+
} catch (err) {
|
|
322
|
+
return {
|
|
323
|
+
model, ok: false, latencyMs: Date.now() - started,
|
|
324
|
+
reply: null, modelUsed: null, error: String(err?.message || err),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// 固定宽度的工作者池:每个 worker 不断取下一個模型,直到列表耗尽
|
|
330
|
+
const worker = async () => {
|
|
331
|
+
for (;;) {
|
|
332
|
+
if (signal?.aborted) { aborted = true; return; }
|
|
333
|
+
const idx = cursor++;
|
|
334
|
+
if (idx >= models.length) return;
|
|
335
|
+
const entry = await runOne(models[idx]);
|
|
336
|
+
results.push(entry);
|
|
337
|
+
try {
|
|
338
|
+
onResult?.(entry, results.length, models.length);
|
|
339
|
+
} catch {
|
|
340
|
+
/* UI 回调异常不影响后续测试 */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
await Promise.all(Array.from({ length: Math.min(width, models.length) }, () => worker()));
|
|
346
|
+
|
|
347
|
+
// 结果按原始顺序排列,方便 UI 与清单对齐对照
|
|
348
|
+
const order = new Map(models.map((m, i) => [m, i]));
|
|
349
|
+
results.sort((a, b) => (order.get(a.model) ?? 0) - (order.get(b.model) ?? 0));
|
|
350
|
+
|
|
351
|
+
const passed = results.filter((r) => r.ok).length;
|
|
352
|
+
return ok({
|
|
353
|
+
results,
|
|
354
|
+
total: models.length,
|
|
355
|
+
passed,
|
|
356
|
+
failed: results.length - passed,
|
|
357
|
+
aborted,
|
|
358
|
+
concurrency: width,
|
|
359
|
+
finishedAt: new Date().toISOString(),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// -------------------------------------------------------------------------
|
|
364
|
+
// 3. 热重载
|
|
365
|
+
// -------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 探测「本网关真正可用」的配置热重载端点。
|
|
369
|
+
*
|
|
370
|
+
* 实测事实(本机 litellm 1.108.3):
|
|
371
|
+
* - POST /config/reload -> 404 该构建没有这条路由
|
|
372
|
+
* - POST /reload/config -> 404 同上
|
|
373
|
+
* - POST /config/update -> 400 "Authentication Error, No DB Connected"
|
|
374
|
+
* 路由存在,但**需要数据库**(store_model_in_db)。纯 config.yaml 模式下它不可用。
|
|
375
|
+
*
|
|
376
|
+
* 关键设计:不能只看「路由存在」就当可用。必须实际调一次、看是否真成功 ——
|
|
377
|
+
* 否则会把 400 当成「重载成功」,比直接报错更糟。
|
|
378
|
+
*/
|
|
379
|
+
async detectReloadEndpoint() {
|
|
380
|
+
if (this._reloadEndpoint) return ok(this._reloadEndpoint);
|
|
381
|
+
|
|
382
|
+
const candidates = [
|
|
383
|
+
{ method: 'POST', path: '/config/reload', label: '/config/reload' },
|
|
384
|
+
{ method: 'POST', path: '/reload/config', label: '/reload/config' },
|
|
385
|
+
{ method: 'POST', path: '/config/update', label: '/config/update (requires DB)' },
|
|
386
|
+
];
|
|
387
|
+
|
|
388
|
+
const probed = [];
|
|
389
|
+
for (const c of candidates) {
|
|
390
|
+
const res = await this._fetch(c.path, { method: c.method, body: {}, timeoutMs: 15000 });
|
|
391
|
+
const notFound = res.status === 404 || res.status === 405 || res.status === undefined;
|
|
392
|
+
// 「路由存在」和「调用成功」是两件事:只有真的 2xx 才算可用。
|
|
393
|
+
const usable = Boolean(res.ok);
|
|
394
|
+
probed.push({
|
|
395
|
+
...c,
|
|
396
|
+
status: res.status ?? 0,
|
|
397
|
+
exists: !notFound,
|
|
398
|
+
usable,
|
|
399
|
+
error: usable ? undefined : res.error,
|
|
400
|
+
});
|
|
401
|
+
if (usable) {
|
|
402
|
+
this._reloadEndpoint = c.path;
|
|
403
|
+
return ok({ endpoint: c.path, label: c.label, probed });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return ok({ endpoint: null, label: null, probed });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 调用热重载。返回真实结果;若网关没有可用端点则明确失败并给出手动方案,
|
|
411
|
+
* 绝不返回「成功」。
|
|
412
|
+
*/
|
|
413
|
+
async reloadConfig() {
|
|
414
|
+
const detected = await this.detectReloadEndpoint();
|
|
415
|
+
const endpoint = detected.data?.endpoint;
|
|
416
|
+
if (!endpoint) {
|
|
417
|
+
const probed = detected.data?.probed || [];
|
|
418
|
+
// 区分两种失败:路由不存在 vs 路由存在但需要数据库
|
|
419
|
+
const needDb = probed.some((p) => p.exists && !p.usable && String(p.error || '').includes('DB'));
|
|
420
|
+
return fail(
|
|
421
|
+
needDb
|
|
422
|
+
? '网关的 /config/update 存在,但需要数据库(store_model_in_db)才能写入配置;' +
|
|
423
|
+
'当前是纯 config.yaml 模式,无法热重载。请手动重启网关(./start-gateway.sh)以加载新配置。'
|
|
424
|
+
: '网关未提供可用的配置热重载端点。已探测 /config/reload、/reload/config、/config/update 均不可用。' +
|
|
425
|
+
'请手动重启网关(./start-gateway.sh)以加载新的 config.yaml。',
|
|
426
|
+
{ probed, needsManualRestart: true },
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
const res = await this._fetch(endpoint, { method: 'POST', body: {}, timeoutMs: 30000 });
|
|
430
|
+
return { ...res, endpoint };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// -------------------------------------------------------------------------
|
|
434
|
+
// 4. 日志
|
|
435
|
+
// -------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 拉取网关调用日志。优先 /spend/logs(多数版本可用),失败则退回 /global/spend/logs。
|
|
439
|
+
* 返回已归一化的记录数组,便于 UI 直接渲染。
|
|
440
|
+
*/
|
|
441
|
+
async fetchLogs({ limit = 50 } = {}) {
|
|
442
|
+
const attempts = [
|
|
443
|
+
() => this._fetch(`/spend/logs?limit=${encodeURIComponent(limit)}`),
|
|
444
|
+
() => this._fetch(`/global/spend/logs?limit=${encodeURIComponent(limit)}`),
|
|
445
|
+
];
|
|
446
|
+
let lastErr = null;
|
|
447
|
+
for (const attempt of attempts) {
|
|
448
|
+
const res = await attempt();
|
|
449
|
+
if (res.ok) {
|
|
450
|
+
const rows = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
|
451
|
+
return ok(normalizeLogs(rows).slice(0, limit));
|
|
452
|
+
}
|
|
453
|
+
lastErr = res.error;
|
|
454
|
+
}
|
|
455
|
+
// 实测:纯 config.yaml 模式(无 DATABASE_URL)下 /spend/logs 返回 500
|
|
456
|
+
// "Prisma Client is not initialized"。这不是插件 bug,要把原因讲清楚。
|
|
457
|
+
const raw = String(lastErr || '');
|
|
458
|
+
if (/Prisma|database|DB|not initialized|No DB/i.test(raw)) {
|
|
459
|
+
return fail(
|
|
460
|
+
'日志接口需要数据库支持。/spend/logs 与 /global/spend/logs 都依赖 LiteLLM 的数据库,' +
|
|
461
|
+
'当前网关是纯 config.yaml 模式(未配置 DATABASE_URL 或 store_model_in_db),因此不记录调用日志。' +
|
|
462
|
+
'启用日志:在 .env 设置 DATABASE_URL,并在 config.yaml 打开 store_model_in_db: true。',
|
|
463
|
+
{ needsDatabase: true },
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
return fail(lastErr || '无法获取网关日志');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// -------------------------------------------------------------------------
|
|
470
|
+
// 5. 探针
|
|
471
|
+
// -------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* 一键探针:发一条带 custom header 的最小补全请求,验证
|
|
475
|
+
* 「插件 → 网关 → 上游」整条链路 + header 透传。
|
|
476
|
+
* 这是「测试」行为,不是「路由」行为。
|
|
477
|
+
*/
|
|
478
|
+
async probe({ model, agentType, profile, timeoutMs = 120000 }) {
|
|
479
|
+
if (!this.hasKey) {
|
|
480
|
+
return fail('缺少网关 master key,无法发探针请求(应在网关侧 .env 中配置)');
|
|
481
|
+
}
|
|
482
|
+
const body = {
|
|
483
|
+
model,
|
|
484
|
+
messages: [{ role: 'user', content: 'Reply with the single word: pong' }],
|
|
485
|
+
// 16 太小:实测降级到某些小模型时会返回空串,看起来像失败。
|
|
486
|
+
// 64 足够容纳一个单词,也留出降级/推理模型的余量。
|
|
487
|
+
max_tokens: 64,
|
|
488
|
+
temperature: 0,
|
|
489
|
+
};
|
|
490
|
+
const headers = {};
|
|
491
|
+
if (agentType) headers['x-agent-type'] = agentType;
|
|
492
|
+
if (profile) headers['x-session-profile'] = profile;
|
|
493
|
+
|
|
494
|
+
const started = Date.now();
|
|
495
|
+
const res = await this._fetch('/v1/chat/completions', {
|
|
496
|
+
method: 'POST',
|
|
497
|
+
body,
|
|
498
|
+
headers,
|
|
499
|
+
timeoutMs,
|
|
500
|
+
});
|
|
501
|
+
const latencyMs = Date.now() - started;
|
|
502
|
+
|
|
503
|
+
if (!res.ok) {
|
|
504
|
+
return fail(res.error, { latencyMs, headers });
|
|
505
|
+
}
|
|
506
|
+
const reply = res.data?.choices?.[0]?.message?.content ?? '';
|
|
507
|
+
const modelUsed = res.data?.model ?? model;
|
|
508
|
+
// 空回复要如实报告,不能伪装成成功:
|
|
509
|
+
// 说明上游连上了但没产出内容(常见于降级到小模型 / 达到 max_tokens / 拒答)。
|
|
510
|
+
if (!String(reply).trim()) {
|
|
511
|
+
return fail(
|
|
512
|
+
`上游可达但返回空内容(实际模型 ${modelUsed})。链路本身是通的;` +
|
|
513
|
+
'可能是降级到的小模型未产出,或 max_tokens 太小。可换模型再试。',
|
|
514
|
+
{ latencyMs, headers, modelUsed, emptyReply: true },
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
return ok(
|
|
518
|
+
{
|
|
519
|
+
reply: String(reply).slice(0, 200),
|
|
520
|
+
modelUsed,
|
|
521
|
+
agentType: agentType || null,
|
|
522
|
+
profile: profile || null,
|
|
523
|
+
headersSent: headers,
|
|
524
|
+
},
|
|
525
|
+
{ latencyMs },
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** 从 upstream model 串 / api_base 猜一个供应商标签,纯展示用。 */
|
|
531
|
+
function guessProvider(upstream = '', apiBase = '') {
|
|
532
|
+
const s = `${upstream} ${apiBase}`.toLowerCase();
|
|
533
|
+
if (s.includes('agnes-ai')) return 'agnes';
|
|
534
|
+
if (s.includes('bigmodel.cn') || s.includes('zhipu')) return 'zhipuai';
|
|
535
|
+
if (s.includes('nvidia')) return 'nvidia';
|
|
536
|
+
if (s.includes('siliconflow')) return 'siliconflow';
|
|
537
|
+
if (s.includes('amd')) return 'amd';
|
|
538
|
+
if (s.includes('deepseek')) return 'deepseek';
|
|
539
|
+
if (s.includes('openai')) return 'openai';
|
|
540
|
+
return 'other';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* 把网关返回的各种日志形状归一化成 UI 需要的字段。
|
|
545
|
+
*
|
|
546
|
+
* 降级判定:LiteLLM 的响应/日志里可能带 attempted_fallbacks / fallback 痕迹,
|
|
547
|
+
* 各版本字段名不一,所以做多字段嗅探。判定不出来就标 'unknown',
|
|
548
|
+
* 绝不猜——猜错会让橙色高亮变得没有意义。
|
|
549
|
+
*/
|
|
550
|
+
export function normalizeLogs(rows) {
|
|
551
|
+
return rows.map((r, i) => {
|
|
552
|
+
const meta = r?.metadata || {};
|
|
553
|
+
const attempted =
|
|
554
|
+
r?.attempted_fallbacks ??
|
|
555
|
+
meta?.attempted_fallbacks ??
|
|
556
|
+
r?.fallback_attempted ??
|
|
557
|
+
r?.model_group_attempted ??
|
|
558
|
+
null;
|
|
559
|
+
|
|
560
|
+
let fallback = 'unknown';
|
|
561
|
+
if (Array.isArray(attempted)) fallback = attempted.length > 0 ? 'yes' : 'no';
|
|
562
|
+
else if (attempted === true) fallback = 'yes';
|
|
563
|
+
else if (attempted === false) fallback = 'no';
|
|
564
|
+
// 备用判定:请求模型 ≠ 实际返回模型,通常意味着发生过降级
|
|
565
|
+
if (fallback === 'unknown' && r?.model && r?.model_group && r.model !== r.model_group) {
|
|
566
|
+
fallback = 'yes';
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const status = r?.status ?? (r?.error ? 'failure' : 'success');
|
|
570
|
+
const failed = Boolean(r?.error) || String(status).toLowerCase().includes('fail');
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
id: r?.request_id || r?.id || `log-${i}`,
|
|
574
|
+
time: r?.startTime || r?.start_time || r?.timestamp || r?.endTime || r?.end_time || null,
|
|
575
|
+
model: r?.model || r?.model_group || '-',
|
|
576
|
+
modelGroup: r?.model_group || null,
|
|
577
|
+
agentType: meta?.['x-agent-type'] || r?.['x-agent-type'] || r?.agent_type || '-',
|
|
578
|
+
profile: meta?.['x-session-profile'] || r?.['x-session-profile'] || r?.session_profile || '-',
|
|
579
|
+
status: failed ? 'failure' : 'success',
|
|
580
|
+
error: failed ? redact(r?.error || status, null).slice(0, 300) : null,
|
|
581
|
+
latencyMs: r?.response_time ?? r?.latency ?? null,
|
|
582
|
+
tokens: r?.total_tokens ?? null,
|
|
583
|
+
spend: r?.spend ?? null,
|
|
584
|
+
fallback,
|
|
585
|
+
};
|
|
586
|
+
});
|
|
587
|
+
}
|