dsh-router-core 0.3.2 → 0.3.3

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/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { basename, dirname, join } from "node:path";
2
2
  import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
3
- import { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
3
+ import { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, attributionHeaders } from "@deepseek-ai/dsh-llm";
4
4
  import { randomBytes } from "node:crypto";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { DatabaseSync } from "node:sqlite";
@@ -14,143 +14,239 @@ const API_KEY_REF = "TW2A_API_KEY";
14
14
  const MINUTE = 6e4;
15
15
  /**
16
16
  * 状态 → 处置表。**核心策略就在这一张表里**,插件只报状态不做决策。
17
- * 时长参考 9router 的 errorConfig(限流类短冷却、额度类长冷却),
18
- * 但 9router 按文本/状态匹配,这里按插件解读后的语义状态匹配,更准。
17
+ *
18
+ * 冷却策略对齐 9router 的 accountFallback / errorConfig:
19
+ * - `rate_limit` 用**指数退避**(9router 的 `backoff: true`):偶发限流只
20
+ * 短冷,持续限流越退越久,比固定 1 分钟两头不讨好要准。
21
+ * - 未知/瞬时错误用**瞬时短冷却**(9router 默认分支 TRANSIENT_COOLDOWN_MS):
22
+ * 每次失败都冻结,坏号不会留在池里被下一个请求再选中。
23
+ *
24
+ * 与 9router 的差别:9router 按错误文本/HTTP 状态匹配,这里按插件解读后的
25
+ * 语义状态匹配 —— 插件已经把 11133/11134 这类网关噪声归成 `rate_limit`,
26
+ * 核心不必再猜文本,更准。
19
27
  */
20
28
  const RULES = {
21
29
  ok: {
22
- cooldownMs: 0,
30
+ cooldown: 0,
23
31
  disable: false,
24
32
  counts: false
25
33
  },
26
34
  rate_limit: {
27
- cooldownMs: 1 * MINUTE,
35
+ cooldown: "backoff",
28
36
  disable: false,
29
37
  counts: false
30
38
  },
31
39
  quota: {
32
- cooldownMs: 10 * MINUTE,
40
+ cooldown: 10 * MINUTE,
33
41
  disable: false,
34
42
  counts: false
35
43
  },
36
44
  session_dead: {
37
- cooldownMs: 0,
45
+ cooldown: 0,
38
46
  disable: true,
39
47
  counts: false
40
48
  },
41
49
  unavailable: {
42
- cooldownMs: 0,
50
+ cooldown: "transient",
43
51
  disable: false,
44
- counts: true
52
+ counts: false
45
53
  },
46
54
  transport: {
47
- cooldownMs: 0,
55
+ cooldown: "transient",
48
56
  disable: false,
49
- counts: true
57
+ counts: false
50
58
  },
51
59
  unknown: {
52
- cooldownMs: 0,
60
+ cooldown: "transient",
53
61
  disable: false,
54
- counts: true
62
+ counts: false
55
63
  },
56
64
  no_such_model: {
57
- cooldownMs: 0,
65
+ cooldown: 0,
58
66
  disable: false,
59
67
  counts: false
60
68
  }
61
69
  };
62
- /** 连续错误攒够这么多次就自动冷却(防止坏号被反复重试)。 */
63
- const ERR_THRESHOLD = 3;
64
- /** 攒够后的冷却时长。 */
65
- const ERR_COOLDOWN_MS = 10 * MINUTE;
70
+ /**
71
+ * 瞬时/未知错误的冷却时长 —— 对齐 9router 的 TRANSIENT_COOLDOWN_MS。
72
+ * 取短值:这类错误大多是偶发的上游抖动,冷太久会把好号白白关掉;
73
+ * 但**必须冷**,否则坏号留在池里,每个请求都要先撞一次才知道它坏了。
74
+ * (现在按 (模型, 号) 记,只关这一个号在这个模型上的可用性。)
75
+ */
76
+ const TRANSIENT_COOLDOWN_MS = 3e4;
77
+ /** 限流退避:首次 2s,每次翻倍,上限 5 分钟(对齐 9router BACKOFF_CONFIG)。 */
78
+ const BACKOFF_BASE_MS = 2e3;
79
+ const BACKOFF_MAX_MS = 5 * MINUTE;
80
+ const BACKOFF_MAX_LEVEL = 15;
81
+ /** 指数退避:等级 1 = BACKOFF_BASE_MS,每级翻倍,封顶 BACKOFF_MAX_MS。 */
82
+ function backoffMs(level) {
83
+ return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, level - 1), BACKOFF_MAX_MS);
84
+ }
85
+ /** 冷却键分隔符:模型 id 本身可含 `/` `,`,用不可见控制符隔离最稳。 */
86
+ const SEP = "\0";
66
87
  var AccountPool = class {
67
- entries = /* @__PURE__ */ new Map();
88
+ /** 本池所属供应商(loader 每供应商一个实例;键里带上,跨供应商同名模型不串)。 */
89
+ supplierId;
90
+ /** (supplier, model, uid) → 冷却/退避。 */
91
+ cooldowns = /* @__PURE__ */ new Map();
92
+ /** uid → 连接级禁用 / 手动暂停。 */
93
+ byUid = /* @__PURE__ */ new Map();
68
94
  rrCursor = 0;
69
- entry(uid) {
70
- let e = this.entries.get(uid);
95
+ constructor(supplierId = "") {
96
+ this.supplierId = supplierId;
97
+ }
98
+ key(model, uid) {
99
+ return `${this.supplierId}${SEP}${model}${SEP}${uid}`;
100
+ }
101
+ entry(model, uid) {
102
+ const k = this.key(model, uid);
103
+ let e = this.cooldowns.get(k);
104
+ if (!e) {
105
+ e = {
106
+ until: 0,
107
+ backoffLevel: 0,
108
+ reason: ""
109
+ };
110
+ this.cooldowns.set(k, e);
111
+ }
112
+ return e;
113
+ }
114
+ /** 某连接整体状态(禁用/手动暂停)。 */
115
+ uidState(uid) {
116
+ let e = this.byUid.get(uid);
71
117
  if (!e) {
72
118
  e = {
73
119
  until: 0,
74
120
  disabled: false,
75
- errCount: 0,
76
121
  reason: ""
77
122
  };
78
- this.entries.set(uid, e);
123
+ this.byUid.set(uid, e);
79
124
  }
80
125
  return e;
81
126
  }
82
- /** 是否可服务:未禁用且不在冷却中。 */
83
- healthy(uid, now) {
84
- const e = this.entries.get(uid);
85
- if (!e) return true;
86
- if (e.disabled) return false;
87
- return e.until <= now;
127
+ /** 该连接是否被禁用 / 手动整连接暂停中(跨模型生效)。 */
128
+ uidBlocked(uid, now) {
129
+ const e = this.byUid.get(uid);
130
+ if (!e) return {
131
+ blocked: false,
132
+ disabled: false
133
+ };
134
+ if (e.disabled) return {
135
+ blocked: true,
136
+ disabled: true
137
+ };
138
+ if (e.until > now) return {
139
+ blocked: true,
140
+ disabled: false
141
+ };
142
+ return {
143
+ blocked: false,
144
+ disabled: false
145
+ };
88
146
  }
89
147
  /**
90
- * 按策略选一个健康账号。
148
+ * 给定要试的模型,该连接是否可服务:
149
+ * - 未被连接级禁用/手动暂停(跨模型);
150
+ * - 且此 (supplier, model, uid) 冷却单元不在冷却中。
151
+ */
152
+ healthy(uid, model, now) {
153
+ if (this.uidBlocked(uid, now).blocked) return false;
154
+ const c = this.cooldowns.get(this.key(model, uid));
155
+ if (!c) return true;
156
+ return c.until <= now;
157
+ }
158
+ /**
159
+ * 按策略为「某个模型」选一个健康账号。
91
160
  * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
92
161
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
162
+ * @param strategy fallback 取第一个健康 / round-robin 轮转健康号
163
+ * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
93
164
  * @returns 选中的 uid;无健康账号返回 undefined
94
165
  */
95
- pick(accounts, poolOrder, strategy) {
166
+ pick(accounts, poolOrder, strategy, modelId) {
96
167
  const now = Date.now();
97
168
  const byUid = new Map(accounts.map((a) => [a.uid, a]));
98
- const healthy = [...poolOrder.filter((uid) => byUid.has(uid)), ...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid))].filter((uid) => this.healthy(uid, now));
169
+ const healthy = [...poolOrder.filter((uid) => byUid.has(uid)), ...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid))].filter((uid) => this.healthy(uid, modelId, now));
99
170
  if (healthy.length === 0) return void 0;
100
171
  if (strategy !== "round-robin") return healthy[0];
101
172
  const uid = healthy[this.rrCursor % healthy.length];
102
173
  this.rrCursor = (this.rrCursor + 1) % healthy.length;
103
174
  return uid;
104
175
  }
