dsh-router-core 0.1.0 → 0.3.0
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 +58 -13
- package/lib/client-registry.js +824 -153
- package/lib/client.js +824 -153
- package/lib/index.js +1606 -227
- package/lib/types/llm/adapter.d.ts +2 -0
- package/lib/types/router/account-pool.d.ts +41 -0
- package/lib/types/router/index.d.ts +72 -4
- package/lib/types/router/types.d.ts +12 -10
- package/lib/types/router/usage-store.d.ts +93 -0
- package/lib/types/router/usage-tokens.d.ts +118 -0
- package/lib/types/shared.d.ts +69 -0
- package/lib/types/supplier-config.d.ts +15 -4
- package/lib/types/suppliers/contract.d.ts +70 -21
- package/lib/types/suppliers/registry.d.ts +4 -3
- package/package.json +2 -1
- package/src/client/CombosTab.tsx +28 -16
- package/src/client/RouterView.tsx +15 -2
- package/src/client/StatsTab.tsx +521 -0
- package/src/client/SupplierDetail.tsx +58 -34
- package/src/client/router.css +406 -0
- package/src/client/workspace-mount.tsx +2 -9
- package/src/index.ts +52 -4
- package/src/llm/adapter.test.ts +108 -0
- package/src/llm/adapter.ts +18 -6
- package/src/router/account-pool.test.ts +120 -0
- package/src/router/account-pool.ts +161 -0
- package/src/router/index.ts +652 -26
- package/src/router/models-cache.test.ts +455 -0
- package/src/router/precise-dispatch.test.ts +198 -0
- package/src/router/types.ts +12 -7
- package/src/router/usage-store.ts +499 -0
- package/src/router/usage-tokens.ts +327 -0
- package/src/router/usage.test.ts +558 -0
- package/src/shared.ts +77 -0
- package/src/supplier-config.ts +31 -14
- package/src/suppliers/contract.ts +71 -8
- package/src/suppliers/loader.ts +21 -13
- package/src/suppliers/nvidia/plugin.ts +161 -119
- package/src/suppliers/opencode/plugin.ts +47 -95
- package/src/suppliers/openrouter/plugin.ts +67 -122
- package/src/suppliers/registry.test.ts +194 -0
- package/src/suppliers/registry.ts +79 -59
package/lib/index.js
CHANGED
|
@@ -10,12 +10,880 @@ const ROUTER_API_BASE = "/router/api";
|
|
|
10
10
|
/** Bearer key env / credential reference protecting /v1/*. */
|
|
11
11
|
const API_KEY_REF = "TW2A_API_KEY";
|
|
12
12
|
//#endregion
|
|
13
|
+
//#region src/router/account-pool.ts
|
|
14
|
+
const MINUTE = 6e4;
|
|
15
|
+
/**
|
|
16
|
+
* 状态 → 处置表。**核心策略就在这一张表里**,插件只报状态不做决策。
|
|
17
|
+
* 时长参考 9router 的 errorConfig(限流类短冷却、额度类长冷却),
|
|
18
|
+
* 但 9router 按文本/状态匹配,这里按插件解读后的语义状态匹配,更准。
|
|
19
|
+
*/
|
|
20
|
+
const RULES = {
|
|
21
|
+
ok: {
|
|
22
|
+
cooldownMs: 0,
|
|
23
|
+
disable: false,
|
|
24
|
+
counts: false
|
|
25
|
+
},
|
|
26
|
+
rate_limit: {
|
|
27
|
+
cooldownMs: 1 * MINUTE,
|
|
28
|
+
disable: false,
|
|
29
|
+
counts: false
|
|
30
|
+
},
|
|
31
|
+
quota: {
|
|
32
|
+
cooldownMs: 10 * MINUTE,
|
|
33
|
+
disable: false,
|
|
34
|
+
counts: false
|
|
35
|
+
},
|
|
36
|
+
session_dead: {
|
|
37
|
+
cooldownMs: 0,
|
|
38
|
+
disable: true,
|
|
39
|
+
counts: false
|
|
40
|
+
},
|
|
41
|
+
unavailable: {
|
|
42
|
+
cooldownMs: 0,
|
|
43
|
+
disable: false,
|
|
44
|
+
counts: true
|
|
45
|
+
},
|
|
46
|
+
transport: {
|
|
47
|
+
cooldownMs: 0,
|
|
48
|
+
disable: false,
|
|
49
|
+
counts: true
|
|
50
|
+
},
|
|
51
|
+
unknown: {
|
|
52
|
+
cooldownMs: 0,
|
|
53
|
+
disable: false,
|
|
54
|
+
counts: true
|
|
55
|
+
},
|
|
56
|
+
no_such_model: {
|
|
57
|
+
cooldownMs: 0,
|
|
58
|
+
disable: false,
|
|
59
|
+
counts: false
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/** 连续错误攒够这么多次就自动冷却(防止坏号被反复重试)。 */
|
|
63
|
+
const ERR_THRESHOLD = 3;
|
|
64
|
+
/** 攒够后的冷却时长。 */
|
|
65
|
+
const ERR_COOLDOWN_MS = 10 * MINUTE;
|
|
66
|
+
var AccountPool = class {
|
|
67
|
+
entries = /* @__PURE__ */ new Map();
|
|
68
|
+
rrCursor = 0;
|
|
69
|
+
entry(uid) {
|
|
70
|
+
let e = this.entries.get(uid);
|
|
71
|
+
if (!e) {
|
|
72
|
+
e = {
|
|
73
|
+
until: 0,
|
|
74
|
+
disabled: false,
|
|
75
|
+
errCount: 0,
|
|
76
|
+
reason: ""
|
|
77
|
+
};
|
|
78
|
+
this.entries.set(uid, e);
|
|
79
|
+
}
|
|
80
|
+
return e;
|
|
81
|
+
}
|
|
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;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 按策略选一个健康账号。
|
|
91
|
+
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
92
|
+
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
93
|
+
* @returns 选中的 uid;无健康账号返回 undefined
|
|
94
|
+
*/
|
|
95
|
+
pick(accounts, poolOrder, strategy) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
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));
|
|
99
|
+
if (healthy.length === 0) return void 0;
|
|
100
|
+
if (strategy !== "round-robin") return healthy[0];
|
|
101
|
+
const uid = healthy[this.rrCursor % healthy.length];
|
|
102
|
+
this.rrCursor = (this.rrCursor + 1) % healthy.length;
|
|
103
|
+
return uid;
|
|
104
|
+
}
|
|
105
|
+
/** 记录一次失败:按状态处置(冷却/禁用/错误累计)。 */
|
|
106
|
+
noteFailure(uid, state, message) {
|
|
107
|
+
const rule = RULES[state];
|
|
108
|
+
const e = this.entry(uid);
|
|
109
|
+
if (rule.disable) {
|
|
110
|
+
e.disabled = true;
|
|
111
|
+
e.reason = message;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (rule.cooldownMs > 0) {
|
|
115
|
+
e.until = Math.max(e.until, Date.now() + rule.cooldownMs);
|
|
116
|
+
e.reason = message;
|
|
117
|
+
e.errCount = 0;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
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
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** 记录一次成功:清零连续错误。 */
|
|
130
|
+
noteSuccess(uid) {
|
|
131
|
+
const e = this.entries.get(uid);
|
|
132
|
+
if (e) e.errCount = 0;
|
|
133
|
+
}
|
|
134
|
+
/** 手动冷却(面板/管理用途)。 */
|
|
135
|
+
cooldown(uid, ms, reason) {
|
|
136
|
+
const e = this.entry(uid);
|
|
137
|
+
e.until = Date.now() + ms;
|
|
138
|
+
e.reason = reason;
|
|
139
|
+
e.errCount = 0;
|
|
140
|
+
}
|
|
141
|
+
/** 把核心的冷却/禁用/错误累计叠加到插件报告的「现在状态」上,产出面板态。 */
|
|
142
|
+
decorate(accounts) {
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
return accounts.map((a) => {
|
|
145
|
+
const e = this.entries.get(a.uid);
|
|
146
|
+
const cooling = e !== void 0 && !e.disabled && e.until > now;
|
|
147
|
+
return {
|
|
148
|
+
...a,
|
|
149
|
+
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
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/supplier-config.ts
|
|
160
|
+
/**
|
|
161
|
+
* 通用供应商配置 —— dsh-router 核心能力,按供应商 id 存储。
|
|
162
|
+
*
|
|
163
|
+
* 通用能力(所有供应商共享,供应商 js 不需要实现):
|
|
164
|
+
* - 别名 alias(前缀,模型全名 = alias/id)
|
|
165
|
+
* - 模型管理:启用/禁用 disabled、自定义 custom
|
|
166
|
+
* - 连接池:poolOrder(拖动排序)、poolStrategy(回退/轮询)
|
|
167
|
+
*
|
|
168
|
+
* 持久化到 data/supplier-config.json。
|
|
169
|
+
*/
|
|
170
|
+
/** 通用供应商配置存储。 */
|
|
171
|
+
var SupplierConfigStore = class {
|
|
172
|
+
fp = "";
|
|
173
|
+
bySupplier = /* @__PURE__ */ new Map();
|
|
174
|
+
/** 全部供应商 id(加载时同步),用于别名唯一性校验。 */
|
|
175
|
+
knownIds = /* @__PURE__ */ new Set();
|
|
176
|
+
constructor(stateFile) {
|
|
177
|
+
this.fp = stateFile ? join(dirname(stateFile), "supplier-config.json") : "";
|
|
178
|
+
this.load();
|
|
179
|
+
}
|
|
180
|
+
/** 同步已知供应商 id 集合(加载供应商后调用)。 */
|
|
181
|
+
sync(supplierIds) {
|
|
182
|
+
this.knownIds = new Set(supplierIds);
|
|
183
|
+
}
|
|
184
|
+
/** 各供应商当前生效的别名(未设置别名的用供应商 id)。 */
|
|
185
|
+
effectiveAliases() {
|
|
186
|
+
const out = /* @__PURE__ */ new Map();
|
|
187
|
+
for (const id of this.knownIds) out.set(id, this.bySupplier.get(id)?.alias || id);
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
/** 读取某供应商配置(不存在则返回默认并注册)。 */
|
|
191
|
+
get(supplierId) {
|
|
192
|
+
let cfg = this.bySupplier.get(supplierId);
|
|
193
|
+
if (!cfg) {
|
|
194
|
+
cfg = {
|
|
195
|
+
alias: "",
|
|
196
|
+
disabled: [],
|
|
197
|
+
custom: [],
|
|
198
|
+
poolOrder: [],
|
|
199
|
+
poolStrategy: "fallback"
|
|
200
|
+
};
|
|
201
|
+
this.bySupplier.set(supplierId, cfg);
|
|
202
|
+
}
|
|
203
|
+
return cfg;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 设置别名。别名必须唯一——它是对外模型全名的前缀(alias/model),
|
|
207
|
+
* 请求时用别名反查供应商,重复就会指错人。空别名表示用供应商 id(默认值)。
|
|
208
|
+
* @returns 冲突时返回占用的供应商 id
|
|
209
|
+
*/
|
|
210
|
+
setAlias(supplierId, alias) {
|
|
211
|
+
const clean = alias.trim();
|
|
212
|
+
const effective = clean === "" ? supplierId : clean;
|
|
213
|
+
for (const [id, cur] of this.effectiveAliases()) if (id !== supplierId && cur === effective) return {
|
|
214
|
+
ok: false,
|
|
215
|
+
conflictWith: id
|
|
216
|
+
};
|
|
217
|
+
this.get(supplierId).alias = clean;
|
|
218
|
+
this.saveLocked();
|
|
219
|
+
return { ok: true };
|
|
220
|
+
}
|
|
221
|
+
setModelEnabled(supplierId, modelId, enabled) {
|
|
222
|
+
const cfg = this.get(supplierId);
|
|
223
|
+
cfg.disabled = enabled ? cfg.disabled.filter((m) => m !== modelId) : [.../* @__PURE__ */ new Set([...cfg.disabled, modelId])];
|
|
224
|
+
this.saveLocked();
|
|
225
|
+
}
|
|
226
|
+
setAllModelsEnabled(supplierId, enabled, allModelIds) {
|
|
227
|
+
const cfg = this.get(supplierId);
|
|
228
|
+
cfg.disabled = enabled ? [] : [...new Set(allModelIds)];
|
|
229
|
+
this.saveLocked();
|
|
230
|
+
}
|
|
231
|
+
addCustomModel(supplierId, modelId) {
|
|
232
|
+
const cfg = this.get(supplierId);
|
|
233
|
+
if (!cfg.custom.includes(modelId)) cfg.custom.push(modelId);
|
|
234
|
+
this.saveLocked();
|
|
235
|
+
}
|
|
236
|
+
removeCustomModel(supplierId, modelId) {
|
|
237
|
+
const cfg = this.get(supplierId);
|
|
238
|
+
cfg.custom = cfg.custom.filter((m) => m !== modelId);
|
|
239
|
+
cfg.disabled = cfg.disabled.filter((m) => m !== modelId);
|
|
240
|
+
this.saveLocked();
|
|
241
|
+
}
|
|
242
|
+
setPoolOrder(supplierId, uids) {
|
|
243
|
+
this.get(supplierId).poolOrder = [...new Set(uids)];
|
|
244
|
+
this.saveLocked();
|
|
245
|
+
}
|
|
246
|
+
setPoolStrategy(supplierId, strategy) {
|
|
247
|
+
if (strategy !== "fallback" && strategy !== "round-robin") return;
|
|
248
|
+
this.get(supplierId).poolStrategy = strategy;
|
|
249
|
+
this.saveLocked();
|
|
250
|
+
}
|
|
251
|
+
load() {
|
|
252
|
+
if (this.fp === "") return;
|
|
253
|
+
let f;
|
|
254
|
+
try {
|
|
255
|
+
f = JSON.parse(readFileSync(this.fp, "utf8"));
|
|
256
|
+
} catch {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
for (const [id, c] of Object.entries(f.suppliers ?? {})) this.bySupplier.set(id, {
|
|
260
|
+
alias: typeof c.alias === "string" ? c.alias : "",
|
|
261
|
+
disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === "string") : [],
|
|
262
|
+
custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === "string") : [],
|
|
263
|
+
poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === "string") : [],
|
|
264
|
+
poolStrategy: c.poolStrategy === "round-robin" ? "round-robin" : "fallback"
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
saveLocked() {
|
|
268
|
+
if (this.fp === "") return;
|
|
269
|
+
const f = { suppliers: {} };
|
|
270
|
+
for (const [id, cfg] of this.bySupplier) f.suppliers[id] = {
|
|
271
|
+
alias: cfg.alias,
|
|
272
|
+
disabled: [...cfg.disabled],
|
|
273
|
+
custom: [...cfg.custom],
|
|
274
|
+
poolOrder: [...cfg.poolOrder],
|
|
275
|
+
poolStrategy: cfg.poolStrategy
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
const dir = dirname(this.fp);
|
|
279
|
+
if (dir !== "" && dir !== ".") mkdirSync(dir, { recursive: true });
|
|
280
|
+
const raw = JSON.stringify(f, null, 2);
|
|
281
|
+
const tmp = this.fp + ".tmp";
|
|
282
|
+
writeFileSync(tmp, raw, { mode: 384 });
|
|
283
|
+
renameSync(tmp, this.fp);
|
|
284
|
+
} catch {}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region src/router/usage-store.ts
|
|
289
|
+
/**
|
|
290
|
+
* 用量落盘与聚合 —— 概览看板的数据源。
|
|
291
|
+
*
|
|
292
|
+
* 结构照 9router `usageRepo` 的双表思路(按天聚合 + 最近明细),但用 **JSON
|
|
293
|
+
* 落盘**而不是 SQLite:dsh-router 现有持久化全是 JSON(supplier-config.json /
|
|
294
|
+
* keys.json),不为一个看板引入数据库依赖。
|
|
295
|
+
*
|
|
296
|
+
* 两条查询路径(照 9router `getUsageStats` 的 `useDailySummary` 分叉):
|
|
297
|
+
* - `today` / `24h` → 走**明细环**(滚动窗口,天桶是自然日,对不上)
|
|
298
|
+
* - `7d` / `30d` → 走**天桶**(长期精确,不受明细环容量限制)
|
|
299
|
+
*
|
|
300
|
+
* 天花板(JSON 的代价,刻意接受):
|
|
301
|
+
* - 明细环只留最近 `RING_CAP` 条。超量时 today/24h 会**少算**(天桶不会,
|
|
302
|
+
* 所以 7d/30d 永远准)。要彻底准就把明细也落盘成按小时的桶,届时
|
|
303
|
+
* `chart()` 的小时桶直接读它即可,接口不用变。
|
|
304
|
+
* - 天桶保留 30 天(9router 有 SQLite 不 prune,JSON 撑不住,得自己限)
|
|
305
|
+
* - 落盘防抖 2s:进程被 kill 最多丢 2s 的计数。看板不是账本,可接受;
|
|
306
|
+
* 要严格就改成立即写,代价是每个请求一次磁盘写。
|
|
307
|
+
*/
|
|
308
|
+
/** 明细环容量:够 today/24h 看,又不至于让 JSON 无限长。 */
|
|
309
|
+
const RING_CAP = 500;
|
|
310
|
+
/** 天桶保留天数。 */
|
|
311
|
+
const KEEP_DAYS = 30;
|
|
312
|
+
/** 落盘防抖。 */
|
|
313
|
+
const SAVE_DEBOUNCE_MS = 2e3;
|
|
314
|
+
const emptyDay = () => ({
|
|
315
|
+
requests: 0,
|
|
316
|
+
ok: 0,
|
|
317
|
+
failed: 0,
|
|
318
|
+
promptTokens: 0,
|
|
319
|
+
completionTokens: 0,
|
|
320
|
+
cachedTokens: 0,
|
|
321
|
+
durationMs: 0,
|
|
322
|
+
ttfbMs: 0,
|
|
323
|
+
ttfbCount: 0,
|
|
324
|
+
estimatedInputs: 0,
|
|
325
|
+
estimatedOutputs: 0,
|
|
326
|
+
bySupplier: {},
|
|
327
|
+
byModel: {},
|
|
328
|
+
byRequested: {}
|
|
329
|
+
});
|
|
330
|
+
const emptyEntry = () => ({
|
|
331
|
+
requests: 0,
|
|
332
|
+
ok: 0,
|
|
333
|
+
failed: 0,
|
|
334
|
+
promptTokens: 0,
|
|
335
|
+
completionTokens: 0,
|
|
336
|
+
cachedTokens: 0,
|
|
337
|
+
lastTs: 0
|
|
338
|
+
});
|
|
339
|
+
/** 本地日期键 `YYYY-MM-DD`(照 9router getLocalDateKey,不按 UTC 切天)。 */
|
|
340
|
+
function localDateKey(ts) {
|
|
341
|
+
const d = new Date(ts);
|
|
342
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
343
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
344
|
+
}
|
|
345
|
+
/** 用量存储。stateFile 为空时纯内存(测试用),不落盘。 */
|
|
346
|
+
var UsageStore = class {
|
|
347
|
+
fp = "";
|
|
348
|
+
days = /* @__PURE__ */ new Map();
|
|
349
|
+
recent = [];
|
|
350
|
+
lifetime = 0;
|
|
351
|
+
timer = null;
|
|
352
|
+
constructor(stateFile) {
|
|
353
|
+
this.fp = stateFile ? join(dirname(stateFile), "usage.json") : "";
|
|
354
|
+
this.load();
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* 记一次请求。
|
|
358
|
+
*
|
|
359
|
+
* **成功的零 token 请求不记**(照 9router `saveUsageStats` 的闸门:
|
|
360
|
+
* `in === 0 && out === 0` 直接 return,空请求混进来只会稀释统计)。
|
|
361
|
+
* **失败请求一定记** —— 不管有没有 token。成功率全靠它,挡掉的话
|
|
362
|
+
* 失败永远是 0 条,面板会显示 100% 成功,这个数字是骗人的。
|
|
363
|
+
*/
|
|
364
|
+
record(r, usage) {
|
|
365
|
+
const promptTokens = usage.promptTokens;
|
|
366
|
+
const completionTokens = usage.completionTokens;
|
|
367
|
+
if (r.ok && promptTokens === 0 && completionTokens === 0) return;
|
|
368
|
+
const ts = r.ts ?? Date.now();
|
|
369
|
+
const rec = {
|
|
370
|
+
...r,
|
|
371
|
+
ts,
|
|
372
|
+
promptTokens,
|
|
373
|
+
completionTokens,
|
|
374
|
+
cachedTokens: usage.cachedTokens,
|
|
375
|
+
inputEstimated: usage.inputEstimated,
|
|
376
|
+
outputEstimated: usage.outputEstimated
|
|
377
|
+
};
|
|
378
|
+
const key = localDateKey(ts);
|
|
379
|
+
let day = this.days.get(key);
|
|
380
|
+
if (day === void 0) {
|
|
381
|
+
day = emptyDay();
|
|
382
|
+
this.days.set(key, day);
|
|
383
|
+
}
|
|
384
|
+
day.requests += 1;
|
|
385
|
+
if (rec.ok) day.ok += 1;
|
|
386
|
+
else day.failed += 1;
|
|
387
|
+
day.promptTokens += promptTokens;
|
|
388
|
+
day.completionTokens += completionTokens;
|
|
389
|
+
day.cachedTokens += rec.cachedTokens;
|
|
390
|
+
day.durationMs += rec.durationMs;
|
|
391
|
+
if (rec.ttfbMs > 0) {
|
|
392
|
+
day.ttfbMs += rec.ttfbMs;
|
|
393
|
+
day.ttfbCount += 1;
|
|
394
|
+
}
|
|
395
|
+
if (rec.inputEstimated) day.estimatedInputs += 1;
|
|
396
|
+
if (rec.outputEstimated) day.estimatedOutputs += 1;
|
|
397
|
+
this.bump(day.bySupplier, rec.supplier, rec);
|
|
398
|
+
this.bump(day.byModel, rec.model, rec);
|
|
399
|
+
this.bump(day.byRequested, rec.requested, rec);
|
|
400
|
+
this.recent.unshift(rec);
|
|
401
|
+
if (this.recent.length > RING_CAP) this.recent.length = RING_CAP;
|
|
402
|
+
this.lifetime += 1;
|
|
403
|
+
this.scheduleSave();
|
|
404
|
+
}
|
|
405
|
+
bump(map, key, rec) {
|
|
406
|
+
if (key === "") return;
|
|
407
|
+
const e = map[key] ?? emptyEntry();
|
|
408
|
+
e.requests += 1;
|
|
409
|
+
if (rec.ok) e.ok += 1;
|
|
410
|
+
else e.failed += 1;
|
|
411
|
+
e.promptTokens += rec.promptTokens;
|
|
412
|
+
e.completionTokens += rec.completionTokens;
|
|
413
|
+
e.cachedTokens += rec.cachedTokens;
|
|
414
|
+
e.lastTs = Math.max(e.lastTs, rec.ts);
|
|
415
|
+
map[key] = e;
|
|
416
|
+
}
|
|
417
|
+
/** 窗口内的明细(today/24h 路径)。 */
|
|
418
|
+
inWindow(period, now) {
|
|
419
|
+
const cutoff = period === "today" ? new Date(now).setHours(0, 0, 0, 0) : now - 864e5;
|
|
420
|
+
return this.recent.filter((r) => r.ts >= cutoff);
|
|
421
|
+
}
|
|
422
|
+
/** 汇总 + Top 榜。 */
|
|
423
|
+
stats(period, now = Date.now()) {
|
|
424
|
+
if (period === "today" || period === "24h") {
|
|
425
|
+
const rows = this.inWindow(period, now);
|
|
426
|
+
const acc = emptyAcc();
|
|
427
|
+
const bySupplier = {};
|
|
428
|
+
const byModel = {};
|
|
429
|
+
const byRequested = {};
|
|
430
|
+
for (const rec of rows) {
|
|
431
|
+
addRec(acc, rec);
|
|
432
|
+
bumpInto(bySupplier, rec.supplier, rec);
|
|
433
|
+
bumpInto(byModel, rec.model, rec);
|
|
434
|
+
bumpInto(byRequested, rec.requested, rec);
|
|
435
|
+
}
|
|
436
|
+
return finish(acc, bySupplier, byModel, byRequested, this.lifetime);
|
|
437
|
+
}
|
|
438
|
+
const days = period === "7d" ? 7 : 30;
|
|
439
|
+
const acc = emptyAcc();
|
|
440
|
+
const bySupplier = {};
|
|
441
|
+
const byModel = {};
|
|
442
|
+
const byRequested = {};
|
|
443
|
+
for (let i = days - 1; i >= 0; i -= 1) {
|
|
444
|
+
const d = this.days.get(localDateKey(now - i * 864e5));
|
|
445
|
+
if (d === void 0) continue;
|
|
446
|
+
acc.requests += d.requests;
|
|
447
|
+
acc.ok += d.ok;
|
|
448
|
+
acc.failed += d.failed;
|
|
449
|
+
acc.promptTokens += d.promptTokens;
|
|
450
|
+
acc.completionTokens += d.completionTokens;
|
|
451
|
+
acc.cachedTokens += d.cachedTokens;
|
|
452
|
+
acc.durationMs += d.durationMs;
|
|
453
|
+
acc.ttfbMs += d.ttfbMs;
|
|
454
|
+
acc.ttfbCount += d.ttfbCount;
|
|
455
|
+
acc.estimatedInputs += d.estimatedInputs;
|
|
456
|
+
acc.estimatedOutputs += d.estimatedOutputs;
|
|
457
|
+
mergeInto(bySupplier, d.bySupplier);
|
|
458
|
+
mergeInto(byModel, d.byModel);
|
|
459
|
+
mergeInto(byRequested, d.byRequested);
|
|
460
|
+
}
|
|
461
|
+
return finish(acc, bySupplier, byModel, byRequested, this.lifetime);
|
|
462
|
+
}
|
|
463
|
+
/** 柱状图数据:today/24h = 24 个小时桶(走明细环),7d/30d = 天桶。 */
|
|
464
|
+
chart(period, now = Date.now()) {
|
|
465
|
+
if (period === "today" || period === "24h") {
|
|
466
|
+
const rows = this.inWindow(period, now);
|
|
467
|
+
const start = period === "today" ? Math.floor(new Date(now).setHours(0, 0, 0, 0) / 36e5) * 36e5 : Math.floor((now - 828e5) / 36e5) * 36e5;
|
|
468
|
+
const buckets = Array.from({ length: 24 }, (_, i) => ({
|
|
469
|
+
label: `${String(new Date(start + i * 36e5).getHours()).padStart(2, "0")}:00`,
|
|
470
|
+
requests: 0,
|
|
471
|
+
tokens: 0
|
|
472
|
+
}));
|
|
473
|
+
for (const rec of rows) {
|
|
474
|
+
const b = buckets[Math.floor((rec.ts - start) / 36e5)];
|
|
475
|
+
if (b === void 0) continue;
|
|
476
|
+
b.requests += 1;
|
|
477
|
+
b.tokens += rec.promptTokens + rec.completionTokens;
|
|
478
|
+
}
|
|
479
|
+
return buckets;
|
|
480
|
+
}
|
|
481
|
+
const days = period === "7d" ? 7 : 30;
|
|
482
|
+
const out = [];
|
|
483
|
+
for (let i = days - 1; i >= 0; i -= 1) {
|
|
484
|
+
const ts = now - i * 864e5;
|
|
485
|
+
const d = this.days.get(localDateKey(ts));
|
|
486
|
+
out.push({
|
|
487
|
+
label: localDateKey(ts).slice(5),
|
|
488
|
+
requests: d?.requests ?? 0,
|
|
489
|
+
tokens: (d?.promptTokens ?? 0) + (d?.completionTokens ?? 0)
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
/** 最近请求明细。 */
|
|
495
|
+
recentList(limit = 20) {
|
|
496
|
+
return this.recent.slice(0, limit);
|
|
497
|
+
}
|
|
498
|
+
clear() {
|
|
499
|
+
this.days.clear();
|
|
500
|
+
this.recent = [];
|
|
501
|
+
this.lifetime = 0;
|
|
502
|
+
this.save();
|
|
503
|
+
}
|
|
504
|
+
/** 进程退出前把防抖中的写入落盘。 */
|
|
505
|
+
flush() {
|
|
506
|
+
if (this.timer !== null) {
|
|
507
|
+
clearTimeout(this.timer);
|
|
508
|
+
this.timer = null;
|
|
509
|
+
}
|
|
510
|
+
this.save();
|
|
511
|
+
}
|
|
512
|
+
scheduleSave() {
|
|
513
|
+
if (this.fp === "" || this.timer !== null) return;
|
|
514
|
+
this.timer = setTimeout(() => {
|
|
515
|
+
this.timer = null;
|
|
516
|
+
this.save();
|
|
517
|
+
}, SAVE_DEBOUNCE_MS);
|
|
518
|
+
this.timer.unref?.();
|
|
519
|
+
}
|
|
520
|
+
load() {
|
|
521
|
+
if (this.fp === "") return;
|
|
522
|
+
try {
|
|
523
|
+
const f = JSON.parse(readFileSync(this.fp, "utf8"));
|
|
524
|
+
this.days = new Map(Object.entries(f.days ?? {}));
|
|
525
|
+
this.recent = Array.isArray(f.recent) ? f.recent.slice(0, RING_CAP) : [];
|
|
526
|
+
this.lifetime = typeof f.lifetime === "number" ? f.lifetime : 0;
|
|
527
|
+
for (const [k, d] of this.days) this.days.set(k, {
|
|
528
|
+
...emptyDay(),
|
|
529
|
+
...d
|
|
530
|
+
});
|
|
531
|
+
} catch {}
|
|
532
|
+
}
|
|
533
|
+
save() {
|
|
534
|
+
if (this.fp === "") return;
|
|
535
|
+
const cutoff = localDateKey(Date.now() - KEEP_DAYS * 864e5);
|
|
536
|
+
for (const k of [...this.days.keys()]) if (k < cutoff) this.days.delete(k);
|
|
537
|
+
const f = {
|
|
538
|
+
days: Object.fromEntries(this.days),
|
|
539
|
+
recent: this.recent,
|
|
540
|
+
lifetime: this.lifetime
|
|
541
|
+
};
|
|
542
|
+
try {
|
|
543
|
+
const dir = dirname(this.fp);
|
|
544
|
+
if (dir !== "" && dir !== ".") mkdirSync(dir, { recursive: true });
|
|
545
|
+
const tmp = `${this.fp}.tmp`;
|
|
546
|
+
writeFileSync(tmp, JSON.stringify(f), { mode: 384 });
|
|
547
|
+
renameSync(tmp, this.fp);
|
|
548
|
+
} catch {}
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
const emptyAcc = () => ({
|
|
552
|
+
requests: 0,
|
|
553
|
+
ok: 0,
|
|
554
|
+
failed: 0,
|
|
555
|
+
promptTokens: 0,
|
|
556
|
+
completionTokens: 0,
|
|
557
|
+
cachedTokens: 0,
|
|
558
|
+
durationMs: 0,
|
|
559
|
+
ttfbMs: 0,
|
|
560
|
+
ttfbCount: 0,
|
|
561
|
+
estimatedInputs: 0,
|
|
562
|
+
estimatedOutputs: 0
|
|
563
|
+
});
|
|
564
|
+
function addRec(acc, rec) {
|
|
565
|
+
acc.requests += 1;
|
|
566
|
+
if (rec.ok) acc.ok += 1;
|
|
567
|
+
else acc.failed += 1;
|
|
568
|
+
acc.promptTokens += rec.promptTokens;
|
|
569
|
+
acc.completionTokens += rec.completionTokens;
|
|
570
|
+
acc.cachedTokens += rec.cachedTokens;
|
|
571
|
+
acc.durationMs += rec.durationMs;
|
|
572
|
+
if (rec.ttfbMs > 0) {
|
|
573
|
+
acc.ttfbMs += rec.ttfbMs;
|
|
574
|
+
acc.ttfbCount += 1;
|
|
575
|
+
}
|
|
576
|
+
if (rec.inputEstimated) acc.estimatedInputs += 1;
|
|
577
|
+
if (rec.outputEstimated) acc.estimatedOutputs += 1;
|
|
578
|
+
}
|
|
579
|
+
function bumpInto(map, key, rec) {
|
|
580
|
+
if (key === "") return;
|
|
581
|
+
const e = map[key] ?? emptyEntry();
|
|
582
|
+
e.requests += 1;
|
|
583
|
+
if (rec.ok) e.ok += 1;
|
|
584
|
+
else e.failed += 1;
|
|
585
|
+
e.promptTokens += rec.promptTokens;
|
|
586
|
+
e.completionTokens += rec.completionTokens;
|
|
587
|
+
e.cachedTokens += rec.cachedTokens;
|
|
588
|
+
e.lastTs = Math.max(e.lastTs, rec.ts);
|
|
589
|
+
map[key] = e;
|
|
590
|
+
}
|
|
591
|
+
function finish(acc, bySupplier, byModel, byRequested, lifetime) {
|
|
592
|
+
return {
|
|
593
|
+
requests: acc.requests,
|
|
594
|
+
ok: acc.ok,
|
|
595
|
+
failed: acc.failed,
|
|
596
|
+
promptTokens: acc.promptTokens,
|
|
597
|
+
completionTokens: acc.completionTokens,
|
|
598
|
+
cachedTokens: acc.cachedTokens,
|
|
599
|
+
avgDurationMs: acc.requests > 0 ? Math.round(acc.durationMs / acc.requests) : 0,
|
|
600
|
+
avgTtfbMs: acc.ttfbCount > 0 ? Math.round(acc.ttfbMs / acc.ttfbCount) : 0,
|
|
601
|
+
estimatedInputs: acc.estimatedInputs,
|
|
602
|
+
estimatedOutputs: acc.estimatedOutputs,
|
|
603
|
+
lifetime,
|
|
604
|
+
bySupplier: top(bySupplier),
|
|
605
|
+
byModel: top(byModel),
|
|
606
|
+
byRequested: top(byRequested)
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function mergeInto(target, src) {
|
|
610
|
+
for (const [k, v] of Object.entries(src)) {
|
|
611
|
+
const t = target[k];
|
|
612
|
+
if (t === void 0) {
|
|
613
|
+
target[k] = {
|
|
614
|
+
...emptyEntry(),
|
|
615
|
+
...v
|
|
616
|
+
};
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
t.requests += v.requests;
|
|
620
|
+
t.ok += v.ok;
|
|
621
|
+
t.failed += v.failed;
|
|
622
|
+
t.promptTokens += v.promptTokens;
|
|
623
|
+
t.completionTokens += v.completionTokens;
|
|
624
|
+
t.cachedTokens += v.cachedTokens;
|
|
625
|
+
t.lastTs = Math.max(t.lastTs, v.lastTs);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** Top 榜:按请求数降序,取前 10。 */
|
|
629
|
+
function top(map) {
|
|
630
|
+
return Object.entries(map).map(([name, e]) => ({
|
|
631
|
+
name,
|
|
632
|
+
requests: e.requests,
|
|
633
|
+
ok: e.ok,
|
|
634
|
+
failed: e.failed,
|
|
635
|
+
promptTokens: e.promptTokens,
|
|
636
|
+
completionTokens: e.completionTokens,
|
|
637
|
+
lastTs: e.lastTs
|
|
638
|
+
})).sort((a, b) => b.requests - a.requests || b.lastTs - a.lastTs).slice(0, 10);
|
|
639
|
+
}
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region src/router/usage-tokens.ts
|
|
642
|
+
/** 取有限数,其余当 0(NaN 会毒穿整个累加:`Math.max(x, NaN) === NaN`)。 */
|
|
643
|
+
function num(v) {
|
|
644
|
+
const n = Number(v);
|
|
645
|
+
return Number.isFinite(n) ? n : 0;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* 从上游任意形态的 usage 对象提取归一用量。
|
|
649
|
+
*
|
|
650
|
+
* `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
|
|
651
|
+
* prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
|
|
652
|
+
* prompt + 单独的 cache_read,要折进来,否则总输入偏小。
|
|
653
|
+
* 判别式就是「有没有 cache_read_input_tokens」。
|
|
654
|
+
*
|
|
655
|
+
* @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
|
|
656
|
+
*/
|
|
657
|
+
function normalizeUsage(raw) {
|
|
658
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
659
|
+
const u = raw;
|
|
660
|
+
const cached = num(u.cached_tokens) + num(u.cache_read_input_tokens) + num(u.cachedContentTokenCount) + num(u.prompt_tokens_details?.cached_tokens);
|
|
661
|
+
let prompt = num(u.prompt_tokens) + num(u.input_tokens) + num(u.promptTokenCount);
|
|
662
|
+
if (u.cache_read_input_tokens !== void 0) prompt += num(u.cache_read_input_tokens);
|
|
663
|
+
const completion = num(u.completion_tokens) + num(u.output_tokens) + num(u.candidatesTokenCount);
|
|
664
|
+
if (prompt === 0 && completion === 0 && cached === 0) return null;
|
|
665
|
+
return {
|
|
666
|
+
promptTokens: prompt,
|
|
667
|
+
completionTokens: completion,
|
|
668
|
+
cachedTokens: cached
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
/** 字段级 max 合并:后来的同名字段取大者(Claude 把 usage 拆在多个事件里)。 */
|
|
672
|
+
function mergeUsage(prev, next) {
|
|
673
|
+
if (prev === null) return next;
|
|
674
|
+
if (next === null) return prev;
|
|
675
|
+
return {
|
|
676
|
+
promptTokens: Math.max(prev.promptTokens, next.promptTokens),
|
|
677
|
+
completionTokens: Math.max(prev.completionTokens, next.completionTokens),
|
|
678
|
+
cachedTokens: Math.max(prev.cachedTokens, next.cachedTokens)
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* 从流式上游的一帧 JSON 里取 usage。
|
|
683
|
+
* 覆盖 OpenAI(Chat/Responses)、Claude、Gemini 三种事件形状,照 9router
|
|
684
|
+
* `extractUsage` 的形态判断(dsh-router 的上游多是 OpenAI SSE,但供应商
|
|
685
|
+
* 插件可能有直通的原生流,多认几种不亏)。
|
|
686
|
+
*/
|
|
687
|
+
function extractFrameUsage(chunk) {
|
|
688
|
+
if (chunk === null || typeof chunk !== "object" || Array.isArray(chunk)) return null;
|
|
689
|
+
const c = chunk;
|
|
690
|
+
if (c.type === "response.completed" || c.type === "response.done") {
|
|
691
|
+
const r = c.response;
|
|
692
|
+
if (r !== void 0) return normalizeUsage(r.usage);
|
|
693
|
+
}
|
|
694
|
+
if (c.type === "message_start") {
|
|
695
|
+
const m = c.message;
|
|
696
|
+
if (m !== void 0) return normalizeUsage(m.usage);
|
|
697
|
+
}
|
|
698
|
+
if (c.type === "message_delta") return normalizeUsage(c.usage);
|
|
699
|
+
if (c.usage !== void 0) return normalizeUsage(c.usage);
|
|
700
|
+
if (c.usageMetadata !== void 0) return normalizeUsage(c.usageMetadata);
|
|
701
|
+
const wrapped = c.response;
|
|
702
|
+
if (wrapped?.usageMetadata !== void 0) return normalizeUsage(wrapped.usageMetadata);
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
/** 估算:~4 字符/token(照 9router 的 estimateInputTokens/estimateOutputTokens)。 */
|
|
706
|
+
function estimateTokens(chars) {
|
|
707
|
+
if (!Number.isFinite(chars) || chars <= 0) return 0;
|
|
708
|
+
return Math.ceil(chars / 4);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* 非流式响应体取 usage。
|
|
712
|
+
*
|
|
713
|
+
* 正常是纯 JSON;但个别供应商客户端要 JSON 却回 SSE(核心会聚合成一次响应,
|
|
714
|
+
* 见 `aggregateSSE`),所以顺手扫尾部几帧——只扫尾部,不多做。
|
|
715
|
+
*/
|
|
716
|
+
function usageFromJsonBody(body) {
|
|
717
|
+
try {
|
|
718
|
+
return normalizeUsage(JSON.parse(body).usage);
|
|
719
|
+
} catch {}
|
|
720
|
+
let merged = null;
|
|
721
|
+
for (const frame of body.split("\n\n").slice(-3)) for (const line of frame.split("\n")) {
|
|
722
|
+
if (!line.startsWith("data:")) continue;
|
|
723
|
+
const payload = line.slice(5).trim();
|
|
724
|
+
if (payload === "" || payload === "[DONE]") continue;
|
|
725
|
+
let obj;
|
|
726
|
+
try {
|
|
727
|
+
obj = JSON.parse(payload);
|
|
728
|
+
} catch {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
merged = mergeUsage(merged, extractFrameUsage(obj));
|
|
732
|
+
}
|
|
733
|
+
return merged;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* 归一用量 → DSH 的 `TokenUsage`(**必须**走这一步再交给 dsh-llm)。
|
|
737
|
+
*
|
|
738
|
+
* 为什么不能把上游 usage 原样透传:dsh-llm 的 `TokenUsage` 有两条硬性约定,
|
|
739
|
+
* 上游的 OpenAI 形态两条都踩:
|
|
740
|
+
* 1. 字段名是 `inputTokens` / `outputTokens`,不是 `prompt_tokens`;
|
|
741
|
+
* 名字对不上 → 下游读 `usage.inputTokens` 得 `undefined` → 累加出 `NaN`。
|
|
742
|
+
* 2. **DISJOINT 口径**:`inputTokens` 只算**未缓存**输入,缓存单列
|
|
743
|
+
* `cacheReadTokens`/`cacheWriteTokens`(计费输入 = 三者之和)。
|
|
744
|
+
* OpenAI 的 `prompt_tokens` 是**含**缓存的总量,直接当 inputTokens
|
|
745
|
+
* 就是重复计费。
|
|
746
|
+
*
|
|
747
|
+
* 后果不只是「数字不好看」:token-meter 的投影 schema 是
|
|
748
|
+
* `z.number().int().nonnegative()`,`NaN` 一进去校验就抛,整条
|
|
749
|
+
* `session.history` RPC 失败 —— 表现为「历史加载失败:history unavailable
|
|
750
|
+
* for session ... expected number, received NaN」。所以这里是**写入侧**
|
|
751
|
+
* 的防线:宁可丢掉这一帧 usage,也不能往会话日志里写 NaN。
|
|
752
|
+
*
|
|
753
|
+
* 天花板(刻意不做):不区分 cacheRead/cacheWrite。上游多数只报读不报写,
|
|
754
|
+
* 拆不开的字段硬拆只会更错;哪天上游稳定给 `cache_creation_input_tokens`
|
|
755
|
+
* 再补 `cacheWriteTokens`(升级路径就在这)。
|
|
756
|
+
*
|
|
757
|
+
* @returns DSH 契约用量;三个数全为 0 时返回 null(= 这一帧没真数据)
|
|
758
|
+
*/
|
|
759
|
+
function toTokenUsage(tokens) {
|
|
760
|
+
if (tokens === null) return null;
|
|
761
|
+
const input = finiteInt(tokens.promptTokens - tokens.cachedTokens);
|
|
762
|
+
const output = finiteInt(tokens.completionTokens);
|
|
763
|
+
const cachedTokens = finiteInt(tokens.cachedTokens);
|
|
764
|
+
if (input === 0 && output === 0 && cachedTokens === 0) return null;
|
|
765
|
+
return {
|
|
766
|
+
inputTokens: input,
|
|
767
|
+
outputTokens: output,
|
|
768
|
+
...cachedTokens > 0 ? { cacheReadTokens: cachedTokens } : {}
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
/** 取非负有限整数:NaN / Infinity / 负数一律归 0,小数取整。 */
|
|
772
|
+
function finiteInt(v) {
|
|
773
|
+
if (!Number.isFinite(v)) return 0;
|
|
774
|
+
return Math.max(0, Math.round(v));
|
|
775
|
+
}
|
|
776
|
+
/** 一帧 SSE 里的可见文本内容(用于输出字符数估算)。 */
|
|
777
|
+
function frameText(chunk) {
|
|
778
|
+
const choices = chunk.choices;
|
|
779
|
+
if (choices === void 0) return "";
|
|
780
|
+
let out = "";
|
|
781
|
+
for (const ch of choices) {
|
|
782
|
+
const delta = ch.delta;
|
|
783
|
+
if (delta !== void 0) {
|
|
784
|
+
if (typeof delta.content === "string") out += delta.content;
|
|
785
|
+
if (typeof delta.reasoning_content === "string") out += delta.reasoning_content;
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const msg = ch.message;
|
|
789
|
+
if (msg !== void 0) {
|
|
790
|
+
if (typeof msg.content === "string") out += msg.content;
|
|
791
|
+
if (typeof msg.reasoning_content === "string") out += msg.reasoning_content;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return out;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* 边透传边统计:原流**原样**出去,统计在旁边做。
|
|
798
|
+
*
|
|
799
|
+
* 为什么不用 `source.tee()`:tee 的两条分支互相牵制,慢的一边会把 buffer 憋住;
|
|
800
|
+
* 而统计这条分支得读完整个流才出得了 usage,客户端那边却要尽快拿到首字节。
|
|
801
|
+
* 手动转发是把上游 chunk **即刻**交给下游,统计只攒几个数字,零缓冲、
|
|
802
|
+
* 首字节延迟不受影响。
|
|
803
|
+
*
|
|
804
|
+
* @param source 上游流(插件已转成 OpenAI SSE)
|
|
805
|
+
* @param startedAt 请求开始时间(`Date.now()`),TTFB 以此为基准
|
|
806
|
+
*/
|
|
807
|
+
function tapStreamUsage(source, startedAt) {
|
|
808
|
+
const dec = new TextDecoder();
|
|
809
|
+
let buf = "";
|
|
810
|
+
let merged = null;
|
|
811
|
+
let outputChars = 0;
|
|
812
|
+
let firstAt = 0;
|
|
813
|
+
const parseFrame = (frame) => {
|
|
814
|
+
for (const line of frame.split("\n")) {
|
|
815
|
+
if (!line.startsWith("data:")) continue;
|
|
816
|
+
const payload = line.slice(5).trim();
|
|
817
|
+
if (payload === "" || payload === "[DONE]") continue;
|
|
818
|
+
let obj;
|
|
819
|
+
try {
|
|
820
|
+
obj = JSON.parse(payload);
|
|
821
|
+
} catch {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) continue;
|
|
825
|
+
merged = mergeUsage(merged, extractFrameUsage(obj));
|
|
826
|
+
outputChars += frameText(obj).length;
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
return {
|
|
830
|
+
stream: new ReadableStream({ start: async (ctrl) => {
|
|
831
|
+
const reader = source.getReader();
|
|
832
|
+
try {
|
|
833
|
+
for (;;) {
|
|
834
|
+
const { done, value } = await reader.read();
|
|
835
|
+
if (done) break;
|
|
836
|
+
if (firstAt === 0) firstAt = Date.now();
|
|
837
|
+
buf += dec.decode(value, { stream: true });
|
|
838
|
+
let sep;
|
|
839
|
+
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
840
|
+
const frame = buf.slice(0, sep + 2);
|
|
841
|
+
buf = buf.slice(sep + 2);
|
|
842
|
+
parseFrame(frame);
|
|
843
|
+
}
|
|
844
|
+
ctrl.enqueue(value);
|
|
845
|
+
}
|
|
846
|
+
if (buf !== "") parseFrame(buf);
|
|
847
|
+
} finally {
|
|
848
|
+
reader.releaseLock();
|
|
849
|
+
}
|
|
850
|
+
ctrl.close();
|
|
851
|
+
} }),
|
|
852
|
+
done: async () => ({
|
|
853
|
+
usage: merged,
|
|
854
|
+
outputChars,
|
|
855
|
+
ttfbMs: firstAt === 0 ? 0 : firstAt - startedAt
|
|
856
|
+
})
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* 补齐估算:上游没给的字段用字符数估,并打标记。
|
|
861
|
+
*
|
|
862
|
+
* `requestChars` = 请求体字符数(估输入),`outputChars` = 输出字符数(估输出)。
|
|
863
|
+
* 上游给了的字段**不覆盖**——估算只填空,不抢真数据。
|
|
864
|
+
*/
|
|
865
|
+
function withEstimates(usage, requestChars, outputChars) {
|
|
866
|
+
const prompt = usage?.promptTokens ?? 0;
|
|
867
|
+
const completion = usage?.completionTokens ?? 0;
|
|
868
|
+
const inputEstimated = prompt === 0;
|
|
869
|
+
const outputEstimated = completion === 0;
|
|
870
|
+
return {
|
|
871
|
+
promptTokens: inputEstimated ? estimateTokens(requestChars) : prompt,
|
|
872
|
+
completionTokens: outputEstimated ? estimateTokens(outputChars) : completion,
|
|
873
|
+
cachedTokens: usage?.cachedTokens ?? 0,
|
|
874
|
+
inputEstimated,
|
|
875
|
+
outputEstimated
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
//#endregion
|
|
13
879
|
//#region src/router/index.ts
|
|
14
880
|
/**
|
|
15
881
|
* dsh-router 路由器本体:供应商注册表 + OpenAI 兼容 /v1/* 处理。
|
|
16
882
|
* 仿 9router:组合 = 一组模型,请求 model 命中组合名时,按策略
|
|
17
883
|
* (fallback 顺序尝试 / round-robin 轮转)选中一个模型路由。
|
|
18
884
|
*/
|
|
885
|
+
/** 模型列表缓存有效期。 */
|
|
886
|
+
const MODELS_TTL_MS = 6e4;
|
|
19
887
|
function openAIError(code, msg) {
|
|
20
888
|
return { error: {
|
|
21
889
|
message: msg,
|
|
@@ -23,12 +891,295 @@ function openAIError(code, msg) {
|
|
|
23
891
|
code
|
|
24
892
|
} };
|
|
25
893
|
}
|
|
26
|
-
function writeJson$2(res, status, body) {
|
|
27
|
-
res.writeHead(status, {
|
|
28
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
29
|
-
"Cache-Control": "no-store"
|
|
894
|
+
function writeJson$2(res, status, body) {
|
|
895
|
+
res.writeHead(status, {
|
|
896
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
897
|
+
"Cache-Control": "no-store"
|
|
898
|
+
});
|
|
899
|
+
res.end(JSON.stringify(body));
|
|
900
|
+
}
|
|
901
|
+
/** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
|
|
902
|
+
function sinkRes() {
|
|
903
|
+
let status = 200;
|
|
904
|
+
let text = "";
|
|
905
|
+
const self = {
|
|
906
|
+
headersSent: false,
|
|
907
|
+
writableEnded: false,
|
|
908
|
+
writeHead: (code) => {
|
|
909
|
+
status = code;
|
|
910
|
+
return self;
|
|
911
|
+
},
|
|
912
|
+
write: (chunk) => {
|
|
913
|
+
if (chunk !== void 0) text += String(chunk);
|
|
914
|
+
return true;
|
|
915
|
+
},
|
|
916
|
+
end: (chunk) => {
|
|
917
|
+
if (chunk !== void 0) text += String(chunk);
|
|
918
|
+
return self;
|
|
919
|
+
},
|
|
920
|
+
flushHeaders: () => {},
|
|
921
|
+
status: () => status,
|
|
922
|
+
body: () => text
|
|
923
|
+
};
|
|
924
|
+
return self;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* 把 OpenAI SSE 流聚合成一次非流式响应体。
|
|
928
|
+
*
|
|
929
|
+
* 为什么核心要做这个:有些供应商**只支持流式**(如 codebuddy 强制 stream:true),
|
|
930
|
+
* 于是客户端要 JSON 时也会拿到流。核心独占响应写入权,就得把这个错配吸收掉——
|
|
931
|
+
* 否则客户端按 JSON 解析 `choices[0].message.tool_calls[].function.name`,
|
|
932
|
+
* 而名字藏在 SSE 的 `delta` 里,解析结果就是 name 为空(下游报
|
|
933
|
+
* `unknown tool ""`)。这是协议错配,不是丢数据。
|
|
934
|
+
*/
|
|
935
|
+
async function aggregateSSE(stream) {
|
|
936
|
+
const dec = new TextDecoder();
|
|
937
|
+
let id = "";
|
|
938
|
+
let model = "";
|
|
939
|
+
let created = Math.floor(Date.now() / 1e3);
|
|
940
|
+
let content = "";
|
|
941
|
+
let reasoning = "";
|
|
942
|
+
let finish = null;
|
|
943
|
+
let usage;
|
|
944
|
+
const calls = /* @__PURE__ */ new Map();
|
|
945
|
+
const reader = stream.getReader();
|
|
946
|
+
let buf = "";
|
|
947
|
+
for (;;) {
|
|
948
|
+
const { done, value } = await reader.read();
|
|
949
|
+
if (done) break;
|
|
950
|
+
buf += dec.decode(value, { stream: true });
|
|
951
|
+
let sep;
|
|
952
|
+
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
953
|
+
const frame = buf.slice(0, sep);
|
|
954
|
+
buf = buf.slice(sep + 2);
|
|
955
|
+
for (const line of frame.split("\n")) {
|
|
956
|
+
if (!line.startsWith("data:")) continue;
|
|
957
|
+
const payload = line.slice(5).trim();
|
|
958
|
+
if (payload === "" || payload === "[DONE]") continue;
|
|
959
|
+
let obj;
|
|
960
|
+
try {
|
|
961
|
+
obj = JSON.parse(payload);
|
|
962
|
+
} catch {
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
if (typeof obj.id === "string" && obj.id !== "") id = obj.id;
|
|
966
|
+
if (typeof obj.model === "string" && obj.model !== "") model = obj.model;
|
|
967
|
+
if (typeof obj.created === "number") created = obj.created;
|
|
968
|
+
if (obj.usage !== void 0) usage = obj.usage;
|
|
969
|
+
const ch = obj.choices?.[0];
|
|
970
|
+
if (ch === void 0) continue;
|
|
971
|
+
if (typeof ch.finish_reason === "string") finish = ch.finish_reason;
|
|
972
|
+
const delta = ch.delta;
|
|
973
|
+
if (delta === void 0) {
|
|
974
|
+
const msg = ch.message;
|
|
975
|
+
if (msg !== void 0) {
|
|
976
|
+
if (typeof msg.content === "string") content += msg.content;
|
|
977
|
+
if (typeof msg.reasoning_content === "string") reasoning += msg.reasoning_content;
|
|
978
|
+
}
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
if (typeof delta.content === "string") content += delta.content;
|
|
982
|
+
if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
|
|
983
|
+
const tcs = delta.tool_calls;
|
|
984
|
+
if (!Array.isArray(tcs)) continue;
|
|
985
|
+
for (const tc of tcs) {
|
|
986
|
+
const idx = typeof tc.index === "number" ? tc.index : 0;
|
|
987
|
+
const fn = tc.function;
|
|
988
|
+
const prev = calls.get(idx) ?? {
|
|
989
|
+
name: "",
|
|
990
|
+
args: ""
|
|
991
|
+
};
|
|
992
|
+
if (typeof tc.id === "string" && tc.id !== "") prev.id = tc.id;
|
|
993
|
+
if (typeof tc.type === "string") prev.type = tc.type;
|
|
994
|
+
if (typeof fn?.name === "string" && fn.name !== "") prev.name = fn.name;
|
|
995
|
+
if (typeof fn?.arguments === "string") prev.args += fn.arguments;
|
|
996
|
+
calls.set(idx, prev);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const toolCalls = [...calls.entries()].sort((a, b) => a[0] - b[0]).filter(([, c]) => c.name !== "").map(([, c]) => ({
|
|
1002
|
+
id: c.id ?? `call_${c.name}`,
|
|
1003
|
+
type: c.type ?? "function",
|
|
1004
|
+
function: {
|
|
1005
|
+
name: c.name,
|
|
1006
|
+
arguments: c.args
|
|
1007
|
+
}
|
|
1008
|
+
}));
|
|
1009
|
+
const message = {
|
|
1010
|
+
role: "assistant",
|
|
1011
|
+
content
|
|
1012
|
+
};
|
|
1013
|
+
if (reasoning !== "") message.reasoning_content = reasoning;
|
|
1014
|
+
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
|
1015
|
+
const body = {
|
|
1016
|
+
id: id !== "" ? id : `chatcmpl-${Date.now()}`,
|
|
1017
|
+
object: "chat.completion",
|
|
1018
|
+
created,
|
|
1019
|
+
model,
|
|
1020
|
+
choices: [{
|
|
1021
|
+
index: 0,
|
|
1022
|
+
message,
|
|
1023
|
+
finish_reason: finish ?? "stop",
|
|
1024
|
+
logprobs: null
|
|
1025
|
+
}]
|
|
1026
|
+
};
|
|
1027
|
+
if (usage !== void 0) body.usage = usage;
|
|
1028
|
+
return JSON.stringify(body);
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* 流式 SSE 逐帧归一化:剥掉上游多发一次的**空** tool_call 字段。
|
|
1032
|
+
*
|
|
1033
|
+
* 为什么必须做:CodeBuddy 上游的工具调用第二个 delta 会**显式发空串**
|
|
1034
|
+
* `{"function":{"name":"","arguments":"{...}"},"index":0}`(以及 `"id":""`)。
|
|
1035
|
+
* OpenAI 规范里后续 delta 应当**省略**这些字段,客户端沿用首帧的名字;
|
|
1036
|
+
* 而客户端(dsh)判空用的是 `name !== void 0`,空串过不了这个判断,于是首帧
|
|
1037
|
+
* 的 "bash" 被第二帧的 "" 冲掉 —— 工具名变空,下游报 `unknown tool ""`,
|
|
1038
|
+
* 而 arguments 是独立累积的,所以现象是「参数对、名字空」。
|
|
1039
|
+
*
|
|
1040
|
+
* 核心是流式响应的唯一写入方,这个上游不合规得核心吸收:把空串字段**删掉**
|
|
1041
|
+
* (而不是留着空串),客户端看到的就是 undefined,不会覆盖。
|
|
1042
|
+
*
|
|
1043
|
+
* 只动 tool_calls 的空 name/id,其余原样透传;逐帧流式处理,不缓冲整条流
|
|
1044
|
+
* (首字节延迟不受影响)。解析失败一律原样透传——宁可不改,也不能改坏。
|
|
1045
|
+
*/
|
|
1046
|
+
function normalizeSSEStream(stream) {
|
|
1047
|
+
const enc = new TextEncoder();
|
|
1048
|
+
const dec = new TextDecoder();
|
|
1049
|
+
let buf = "";
|
|
1050
|
+
return new ReadableStream({ start: async (ctrl) => {
|
|
1051
|
+
const reader = stream.getReader();
|
|
1052
|
+
try {
|
|
1053
|
+
for (;;) {
|
|
1054
|
+
const { done, value } = await reader.read();
|
|
1055
|
+
if (done) break;
|
|
1056
|
+
buf += dec.decode(value, { stream: true });
|
|
1057
|
+
let sep;
|
|
1058
|
+
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
1059
|
+
const frame = buf.slice(0, sep + 2);
|
|
1060
|
+
buf = buf.slice(sep + 2);
|
|
1061
|
+
ctrl.enqueue(enc.encode(fixFrame(frame)));
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (buf !== "") ctrl.enqueue(enc.encode(fixFrame(buf)));
|
|
1065
|
+
} finally {
|
|
1066
|
+
reader.releaseLock();
|
|
1067
|
+
}
|
|
1068
|
+
ctrl.close();
|
|
1069
|
+
} });
|
|
1070
|
+
}
|
|
1071
|
+
/** 修一帧 SSE:只剥空的 name/id,出任何问题都原样返回。 */
|
|
1072
|
+
function fixFrame(frame) {
|
|
1073
|
+
if (!frame.includes("tool_calls")) return frame;
|
|
1074
|
+
return frame.split("\n").map((line) => {
|
|
1075
|
+
if (!line.startsWith("data: ")) return line;
|
|
1076
|
+
const payload = line.slice(6).trim();
|
|
1077
|
+
if (payload === "" || payload === "[DONE]") return line;
|
|
1078
|
+
let obj;
|
|
1079
|
+
try {
|
|
1080
|
+
obj = JSON.parse(payload);
|
|
1081
|
+
} catch {
|
|
1082
|
+
return line;
|
|
1083
|
+
}
|
|
1084
|
+
let changed = false;
|
|
1085
|
+
const tcs = (obj.choices?.[0]?.delta)?.tool_calls;
|
|
1086
|
+
if (Array.isArray(tcs)) for (const tc of tcs) {
|
|
1087
|
+
if (tc.id === "") {
|
|
1088
|
+
delete tc.id;
|
|
1089
|
+
changed = true;
|
|
1090
|
+
}
|
|
1091
|
+
const fn = tc.function;
|
|
1092
|
+
if (fn !== null && typeof fn === "object" && fn.name === "") {
|
|
1093
|
+
delete fn.name;
|
|
1094
|
+
changed = true;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return changed ? `data: ${JSON.stringify(obj)}` : line;
|
|
1098
|
+
}).join("\n");
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* 把 chatOnce 的结果写进 res(核心独占响应写入权)。
|
|
1102
|
+
* 流式:SSE 头 + pipe 上游已转好的 OpenAI SSE;非流式:原样写 JSON。
|
|
1103
|
+
*
|
|
1104
|
+
* @returns true = 响应已提交(客户端拿到东西了);false = **一个字节都没写**
|
|
1105
|
+
* (响应头也还没发),调用方可以换账号/换模型重试。
|
|
1106
|
+
*
|
|
1107
|
+
* 流式**故意推迟到第一个字节才写响应头**:上游常见「连上了但立刻断」的情况,
|
|
1108
|
+
* 先写头就等于把自己锁死在这一个账号上,组合就没法回退了——而客户端只拿到
|
|
1109
|
+
* 一个空/截断的 SSE,表现为工具调用名是空串(`unknown tool ""`)。
|
|
1110
|
+
*
|
|
1111
|
+
* 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
|
|
1112
|
+
* 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
|
|
1113
|
+
*/
|
|
1114
|
+
async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
1115
|
+
if (!r.ok) return false;
|
|
1116
|
+
if (!("stream" in r)) {
|
|
1117
|
+
if (probe !== void 0) probe.tokens = usageFromJsonBody(r.body);
|
|
1118
|
+
res.writeHead(r.status, {
|
|
1119
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1120
|
+
"Cache-Control": "no-store"
|
|
1121
|
+
});
|
|
1122
|
+
res.end(r.body);
|
|
1123
|
+
return true;
|
|
1124
|
+
}
|
|
1125
|
+
if (!wantsStream) {
|
|
1126
|
+
let body;
|
|
1127
|
+
try {
|
|
1128
|
+
body = await aggregateSSE(r.stream);
|
|
1129
|
+
} catch {
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1132
|
+
if (probe !== void 0) probe.tokens = usageFromJsonBody(body);
|
|
1133
|
+
res.writeHead(200, {
|
|
1134
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1135
|
+
"Cache-Control": "no-store"
|
|
1136
|
+
});
|
|
1137
|
+
res.end(body);
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
const tapped = probe === void 0 ? void 0 : tapStreamUsage(r.stream, startedAt);
|
|
1141
|
+
let wroteAny = false;
|
|
1142
|
+
const writeChunk = (chunk) => new Promise((resolve) => {
|
|
1143
|
+
if (!wroteAny) {
|
|
1144
|
+
res.writeHead(200, {
|
|
1145
|
+
"Content-Type": "text/event-stream",
|
|
1146
|
+
"Cache-Control": "no-cache",
|
|
1147
|
+
Connection: "keep-alive",
|
|
1148
|
+
"X-Accel-Buffering": "no"
|
|
1149
|
+
});
|
|
1150
|
+
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
1151
|
+
wroteAny = true;
|
|
1152
|
+
}
|
|
1153
|
+
if (res.write(chunk)) resolve();
|
|
1154
|
+
else res.once("drain", resolve);
|
|
30
1155
|
});
|
|
31
|
-
|
|
1156
|
+
const onError = () => {
|
|
1157
|
+
res.destroy();
|
|
1158
|
+
};
|
|
1159
|
+
res.once("error", onError);
|
|
1160
|
+
try {
|
|
1161
|
+
await normalizeSSEStream(tapped?.stream ?? r.stream).pipeTo(new WritableStream({ write: writeChunk }));
|
|
1162
|
+
} catch {} finally {
|
|
1163
|
+
res.removeListener("error", onError);
|
|
1164
|
+
if (wroteAny) res.end();
|
|
1165
|
+
if (probe !== void 0 && tapped !== void 0) {
|
|
1166
|
+
const got = await tapped.done();
|
|
1167
|
+
probe.tokens = got.usage;
|
|
1168
|
+
probe.outputChars = got.outputChars;
|
|
1169
|
+
probe.ttfbMs = got.ttfbMs;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return wroteAny;
|
|
1173
|
+
}
|
|
1174
|
+
/** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
|
|
1175
|
+
function extractUpstreamError(body) {
|
|
1176
|
+
try {
|
|
1177
|
+
const j = JSON.parse(body);
|
|
1178
|
+
if (typeof j.error?.message === "string") return j.error.message;
|
|
1179
|
+
if (typeof j.message === "string") return j.message;
|
|
1180
|
+
if (typeof j.msg === "string") return j.msg;
|
|
1181
|
+
} catch {}
|
|
1182
|
+
return body.slice(0, 200);
|
|
32
1183
|
}
|
|
33
1184
|
/** 路由器。 */
|
|
34
1185
|
var Router = class {
|
|
@@ -37,10 +1188,29 @@ var Router = class {
|
|
|
37
1188
|
customCombos = [];
|
|
38
1189
|
/** round-robin 轮转游标(按组合 id 记忆)。 */
|
|
39
1190
|
rrCursors = /* @__PURE__ */ new Map();
|
|
40
|
-
|
|
1191
|
+
/** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
|
|
1192
|
+
store;
|
|
1193
|
+
/** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
|
|
1194
|
+
modelsCache = /* @__PURE__ */ new Map();
|
|
1195
|
+
/** 请求日志出口(面板/宿主 logger)。 */
|
|
1196
|
+
log;
|
|
1197
|
+
/** 用量统计(概览看板)。 */
|
|
1198
|
+
usage;
|
|
1199
|
+
constructor(stateFile = "", store, log) {
|
|
41
1200
|
this.combosFp = stateFile ? join(dirname(stateFile), "combos.json") : "";
|
|
1201
|
+
this.store = store ?? new SupplierConfigStore(stateFile);
|
|
1202
|
+
this.log = log ?? (() => {});
|
|
1203
|
+
this.usage = new UsageStore(stateFile);
|
|
42
1204
|
this.loadCombos();
|
|
43
1205
|
}
|
|
1206
|
+
/**
|
|
1207
|
+
* 请求追踪:每次 chat 记一行,回答「这个组合到底用的哪个模型和账号」。
|
|
1208
|
+
* 排查「组合里某个模型不稳定」全靠它——没这个就只能猜。
|
|
1209
|
+
* 格式刻意做成一行 grep 友好:`chat 请求model → supplier/model (uid) 结果 耗时`。
|
|
1210
|
+
*/
|
|
1211
|
+
logChat(reqModel, detail, ms) {
|
|
1212
|
+
this.log(`chat ${JSON.stringify(reqModel)} → ${detail} ${ms}ms`);
|
|
1213
|
+
}
|
|
44
1214
|
loadCombos() {
|
|
45
1215
|
if (this.combosFp === "") return;
|
|
46
1216
|
try {
|
|
@@ -48,8 +1218,13 @@ var Router = class {
|
|
|
48
1218
|
if (Array.isArray(f.combos)) this.customCombos = f.combos.filter((c) => typeof c.id === "string" && c.id !== "").map((c) => {
|
|
49
1219
|
const old = c;
|
|
50
1220
|
const models = (Array.isArray(c.models) ? c.models.filter((m) => typeof m === "string" && m !== "") : Array.isArray(old.steps) ? old.steps.map((s) => s.supplier).filter((m) => typeof m === "string" && m !== "") : []).map((m) => {
|
|
51
|
-
|
|
52
|
-
|
|
1221
|
+
if (m.indexOf(",") > 0) return m;
|
|
1222
|
+
const slash = m.indexOf("/");
|
|
1223
|
+
if (slash <= 0) return m;
|
|
1224
|
+
const alias = m.slice(0, slash);
|
|
1225
|
+
const modelId = m.slice(slash + 1);
|
|
1226
|
+
const s = this.suppliers.find((x) => x.getAlias() === alias);
|
|
1227
|
+
return s === void 0 ? modelId : `${s.id},${modelId}`;
|
|
53
1228
|
});
|
|
54
1229
|
return {
|
|
55
1230
|
id: c.id,
|
|
@@ -74,12 +1249,14 @@ var Router = class {
|
|
|
74
1249
|
add(supplier) {
|
|
75
1250
|
this.suppliers.push(supplier);
|
|
76
1251
|
this.suppliers.sort((a, b) => a.priority - b.priority);
|
|
1252
|
+
this.store.sync(this.suppliers.map((s) => s.id));
|
|
77
1253
|
}
|
|
78
1254
|
/** 移除供应商(外部插件卸载时注销)。 */
|
|
79
1255
|
removeSupplier(id) {
|
|
80
1256
|
const i = this.suppliers.findIndex((s) => s.id === id);
|
|
81
1257
|
if (i < 0) return false;
|
|
82
1258
|
const [s] = this.suppliers.splice(i, 1);
|
|
1259
|
+
this.modelsCache.delete(id);
|
|
83
1260
|
s?.dispose();
|
|
84
1261
|
return true;
|
|
85
1262
|
}
|
|
@@ -120,21 +1297,67 @@ var Router = class {
|
|
|
120
1297
|
async combos() {
|
|
121
1298
|
return [...this.customCombos];
|
|
122
1299
|
}
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
1300
|
+
/**
|
|
1301
|
+
* 取某供应商的模型(核心统一缓存)。
|
|
1302
|
+
*
|
|
1303
|
+
* 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
|
|
1304
|
+
* `/combos`(组合加模型)共用。组合面板**不主动打上游**——它只是读缓存,
|
|
1305
|
+
* 冷启动(缓存还没建)时才拉一次;真正刷新由详情页打开或「获取模型」
|
|
1306
|
+
* 按钮(force)触发。
|
|
1307
|
+
*
|
|
1308
|
+
* 天花板:TTL 60s 是拍的。若上游模型列表变更很频繁,可调小;要彻底实时
|
|
1309
|
+
* 就得让供应商暴露 etag/版本号,目前没有这个需求。
|
|
1310
|
+
*/
|
|
1311
|
+
async modelsOf(supplierId, force = false) {
|
|
1312
|
+
const s = this.suppliers.find((x) => x.id === supplierId);
|
|
1313
|
+
if (s === void 0) return [];
|
|
1314
|
+
const hit = this.modelsCache.get(supplierId);
|
|
1315
|
+
if (!force && hit !== void 0 && Date.now() - hit.fetchedAt < MODELS_TTL_MS) return hit.models;
|
|
1316
|
+
const cfg = this.store.get(supplierId);
|
|
1317
|
+
const custom = new Set(cfg.custom);
|
|
1318
|
+
const disabled = new Set(cfg.disabled);
|
|
1319
|
+
const models = (await Promise.resolve(s.listModels())).map((mm) => ({
|
|
1320
|
+
...mm,
|
|
1321
|
+
enabled: !disabled.has(mm.id),
|
|
1322
|
+
custom: custom.has(mm.id) ? true : void 0
|
|
1323
|
+
}));
|
|
1324
|
+
const seen = new Set(models.map((mm) => mm.id));
|
|
1325
|
+
for (const id of custom) if (!seen.has(id)) {
|
|
1326
|
+
seen.add(id);
|
|
1327
|
+
models.push({
|
|
1328
|
+
id,
|
|
1329
|
+
enabled: !disabled.has(id),
|
|
1330
|
+
custom: true
|
|
135
1331
|
});
|
|
136
|
-
}
|
|
137
|
-
|
|
1332
|
+
}
|
|
1333
|
+
this.modelsCache.set(supplierId, {
|
|
1334
|
+
models,
|
|
1335
|
+
fetchedAt: Date.now()
|
|
1336
|
+
});
|
|
1337
|
+
return models;
|
|
1338
|
+
}
|
|
1339
|
+
/** 失效某供应商的模型缓存(增删改模型后调用)。 */
|
|
1340
|
+
invalidateModels(supplierId) {
|
|
1341
|
+
this.modelsCache.delete(supplierId);
|
|
1342
|
+
}
|
|
1343
|
+
/** 可用模型(按供应商分组,仅启用),面板加模型用。
|
|
1344
|
+
* 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
|
|
1345
|
+
async supplierModels() {
|
|
1346
|
+
return (await Promise.all(this.suppliers.map(async (s) => {
|
|
1347
|
+
try {
|
|
1348
|
+
const models = (await this.modelsOf(s.id)).filter((m) => m.enabled);
|
|
1349
|
+
return {
|
|
1350
|
+
supplier: {
|
|
1351
|
+
id: s.id,
|
|
1352
|
+
name: s.name,
|
|
1353
|
+
alias: s.getAlias()
|
|
1354
|
+
},
|
|
1355
|
+
models
|
|
1356
|
+
};
|
|
1357
|
+
} catch {
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
}))).filter((g) => g !== void 0);
|
|
138
1361
|
}
|
|
139
1362
|
validModels(models) {
|
|
140
1363
|
return Array.isArray(models) && models.length > 0 && models.every((m) => typeof m === "string" && m !== "");
|
|
@@ -142,11 +1365,24 @@ var Router = class {
|
|
|
142
1365
|
validStrategy(strategy) {
|
|
143
1366
|
return strategy === "fallback" || strategy === "round-robin";
|
|
144
1367
|
}
|
|
145
|
-
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
1368
|
+
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
1369
|
+
* 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
|
|
1370
|
+
* (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
|
|
1371
|
+
* 命名空间一起吃掉,后面请求必然 404。 */
|
|
1372
|
+
/**
|
|
1373
|
+
* 归一组合模型名为存储格式 `supplierId,modelId`。
|
|
1374
|
+
* 面板提交的是「供应商 id + 模型 id」两两组合(或旧数据的裸 id),
|
|
1375
|
+
* 这里只做清理与校验,不再剥掉前缀——精准调用靠的就是供应商 id。
|
|
1376
|
+
*/
|
|
146
1377
|
normalizeModelIds(models) {
|
|
147
|
-
return models.map((m) => {
|
|
148
|
-
|
|
149
|
-
|
|
1378
|
+
return models.map((m) => m.trim()).filter((m) => m !== "").map((m) => {
|
|
1379
|
+
if (m.indexOf(",") > 0) return m;
|
|
1380
|
+
const slash = m.indexOf("/");
|
|
1381
|
+
if (slash <= 0) return m;
|
|
1382
|
+
const alias = m.slice(0, slash);
|
|
1383
|
+
const modelId = m.slice(slash + 1);
|
|
1384
|
+
const s = this.suppliers.find((x) => x.getAlias() === alias);
|
|
1385
|
+
return s === void 0 ? modelId : `${s.id},${modelId}`;
|
|
150
1386
|
});
|
|
151
1387
|
}
|
|
152
1388
|
/** 创建组合(name 唯一,非 default)。 */
|
|
@@ -245,6 +1481,34 @@ var Router = class {
|
|
|
245
1481
|
* - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
|
|
246
1482
|
*/
|
|
247
1483
|
async chatCompletions(req, res) {
|
|
1484
|
+
const probe = {
|
|
1485
|
+
requested: req.model,
|
|
1486
|
+
supplier: "",
|
|
1487
|
+
model: "",
|
|
1488
|
+
uid: "",
|
|
1489
|
+
tokens: null,
|
|
1490
|
+
outputChars: 0,
|
|
1491
|
+
ttfbMs: 0,
|
|
1492
|
+
startedAt: Date.now()
|
|
1493
|
+
};
|
|
1494
|
+
const settle = (ok, error) => {
|
|
1495
|
+
const usage = ok ? withEstimates(probe.tokens, req.rawBody.length, probe.outputChars) : {
|
|
1496
|
+
promptTokens: 0,
|
|
1497
|
+
completionTokens: 0,
|
|
1498
|
+
cachedTokens: 0,
|
|
1499
|
+
inputEstimated: false,
|
|
1500
|
+
outputEstimated: false
|
|
1501
|
+
};
|
|
1502
|
+
this.usage.record({
|
|
1503
|
+
supplier: probe.supplier,
|
|
1504
|
+
model: probe.model,
|
|
1505
|
+
requested: probe.requested,
|
|
1506
|
+
ok,
|
|
1507
|
+
durationMs: Date.now() - probe.startedAt,
|
|
1508
|
+
ttfbMs: probe.ttfbMs,
|
|
1509
|
+
...error === void 0 ? {} : { error }
|
|
1510
|
+
}, usage);
|
|
1511
|
+
};
|
|
248
1512
|
const combo = this.comboByName(req.model);
|
|
249
1513
|
if (combo) {
|
|
250
1514
|
const start = combo.strategy === "round-robin" ? (this.rrCursors.get(combo.id) ?? 0) % combo.models.length : 0;
|
|
@@ -252,33 +1516,185 @@ var Router = class {
|
|
|
252
1516
|
for (let i = 0; i < combo.models.length; i++) {
|
|
253
1517
|
const model = combo.models[(start + i) % combo.models.length];
|
|
254
1518
|
if (model === void 0) continue;
|
|
255
|
-
if (await this.chatWithModel(req, res, model))
|
|
1519
|
+
if (await this.chatWithModel(req, res, model, probe)) {
|
|
1520
|
+
settle(true);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
256
1523
|
}
|
|
1524
|
+
settle(false, "combo: all models unavailable");
|
|
257
1525
|
writeJson$2(res, 503, openAIError("no_healthy_supplier", `combo ${JSON.stringify(req.model)}: all models unavailable`));
|
|
258
1526
|
return;
|
|
259
1527
|
}
|
|
260
|
-
|
|
1528
|
+
const slash = req.model.indexOf("/");
|
|
1529
|
+
const alias = slash > 0 ? req.model.slice(0, slash) : "";
|
|
1530
|
+
const target = alias === "" ? void 0 : this.supplierByAlias(alias);
|
|
1531
|
+
if (target !== void 0) {
|
|
1532
|
+
if (await this.chatWithTarget(target, req, res, probe)) {
|
|
1533
|
+
settle(true);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
settle(false, `supplier ${target.id}: all accounts unavailable`);
|
|
1537
|
+
writeJson$2(res, 503, openAIError("no_healthy_supplier", `supplier ${JSON.stringify(target.id)}: all accounts unavailable`));
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
if (await this.chatWithModel(req, res, req.model, probe)) {
|
|
1541
|
+
settle(true);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
settle(false, "all suppliers unavailable");
|
|
261
1545
|
writeJson$2(res, 503, openAIError("no_healthy_supplier", "all suppliers unavailable"));
|
|
262
1546
|
}
|
|
263
|
-
/**
|
|
264
|
-
|
|
1547
|
+
/** 调指定供应商。
|
|
1548
|
+
* 模型名**原样**传给插件(不在这剥 alias/)——插件自己认得自己的别名
|
|
1549
|
+
* (如 traework 会剥第一段)。核心在这剥就会剥两次,把模型 id 里的
|
|
1550
|
+
* 命名空间(如 org/name)吃掉。 */
|
|
1551
|
+
async chatWithTarget(s, req, res, probe) {
|
|
1552
|
+
return await this.chatWithModel(req, res, `${s.id},${req.model}`, probe);
|
|
1553
|
+
}
|
|
1554
|
+
/** 把 model 改写为组合选中的模型名后,找对应的供应商 + 账号(策略全在核心)。
|
|
1555
|
+
*
|
|
1556
|
+
* `model` 形如 `supplierId,modelId`(组合的存储格式):直接用 supplierId 定位,
|
|
1557
|
+
* 不再挨个问供应商「这是不是你的模型」——精准调用,也避免同名模型串台。
|
|
1558
|
+
* 兼容旧的裸 `modelId`(没有逗号):降级为遍历,行为同以前。
|
|
1559
|
+
*/
|
|
1560
|
+
async chatWithModel(req, res, model, probe) {
|
|
1561
|
+
const comma = model.indexOf(",");
|
|
1562
|
+
const supplierId = comma > 0 ? model.slice(0, comma) : void 0;
|
|
1563
|
+
const modelId = comma > 0 ? model.slice(comma + 1) : model;
|
|
265
1564
|
const clone = {
|
|
266
1565
|
...req,
|
|
267
|
-
model
|
|
1566
|
+
model: modelId
|
|
268
1567
|
};
|
|
269
1568
|
try {
|
|
270
1569
|
const obj = JSON.parse(req.rawBody);
|
|
271
|
-
obj.model =
|
|
1570
|
+
obj.model = modelId;
|
|
272
1571
|
clone.rawBody = JSON.stringify(obj);
|
|
273
1572
|
} catch {
|
|
274
1573
|
clone.rawBody = req.rawBody;
|
|
275
1574
|
}
|
|
276
|
-
|
|
1575
|
+
if (supplierId !== void 0) {
|
|
1576
|
+
const s = this.suppliers.find((x) => x.id === supplierId);
|
|
1577
|
+
if (s === void 0) {
|
|
1578
|
+
this.logChat(req.model, `supplier ${JSON.stringify(supplierId)} 不存在`, 0);
|
|
1579
|
+
return false;
|
|
1580
|
+
}
|
|
1581
|
+
const t0 = Date.now();
|
|
1582
|
+
const trace = { attempts: 0 };
|
|
1583
|
+
const served = await this.chatWithSupplier(s, clone, res, trace, probe);
|
|
1584
|
+
this.logChat(req.model, served ? `${s.id}/${modelId} (${trace.uid ?? "?"}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ""}` : `${s.id}/${modelId} 失败 ${trace.lastError ?? "no account"}`, Date.now() - t0);
|
|
1585
|
+
return served;
|
|
1586
|
+
}
|
|
1587
|
+
for (const s of this.suppliers) if (await this.chatWithSupplier(s, clone, res, void 0, probe)) return true;
|
|
277
1588
|
return false;
|
|
278
1589
|
}
|
|
1590
|
+
/** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
|
|
1591
|
+
supplierByAlias(alias) {
|
|
1592
|
+
return this.suppliers.find((s) => s.getAlias() === alias);
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
|
|
1596
|
+
* AccountState 处置 → 失败换号;写响应只在成功后发生。
|
|
1597
|
+
*
|
|
1598
|
+
* 流式**推迟到第一个字节才写响应头**,所以「上游刚连上就断」这种情况
|
|
1599
|
+
* 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
|
|
1600
|
+
* 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
|
|
1601
|
+
* 延迟,目前未做。
|
|
1602
|
+
*/
|
|
1603
|
+
async chatWithSupplier(s, req, res, trace, probe) {
|
|
1604
|
+
const pool = s.pool;
|
|
1605
|
+
const cfg = this.store.get(s.id);
|
|
1606
|
+
if (s.accounts !== void 0 && s.accounts().length === 0) {
|
|
1607
|
+
const r = await s.chatOnce("", req);
|
|
1608
|
+
if (!r.ok) {
|
|
1609
|
+
if (r.state === "no_such_model") return false;
|
|
1610
|
+
pool.noteFailure("", r.state, r.message);
|
|
1611
|
+
return false;
|
|
1612
|
+
}
|
|
1613
|
+
if (trace !== void 0) trace.uid = "(no-account)";
|
|
1614
|
+
if (probe !== void 0) {
|
|
1615
|
+
probe.supplier = s.id;
|
|
1616
|
+
probe.model = `${s.getAlias()}/${req.model}`;
|
|
1617
|
+
probe.uid = "";
|
|
1618
|
+
}
|
|
1619
|
+
return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0);
|
|
1620
|
+
}
|
|
1621
|
+
const tried = /* @__PURE__ */ new Set();
|
|
1622
|
+
for (;;) {
|
|
1623
|
+
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy);
|
|
1624
|
+
if (uid === void 0) return false;
|
|
1625
|
+
tried.add(uid);
|
|
1626
|
+
const r = await s.chatOnce(uid, req);
|
|
1627
|
+
if (!r.ok) {
|
|
1628
|
+
if (r.state === "no_such_model") {
|
|
1629
|
+
if (trace !== void 0) trace.lastError = "no_such_model";
|
|
1630
|
+
return false;
|
|
1631
|
+
}
|
|
1632
|
+
pool.noteFailure(uid, r.state, r.message);
|
|
1633
|
+
if (trace !== void 0) {
|
|
1634
|
+
trace.attempts += 1;
|
|
1635
|
+
trace.lastError = `${r.state}: ${r.message}`;
|
|
1636
|
+
}
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
pool.noteSuccess(uid);
|
|
1640
|
+
if (trace !== void 0) trace.uid = uid;
|
|
1641
|
+
if (probe !== void 0) {
|
|
1642
|
+
probe.supplier = s.id;
|
|
1643
|
+
probe.model = `${s.getAlias()}/${req.model}`;
|
|
1644
|
+
probe.uid = uid;
|
|
1645
|
+
}
|
|
1646
|
+
if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) return true;
|
|
1647
|
+
pool.noteFailure(uid, "transport", "stream failed before first byte");
|
|
1648
|
+
if (trace !== void 0) {
|
|
1649
|
+
trace.attempts += 1;
|
|
1650
|
+
trace.lastError = "stream failed before first byte";
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
/** 测试某供应商的某模型是否可用。
|
|
1655
|
+
* 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
|
|
1656
|
+
* 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。 */
|
|
1657
|
+
async testModel(supplierId, model) {
|
|
1658
|
+
const s = this.suppliers.find((x) => x.id === supplierId);
|
|
1659
|
+
if (s === void 0) return {
|
|
1660
|
+
ok: false,
|
|
1661
|
+
error: `unknown supplier ${JSON.stringify(supplierId)}`
|
|
1662
|
+
};
|
|
1663
|
+
const sink = sinkRes();
|
|
1664
|
+
const req = {
|
|
1665
|
+
model,
|
|
1666
|
+
stream: false,
|
|
1667
|
+
rawBody: JSON.stringify({
|
|
1668
|
+
model,
|
|
1669
|
+
messages: [{
|
|
1670
|
+
role: "user",
|
|
1671
|
+
content: "ping"
|
|
1672
|
+
}],
|
|
1673
|
+
stream: false,
|
|
1674
|
+
max_tokens: 1
|
|
1675
|
+
})
|
|
1676
|
+
};
|
|
1677
|
+
let served = false;
|
|
1678
|
+
try {
|
|
1679
|
+
served = await this.chatWithSupplier(s, req, sink);
|
|
1680
|
+
} catch (err) {
|
|
1681
|
+
return {
|
|
1682
|
+
ok: false,
|
|
1683
|
+
error: err.message
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
if (served && sink.status() < 400) return { ok: true };
|
|
1687
|
+
const fromSink = served && sink.status() >= 400 ? extractUpstreamError(sink.body()) : "";
|
|
1688
|
+
const detail = fromSink !== "" ? fromSink : s.lastError?.();
|
|
1689
|
+
return {
|
|
1690
|
+
ok: false,
|
|
1691
|
+
error: detail !== void 0 && detail !== "" ? `${detail}(账号/额度/限流问题,非模型问题)` : "所有账号都失败或账号都在冷却中(稍后重试)"
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
279
1694
|
dispose() {
|
|
280
1695
|
for (const s of this.suppliers) s.dispose();
|
|
281
1696
|
this.suppliers = [];
|
|
1697
|
+
this.usage.flush();
|
|
282
1698
|
}
|
|
283
1699
|
};
|
|
284
1700
|
//#endregion
|
|
@@ -378,15 +1794,16 @@ function wireRequest(options) {
|
|
|
378
1794
|
function flattenText(blocks) {
|
|
379
1795
|
return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
380
1796
|
}
|
|
381
|
-
/** openai SSE payload → DSH StreamChunk
|
|
382
|
-
async function*
|
|
1797
|
+
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
1798
|
+
async function* translateSse(payloads) {
|
|
383
1799
|
let nextIndex = 0;
|
|
384
1800
|
let textBlock;
|
|
385
1801
|
let reasoningBlock;
|
|
386
1802
|
const toolBlocks = /* @__PURE__ */ new Map();
|
|
387
1803
|
const order = [];
|
|
388
1804
|
let pendingFinish;
|
|
389
|
-
|
|
1805
|
+
/** 攒上游各帧的 usage(归一形态),[DONE] 时统一转成 DSH 契约。 */
|
|
1806
|
+
let accumulated = null;
|
|
390
1807
|
const open = (kind) => {
|
|
391
1808
|
const block = {
|
|
392
1809
|
index: nextIndex++,
|
|
@@ -403,10 +1820,13 @@ async function* translate(payloads) {
|
|
|
403
1820
|
index: block.index,
|
|
404
1821
|
block: closeBlock(block)
|
|
405
1822
|
};
|
|
406
|
-
if (
|
|
407
|
-
|
|
408
|
-
usage
|
|
409
|
-
|
|
1823
|
+
if (accumulated !== null) {
|
|
1824
|
+
const usage = toTokenUsage(accumulated);
|
|
1825
|
+
if (usage !== null) yield {
|
|
1826
|
+
type: "usage",
|
|
1827
|
+
usage
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
410
1830
|
const reason = pendingFinish ?? { kind: "stop" };
|
|
411
1831
|
yield {
|
|
412
1832
|
type: "finish",
|
|
@@ -489,7 +1909,7 @@ async function* translate(payloads) {
|
|
|
489
1909
|
}
|
|
490
1910
|
if (typeof choice.finish_reason === "string") pendingFinish = mapFinishReason(choice.finish_reason);
|
|
491
1911
|
}
|
|
492
|
-
if (chunk.usage !== void 0)
|
|
1912
|
+
if (chunk.usage !== void 0) accumulated = mergeUsage(accumulated, normalizeUsage(chunk.usage));
|
|
493
1913
|
}
|
|
494
1914
|
throw new LlmError("SSE payload stream ended without [DONE]", "STREAM_CLOSED");
|
|
495
1915
|
}
|
|
@@ -583,7 +2003,7 @@ var RouterAdapter = class extends LlmAdapter {
|
|
|
583
2003
|
const detail = await resp.text().catch(() => "");
|
|
584
2004
|
throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, "TRANSPORT");
|
|
585
2005
|
}
|
|
586
|
-
yield*
|
|
2006
|
+
yield* translateSse(parseSseText(resp.body === null ? "" : await resp.text()));
|
|
587
2007
|
}
|
|
588
2008
|
};
|
|
589
2009
|
//#endregion
|
|
@@ -720,7 +2140,6 @@ function resolveModule(mod, file) {
|
|
|
720
2140
|
if (typeof cand !== "object" || cand === null) throw new Error(`供应商模块无效: ${file}`);
|
|
721
2141
|
if (typeof cand.id !== "string" || cand.id === "") throw new Error(`供应商缺少 id: ${file}`);
|
|
722
2142
|
if (typeof cand.name !== "string" || cand.name === "") throw new Error(`供应商缺少 name: ${file}`);
|
|
723
|
-
if (typeof cand.testModel !== "function") throw new Error(`供应商 ${cand.id} 缺少 testModel(必要差异化能力): ${file}`);
|
|
724
2143
|
return cand;
|
|
725
2144
|
}
|
|
726
2145
|
/** 加载单个供应商文件,返回模块对象或抛错。 */
|
|
@@ -729,7 +2148,7 @@ async function importSupplierFile(file) {
|
|
|
729
2148
|
}
|
|
730
2149
|
/**
|
|
731
2150
|
* 差异化能力(供应商 js 实现;其余为通用能力由核心提供)。
|
|
732
|
-
* testModel
|
|
2151
|
+
* testModel / 连接池 / 模型管理 / 别名 / 凭证存储均为核心通用能力,不在此列。
|
|
733
2152
|
*/
|
|
734
2153
|
const DIFF_CAPS = [
|
|
735
2154
|
"generateLoginUrl",
|
|
@@ -745,7 +2164,6 @@ async function loadOne(file, env, source) {
|
|
|
745
2164
|
if (typeof mod === "function") {
|
|
746
2165
|
instance = mod(env);
|
|
747
2166
|
if (typeof instance !== "object" || instance === null || typeof instance.id !== "string" || instance.id === "") throw new Error(`供应商 factory 返回无效: ${file}`);
|
|
748
|
-
if (typeof instance.testModel !== "function") throw new Error(`供应商 ${instance.id} 缺少 testModel(必要差异化能力): ${file}`);
|
|
749
2167
|
} else instance = mod;
|
|
750
2168
|
return wrapModule(instance, env, file, source);
|
|
751
2169
|
}
|
|
@@ -755,15 +2173,22 @@ async function loadOne(file, env, source) {
|
|
|
755
2173
|
*/
|
|
756
2174
|
function wrapModule(instance, env, source, kind = "external") {
|
|
757
2175
|
if (typeof instance !== "object" || instance === null || typeof instance.id !== "string" || instance.id === "") throw new Error(`供应商模块无效: ${source}`);
|
|
758
|
-
if (typeof instance.testModel !== "function") throw new Error(`供应商 ${instance.id} 缺少 testModel(必要差异化能力): ${source}`);
|
|
759
2176
|
const capabilities = /* @__PURE__ */ new Set();
|
|
760
2177
|
for (const key of DIFF_CAPS) if (isFn(instance[key])) capabilities.add(key);
|
|
2178
|
+
const pool = new AccountPool();
|
|
761
2179
|
const supplier = {
|
|
762
2180
|
id: instance.id,
|
|
763
2181
|
name: instance.name,
|
|
764
2182
|
priority: instance.priority ?? 0,
|
|
765
2183
|
icon: instance.icon,
|
|
766
|
-
status: () =>
|
|
2184
|
+
status: () => {
|
|
2185
|
+
const now = instance.status();
|
|
2186
|
+
return {
|
|
2187
|
+
id: now.id,
|
|
2188
|
+
name: now.name,
|
|
2189
|
+
accounts: pool.decorate(now.accounts)
|
|
2190
|
+
};
|
|
2191
|
+
},
|
|
767
2192
|
listModels: (force) => instance.listModels(force),
|
|
768
2193
|
modelsWithEnabled: (force) => {
|
|
769
2194
|
const cfg = env.store.get(instance.id);
|
|
@@ -775,12 +2200,14 @@ function wrapModule(instance, env, source, kind = "external") {
|
|
|
775
2200
|
custom: custom.has(mm.id) ? true : void 0
|
|
776
2201
|
})));
|
|
777
2202
|
},
|
|
778
|
-
getAlias: () => env.store.get(instance.id).alias || instance.
|
|
2203
|
+
getAlias: () => env.store.get(instance.id).alias || instance.id,
|
|
779
2204
|
customModelIds: () => [...env.store.get(instance.id).custom],
|
|
780
|
-
|
|
781
|
-
|
|
2205
|
+
accounts: () => instance.status().accounts,
|
|
2206
|
+
chatOnce: (uid, req) => instance.chatOnce(uid, req),
|
|
2207
|
+
lastError: isFn(instance.lastError) ? () => instance.lastError() : void 0,
|
|
782
2208
|
removeLink: isFn(instance.removeLink) ? (uid) => instance.removeLink(uid) : void 0,
|
|
783
|
-
dispose: () => instance.dispose()
|
|
2209
|
+
dispose: () => instance.dispose(),
|
|
2210
|
+
pool
|
|
784
2211
|
};
|
|
785
2212
|
supplier.__module = instance;
|
|
786
2213
|
return {
|
|
@@ -862,41 +2289,36 @@ async function readBody$1(req, limit = 65536) {
|
|
|
862
2289
|
function cap(set, key) {
|
|
863
2290
|
return set.has(key);
|
|
864
2291
|
}
|
|
865
|
-
/**
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
});
|
|
2292
|
+
/** 账号状态指纹(积分 + 健康),用于判断刷新是否落地。 */
|
|
2293
|
+
function fingerprint(accounts) {
|
|
2294
|
+
return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}:${a.disabled}`).join("|");
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* 反复调插件已有的 status(),直到快照稳定再返回(是否有变化 = changed)。
|
|
2298
|
+
* 插件的积分刷新是 fire-and-forget(status() 内部异步拉),核心拿不到句柄,
|
|
2299
|
+
* 只能按指纹变化等它落地。天花板:缓存未过期时插件不会真拉上游,此时按钮
|
|
2300
|
+
* 退化为「重读一次状态」(冷却/禁用这类健康状态仍是 status() 实时算的)。
|
|
2301
|
+
* 升级路径:插件暴露可 await 的刷新能力,这里就不用轮询了。
|
|
2302
|
+
*/
|
|
2303
|
+
async function settleStatus(s, timeoutMs = 3e3) {
|
|
2304
|
+
const first = fingerprint(s.status().accounts);
|
|
2305
|
+
const deadline = Date.now() + timeoutMs;
|
|
2306
|
+
let last = first;
|
|
2307
|
+
let stable = 0;
|
|
2308
|
+
while (Date.now() < deadline) {
|
|
2309
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2310
|
+
const now = fingerprint(s.status().accounts);
|
|
2311
|
+
stable = now === last ? stable + 1 : 0;
|
|
2312
|
+
last = now;
|
|
2313
|
+
if (stable >= 2) break;
|
|
888
2314
|
}
|
|
889
|
-
|
|
890
|
-
models,
|
|
891
|
-
fetchedAt: Date.now()
|
|
892
|
-
});
|
|
893
|
-
return models;
|
|
2315
|
+
return last !== first;
|
|
894
2316
|
}
|
|
895
2317
|
function mod(s) {
|
|
896
2318
|
return s.supplier.__module ?? {};
|
|
897
2319
|
}
|
|
898
2320
|
/** 为一个供应商生成端点(通用 + 差异化,返回 route 列表)。 */
|
|
899
|
-
function supplierRoutes(base, loaded, store) {
|
|
2321
|
+
function supplierRoutes(base, loaded, store, router) {
|
|
900
2322
|
const s = loaded.supplier;
|
|
901
2323
|
const m = mod(loaded);
|
|
902
2324
|
const c = loaded.capabilities;
|
|
@@ -906,7 +2328,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
906
2328
|
kind: "exact",
|
|
907
2329
|
path: `${p}/models`,
|
|
908
2330
|
handler: async (_req, res) => {
|
|
909
|
-
const models = await
|
|
2331
|
+
const models = await router.modelsOf(s.id);
|
|
910
2332
|
writeJson$1(res, 200, {
|
|
911
2333
|
ok: true,
|
|
912
2334
|
alias: s.getAlias(),
|
|
@@ -919,14 +2341,21 @@ function supplierRoutes(base, loaded, store) {
|
|
|
919
2341
|
path: `${p}/alias`,
|
|
920
2342
|
handler: async (req, res) => {
|
|
921
2343
|
const clean = (JSON.parse(await readBody$1(req)).alias ?? "").trim();
|
|
922
|
-
if (clean
|
|
2344
|
+
if (clean !== "" && !/^[A-Za-z0-9_-]+$/.test(clean)) {
|
|
923
2345
|
writeJson$1(res, 400, {
|
|
924
2346
|
ok: false,
|
|
925
2347
|
error: "前缀只能包含字母、数字、- 和 _"
|
|
926
2348
|
});
|
|
927
2349
|
return;
|
|
928
2350
|
}
|
|
929
|
-
store.setAlias(s.id, clean);
|
|
2351
|
+
const r = store.setAlias(s.id, clean);
|
|
2352
|
+
if (!r.ok) {
|
|
2353
|
+
writeJson$1(res, 400, {
|
|
2354
|
+
ok: false,
|
|
2355
|
+
error: `前缀已被供应商 ${JSON.stringify(r.conflictWith ?? "?")} 占用`
|
|
2356
|
+
});
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
930
2359
|
writeJson$1(res, 200, { ok: true });
|
|
931
2360
|
}
|
|
932
2361
|
});
|
|
@@ -935,8 +2364,8 @@ function supplierRoutes(base, loaded, store) {
|
|
|
935
2364
|
path: `${p}/models/toggle`,
|
|
936
2365
|
handler: async (req, res) => {
|
|
937
2366
|
const body = JSON.parse(await readBody$1(req));
|
|
938
|
-
|
|
939
|
-
const models = await
|
|
2367
|
+
store.get(s.id);
|
|
2368
|
+
const models = await router.modelsOf(s.id);
|
|
940
2369
|
if (!body.id || !models.some((mm) => mm.id === body.id)) {
|
|
941
2370
|
writeJson$1(res, 400, {
|
|
942
2371
|
ok: false,
|
|
@@ -945,7 +2374,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
945
2374
|
return;
|
|
946
2375
|
}
|
|
947
2376
|
store.setModelEnabled(s.id, body.id, !!body.enabled);
|
|
948
|
-
|
|
2377
|
+
router.invalidateModels(s.id);
|
|
949
2378
|
writeJson$1(res, 200, { ok: true });
|
|
950
2379
|
}
|
|
951
2380
|
});
|
|
@@ -961,7 +2390,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
961
2390
|
});
|
|
962
2391
|
return;
|
|
963
2392
|
}
|
|
964
|
-
if ((await
|
|
2393
|
+
if ((await router.modelsOf(s.id)).some((mm) => mm.id === id)) {
|
|
965
2394
|
writeJson$1(res, 400, {
|
|
966
2395
|
ok: false,
|
|
967
2396
|
error: `模型 ${id} 已存在`
|
|
@@ -969,7 +2398,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
969
2398
|
return;
|
|
970
2399
|
}
|
|
971
2400
|
store.addCustomModel(s.id, id);
|
|
972
|
-
|
|
2401
|
+
router.invalidateModels(s.id);
|
|
973
2402
|
writeJson$1(res, 200, { ok: true });
|
|
974
2403
|
}
|
|
975
2404
|
});
|
|
@@ -987,7 +2416,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
987
2416
|
return;
|
|
988
2417
|
}
|
|
989
2418
|
store.removeCustomModel(s.id, body.id);
|
|
990
|
-
|
|
2419
|
+
router.invalidateModels(s.id);
|
|
991
2420
|
writeJson$1(res, 200, { ok: true });
|
|
992
2421
|
}
|
|
993
2422
|
});
|
|
@@ -996,9 +2425,9 @@ function supplierRoutes(base, loaded, store) {
|
|
|
996
2425
|
path: `${p}/models/bulk`,
|
|
997
2426
|
handler: async (req, res) => {
|
|
998
2427
|
const body = JSON.parse(await readBody$1(req));
|
|
999
|
-
const models = await
|
|
2428
|
+
const models = await router.modelsOf(s.id);
|
|
1000
2429
|
store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id));
|
|
1001
|
-
|
|
2430
|
+
router.invalidateModels(s.id);
|
|
1002
2431
|
writeJson$1(res, 200, { ok: true });
|
|
1003
2432
|
}
|
|
1004
2433
|
});
|
|
@@ -1054,7 +2483,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
1054
2483
|
handler: async (_req, res) => {
|
|
1055
2484
|
writeJson$1(res, 200, {
|
|
1056
2485
|
ok: true,
|
|
1057
|
-
models: await
|
|
2486
|
+
models: await router.modelsOf(s.id, true)
|
|
1058
2487
|
});
|
|
1059
2488
|
}
|
|
1060
2489
|
});
|
|
@@ -1063,7 +2492,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
1063
2492
|
path: `${p}/models/test`,
|
|
1064
2493
|
handler: async (req, res) => {
|
|
1065
2494
|
const body = JSON.parse(await readBody$1(req));
|
|
1066
|
-
const result = await
|
|
2495
|
+
const result = await router.testModel(s.id, body.id ?? "");
|
|
1067
2496
|
writeJson$1(res, result.ok ? 200 : 400, result);
|
|
1068
2497
|
}
|
|
1069
2498
|
});
|
|
@@ -1158,159 +2587,64 @@ function supplierRoutes(base, loaded, store) {
|
|
|
1158
2587
|
});
|
|
1159
2588
|
routes.push({
|
|
1160
2589
|
kind: "exact",
|
|
1161
|
-
path: `${p}/
|
|
1162
|
-
handler: async (
|
|
1163
|
-
|
|
2590
|
+
path: `${p}/links/refresh`,
|
|
2591
|
+
handler: async (_req, res) => {
|
|
2592
|
+
try {
|
|
1164
2593
|
writeJson$1(res, 200, {
|
|
1165
2594
|
ok: true,
|
|
1166
|
-
|
|
2595
|
+
changed: await settleStatus(s),
|
|
2596
|
+
accounts: s.status().accounts
|
|
1167
2597
|
});
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
const body = JSON.parse(await readBody$1(req));
|
|
1171
|
-
if (body.rule !== "all" && body.rule !== "first") {
|
|
1172
|
-
writeJson$1(res, 400, {
|
|
2598
|
+
} catch (err) {
|
|
2599
|
+
writeJson$1(res, 500, {
|
|
1173
2600
|
ok: false,
|
|
1174
|
-
error:
|
|
2601
|
+
error: err.message
|
|
1175
2602
|
});
|
|
1176
|
-
return;
|
|
1177
2603
|
}
|
|
1178
|
-
store.setCheckinRule(s.id, body.rule);
|
|
1179
|
-
writeJson$1(res, 200, { ok: true });
|
|
1180
2604
|
}
|
|
1181
2605
|
});
|
|
1182
2606
|
if (cap(c, "checkinNow")) routes.push({
|
|
1183
2607
|
kind: "exact",
|
|
1184
2608
|
path: `${p}/checkin`,
|
|
1185
2609
|
handler: async (_req, res) => {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
writeJson$1(res,
|
|
2610
|
+
const checkinOne = m.checkinNow;
|
|
2611
|
+
if (!checkinOne) {
|
|
2612
|
+
writeJson$1(res, 400, {
|
|
2613
|
+
ok: false,
|
|
2614
|
+
error: "该供应商不支持签到"
|
|
2615
|
+
});
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
const uids = s.status().accounts.map((a) => a.uid);
|
|
2619
|
+
const results = [];
|
|
2620
|
+
for (const uid of uids) try {
|
|
2621
|
+
results.push({
|
|
2622
|
+
uid,
|
|
2623
|
+
...await checkinOne.call(loaded.supplier, uid)
|
|
2624
|
+
});
|
|
1189
2625
|
} catch (err) {
|
|
1190
|
-
|
|
2626
|
+
results.push({
|
|
2627
|
+
uid,
|
|
1191
2628
|
ok: false,
|
|
1192
|
-
|
|
2629
|
+
status: "error",
|
|
2630
|
+
message: err.message
|
|
1193
2631
|
});
|
|
1194
2632
|
}
|
|
2633
|
+
const succeeded = results.filter((r) => r.status === "ok").length;
|
|
2634
|
+
const already = results.filter((r) => r.status === "already").length;
|
|
2635
|
+
const payload = {
|
|
2636
|
+
ok: succeeded + already > 0,
|
|
2637
|
+
total: uids.length,
|
|
2638
|
+
succeeded,
|
|
2639
|
+
already,
|
|
2640
|
+
results
|
|
2641
|
+
};
|
|
2642
|
+
writeJson$1(res, payload.ok ? 200 : 400, payload);
|
|
1195
2643
|
}
|
|
1196
2644
|
});
|
|
1197
2645
|
return routes;
|
|
1198
2646
|
}
|
|
1199
2647
|
//#endregion
|
|
1200
|
-
//#region src/supplier-config.ts
|
|
1201
|
-
/**
|
|
1202
|
-
* 通用供应商配置 —— dsh-router 核心能力,按供应商 id 存储。
|
|
1203
|
-
*
|
|
1204
|
-
* 通用能力(所有供应商共享,供应商 js 不需要实现):
|
|
1205
|
-
* - 别名 alias(前缀,模型全名 = alias/id)
|
|
1206
|
-
* - 模型管理:启用/禁用 disabled、自定义 custom
|
|
1207
|
-
* - 连接池:poolOrder(拖动排序)、poolStrategy(回退/轮询)
|
|
1208
|
-
* - 签到规则 checkinRule(所有链接 / 首个链接)
|
|
1209
|
-
*
|
|
1210
|
-
* 持久化到 data/supplier-config.json。
|
|
1211
|
-
*/
|
|
1212
|
-
/** 通用供应商配置存储。 */
|
|
1213
|
-
var SupplierConfigStore = class {
|
|
1214
|
-
fp = "";
|
|
1215
|
-
bySupplier = /* @__PURE__ */ new Map();
|
|
1216
|
-
constructor(stateFile) {
|
|
1217
|
-
this.fp = stateFile ? join(dirname(stateFile), "supplier-config.json") : "";
|
|
1218
|
-
this.load();
|
|
1219
|
-
}
|
|
1220
|
-
/** 读取某供应商配置(不存在则返回默认并注册)。 */
|
|
1221
|
-
get(supplierId) {
|
|
1222
|
-
let cfg = this.bySupplier.get(supplierId);
|
|
1223
|
-
if (!cfg) {
|
|
1224
|
-
cfg = {
|
|
1225
|
-
alias: "",
|
|
1226
|
-
disabled: [],
|
|
1227
|
-
custom: [],
|
|
1228
|
-
poolOrder: [],
|
|
1229
|
-
poolStrategy: "fallback",
|
|
1230
|
-
checkinRule: "all"
|
|
1231
|
-
};
|
|
1232
|
-
this.bySupplier.set(supplierId, cfg);
|
|
1233
|
-
}
|
|
1234
|
-
return cfg;
|
|
1235
|
-
}
|
|
1236
|
-
setAlias(supplierId, alias) {
|
|
1237
|
-
this.get(supplierId).alias = alias;
|
|
1238
|
-
this.saveLocked();
|
|
1239
|
-
}
|
|
1240
|
-
setModelEnabled(supplierId, modelId, enabled) {
|
|
1241
|
-
const cfg = this.get(supplierId);
|
|
1242
|
-
cfg.disabled = enabled ? cfg.disabled.filter((m) => m !== modelId) : [.../* @__PURE__ */ new Set([...cfg.disabled, modelId])];
|
|
1243
|
-
this.saveLocked();
|
|
1244
|
-
}
|
|
1245
|
-
setAllModelsEnabled(supplierId, enabled, allModelIds) {
|
|
1246
|
-
const cfg = this.get(supplierId);
|
|
1247
|
-
cfg.disabled = enabled ? [] : [...new Set(allModelIds)];
|
|
1248
|
-
this.saveLocked();
|
|
1249
|
-
}
|
|
1250
|
-
addCustomModel(supplierId, modelId) {
|
|
1251
|
-
const cfg = this.get(supplierId);
|
|
1252
|
-
if (!cfg.custom.includes(modelId)) cfg.custom.push(modelId);
|
|
1253
|
-
this.saveLocked();
|
|
1254
|
-
}
|
|
1255
|
-
removeCustomModel(supplierId, modelId) {
|
|
1256
|
-
const cfg = this.get(supplierId);
|
|
1257
|
-
cfg.custom = cfg.custom.filter((m) => m !== modelId);
|
|
1258
|
-
cfg.disabled = cfg.disabled.filter((m) => m !== modelId);
|
|
1259
|
-
this.saveLocked();
|
|
1260
|
-
}
|
|
1261
|
-
setPoolOrder(supplierId, uids) {
|
|
1262
|
-
this.get(supplierId).poolOrder = [...new Set(uids)];
|
|
1263
|
-
this.saveLocked();
|
|
1264
|
-
}
|
|
1265
|
-
setPoolStrategy(supplierId, strategy) {
|
|
1266
|
-
if (strategy !== "fallback" && strategy !== "round-robin") return;
|
|
1267
|
-
this.get(supplierId).poolStrategy = strategy;
|
|
1268
|
-
this.saveLocked();
|
|
1269
|
-
}
|
|
1270
|
-
setCheckinRule(supplierId, rule) {
|
|
1271
|
-
if (rule !== "all" && rule !== "first") return;
|
|
1272
|
-
this.get(supplierId).checkinRule = rule;
|
|
1273
|
-
this.saveLocked();
|
|
1274
|
-
}
|
|
1275
|
-
load() {
|
|
1276
|
-
if (this.fp === "") return;
|
|
1277
|
-
let f;
|
|
1278
|
-
try {
|
|
1279
|
-
f = JSON.parse(readFileSync(this.fp, "utf8"));
|
|
1280
|
-
} catch {
|
|
1281
|
-
return;
|
|
1282
|
-
}
|
|
1283
|
-
for (const [id, c] of Object.entries(f.suppliers ?? {})) this.bySupplier.set(id, {
|
|
1284
|
-
alias: typeof c.alias === "string" ? c.alias : "",
|
|
1285
|
-
disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === "string") : [],
|
|
1286
|
-
custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === "string") : [],
|
|
1287
|
-
poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === "string") : [],
|
|
1288
|
-
poolStrategy: c.poolStrategy === "round-robin" ? "round-robin" : "fallback",
|
|
1289
|
-
checkinRule: c.checkinRule === "first" ? "first" : "all"
|
|
1290
|
-
});
|
|
1291
|
-
}
|
|
1292
|
-
saveLocked() {
|
|
1293
|
-
if (this.fp === "") return;
|
|
1294
|
-
const f = { suppliers: {} };
|
|
1295
|
-
for (const [id, cfg] of this.bySupplier) f.suppliers[id] = {
|
|
1296
|
-
alias: cfg.alias,
|
|
1297
|
-
disabled: [...cfg.disabled],
|
|
1298
|
-
custom: [...cfg.custom],
|
|
1299
|
-
poolOrder: [...cfg.poolOrder],
|
|
1300
|
-
poolStrategy: cfg.poolStrategy,
|
|
1301
|
-
checkinRule: cfg.checkinRule
|
|
1302
|
-
};
|
|
1303
|
-
try {
|
|
1304
|
-
const dir = dirname(this.fp);
|
|
1305
|
-
if (dir !== "" && dir !== ".") mkdirSync(dir, { recursive: true });
|
|
1306
|
-
const raw = JSON.stringify(f, null, 2);
|
|
1307
|
-
const tmp = this.fp + ".tmp";
|
|
1308
|
-
writeFileSync(tmp, raw, { mode: 384 });
|
|
1309
|
-
renameSync(tmp, this.fp);
|
|
1310
|
-
} catch {}
|
|
1311
|
-
}
|
|
1312
|
-
};
|
|
1313
|
-
//#endregion
|
|
1314
2648
|
//#region src/credential-store.ts
|
|
1315
2649
|
/**
|
|
1316
2650
|
* 通用凭证存储 —— dsh-router 核心统一管账号凭证(SQLite,参考 9router 的
|
|
@@ -1399,7 +2733,7 @@ function apply(rawContext) {
|
|
|
1399
2733
|
const stateFile = process.env.TW2A_STATE_FILE ?? "data/state.json";
|
|
1400
2734
|
const store = new SupplierConfigStore(stateFile);
|
|
1401
2735
|
const credentials = new CredentialStore(process.env.TW2A_AUTH_DIR ?? join(dirname(stateFile), "auths"));
|
|
1402
|
-
const router = new Router(stateFile);
|
|
2736
|
+
const router = new Router(stateFile, store, log);
|
|
1403
2737
|
const keys = new KeysStore(stateFile);
|
|
1404
2738
|
const disposers = [];
|
|
1405
2739
|
const route = (path, handler) => {
|
|
@@ -1414,7 +2748,7 @@ function apply(rawContext) {
|
|
|
1414
2748
|
const supplierDisposers = /* @__PURE__ */ new Map();
|
|
1415
2749
|
const registerSupplierRoutes = (loaded) => {
|
|
1416
2750
|
const dis = [];
|
|
1417
|
-
for (const r of supplierRoutes(ROUTER_API_BASE, loaded, store)) dis.push(ctx.webServer.register(r));
|
|
2751
|
+
for (const r of supplierRoutes(ROUTER_API_BASE, loaded, store, router)) dis.push(ctx.webServer.register(r));
|
|
1418
2752
|
supplierDisposers.set(loaded.supplier.id, dis);
|
|
1419
2753
|
log(`supplier routes registered: ${loaded.supplier.id}`);
|
|
1420
2754
|
};
|
|
@@ -1708,6 +3042,51 @@ function apply(rawContext) {
|
|
|
1708
3042
|
requireApiKey: keys.requireApiKey
|
|
1709
3043
|
});
|
|
1710
3044
|
});
|
|
3045
|
+
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
3046
|
+
const periodOf = (raw) => {
|
|
3047
|
+
return raw === "24h" || raw === "7d" || raw === "30d" ? raw : "today";
|
|
3048
|
+
};
|
|
3049
|
+
route(`${ROUTER_API_BASE}/stats`, (req, res) => {
|
|
3050
|
+
const period = periodOf(new URL(req.url ?? "/", "http://localhost").searchParams.get("period"));
|
|
3051
|
+
const s = router.usage.stats(period);
|
|
3052
|
+
writeJson(res, 200, {
|
|
3053
|
+
ok: true,
|
|
3054
|
+
period,
|
|
3055
|
+
requests: s.requests,
|
|
3056
|
+
okCount: s.ok,
|
|
3057
|
+
failed: s.failed,
|
|
3058
|
+
promptTokens: s.promptTokens,
|
|
3059
|
+
completionTokens: s.completionTokens,
|
|
3060
|
+
cachedTokens: s.cachedTokens,
|
|
3061
|
+
avgDurationMs: s.avgDurationMs,
|
|
3062
|
+
avgTtfbMs: s.avgTtfbMs,
|
|
3063
|
+
estimatedInputs: s.estimatedInputs,
|
|
3064
|
+
estimatedOutputs: s.estimatedOutputs,
|
|
3065
|
+
lifetime: s.lifetime,
|
|
3066
|
+
bySupplier: s.bySupplier,
|
|
3067
|
+
byModel: s.byModel,
|
|
3068
|
+
byRequested: s.byRequested,
|
|
3069
|
+
recent: router.usage.recentList(20)
|
|
3070
|
+
});
|
|
3071
|
+
});
|
|
3072
|
+
route(`${ROUTER_API_BASE}/stats/chart`, (req, res) => {
|
|
3073
|
+
const period = periodOf(new URL(req.url ?? "/", "http://localhost").searchParams.get("period"));
|
|
3074
|
+
writeJson(res, 200, {
|
|
3075
|
+
ok: true,
|
|
3076
|
+
chart: router.usage.chart(period)
|
|
3077
|
+
});
|
|
3078
|
+
});
|
|
3079
|
+
route(`${ROUTER_API_BASE}/stats/clear`, async (req, res) => {
|
|
3080
|
+
if (req.method !== "POST") {
|
|
3081
|
+
writeJson(res, 405, {
|
|
3082
|
+
ok: false,
|
|
3083
|
+
error: "method not allowed"
|
|
3084
|
+
});
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
router.usage.clear();
|
|
3088
|
+
writeJson(res, 200, { ok: true });
|
|
3089
|
+
});
|
|
1711
3090
|
if (ctx.llm !== void 0) {
|
|
1712
3091
|
const LLM_NS = "llm-dsh-router";
|
|
1713
3092
|
try {
|