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/README.md +5 -1
- package/docs/suppliers.md +19 -3
- package/lib/client-registry.js +13 -8
- package/lib/client.js +13 -8
- package/lib/index.js +611 -215
- package/lib/types/llm/adapter.d.ts +7 -1
- package/lib/types/router/account-pool.d.ts +54 -19
- package/lib/types/router/index.d.ts +5 -0
- package/lib/types/router/usage-store.d.ts +20 -3
- package/lib/types/shared.d.ts +2 -0
- package/package.json +1 -1
- package/src/client/RouterView.tsx +1 -1
- package/src/client/SupplierDetail.tsx +8 -8
- package/src/llm/adapter.test.ts +404 -1
- package/src/llm/adapter.ts +242 -34
- package/src/router/account-pool.test.ts +163 -61
- package/src/router/account-pool.ts +200 -68
- package/src/router/index.ts +137 -38
- package/src/router/models-cache.test.ts +87 -4
- package/src/router/precise-dispatch.test.ts +110 -7
- package/src/router/usage-store.ts +174 -74
- package/src/router/usage.test.ts +115 -0
- package/src/shared.ts +2 -0
- package/src/suppliers/loader.ts +2 -1
- package/src/suppliers/registry.test.ts +23 -3
- package/src/suppliers/registry.ts +7 -2
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
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
30
|
+
cooldown: 0,
|
|
23
31
|
disable: false,
|
|
24
32
|
counts: false
|
|
25
33
|
},
|
|
26
34
|
rate_limit: {
|
|
27
|
-
|
|
35
|
+
cooldown: "backoff",
|
|
28
36
|
disable: false,
|
|
29
37
|
counts: false
|
|
30
38
|
},
|
|
31
39
|
quota: {
|
|
32
|
-
|
|
40
|
+
cooldown: 10 * MINUTE,
|
|
33
41
|
disable: false,
|
|
34
42
|
counts: false
|
|
35
43
|
},
|
|
36
44
|
session_dead: {
|
|
37
|
-
|
|
45
|
+
cooldown: 0,
|
|
38
46
|
disable: true,
|
|
39
47
|
counts: false
|
|
40
48
|
},
|
|
41
49
|
unavailable: {
|
|
42
|
-
|
|
50
|
+
cooldown: "transient",
|
|
43
51
|
disable: false,
|
|
44
|
-
counts:
|
|
52
|
+
counts: false
|
|
45
53
|
},
|
|
46
54
|
transport: {
|
|
47
|
-
|
|
55
|
+
cooldown: "transient",
|
|
48
56
|
disable: false,
|
|
49
|
-
counts:
|
|
57
|
+
counts: false
|
|
50
58
|
},
|
|
51
59
|
unknown: {
|
|
52
|
-
|
|
60
|
+
cooldown: "transient",
|
|
53
61
|
disable: false,
|
|
54
|
-
counts:
|
|
62
|
+
counts: false
|
|
55
63
|
},
|
|
56
64
|
no_such_model: {
|
|
57
|
-
|
|
65
|
+
cooldown: 0,
|
|
58
66
|
disable: false,
|
|
59
67
|
counts: false
|
|
60
68
|
}
|
|
61
69
|
};
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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.
|
|
123
|
+
this.byUid.set(uid, e);
|
|
79
124
|
}
|
|
80
125
|
return e;
|
|
81
126
|
}
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
const e = this.
|
|
85
|
-
if (!e) return
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
121
|
-
e.
|
|
122
|
-
|
|
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.
|
|
132
|
-
if (e) e.
|
|
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.
|
|
206
|
+
const e = this.uidState(uid);
|
|
137
207
|
e.until = Date.now() + ms;
|
|
138
208
|
e.reason = reason;
|
|
139
|
-
|
|
209
|
+
for (const [k, ce] of this.cooldowns) if (k.endsWith(`${SEP}${uid}`)) ce.backoffLevel = 0;
|
|
140
210
|
}
|
|
141
|
-
/**
|
|
142
|
-
|
|
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
|
|
146
|
-
const
|
|
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
|
|
151
|
-
err_count:
|
|
152
|
-
until: cooling &&
|
|
153
|
-
reason:
|
|
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
|
-
*
|
|
297
|
-
* - `today`
|
|
298
|
-
* - `
|
|
392
|
+
* 统计口径(不再走明细环 —— 见下面「天花板」):
|
|
393
|
+
* - `today` → 走**天桶**(今天这一桶就是答案,天然精确)
|
|
394
|
+
* - `24h` → 走**小时桶**(滚动窗口,天桶是自然日,对不上)
|
|
395
|
+
* - `7d` / `30d` → 走**天桶**(长期精确)
|
|
299
396
|
*
|
|
300
397
|
* 天花板(JSON 的代价,刻意接受):
|
|
301
|
-
* -
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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"
|
|
425
|
-
const
|
|
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
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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
|
|
635
|
+
/** 柱状图数据:today/24h = 24 个小时桶,7d/30d = 天桶。 */
|
|
464
636
|
chart(period, now = Date.now()) {
|
|
465
637
|
if (period === "today" || period === "24h") {
|
|
466
|
-
const
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
label: `${
|
|
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)
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
acc.
|
|
569
|
-
acc.
|
|
570
|
-
acc.
|
|
571
|
-
acc.
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
-
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1576
|
-
const
|
|
1577
|
-
|
|
1578
|
-
|
|
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
|
-
|
|
1588
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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:
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
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
|
-
|
|
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
|
|
3031
|
+
ok,
|
|
2637
3032
|
total: uids.length,
|
|
2638
3033
|
succeeded,
|
|
2639
3034
|
already,
|
|
3035
|
+
failed,
|
|
2640
3036
|
results
|
|
2641
3037
|
};
|
|
2642
|
-
writeJson$1(res,
|
|
3038
|
+
writeJson$1(res, ok ? 200 : 400, payload);
|
|
2643
3039
|
}
|
|
2644
3040
|
});
|
|
2645
3041
|
return routes;
|