105
- /** 记录一次失败:按状态处置(冷却/禁用/错误累计)。 */
106
- noteFailure(uid, state, message) {
176
+ /** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
177
+ noteFailure(uid, modelId, state, message) {
107
178
  const rule = RULES[state];
108
- const e = this.entry(uid);
109
179
  if (rule.disable) {
180
+ const e = this.uidState(uid);
110
181
  e.disabled = true;
111
182
  e.reason = message;
112
183
  return;
113
184
  }
114
- if (rule.cooldownMs > 0) {
115
- e.until = Math.max(e.until, Date.now() + rule.cooldownMs);
116
- e.reason = message;
117
- e.errCount = 0;
185
+ if (rule.cooldown === 0) return;
186
+ const e = this.entry(modelId, uid);
187
+ e.reason = message;
188
+ if (rule.cooldown === "backoff") {
189
+ e.backoffLevel = Math.min(e.backoffLevel + 1, BACKOFF_MAX_LEVEL);
190
+ e.until = Math.max(e.until, Date.now() + backoffMs(e.backoffLevel));
118
191
  return;
119
192
  }
120
- if (rule.counts) {
121
- e.errCount += 1;
122
- e.reason = message;
123
- if (e.errCount >= ERR_THRESHOLD) {
124
- e.until = Date.now() + ERR_COOLDOWN_MS;
125
- e.errCount = 0;
126
- }
193
+ if (rule.cooldown === "transient") {
194
+ e.until = Math.max(e.until, Date.now() + TRANSIENT_COOLDOWN_MS);
195
+ return;
127
196
  }
197
+ e.until = Math.max(e.until, Date.now() + rule.cooldown);
128
198
  }
129
- /** 记录一次成功:清零连续错误。 */
130
- noteSuccess(uid) {
131
- const e = this.entries.get(uid);
132
- if (e) e.errCount = 0;
199
+ /** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。不解除禁用。 */
200
+ noteSuccess(uid, modelId) {
201
+ const e = this.cooldowns.get(this.key(modelId, uid));
202
+ if (e) e.backoffLevel = 0;
133
203
  }
134
- /** 手动冷却(面板/管理用途)。 */
204
+ /** 手动暂停整连接(面板/管理用途,跨模型)。ms<=0 表示解除。 */
135
205
  cooldown(uid, ms, reason) {
136
- const e = this.entry(uid);
206
+ const e = this.uidState(uid);
137
207
  e.until = Date.now() + ms;
138
208
  e.reason = reason;
139
- e.errCount = 0;
209
+ for (const [k, ce] of this.cooldowns) if (k.endsWith(`${SEP}${uid}`)) ce.backoffLevel = 0;
140
210
  }
141
- /** 把核心的冷却/禁用/错误累计叠加到插件报告的「现在状态」上,产出面板态。 */
142
- decorate(accounts) {
211
+ /** 把核心的冷却/禁用叠加到插件报的「现在状态」上,产出面板态。
212
+ *
213
+ * 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
214
+ * 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
215
+ * (reason 带上具体模型),并在 `until` 给最长者。真实路由用 `pick` 的
216
+ * 模型级判定,不受此展示聚合影响。传 modelId 可只按该模型判定。
217
+ */
218
+ decorate(accounts, modelId) {
143
219
  const now = Date.now();
144
220
  return accounts.map((a) => {
145
- const e = this.entries.get(a.uid);
146
- const cooling = e !== void 0 && !e.disabled && e.until > now;
221
+ const u = this.byUid.get(a.uid);
222
+ const disabled = u?.disabled ?? false;
223
+ const manualCooling = u !== void 0 && !u.disabled && u.until > now;
224
+ let cooling = manualCooling;
225
+ let maxUntil = manualCooling ? u.until : 0;
226
+ let reason = manualCooling ? u.reason : void 0;
227
+ let err = 0;
228
+ for (const [k, ce] of this.cooldowns) {
229
+ if (!k.endsWith(`${SEP}${a.uid}`)) continue;
230
+ if (modelId !== void 0) {
231
+ const prefix = `${this.supplierId}${SEP}${modelId}${SEP}`;
232
+ if (!k.startsWith(prefix)) continue;
233
+ }
234
+ if (ce.until > now) {
235
+ cooling = true;
236
+ if (ce.until > maxUntil) {
237
+ maxUntil = ce.until;
238
+ reason = ce.reason;
239
+ }
240
+ }
241
+ if (ce.backoffLevel > err) err = ce.backoffLevel;
242
+ }
147
243
  return {
148
244
  ...a,
149
245
  cooling,
150
- disabled: e?.disabled ?? false,
151
- err_count: e?.errCount ?? 0,
152
- until: cooling && e ? new Date(e.until).toISOString() : void 0,
153
- reason: e?.reason !== "" ? e?.reason : void 0
246
+ disabled,
247
+ err_count: err,
248
+ until: cooling && maxUntil > 0 ? new Date(maxUntil).toISOString() : void 0,
249
+ reason: reason !== void 0 && reason !== "" ? reason : void 0
154
250
  };
155
251
  });
156
252
  }
@@ -293,24 +389,46 @@ var SupplierConfigStore = class {
293
389
  * 落盘**而不是 SQLite:dsh-router 现有持久化全是 JSON(supplier-config.json /
294
390
  * keys.json),不为一个看板引入数据库依赖。
295
391
  *
296
- * 两条查询路径(照 9router `getUsageStats` 的 `useDailySummary` 分叉):
297
- * - `today` / `24h` → 走**明细环**(滚动窗口,天桶是自然日,对不上)
298
- * - `7d` / `30d` → 走**天桶**(长期精确,不受明细环容量限制)
392
+ * 统计口径(不再走明细环 —— 见下面「天花板」):
393
+ * - `today` → 走**天桶**(今天这一桶就是答案,天然精确)
394
+ * - `24h` → 走**小时桶**(滚动窗口,天桶是自然日,对不上)
395
+ * - `7d` / `30d` → 走**天桶**(长期精确)
299
396
  *
300
397
  * 天花板(JSON 的代价,刻意接受):
301
- * - 明细环只留最近 `RING_CAP` 条。超量时 today/24h 会**少算**(天桶不会,
302
- * 所以 7d/30d 永远准)。要彻底准就把明细也落盘成按小时的桶,届时
303
- * `chart()` 的小时桶直接读它即可,接口不用变。
398
+ * - 明细环 `recent[]` 只留最近 `RING_CAP` 条,**只服务「最近请求列表」**,
399
+ * 不参与任何统计。历史上 today/24h 直接扫它,单日超 500 条就被静默截断,
400
+ * 于是出现「今日 500 / 7 天 1621」这种自相矛盾的数字——现在不会了。
401
+ * - **小时桶只从新数据开始累积**:升级前落的盘没有小时粒度,而明细环只剩
402
+ * 最近 500 条、回溯不回去,所以那段历史的**天内分布是永久缺失的**。
403
+ * 影响面:①`24h` 遇到「小时粒度不齐」的自然日 → 整桶计入(宁可多算,
404
+ * 也不显示一个比「今日」还小的荒唐数字);②`today`/`24h` 的小时柱状图
405
+ * 那几格是空的(不编数据)。天桶口径(today/7d/30d)完全不受影响,
406
+ * 新数据立刻就有小时粒度,隔天起图表全正常。
304
407
  * - 天桶保留 30 天(9router 有 SQLite 不 prune,JSON 撑不住,得自己限)
305
408
  * - 落盘防抖 2s:进程被 kill 最多丢 2s 的计数。看板不是账本,可接受;
306
409
  * 要严格就改成立即写,代价是每个请求一次磁盘写。
307
410
  */
308
- /** 明细环容量:够 today/24h 看,又不至于让 JSON 无限长。 */
411
+ /** 明细环容量:只喂「最近请求列表」,跟统计口径无关了。 */
309
412
  const RING_CAP = 500;
413
+ /** 一天几格(小时桶定长;要更细的精度就把这个值放大)。 */
414
+ const HOURS = 24;
310
415
  /** 天桶保留天数。 */
311
416
  const KEEP_DAYS = 30;
312
417
  /** 落盘防抖。 */
313
418
  const SAVE_DEBOUNCE_MS = 2e3;
419
+ const emptyHour = () => ({
420
+ requests: 0,
421
+ ok: 0,
422
+ failed: 0,
423
+ promptTokens: 0,
424
+ completionTokens: 0,
425
+ cachedTokens: 0,
426
+ durationMs: 0,
427
+ ttfbMs: 0,
428
+ ttfbCount: 0,
429
+ estimatedInputs: 0,
430
+ estimatedOutputs: 0
431
+ });
314
432
  const emptyDay = () => ({
315
433
  requests: 0,
316
434
  ok: 0,
@@ -323,6 +441,7 @@ const emptyDay = () => ({
323
441
  ttfbCount: 0,
324
442
  estimatedInputs: 0,
325
443
  estimatedOutputs: 0,
444
+ hours: Array.from({ length: HOURS }, () => emptyHour()),
326
445
  bySupplier: {},
327
446
  byModel: {},
328
447
  byRequested: {}
@@ -343,7 +462,7 @@ function localDateKey(ts) {
343
462
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
344
463
  }
345
464
  /** 用量存储。stateFile 为空时纯内存(测试用),不落盘。 */
346
- var UsageStore = class {
465
+ var UsageStore = class UsageStore {
347
466
  fp = "";
348
467
  days = /* @__PURE__ */ new Map();
349
468
  recent = [];
@@ -394,6 +513,22 @@ var UsageStore = class {
394
513
  }
395
514
  if (rec.inputEstimated) day.estimatedInputs += 1;
396
515
  if (rec.outputEstimated) day.estimatedOutputs += 1;
516
+ const h = day.hours[new Date(ts).getHours()];
517
+ if (h !== void 0) {
518
+ h.requests += 1;
519
+ if (rec.ok) h.ok += 1;
520
+ else h.failed += 1;
521
+ h.promptTokens += promptTokens;
522
+ h.completionTokens += completionTokens;
523
+ h.cachedTokens += rec.cachedTokens;
524
+ h.durationMs += rec.durationMs;
525
+ if (rec.ttfbMs > 0) {
526
+ h.ttfbMs += rec.ttfbMs;
527
+ h.ttfbCount += 1;
528
+ }
529
+ if (rec.inputEstimated) h.estimatedInputs += 1;
530
+ if (rec.outputEstimated) h.estimatedOutputs += 1;
531
+ }
397
532
  this.bump(day.bySupplier, rec.supplier, rec);
398
533
  this.bump(day.byModel, rec.model, rec);
399
534
  this.bump(day.byRequested, rec.requested, rec);
@@ -414,24 +549,71 @@ var UsageStore = class {
414
549
  e.lastTs = Math.max(e.lastTs, rec.ts);
415
550
  map[key] = e;
416
551
  }
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);
552
+ /**
553
+ * 从 `first` 起的 24 个小时格:每格带整点时间戳、所属自然日、那天的数据。
554
+ *
555
+ * `today` 传今天 00:00,`24h` 传「当前整点往前 23 格」。
556
+ * 跨天一律靠 `localDateKey(start)` 现算,不用「减 N 小时」去猜天数:
557
+ * 本地时区(尤其 DST)下小时偏移和自然日不是一回事,算错就漏一整天。
558
+ */
559
+ hourSlots(first) {
560
+ const out = [];
561
+ for (let i = 0; i < HOURS; i += 1) {
562
+ const start = first + i * 36e5;
563
+ const dayKey = localDateKey(start);
564
+ const day = this.days.get(dayKey);
565
+ out.push({
566
+ dayKey,
567
+ start,
568
+ day,
569
+ b: day?.hours[new Date(start).getHours()]
570
+ });
571
+ }
572
+ return out;
573
+ }
574
+ /** 今天的小时格起点(自然日零点)。 */
575
+ static dayStartOf(now) {
576
+ return new Date(now).setHours(0, 0, 0, 0);
577
+ }
578
+ /** 24h 滚动窗口的小时格起点(当前整点往前 23 格)。 */
579
+ static rollingStartOf(now) {
580
+ const c = new Date(now);
581
+ c.setMinutes(0, 0, 0);
582
+ return c.getTime() - 828e5;
583
+ }
584
+ /**
585
+ * 一整天的小时桶是否齐(各格之和 >= 天桶计数)。
586
+ *
587
+ * 不齐 = 这天是升级前落的盘(没有小时粒度)→ 24h 窗口踩到它就只能整桶计入。
588
+ * 宁可多算窗口外那截,也不能报一个比「今日」还小的数。
589
+ */
590
+ static hoursComplete(d) {
591
+ return d.hours.reduce((n, h) => n + h.requests, 0) >= d.requests;
421
592
  }
422
593
  /** 汇总 + Top 榜。 */
423
594
  stats(period, now = Date.now()) {
424
- if (period === "today" || period === "24h") {
425
- const rows = this.inWindow(period, now);
595
+ if (period === "today") {
596
+ const d = this.days.get(localDateKey(now));
597
+ const acc = emptyAcc();
598
+ if (d === void 0) return finish(acc, {}, {}, {}, this.lifetime);
599
+ addDay(acc, d);
600
+ return finish(acc, d.bySupplier, d.byModel, d.byRequested, this.lifetime);
601
+ }
602
+ if (period === "24h") {
603
+ const slots = this.hourSlots(UsageStore.rollingStartOf(now));
426
604
  const acc = emptyAcc();
427
605
  const bySupplier = {};
428
606
  const byModel = {};
429
607
  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);
608
+ for (const key of new Set(slots.map((s) => s.dayKey))) {
609
+ const d = this.days.get(key);
610
+ if (d === void 0) continue;
611
+ if (UsageStore.hoursComplete(d)) {
612
+ for (const s of slots) if (s.dayKey === key && s.b !== void 0) addHour(acc, s.b);
613
+ } else addDay(acc, d);
614
+ mergeInto(bySupplier, d.bySupplier);
615
+ mergeInto(byModel, d.byModel);
616
+ mergeInto(byRequested, d.byRequested);
435
617
  }
436
618
  return finish(acc, bySupplier, byModel, byRequested, this.lifetime);
437
619
  }
@@ -443,40 +625,23 @@ var UsageStore = class {
443
625
  for (let i = days - 1; i >= 0; i -= 1) {
444
626
  const d = this.days.get(localDateKey(now - i * 864e5));
445
627
  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;
628
+ addDay(acc, d);
457
629
  mergeInto(bySupplier, d.bySupplier);
458
630
  mergeInto(byModel, d.byModel);
459
631
  mergeInto(byRequested, d.byRequested);
460
632
  }
461
633
  return finish(acc, bySupplier, byModel, byRequested, this.lifetime);
462
634
  }
463
- /** 柱状图数据:today/24h = 24 个小时桶(走明细环),7d/30d = 天桶。 */
635
+ /** 柱状图数据:today/24h = 24 个小时桶,7d/30d = 天桶。 */
464
636
  chart(period, now = Date.now()) {
465
637
  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
638
+ const p = (n) => String(n).padStart(2, "0");
639
+ const first = period === "today" ? UsageStore.dayStartOf(now) : UsageStore.rollingStartOf(now);
640
+ return this.hourSlots(first).map((s) => ({
641
+ label: `${p(new Date(s.start).getHours())}:00`,
642
+ requests: s.b?.requests ?? 0,
643
+ tokens: (s.b?.promptTokens ?? 0) + (s.b?.completionTokens ?? 0)
472
644
  }));
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
645
  }
481
646
  const days = period === "7d" ? 7 : 30;
482
647
  const out = [];
@@ -524,10 +689,17 @@ var UsageStore = class {
524
689
  this.days = new Map(Object.entries(f.days ?? {}));
525
690
  this.recent = Array.isArray(f.recent) ? f.recent.slice(0, RING_CAP) : [];
526
691
  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
- });
692
+ for (const [k, d] of this.days) {
693
+ const day = {
694
+ ...emptyDay(),
695
+ ...d
696
+ };
697
+ day.hours = Array.from({ length: HOURS }, (_, i) => ({
698
+ ...emptyHour(),
699
+ ...d.hours?.[i]
700
+ }));
701
+ this.days.set(k, day);
702
+ }
531
703
  } catch {}
532
704
  }
533
705
  save() {
@@ -561,32 +733,33 @@ const emptyAcc = () => ({
561
733
  estimatedInputs: 0,
562
734
  estimatedOutputs: 0
563
735
  });
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;
736
+ /** 天桶累加(today / 7d / 30d 路径,以及 24h 的「老数据整桶兜底」)。 */
737
+ function addDay(acc, d) {
738
+ acc.requests += d.requests;
739
+ acc.ok += d.ok;
740
+ acc.failed += d.failed;
741
+ acc.promptTokens += d.promptTokens;
742
+ acc.completionTokens += d.completionTokens;
743
+ acc.cachedTokens += d.cachedTokens;
744
+ acc.durationMs += d.durationMs;
745
+ acc.ttfbMs += d.ttfbMs;
746
+ acc.ttfbCount += d.ttfbCount;
747
+ acc.estimatedInputs += d.estimatedInputs;
748
+ acc.estimatedOutputs += d.estimatedOutputs;
578
749
  }
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;
750
+ /** 小时桶累加(24h 路径)。 */
751
+ function addHour(acc, h) {
752
+ acc.requests += h.requests;
753
+ acc.ok += h.ok;
754
+ acc.failed += h.failed;
755
+ acc.promptTokens += h.promptTokens;
756
+ acc.completionTokens += h.completionTokens;
757
+ acc.cachedTokens += h.cachedTokens;
758
+ acc.durationMs += h.durationMs;
759
+ acc.ttfbMs += h.ttfbMs;
760
+ acc.ttfbCount += h.ttfbCount;
761
+ acc.estimatedInputs += h.estimatedInputs;
762
+ acc.estimatedOutputs += h.estimatedOutputs;
590
763
  }
591
764
  function finish(acc, bySupplier, byModel, byRequested, lifetime) {
592
765
  return {
@@ -898,6 +1071,33 @@ function writeJson$2(res, status, body) {
898
1071
  });
899
1072
  res.end(JSON.stringify(body));
900
1073
  }
1074
+ /**
1075
+ * 剥掉本供应商自己的 alias 前缀(只剥一层,且只认完整 `alias/` 开头)。
1076
+ *
1077
+ * 为什么不能用 lastIndexOf:模型 id 本身可以含斜杠(nvidia 的
1078
+ * `deepseek-ai/deepseek-v4-flash-0731`),剥最后一段会把命名空间吃掉,
1079
+ * 后面请求必然 404。只认「已知 alias + /」开头,其他原样返回。
1080
+ *
1081
+ * 注意:核心**只在记统计名时**用它。传给插件的 model 全名保持原样——
1082
+ * 剥前缀是插件自己的事(各供应商插件都有一份同名实现)。
1083
+ */
1084
+ function stripAlias(model, alias) {
1085
+ return alias !== "" && model.startsWith(`${alias}/`) ? model.slice(alias.length + 1) : model;
1086
+ }
1087
+ /**
1088
+ * 组合降级前的「喘口气」等待:瞬时故障给上游一个恢复窗口再换下一个模型。
1089
+ *
1090
+ * 学 9router combo.js 的同名处理(注释原话:fixes: combo falls through on
1091
+ * transient 503)—— 过载的上游往往几百毫秒内就恢复,立刻换下一个模型会
1092
+ * 让整个组合在几毫秒内被打穿,最后全灭返回 503。
1093
+ *
1094
+ * 只对**瞬时**故障等:连接层失败 / 上游不可用(对应 9router 的 502/503/504)。
1095
+ * 模型不属于本供应商(no_such_model)绝不能等 —— 它不是故障,等纯属浪费。
1096
+ */
1097
+ const TRANSIENT_SETTLE_MS = 2e3;
1098
+ function isTransient(state) {
1099
+ return state === "transport" || state === "unavailable";
1100
+ }
901
1101
  /** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
902
1102
  function sinkRes() {
903
1103
  let status = 200;
@@ -1028,7 +1228,9 @@ async function aggregateSSE(stream) {
1028
1228
  return JSON.stringify(body);
1029
1229
  }
1030
1230
  /**
1031
- * 流式 SSE 逐帧归一化:剥掉上游多发一次的**空** tool_call 字段。
1231
+ * 流式 SSE 逐帧归一化:剥掉上游多发一次的**空** tool_call 字段,
1232
+ * 并记住上游有没有发终止帧(`data: [DONE]`)—— 断流时核心要据此补发,
1233
+ * 见 `writeChatResult`。
1032
1234
  *
1033
1235
  * 为什么必须做:CodeBuddy 上游的工具调用第二个 delta 会**显式发空串**
1034
1236
  * `{"function":{"name":"","arguments":"{...}"},"index":0}`(以及 `"id":""`)。
@@ -1047,26 +1249,38 @@ function normalizeSSEStream(stream) {
1047
1249
  const enc = new TextEncoder();
1048
1250
  const dec = new TextDecoder();
1049
1251
  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)));
1252
+ let sawDone = false;
1253
+ return {
1254
+ stream: new ReadableStream({ start: async (ctrl) => {
1255
+ const reader = stream.getReader();
1256
+ try {
1257
+ for (;;) {
1258
+ const { done, value } = await reader.read();
1259
+ if (done) break;
1260
+ buf += dec.decode(value, { stream: true });
1261
+ let sep;
1262
+ while ((sep = buf.indexOf("\n\n")) >= 0) {
1263
+ const frame = buf.slice(0, sep + 2);
1264
+ buf = buf.slice(sep + 2);
1265
+ if (isDoneFrame(frame)) sawDone = true;
1266
+ ctrl.enqueue(enc.encode(fixFrame(frame)));
1267
+ }
1062
1268
  }
1269
+ if (buf !== "") {
1270
+ if (isDoneFrame(buf)) sawDone = true;
1271
+ ctrl.enqueue(enc.encode(fixFrame(buf)));
1272
+ }
1273
+ } finally {
1274
+ reader.releaseLock();
1063
1275
  }
1064
- if (buf !== "") ctrl.enqueue(enc.encode(fixFrame(buf)));
1065
- } finally {
1066
- reader.releaseLock();
1067
- }
1068
- ctrl.close();
1069
- } });
1276
+ ctrl.close();
1277
+ } }),
1278
+ sawDone: () => sawDone
1279
+ };
1280
+ }
1281
+ /** 一帧 SSE 里有没有终止帧(宽松匹配:跨 chunk 的残留尾帧也认)。 */
1282
+ function isDoneFrame(frame) {
1283
+ return /(^|\n)data:[ \t]*\[DONE\][ \t]*(\n|$)/.test(frame);
1070
1284
  }
1071
1285
  /** 修一帧 SSE:只剥空的 name/id,出任何问题都原样返回。 */
1072
1286
  function fixFrame(frame) {
@@ -1108,6 +1322,9 @@ function fixFrame(frame) {
1108
1322
  * 先写头就等于把自己锁死在这一个账号上,组合就没法回退了——而客户端只拿到
1109
1323
  * 一个空/截断的 SSE,表现为工具调用名是空串(`unknown tool ""`)。
1110
1324
  *
1325
+ * 截断时**补发终止帧**(见末尾 finally):客户端严格等 `[DONE]`,缺了就
1326
+ * 整轮判失败,已流式吐出的内容全废。
1327
+ *
1111
1328
  * 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
1112
1329
  * 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
1113
1330
  */
@@ -1139,7 +1356,13 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1139
1356
  }
1140
1357
  const tapped = probe === void 0 ? void 0 : tapStreamUsage(r.stream, startedAt);
1141
1358
  let wroteAny = false;
1359
+ /** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
1360
+ let dead = false;
1142
1361
  const writeChunk = (chunk) => new Promise((resolve) => {
1362
+ if (dead) {
1363
+ resolve();
1364
+ return;
1365
+ }
1143
1366
  if (!wroteAny) {
1144
1367
  res.writeHead(200, {
1145
1368
  "Content-Type": "text/event-stream",
@@ -1151,16 +1374,25 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1151
1374
  wroteAny = true;
1152
1375
  }
1153
1376
  if (res.write(chunk)) resolve();
1154
- else res.once("drain", resolve);
1377
+ else {
1378
+ const onDrain = () => {
1379
+ res.removeListener("error", onDrain);
1380
+ resolve();
1381
+ };
1382
+ res.once("drain", onDrain);
1383
+ }
1155
1384
  });
1156
1385
  const onError = () => {
1386
+ dead = true;
1157
1387
  res.destroy();
1158
1388
  };
1159
1389
  res.once("error", onError);
1390
+ const norm = normalizeSSEStream(tapped?.stream ?? r.stream);
1160
1391
  try {
1161
- await normalizeSSEStream(tapped?.stream ?? r.stream).pipeTo(new WritableStream({ write: writeChunk }));
1392
+ await norm.stream.pipeTo(new WritableStream({ write: writeChunk }));
1162
1393
  } catch {} finally {
1163
1394
  res.removeListener("error", onError);
1395
+ if (wroteAny && !norm.sawDone() && !dead && !res.writableEnded && !res.destroyed) await writeChunk(DONE_FRAME);
1164
1396
  if (wroteAny) res.end();
1165
1397
  if (probe !== void 0 && tapped !== void 0) {
1166
1398
  const got = await tapped.done();
@@ -1171,6 +1403,8 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1171
1403
  }
1172
1404
  return wroteAny;
1173
1405
  }
1406
+ /** SSE 终止帧(断流兜底补发用)。 */
1407
+ const DONE_FRAME = new TextEncoder().encode("data: [DONE]\n\n");
1174
1408
  /** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
1175
1409
  function extractUpstreamError(body) {
1176
1410
  try {
@@ -1516,10 +1750,12 @@ var Router = class {
1516
1750
  for (let i = 0; i < combo.models.length; i++) {
1517
1751
  const model = combo.models[(start + i) % combo.models.length];
1518
1752
  if (model === void 0) continue;
1519
- if (await this.chatWithModel(req, res, model, probe)) {
1753
+ const trace = { attempts: 0 };
1754
+ if (await this.chatWithModel(req, res, model, probe, trace)) {
1520
1755
  settle(true);
1521
1756
  return;
1522
1757
  }
1758
+ if (trace.lastState !== void 0 && isTransient(trace.lastState) && i < combo.models.length - 1) await new Promise((r) => setTimeout(r, TRANSIENT_SETTLE_MS));
1523
1759
  }
1524
1760
  settle(false, "combo: all models unavailable");
1525
1761
  writeJson$2(res, 503, openAIError("no_healthy_supplier", `combo ${JSON.stringify(req.model)}: all models unavailable`));
@@ -1557,7 +1793,12 @@ var Router = class {
1557
1793
  * 不再挨个问供应商「这是不是你的模型」——精准调用,也避免同名模型串台。
1558
1794
  * 兼容旧的裸 `modelId`(没有逗号):降级为遍历,行为同以前。
1559
1795
  */
1560
- async chatWithModel(req, res, model, probe) {
1796
+ /**
1797
+ * @param outTrace 可选出参:把这次调用的追踪信息带回去(组合据此判断
1798
+ * 要不要「喘口气」再降级)。裸 id 遍历路径不填(多供应商混在一起,
1799
+ * 失败状态没有单一归属)。
1800
+ */
1801
+ async chatWithModel(req, res, model, probe, outTrace) {
1561
1802
  const comma = model.indexOf(",");
1562
1803
  const supplierId = comma > 0 ? model.slice(0, comma) : void 0;
1563
1804
  const modelId = comma > 0 ? model.slice(comma + 1) : model;
@@ -1572,20 +1813,26 @@ var Router = class {
1572
1813
  } catch {
1573
1814
  clone.rawBody = req.rawBody;
1574
1815
  }
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;
1816
+ if (supplierId === void 0) {
1817
+ const msg = `model ${JSON.stringify(model)} 缺供应商前缀(应为 supplierId,modelId)`;
1818
+ this.logChat(req.model, msg, 0);
1819
+ return false;
1586
1820
  }
1587
- for (const s of this.suppliers) if (await this.chatWithSupplier(s, clone, res, void 0, probe)) return true;
1588
- return false;
1821
+ const s = this.suppliers.find((x) => x.id === supplierId);
1822
+ if (s === void 0) {
1823
+ this.logChat(req.model, `supplier ${JSON.stringify(supplierId)} 不存在`, 0);
1824
+ return false;
1825
+ }
1826
+ const t0 = Date.now();
1827
+ const trace = { attempts: 0 };
1828
+ const served = await this.chatWithSupplier(s, clone, res, trace, probe);
1829
+ if (outTrace !== void 0) {
1830
+ outTrace.attempts = trace.attempts;
1831
+ outTrace.lastError = trace.lastError;
1832
+ outTrace.lastState = trace.lastState;
1833
+ }
1834
+ 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);
1835
+ return served;
1589
1836
  }
1590
1837
  /** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
1591
1838
  supplierByAlias(alias) {
@@ -1607,20 +1854,21 @@ var Router = class {
1607
1854
  const r = await s.chatOnce("", req);
1608
1855
  if (!r.ok) {
1609
1856
  if (r.state === "no_such_model") return false;
1610
- pool.noteFailure("", r.state, r.message);
1857
+ if (trace !== void 0) trace.lastState = r.state;
1858
+ pool.noteFailure("", req.model, r.state, r.message);
1611
1859
  return false;
1612
1860
  }
1613
1861
  if (trace !== void 0) trace.uid = "(no-account)";
1614
1862
  if (probe !== void 0) {
1615
1863
  probe.supplier = s.id;
1616
- probe.model = `${s.getAlias()}/${req.model}`;
1864
+ probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
1617
1865
  probe.uid = "";
1618
1866
  }
1619
1867
  return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0);
1620
1868
  }
1621
1869
  const tried = /* @__PURE__ */ new Set();
1622
1870
  for (;;) {
1623
- const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy);
1871
+ const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model);
1624
1872
  if (uid === void 0) return false;
1625
1873
  tried.add(uid);
1626
1874
  const r = await s.chatOnce(uid, req);
@@ -1629,24 +1877,26 @@ var Router = class {
1629
1877
  if (trace !== void 0) trace.lastError = "no_such_model";
1630
1878
  return false;
1631
1879
  }
1632
- pool.noteFailure(uid, r.state, r.message);
1880
+ pool.noteFailure(uid, req.model, r.state, r.message);
1633
1881
  if (trace !== void 0) {
1634
1882
  trace.attempts += 1;
1883
+ trace.lastState = r.state;
1635
1884
  trace.lastError = `${r.state}: ${r.message}`;
1636
1885
  }
1637
1886
  continue;
1638
1887
  }
1639
- pool.noteSuccess(uid);
1888
+ pool.noteSuccess(uid, req.model);
1640
1889
  if (trace !== void 0) trace.uid = uid;
1641
1890
  if (probe !== void 0) {
1642
1891
  probe.supplier = s.id;
1643
- probe.model = `${s.getAlias()}/${req.model}`;
1892
+ probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
1644
1893
  probe.uid = uid;
1645
1894
  }
1646
1895
  if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) return true;
1647
- pool.noteFailure(uid, "transport", "stream failed before first byte");
1896
+ pool.noteFailure(uid, req.model, "transport", "stream failed before first byte");
1648
1897
  if (trace !== void 0) {
1649
1898
  trace.attempts += 1;
1899
+ trace.lastState = "transport";
1650
1900
  trace.lastError = "stream failed before first byte";
1651
1901
  }
1652
1902
  }
@@ -1707,26 +1957,21 @@ var Router = class {
1707
1957
  *
1708
1958
  * 纯文本流:组合为文本模型,本 adapter 不处理图片附件。
1709
1959
  */
1710
- /** 解析 SSE 文本,产出 `data:` 载荷(含结尾 [DONE])。 */
1711
- function* parseSseText(text) {
1712
- let data = "";
1713
- for (const line of text.split("\n")) {
1714
- if (line.startsWith("data:")) {
1715
- const value = line.slice(5).trimStart();
1716
- if (data.length > 0) data += "\n";
1717
- data += value;
1718
- continue;
1719
- }
1720
- if (line === "" && data.length > 0) {
1721
- yield data;
1722
- data = "";
1723
- }
1724
- }
1725
- if (data.length > 0) yield data;
1726
- }
1727
- /** 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。 */
1728
- function wireMessages(options) {
1960
+ /**
1961
+ * 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。
1962
+ *
1963
+ * `system` 必须由调用方从 `options.system` 传进来:agent-loop 把系统提示词
1964
+ * 放在 **options.system 这个独立槽位**,不放进 messages(见 dsh-llm
1965
+ * types.d.ts 的 `system?: string` —— 注释原话 "adapters map to the
1966
+ * provider's system slot")。不读这个槽位,模型每一轮都拿不到身份/规则/
1967
+ * 工具用法约束,且**不会报错**,只是行为悄悄降级。
1968
+ */
1969
+ function wireMessages(options, system) {
1729
1970
  const out = [];
1971
+ if (typeof system === "string" && system !== "") out.push({
1972
+ role: "system",
1973
+ content: system
1974
+ });
1730
1975
  for (const message of options.messages) {
1731
1976
  if (message.role === "system") {
1732
1977
  out.push({
@@ -1763,14 +2008,14 @@ function wireMessages(options) {
1763
2008
  for (const result of toolResults) out.push({
1764
2009
  role: "tool",
1765
2010
  tool_call_id: result.toolCallId,
1766
- content: flattenText(result.content) || "(no output)"
2011
+ content: flattenText(result.content)
1767
2012
  });
1768
2013
  }
1769
2014
  return out;
1770
2015
  }
1771
2016
  /** 组装 wire 请求体。 */
1772
2017
  function wireRequest(options) {
1773
- const messages = wireMessages(options);
2018
+ const messages = wireMessages(options, options.system);
1774
2019
  const body = {
1775
2020
  model: options.model,
1776
2021
  messages,
@@ -1789,12 +2034,41 @@ function wireRequest(options) {
1789
2034
  if (options.temperature !== void 0) body.temperature = options.temperature;
1790
2035
  if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
1791
2036
  if (options.stop !== void 0) body.stop = options.stop;
2037
+ applyReasoning(body, options.reasoningEffort);
1792
2038
  return body;
1793
2039
  }
2040
+ /**
2041
+ * 推理强度 → CodeBuddy 的 OpenAI 风格参数。
2042
+ *
2043
+ * 照 9router `executors/codebuddy-cn.js` 的处理:CodeBuddy 只有同时收到
2044
+ * `reasoning_effort` + `reasoning_summary:"auto"` 才吐推理内容,而 harness
2045
+ * 只给 `reasoning_effort`,从不下发 `reasoning_summary`。
2046
+ *
2047
+ * 关键陷阱(9router 注释里的 #2071):**不能无条件加**。对没要推理的普通
2048
+ * 请求强行加 `reasoning_effort:"medium"` + `reasoning_summary`,会让
2049
+ * CodeBuddy 触发内容过滤直接报错。`none`/`off` 也必须是**删字段**而不是传
2050
+ * `"none"` —— 网关没有这个值。
2051
+ */
2052
+ function applyReasoning(body, effort) {
2053
+ if (effort === void 0 || effort === "") return;
2054
+ if (effort === "none" || effort === "off") {
2055
+ delete body.reasoning_effort;
2056
+ delete body.reasoning_summary;
2057
+ return;
2058
+ }
2059
+ body.reasoning_effort = effort;
2060
+ body.reasoning_summary = "auto";
2061
+ }
1794
2062
  function flattenText(blocks) {
1795
2063
  return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
1796
2064
  }
1797
2065
  /** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
2066
+ /**
2067
+ * 把上游 SSE 的 `data:` 载荷流翻译成 DSH 的 StreamChunk 事件流。
2068
+ *
2069
+ * 吃 `AsyncIterable`(真流式:边收边吐)或 `Iterable`(测试用数组)。
2070
+ * 用 `for await` 是因为它天然兼容两者 —— 不必为流式再写一份。
2071
+ */
1798
2072
  async function* translateSse(payloads) {
1799
2073
  let nextIndex = 0;
1800
2074
  let textBlock;
@@ -1804,6 +2078,8 @@ async function* translateSse(payloads) {
1804
2078
  let pendingFinish;
1805
2079
  /** 攒上游各帧的 usage(归一形态),[DONE] 时统一转成 DSH 契约。 */
1806
2080
  let accumulated = null;
2081
+ /** 解析失败的帧数(坏帧跳过,不硬抛;全是坏帧才当断流处理)。 */
2082
+ let malformed = 0;
1807
2083
  const open = (kind) => {
1808
2084
  const block = {
1809
2085
  index: nextIndex++,
@@ -1813,13 +2089,35 @@ async function* translateSse(payloads) {
1813
2089
  order.push(block);
1814
2090
  return block;
1815
2091
  };
1816
- for (const payload of payloads) {
2092
+ for await (const payload of payloads) {
1817
2093
  if (payload === "[DONE]") {
1818
- for (const block of order) yield {
2094
+ const complete = order.filter((block) => block.kind !== "tool-call" || completeJson(block.text));
2095
+ const dropped = order.length - complete.length;
2096
+ for (const block of complete) yield {
1819
2097
  type: "block-end",
1820
2098
  index: block.index,
1821
2099
  block: closeBlock(block)
1822
2100
  };
2101
+ for (const block of order) {
2102
+ if (block.kind !== "tool-call" || completeJson(block.text)) continue;
2103
+ yield {
2104
+ type: "block-end",
2105
+ index: block.index,
2106
+ block: {
2107
+ type: "tool-call",
2108
+ id: CallId(block.callId ?? ""),
2109
+ name: "",
2110
+ arguments: "{}"
2111
+ }
2112
+ };
2113
+ }
2114
+ if (dropped > 0) pendingFinish = {
2115
+ kind: "error",
2116
+ failure: {
2117
+ message: `upstream closed the stream before ${dropped} tool call argument${dropped > 1 ? "s were" : " was"} complete`,
2118
+ code: EMPTY_RESPONSE_CODE
2119
+ }
2120
+ };
1823
2121
  if (accumulated !== null) {
1824
2122
  const usage = toTokenUsage(accumulated);
1825
2123
  if (usage !== null) yield {
@@ -1844,7 +2142,8 @@ async function* translateSse(payloads) {
1844
2142
  try {
1845
2143
  chunk = JSON.parse(payload);
1846
2144
  } catch {
1847
- throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, "MALFORMED_RESPONSE");
2145
+ malformed += 1;
2146
+ continue;
1848
2147
  }
1849
2148
  for (const choice of chunk.choices ?? []) {
1850
2149
  const delta = choice.delta ?? {};
@@ -1907,24 +2206,33 @@ async function* translateSse(payloads) {
1907
2206
  argumentsDelta: fragment
1908
2207
  };
1909
2208
  }
1910
- if (typeof choice.finish_reason === "string") pendingFinish = mapFinishReason(choice.finish_reason);
2209
+ if (typeof choice.finish_reason === "string") {
2210
+ const mapped = mapFinishReason(choice.finish_reason);
2211
+ if (mapped !== void 0) pendingFinish = mapped;
2212
+ }
1911
2213
  }
1912
2214
  if (chunk.usage !== void 0) accumulated = mergeUsage(accumulated, normalizeUsage(chunk.usage));
1913
2215
  }
1914
- throw new LlmError("SSE payload stream ended without [DONE]", "STREAM_CLOSED");
2216
+ const detail = malformed > 0 ? ` (${malformed} frame(s) unparseable)` : "";
2217
+ throw new LlmError(`SSE payload stream ended without [DONE]${detail}`, "TRANSPORT");
1915
2218
  }
2219
+ /** 上游 finish_reason → DSH finish reason;空串(「未终止」)返回 undefined。 */
1916
2220
  function mapFinishReason(reason) {
1917
2221
  switch (reason) {
2222
+ case "": return;
1918
2223
  case "stop": return { kind: "stop" };
1919
2224
  case "tool_calls": return { kind: "tool-calls" };
1920
2225
  case "length": return { kind: "max-tokens" };
1921
- default: return {
1922
- kind: "error",
1923
- failure: {
1924
- message: `model stopped: ${reason}`,
1925
- code: reason.toUpperCase()
1926
- }
1927
- };
2226
+ default: {
2227
+ const code = reason.trim().toUpperCase();
2228
+ return {
2229
+ kind: "error",
2230
+ failure: {
2231
+ message: `model stopped: ${reason}`,
2232
+ code: code === "" ? "UNKNOWN_FINISH_REASON" : code
2233
+ }
2234
+ };
2235
+ }
1928
2236
  }
1929
2237
  }
1930
2238
  function closeBlock(block) {
@@ -1941,11 +2249,27 @@ function closeBlock(block) {
1941
2249
  type: "tool-call",
1942
2250
  id: CallId(block.callId ?? ""),
1943
2251
  name: block.name ?? "",
1944
- arguments: block.text
2252
+ arguments: block.text === "" ? "{}" : block.text
1945
2253
  };
1946
2254
  }
1947
2255
  }
1948
2256
  /**
2257
+ * 工具调用的 arguments 是否已经收完整(能解析成一个 JSON 对象)。
2258
+ *
2259
+ * 判断口径刻意简单:只看能不能 `JSON.parse` 出对象。不校验 schema——
2260
+ * 那是工具自己的活,adapter 只负责「别把半成品交出去」。
2261
+ * 空串视为完整(无参工具),因为上游不发参数分片是合法的。
2262
+ */
2263
+ function completeJson(text) {
2264
+ if (text.trim() === "") return true;
2265
+ try {
2266
+ const v = JSON.parse(text);
2267
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2268
+ } catch {
2269
+ return false;
2270
+ }
2271
+ }
2272
+ /**
1949
2273
  * DSH adapter:provider `router`。模型目录 = 组合;stream 转发到本插件
1950
2274
  * /v1/chat/completions(组合路由在 /v1 内完成)。
1951
2275
  */
@@ -1988,6 +2312,7 @@ var RouterAdapter = class extends LlmAdapter {
1988
2312
  resp = await fetch(`${this.baseURL}/chat/completions`, {
1989
2313
  method: "POST",
1990
2314
  headers: {
2315
+ ...attributionHeaders(),
1991
2316
  "content-type": "application/json",
1992
2317
  accept: "text/event-stream"
1993
2318
  },
@@ -2003,9 +2328,77 @@ var RouterAdapter = class extends LlmAdapter {
2003
2328
  const detail = await resp.text().catch(() => "");
2004
2329
  throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, "TRANSPORT");
2005
2330
  }
2006
- yield* translateSse(parseSseText(resp.body === null ? "" : await resp.text()));
2331
+ if (resp.body === null) return;
2332
+ yield* translateSse(ssePayloads(resp.body));
2007
2333
  }
2008
2334
  };
2335
+ /**
2336
+ * 已攒的 payload 是否已经是完整的(`[DONE]` 或一个能解析的 JSON 对象)。
2337
+ *
2338
+ * 只用于「缺空行分隔」时的前瞻回退,不校验业务结构 —— 那是 translateSse 的事。
2339
+ */
2340
+ function looksComplete(payload) {
2341
+ if (payload === "[DONE]") return true;
2342
+ try {
2343
+ JSON.parse(payload);
2344
+ return true;
2345
+ } catch {
2346
+ return false;
2347
+ }
2348
+ }
2349
+ /**
2350
+ * 从上游响应体**增量**产出 SSE 的 `data:` 载荷。
2351
+ *
2352
+ * 为什么必须逐块读:曾经这里写 `await resp.text()` 攒完整串再解析,等于
2353
+ * 把流式降级成批处理 —— 首字节延迟等于整个响应耗时;更糟的是中途断流时
2354
+ * resp.text() 只拿到半截文本,最后一帧是半截 JSON(`{"command": "cd`),
2355
+ * 表现为「工具参数残缺」。短响应连接不断,所以本地抓包复现不了。
2356
+ *
2357
+ * 跨块边界:一个 `data:` 行可能横跨两次 read(),所以留 `buffer` 接住
2358
+ * 最后一段未完整的行(9router 的 pipeTransformedSSE 同样处理)。
2359
+ */
2360
+ async function* ssePayloads(body) {
2361
+ const reader = body.getReader();
2362
+ const decoder = new TextDecoder("utf-8", { fatal: false });
2363
+ let buffer = "";
2364
+ /** 攒一帧内的多行 data(SSE 规范:多行用 \n 连起来)。**必须**是局部
2365
+ * 状态 —— 放模块级会让并发请求互相串数据。 */
2366
+ let payload = "";
2367
+ try {
2368
+ for (;;) {
2369
+ const { done, value } = await reader.read();
2370
+ if (done) break;
2371
+ buffer += decoder.decode(value, { stream: true });
2372
+ const lines = buffer.split("\n");
2373
+ buffer = lines.pop() ?? "";
2374
+ for (const line of lines) {
2375
+ const t = line.trim();
2376
+ if (t.startsWith("data:")) {
2377
+ if (payload.length > 0 && looksComplete(payload)) {
2378
+ yield payload;
2379
+ payload = "";
2380
+ }
2381
+ payload += (payload.length > 0 ? "\n" : "") + t.slice(5).trimStart();
2382
+ continue;
2383
+ }
2384
+ if (t === "" && payload.length > 0) {
2385
+ yield payload;
2386
+ payload = "";
2387
+ }
2388
+ }
2389
+ }
2390
+ if (buffer.length > 0) {
2391
+ const t = buffer.trim();
2392
+ if (t.startsWith("data:")) payload += (payload.length > 0 ? "\n" : "") + t.slice(5).trimStart();
2393
+ }
2394
+ if (payload.length > 0) yield payload;
2395
+ } finally {
2396
+ try {
2397
+ reader.releaseLock();
2398
+ } catch {}
2399
+ await body.cancel().catch(() => {});
2400
+ }
2401
+ }
2009
2402
  //#endregion
2010
2403
  //#region src/keys.ts
2011
2404
  /**
@@ -2175,7 +2568,7 @@ function wrapModule(instance, env, source, kind = "external") {
2175
2568
  if (typeof instance !== "object" || instance === null || typeof instance.id !== "string" || instance.id === "") throw new Error(`供应商模块无效: ${source}`);
2176
2569
  const capabilities = /* @__PURE__ */ new Set();
2177
2570
  for (const key of DIFF_CAPS) if (isFn(instance[key])) capabilities.add(key);
2178
- const pool = new AccountPool();
2571
+ const pool = new AccountPool(instance.id);
2179
2572
  const supplier = {
2180
2573
  id: instance.id,
2181
2574
  name: instance.name,
@@ -2632,14 +3025,17 @@ function supplierRoutes(base, loaded, store, router) {
2632
3025
  }
2633
3026
  const succeeded = results.filter((r) => r.status === "ok").length;
2634
3027
  const already = results.filter((r) => r.status === "already").length;
3028
+ const failed = results.length - succeeded - already;
3029
+ const ok = failed === 0 && results.length > 0;
2635
3030
  const payload = {
2636
- ok: succeeded + already > 0,
3031
+ ok,
2637
3032
  total: uids.length,
2638
3033
  succeeded,
2639
3034
  already,
3035
+ failed,
2640
3036
  results
2641
3037
  };
2642
- writeJson$1(res, payload.ok ? 200 : 400, payload);
3038
+ writeJson$1(res, ok ? 200 : 400, payload);
2643
3039
  }
2644
3040
  });
2645
3041
  return routes;