dsh-router-core 0.2.0 → 0.3.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.
- package/README.md +40 -11
- package/docs/screenshot.png +0 -0
- package/docs/suppliers.md +302 -0
- package/lib/client-registry.js +1119 -213
- package/lib/client.js +1119 -213
- package/lib/index.js +1066 -50
- package/lib/types/llm/adapter.d.ts +2 -0
- package/lib/types/router/index.d.ts +34 -5
- package/lib/types/router/usage-store.d.ts +93 -0
- package/lib/types/router/usage-tokens.d.ts +118 -0
- package/lib/types/shared.d.ts +86 -1
- package/lib/types/supplier-config.d.ts +15 -1
- package/package.json +21 -4
- package/src/client/CheckinCard.tsx +180 -0
- package/src/client/CombosTab.tsx +28 -18
- package/src/client/RouterView.tsx +44 -27
- package/src/client/StatsTab.tsx +531 -0
- package/src/client/index.tsx +61 -11
- package/src/client/router.css +688 -35
- package/src/client/settings-nav-icon.ts +55 -0
- package/src/client/settings-section.tsx +21 -0
- package/src/client/workspace-mount.tsx +2 -9
- package/src/index.ts +51 -3
- package/src/llm/adapter.test.ts +108 -0
- package/src/llm/adapter.ts +18 -6
- package/src/router/index.ts +461 -49
- package/src/router/models-cache.test.ts +269 -0
- package/src/router/precise-dispatch.test.ts +198 -0
- package/src/router/usage-store.ts +499 -0
- package/src/router/usage-tokens.ts +327 -0
- package/src/router/usage.test.ts +558 -0
- package/src/shared.ts +90 -1
- package/src/supplier-config.ts +30 -2
- package/src/suppliers/loader.ts +2 -1
- package/src/suppliers/nvidia/plugin.ts +2 -3
- package/src/suppliers/opencode/plugin.ts +2 -3
- package/src/suppliers/openrouter/plugin.ts +2 -3
- package/src/suppliers/registry.ts +7 -2
package/lib/index.js
CHANGED
|
@@ -171,10 +171,22 @@ var AccountPool = class {
|
|
|
171
171
|
var SupplierConfigStore = class {
|
|
172
172
|
fp = "";
|
|
173
173
|
bySupplier = /* @__PURE__ */ new Map();
|
|
174
|
+
/** 全部供应商 id(加载时同步),用于别名唯一性校验。 */
|
|
175
|
+
knownIds = /* @__PURE__ */ new Set();
|
|
174
176
|
constructor(stateFile) {
|
|
175
177
|
this.fp = stateFile ? join(dirname(stateFile), "supplier-config.json") : "";
|
|
176
178
|
this.load();
|
|
177
179
|
}
|
|
180
|
+
/** 同步已知供应商 id 集合(加载供应商后调用)。 */
|
|
181
|
+
sync(supplierIds) {
|
|
182
|
+
this.knownIds = new Set(supplierIds);
|
|
183
|
+
}
|
|
184
|
+
/** 各供应商当前生效的别名(未设置别名的用供应商 id)。 */
|
|
185
|
+
effectiveAliases() {
|
|
186
|
+
const out = /* @__PURE__ */ new Map();
|
|
187
|
+
for (const id of this.knownIds) out.set(id, this.bySupplier.get(id)?.alias || id);
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
178
190
|
/** 读取某供应商配置(不存在则返回默认并注册)。 */
|
|
179
191
|
get(supplierId) {
|
|
180
192
|
let cfg = this.bySupplier.get(supplierId);
|
|
@@ -190,9 +202,21 @@ var SupplierConfigStore = class {
|
|
|
190
202
|
}
|
|
191
203
|
return cfg;
|
|
192
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* 设置别名。别名必须唯一——它是对外模型全名的前缀(alias/model),
|
|
207
|
+
* 请求时用别名反查供应商,重复就会指错人。空别名表示用供应商 id(默认值)。
|
|
208
|
+
* @returns 冲突时返回占用的供应商 id
|
|
209
|
+
*/
|
|
193
210
|
setAlias(supplierId, alias) {
|
|
194
|
-
|
|
211
|
+
const clean = alias.trim();
|
|
212
|
+
const effective = clean === "" ? supplierId : clean;
|
|
213
|
+
for (const [id, cur] of this.effectiveAliases()) if (id !== supplierId && cur === effective) return {
|
|
214
|
+
ok: false,
|
|
215
|
+
conflictWith: id
|
|
216
|
+
};
|
|
217
|
+
this.get(supplierId).alias = clean;
|
|
195
218
|
this.saveLocked();
|
|
219
|
+
return { ok: true };
|
|
196
220
|
}
|
|
197
221
|
setModelEnabled(supplierId, modelId, enabled) {
|
|
198
222
|
const cfg = this.get(supplierId);
|
|
@@ -261,6 +285,597 @@ var SupplierConfigStore = class {
|
|
|
261
285
|
}
|
|
262
286
|
};
|
|
263
287
|
//#endregion
|
|
288
|
+
//#region src/router/usage-store.ts
|
|
289
|
+
/**
|
|
290
|
+
* 用量落盘与聚合 —— 概览看板的数据源。
|
|
291
|
+
*
|
|
292
|
+
* 结构照 9router `usageRepo` 的双表思路(按天聚合 + 最近明细),但用 **JSON
|
|
293
|
+
* 落盘**而不是 SQLite:dsh-router 现有持久化全是 JSON(supplier-config.json /
|
|
294
|
+
* keys.json),不为一个看板引入数据库依赖。
|
|
295
|
+
*
|
|
296
|
+
* 两条查询路径(照 9router `getUsageStats` 的 `useDailySummary` 分叉):
|
|
297
|
+
* - `today` / `24h` → 走**明细环**(滚动窗口,天桶是自然日,对不上)
|
|
298
|
+
* - `7d` / `30d` → 走**天桶**(长期精确,不受明细环容量限制)
|
|
299
|
+
*
|
|
300
|
+
* 天花板(JSON 的代价,刻意接受):
|
|
301
|
+
* - 明细环只留最近 `RING_CAP` 条。超量时 today/24h 会**少算**(天桶不会,
|
|
302
|
+
* 所以 7d/30d 永远准)。要彻底准就把明细也落盘成按小时的桶,届时
|
|
303
|
+
* `chart()` 的小时桶直接读它即可,接口不用变。
|
|
304
|
+
* - 天桶保留 30 天(9router 有 SQLite 不 prune,JSON 撑不住,得自己限)
|
|
305
|
+
* - 落盘防抖 2s:进程被 kill 最多丢 2s 的计数。看板不是账本,可接受;
|
|
306
|
+
* 要严格就改成立即写,代价是每个请求一次磁盘写。
|
|
307
|
+
*/
|
|
308
|
+
/** 明细环容量:够 today/24h 看,又不至于让 JSON 无限长。 */
|
|
309
|
+
const RING_CAP = 500;
|
|
310
|
+
/** 天桶保留天数。 */
|
|
311
|
+
const KEEP_DAYS = 30;
|
|
312
|
+
/** 落盘防抖。 */
|
|
313
|
+
const SAVE_DEBOUNCE_MS = 2e3;
|
|
314
|
+
const emptyDay = () => ({
|
|
315
|
+
requests: 0,
|
|
316
|
+
ok: 0,
|
|
317
|
+
failed: 0,
|
|
318
|
+
promptTokens: 0,
|
|
319
|
+
completionTokens: 0,
|
|
320
|
+
cachedTokens: 0,
|
|
321
|
+
durationMs: 0,
|
|
322
|
+
ttfbMs: 0,
|
|
323
|
+
ttfbCount: 0,
|
|
324
|
+
estimatedInputs: 0,
|
|
325
|
+
estimatedOutputs: 0,
|
|
326
|
+
bySupplier: {},
|
|
327
|
+
byModel: {},
|
|
328
|
+
byRequested: {}
|
|
329
|
+
});
|
|
330
|
+
const emptyEntry = () => ({
|
|
331
|
+
requests: 0,
|
|
332
|
+
ok: 0,
|
|
333
|
+
failed: 0,
|
|
334
|
+
promptTokens: 0,
|
|
335
|
+
completionTokens: 0,
|
|
336
|
+
cachedTokens: 0,
|
|
337
|
+
lastTs: 0
|
|
338
|
+
});
|
|
339
|
+
/** 本地日期键 `YYYY-MM-DD`(照 9router getLocalDateKey,不按 UTC 切天)。 */
|
|
340
|
+
function localDateKey(ts) {
|
|
341
|
+
const d = new Date(ts);
|
|
342
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
343
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
344
|
+
}
|
|
345
|
+
/** 用量存储。stateFile 为空时纯内存(测试用),不落盘。 */
|
|
346
|
+
var UsageStore = class {
|
|
347
|
+
fp = "";
|
|
348
|
+
days = /* @__PURE__ */ new Map();
|
|
349
|
+
recent = [];
|
|
350
|
+
lifetime = 0;
|
|
351
|
+
timer = null;
|
|
352
|
+
constructor(stateFile) {
|
|
353
|
+
this.fp = stateFile ? join(dirname(stateFile), "usage.json") : "";
|
|
354
|
+
this.load();
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* 记一次请求。
|
|
358
|
+
*
|
|
359
|
+
* **成功的零 token 请求不记**(照 9router `saveUsageStats` 的闸门:
|
|
360
|
+
* `in === 0 && out === 0` 直接 return,空请求混进来只会稀释统计)。
|
|
361
|
+
* **失败请求一定记** —— 不管有没有 token。成功率全靠它,挡掉的话
|
|
362
|
+
* 失败永远是 0 条,面板会显示 100% 成功,这个数字是骗人的。
|
|
363
|
+
*/
|
|
364
|
+
record(r, usage) {
|
|
365
|
+
const promptTokens = usage.promptTokens;
|
|
366
|
+
const completionTokens = usage.completionTokens;
|
|
367
|
+
if (r.ok && promptTokens === 0 && completionTokens === 0) return;
|
|
368
|
+
const ts = r.ts ?? Date.now();
|
|
369
|
+
const rec = {
|
|
370
|
+
...r,
|
|
371
|
+
ts,
|
|
372
|
+
promptTokens,
|
|
373
|
+
completionTokens,
|
|
374
|
+
cachedTokens: usage.cachedTokens,
|
|
375
|
+
inputEstimated: usage.inputEstimated,
|
|
376
|
+
outputEstimated: usage.outputEstimated
|
|
377
|
+
};
|
|
378
|
+
const key = localDateKey(ts);
|
|
379
|
+
let day = this.days.get(key);
|
|
380
|
+
if (day === void 0) {
|
|
381
|
+
day = emptyDay();
|
|
382
|
+
this.days.set(key, day);
|
|
383
|
+
}
|
|
384
|
+
day.requests += 1;
|
|
385
|
+
if (rec.ok) day.ok += 1;
|
|
386
|
+
else day.failed += 1;
|
|
387
|
+
day.promptTokens += promptTokens;
|
|
388
|
+
day.completionTokens += completionTokens;
|
|
389
|
+
day.cachedTokens += rec.cachedTokens;
|
|
390
|
+
day.durationMs += rec.durationMs;
|
|
391
|
+
if (rec.ttfbMs > 0) {
|
|
392
|
+
day.ttfbMs += rec.ttfbMs;
|
|
393
|
+
day.ttfbCount += 1;
|
|
394
|
+
}
|
|
395
|
+
if (rec.inputEstimated) day.estimatedInputs += 1;
|
|
396
|
+
if (rec.outputEstimated) day.estimatedOutputs += 1;
|
|
397
|
+
this.bump(day.bySupplier, rec.supplier, rec);
|
|
398
|
+
this.bump(day.byModel, rec.model, rec);
|
|
399
|
+
this.bump(day.byRequested, rec.requested, rec);
|
|
400
|
+
this.recent.unshift(rec);
|
|
401
|
+
if (this.recent.length > RING_CAP) this.recent.length = RING_CAP;
|
|
402
|
+
this.lifetime += 1;
|
|
403
|
+
this.scheduleSave();
|
|
404
|
+
}
|
|
405
|
+
bump(map, key, rec) {
|
|
406
|
+
if (key === "") return;
|
|
407
|
+
const e = map[key] ?? emptyEntry();
|
|
408
|
+
e.requests += 1;
|
|
409
|
+
if (rec.ok) e.ok += 1;
|
|
410
|
+
else e.failed += 1;
|
|
411
|
+
e.promptTokens += rec.promptTokens;
|
|
412
|
+
e.completionTokens += rec.completionTokens;
|
|
413
|
+
e.cachedTokens += rec.cachedTokens;
|
|
414
|
+
e.lastTs = Math.max(e.lastTs, rec.ts);
|
|
415
|
+
map[key] = e;
|
|
416
|
+
}
|
|
417
|
+
/** 窗口内的明细(today/24h 路径)。 */
|
|
418
|
+
inWindow(period, now) {
|
|
419
|
+
const cutoff = period === "today" ? new Date(now).setHours(0, 0, 0, 0) : now - 864e5;
|
|
420
|
+
return this.recent.filter((r) => r.ts >= cutoff);
|
|
421
|
+
}
|
|
422
|
+
/** 汇总 + Top 榜。 */
|
|
423
|
+
stats(period, now = Date.now()) {
|
|
424
|
+
if (period === "today" || period === "24h") {
|
|
425
|
+
const rows = this.inWindow(period, now);
|
|
426
|
+
const acc = emptyAcc();
|
|
427
|
+
const bySupplier = {};
|
|
428
|
+
const byModel = {};
|
|
429
|
+
const byRequested = {};
|
|
430
|
+
for (const rec of rows) {
|
|
431
|
+
addRec(acc, rec);
|
|
432
|
+
bumpInto(bySupplier, rec.supplier, rec);
|
|
433
|
+
bumpInto(byModel, rec.model, rec);
|
|
434
|
+
bumpInto(byRequested, rec.requested, rec);
|
|
435
|
+
}
|
|
436
|
+
return finish(acc, bySupplier, byModel, byRequested, this.lifetime);
|
|
437
|
+
}
|
|
438
|
+
const days = period === "7d" ? 7 : 30;
|
|
439
|
+
const acc = emptyAcc();
|
|
440
|
+
const bySupplier = {};
|
|
441
|
+
const byModel = {};
|
|
442
|
+
const byRequested = {};
|
|
443
|
+
for (let i = days - 1; i >= 0; i -= 1) {
|
|
444
|
+
const d = this.days.get(localDateKey(now - i * 864e5));
|
|
445
|
+
if (d === void 0) continue;
|
|
446
|
+
acc.requests += d.requests;
|
|
447
|
+
acc.ok += d.ok;
|
|
448
|
+
acc.failed += d.failed;
|
|
449
|
+
acc.promptTokens += d.promptTokens;
|
|
450
|
+
acc.completionTokens += d.completionTokens;
|
|
451
|
+
acc.cachedTokens += d.cachedTokens;
|
|
452
|
+
acc.durationMs += d.durationMs;
|
|
453
|
+
acc.ttfbMs += d.ttfbMs;
|
|
454
|
+
acc.ttfbCount += d.ttfbCount;
|
|
455
|
+
acc.estimatedInputs += d.estimatedInputs;
|
|
456
|
+
acc.estimatedOutputs += d.estimatedOutputs;
|
|
457
|
+
mergeInto(bySupplier, d.bySupplier);
|
|
458
|
+
mergeInto(byModel, d.byModel);
|
|
459
|
+
mergeInto(byRequested, d.byRequested);
|
|
460
|
+
}
|
|
461
|
+
return finish(acc, bySupplier, byModel, byRequested, this.lifetime);
|
|
462
|
+
}
|
|
463
|
+
/** 柱状图数据:today/24h = 24 个小时桶(走明细环),7d/30d = 天桶。 */
|
|
464
|
+
chart(period, now = Date.now()) {
|
|
465
|
+
if (period === "today" || period === "24h") {
|
|
466
|
+
const rows = this.inWindow(period, now);
|
|
467
|
+
const start = period === "today" ? Math.floor(new Date(now).setHours(0, 0, 0, 0) / 36e5) * 36e5 : Math.floor((now - 828e5) / 36e5) * 36e5;
|
|
468
|
+
const buckets = Array.from({ length: 24 }, (_, i) => ({
|
|
469
|
+
label: `${String(new Date(start + i * 36e5).getHours()).padStart(2, "0")}:00`,
|
|
470
|
+
requests: 0,
|
|
471
|
+
tokens: 0
|
|
472
|
+
}));
|
|
473
|
+
for (const rec of rows) {
|
|
474
|
+
const b = buckets[Math.floor((rec.ts - start) / 36e5)];
|
|
475
|
+
if (b === void 0) continue;
|
|
476
|
+
b.requests += 1;
|
|
477
|
+
b.tokens += rec.promptTokens + rec.completionTokens;
|
|
478
|
+
}
|
|
479
|
+
return buckets;
|
|
480
|
+
}
|
|
481
|
+
const days = period === "7d" ? 7 : 30;
|
|
482
|
+
const out = [];
|
|
483
|
+
for (let i = days - 1; i >= 0; i -= 1) {
|
|
484
|
+
const ts = now - i * 864e5;
|
|
485
|
+
const d = this.days.get(localDateKey(ts));
|
|
486
|
+
out.push({
|
|
487
|
+
label: localDateKey(ts).slice(5),
|
|
488
|
+
requests: d?.requests ?? 0,
|
|
489
|
+
tokens: (d?.promptTokens ?? 0) + (d?.completionTokens ?? 0)
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
/** 最近请求明细。 */
|
|
495
|
+
recentList(limit = 20) {
|
|
496
|
+
return this.recent.slice(0, limit);
|
|
497
|
+
}
|
|
498
|
+
clear() {
|
|
499
|
+
this.days.clear();
|
|
500
|
+
this.recent = [];
|
|
501
|
+
this.lifetime = 0;
|
|
502
|
+
this.save();
|
|
503
|
+
}
|
|
504
|
+
/** 进程退出前把防抖中的写入落盘。 */
|
|
505
|
+
flush() {
|
|
506
|
+
if (this.timer !== null) {
|
|
507
|
+
clearTimeout(this.timer);
|
|
508
|
+
this.timer = null;
|
|
509
|
+
}
|
|
510
|
+
this.save();
|
|
511
|
+
}
|
|
512
|
+
scheduleSave() {
|
|
513
|
+
if (this.fp === "" || this.timer !== null) return;
|
|
514
|
+
this.timer = setTimeout(() => {
|
|
515
|
+
this.timer = null;
|
|
516
|
+
this.save();
|
|
517
|
+
}, SAVE_DEBOUNCE_MS);
|
|
518
|
+
this.timer.unref?.();
|
|
519
|
+
}
|
|
520
|
+
load() {
|
|
521
|
+
if (this.fp === "") return;
|
|
522
|
+
try {
|
|
523
|
+
const f = JSON.parse(readFileSync(this.fp, "utf8"));
|
|
524
|
+
this.days = new Map(Object.entries(f.days ?? {}));
|
|
525
|
+
this.recent = Array.isArray(f.recent) ? f.recent.slice(0, RING_CAP) : [];
|
|
526
|
+
this.lifetime = typeof f.lifetime === "number" ? f.lifetime : 0;
|
|
527
|
+
for (const [k, d] of this.days) this.days.set(k, {
|
|
528
|
+
...emptyDay(),
|
|
529
|
+
...d
|
|
530
|
+
});
|
|
531
|
+
} catch {}
|
|
532
|
+
}
|
|
533
|
+
save() {
|
|
534
|
+
if (this.fp === "") return;
|
|
535
|
+
const cutoff = localDateKey(Date.now() - KEEP_DAYS * 864e5);
|
|
536
|
+
for (const k of [...this.days.keys()]) if (k < cutoff) this.days.delete(k);
|
|
537
|
+
const f = {
|
|
538
|
+
days: Object.fromEntries(this.days),
|
|
539
|
+
recent: this.recent,
|
|
540
|
+
lifetime: this.lifetime
|
|
541
|
+
};
|
|
542
|
+
try {
|
|
543
|
+
const dir = dirname(this.fp);
|
|
544
|
+
if (dir !== "" && dir !== ".") mkdirSync(dir, { recursive: true });
|
|
545
|
+
const tmp = `${this.fp}.tmp`;
|
|
546
|
+
writeFileSync(tmp, JSON.stringify(f), { mode: 384 });
|
|
547
|
+
renameSync(tmp, this.fp);
|
|
548
|
+
} catch {}
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
const emptyAcc = () => ({
|
|
552
|
+
requests: 0,
|
|
553
|
+
ok: 0,
|
|
554
|
+
failed: 0,
|
|
555
|
+
promptTokens: 0,
|
|
556
|
+
completionTokens: 0,
|
|
557
|
+
cachedTokens: 0,
|
|
558
|
+
durationMs: 0,
|
|
559
|
+
ttfbMs: 0,
|
|
560
|
+
ttfbCount: 0,
|
|
561
|
+
estimatedInputs: 0,
|
|
562
|
+
estimatedOutputs: 0
|
|
563
|
+
});
|
|
564
|
+
function addRec(acc, rec) {
|
|
565
|
+
acc.requests += 1;
|
|
566
|
+
if (rec.ok) acc.ok += 1;
|
|
567
|
+
else acc.failed += 1;
|
|
568
|
+
acc.promptTokens += rec.promptTokens;
|
|
569
|
+
acc.completionTokens += rec.completionTokens;
|
|
570
|
+
acc.cachedTokens += rec.cachedTokens;
|
|
571
|
+
acc.durationMs += rec.durationMs;
|
|
572
|
+
if (rec.ttfbMs > 0) {
|
|
573
|
+
acc.ttfbMs += rec.ttfbMs;
|
|
574
|
+
acc.ttfbCount += 1;
|
|
575
|
+
}
|
|
576
|
+
if (rec.inputEstimated) acc.estimatedInputs += 1;
|
|
577
|
+
if (rec.outputEstimated) acc.estimatedOutputs += 1;
|
|
578
|
+
}
|
|
579
|
+
function bumpInto(map, key, rec) {
|
|
580
|
+
if (key === "") return;
|
|
581
|
+
const e = map[key] ?? emptyEntry();
|
|
582
|
+
e.requests += 1;
|
|
583
|
+
if (rec.ok) e.ok += 1;
|
|
584
|
+
else e.failed += 1;
|
|
585
|
+
e.promptTokens += rec.promptTokens;
|
|
586
|
+
e.completionTokens += rec.completionTokens;
|
|
587
|
+
e.cachedTokens += rec.cachedTokens;
|
|
588
|
+
e.lastTs = Math.max(e.lastTs, rec.ts);
|
|
589
|
+
map[key] = e;
|
|
590
|
+
}
|
|
591
|
+
function finish(acc, bySupplier, byModel, byRequested, lifetime) {
|
|
592
|
+
return {
|
|
593
|
+
requests: acc.requests,
|
|
594
|
+
ok: acc.ok,
|
|
595
|
+
failed: acc.failed,
|
|
596
|
+
promptTokens: acc.promptTokens,
|
|
597
|
+
completionTokens: acc.completionTokens,
|
|
598
|
+
cachedTokens: acc.cachedTokens,
|
|
599
|
+
avgDurationMs: acc.requests > 0 ? Math.round(acc.durationMs / acc.requests) : 0,
|
|
600
|
+
avgTtfbMs: acc.ttfbCount > 0 ? Math.round(acc.ttfbMs / acc.ttfbCount) : 0,
|
|
601
|
+
estimatedInputs: acc.estimatedInputs,
|
|
602
|
+
estimatedOutputs: acc.estimatedOutputs,
|
|
603
|
+
lifetime,
|
|
604
|
+
bySupplier: top(bySupplier),
|
|
605
|
+
byModel: top(byModel),
|
|
606
|
+
byRequested: top(byRequested)
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function mergeInto(target, src) {
|
|
610
|
+
for (const [k, v] of Object.entries(src)) {
|
|
611
|
+
const t = target[k];
|
|
612
|
+
if (t === void 0) {
|
|
613
|
+
target[k] = {
|
|
614
|
+
...emptyEntry(),
|
|
615
|
+
...v
|
|
616
|
+
};
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
t.requests += v.requests;
|
|
620
|
+
t.ok += v.ok;
|
|
621
|
+
t.failed += v.failed;
|
|
622
|
+
t.promptTokens += v.promptTokens;
|
|
623
|
+
t.completionTokens += v.completionTokens;
|
|
624
|
+
t.cachedTokens += v.cachedTokens;
|
|
625
|
+
t.lastTs = Math.max(t.lastTs, v.lastTs);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** Top 榜:按请求数降序,取前 10。 */
|
|
629
|
+
function top(map) {
|
|
630
|
+
return Object.entries(map).map(([name, e]) => ({
|
|
631
|
+
name,
|
|
632
|
+
requests: e.requests,
|
|
633
|
+
ok: e.ok,
|
|
634
|
+
failed: e.failed,
|
|
635
|
+
promptTokens: e.promptTokens,
|
|
636
|
+
completionTokens: e.completionTokens,
|
|
637
|
+
lastTs: e.lastTs
|
|
638
|
+
})).sort((a, b) => b.requests - a.requests || b.lastTs - a.lastTs).slice(0, 10);
|
|
639
|
+
}
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region src/router/usage-tokens.ts
|
|
642
|
+
/** 取有限数,其余当 0(NaN 会毒穿整个累加:`Math.max(x, NaN) === NaN`)。 */
|
|
643
|
+
function num(v) {
|
|
644
|
+
const n = Number(v);
|
|
645
|
+
return Number.isFinite(n) ? n : 0;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* 从上游任意形态的 usage 对象提取归一用量。
|
|
649
|
+
*
|
|
650
|
+
* `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
|
|
651
|
+
* prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
|
|
652
|
+
* prompt + 单独的 cache_read,要折进来,否则总输入偏小。
|
|
653
|
+
* 判别式就是「有没有 cache_read_input_tokens」。
|
|
654
|
+
*
|
|
655
|
+
* @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
|
|
656
|
+
*/
|
|
657
|
+
function normalizeUsage(raw) {
|
|
658
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
659
|
+
const u = raw;
|
|
660
|
+
const cached = num(u.cached_tokens) + num(u.cache_read_input_tokens) + num(u.cachedContentTokenCount) + num(u.prompt_tokens_details?.cached_tokens);
|
|
661
|
+
let prompt = num(u.prompt_tokens) + num(u.input_tokens) + num(u.promptTokenCount);
|
|
662
|
+
if (u.cache_read_input_tokens !== void 0) prompt += num(u.cache_read_input_tokens);
|
|
663
|
+
const completion = num(u.completion_tokens) + num(u.output_tokens) + num(u.candidatesTokenCount);
|
|
664
|
+
if (prompt === 0 && completion === 0 && cached === 0) return null;
|
|
665
|
+
return {
|
|
666
|
+
promptTokens: prompt,
|
|
667
|
+
completionTokens: completion,
|
|
668
|
+
cachedTokens: cached
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
/** 字段级 max 合并:后来的同名字段取大者(Claude 把 usage 拆在多个事件里)。 */
|
|
672
|
+
function mergeUsage(prev, next) {
|
|
673
|
+
if (prev === null) return next;
|
|
674
|
+
if (next === null) return prev;
|
|
675
|
+
return {
|
|
676
|
+
promptTokens: Math.max(prev.promptTokens, next.promptTokens),
|
|
677
|
+
completionTokens: Math.max(prev.completionTokens, next.completionTokens),
|
|
678
|
+
cachedTokens: Math.max(prev.cachedTokens, next.cachedTokens)
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* 从流式上游的一帧 JSON 里取 usage。
|
|
683
|
+
* 覆盖 OpenAI(Chat/Responses)、Claude、Gemini 三种事件形状,照 9router
|
|
684
|
+
* `extractUsage` 的形态判断(dsh-router 的上游多是 OpenAI SSE,但供应商
|
|
685
|
+
* 插件可能有直通的原生流,多认几种不亏)。
|
|
686
|
+
*/
|
|
687
|
+
function extractFrameUsage(chunk) {
|
|
688
|
+
if (chunk === null || typeof chunk !== "object" || Array.isArray(chunk)) return null;
|
|
689
|
+
const c = chunk;
|
|
690
|
+
if (c.type === "response.completed" || c.type === "response.done") {
|
|
691
|
+
const r = c.response;
|
|
692
|
+
if (r !== void 0) return normalizeUsage(r.usage);
|
|
693
|
+
}
|
|
694
|
+
if (c.type === "message_start") {
|
|
695
|
+
const m = c.message;
|
|
696
|
+
if (m !== void 0) return normalizeUsage(m.usage);
|
|
697
|
+
}
|
|
698
|
+
if (c.type === "message_delta") return normalizeUsage(c.usage);
|
|
699
|
+
if (c.usage !== void 0) return normalizeUsage(c.usage);
|
|
700
|
+
if (c.usageMetadata !== void 0) return normalizeUsage(c.usageMetadata);
|
|
701
|
+
const wrapped = c.response;
|
|
702
|
+
if (wrapped?.usageMetadata !== void 0) return normalizeUsage(wrapped.usageMetadata);
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
/** 估算:~4 字符/token(照 9router 的 estimateInputTokens/estimateOutputTokens)。 */
|
|
706
|
+
function estimateTokens(chars) {
|
|
707
|
+
if (!Number.isFinite(chars) || chars <= 0) return 0;
|
|
708
|
+
return Math.ceil(chars / 4);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* 非流式响应体取 usage。
|
|
712
|
+
*
|
|
713
|
+
* 正常是纯 JSON;但个别供应商客户端要 JSON 却回 SSE(核心会聚合成一次响应,
|
|
714
|
+
* 见 `aggregateSSE`),所以顺手扫尾部几帧——只扫尾部,不多做。
|
|
715
|
+
*/
|
|
716
|
+
function usageFromJsonBody(body) {
|
|
717
|
+
try {
|
|
718
|
+
return normalizeUsage(JSON.parse(body).usage);
|
|
719
|
+
} catch {}
|
|
720
|
+
let merged = null;
|
|
721
|
+
for (const frame of body.split("\n\n").slice(-3)) for (const line of frame.split("\n")) {
|
|
722
|
+
if (!line.startsWith("data:")) continue;
|
|
723
|
+
const payload = line.slice(5).trim();
|
|
724
|
+
if (payload === "" || payload === "[DONE]") continue;
|
|
725
|
+
let obj;
|
|
726
|
+
try {
|
|
727
|
+
obj = JSON.parse(payload);
|
|
728
|
+
} catch {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
merged = mergeUsage(merged, extractFrameUsage(obj));
|
|
732
|
+
}
|
|
733
|
+
return merged;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* 归一用量 → DSH 的 `TokenUsage`(**必须**走这一步再交给 dsh-llm)。
|
|
737
|
+
*
|
|
738
|
+
* 为什么不能把上游 usage 原样透传:dsh-llm 的 `TokenUsage` 有两条硬性约定,
|
|
739
|
+
* 上游的 OpenAI 形态两条都踩:
|
|
740
|
+
* 1. 字段名是 `inputTokens` / `outputTokens`,不是 `prompt_tokens`;
|
|
741
|
+
* 名字对不上 → 下游读 `usage.inputTokens` 得 `undefined` → 累加出 `NaN`。
|
|
742
|
+
* 2. **DISJOINT 口径**:`inputTokens` 只算**未缓存**输入,缓存单列
|
|
743
|
+
* `cacheReadTokens`/`cacheWriteTokens`(计费输入 = 三者之和)。
|
|
744
|
+
* OpenAI 的 `prompt_tokens` 是**含**缓存的总量,直接当 inputTokens
|
|
745
|
+
* 就是重复计费。
|
|
746
|
+
*
|
|
747
|
+
* 后果不只是「数字不好看」:token-meter 的投影 schema 是
|
|
748
|
+
* `z.number().int().nonnegative()`,`NaN` 一进去校验就抛,整条
|
|
749
|
+
* `session.history` RPC 失败 —— 表现为「历史加载失败:history unavailable
|
|
750
|
+
* for session ... expected number, received NaN」。所以这里是**写入侧**
|
|
751
|
+
* 的防线:宁可丢掉这一帧 usage,也不能往会话日志里写 NaN。
|
|
752
|
+
*
|
|
753
|
+
* 天花板(刻意不做):不区分 cacheRead/cacheWrite。上游多数只报读不报写,
|
|
754
|
+
* 拆不开的字段硬拆只会更错;哪天上游稳定给 `cache_creation_input_tokens`
|
|
755
|
+
* 再补 `cacheWriteTokens`(升级路径就在这)。
|
|
756
|
+
*
|
|
757
|
+
* @returns DSH 契约用量;三个数全为 0 时返回 null(= 这一帧没真数据)
|
|
758
|
+
*/
|
|
759
|
+
function toTokenUsage(tokens) {
|
|
760
|
+
if (tokens === null) return null;
|
|
761
|
+
const input = finiteInt(tokens.promptTokens - tokens.cachedTokens);
|
|
762
|
+
const output = finiteInt(tokens.completionTokens);
|
|
763
|
+
const cachedTokens = finiteInt(tokens.cachedTokens);
|
|
764
|
+
if (input === 0 && output === 0 && cachedTokens === 0) return null;
|
|
765
|
+
return {
|
|
766
|
+
inputTokens: input,
|
|
767
|
+
outputTokens: output,
|
|
768
|
+
...cachedTokens > 0 ? { cacheReadTokens: cachedTokens } : {}
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
/** 取非负有限整数:NaN / Infinity / 负数一律归 0,小数取整。 */
|
|
772
|
+
function finiteInt(v) {
|
|
773
|
+
if (!Number.isFinite(v)) return 0;
|
|
774
|
+
return Math.max(0, Math.round(v));
|
|
775
|
+
}
|
|
776
|
+
/** 一帧 SSE 里的可见文本内容(用于输出字符数估算)。 */
|
|
777
|
+
function frameText(chunk) {
|
|
778
|
+
const choices = chunk.choices;
|
|
779
|
+
if (choices === void 0) return "";
|
|
780
|
+
let out = "";
|
|
781
|
+
for (const ch of choices) {
|
|
782
|
+
const delta = ch.delta;
|
|
783
|
+
if (delta !== void 0) {
|
|
784
|
+
if (typeof delta.content === "string") out += delta.content;
|
|
785
|
+
if (typeof delta.reasoning_content === "string") out += delta.reasoning_content;
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const msg = ch.message;
|
|
789
|
+
if (msg !== void 0) {
|
|
790
|
+
if (typeof msg.content === "string") out += msg.content;
|
|
791
|
+
if (typeof msg.reasoning_content === "string") out += msg.reasoning_content;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return out;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* 边透传边统计:原流**原样**出去,统计在旁边做。
|
|
798
|
+
*
|
|
799
|
+
* 为什么不用 `source.tee()`:tee 的两条分支互相牵制,慢的一边会把 buffer 憋住;
|
|
800
|
+
* 而统计这条分支得读完整个流才出得了 usage,客户端那边却要尽快拿到首字节。
|
|
801
|
+
* 手动转发是把上游 chunk **即刻**交给下游,统计只攒几个数字,零缓冲、
|
|
802
|
+
* 首字节延迟不受影响。
|
|
803
|
+
*
|
|
804
|
+
* @param source 上游流(插件已转成 OpenAI SSE)
|
|
805
|
+
* @param startedAt 请求开始时间(`Date.now()`),TTFB 以此为基准
|
|
806
|
+
*/
|
|
807
|
+
function tapStreamUsage(source, startedAt) {
|
|
808
|
+
const dec = new TextDecoder();
|
|
809
|
+
let buf = "";
|
|
810
|
+
let merged = null;
|
|
811
|
+
let outputChars = 0;
|
|
812
|
+
let firstAt = 0;
|
|
813
|
+
const parseFrame = (frame) => {
|
|
814
|
+
for (const line of frame.split("\n")) {
|
|
815
|
+
if (!line.startsWith("data:")) continue;
|
|
816
|
+
const payload = line.slice(5).trim();
|
|
817
|
+
if (payload === "" || payload === "[DONE]") continue;
|
|
818
|
+
let obj;
|
|
819
|
+
try {
|
|
820
|
+
obj = JSON.parse(payload);
|
|
821
|
+
} catch {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) continue;
|
|
825
|
+
merged = mergeUsage(merged, extractFrameUsage(obj));
|
|
826
|
+
outputChars += frameText(obj).length;
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
return {
|
|
830
|
+
stream: new ReadableStream({ start: async (ctrl) => {
|
|
831
|
+
const reader = source.getReader();
|
|
832
|
+
try {
|
|
833
|
+
for (;;) {
|
|
834
|
+
const { done, value } = await reader.read();
|
|
835
|
+
if (done) break;
|
|
836
|
+
if (firstAt === 0) firstAt = Date.now();
|
|
837
|
+
buf += dec.decode(value, { stream: true });
|
|
838
|
+
let sep;
|
|
839
|
+
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
840
|
+
const frame = buf.slice(0, sep + 2);
|
|
841
|
+
buf = buf.slice(sep + 2);
|
|
842
|
+
parseFrame(frame);
|
|
843
|
+
}
|
|
844
|
+
ctrl.enqueue(value);
|
|
845
|
+
}
|
|
846
|
+
if (buf !== "") parseFrame(buf);
|
|
847
|
+
} finally {
|
|
848
|
+
reader.releaseLock();
|
|
849
|
+
}
|
|
850
|
+
ctrl.close();
|
|
851
|
+
} }),
|
|
852
|
+
done: async () => ({
|
|
853
|
+
usage: merged,
|
|
854
|
+
outputChars,
|
|
855
|
+
ttfbMs: firstAt === 0 ? 0 : firstAt - startedAt
|
|
856
|
+
})
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* 补齐估算:上游没给的字段用字符数估,并打标记。
|
|
861
|
+
*
|
|
862
|
+
* `requestChars` = 请求体字符数(估输入),`outputChars` = 输出字符数(估输出)。
|
|
863
|
+
* 上游给了的字段**不覆盖**——估算只填空,不抢真数据。
|
|
864
|
+
*/
|
|
865
|
+
function withEstimates(usage, requestChars, outputChars) {
|
|
866
|
+
const prompt = usage?.promptTokens ?? 0;
|
|
867
|
+
const completion = usage?.completionTokens ?? 0;
|
|
868
|
+
const inputEstimated = prompt === 0;
|
|
869
|
+
const outputEstimated = completion === 0;
|
|
870
|
+
return {
|
|
871
|
+
promptTokens: inputEstimated ? estimateTokens(requestChars) : prompt,
|
|
872
|
+
completionTokens: outputEstimated ? estimateTokens(outputChars) : completion,
|
|
873
|
+
cachedTokens: usage?.cachedTokens ?? 0,
|
|
874
|
+
inputEstimated,
|
|
875
|
+
outputEstimated
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
//#endregion
|
|
264
879
|
//#region src/router/index.ts
|
|
265
880
|
/**
|
|
266
881
|
* dsh-router 路由器本体:供应商注册表 + OpenAI 兼容 /v1/* 处理。
|
|
@@ -309,39 +924,252 @@ function sinkRes() {
|
|
|
309
924
|
return self;
|
|
310
925
|
}
|
|
311
926
|
/**
|
|
927
|
+
* 把 OpenAI SSE 流聚合成一次非流式响应体。
|
|
928
|
+
*
|
|
929
|
+
* 为什么核心要做这个:有些供应商**只支持流式**(如 codebuddy 强制 stream:true),
|
|
930
|
+
* 于是客户端要 JSON 时也会拿到流。核心独占响应写入权,就得把这个错配吸收掉——
|
|
931
|
+
* 否则客户端按 JSON 解析 `choices[0].message.tool_calls[].function.name`,
|
|
932
|
+
* 而名字藏在 SSE 的 `delta` 里,解析结果就是 name 为空(下游报
|
|
933
|
+
* `unknown tool ""`)。这是协议错配,不是丢数据。
|
|
934
|
+
*/
|
|
935
|
+
async function aggregateSSE(stream) {
|
|
936
|
+
const dec = new TextDecoder();
|
|
937
|
+
let id = "";
|
|
938
|
+
let model = "";
|
|
939
|
+
let created = Math.floor(Date.now() / 1e3);
|
|
940
|
+
let content = "";
|
|
941
|
+
let reasoning = "";
|
|
942
|
+
let finish = null;
|
|
943
|
+
let usage;
|
|
944
|
+
const calls = /* @__PURE__ */ new Map();
|
|
945
|
+
const reader = stream.getReader();
|
|
946
|
+
let buf = "";
|
|
947
|
+
for (;;) {
|
|
948
|
+
const { done, value } = await reader.read();
|
|
949
|
+
if (done) break;
|
|
950
|
+
buf += dec.decode(value, { stream: true });
|
|
951
|
+
let sep;
|
|
952
|
+
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
953
|
+
const frame = buf.slice(0, sep);
|
|
954
|
+
buf = buf.slice(sep + 2);
|
|
955
|
+
for (const line of frame.split("\n")) {
|
|
956
|
+
if (!line.startsWith("data:")) continue;
|
|
957
|
+
const payload = line.slice(5).trim();
|
|
958
|
+
if (payload === "" || payload === "[DONE]") continue;
|
|
959
|
+
let obj;
|
|
960
|
+
try {
|
|
961
|
+
obj = JSON.parse(payload);
|
|
962
|
+
} catch {
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
if (typeof obj.id === "string" && obj.id !== "") id = obj.id;
|
|
966
|
+
if (typeof obj.model === "string" && obj.model !== "") model = obj.model;
|
|
967
|
+
if (typeof obj.created === "number") created = obj.created;
|
|
968
|
+
if (obj.usage !== void 0) usage = obj.usage;
|
|
969
|
+
const ch = obj.choices?.[0];
|
|
970
|
+
if (ch === void 0) continue;
|
|
971
|
+
if (typeof ch.finish_reason === "string") finish = ch.finish_reason;
|
|
972
|
+
const delta = ch.delta;
|
|
973
|
+
if (delta === void 0) {
|
|
974
|
+
const msg = ch.message;
|
|
975
|
+
if (msg !== void 0) {
|
|
976
|
+
if (typeof msg.content === "string") content += msg.content;
|
|
977
|
+
if (typeof msg.reasoning_content === "string") reasoning += msg.reasoning_content;
|
|
978
|
+
}
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
if (typeof delta.content === "string") content += delta.content;
|
|
982
|
+
if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
|
|
983
|
+
const tcs = delta.tool_calls;
|
|
984
|
+
if (!Array.isArray(tcs)) continue;
|
|
985
|
+
for (const tc of tcs) {
|
|
986
|
+
const idx = typeof tc.index === "number" ? tc.index : 0;
|
|
987
|
+
const fn = tc.function;
|
|
988
|
+
const prev = calls.get(idx) ?? {
|
|
989
|
+
name: "",
|
|
990
|
+
args: ""
|
|
991
|
+
};
|
|
992
|
+
if (typeof tc.id === "string" && tc.id !== "") prev.id = tc.id;
|
|
993
|
+
if (typeof tc.type === "string") prev.type = tc.type;
|
|
994
|
+
if (typeof fn?.name === "string" && fn.name !== "") prev.name = fn.name;
|
|
995
|
+
if (typeof fn?.arguments === "string") prev.args += fn.arguments;
|
|
996
|
+
calls.set(idx, prev);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const toolCalls = [...calls.entries()].sort((a, b) => a[0] - b[0]).filter(([, c]) => c.name !== "").map(([, c]) => ({
|
|
1002
|
+
id: c.id ?? `call_${c.name}`,
|
|
1003
|
+
type: c.type ?? "function",
|
|
1004
|
+
function: {
|
|
1005
|
+
name: c.name,
|
|
1006
|
+
arguments: c.args
|
|
1007
|
+
}
|
|
1008
|
+
}));
|
|
1009
|
+
const message = {
|
|
1010
|
+
role: "assistant",
|
|
1011
|
+
content
|
|
1012
|
+
};
|
|
1013
|
+
if (reasoning !== "") message.reasoning_content = reasoning;
|
|
1014
|
+
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
|
1015
|
+
const body = {
|
|
1016
|
+
id: id !== "" ? id : `chatcmpl-${Date.now()}`,
|
|
1017
|
+
object: "chat.completion",
|
|
1018
|
+
created,
|
|
1019
|
+
model,
|
|
1020
|
+
choices: [{
|
|
1021
|
+
index: 0,
|
|
1022
|
+
message,
|
|
1023
|
+
finish_reason: finish ?? "stop",
|
|
1024
|
+
logprobs: null
|
|
1025
|
+
}]
|
|
1026
|
+
};
|
|
1027
|
+
if (usage !== void 0) body.usage = usage;
|
|
1028
|
+
return JSON.stringify(body);
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* 流式 SSE 逐帧归一化:剥掉上游多发一次的**空** tool_call 字段。
|
|
1032
|
+
*
|
|
1033
|
+
* 为什么必须做:CodeBuddy 上游的工具调用第二个 delta 会**显式发空串**
|
|
1034
|
+
* `{"function":{"name":"","arguments":"{...}"},"index":0}`(以及 `"id":""`)。
|
|
1035
|
+
* OpenAI 规范里后续 delta 应当**省略**这些字段,客户端沿用首帧的名字;
|
|
1036
|
+
* 而客户端(dsh)判空用的是 `name !== void 0`,空串过不了这个判断,于是首帧
|
|
1037
|
+
* 的 "bash" 被第二帧的 "" 冲掉 —— 工具名变空,下游报 `unknown tool ""`,
|
|
1038
|
+
* 而 arguments 是独立累积的,所以现象是「参数对、名字空」。
|
|
1039
|
+
*
|
|
1040
|
+
* 核心是流式响应的唯一写入方,这个上游不合规得核心吸收:把空串字段**删掉**
|
|
1041
|
+
* (而不是留着空串),客户端看到的就是 undefined,不会覆盖。
|
|
1042
|
+
*
|
|
1043
|
+
* 只动 tool_calls 的空 name/id,其余原样透传;逐帧流式处理,不缓冲整条流
|
|
1044
|
+
* (首字节延迟不受影响)。解析失败一律原样透传——宁可不改,也不能改坏。
|
|
1045
|
+
*/
|
|
1046
|
+
function normalizeSSEStream(stream) {
|
|
1047
|
+
const enc = new TextEncoder();
|
|
1048
|
+
const dec = new TextDecoder();
|
|
1049
|
+
let buf = "";
|
|
1050
|
+
return new ReadableStream({ start: async (ctrl) => {
|
|
1051
|
+
const reader = stream.getReader();
|
|
1052
|
+
try {
|
|
1053
|
+
for (;;) {
|
|
1054
|
+
const { done, value } = await reader.read();
|
|
1055
|
+
if (done) break;
|
|
1056
|
+
buf += dec.decode(value, { stream: true });
|
|
1057
|
+
let sep;
|
|
1058
|
+
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
1059
|
+
const frame = buf.slice(0, sep + 2);
|
|
1060
|
+
buf = buf.slice(sep + 2);
|
|
1061
|
+
ctrl.enqueue(enc.encode(fixFrame(frame)));
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (buf !== "") ctrl.enqueue(enc.encode(fixFrame(buf)));
|
|
1065
|
+
} finally {
|
|
1066
|
+
reader.releaseLock();
|
|
1067
|
+
}
|
|
1068
|
+
ctrl.close();
|
|
1069
|
+
} });
|
|
1070
|
+
}
|
|
1071
|
+
/** 修一帧 SSE:只剥空的 name/id,出任何问题都原样返回。 */
|
|
1072
|
+
function fixFrame(frame) {
|
|
1073
|
+
if (!frame.includes("tool_calls")) return frame;
|
|
1074
|
+
return frame.split("\n").map((line) => {
|
|
1075
|
+
if (!line.startsWith("data: ")) return line;
|
|
1076
|
+
const payload = line.slice(6).trim();
|
|
1077
|
+
if (payload === "" || payload === "[DONE]") return line;
|
|
1078
|
+
let obj;
|
|
1079
|
+
try {
|
|
1080
|
+
obj = JSON.parse(payload);
|
|
1081
|
+
} catch {
|
|
1082
|
+
return line;
|
|
1083
|
+
}
|
|
1084
|
+
let changed = false;
|
|
1085
|
+
const tcs = (obj.choices?.[0]?.delta)?.tool_calls;
|
|
1086
|
+
if (Array.isArray(tcs)) for (const tc of tcs) {
|
|
1087
|
+
if (tc.id === "") {
|
|
1088
|
+
delete tc.id;
|
|
1089
|
+
changed = true;
|
|
1090
|
+
}
|
|
1091
|
+
const fn = tc.function;
|
|
1092
|
+
if (fn !== null && typeof fn === "object" && fn.name === "") {
|
|
1093
|
+
delete fn.name;
|
|
1094
|
+
changed = true;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return changed ? `data: ${JSON.stringify(obj)}` : line;
|
|
1098
|
+
}).join("\n");
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
312
1101
|
* 把 chatOnce 的结果写进 res(核心独占响应写入权)。
|
|
313
1102
|
* 流式:SSE 头 + pipe 上游已转好的 OpenAI SSE;非流式:原样写 JSON。
|
|
1103
|
+
*
|
|
1104
|
+
* @returns true = 响应已提交(客户端拿到东西了);false = **一个字节都没写**
|
|
1105
|
+
* (响应头也还没发),调用方可以换账号/换模型重试。
|
|
1106
|
+
*
|
|
1107
|
+
* 流式**故意推迟到第一个字节才写响应头**:上游常见「连上了但立刻断」的情况,
|
|
1108
|
+
* 先写头就等于把自己锁死在这一个账号上,组合就没法回退了——而客户端只拿到
|
|
1109
|
+
* 一个空/截断的 SSE,表现为工具调用名是空串(`unknown tool ""`)。
|
|
1110
|
+
*
|
|
1111
|
+
* 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
|
|
1112
|
+
* 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
|
|
314
1113
|
*/
|
|
315
|
-
async function writeChatResult(res, r) {
|
|
316
|
-
if (!r.ok) return;
|
|
1114
|
+
async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
1115
|
+
if (!r.ok) return false;
|
|
317
1116
|
if (!("stream" in r)) {
|
|
1117
|
+
if (probe !== void 0) probe.tokens = usageFromJsonBody(r.body);
|
|
318
1118
|
res.writeHead(r.status, {
|
|
319
1119
|
"Content-Type": "application/json; charset=utf-8",
|
|
320
1120
|
"Cache-Control": "no-store"
|
|
321
1121
|
});
|
|
322
1122
|
res.end(r.body);
|
|
323
|
-
return;
|
|
1123
|
+
return true;
|
|
324
1124
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
1125
|
+
if (!wantsStream) {
|
|
1126
|
+
let body;
|
|
1127
|
+
try {
|
|
1128
|
+
body = await aggregateSSE(r.stream);
|
|
1129
|
+
} catch {
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1132
|
+
if (probe !== void 0) probe.tokens = usageFromJsonBody(body);
|
|
1133
|
+
res.writeHead(200, {
|
|
1134
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1135
|
+
"Cache-Control": "no-store"
|
|
1136
|
+
});
|
|
1137
|
+
res.end(body);
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
const tapped = probe === void 0 ? void 0 : tapStreamUsage(r.stream, startedAt);
|
|
1141
|
+
let wroteAny = false;
|
|
1142
|
+
const writeChunk = (chunk) => new Promise((resolve) => {
|
|
1143
|
+
if (!wroteAny) {
|
|
1144
|
+
res.writeHead(200, {
|
|
1145
|
+
"Content-Type": "text/event-stream",
|
|
1146
|
+
"Cache-Control": "no-cache",
|
|
1147
|
+
Connection: "keep-alive",
|
|
1148
|
+
"X-Accel-Buffering": "no"
|
|
1149
|
+
});
|
|
1150
|
+
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
1151
|
+
wroteAny = true;
|
|
1152
|
+
}
|
|
1153
|
+
if (res.write(chunk)) resolve();
|
|
1154
|
+
else res.once("drain", resolve);
|
|
330
1155
|
});
|
|
331
|
-
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
332
1156
|
const onError = () => {
|
|
333
1157
|
res.destroy();
|
|
334
1158
|
};
|
|
335
1159
|
res.once("error", onError);
|
|
336
1160
|
try {
|
|
337
|
-
await r.stream.pipeTo(new WritableStream({ write:
|
|
338
|
-
if (res.write(chunk)) resolve();
|
|
339
|
-
else res.once("drain", resolve);
|
|
340
|
-
}) }));
|
|
1161
|
+
await normalizeSSEStream(tapped?.stream ?? r.stream).pipeTo(new WritableStream({ write: writeChunk }));
|
|
341
1162
|
} catch {} finally {
|
|
342
1163
|
res.removeListener("error", onError);
|
|
343
|
-
res.end();
|
|
1164
|
+
if (wroteAny) res.end();
|
|
1165
|
+
if (probe !== void 0 && tapped !== void 0) {
|
|
1166
|
+
const got = await tapped.done();
|
|
1167
|
+
probe.tokens = got.usage;
|
|
1168
|
+
probe.outputChars = got.outputChars;
|
|
1169
|
+
probe.ttfbMs = got.ttfbMs;
|
|
1170
|
+
}
|
|
344
1171
|
}
|
|
1172
|
+
return wroteAny;
|
|
345
1173
|
}
|
|
346
1174
|
/** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
|
|
347
1175
|
function extractUpstreamError(body) {
|
|
@@ -364,11 +1192,25 @@ var Router = class {
|
|
|
364
1192
|
store;
|
|
365
1193
|
/** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
|
|
366
1194
|
modelsCache = /* @__PURE__ */ new Map();
|
|
367
|
-
|
|
1195
|
+
/** 请求日志出口(面板/宿主 logger)。 */
|
|
1196
|
+
log;
|
|
1197
|
+
/** 用量统计(概览看板)。 */
|
|
1198
|
+
usage;
|
|
1199
|
+
constructor(stateFile = "", store, log) {
|
|
368
1200
|
this.combosFp = stateFile ? join(dirname(stateFile), "combos.json") : "";
|
|
369
1201
|
this.store = store ?? new SupplierConfigStore(stateFile);
|
|
1202
|
+
this.log = log ?? (() => {});
|
|
1203
|
+
this.usage = new UsageStore(stateFile);
|
|
370
1204
|
this.loadCombos();
|
|
371
1205
|
}
|
|
1206
|
+
/**
|
|
1207
|
+
* 请求追踪:每次 chat 记一行,回答「这个组合到底用的哪个模型和账号」。
|
|
1208
|
+
* 排查「组合里某个模型不稳定」全靠它——没这个就只能猜。
|
|
1209
|
+
* 格式刻意做成一行 grep 友好:`chat 请求model → supplier/model (uid) 结果 耗时`。
|
|
1210
|
+
*/
|
|
1211
|
+
logChat(reqModel, detail, ms) {
|
|
1212
|
+
this.log(`chat ${JSON.stringify(reqModel)} → ${detail} ${ms}ms`);
|
|
1213
|
+
}
|
|
372
1214
|
loadCombos() {
|
|
373
1215
|
if (this.combosFp === "") return;
|
|
374
1216
|
try {
|
|
@@ -376,8 +1218,13 @@ var Router = class {
|
|
|
376
1218
|
if (Array.isArray(f.combos)) this.customCombos = f.combos.filter((c) => typeof c.id === "string" && c.id !== "").map((c) => {
|
|
377
1219
|
const old = c;
|
|
378
1220
|
const models = (Array.isArray(c.models) ? c.models.filter((m) => typeof m === "string" && m !== "") : Array.isArray(old.steps) ? old.steps.map((s) => s.supplier).filter((m) => typeof m === "string" && m !== "") : []).map((m) => {
|
|
1221
|
+
if (m.indexOf(",") > 0) return m;
|
|
379
1222
|
const slash = m.indexOf("/");
|
|
380
|
-
|
|
1223
|
+
if (slash <= 0) return m;
|
|
1224
|
+
const alias = m.slice(0, slash);
|
|
1225
|
+
const modelId = m.slice(slash + 1);
|
|
1226
|
+
const s = this.suppliers.find((x) => x.getAlias() === alias);
|
|
1227
|
+
return s === void 0 ? modelId : `${s.id},${modelId}`;
|
|
381
1228
|
});
|
|
382
1229
|
return {
|
|
383
1230
|
id: c.id,
|
|
@@ -402,6 +1249,7 @@ var Router = class {
|
|
|
402
1249
|
add(supplier) {
|
|
403
1250
|
this.suppliers.push(supplier);
|
|
404
1251
|
this.suppliers.sort((a, b) => a.priority - b.priority);
|
|
1252
|
+
this.store.sync(this.suppliers.map((s) => s.id));
|
|
405
1253
|
}
|
|
406
1254
|
/** 移除供应商(外部插件卸载时注销)。 */
|
|
407
1255
|
removeSupplier(id) {
|
|
@@ -521,11 +1369,20 @@ var Router = class {
|
|
|
521
1369
|
* 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
|
|
522
1370
|
* (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
|
|
523
1371
|
* 命名空间一起吃掉,后面请求必然 404。 */
|
|
1372
|
+
/**
|
|
1373
|
+
* 归一组合模型名为存储格式 `supplierId,modelId`。
|
|
1374
|
+
* 面板提交的是「供应商 id + 模型 id」两两组合(或旧数据的裸 id),
|
|
1375
|
+
* 这里只做清理与校验,不再剥掉前缀——精准调用靠的就是供应商 id。
|
|
1376
|
+
*/
|
|
524
1377
|
normalizeModelIds(models) {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
1378
|
+
return models.map((m) => m.trim()).filter((m) => m !== "").map((m) => {
|
|
1379
|
+
if (m.indexOf(",") > 0) return m;
|
|
1380
|
+
const slash = m.indexOf("/");
|
|
1381
|
+
if (slash <= 0) return m;
|
|
1382
|
+
const alias = m.slice(0, slash);
|
|
1383
|
+
const modelId = m.slice(slash + 1);
|
|
1384
|
+
const s = this.suppliers.find((x) => x.getAlias() === alias);
|
|
1385
|
+
return s === void 0 ? modelId : `${s.id},${modelId}`;
|
|
529
1386
|
});
|
|
530
1387
|
}
|
|
531
1388
|
/** 创建组合(name 唯一,非 default)。 */
|
|
@@ -624,6 +1481,34 @@ var Router = class {
|
|
|
624
1481
|
* - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
|
|
625
1482
|
*/
|
|
626
1483
|
async chatCompletions(req, res) {
|
|
1484
|
+
const probe = {
|
|
1485
|
+
requested: req.model,
|
|
1486
|
+
supplier: "",
|
|
1487
|
+
model: "",
|
|
1488
|
+
uid: "",
|
|
1489
|
+
tokens: null,
|
|
1490
|
+
outputChars: 0,
|
|
1491
|
+
ttfbMs: 0,
|
|
1492
|
+
startedAt: Date.now()
|
|
1493
|
+
};
|
|
1494
|
+
const settle = (ok, error) => {
|
|
1495
|
+
const usage = ok ? withEstimates(probe.tokens, req.rawBody.length, probe.outputChars) : {
|
|
1496
|
+
promptTokens: 0,
|
|
1497
|
+
completionTokens: 0,
|
|
1498
|
+
cachedTokens: 0,
|
|
1499
|
+
inputEstimated: false,
|
|
1500
|
+
outputEstimated: false
|
|
1501
|
+
};
|
|
1502
|
+
this.usage.record({
|
|
1503
|
+
supplier: probe.supplier,
|
|
1504
|
+
model: probe.model,
|
|
1505
|
+
requested: probe.requested,
|
|
1506
|
+
ok,
|
|
1507
|
+
durationMs: Date.now() - probe.startedAt,
|
|
1508
|
+
ttfbMs: probe.ttfbMs,
|
|
1509
|
+
...error === void 0 ? {} : { error }
|
|
1510
|
+
}, usage);
|
|
1511
|
+
};
|
|
627
1512
|
const combo = this.comboByName(req.model);
|
|
628
1513
|
if (combo) {
|
|
629
1514
|
const start = combo.strategy === "round-robin" ? (this.rrCursors.get(combo.id) ?? 0) % combo.models.length : 0;
|
|
@@ -631,39 +1516,91 @@ var Router = class {
|
|
|
631
1516
|
for (let i = 0; i < combo.models.length; i++) {
|
|
632
1517
|
const model = combo.models[(start + i) % combo.models.length];
|
|
633
1518
|
if (model === void 0) continue;
|
|
634
|
-
if (await this.chatWithModel(req, res, model))
|
|
1519
|
+
if (await this.chatWithModel(req, res, model, probe)) {
|
|
1520
|
+
settle(true);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
635
1523
|
}
|
|
1524
|
+
settle(false, "combo: all models unavailable");
|
|
636
1525
|
writeJson$2(res, 503, openAIError("no_healthy_supplier", `combo ${JSON.stringify(req.model)}: all models unavailable`));
|
|
637
1526
|
return;
|
|
638
1527
|
}
|
|
639
|
-
|
|
1528
|
+
const slash = req.model.indexOf("/");
|
|
1529
|
+
const alias = slash > 0 ? req.model.slice(0, slash) : "";
|
|
1530
|
+
const target = alias === "" ? void 0 : this.supplierByAlias(alias);
|
|
1531
|
+
if (target !== void 0) {
|
|
1532
|
+
if (await this.chatWithTarget(target, req, res, probe)) {
|
|
1533
|
+
settle(true);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
settle(false, `supplier ${target.id}: all accounts unavailable`);
|
|
1537
|
+
writeJson$2(res, 503, openAIError("no_healthy_supplier", `supplier ${JSON.stringify(target.id)}: all accounts unavailable`));
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
if (await this.chatWithModel(req, res, req.model, probe)) {
|
|
1541
|
+
settle(true);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
settle(false, "all suppliers unavailable");
|
|
640
1545
|
writeJson$2(res, 503, openAIError("no_healthy_supplier", "all suppliers unavailable"));
|
|
641
1546
|
}
|
|
642
|
-
/**
|
|
643
|
-
|
|
1547
|
+
/** 调指定供应商。
|
|
1548
|
+
* 模型名**原样**传给插件(不在这剥 alias/)——插件自己认得自己的别名
|
|
1549
|
+
* (如 traework 会剥第一段)。核心在这剥就会剥两次,把模型 id 里的
|
|
1550
|
+
* 命名空间(如 org/name)吃掉。 */
|
|
1551
|
+
async chatWithTarget(s, req, res, probe) {
|
|
1552
|
+
return await this.chatWithModel(req, res, `${s.id},${req.model}`, probe);
|
|
1553
|
+
}
|
|
1554
|
+
/** 把 model 改写为组合选中的模型名后,找对应的供应商 + 账号(策略全在核心)。
|
|
1555
|
+
*
|
|
1556
|
+
* `model` 形如 `supplierId,modelId`(组合的存储格式):直接用 supplierId 定位,
|
|
1557
|
+
* 不再挨个问供应商「这是不是你的模型」——精准调用,也避免同名模型串台。
|
|
1558
|
+
* 兼容旧的裸 `modelId`(没有逗号):降级为遍历,行为同以前。
|
|
1559
|
+
*/
|
|
1560
|
+
async chatWithModel(req, res, model, probe) {
|
|
1561
|
+
const comma = model.indexOf(",");
|
|
1562
|
+
const supplierId = comma > 0 ? model.slice(0, comma) : void 0;
|
|
1563
|
+
const modelId = comma > 0 ? model.slice(comma + 1) : model;
|
|
644
1564
|
const clone = {
|
|
645
1565
|
...req,
|
|
646
|
-
model
|
|
1566
|
+
model: modelId
|
|
647
1567
|
};
|
|
648
1568
|
try {
|
|
649
1569
|
const obj = JSON.parse(req.rawBody);
|
|
650
|
-
obj.model =
|
|
1570
|
+
obj.model = modelId;
|
|
651
1571
|
clone.rawBody = JSON.stringify(obj);
|
|
652
1572
|
} catch {
|
|
653
1573
|
clone.rawBody = req.rawBody;
|
|
654
1574
|
}
|
|
655
|
-
|
|
1575
|
+
if (supplierId !== void 0) {
|
|
1576
|
+
const s = this.suppliers.find((x) => x.id === supplierId);
|
|
1577
|
+
if (s === void 0) {
|
|
1578
|
+
this.logChat(req.model, `supplier ${JSON.stringify(supplierId)} 不存在`, 0);
|
|
1579
|
+
return false;
|
|
1580
|
+
}
|
|
1581
|
+
const t0 = Date.now();
|
|
1582
|
+
const trace = { attempts: 0 };
|
|
1583
|
+
const served = await this.chatWithSupplier(s, clone, res, trace, probe);
|
|
1584
|
+
this.logChat(req.model, served ? `${s.id}/${modelId} (${trace.uid ?? "?"}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ""}` : `${s.id}/${modelId} 失败 ${trace.lastError ?? "no account"}`, Date.now() - t0);
|
|
1585
|
+
return served;
|
|
1586
|
+
}
|
|
1587
|
+
for (const s of this.suppliers) if (await this.chatWithSupplier(s, clone, res, void 0, probe)) return true;
|
|
656
1588
|
return false;
|
|
657
1589
|
}
|
|
1590
|
+
/** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
|
|
1591
|
+
supplierByAlias(alias) {
|
|
1592
|
+
return this.suppliers.find((s) => s.getAlias() === alias);
|
|
1593
|
+
}
|
|
658
1594
|
/**
|
|
659
1595
|
* 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
|
|
660
1596
|
* AccountState 处置 → 失败换号;写响应只在成功后发生。
|
|
661
1597
|
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
1598
|
+
* 流式**推迟到第一个字节才写响应头**,所以「上游刚连上就断」这种情况
|
|
1599
|
+
* 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
|
|
1600
|
+
* 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
|
|
1601
|
+
* 延迟,目前未做。
|
|
665
1602
|
*/
|
|
666
|
-
async chatWithSupplier(s, req, res) {
|
|
1603
|
+
async chatWithSupplier(s, req, res, trace, probe) {
|
|
667
1604
|
const pool = s.pool;
|
|
668
1605
|
const cfg = this.store.get(s.id);
|
|
669
1606
|
if (s.accounts !== void 0 && s.accounts().length === 0) {
|
|
@@ -673,8 +1610,13 @@ var Router = class {
|
|
|
673
1610
|
pool.noteFailure("", r.state, r.message);
|
|
674
1611
|
return false;
|
|
675
1612
|
}
|
|
676
|
-
|
|
677
|
-
|
|
1613
|
+
if (trace !== void 0) trace.uid = "(no-account)";
|
|
1614
|
+
if (probe !== void 0) {
|
|
1615
|
+
probe.supplier = s.id;
|
|
1616
|
+
probe.model = `${s.getAlias()}/${req.model}`;
|
|
1617
|
+
probe.uid = "";
|
|
1618
|
+
}
|
|
1619
|
+
return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0);
|
|
678
1620
|
}
|
|
679
1621
|
const tried = /* @__PURE__ */ new Set();
|
|
680
1622
|
for (;;) {
|
|
@@ -683,13 +1625,30 @@ var Router = class {
|
|
|
683
1625
|
tried.add(uid);
|
|
684
1626
|
const r = await s.chatOnce(uid, req);
|
|
685
1627
|
if (!r.ok) {
|
|
686
|
-
if (r.state === "no_such_model")
|
|
1628
|
+
if (r.state === "no_such_model") {
|
|
1629
|
+
if (trace !== void 0) trace.lastError = "no_such_model";
|
|
1630
|
+
return false;
|
|
1631
|
+
}
|
|
687
1632
|
pool.noteFailure(uid, r.state, r.message);
|
|
1633
|
+
if (trace !== void 0) {
|
|
1634
|
+
trace.attempts += 1;
|
|
1635
|
+
trace.lastError = `${r.state}: ${r.message}`;
|
|
1636
|
+
}
|
|
688
1637
|
continue;
|
|
689
1638
|
}
|
|
690
1639
|
pool.noteSuccess(uid);
|
|
691
|
-
|
|
692
|
-
|
|
1640
|
+
if (trace !== void 0) trace.uid = uid;
|
|
1641
|
+
if (probe !== void 0) {
|
|
1642
|
+
probe.supplier = s.id;
|
|
1643
|
+
probe.model = `${s.getAlias()}/${req.model}`;
|
|
1644
|
+
probe.uid = uid;
|
|
1645
|
+
}
|
|
1646
|
+
if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) return true;
|
|
1647
|
+
pool.noteFailure(uid, "transport", "stream failed before first byte");
|
|
1648
|
+
if (trace !== void 0) {
|
|
1649
|
+
trace.attempts += 1;
|
|
1650
|
+
trace.lastError = "stream failed before first byte";
|
|
1651
|
+
}
|
|
693
1652
|
}
|
|
694
1653
|
}
|
|
695
1654
|
/** 测试某供应商的某模型是否可用。
|
|
@@ -735,6 +1694,7 @@ var Router = class {
|
|
|
735
1694
|
dispose() {
|
|
736
1695
|
for (const s of this.suppliers) s.dispose();
|
|
737
1696
|
this.suppliers = [];
|
|
1697
|
+
this.usage.flush();
|
|
738
1698
|
}
|
|
739
1699
|
};
|
|
740
1700
|
//#endregion
|
|
@@ -834,15 +1794,16 @@ function wireRequest(options) {
|
|
|
834
1794
|
function flattenText(blocks) {
|
|
835
1795
|
return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
836
1796
|
}
|
|
837
|
-
/** openai SSE payload → DSH StreamChunk
|
|
838
|
-
async function*
|
|
1797
|
+
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
1798
|
+
async function* translateSse(payloads) {
|
|
839
1799
|
let nextIndex = 0;
|
|
840
1800
|
let textBlock;
|
|
841
1801
|
let reasoningBlock;
|
|
842
1802
|
const toolBlocks = /* @__PURE__ */ new Map();
|
|
843
1803
|
const order = [];
|
|
844
1804
|
let pendingFinish;
|
|
845
|
-
|
|
1805
|
+
/** 攒上游各帧的 usage(归一形态),[DONE] 时统一转成 DSH 契约。 */
|
|
1806
|
+
let accumulated = null;
|
|
846
1807
|
const open = (kind) => {
|
|
847
1808
|
const block = {
|
|
848
1809
|
index: nextIndex++,
|
|
@@ -859,10 +1820,13 @@ async function* translate(payloads) {
|
|
|
859
1820
|
index: block.index,
|
|
860
1821
|
block: closeBlock(block)
|
|
861
1822
|
};
|
|
862
|
-
if (
|
|
863
|
-
|
|
864
|
-
usage
|
|
865
|
-
|
|
1823
|
+
if (accumulated !== null) {
|
|
1824
|
+
const usage = toTokenUsage(accumulated);
|
|
1825
|
+
if (usage !== null) yield {
|
|
1826
|
+
type: "usage",
|
|
1827
|
+
usage
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
866
1830
|
const reason = pendingFinish ?? { kind: "stop" };
|
|
867
1831
|
yield {
|
|
868
1832
|
type: "finish",
|
|
@@ -945,7 +1909,7 @@ async function* translate(payloads) {
|
|
|
945
1909
|
}
|
|
946
1910
|
if (typeof choice.finish_reason === "string") pendingFinish = mapFinishReason(choice.finish_reason);
|
|
947
1911
|
}
|
|
948
|
-
if (chunk.usage !== void 0)
|
|
1912
|
+
if (chunk.usage !== void 0) accumulated = mergeUsage(accumulated, normalizeUsage(chunk.usage));
|
|
949
1913
|
}
|
|
950
1914
|
throw new LlmError("SSE payload stream ended without [DONE]", "STREAM_CLOSED");
|
|
951
1915
|
}
|
|
@@ -1039,7 +2003,7 @@ var RouterAdapter = class extends LlmAdapter {
|
|
|
1039
2003
|
const detail = await resp.text().catch(() => "");
|
|
1040
2004
|
throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, "TRANSPORT");
|
|
1041
2005
|
}
|
|
1042
|
-
yield*
|
|
2006
|
+
yield* translateSse(parseSseText(resp.body === null ? "" : await resp.text()));
|
|
1043
2007
|
}
|
|
1044
2008
|
};
|
|
1045
2009
|
//#endregion
|
|
@@ -1236,7 +2200,7 @@ function wrapModule(instance, env, source, kind = "external") {
|
|
|
1236
2200
|
custom: custom.has(mm.id) ? true : void 0
|
|
1237
2201
|
})));
|
|
1238
2202
|
},
|
|
1239
|
-
getAlias: () => env.store.get(instance.id).alias || instance.
|
|
2203
|
+
getAlias: () => env.store.get(instance.id).alias || instance.id,
|
|
1240
2204
|
customModelIds: () => [...env.store.get(instance.id).custom],
|
|
1241
2205
|
accounts: () => instance.status().accounts,
|
|
1242
2206
|
chatOnce: (uid, req) => instance.chatOnce(uid, req),
|
|
@@ -1377,14 +2341,21 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
1377
2341
|
path: `${p}/alias`,
|
|
1378
2342
|
handler: async (req, res) => {
|
|
1379
2343
|
const clean = (JSON.parse(await readBody$1(req)).alias ?? "").trim();
|
|
1380
|
-
if (clean
|
|
2344
|
+
if (clean !== "" && !/^[A-Za-z0-9_-]+$/.test(clean)) {
|
|
1381
2345
|
writeJson$1(res, 400, {
|
|
1382
2346
|
ok: false,
|
|
1383
2347
|
error: "前缀只能包含字母、数字、- 和 _"
|
|
1384
2348
|
});
|
|
1385
2349
|
return;
|
|
1386
2350
|
}
|
|
1387
|
-
store.setAlias(s.id, clean);
|
|
2351
|
+
const r = store.setAlias(s.id, clean);
|
|
2352
|
+
if (!r.ok) {
|
|
2353
|
+
writeJson$1(res, 400, {
|
|
2354
|
+
ok: false,
|
|
2355
|
+
error: `前缀已被供应商 ${JSON.stringify(r.conflictWith ?? "?")} 占用`
|
|
2356
|
+
});
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
1388
2359
|
writeJson$1(res, 200, { ok: true });
|
|
1389
2360
|
}
|
|
1390
2361
|
});
|
|
@@ -1762,7 +2733,7 @@ function apply(rawContext) {
|
|
|
1762
2733
|
const stateFile = process.env.TW2A_STATE_FILE ?? "data/state.json";
|
|
1763
2734
|
const store = new SupplierConfigStore(stateFile);
|
|
1764
2735
|
const credentials = new CredentialStore(process.env.TW2A_AUTH_DIR ?? join(dirname(stateFile), "auths"));
|
|
1765
|
-
const router = new Router(stateFile, store);
|
|
2736
|
+
const router = new Router(stateFile, store, log);
|
|
1766
2737
|
const keys = new KeysStore(stateFile);
|
|
1767
2738
|
const disposers = [];
|
|
1768
2739
|
const route = (path, handler) => {
|
|
@@ -2071,6 +3042,51 @@ function apply(rawContext) {
|
|
|
2071
3042
|
requireApiKey: keys.requireApiKey
|
|
2072
3043
|
});
|
|
2073
3044
|
});
|
|
3045
|
+
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
3046
|
+
const periodOf = (raw) => {
|
|
3047
|
+
return raw === "24h" || raw === "7d" || raw === "30d" ? raw : "today";
|
|
3048
|
+
};
|
|
3049
|
+
route(`${ROUTER_API_BASE}/stats`, (req, res) => {
|
|
3050
|
+
const period = periodOf(new URL(req.url ?? "/", "http://localhost").searchParams.get("period"));
|
|
3051
|
+
const s = router.usage.stats(period);
|
|
3052
|
+
writeJson(res, 200, {
|
|
3053
|
+
ok: true,
|
|
3054
|
+
period,
|
|
3055
|
+
requests: s.requests,
|
|
3056
|
+
okCount: s.ok,
|
|
3057
|
+
failed: s.failed,
|
|
3058
|
+
promptTokens: s.promptTokens,
|
|
3059
|
+
completionTokens: s.completionTokens,
|
|
3060
|
+
cachedTokens: s.cachedTokens,
|
|
3061
|
+
avgDurationMs: s.avgDurationMs,
|
|
3062
|
+
avgTtfbMs: s.avgTtfbMs,
|
|
3063
|
+
estimatedInputs: s.estimatedInputs,
|
|
3064
|
+
estimatedOutputs: s.estimatedOutputs,
|
|
3065
|
+
lifetime: s.lifetime,
|
|
3066
|
+
bySupplier: s.bySupplier,
|
|
3067
|
+
byModel: s.byModel,
|
|
3068
|
+
byRequested: s.byRequested,
|
|
3069
|
+
recent: router.usage.recentList(20)
|
|
3070
|
+
});
|
|
3071
|
+
});
|
|
3072
|
+
route(`${ROUTER_API_BASE}/stats/chart`, (req, res) => {
|
|
3073
|
+
const period = periodOf(new URL(req.url ?? "/", "http://localhost").searchParams.get("period"));
|
|
3074
|
+
writeJson(res, 200, {
|
|
3075
|
+
ok: true,
|
|
3076
|
+
chart: router.usage.chart(period)
|
|
3077
|
+
});
|
|
3078
|
+
});
|
|
3079
|
+
route(`${ROUTER_API_BASE}/stats/clear`, async (req, res) => {
|
|
3080
|
+
if (req.method !== "POST") {
|
|
3081
|
+
writeJson(res, 405, {
|
|
3082
|
+
ok: false,
|
|
3083
|
+
error: "method not allowed"
|
|
3084
|
+
});
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
router.usage.clear();
|
|
3088
|
+
writeJson(res, 200, { ok: true });
|
|
3089
|
+
});
|
|
2074
3090
|
if (ctx.llm !== void 0) {
|
|
2075
3091
|
const LLM_NS = "llm-dsh-router";
|
|
2076
3092
|
try {
|