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
|
@@ -5,14 +5,21 @@
|
|
|
5
5
|
* 落盘**而不是 SQLite:dsh-router 现有持久化全是 JSON(supplier-config.json /
|
|
6
6
|
* keys.json),不为一个看板引入数据库依赖。
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* - `today`
|
|
10
|
-
* - `
|
|
8
|
+
* 统计口径(不再走明细环 —— 见下面「天花板」):
|
|
9
|
+
* - `today` → 走**天桶**(今天这一桶就是答案,天然精确)
|
|
10
|
+
* - `24h` → 走**小时桶**(滚动窗口,天桶是自然日,对不上)
|
|
11
|
+
* - `7d` / `30d` → 走**天桶**(长期精确)
|
|
11
12
|
*
|
|
12
13
|
* 天花板(JSON 的代价,刻意接受):
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* - 明细环 `recent[]` 只留最近 `RING_CAP` 条,**只服务「最近请求列表」**,
|
|
15
|
+
* 不参与任何统计。历史上 today/24h 直接扫它,单日超 500 条就被静默截断,
|
|
16
|
+
* 于是出现「今日 500 / 7 天 1621」这种自相矛盾的数字——现在不会了。
|
|
17
|
+
* - **小时桶只从新数据开始累积**:升级前落的盘没有小时粒度,而明细环只剩
|
|
18
|
+
* 最近 500 条、回溯不回去,所以那段历史的**天内分布是永久缺失的**。
|
|
19
|
+
* 影响面:①`24h` 遇到「小时粒度不齐」的自然日 → 整桶计入(宁可多算,
|
|
20
|
+
* 也不显示一个比「今日」还小的荒唐数字);②`today`/`24h` 的小时柱状图
|
|
21
|
+
* 那几格是空的(不编数据)。天桶口径(today/7d/30d)完全不受影响,
|
|
22
|
+
* 新数据立刻就有小时粒度,隔天起图表全正常。
|
|
16
23
|
* - 天桶保留 30 天(9router 有 SQLite 不 prune,JSON 撑不住,得自己限)
|
|
17
24
|
* - 落盘防抖 2s:进程被 kill 最多丢 2s 的计数。看板不是账本,可接受;
|
|
18
25
|
* 要严格就改成立即写,代价是每个请求一次磁盘写。
|
|
@@ -52,6 +59,21 @@ interface Entry {
|
|
|
52
59
|
lastTs: number
|
|
53
60
|
}
|
|
54
61
|
|
|
62
|
+
/** 小时桶(today/24h 的统计口径,下标 = 本地小时 0..23)。 */
|
|
63
|
+
interface HourBucket {
|
|
64
|
+
requests: number
|
|
65
|
+
ok: number
|
|
66
|
+
failed: number
|
|
67
|
+
promptTokens: number
|
|
68
|
+
completionTokens: number
|
|
69
|
+
cachedTokens: number
|
|
70
|
+
durationMs: number
|
|
71
|
+
ttfbMs: number
|
|
72
|
+
ttfbCount: number
|
|
73
|
+
estimatedInputs: number
|
|
74
|
+
estimatedOutputs: number
|
|
75
|
+
}
|
|
76
|
+
|
|
55
77
|
/** 一天一桶。 */
|
|
56
78
|
interface DayBucket {
|
|
57
79
|
requests: number
|
|
@@ -67,6 +89,8 @@ interface DayBucket {
|
|
|
67
89
|
/** 输入是估算的请求数(面板据此提示「部分数据为估算」)。 */
|
|
68
90
|
estimatedInputs: number
|
|
69
91
|
estimatedOutputs: number
|
|
92
|
+
/** 24 个小时桶,定长(天桶本身才 1.5KB,加这个可忽略)。 */
|
|
93
|
+
hours: HourBucket[]
|
|
70
94
|
bySupplier: Record<string, Entry>
|
|
71
95
|
byModel: Record<string, Entry>
|
|
72
96
|
byRequested: Record<string, Entry>
|
|
@@ -80,13 +104,30 @@ interface UsageFile {
|
|
|
80
104
|
|
|
81
105
|
export type Period = 'today' | '24h' | '7d' | '30d'
|
|
82
106
|
|
|
83
|
-
/**
|
|
107
|
+
/** 明细环容量:只喂「最近请求列表」,跟统计口径无关了。 */
|
|
84
108
|
const RING_CAP = 500
|
|
109
|
+
/** 一天几格(小时桶定长;要更细的精度就把这个值放大)。 */
|
|
110
|
+
const HOURS = 24
|
|
85
111
|
/** 天桶保留天数。 */
|
|
86
112
|
const KEEP_DAYS = 30
|
|
87
113
|
/** 落盘防抖。 */
|
|
88
114
|
const SAVE_DEBOUNCE_MS = 2000
|
|
89
115
|
|
|
116
|
+
/** 小时格:一个整点 + 它落在哪个自然日 + 那天的数据/该小时桶(无数据则 undefined)。 */
|
|
117
|
+
interface HourSlot {
|
|
118
|
+
dayKey: string
|
|
119
|
+
start: number
|
|
120
|
+
day: DayBucket | undefined
|
|
121
|
+
b: HourBucket | undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const emptyHour = (): HourBucket => ({
|
|
125
|
+
requests: 0, ok: 0, failed: 0,
|
|
126
|
+
promptTokens: 0, completionTokens: 0, cachedTokens: 0,
|
|
127
|
+
durationMs: 0, ttfbMs: 0, ttfbCount: 0,
|
|
128
|
+
estimatedInputs: 0, estimatedOutputs: 0,
|
|
129
|
+
})
|
|
130
|
+
|
|
90
131
|
const emptyDay = (): DayBucket => ({
|
|
91
132
|
requests: 0,
|
|
92
133
|
ok: 0,
|
|
@@ -99,6 +140,7 @@ const emptyDay = (): DayBucket => ({
|
|
|
99
140
|
ttfbCount: 0,
|
|
100
141
|
estimatedInputs: 0,
|
|
101
142
|
estimatedOutputs: 0,
|
|
143
|
+
hours: Array.from({ length: HOURS }, () => emptyHour()),
|
|
102
144
|
bySupplier: {},
|
|
103
145
|
byModel: {},
|
|
104
146
|
byRequested: {},
|
|
@@ -174,6 +216,23 @@ export class UsageStore {
|
|
|
174
216
|
if (rec.inputEstimated) day.estimatedInputs += 1
|
|
175
217
|
if (rec.outputEstimated) day.estimatedOutputs += 1
|
|
176
218
|
|
|
219
|
+
const h = day.hours[new Date(ts).getHours()]
|
|
220
|
+
if (h !== undefined) {
|
|
221
|
+
h.requests += 1
|
|
222
|
+
if (rec.ok) h.ok += 1
|
|
223
|
+
else h.failed += 1
|
|
224
|
+
h.promptTokens += promptTokens
|
|
225
|
+
h.completionTokens += completionTokens
|
|
226
|
+
h.cachedTokens += rec.cachedTokens
|
|
227
|
+
h.durationMs += rec.durationMs
|
|
228
|
+
if (rec.ttfbMs > 0) {
|
|
229
|
+
h.ttfbMs += rec.ttfbMs
|
|
230
|
+
h.ttfbCount += 1
|
|
231
|
+
}
|
|
232
|
+
if (rec.inputEstimated) h.estimatedInputs += 1
|
|
233
|
+
if (rec.outputEstimated) h.estimatedOutputs += 1
|
|
234
|
+
}
|
|
235
|
+
|
|
177
236
|
this.bump(day.bySupplier, rec.supplier, rec)
|
|
178
237
|
this.bump(day.byModel, rec.model, rec)
|
|
179
238
|
this.bump(day.byRequested, rec.requested, rec)
|
|
@@ -198,26 +257,80 @@ export class UsageStore {
|
|
|
198
257
|
map[key] = e
|
|
199
258
|
}
|
|
200
259
|
|
|
201
|
-
/**
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
260
|
+
/**
|
|
261
|
+
* 从 `first` 起的 24 个小时格:每格带整点时间戳、所属自然日、那天的数据。
|
|
262
|
+
*
|
|
263
|
+
* `today` 传今天 00:00,`24h` 传「当前整点往前 23 格」。
|
|
264
|
+
* 跨天一律靠 `localDateKey(start)` 现算,不用「减 N 小时」去猜天数:
|
|
265
|
+
* 本地时区(尤其 DST)下小时偏移和自然日不是一回事,算错就漏一整天。
|
|
266
|
+
*/
|
|
267
|
+
private hourSlots(first: number): HourSlot[] {
|
|
268
|
+
const out: HourSlot[] = []
|
|
269
|
+
for (let i = 0; i < HOURS; i += 1) {
|
|
270
|
+
const start = first + i * 3600000
|
|
271
|
+
const dayKey = localDateKey(start)
|
|
272
|
+
const day = this.days.get(dayKey)
|
|
273
|
+
out.push({ dayKey, start, day, b: day?.hours[new Date(start).getHours()] })
|
|
274
|
+
}
|
|
275
|
+
return out
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 今天的小时格起点(自然日零点)。 */
|
|
279
|
+
private static dayStartOf(now: number): number {
|
|
280
|
+
return new Date(now).setHours(0, 0, 0, 0)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 24h 滚动窗口的小时格起点(当前整点往前 23 格)。 */
|
|
284
|
+
private static rollingStartOf(now: number): number {
|
|
285
|
+
const c = new Date(now)
|
|
286
|
+
c.setMinutes(0, 0, 0)
|
|
287
|
+
return c.getTime() - (HOURS - 1) * 3600000
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 一整天的小时桶是否齐(各格之和 >= 天桶计数)。
|
|
292
|
+
*
|
|
293
|
+
* 不齐 = 这天是升级前落的盘(没有小时粒度)→ 24h 窗口踩到它就只能整桶计入。
|
|
294
|
+
* 宁可多算窗口外那截,也不能报一个比「今日」还小的数。
|
|
295
|
+
*/
|
|
296
|
+
private static hoursComplete(d: DayBucket): boolean {
|
|
297
|
+
return d.hours.reduce((n, h) => n + h.requests, 0) >= d.requests
|
|
205
298
|
}
|
|
206
299
|
|
|
207
300
|
/** 汇总 + Top 榜。 */
|
|
208
301
|
stats(period: Period, now = Date.now()): StatsResult {
|
|
209
|
-
// today
|
|
210
|
-
if (period === 'today'
|
|
211
|
-
const
|
|
302
|
+
// today 走天桶(今天这一桶就是答案);24h 走小时桶;7d/30d 走天桶
|
|
303
|
+
if (period === 'today') {
|
|
304
|
+
const d = this.days.get(localDateKey(now))
|
|
305
|
+
const acc = emptyAcc()
|
|
306
|
+
if (d === undefined) return finish(acc, {}, {}, {}, this.lifetime)
|
|
307
|
+
addDay(acc, d)
|
|
308
|
+
// 天桶的维度表就是 today 的 Top 榜,不用另算
|
|
309
|
+
return finish(acc, d.bySupplier, d.byModel, d.byRequested, this.lifetime)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (period === '24h') {
|
|
313
|
+
const slots = this.hourSlots(UsageStore.rollingStartOf(now))
|
|
212
314
|
const acc = emptyAcc()
|
|
213
315
|
const bySupplier: Record<string, Entry> = {}
|
|
214
316
|
const byModel: Record<string, Entry> = {}
|
|
215
317
|
const byRequested: Record<string, Entry> = {}
|
|
216
|
-
for (const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
318
|
+
for (const key of new Set(slots.map((s) => s.dayKey))) {
|
|
319
|
+
const d = this.days.get(key)
|
|
320
|
+
if (d === undefined) continue
|
|
321
|
+
if (UsageStore.hoursComplete(d)) {
|
|
322
|
+
// 小时粒度齐:只算窗口内踩到的格
|
|
323
|
+
for (const s of slots) {
|
|
324
|
+
if (s.dayKey === key && s.b !== undefined) addHour(acc, s.b)
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
// 老数据(升级前落的盘):整桶计入,宁可多算也不漏算
|
|
328
|
+
addDay(acc, d)
|
|
329
|
+
}
|
|
330
|
+
// Top 榜只能走天桶的维度表(小时桶不拆维度)
|
|
331
|
+
mergeInto(bySupplier, d.bySupplier)
|
|
332
|
+
mergeInto(byModel, d.byModel)
|
|
333
|
+
mergeInto(byRequested, d.byRequested)
|
|
221
334
|
}
|
|
222
335
|
return finish(acc, bySupplier, byModel, byRequested, this.lifetime)
|
|
223
336
|
}
|
|
@@ -230,17 +343,7 @@ export class UsageStore {
|
|
|
230
343
|
for (let i = days - 1; i >= 0; i -= 1) {
|
|
231
344
|
const d = this.days.get(localDateKey(now - i * 86400000))
|
|
232
345
|
if (d === undefined) continue
|
|
233
|
-
acc
|
|
234
|
-
acc.ok += d.ok
|
|
235
|
-
acc.failed += d.failed
|
|
236
|
-
acc.promptTokens += d.promptTokens
|
|
237
|
-
acc.completionTokens += d.completionTokens
|
|
238
|
-
acc.cachedTokens += d.cachedTokens
|
|
239
|
-
acc.durationMs += d.durationMs
|
|
240
|
-
acc.ttfbMs += d.ttfbMs
|
|
241
|
-
acc.ttfbCount += d.ttfbCount
|
|
242
|
-
acc.estimatedInputs += d.estimatedInputs
|
|
243
|
-
acc.estimatedOutputs += d.estimatedOutputs
|
|
346
|
+
addDay(acc, d)
|
|
244
347
|
mergeInto(bySupplier, d.bySupplier)
|
|
245
348
|
mergeInto(byModel, d.byModel)
|
|
246
349
|
mergeInto(byRequested, d.byRequested)
|
|
@@ -248,26 +351,16 @@ export class UsageStore {
|
|
|
248
351
|
return finish(acc, bySupplier, byModel, byRequested, this.lifetime)
|
|
249
352
|
}
|
|
250
353
|
|
|
251
|
-
/** 柱状图数据:today/24h = 24
|
|
354
|
+
/** 柱状图数据:today/24h = 24 个小时桶,7d/30d = 天桶。 */
|
|
252
355
|
chart(period: Period, now = Date.now()): ChartBucket[] {
|
|
253
356
|
if (period === 'today' || period === '24h') {
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
:
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
requests: 0,
|
|
261
|
-
tokens: 0,
|
|
357
|
+
const p = (n: number): string => String(n).padStart(2, '0')
|
|
358
|
+
const first = period === 'today' ? UsageStore.dayStartOf(now) : UsageStore.rollingStartOf(now)
|
|
359
|
+
return this.hourSlots(first).map((s) => ({
|
|
360
|
+
label: `${p(new Date(s.start).getHours())}:00`,
|
|
361
|
+
requests: s.b?.requests ?? 0,
|
|
362
|
+
tokens: (s.b?.promptTokens ?? 0) + (s.b?.completionTokens ?? 0),
|
|
262
363
|
}))
|
|
263
|
-
for (const rec of rows) {
|
|
264
|
-
const idx = Math.floor((rec.ts - start) / 3600000)
|
|
265
|
-
const b = buckets[idx]
|
|
266
|
-
if (b === undefined) continue
|
|
267
|
-
b.requests += 1
|
|
268
|
-
b.tokens += rec.promptTokens + rec.completionTokens
|
|
269
|
-
}
|
|
270
|
-
return buckets
|
|
271
364
|
}
|
|
272
365
|
const days = period === '7d' ? 7 : 30
|
|
273
366
|
const out: ChartBucket[] = []
|
|
@@ -320,8 +413,14 @@ export class UsageStore {
|
|
|
320
413
|
this.days = new Map(Object.entries(f.days ?? {}))
|
|
321
414
|
this.recent = Array.isArray(f.recent) ? f.recent.slice(0, RING_CAP) : []
|
|
322
415
|
this.lifetime = typeof f.lifetime === 'number' ? f.lifetime : 0
|
|
323
|
-
// 逐字段兜底:旧文件缺后加的字段(ttfb*/estimated
|
|
324
|
-
|
|
416
|
+
// 逐字段兜底:旧文件缺后加的字段(ttfb*/estimated*/hours)不能让整个桶变 NaN。
|
|
417
|
+
// hours 额外修长度/补空:老文件没有它,且 spread 会整段覆盖成短数组或带洞。
|
|
418
|
+
for (const [k, d] of this.days) {
|
|
419
|
+
const day: DayBucket = { ...emptyDay(), ...d }
|
|
420
|
+
const hours = Array.from({ length: HOURS }, (_, i) => ({ ...emptyHour(), ...d.hours?.[i] }))
|
|
421
|
+
day.hours = hours
|
|
422
|
+
this.days.set(k, day)
|
|
423
|
+
}
|
|
325
424
|
} catch {
|
|
326
425
|
// 首次运行/文件损坏:从空开始,不阻断启动
|
|
327
426
|
}
|
|
@@ -411,33 +510,34 @@ const emptyAcc = (): Acc => ({
|
|
|
411
510
|
estimatedInputs: 0, estimatedOutputs: 0,
|
|
412
511
|
})
|
|
413
512
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
acc.
|
|
419
|
-
acc.
|
|
420
|
-
acc.
|
|
421
|
-
acc.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
if (rec.outputEstimated) acc.estimatedOutputs += 1
|
|
513
|
+
/** 天桶累加(today / 7d / 30d 路径,以及 24h 的「老数据整桶兜底」)。 */
|
|
514
|
+
function addDay(acc: Acc, d: DayBucket): void {
|
|
515
|
+
acc.requests += d.requests
|
|
516
|
+
acc.ok += d.ok
|
|
517
|
+
acc.failed += d.failed
|
|
518
|
+
acc.promptTokens += d.promptTokens
|
|
519
|
+
acc.completionTokens += d.completionTokens
|
|
520
|
+
acc.cachedTokens += d.cachedTokens
|
|
521
|
+
acc.durationMs += d.durationMs
|
|
522
|
+
acc.ttfbMs += d.ttfbMs
|
|
523
|
+
acc.ttfbCount += d.ttfbCount
|
|
524
|
+
acc.estimatedInputs += d.estimatedInputs
|
|
525
|
+
acc.estimatedOutputs += d.estimatedOutputs
|
|
428
526
|
}
|
|
429
527
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
528
|
+
/** 小时桶累加(24h 路径)。 */
|
|
529
|
+
function addHour(acc: Acc, h: HourBucket): void {
|
|
530
|
+
acc.requests += h.requests
|
|
531
|
+
acc.ok += h.ok
|
|
532
|
+
acc.failed += h.failed
|
|
533
|
+
acc.promptTokens += h.promptTokens
|
|
534
|
+
acc.completionTokens += h.completionTokens
|
|
535
|
+
acc.cachedTokens += h.cachedTokens
|
|
536
|
+
acc.durationMs += h.durationMs
|
|
537
|
+
acc.ttfbMs += h.ttfbMs
|
|
538
|
+
acc.ttfbCount += h.ttfbCount
|
|
539
|
+
acc.estimatedInputs += h.estimatedInputs
|
|
540
|
+
acc.estimatedOutputs += h.estimatedOutputs
|
|
441
541
|
}
|
|
442
542
|
|
|
443
543
|
function finish(
|
package/src/router/usage.test.ts
CHANGED
|
@@ -288,6 +288,121 @@ test('周期:7d/30d 走天桶,明细环撑爆也不影响', () => {
|
|
|
288
288
|
assert.equal(s.recentList(1000).length, 500) // 明细环只留 500
|
|
289
289
|
})
|
|
290
290
|
|
|
291
|
+
test('周期:today/24h 走小时桶,明细环撑爆也不影响(回归:今日曾被截断成 500)', () => {
|
|
292
|
+
const s = new UsageStore('')
|
|
293
|
+
const now = Date.now()
|
|
294
|
+
// 塞 600 条(> RING_CAP 500),全在今天
|
|
295
|
+
for (let i = 0; i < 600; i += 1) rec(s, { ts: now })
|
|
296
|
+
assert.equal(s.stats('today', now).requests, 600, 'today 必须精确,不能受明细环容量限制')
|
|
297
|
+
assert.equal(s.stats('24h', now).requests, 600)
|
|
298
|
+
assert.equal(s.stats('7d', now).requests, 600)
|
|
299
|
+
assert.equal(s.stats('30d', now).requests, 600)
|
|
300
|
+
assert.equal(s.recentList(1000).length, 500, '明细环仍然只留 500(它只服务最近列表)')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('周期:today 精确,不会把昨天算进来', () => {
|
|
304
|
+
const s = new UsageStore('')
|
|
305
|
+
// 基准取正午:两头都留够余量,免得真实挂钟一过 23 点测试就翻车(踩过)
|
|
306
|
+
const base = new Date()
|
|
307
|
+
base.setHours(12, 0, 0, 0)
|
|
308
|
+
const now = base.getTime()
|
|
309
|
+
const dayStart = new Date(now).setHours(0, 0, 0, 0)
|
|
310
|
+
|
|
311
|
+
// 昨天 22:00 一批、今天 08/09 点各一批 —— 每批都逼近 RING_CAP
|
|
312
|
+
for (let i = 0; i < 300; i += 1) rec(s, { ts: dayStart - 2 * 3600000 })
|
|
313
|
+
for (let i = 0; i < 300; i += 1) rec(s, { ts: dayStart + 8 * 3600000 })
|
|
314
|
+
for (let i = 0; i < 300; i += 1) rec(s, { ts: dayStart + 9 * 3600000 })
|
|
315
|
+
|
|
316
|
+
assert.equal(s.stats('today', now).requests, 600, 'today 只算今天')
|
|
317
|
+
assert.equal(s.stats('7d', now).requests, 900)
|
|
318
|
+
// 24h 从今天 12:00 往前滚:昨天 22:00 那批距今 14 小时,还在窗口内
|
|
319
|
+
assert.equal(s.stats('24h', now).requests, 900)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test('图表:today 的小时桶按整点归位,不再靠明细环', () => {
|
|
323
|
+
const s = new UsageStore('')
|
|
324
|
+
const base = new Date()
|
|
325
|
+
base.setHours(12, 0, 0, 0)
|
|
326
|
+
const now = base.getTime()
|
|
327
|
+
const dayStart = new Date(now).setHours(0, 0, 0, 0)
|
|
328
|
+
|
|
329
|
+
for (let i = 0; i < 600; i += 1) rec(s, { ts: dayStart + 9 * 3600000 + 1000 }) // 今天 09:00 那格
|
|
330
|
+
|
|
331
|
+
const ch = s.chart('today', now)
|
|
332
|
+
assert.equal(ch.length, 24)
|
|
333
|
+
assert.equal(ch[9]?.requests, 600, '全落 09:00 那格')
|
|
334
|
+
assert.equal(ch[9]?.tokens, 600 * 15)
|
|
335
|
+
assert.equal(ch[10]?.requests, 0)
|
|
336
|
+
assert.equal(ch.reduce((n, b) => n + b.requests, 0), 600, '各格之和 = today 总数')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('落盘:旧文件没有 hours 字段也能读(没有迁移,补空即可)', () => {
|
|
340
|
+
const dir = `${process.env.TMPDIR ?? '/tmp'}/dshr-usage-${Math.random().toString(36).slice(2)}`
|
|
341
|
+
mkdirSync(dir, { recursive: true })
|
|
342
|
+
const fp = `${dir}/state.json`
|
|
343
|
+
// 手写一份「老格式」:天桶没有 hours(升级前落盘的就是这样)
|
|
344
|
+
writeFileSync(
|
|
345
|
+
`${dir}/usage.json`,
|
|
346
|
+
JSON.stringify({
|
|
347
|
+
days: { [localDateKey(Date.now())]: { requests: 3, ok: 3, failed: 0, bySupplier: {} } },
|
|
348
|
+
recent: [],
|
|
349
|
+
lifetime: 3,
|
|
350
|
+
}),
|
|
351
|
+
)
|
|
352
|
+
const s = new UsageStore(fp)
|
|
353
|
+
// 天桶口径照常:老数据一天都不会丢
|
|
354
|
+
assert.equal(s.stats('7d').requests, 3)
|
|
355
|
+
assert.equal(s.stats('today').requests, 3, 'today 走天桶,老数据照读')
|
|
356
|
+
// 小时粒度是永久缺失的(明细环回溯不回去)→ 24h 整桶兜底,不编数据
|
|
357
|
+
assert.equal(s.stats('24h').requests, 3, '小时桶不齐 → 整桶计入,不报比 today 更小的数')
|
|
358
|
+
assert.equal(s.chart('today').length, 24, '图表格子数不变')
|
|
359
|
+
assert.equal(s.chart('today').reduce((n, b) => n + b.requests, 0), 0, '没有小时粒度就不瞎填')
|
|
360
|
+
// 新请求照常累加,补上的 hours 立刻生效
|
|
361
|
+
rec(s, { ts: Date.now() })
|
|
362
|
+
assert.equal(s.stats('today').requests, 4)
|
|
363
|
+
assert.equal(s.chart('today').reduce((n, b) => n + b.requests, 0), 1)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('周期:24h 跨天时,小时粒度齐的走小时格、不齐的整桶兜底', () => {
|
|
367
|
+
const dir = `${process.env.TMPDIR ?? '/tmp'}/dshr-usage-${Math.random().toString(36).slice(2)}`
|
|
368
|
+
mkdirSync(dir, { recursive: true })
|
|
369
|
+
const fp = `${dir}/state.json`
|
|
370
|
+
// 时钟固定当天 12:00,不用 Date.now():24h 是当前整点往前 23 格,
|
|
371
|
+
// 真实时钟在 23 点时窗口起点正好是今天 00:00,昨天整桶落在窗口外,
|
|
372
|
+
// 这条用例就会从 700 变成 300 —— 测试挂不挂取决于跑的时刻,不能接受。
|
|
373
|
+
const now = new Date().setHours(12, 30, 0, 0)
|
|
374
|
+
const dayStart = new Date(now).setHours(0, 0, 0, 0)
|
|
375
|
+
const yesterdayKey = localDateKey(dayStart - 12 * 3600000)
|
|
376
|
+
|
|
377
|
+
// 昨天是「老数据」:天桶有 400 条但没有小时粒度
|
|
378
|
+
writeFileSync(
|
|
379
|
+
`${dir}/usage.json`,
|
|
380
|
+
JSON.stringify({
|
|
381
|
+
days: {
|
|
382
|
+
[yesterdayKey]: { requests: 400, ok: 400, failed: 0, bySupplier: {}, byModel: {}, byRequested: {} },
|
|
383
|
+
},
|
|
384
|
+
recent: [],
|
|
385
|
+
lifetime: 400,
|
|
386
|
+
}),
|
|
387
|
+
)
|
|
388
|
+
const s = new UsageStore(fp)
|
|
389
|
+
// 今天补 300 条(有小时粒度),全在当前小时,一定落在 24h 窗口内
|
|
390
|
+
for (let i = 0; i < 300; i += 1) rec(s, { ts: now })
|
|
391
|
+
|
|
392
|
+
assert.equal(s.stats('today', now).requests, 300, 'today 只算今天')
|
|
393
|
+
// 窗口含昨天 12:00 之后那截 → 但昨天没小时粒度,只能整桶(400)计入
|
|
394
|
+
const d24 = s.stats('24h', now).requests
|
|
395
|
+
assert.ok(d24 >= 300, '24h 绝不能小于 today')
|
|
396
|
+
assert.ok(d24 <= 700, '24h 也绝不能超总数')
|
|
397
|
+
assert.equal(d24, 700, '昨天整桶 400 + 今天 300')
|
|
398
|
+
|
|
399
|
+
// 反面对照:时钟移到 23 点,窗口不再含昨天,昨天那桶必须整桶退出
|
|
400
|
+
// (不是被算一半,也不是漏掉今天的 300)。锁死「整桶进/整桶出」。
|
|
401
|
+
const late = new Date().setHours(23, 30, 0, 0)
|
|
402
|
+
assert.equal(s.stats('24h', late).requests, 300, '窗口不含昨天时只剩今天')
|
|
403
|
+
assert.equal(s.stats('today', late).requests, 300, 'today 不受影响')
|
|
404
|
+
})
|
|
405
|
+
|
|
291
406
|
test('Top 榜:按请求数降序,且三个维度各自聚合', () => {
|
|
292
407
|
const s = new UsageStore('')
|
|
293
408
|
rec(s, { supplier: 'a', model: 'm1', requested: 'combo-x' })
|
package/src/shared.ts
CHANGED
package/src/suppliers/loader.ts
CHANGED
|
@@ -98,7 +98,8 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
|
|
|
98
98
|
|
|
99
99
|
// 账号池由核心持有(选号/冷却/禁用/错误累计),插件不参与。
|
|
100
100
|
// 挂在 supplier 上:status() 叠加状态与 Router 的 chat 循环共用同一实例。
|
|
101
|
-
|
|
101
|
+
// 冷却键带 supplierId(跨供应商同名模型不串),故构造时注入本供应商 id。
|
|
102
|
+
const pool = new AccountPool(instance.id)
|
|
102
103
|
|
|
103
104
|
const supplier: Supplier = {
|
|
104
105
|
id: instance.id,
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* 锁定的契约:checkinNow 是**单账号**能力,遍历所有链接 + 汇总是核心的活:
|
|
5
5
|
* - 禁用链接不签(不动它)
|
|
6
6
|
* - 某个链接抛错 → 记成 error,不带垮其它链接
|
|
7
|
-
* - succeeded / already 按 status
|
|
7
|
+
* - succeeded / already / failed 按 status 统计
|
|
8
|
+
* - **ok = 全部链接都成功(含已签)**;有任一失败 → HTTP 400 + ok:false,
|
|
9
|
+
* 让面板把失败如实亮出来(2026-09-02 修正:旧口径「有任一成功就算 ok」
|
|
10
|
+
* 会把部分失败吞成整体成功,UI 谎报「签到完成」)
|
|
8
11
|
*/
|
|
9
12
|
import test from 'node:test'
|
|
10
13
|
import assert from 'node:assert/strict'
|
|
@@ -84,6 +87,7 @@ interface CheckinPayload {
|
|
|
84
87
|
total: number
|
|
85
88
|
succeeded: number
|
|
86
89
|
already: number
|
|
90
|
+
failed: number
|
|
87
91
|
results?: CheckinResult[]
|
|
88
92
|
error?: string
|
|
89
93
|
}
|
|
@@ -120,20 +124,36 @@ test('签到:禁用链接照样签(禁用由插件自己判定,核心不
|
|
|
120
124
|
assert.equal(body.results?.[0]?.status, 'disabled')
|
|
121
125
|
})
|
|
122
126
|
|
|
123
|
-
test('签到:单链接抛错记成 error
|
|
127
|
+
test('签到:单链接抛错记成 error 不连坐;有失败 → 400 不算整体成功', async () => {
|
|
124
128
|
const { status, body } = await postCheckin(
|
|
125
129
|
fixture([{ uid: 'a' }, { uid: 'b' }], async (uid) => {
|
|
126
130
|
if (uid === 'a') throw new Error('boom')
|
|
127
131
|
return { ok: true, status: 'ok' }
|
|
128
132
|
}),
|
|
129
133
|
)
|
|
130
|
-
|
|
134
|
+
// 有任一失败整体就不是 ok(否则 UI 会谎报「签到完成」)——a 的错照记,不连坐 b
|
|
135
|
+
assert.equal(status, 400)
|
|
136
|
+
assert.equal(body.ok, false)
|
|
131
137
|
assert.equal(body.succeeded, 1)
|
|
138
|
+
assert.equal(body.failed, 1)
|
|
132
139
|
assert.equal(body.results?.[0]?.status, 'error')
|
|
133
140
|
assert.equal(body.results?.[0]?.message, 'boom')
|
|
134
141
|
assert.equal(body.results?.[1]?.status, 'ok')
|
|
135
142
|
})
|
|
136
143
|
|
|
144
|
+
test('签到:全部成功(含 already)才算整体 ok', async () => {
|
|
145
|
+
const { status, body } = await postCheckin(
|
|
146
|
+
fixture([{ uid: 'a' }, { uid: 'b' }], async (uid) =>
|
|
147
|
+
uid === 'a' ? { ok: true, status: 'ok', message: '+200' } : { ok: true, status: 'already', message: '今日已签到' },
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
assert.equal(status, 200)
|
|
151
|
+
assert.equal(body.ok, true)
|
|
152
|
+
assert.equal(body.succeeded, 1)
|
|
153
|
+
assert.equal(body.already, 1)
|
|
154
|
+
assert.equal(body.failed, 0)
|
|
155
|
+
})
|
|
156
|
+
|
|
137
157
|
test('签到:今日已签(already)算成功,不计入 succeeded', async () => {
|
|
138
158
|
const { status, body } = await postCheckin(
|
|
139
159
|
fixture([{ uid: 'a' }], async () => ({ ok: true, status: 'already', message: '今日已签到' })),
|
|
@@ -375,8 +375,13 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
375
375
|
}
|
|
376
376
|
const succeeded = results.filter((r) => r.status === 'ok').length
|
|
377
377
|
const already = results.filter((r) => r.status === 'already').length
|
|
378
|
-
|
|
379
|
-
|
|
378
|
+
// 全部链接都成功(含已签)才算整体成功;有任一失败 → ok:false + HTTP 400,
|
|
379
|
+
// 让面板把失败如实亮出来。别学旧逻辑「有成功就算 ok」——部分失败被
|
|
380
|
+
// 当成整体成功,UI 会谎报「签到完成」(2026-09-02 实测踩到)。
|
|
381
|
+
const failed = results.length - succeeded - already
|
|
382
|
+
const ok = failed === 0 && results.length > 0
|
|
383
|
+
const payload = { ok, total: uids.length, succeeded, already, failed, results }
|
|
384
|
+
writeJson(res, ok ? 200 : 400, payload)
|
|
380
385
|
},
|
|
381
386
|
})
|
|
382
387
|
}
|