dsh-router-core 0.3.2 → 0.3.4
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 +6 -2
- package/docs/suppliers.md +87 -30
- package/lib/client-registry.js +20 -11
- package/lib/client.js +20 -11
- package/lib/index.js +751 -264
- 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 +28 -5
- package/lib/types/router/types.d.ts +0 -2
- package/lib/types/router/usage-store.d.ts +20 -3
- package/lib/types/shared.d.ts +2 -0
- package/lib/types/supplier-config.d.ts +23 -0
- package/lib/types/suppliers/contract.d.ts +9 -6
- package/package.json +1 -1
- package/src/client/RouterView.tsx +1 -1
- package/src/client/SupplierDetail.tsx +15 -10
- 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 +225 -66
- package/src/router/models-cache.test.ts +224 -6
- package/src/router/precise-dispatch.test.ts +110 -7
- package/src/router/types.ts +0 -2
- package/src/router/usage-store.ts +174 -74
- package/src/router/usage.test.ts +115 -0
- package/src/shared.ts +2 -0
- package/src/supplier-config.test.ts +98 -0
- package/src/supplier-config.ts +60 -1
- package/src/suppliers/contract.ts +9 -9
- package/src/suppliers/loader.test.ts +118 -0
- package/src/suppliers/loader.ts +21 -5
- package/src/suppliers/nvidia/plugin.ts +12 -15
- package/src/suppliers/opencode/plugin.ts +10 -13
- package/src/suppliers/openrouter/plugin.ts +12 -15
- package/src/suppliers/registry.test.ts +23 -3
- package/src/suppliers/registry.ts +11 -3
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { basename, dirname, join } from "node:path";
|
|
2
2
|
import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, attributionHeaders } from "@deepseek-ai/dsh-llm";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { DatabaseSync } from "node:sqlite";
|
|
@@ -14,159 +14,250 @@ const API_KEY_REF = "TW2A_API_KEY";
|
|
|
14
14
|
const MINUTE = 6e4;
|
|
15
15
|
/**
|
|
16
16
|
* 状态 → 处置表。**核心策略就在这一张表里**,插件只报状态不做决策。
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
*
|
|
18
|
+
* 冷却策略对齐 9router 的 accountFallback / errorConfig:
|
|
19
|
+
* - `rate_limit` 用**指数退避**(9router 的 `backoff: true`):偶发限流只
|
|
20
|
+
* 短冷,持续限流越退越久,比固定 1 分钟两头不讨好要准。
|
|
21
|
+
* - 未知/瞬时错误用**瞬时短冷却**(9router 默认分支 TRANSIENT_COOLDOWN_MS):
|
|
22
|
+
* 每次失败都冻结,坏号不会留在池里被下一个请求再选中。
|
|
23
|
+
*
|
|
24
|
+
* 与 9router 的差别:9router 按错误文本/HTTP 状态匹配,这里按插件解读后的
|
|
25
|
+
* 语义状态匹配 —— 插件已经把 11133/11134 这类网关噪声归成 `rate_limit`,
|
|
26
|
+
* 核心不必再猜文本,更准。
|
|
19
27
|
*/
|
|
20
28
|
const RULES = {
|
|
21
29
|
ok: {
|
|
22
|
-
|
|
30
|
+
cooldown: 0,
|
|
23
31
|
disable: false,
|
|
24
32
|
counts: false
|
|
25
33
|
},
|
|
26
34
|
rate_limit: {
|
|
27
|
-
|
|
35
|
+
cooldown: "backoff",
|
|
28
36
|
disable: false,
|
|
29
37
|
counts: false
|
|
30
38
|
},
|
|
31
39
|
quota: {
|
|
32
|
-
|
|
40
|
+
cooldown: 10 * MINUTE,
|
|
33
41
|
disable: false,
|
|
34
42
|
counts: false
|
|
35
43
|
},
|
|
36
44
|
session_dead: {
|
|
37
|
-
|
|
45
|
+
cooldown: 0,
|
|
38
46
|
disable: true,
|
|
39
47
|
counts: false
|
|
40
48
|
},
|
|
41
49
|
unavailable: {
|
|
42
|
-
|
|
50
|
+
cooldown: "transient",
|
|
43
51
|
disable: false,
|
|
44
|
-
counts:
|
|
52
|
+
counts: false
|
|
45
53
|
},
|
|
46
54
|
transport: {
|
|
47
|
-
|
|
55
|
+
cooldown: "transient",
|
|
48
56
|
disable: false,
|
|
49
|
-
counts:
|
|
57
|
+
counts: false
|
|
50
58
|
},
|
|
51
59
|
unknown: {
|
|
52
|
-
|
|
60
|
+
cooldown: "transient",
|
|
53
61
|
disable: false,
|
|
54
|
-
counts:
|
|
62
|
+
counts: false
|
|
55
63
|
},
|
|
56
64
|
no_such_model: {
|
|
57
|
-
|
|
65
|
+
cooldown: 0,
|
|
58
66
|
disable: false,
|
|
59
67
|
counts: false
|
|
60
68
|
}
|
|
61
69
|
};
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
/**
|
|
71
|
+
* 瞬时/未知错误的冷却时长 —— 对齐 9router 的 TRANSIENT_COOLDOWN_MS。
|
|
72
|
+
* 取短值:这类错误大多是偶发的上游抖动,冷太久会把好号白白关掉;
|
|
73
|
+
* 但**必须冷**,否则坏号留在池里,每个请求都要先撞一次才知道它坏了。
|
|
74
|
+
* (现在按 (模型, 号) 记,只关这一个号在这个模型上的可用性。)
|
|
75
|
+
*/
|
|
76
|
+
const TRANSIENT_COOLDOWN_MS = 3e4;
|
|
77
|
+
/** 限流退避:首次 2s,每次翻倍,上限 5 分钟(对齐 9router BACKOFF_CONFIG)。 */
|
|
78
|
+
const BACKOFF_BASE_MS = 2e3;
|
|
79
|
+
const BACKOFF_MAX_MS = 5 * MINUTE;
|
|
80
|
+
const BACKOFF_MAX_LEVEL = 15;
|
|
81
|
+
/** 指数退避:等级 1 = BACKOFF_BASE_MS,每级翻倍,封顶 BACKOFF_MAX_MS。 */
|
|
82
|
+
function backoffMs(level) {
|
|
83
|
+
return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, level - 1), BACKOFF_MAX_MS);
|
|
84
|
+
}
|
|
85
|
+
/** 冷却键分隔符:模型 id 本身可含 `/` `,`,用不可见控制符隔离最稳。 */
|
|
86
|
+
const SEP = "\0";
|
|
66
87
|
var AccountPool = class {
|
|
67
|
-
|
|
88
|
+
/** 本池所属供应商(loader 每供应商一个实例;键里带上,跨供应商同名模型不串)。 */
|
|
89
|
+
supplierId;
|
|
90
|
+
/** (supplier, model, uid) → 冷却/退避。 */
|
|
91
|
+
cooldowns = /* @__PURE__ */ new Map();
|
|
92
|
+
/** uid → 连接级禁用 / 手动暂停。 */
|
|
93
|
+
byUid = /* @__PURE__ */ new Map();
|
|
68
94
|
rrCursor = 0;
|
|
69
|
-
|
|
70
|
-
|
|
95
|
+
constructor(supplierId = "") {
|
|
96
|
+
this.supplierId = supplierId;
|
|
97
|
+
}
|
|
98
|
+
key(model, uid) {
|
|
99
|
+
return `${this.supplierId}${SEP}${model}${SEP}${uid}`;
|
|
100
|
+
}
|
|
101
|
+
entry(model, uid) {
|
|
102
|
+
const k = this.key(model, uid);
|
|
103
|
+
let e = this.cooldowns.get(k);
|
|
104
|
+
if (!e) {
|
|
105
|
+
e = {
|
|
106
|
+
until: 0,
|
|
107
|
+
backoffLevel: 0,
|
|
108
|
+
reason: ""
|
|
109
|
+
};
|
|
110
|
+
this.cooldowns.set(k, e);
|
|
111
|
+
}
|
|
112
|
+
return e;
|
|
113
|
+
}
|
|
114
|
+
/** 某连接整体状态(禁用/手动暂停)。 */
|
|
115
|
+
uidState(uid) {
|
|
116
|
+
let e = this.byUid.get(uid);
|
|
71
117
|
if (!e) {
|
|
72
118
|
e = {
|
|
73
119
|
until: 0,
|
|
74
120
|
disabled: false,
|
|
75
|
-
errCount: 0,
|
|
76
121
|
reason: ""
|
|
77
122
|
};
|
|
78
|
-
this.
|
|
123
|
+
this.byUid.set(uid, e);
|
|
79
124
|
}
|
|
80
125
|
return e;
|
|
81
126
|
}
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
const e = this.
|
|
85
|
-
if (!e) return
|
|
86
|
-
|
|
87
|
-
|
|
127
|
+
/** 该连接是否被禁用 / 手动整连接暂停中(跨模型生效)。 */
|
|
128
|
+
uidBlocked(uid, now) {
|
|
129
|
+
const e = this.byUid.get(uid);
|
|
130
|
+
if (!e) return {
|
|
131
|
+
blocked: false,
|
|
132
|
+
disabled: false
|
|
133
|
+
};
|
|
134
|
+
if (e.disabled) return {
|
|
135
|
+
blocked: true,
|
|
136
|
+
disabled: true
|
|
137
|
+
};
|
|
138
|
+
if (e.until > now) return {
|
|
139
|
+
blocked: true,
|
|
140
|
+
disabled: false
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
blocked: false,
|
|
144
|
+
disabled: false
|
|
145
|
+
};
|
|
88
146
|
}
|
|
89
147
|
/**
|
|
90
|
-
*
|
|
148
|
+
* 给定要试的模型,该连接是否可服务:
|
|
149
|
+
* - 未被连接级禁用/手动暂停(跨模型);
|
|
150
|
+
* - 且此 (supplier, model, uid) 冷却单元不在冷却中。
|
|
151
|
+
*/
|
|
152
|
+
healthy(uid, model, now) {
|
|
153
|
+
if (this.uidBlocked(uid, now).blocked) return false;
|
|
154
|
+
const c = this.cooldowns.get(this.key(model, uid));
|
|
155
|
+
if (!c) return true;
|
|
156
|
+
return c.until <= now;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* 按策略为「某个模型」选一个健康账号。
|
|
91
160
|
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
92
161
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
162
|
+
* @param strategy fallback 取第一个健康 / round-robin 轮转健康号
|
|
163
|
+
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
93
164
|
* @returns 选中的 uid;无健康账号返回 undefined
|
|
94
165
|
*/
|
|
95
|
-
pick(accounts, poolOrder, strategy) {
|
|
166
|
+
pick(accounts, poolOrder, strategy, modelId) {
|
|
96
167
|
const now = Date.now();
|
|
97
168
|
const byUid = new Map(accounts.map((a) => [a.uid, a]));
|
|
98
|
-
const healthy = [...poolOrder.filter((uid) => byUid.has(uid)), ...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid))].filter((uid) => this.healthy(uid, now));
|
|
169
|
+
const healthy = [...poolOrder.filter((uid) => byUid.has(uid)), ...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid))].filter((uid) => this.healthy(uid, modelId, now));
|
|
99
170
|
if (healthy.length === 0) return void 0;
|
|
100
171
|
if (strategy !== "round-robin") return healthy[0];
|
|
101
172
|
const uid = healthy[this.rrCursor % healthy.length];
|
|
102
173
|
this.rrCursor = (this.rrCursor + 1) % healthy.length;
|
|
103
174
|
return uid;
|
|
104
175
|
}
|
|
105
|
-
/**
|
|
106
|
-
noteFailure(uid, state, message) {
|
|
176
|
+
/** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
|
|
177
|
+
noteFailure(uid, modelId, state, message) {
|
|
107
178
|
const rule = RULES[state];
|
|
108
|
-
const e = this.entry(uid);
|
|
109
179
|
if (rule.disable) {
|
|
180
|
+
const e = this.uidState(uid);
|
|
110
181
|
e.disabled = true;
|
|
111
182
|
e.reason = message;
|
|
112
183
|
return;
|
|
113
184
|
}
|
|
114
|
-
if (rule.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
185
|
+
if (rule.cooldown === 0) return;
|
|
186
|
+
const e = this.entry(modelId, uid);
|
|
187
|
+
e.reason = message;
|
|
188
|
+
if (rule.cooldown === "backoff") {
|
|
189
|
+
e.backoffLevel = Math.min(e.backoffLevel + 1, BACKOFF_MAX_LEVEL);
|
|
190
|
+
e.until = Math.max(e.until, Date.now() + backoffMs(e.backoffLevel));
|
|
118
191
|
return;
|
|
119
192
|
}
|
|
120
|
-
if (rule.
|
|
121
|
-
e.
|
|
122
|
-
|
|
123
|
-
if (e.errCount >= ERR_THRESHOLD) {
|
|
124
|
-
e.until = Date.now() + ERR_COOLDOWN_MS;
|
|
125
|
-
e.errCount = 0;
|
|
126
|
-
}
|
|
193
|
+
if (rule.cooldown === "transient") {
|
|
194
|
+
e.until = Math.max(e.until, Date.now() + TRANSIENT_COOLDOWN_MS);
|
|
195
|
+
return;
|
|
127
196
|
}
|
|
197
|
+
e.until = Math.max(e.until, Date.now() + rule.cooldown);
|
|
128
198
|
}
|
|
129
|
-
/**
|
|
130
|
-
noteSuccess(uid) {
|
|
131
|
-
const e = this.
|
|
132
|
-
if (e) e.
|
|
199
|
+
/** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。不解除禁用。 */
|
|
200
|
+
noteSuccess(uid, modelId) {
|
|
201
|
+
const e = this.cooldowns.get(this.key(modelId, uid));
|
|
202
|
+
if (e) e.backoffLevel = 0;
|
|
133
203
|
}
|
|
134
|
-
/**
|
|
204
|
+
/** 手动暂停整连接(面板/管理用途,跨模型)。ms<=0 表示解除。 */
|
|
135
205
|
cooldown(uid, ms, reason) {
|
|
136
|
-
const e = this.
|
|
206
|
+
const e = this.uidState(uid);
|
|
137
207
|
e.until = Date.now() + ms;
|
|
138
208
|
e.reason = reason;
|
|
139
|
-
|
|
209
|
+
for (const [k, ce] of this.cooldowns) if (k.endsWith(`${SEP}${uid}`)) ce.backoffLevel = 0;
|
|
140
210
|
}
|
|
141
|
-
/**
|
|
142
|
-
|
|
211
|
+
/** 把核心的冷却/禁用叠加到插件报的「现在状态」上,产出面板态。
|
|
212
|
+
*
|
|
213
|
+
* 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
|
|
214
|
+
* 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
|
|
215
|
+
* (reason 带上具体模型),并在 `until` 给最长者。真实路由用 `pick` 的
|
|
216
|
+
* 模型级判定,不受此展示聚合影响。传 modelId 可只按该模型判定。
|
|
217
|
+
*/
|
|
218
|
+
decorate(accounts, modelId) {
|
|
143
219
|
const now = Date.now();
|
|
144
220
|
return accounts.map((a) => {
|
|
145
|
-
const
|
|
146
|
-
const
|
|
221
|
+
const u = this.byUid.get(a.uid);
|
|
222
|
+
const disabled = u?.disabled ?? false;
|
|
223
|
+
const manualCooling = u !== void 0 && !u.disabled && u.until > now;
|
|
224
|
+
let cooling = manualCooling;
|
|
225
|
+
let maxUntil = manualCooling ? u.until : 0;
|
|
226
|
+
let reason = manualCooling ? u.reason : void 0;
|
|
227
|
+
let err = 0;
|
|
228
|
+
for (const [k, ce] of this.cooldowns) {
|
|
229
|
+
if (!k.endsWith(`${SEP}${a.uid}`)) continue;
|
|
230
|
+
if (modelId !== void 0) {
|
|
231
|
+
const prefix = `${this.supplierId}${SEP}${modelId}${SEP}`;
|
|
232
|
+
if (!k.startsWith(prefix)) continue;
|
|
233
|
+
}
|
|
234
|
+
if (ce.until > now) {
|
|
235
|
+
cooling = true;
|
|
236
|
+
if (ce.until > maxUntil) {
|
|
237
|
+
maxUntil = ce.until;
|
|
238
|
+
reason = ce.reason;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (ce.backoffLevel > err) err = ce.backoffLevel;
|
|
242
|
+
}
|
|
147
243
|
return {
|
|
148
244
|
...a,
|
|
149
245
|
cooling,
|
|
150
|
-
disabled
|
|
151
|
-
err_count:
|
|
152
|
-
until: cooling &&
|
|
153
|
-
reason:
|
|
246
|
+
disabled,
|
|
247
|
+
err_count: err,
|
|
248
|
+
until: cooling && maxUntil > 0 ? new Date(maxUntil).toISOString() : void 0,
|
|
249
|
+
reason: reason !== void 0 && reason !== "" ? reason : void 0
|
|
154
250
|
};
|
|
155
251
|
});
|
|
156
252
|
}
|
|
157
253
|
};
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
* - 模型管理:启用/禁用 disabled、自定义 custom
|
|
166
|
-
* - 连接池:poolOrder(拖动排序)、poolStrategy(回退/轮询)
|
|
167
|
-
*
|
|
168
|
-
* 持久化到 data/supplier-config.json。
|
|
169
|
-
*/
|
|
254
|
+
/** 读盘时的积分字段校验:只留有限非负数(负数一律当没缓存过)。 */
|
|
255
|
+
function readCredits(raw) {
|
|
256
|
+
const out = {};
|
|
257
|
+
if (typeof raw !== "object" || raw === null) return out;
|
|
258
|
+
for (const [uid, v] of Object.entries(raw)) if (typeof v === "number" && Number.isFinite(v) && v >= 0) out[uid] = v;
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
170
261
|
/** 通用供应商配置存储。 */
|
|
171
262
|
var SupplierConfigStore = class {
|
|
172
263
|
fp = "";
|
|
@@ -196,7 +287,8 @@ var SupplierConfigStore = class {
|
|
|
196
287
|
disabled: [],
|
|
197
288
|
custom: [],
|
|
198
289
|
poolOrder: [],
|
|
199
|
-
poolStrategy: "fallback"
|
|
290
|
+
poolStrategy: "fallback",
|
|
291
|
+
credits: {}
|
|
200
292
|
};
|
|
201
293
|
this.bySupplier.set(supplierId, cfg);
|
|
202
294
|
}
|
|
@@ -248,6 +340,32 @@ var SupplierConfigStore = class {
|
|
|
248
340
|
this.get(supplierId).poolStrategy = strategy;
|
|
249
341
|
this.saveLocked();
|
|
250
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* 读缓存的积分。没缓存过返回 `CREDITS_UNKNOWN`(-1),让调用方自己决定
|
|
345
|
+
* 显示什么——不要把 -1 当 0 展示。
|
|
346
|
+
*/
|
|
347
|
+
getCredits(supplierId, uid) {
|
|
348
|
+
const v = this.get(supplierId).credits[uid];
|
|
349
|
+
return typeof v === "number" && Number.isFinite(v) ? v : -1;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* 合并插件报的积分:非 -1 就写入并返回它;-1(未知)则回落到上次缓存值。
|
|
353
|
+
* @returns 面板该显示的积分(仍可能是 -1 = 从来没拿到过)
|
|
354
|
+
*/
|
|
355
|
+
putCredits(supplierId, uid, reported) {
|
|
356
|
+
if (typeof reported !== "number" || !Number.isFinite(reported) || reported < 0) return this.getCredits(supplierId, uid);
|
|
357
|
+
if (this.getCredits(supplierId, uid) === reported) return reported;
|
|
358
|
+
this.get(supplierId).credits[uid] = reported;
|
|
359
|
+
this.saveLocked();
|
|
360
|
+
return reported;
|
|
361
|
+
}
|
|
362
|
+
/** 删除链接时清掉它的积分缓存(不写盘:下一行 removeLink 会带出保存)。 */
|
|
363
|
+
clearCredits(supplierId, uid) {
|
|
364
|
+
const credits = this.get(supplierId).credits;
|
|
365
|
+
if (credits[uid] === void 0) return;
|
|
366
|
+
delete credits[uid];
|
|
367
|
+
this.saveLocked();
|
|
368
|
+
}
|
|
251
369
|
load() {
|
|
252
370
|
if (this.fp === "") return;
|
|
253
371
|
let f;
|
|
@@ -261,7 +379,8 @@ var SupplierConfigStore = class {
|
|
|
261
379
|
disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === "string") : [],
|
|
262
380
|
custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === "string") : [],
|
|
263
381
|
poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === "string") : [],
|
|
264
|
-
poolStrategy: c.poolStrategy === "round-robin" ? "round-robin" : "fallback"
|
|
382
|
+
poolStrategy: c.poolStrategy === "round-robin" ? "round-robin" : "fallback",
|
|
383
|
+
credits: readCredits(c.credits)
|
|
265
384
|
});
|
|
266
385
|
}
|
|
267
386
|
saveLocked() {
|
|
@@ -272,7 +391,8 @@ var SupplierConfigStore = class {
|
|
|
272
391
|
disabled: [...cfg.disabled],
|
|
273
392
|
custom: [...cfg.custom],
|
|
274
393
|
poolOrder: [...cfg.poolOrder],
|
|
275
|
-
poolStrategy: cfg.poolStrategy
|
|
394
|
+
poolStrategy: cfg.poolStrategy,
|
|
395
|
+
credits: { ...cfg.credits }
|
|
276
396
|
};
|
|
277
397
|
try {
|
|
278
398
|
const dir = dirname(this.fp);
|
|
@@ -293,24 +413,46 @@ var SupplierConfigStore = class {
|
|
|
293
413
|
* 落盘**而不是 SQLite:dsh-router 现有持久化全是 JSON(supplier-config.json /
|
|
294
414
|
* keys.json),不为一个看板引入数据库依赖。
|
|
295
415
|
*
|
|
296
|
-
*
|
|
297
|
-
* - `today`
|
|
298
|
-
* - `
|
|
416
|
+
* 统计口径(不再走明细环 —— 见下面「天花板」):
|
|
417
|
+
* - `today` → 走**天桶**(今天这一桶就是答案,天然精确)
|
|
418
|
+
* - `24h` → 走**小时桶**(滚动窗口,天桶是自然日,对不上)
|
|
419
|
+
* - `7d` / `30d` → 走**天桶**(长期精确)
|
|
299
420
|
*
|
|
300
421
|
* 天花板(JSON 的代价,刻意接受):
|
|
301
|
-
* -
|
|
302
|
-
*
|
|
303
|
-
*
|
|
422
|
+
* - 明细环 `recent[]` 只留最近 `RING_CAP` 条,**只服务「最近请求列表」**,
|
|
423
|
+
* 不参与任何统计。历史上 today/24h 直接扫它,单日超 500 条就被静默截断,
|
|
424
|
+
* 于是出现「今日 500 / 7 天 1621」这种自相矛盾的数字——现在不会了。
|
|
425
|
+
* - **小时桶只从新数据开始累积**:升级前落的盘没有小时粒度,而明细环只剩
|
|
426
|
+
* 最近 500 条、回溯不回去,所以那段历史的**天内分布是永久缺失的**。
|
|
427
|
+
* 影响面:①`24h` 遇到「小时粒度不齐」的自然日 → 整桶计入(宁可多算,
|
|
428
|
+
* 也不显示一个比「今日」还小的荒唐数字);②`today`/`24h` 的小时柱状图
|
|
429
|
+
* 那几格是空的(不编数据)。天桶口径(today/7d/30d)完全不受影响,
|
|
430
|
+
* 新数据立刻就有小时粒度,隔天起图表全正常。
|
|
304
431
|
* - 天桶保留 30 天(9router 有 SQLite 不 prune,JSON 撑不住,得自己限)
|
|
305
432
|
* - 落盘防抖 2s:进程被 kill 最多丢 2s 的计数。看板不是账本,可接受;
|
|
306
433
|
* 要严格就改成立即写,代价是每个请求一次磁盘写。
|
|
307
434
|
*/
|
|
308
|
-
/**
|
|
435
|
+
/** 明细环容量:只喂「最近请求列表」,跟统计口径无关了。 */
|
|
309
436
|
const RING_CAP = 500;
|
|
437
|
+
/** 一天几格(小时桶定长;要更细的精度就把这个值放大)。 */
|
|
438
|
+
const HOURS = 24;
|
|
310
439
|
/** 天桶保留天数。 */
|
|
311
440
|
const KEEP_DAYS = 30;
|
|
312
441
|
/** 落盘防抖。 */
|
|
313
442
|
const SAVE_DEBOUNCE_MS = 2e3;
|
|
443
|
+
const emptyHour = () => ({
|
|
444
|
+
requests: 0,
|
|
445
|
+
ok: 0,
|
|
446
|
+
failed: 0,
|
|
447
|
+
promptTokens: 0,
|
|
448
|
+
completionTokens: 0,
|
|
449
|
+
cachedTokens: 0,
|
|
450
|
+
durationMs: 0,
|
|
451
|
+
ttfbMs: 0,
|
|
452
|
+
ttfbCount: 0,
|
|
453
|
+
estimatedInputs: 0,
|
|
454
|
+
estimatedOutputs: 0
|
|
455
|
+
});
|
|
314
456
|
const emptyDay = () => ({
|
|
315
457
|
requests: 0,
|
|
316
458
|
ok: 0,
|
|
@@ -323,6 +465,7 @@ const emptyDay = () => ({
|
|
|
323
465
|
ttfbCount: 0,
|
|
324
466
|
estimatedInputs: 0,
|
|
325
467
|
estimatedOutputs: 0,
|
|
468
|
+
hours: Array.from({ length: HOURS }, () => emptyHour()),
|
|
326
469
|
bySupplier: {},
|
|
327
470
|
byModel: {},
|
|
328
471
|
byRequested: {}
|
|
@@ -343,7 +486,7 @@ function localDateKey(ts) {
|
|
|
343
486
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
344
487
|
}
|
|
345
488
|
/** 用量存储。stateFile 为空时纯内存(测试用),不落盘。 */
|
|
346
|
-
var UsageStore = class {
|
|
489
|
+
var UsageStore = class UsageStore {
|
|
347
490
|
fp = "";
|
|
348
491
|
days = /* @__PURE__ */ new Map();
|
|
349
492
|
recent = [];
|
|
@@ -394,6 +537,22 @@ var UsageStore = class {
|
|
|
394
537
|
}
|
|
395
538
|
if (rec.inputEstimated) day.estimatedInputs += 1;
|
|
396
539
|
if (rec.outputEstimated) day.estimatedOutputs += 1;
|
|
540
|
+
const h = day.hours[new Date(ts).getHours()];
|
|
541
|
+
if (h !== void 0) {
|
|
542
|
+
h.requests += 1;
|
|
543
|
+
if (rec.ok) h.ok += 1;
|
|
544
|
+
else h.failed += 1;
|
|
545
|
+
h.promptTokens += promptTokens;
|
|
546
|
+
h.completionTokens += completionTokens;
|
|
547
|
+
h.cachedTokens += rec.cachedTokens;
|
|
548
|
+
h.durationMs += rec.durationMs;
|
|
549
|
+
if (rec.ttfbMs > 0) {
|
|
550
|
+
h.ttfbMs += rec.ttfbMs;
|
|
551
|
+
h.ttfbCount += 1;
|
|
552
|
+
}
|
|
553
|
+
if (rec.inputEstimated) h.estimatedInputs += 1;
|
|
554
|
+
if (rec.outputEstimated) h.estimatedOutputs += 1;
|
|
555
|
+
}
|
|
397
556
|
this.bump(day.bySupplier, rec.supplier, rec);
|
|
398
557
|
this.bump(day.byModel, rec.model, rec);
|
|
399
558
|
this.bump(day.byRequested, rec.requested, rec);
|
|
@@ -414,24 +573,71 @@ var UsageStore = class {
|
|
|
414
573
|
e.lastTs = Math.max(e.lastTs, rec.ts);
|
|
415
574
|
map[key] = e;
|
|
416
575
|
}
|
|
417
|
-
/**
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
576
|
+
/**
|
|
577
|
+
* 从 `first` 起的 24 个小时格:每格带整点时间戳、所属自然日、那天的数据。
|
|
578
|
+
*
|
|
579
|
+
* `today` 传今天 00:00,`24h` 传「当前整点往前 23 格」。
|
|
580
|
+
* 跨天一律靠 `localDateKey(start)` 现算,不用「减 N 小时」去猜天数:
|
|
581
|
+
* 本地时区(尤其 DST)下小时偏移和自然日不是一回事,算错就漏一整天。
|
|
582
|
+
*/
|
|
583
|
+
hourSlots(first) {
|
|
584
|
+
const out = [];
|
|
585
|
+
for (let i = 0; i < HOURS; i += 1) {
|
|
586
|
+
const start = first + i * 36e5;
|
|
587
|
+
const dayKey = localDateKey(start);
|
|
588
|
+
const day = this.days.get(dayKey);
|
|
589
|
+
out.push({
|
|
590
|
+
dayKey,
|
|
591
|
+
start,
|
|
592
|
+
day,
|
|
593
|
+
b: day?.hours[new Date(start).getHours()]
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
return out;
|
|
597
|
+
}
|
|
598
|
+
/** 今天的小时格起点(自然日零点)。 */
|
|
599
|
+
static dayStartOf(now) {
|
|
600
|
+
return new Date(now).setHours(0, 0, 0, 0);
|
|
601
|
+
}
|
|
602
|
+
/** 24h 滚动窗口的小时格起点(当前整点往前 23 格)。 */
|
|
603
|
+
static rollingStartOf(now) {
|
|
604
|
+
const c = new Date(now);
|
|
605
|
+
c.setMinutes(0, 0, 0);
|
|
606
|
+
return c.getTime() - 828e5;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* 一整天的小时桶是否齐(各格之和 >= 天桶计数)。
|
|
610
|
+
*
|
|
611
|
+
* 不齐 = 这天是升级前落的盘(没有小时粒度)→ 24h 窗口踩到它就只能整桶计入。
|
|
612
|
+
* 宁可多算窗口外那截,也不能报一个比「今日」还小的数。
|
|
613
|
+
*/
|
|
614
|
+
static hoursComplete(d) {
|
|
615
|
+
return d.hours.reduce((n, h) => n + h.requests, 0) >= d.requests;
|
|
421
616
|
}
|
|
422
617
|
/** 汇总 + Top 榜。 */
|
|
423
618
|
stats(period, now = Date.now()) {
|
|
424
|
-
if (period === "today"
|
|
425
|
-
const
|
|
619
|
+
if (period === "today") {
|
|
620
|
+
const d = this.days.get(localDateKey(now));
|
|
621
|
+
const acc = emptyAcc();
|
|
622
|
+
if (d === void 0) return finish(acc, {}, {}, {}, this.lifetime);
|
|
623
|
+
addDay(acc, d);
|
|
624
|
+
return finish(acc, d.bySupplier, d.byModel, d.byRequested, this.lifetime);
|
|
625
|
+
}
|
|
626
|
+
if (period === "24h") {
|
|
627
|
+
const slots = this.hourSlots(UsageStore.rollingStartOf(now));
|
|
426
628
|
const acc = emptyAcc();
|
|
427
629
|
const bySupplier = {};
|
|
428
630
|
const byModel = {};
|
|
429
631
|
const byRequested = {};
|
|
430
|
-
for (const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
632
|
+
for (const key of new Set(slots.map((s) => s.dayKey))) {
|
|
633
|
+
const d = this.days.get(key);
|
|
634
|
+
if (d === void 0) continue;
|
|
635
|
+
if (UsageStore.hoursComplete(d)) {
|
|
636
|
+
for (const s of slots) if (s.dayKey === key && s.b !== void 0) addHour(acc, s.b);
|
|
637
|
+
} else addDay(acc, d);
|
|
638
|
+
mergeInto(bySupplier, d.bySupplier);
|
|
639
|
+
mergeInto(byModel, d.byModel);
|
|
640
|
+
mergeInto(byRequested, d.byRequested);
|
|
435
641
|
}
|
|
436
642
|
return finish(acc, bySupplier, byModel, byRequested, this.lifetime);
|
|
437
643
|
}
|
|
@@ -443,40 +649,23 @@ var UsageStore = class {
|
|
|
443
649
|
for (let i = days - 1; i >= 0; i -= 1) {
|
|
444
650
|
const d = this.days.get(localDateKey(now - i * 864e5));
|
|
445
651
|
if (d === void 0) continue;
|
|
446
|
-
acc
|
|
447
|
-
acc.ok += d.ok;
|
|
448
|
-
acc.failed += d.failed;
|
|
449
|
-
acc.promptTokens += d.promptTokens;
|
|
450
|
-
acc.completionTokens += d.completionTokens;
|
|
451
|
-
acc.cachedTokens += d.cachedTokens;
|
|
452
|
-
acc.durationMs += d.durationMs;
|
|
453
|
-
acc.ttfbMs += d.ttfbMs;
|
|
454
|
-
acc.ttfbCount += d.ttfbCount;
|
|
455
|
-
acc.estimatedInputs += d.estimatedInputs;
|
|
456
|
-
acc.estimatedOutputs += d.estimatedOutputs;
|
|
652
|
+
addDay(acc, d);
|
|
457
653
|
mergeInto(bySupplier, d.bySupplier);
|
|
458
654
|
mergeInto(byModel, d.byModel);
|
|
459
655
|
mergeInto(byRequested, d.byRequested);
|
|
460
656
|
}
|
|
461
657
|
return finish(acc, bySupplier, byModel, byRequested, this.lifetime);
|
|
462
658
|
}
|
|
463
|
-
/** 柱状图数据:today/24h = 24
|
|
659
|
+
/** 柱状图数据:today/24h = 24 个小时桶,7d/30d = 天桶。 */
|
|
464
660
|
chart(period, now = Date.now()) {
|
|
465
661
|
if (period === "today" || period === "24h") {
|
|
466
|
-
const
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
label: `${
|
|
470
|
-
requests: 0,
|
|
471
|
-
tokens: 0
|
|
662
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
663
|
+
const first = period === "today" ? UsageStore.dayStartOf(now) : UsageStore.rollingStartOf(now);
|
|
664
|
+
return this.hourSlots(first).map((s) => ({
|
|
665
|
+
label: `${p(new Date(s.start).getHours())}:00`,
|
|
666
|
+
requests: s.b?.requests ?? 0,
|
|
667
|
+
tokens: (s.b?.promptTokens ?? 0) + (s.b?.completionTokens ?? 0)
|
|
472
668
|
}));
|
|
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
669
|
}
|
|
481
670
|
const days = period === "7d" ? 7 : 30;
|
|
482
671
|
const out = [];
|
|
@@ -524,10 +713,17 @@ var UsageStore = class {
|
|
|
524
713
|
this.days = new Map(Object.entries(f.days ?? {}));
|
|
525
714
|
this.recent = Array.isArray(f.recent) ? f.recent.slice(0, RING_CAP) : [];
|
|
526
715
|
this.lifetime = typeof f.lifetime === "number" ? f.lifetime : 0;
|
|
527
|
-
for (const [k, d] of this.days)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
716
|
+
for (const [k, d] of this.days) {
|
|
717
|
+
const day = {
|
|
718
|
+
...emptyDay(),
|
|
719
|
+
...d
|
|
720
|
+
};
|
|
721
|
+
day.hours = Array.from({ length: HOURS }, (_, i) => ({
|
|
722
|
+
...emptyHour(),
|
|
723
|
+
...d.hours?.[i]
|
|
724
|
+
}));
|
|
725
|
+
this.days.set(k, day);
|
|
726
|
+
}
|
|
531
727
|
} catch {}
|
|
532
728
|
}
|
|
533
729
|
save() {
|
|
@@ -561,32 +757,33 @@ const emptyAcc = () => ({
|
|
|
561
757
|
estimatedInputs: 0,
|
|
562
758
|
estimatedOutputs: 0
|
|
563
759
|
});
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
acc.
|
|
569
|
-
acc.
|
|
570
|
-
acc.
|
|
571
|
-
acc.
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
if (rec.outputEstimated) acc.estimatedOutputs += 1;
|
|
760
|
+
/** 天桶累加(today / 7d / 30d 路径,以及 24h 的「老数据整桶兜底」)。 */
|
|
761
|
+
function addDay(acc, d) {
|
|
762
|
+
acc.requests += d.requests;
|
|
763
|
+
acc.ok += d.ok;
|
|
764
|
+
acc.failed += d.failed;
|
|
765
|
+
acc.promptTokens += d.promptTokens;
|
|
766
|
+
acc.completionTokens += d.completionTokens;
|
|
767
|
+
acc.cachedTokens += d.cachedTokens;
|
|
768
|
+
acc.durationMs += d.durationMs;
|
|
769
|
+
acc.ttfbMs += d.ttfbMs;
|
|
770
|
+
acc.ttfbCount += d.ttfbCount;
|
|
771
|
+
acc.estimatedInputs += d.estimatedInputs;
|
|
772
|
+
acc.estimatedOutputs += d.estimatedOutputs;
|
|
578
773
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
774
|
+
/** 小时桶累加(24h 路径)。 */
|
|
775
|
+
function addHour(acc, h) {
|
|
776
|
+
acc.requests += h.requests;
|
|
777
|
+
acc.ok += h.ok;
|
|
778
|
+
acc.failed += h.failed;
|
|
779
|
+
acc.promptTokens += h.promptTokens;
|
|
780
|
+
acc.completionTokens += h.completionTokens;
|
|
781
|
+
acc.cachedTokens += h.cachedTokens;
|
|
782
|
+
acc.durationMs += h.durationMs;
|
|
783
|
+
acc.ttfbMs += h.ttfbMs;
|
|
784
|
+
acc.ttfbCount += h.ttfbCount;
|
|
785
|
+
acc.estimatedInputs += h.estimatedInputs;
|
|
786
|
+
acc.estimatedOutputs += h.estimatedOutputs;
|
|
590
787
|
}
|
|
591
788
|
function finish(acc, bySupplier, byModel, byRequested, lifetime) {
|
|
592
789
|
return {
|
|
@@ -882,8 +1079,15 @@ function withEstimates(usage, requestChars, outputChars) {
|
|
|
882
1079
|
* 仿 9router:组合 = 一组模型,请求 model 命中组合名时,按策略
|
|
883
1080
|
* (fallback 顺序尝试 / round-robin 轮转)选中一个模型路由。
|
|
884
1081
|
*/
|
|
885
|
-
/**
|
|
886
|
-
|
|
1082
|
+
/**
|
|
1083
|
+
* 模型列表缓存有效期。
|
|
1084
|
+
*
|
|
1085
|
+
* 取 10 分钟而不是 60s:模型列表本来很少变(上游上新/下线才动),而每次
|
|
1086
|
+
* TTL 过期都会穿透到插件真打一次上游——实测冷路径 0.3~1.0s/供应商,组合页
|
|
1087
|
+
* 取最慢那家就是 1s+ 的白屏。拉长 TTL 直接把这个穿透频率降一个量级;真要
|
|
1088
|
+
* 立刻看新模型,点「获取模型」走 force 即可。
|
|
1089
|
+
*/
|
|
1090
|
+
const MODELS_TTL_MS = 6e5;
|
|
887
1091
|
function openAIError(code, msg) {
|
|
888
1092
|
return { error: {
|
|
889
1093
|
message: msg,
|
|
@@ -898,6 +1102,33 @@ function writeJson$2(res, status, body) {
|
|
|
898
1102
|
});
|
|
899
1103
|
res.end(JSON.stringify(body));
|
|
900
1104
|
}
|
|
1105
|
+
/**
|
|
1106
|
+
* 剥掉本供应商自己的 alias 前缀(只剥一层,且只认完整 `alias/` 开头)。
|
|
1107
|
+
*
|
|
1108
|
+
* 为什么不能用 lastIndexOf:模型 id 本身可以含斜杠(nvidia 的
|
|
1109
|
+
* `deepseek-ai/deepseek-v4-flash-0731`),剥最后一段会把命名空间吃掉,
|
|
1110
|
+
* 后面请求必然 404。只认「已知 alias + /」开头,其他原样返回。
|
|
1111
|
+
*
|
|
1112
|
+
* 注意:核心**只在记统计名时**用它。传给插件的 model 全名保持原样——
|
|
1113
|
+
* 剥前缀是插件自己的事(各供应商插件都有一份同名实现)。
|
|
1114
|
+
*/
|
|
1115
|
+
function stripAlias(model, alias) {
|
|
1116
|
+
return alias !== "" && model.startsWith(`${alias}/`) ? model.slice(alias.length + 1) : model;
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* 组合降级前的「喘口气」等待:瞬时故障给上游一个恢复窗口再换下一个模型。
|
|
1120
|
+
*
|
|
1121
|
+
* 学 9router combo.js 的同名处理(注释原话:fixes: combo falls through on
|
|
1122
|
+
* transient 503)—— 过载的上游往往几百毫秒内就恢复,立刻换下一个模型会
|
|
1123
|
+
* 让整个组合在几毫秒内被打穿,最后全灭返回 503。
|
|
1124
|
+
*
|
|
1125
|
+
* 只对**瞬时**故障等:连接层失败 / 上游不可用(对应 9router 的 502/503/504)。
|
|
1126
|
+
* 模型不属于本供应商(no_such_model)绝不能等 —— 它不是故障,等纯属浪费。
|
|
1127
|
+
*/
|
|
1128
|
+
const TRANSIENT_SETTLE_MS = 2e3;
|
|
1129
|
+
function isTransient(state) {
|
|
1130
|
+
return state === "transport" || state === "unavailable";
|
|
1131
|
+
}
|
|
901
1132
|
/** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
|
|
902
1133
|
function sinkRes() {
|
|
903
1134
|
let status = 200;
|
|
@@ -1028,7 +1259,9 @@ async function aggregateSSE(stream) {
|
|
|
1028
1259
|
return JSON.stringify(body);
|
|
1029
1260
|
}
|
|
1030
1261
|
/**
|
|
1031
|
-
* 流式 SSE 逐帧归一化:剥掉上游多发一次的**空** tool_call
|
|
1262
|
+
* 流式 SSE 逐帧归一化:剥掉上游多发一次的**空** tool_call 字段,
|
|
1263
|
+
* 并记住上游有没有发终止帧(`data: [DONE]`)—— 断流时核心要据此补发,
|
|
1264
|
+
* 见 `writeChatResult`。
|
|
1032
1265
|
*
|
|
1033
1266
|
* 为什么必须做:CodeBuddy 上游的工具调用第二个 delta 会**显式发空串**
|
|
1034
1267
|
* `{"function":{"name":"","arguments":"{...}"},"index":0}`(以及 `"id":""`)。
|
|
@@ -1047,26 +1280,38 @@ function normalizeSSEStream(stream) {
|
|
|
1047
1280
|
const enc = new TextEncoder();
|
|
1048
1281
|
const dec = new TextDecoder();
|
|
1049
1282
|
let buf = "";
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1283
|
+
let sawDone = false;
|
|
1284
|
+
return {
|
|
1285
|
+
stream: new ReadableStream({ start: async (ctrl) => {
|
|
1286
|
+
const reader = stream.getReader();
|
|
1287
|
+
try {
|
|
1288
|
+
for (;;) {
|
|
1289
|
+
const { done, value } = await reader.read();
|
|
1290
|
+
if (done) break;
|
|
1291
|
+
buf += dec.decode(value, { stream: true });
|
|
1292
|
+
let sep;
|
|
1293
|
+
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
1294
|
+
const frame = buf.slice(0, sep + 2);
|
|
1295
|
+
buf = buf.slice(sep + 2);
|
|
1296
|
+
if (isDoneFrame(frame)) sawDone = true;
|
|
1297
|
+
ctrl.enqueue(enc.encode(fixFrame(frame)));
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (buf !== "") {
|
|
1301
|
+
if (isDoneFrame(buf)) sawDone = true;
|
|
1302
|
+
ctrl.enqueue(enc.encode(fixFrame(buf)));
|
|
1062
1303
|
}
|
|
1304
|
+
} finally {
|
|
1305
|
+
reader.releaseLock();
|
|
1063
1306
|
}
|
|
1064
|
-
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1307
|
+
ctrl.close();
|
|
1308
|
+
} }),
|
|
1309
|
+
sawDone: () => sawDone
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
/** 一帧 SSE 里有没有终止帧(宽松匹配:跨 chunk 的残留尾帧也认)。 */
|
|
1313
|
+
function isDoneFrame(frame) {
|
|
1314
|
+
return /(^|\n)data:[ \t]*\[DONE\][ \t]*(\n|$)/.test(frame);
|
|
1070
1315
|
}
|
|
1071
1316
|
/** 修一帧 SSE:只剥空的 name/id,出任何问题都原样返回。 */
|
|
1072
1317
|
function fixFrame(frame) {
|
|
@@ -1108,6 +1353,9 @@ function fixFrame(frame) {
|
|
|
1108
1353
|
* 先写头就等于把自己锁死在这一个账号上,组合就没法回退了——而客户端只拿到
|
|
1109
1354
|
* 一个空/截断的 SSE,表现为工具调用名是空串(`unknown tool ""`)。
|
|
1110
1355
|
*
|
|
1356
|
+
* 截断时**补发终止帧**(见末尾 finally):客户端严格等 `[DONE]`,缺了就
|
|
1357
|
+
* 整轮判失败,已流式吐出的内容全废。
|
|
1358
|
+
*
|
|
1111
1359
|
* 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
|
|
1112
1360
|
* 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
|
|
1113
1361
|
*/
|
|
@@ -1139,7 +1387,13 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
|
1139
1387
|
}
|
|
1140
1388
|
const tapped = probe === void 0 ? void 0 : tapStreamUsage(r.stream, startedAt);
|
|
1141
1389
|
let wroteAny = false;
|
|
1390
|
+
/** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
|
|
1391
|
+
let dead = false;
|
|
1142
1392
|
const writeChunk = (chunk) => new Promise((resolve) => {
|
|
1393
|
+
if (dead) {
|
|
1394
|
+
resolve();
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1143
1397
|
if (!wroteAny) {
|
|
1144
1398
|
res.writeHead(200, {
|
|
1145
1399
|
"Content-Type": "text/event-stream",
|
|
@@ -1151,16 +1405,25 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
|
1151
1405
|
wroteAny = true;
|
|
1152
1406
|
}
|
|
1153
1407
|
if (res.write(chunk)) resolve();
|
|
1154
|
-
else
|
|
1408
|
+
else {
|
|
1409
|
+
const onDrain = () => {
|
|
1410
|
+
res.removeListener("error", onDrain);
|
|
1411
|
+
resolve();
|
|
1412
|
+
};
|
|
1413
|
+
res.once("drain", onDrain);
|
|
1414
|
+
}
|
|
1155
1415
|
});
|
|
1156
1416
|
const onError = () => {
|
|
1417
|
+
dead = true;
|
|
1157
1418
|
res.destroy();
|
|
1158
1419
|
};
|
|
1159
1420
|
res.once("error", onError);
|
|
1421
|
+
const norm = normalizeSSEStream(tapped?.stream ?? r.stream);
|
|
1160
1422
|
try {
|
|
1161
|
-
await
|
|
1423
|
+
await norm.stream.pipeTo(new WritableStream({ write: writeChunk }));
|
|
1162
1424
|
} catch {} finally {
|
|
1163
1425
|
res.removeListener("error", onError);
|
|
1426
|
+
if (wroteAny && !norm.sawDone() && !dead && !res.writableEnded && !res.destroyed) await writeChunk(DONE_FRAME);
|
|
1164
1427
|
if (wroteAny) res.end();
|
|
1165
1428
|
if (probe !== void 0 && tapped !== void 0) {
|
|
1166
1429
|
const got = await tapped.done();
|
|
@@ -1171,6 +1434,8 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
|
1171
1434
|
}
|
|
1172
1435
|
return wroteAny;
|
|
1173
1436
|
}
|
|
1437
|
+
/** SSE 终止帧(断流兜底补发用)。 */
|
|
1438
|
+
const DONE_FRAME = new TextEncoder().encode("data: [DONE]\n\n");
|
|
1174
1439
|
/** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
|
|
1175
1440
|
function extractUpstreamError(body) {
|
|
1176
1441
|
try {
|
|
@@ -1192,6 +1457,11 @@ var Router = class {
|
|
|
1192
1457
|
store;
|
|
1193
1458
|
/** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
|
|
1194
1459
|
modelsCache = /* @__PURE__ */ new Map();
|
|
1460
|
+
/**
|
|
1461
|
+
* 正在拉模型的供应商 → in-flight promise。
|
|
1462
|
+
* 组合页会同时从多个入口触发同一个供应商的刷新,不去重上游就被按 N 倍打。
|
|
1463
|
+
*/
|
|
1464
|
+
modelsInflight = /* @__PURE__ */ new Map();
|
|
1195
1465
|
/** 请求日志出口(面板/宿主 logger)。 */
|
|
1196
1466
|
log;
|
|
1197
1467
|
/** 用量统计(概览看板)。 */
|
|
@@ -1301,40 +1571,74 @@ var Router = class {
|
|
|
1301
1571
|
* 取某供应商的模型(核心统一缓存)。
|
|
1302
1572
|
*
|
|
1303
1573
|
* 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
|
|
1304
|
-
* `/combos
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1574
|
+
* `/combos`(组合加模型)共用。
|
|
1575
|
+
*
|
|
1576
|
+
* **过期后走 stale-while-revalidate**:先返回旧值让面板立刻有内容,后台
|
|
1577
|
+
* 重新拉,拉完下次请求就是新的。为什么必须这样:插件的 `listModels` 大多
|
|
1578
|
+
* 无条件打上游(实测冷路径 0.3~1.0s/供应商),而组合页要拉全部供应商、
|
|
1579
|
+
* 取最慢那家——同步等就是 1s+ 的白屏。旧值只可能「少列了新模型」,
|
|
1580
|
+
* 比白屏划算;想立刻看新模型点「获取模型」(force)即可。
|
|
1307
1581
|
*
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1582
|
+
* 只有两种情况会真的等:`force`(用户主动刷新)和**从来没有过缓存值**
|
|
1583
|
+
* (冷启动,没有旧值可给)。后者只发生一次。
|
|
1310
1584
|
*/
|
|
1311
1585
|
async modelsOf(supplierId, force = false) {
|
|
1312
1586
|
const s = this.suppliers.find((x) => x.id === supplierId);
|
|
1313
1587
|
if (s === void 0) return [];
|
|
1314
1588
|
const hit = this.modelsCache.get(supplierId);
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
|
1331
|
-
});
|
|
1589
|
+
const fresh = hit !== void 0 && Date.now() - hit.fetchedAt < MODELS_TTL_MS;
|
|
1590
|
+
if (!force && fresh) return hit.models;
|
|
1591
|
+
if (!force && hit !== void 0) {
|
|
1592
|
+
this.refreshModels(supplierId, s).catch(() => {});
|
|
1593
|
+
return hit.models;
|
|
1332
1594
|
}
|
|
1333
|
-
this.
|
|
1334
|
-
|
|
1335
|
-
|
|
1595
|
+
return await this.refreshModels(supplierId, s);
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* 真拉一次上游并写缓存。并发调用共享同一个 in-flight promise——
|
|
1599
|
+
* 组合页会同时触发多个入口,不去重会把上游按 N 倍打(各家插件自己没做这层)。
|
|
1600
|
+
*
|
|
1601
|
+
* 失败时:**有旧值就退回旧值**(宁可显示几分钟前的列表,也别让面板空掉),
|
|
1602
|
+
* **没旧值就把错误抛回去**——调用方(`supplierModels`)据此把该供应商整个
|
|
1603
|
+
* 剔除,而不是显示一个「有 0 个模型」的空壳。
|
|
1604
|
+
*/
|
|
1605
|
+
refreshModels(supplierId, s) {
|
|
1606
|
+
const inflight = this.modelsInflight.get(supplierId);
|
|
1607
|
+
if (inflight !== void 0) return inflight;
|
|
1608
|
+
const p = (async () => {
|
|
1609
|
+
const cfg = this.store.get(supplierId);
|
|
1610
|
+
const custom = new Set(cfg.custom);
|
|
1611
|
+
const disabled = new Set(cfg.disabled);
|
|
1612
|
+
const models = (await Promise.resolve(s.listModels())).map((mm) => ({
|
|
1613
|
+
...mm,
|
|
1614
|
+
enabled: !disabled.has(mm.id),
|
|
1615
|
+
custom: custom.has(mm.id) ? true : void 0
|
|
1616
|
+
}));
|
|
1617
|
+
const seen = new Set(models.map((mm) => mm.id));
|
|
1618
|
+
for (const id of custom) if (!seen.has(id)) {
|
|
1619
|
+
seen.add(id);
|
|
1620
|
+
models.push({
|
|
1621
|
+
id,
|
|
1622
|
+
enabled: !disabled.has(id),
|
|
1623
|
+
custom: true
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
const stale = this.modelsCache.get(supplierId);
|
|
1627
|
+
if (models.length === 0 && stale !== void 0 && stale.models.length > 0) return stale.models;
|
|
1628
|
+
this.modelsCache.set(supplierId, {
|
|
1629
|
+
models,
|
|
1630
|
+
fetchedAt: Date.now()
|
|
1631
|
+
});
|
|
1632
|
+
return models;
|
|
1633
|
+
})().catch((err) => {
|
|
1634
|
+
const stale = this.modelsCache.get(supplierId);
|
|
1635
|
+
if (stale !== void 0) return stale.models;
|
|
1636
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
1637
|
+
}).finally(() => {
|
|
1638
|
+
this.modelsInflight.delete(supplierId);
|
|
1336
1639
|
});
|
|
1337
|
-
|
|
1640
|
+
this.modelsInflight.set(supplierId, p);
|
|
1641
|
+
return p;
|
|
1338
1642
|
}
|
|
1339
1643
|
/** 失效某供应商的模型缓存(增删改模型后调用)。 */
|
|
1340
1644
|
invalidateModels(supplierId) {
|
|
@@ -1516,10 +1820,12 @@ var Router = class {
|
|
|
1516
1820
|
for (let i = 0; i < combo.models.length; i++) {
|
|
1517
1821
|
const model = combo.models[(start + i) % combo.models.length];
|
|
1518
1822
|
if (model === void 0) continue;
|
|
1519
|
-
|
|
1823
|
+
const trace = { attempts: 0 };
|
|
1824
|
+
if (await this.chatWithModel(req, res, model, probe, trace)) {
|
|
1520
1825
|
settle(true);
|
|
1521
1826
|
return;
|
|
1522
1827
|
}
|
|
1828
|
+
if (trace.lastState !== void 0 && isTransient(trace.lastState) && i < combo.models.length - 1) await new Promise((r) => setTimeout(r, TRANSIENT_SETTLE_MS));
|
|
1523
1829
|
}
|
|
1524
1830
|
settle(false, "combo: all models unavailable");
|
|
1525
1831
|
writeJson$2(res, 503, openAIError("no_healthy_supplier", `combo ${JSON.stringify(req.model)}: all models unavailable`));
|
|
@@ -1557,7 +1863,12 @@ var Router = class {
|
|
|
1557
1863
|
* 不再挨个问供应商「这是不是你的模型」——精准调用,也避免同名模型串台。
|
|
1558
1864
|
* 兼容旧的裸 `modelId`(没有逗号):降级为遍历,行为同以前。
|
|
1559
1865
|
*/
|
|
1560
|
-
|
|
1866
|
+
/**
|
|
1867
|
+
* @param outTrace 可选出参:把这次调用的追踪信息带回去(组合据此判断
|
|
1868
|
+
* 要不要「喘口气」再降级)。裸 id 遍历路径不填(多供应商混在一起,
|
|
1869
|
+
* 失败状态没有单一归属)。
|
|
1870
|
+
*/
|
|
1871
|
+
async chatWithModel(req, res, model, probe, outTrace) {
|
|
1561
1872
|
const comma = model.indexOf(",");
|
|
1562
1873
|
const supplierId = comma > 0 ? model.slice(0, comma) : void 0;
|
|
1563
1874
|
const modelId = comma > 0 ? model.slice(comma + 1) : model;
|
|
@@ -1572,20 +1883,26 @@ var Router = class {
|
|
|
1572
1883
|
} catch {
|
|
1573
1884
|
clone.rawBody = req.rawBody;
|
|
1574
1885
|
}
|
|
1575
|
-
if (supplierId
|
|
1576
|
-
const
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
return false;
|
|
1580
|
-
}
|
|
1581
|
-
const t0 = Date.now();
|
|
1582
|
-
const trace = { attempts: 0 };
|
|
1583
|
-
const served = await this.chatWithSupplier(s, clone, res, trace, probe);
|
|
1584
|
-
this.logChat(req.model, served ? `${s.id}/${modelId} (${trace.uid ?? "?"}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ""}` : `${s.id}/${modelId} 失败 ${trace.lastError ?? "no account"}`, Date.now() - t0);
|
|
1585
|
-
return served;
|
|
1886
|
+
if (supplierId === void 0) {
|
|
1887
|
+
const msg = `model ${JSON.stringify(model)} 缺供应商前缀(应为 supplierId,modelId)`;
|
|
1888
|
+
this.logChat(req.model, msg, 0);
|
|
1889
|
+
return false;
|
|
1586
1890
|
}
|
|
1587
|
-
|
|
1588
|
-
|
|
1891
|
+
const s = this.suppliers.find((x) => x.id === supplierId);
|
|
1892
|
+
if (s === void 0) {
|
|
1893
|
+
this.logChat(req.model, `supplier ${JSON.stringify(supplierId)} 不存在`, 0);
|
|
1894
|
+
return false;
|
|
1895
|
+
}
|
|
1896
|
+
const t0 = Date.now();
|
|
1897
|
+
const trace = { attempts: 0 };
|
|
1898
|
+
const served = await this.chatWithSupplier(s, clone, res, trace, probe);
|
|
1899
|
+
if (outTrace !== void 0) {
|
|
1900
|
+
outTrace.attempts = trace.attempts;
|
|
1901
|
+
outTrace.lastError = trace.lastError;
|
|
1902
|
+
outTrace.lastState = trace.lastState;
|
|
1903
|
+
}
|
|
1904
|
+
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);
|
|
1905
|
+
return served;
|
|
1589
1906
|
}
|
|
1590
1907
|
/** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
|
|
1591
1908
|
supplierByAlias(alias) {
|
|
@@ -1607,20 +1924,21 @@ var Router = class {
|
|
|
1607
1924
|
const r = await s.chatOnce("", req);
|
|
1608
1925
|
if (!r.ok) {
|
|
1609
1926
|
if (r.state === "no_such_model") return false;
|
|
1610
|
-
|
|
1927
|
+
if (trace !== void 0) trace.lastState = r.state;
|
|
1928
|
+
pool.noteFailure("", req.model, r.state, r.message);
|
|
1611
1929
|
return false;
|
|
1612
1930
|
}
|
|
1613
1931
|
if (trace !== void 0) trace.uid = "(no-account)";
|
|
1614
1932
|
if (probe !== void 0) {
|
|
1615
1933
|
probe.supplier = s.id;
|
|
1616
|
-
probe.model = `${s.getAlias()}/${req.model}`;
|
|
1934
|
+
probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
|
|
1617
1935
|
probe.uid = "";
|
|
1618
1936
|
}
|
|
1619
1937
|
return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0);
|
|
1620
1938
|
}
|
|
1621
1939
|
const tried = /* @__PURE__ */ new Set();
|
|
1622
1940
|
for (;;) {
|
|
1623
|
-
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy);
|
|
1941
|
+
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model);
|
|
1624
1942
|
if (uid === void 0) return false;
|
|
1625
1943
|
tried.add(uid);
|
|
1626
1944
|
const r = await s.chatOnce(uid, req);
|
|
@@ -1629,24 +1947,26 @@ var Router = class {
|
|
|
1629
1947
|
if (trace !== void 0) trace.lastError = "no_such_model";
|
|
1630
1948
|
return false;
|
|
1631
1949
|
}
|
|
1632
|
-
pool.noteFailure(uid, r.state, r.message);
|
|
1950
|
+
pool.noteFailure(uid, req.model, r.state, r.message);
|
|
1633
1951
|
if (trace !== void 0) {
|
|
1634
1952
|
trace.attempts += 1;
|
|
1953
|
+
trace.lastState = r.state;
|
|
1635
1954
|
trace.lastError = `${r.state}: ${r.message}`;
|
|
1636
1955
|
}
|
|
1637
1956
|
continue;
|
|
1638
1957
|
}
|
|
1639
|
-
pool.noteSuccess(uid);
|
|
1958
|
+
pool.noteSuccess(uid, req.model);
|
|
1640
1959
|
if (trace !== void 0) trace.uid = uid;
|
|
1641
1960
|
if (probe !== void 0) {
|
|
1642
1961
|
probe.supplier = s.id;
|
|
1643
|
-
probe.model = `${s.getAlias()}/${req.model}`;
|
|
1962
|
+
probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
|
|
1644
1963
|
probe.uid = uid;
|
|
1645
1964
|
}
|
|
1646
1965
|
if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) return true;
|
|
1647
|
-
pool.noteFailure(uid, "transport", "stream failed before first byte");
|
|
1966
|
+
pool.noteFailure(uid, req.model, "transport", "stream failed before first byte");
|
|
1648
1967
|
if (trace !== void 0) {
|
|
1649
1968
|
trace.attempts += 1;
|
|
1969
|
+
trace.lastState = "transport";
|
|
1650
1970
|
trace.lastError = "stream failed before first byte";
|
|
1651
1971
|
}
|
|
1652
1972
|
}
|
|
@@ -1675,8 +1995,9 @@ var Router = class {
|
|
|
1675
1995
|
})
|
|
1676
1996
|
};
|
|
1677
1997
|
let served = false;
|
|
1998
|
+
const trace = { attempts: 0 };
|
|
1678
1999
|
try {
|
|
1679
|
-
served = await this.chatWithSupplier(s, req, sink);
|
|
2000
|
+
served = await this.chatWithSupplier(s, req, sink, trace);
|
|
1680
2001
|
} catch (err) {
|
|
1681
2002
|
return {
|
|
1682
2003
|
ok: false,
|
|
@@ -1685,7 +2006,7 @@ var Router = class {
|
|
|
1685
2006
|
}
|
|
1686
2007
|
if (served && sink.status() < 400) return { ok: true };
|
|
1687
2008
|
const fromSink = served && sink.status() >= 400 ? extractUpstreamError(sink.body()) : "";
|
|
1688
|
-
const detail = fromSink !== "" ? fromSink :
|
|
2009
|
+
const detail = fromSink !== "" ? fromSink : trace.lastError;
|
|
1689
2010
|
return {
|
|
1690
2011
|
ok: false,
|
|
1691
2012
|
error: detail !== void 0 && detail !== "" ? `${detail}(账号/额度/限流问题,非模型问题)` : "所有账号都失败或账号都在冷却中(稍后重试)"
|
|
@@ -1707,26 +2028,21 @@ var Router = class {
|
|
|
1707
2028
|
*
|
|
1708
2029
|
* 纯文本流:组合为文本模型,本 adapter 不处理图片附件。
|
|
1709
2030
|
*/
|
|
1710
|
-
/**
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
if (line === "" && data.length > 0) {
|
|
1721
|
-
yield data;
|
|
1722
|
-
data = "";
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
if (data.length > 0) yield data;
|
|
1726
|
-
}
|
|
1727
|
-
/** 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。 */
|
|
1728
|
-
function wireMessages(options) {
|
|
2031
|
+
/**
|
|
2032
|
+
* 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。
|
|
2033
|
+
*
|
|
2034
|
+
* `system` 必须由调用方从 `options.system` 传进来:agent-loop 把系统提示词
|
|
2035
|
+
* 放在 **options.system 这个独立槽位**,不放进 messages(见 dsh-llm
|
|
2036
|
+
* types.d.ts 的 `system?: string` —— 注释原话 "adapters map to the
|
|
2037
|
+
* provider's system slot")。不读这个槽位,模型每一轮都拿不到身份/规则/
|
|
2038
|
+
* 工具用法约束,且**不会报错**,只是行为悄悄降级。
|
|
2039
|
+
*/
|
|
2040
|
+
function wireMessages(options, system) {
|
|
1729
2041
|
const out = [];
|
|
2042
|
+
if (typeof system === "string" && system !== "") out.push({
|
|
2043
|
+
role: "system",
|
|
2044
|
+
content: system
|
|
2045
|
+
});
|
|
1730
2046
|
for (const message of options.messages) {
|
|
1731
2047
|
if (message.role === "system") {
|
|
1732
2048
|
out.push({
|
|
@@ -1763,14 +2079,14 @@ function wireMessages(options) {
|
|
|
1763
2079
|
for (const result of toolResults) out.push({
|
|
1764
2080
|
role: "tool",
|
|
1765
2081
|
tool_call_id: result.toolCallId,
|
|
1766
|
-
content: flattenText(result.content)
|
|
2082
|
+
content: flattenText(result.content)
|
|
1767
2083
|
});
|
|
1768
2084
|
}
|
|
1769
2085
|
return out;
|
|
1770
2086
|
}
|
|
1771
2087
|
/** 组装 wire 请求体。 */
|
|
1772
2088
|
function wireRequest(options) {
|
|
1773
|
-
const messages = wireMessages(options);
|
|
2089
|
+
const messages = wireMessages(options, options.system);
|
|
1774
2090
|
const body = {
|
|
1775
2091
|
model: options.model,
|
|
1776
2092
|
messages,
|
|
@@ -1789,12 +2105,41 @@ function wireRequest(options) {
|
|
|
1789
2105
|
if (options.temperature !== void 0) body.temperature = options.temperature;
|
|
1790
2106
|
if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
|
|
1791
2107
|
if (options.stop !== void 0) body.stop = options.stop;
|
|
2108
|
+
applyReasoning(body, options.reasoningEffort);
|
|
1792
2109
|
return body;
|
|
1793
2110
|
}
|
|
2111
|
+
/**
|
|
2112
|
+
* 推理强度 → CodeBuddy 的 OpenAI 风格参数。
|
|
2113
|
+
*
|
|
2114
|
+
* 照 9router `executors/codebuddy-cn.js` 的处理:CodeBuddy 只有同时收到
|
|
2115
|
+
* `reasoning_effort` + `reasoning_summary:"auto"` 才吐推理内容,而 harness
|
|
2116
|
+
* 只给 `reasoning_effort`,从不下发 `reasoning_summary`。
|
|
2117
|
+
*
|
|
2118
|
+
* 关键陷阱(9router 注释里的 #2071):**不能无条件加**。对没要推理的普通
|
|
2119
|
+
* 请求强行加 `reasoning_effort:"medium"` + `reasoning_summary`,会让
|
|
2120
|
+
* CodeBuddy 触发内容过滤直接报错。`none`/`off` 也必须是**删字段**而不是传
|
|
2121
|
+
* `"none"` —— 网关没有这个值。
|
|
2122
|
+
*/
|
|
2123
|
+
function applyReasoning(body, effort) {
|
|
2124
|
+
if (effort === void 0 || effort === "") return;
|
|
2125
|
+
if (effort === "none" || effort === "off") {
|
|
2126
|
+
delete body.reasoning_effort;
|
|
2127
|
+
delete body.reasoning_summary;
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
body.reasoning_effort = effort;
|
|
2131
|
+
body.reasoning_summary = "auto";
|
|
2132
|
+
}
|
|
1794
2133
|
function flattenText(blocks) {
|
|
1795
2134
|
return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
1796
2135
|
}
|
|
1797
2136
|
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
2137
|
+
/**
|
|
2138
|
+
* 把上游 SSE 的 `data:` 载荷流翻译成 DSH 的 StreamChunk 事件流。
|
|
2139
|
+
*
|
|
2140
|
+
* 吃 `AsyncIterable`(真流式:边收边吐)或 `Iterable`(测试用数组)。
|
|
2141
|
+
* 用 `for await` 是因为它天然兼容两者 —— 不必为流式再写一份。
|
|
2142
|
+
*/
|
|
1798
2143
|
async function* translateSse(payloads) {
|
|
1799
2144
|
let nextIndex = 0;
|
|
1800
2145
|
let textBlock;
|
|
@@ -1804,6 +2149,8 @@ async function* translateSse(payloads) {
|
|
|
1804
2149
|
let pendingFinish;
|
|
1805
2150
|
/** 攒上游各帧的 usage(归一形态),[DONE] 时统一转成 DSH 契约。 */
|
|
1806
2151
|
let accumulated = null;
|
|
2152
|
+
/** 解析失败的帧数(坏帧跳过,不硬抛;全是坏帧才当断流处理)。 */
|
|
2153
|
+
let malformed = 0;
|
|
1807
2154
|
const open = (kind) => {
|
|
1808
2155
|
const block = {
|
|
1809
2156
|
index: nextIndex++,
|
|
@@ -1813,13 +2160,35 @@ async function* translateSse(payloads) {
|
|
|
1813
2160
|
order.push(block);
|
|
1814
2161
|
return block;
|
|
1815
2162
|
};
|
|
1816
|
-
for (const payload of payloads) {
|
|
2163
|
+
for await (const payload of payloads) {
|
|
1817
2164
|
if (payload === "[DONE]") {
|
|
1818
|
-
|
|
2165
|
+
const complete = order.filter((block) => block.kind !== "tool-call" || completeJson(block.text));
|
|
2166
|
+
const dropped = order.length - complete.length;
|
|
2167
|
+
for (const block of complete) yield {
|
|
1819
2168
|
type: "block-end",
|
|
1820
2169
|
index: block.index,
|
|
1821
2170
|
block: closeBlock(block)
|
|
1822
2171
|
};
|
|
2172
|
+
for (const block of order) {
|
|
2173
|
+
if (block.kind !== "tool-call" || completeJson(block.text)) continue;
|
|
2174
|
+
yield {
|
|
2175
|
+
type: "block-end",
|
|
2176
|
+
index: block.index,
|
|
2177
|
+
block: {
|
|
2178
|
+
type: "tool-call",
|
|
2179
|
+
id: CallId(block.callId ?? ""),
|
|
2180
|
+
name: "",
|
|
2181
|
+
arguments: "{}"
|
|
2182
|
+
}
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
if (dropped > 0) pendingFinish = {
|
|
2186
|
+
kind: "error",
|
|
2187
|
+
failure: {
|
|
2188
|
+
message: `upstream closed the stream before ${dropped} tool call argument${dropped > 1 ? "s were" : " was"} complete`,
|
|
2189
|
+
code: EMPTY_RESPONSE_CODE
|
|
2190
|
+
}
|
|
2191
|
+
};
|
|
1823
2192
|
if (accumulated !== null) {
|
|
1824
2193
|
const usage = toTokenUsage(accumulated);
|
|
1825
2194
|
if (usage !== null) yield {
|
|
@@ -1844,7 +2213,8 @@ async function* translateSse(payloads) {
|
|
|
1844
2213
|
try {
|
|
1845
2214
|
chunk = JSON.parse(payload);
|
|
1846
2215
|
} catch {
|
|
1847
|
-
|
|
2216
|
+
malformed += 1;
|
|
2217
|
+
continue;
|
|
1848
2218
|
}
|
|
1849
2219
|
for (const choice of chunk.choices ?? []) {
|
|
1850
2220
|
const delta = choice.delta ?? {};
|
|
@@ -1907,24 +2277,33 @@ async function* translateSse(payloads) {
|
|
|
1907
2277
|
argumentsDelta: fragment
|
|
1908
2278
|
};
|
|
1909
2279
|
}
|
|
1910
|
-
if (typeof choice.finish_reason === "string")
|
|
2280
|
+
if (typeof choice.finish_reason === "string") {
|
|
2281
|
+
const mapped = mapFinishReason(choice.finish_reason);
|
|
2282
|
+
if (mapped !== void 0) pendingFinish = mapped;
|
|
2283
|
+
}
|
|
1911
2284
|
}
|
|
1912
2285
|
if (chunk.usage !== void 0) accumulated = mergeUsage(accumulated, normalizeUsage(chunk.usage));
|
|
1913
2286
|
}
|
|
1914
|
-
|
|
2287
|
+
const detail = malformed > 0 ? ` (${malformed} frame(s) unparseable)` : "";
|
|
2288
|
+
throw new LlmError(`SSE payload stream ended without [DONE]${detail}`, "TRANSPORT");
|
|
1915
2289
|
}
|
|
2290
|
+
/** 上游 finish_reason → DSH finish reason;空串(「未终止」)返回 undefined。 */
|
|
1916
2291
|
function mapFinishReason(reason) {
|
|
1917
2292
|
switch (reason) {
|
|
2293
|
+
case "": return;
|
|
1918
2294
|
case "stop": return { kind: "stop" };
|
|
1919
2295
|
case "tool_calls": return { kind: "tool-calls" };
|
|
1920
2296
|
case "length": return { kind: "max-tokens" };
|
|
1921
|
-
default:
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
2297
|
+
default: {
|
|
2298
|
+
const code = reason.trim().toUpperCase();
|
|
2299
|
+
return {
|
|
2300
|
+
kind: "error",
|
|
2301
|
+
failure: {
|
|
2302
|
+
message: `model stopped: ${reason}`,
|
|
2303
|
+
code: code === "" ? "UNKNOWN_FINISH_REASON" : code
|
|
2304
|
+
}
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
1928
2307
|
}
|
|
1929
2308
|
}
|
|
1930
2309
|
function closeBlock(block) {
|
|
@@ -1941,11 +2320,27 @@ function closeBlock(block) {
|
|
|
1941
2320
|
type: "tool-call",
|
|
1942
2321
|
id: CallId(block.callId ?? ""),
|
|
1943
2322
|
name: block.name ?? "",
|
|
1944
|
-
arguments: block.text
|
|
2323
|
+
arguments: block.text === "" ? "{}" : block.text
|
|
1945
2324
|
};
|
|
1946
2325
|
}
|
|
1947
2326
|
}
|
|
1948
2327
|
/**
|
|
2328
|
+
* 工具调用的 arguments 是否已经收完整(能解析成一个 JSON 对象)。
|
|
2329
|
+
*
|
|
2330
|
+
* 判断口径刻意简单:只看能不能 `JSON.parse` 出对象。不校验 schema——
|
|
2331
|
+
* 那是工具自己的活,adapter 只负责「别把半成品交出去」。
|
|
2332
|
+
* 空串视为完整(无参工具),因为上游不发参数分片是合法的。
|
|
2333
|
+
*/
|
|
2334
|
+
function completeJson(text) {
|
|
2335
|
+
if (text.trim() === "") return true;
|
|
2336
|
+
try {
|
|
2337
|
+
const v = JSON.parse(text);
|
|
2338
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2339
|
+
} catch {
|
|
2340
|
+
return false;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
1949
2344
|
* DSH adapter:provider `router`。模型目录 = 组合;stream 转发到本插件
|
|
1950
2345
|
* /v1/chat/completions(组合路由在 /v1 内完成)。
|
|
1951
2346
|
*/
|
|
@@ -1988,6 +2383,7 @@ var RouterAdapter = class extends LlmAdapter {
|
|
|
1988
2383
|
resp = await fetch(`${this.baseURL}/chat/completions`, {
|
|
1989
2384
|
method: "POST",
|
|
1990
2385
|
headers: {
|
|
2386
|
+
...attributionHeaders(),
|
|
1991
2387
|
"content-type": "application/json",
|
|
1992
2388
|
accept: "text/event-stream"
|
|
1993
2389
|
},
|
|
@@ -2003,9 +2399,77 @@ var RouterAdapter = class extends LlmAdapter {
|
|
|
2003
2399
|
const detail = await resp.text().catch(() => "");
|
|
2004
2400
|
throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, "TRANSPORT");
|
|
2005
2401
|
}
|
|
2006
|
-
|
|
2402
|
+
if (resp.body === null) return;
|
|
2403
|
+
yield* translateSse(ssePayloads(resp.body));
|
|
2007
2404
|
}
|
|
2008
2405
|
};
|
|
2406
|
+
/**
|
|
2407
|
+
* 已攒的 payload 是否已经是完整的(`[DONE]` 或一个能解析的 JSON 对象)。
|
|
2408
|
+
*
|
|
2409
|
+
* 只用于「缺空行分隔」时的前瞻回退,不校验业务结构 —— 那是 translateSse 的事。
|
|
2410
|
+
*/
|
|
2411
|
+
function looksComplete(payload) {
|
|
2412
|
+
if (payload === "[DONE]") return true;
|
|
2413
|
+
try {
|
|
2414
|
+
JSON.parse(payload);
|
|
2415
|
+
return true;
|
|
2416
|
+
} catch {
|
|
2417
|
+
return false;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
/**
|
|
2421
|
+
* 从上游响应体**增量**产出 SSE 的 `data:` 载荷。
|
|
2422
|
+
*
|
|
2423
|
+
* 为什么必须逐块读:曾经这里写 `await resp.text()` 攒完整串再解析,等于
|
|
2424
|
+
* 把流式降级成批处理 —— 首字节延迟等于整个响应耗时;更糟的是中途断流时
|
|
2425
|
+
* resp.text() 只拿到半截文本,最后一帧是半截 JSON(`{"command": "cd`),
|
|
2426
|
+
* 表现为「工具参数残缺」。短响应连接不断,所以本地抓包复现不了。
|
|
2427
|
+
*
|
|
2428
|
+
* 跨块边界:一个 `data:` 行可能横跨两次 read(),所以留 `buffer` 接住
|
|
2429
|
+
* 最后一段未完整的行(9router 的 pipeTransformedSSE 同样处理)。
|
|
2430
|
+
*/
|
|
2431
|
+
async function* ssePayloads(body) {
|
|
2432
|
+
const reader = body.getReader();
|
|
2433
|
+
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
2434
|
+
let buffer = "";
|
|
2435
|
+
/** 攒一帧内的多行 data(SSE 规范:多行用 \n 连起来)。**必须**是局部
|
|
2436
|
+
* 状态 —— 放模块级会让并发请求互相串数据。 */
|
|
2437
|
+
let payload = "";
|
|
2438
|
+
try {
|
|
2439
|
+
for (;;) {
|
|
2440
|
+
const { done, value } = await reader.read();
|
|
2441
|
+
if (done) break;
|
|
2442
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2443
|
+
const lines = buffer.split("\n");
|
|
2444
|
+
buffer = lines.pop() ?? "";
|
|
2445
|
+
for (const line of lines) {
|
|
2446
|
+
const t = line.trim();
|
|
2447
|
+
if (t.startsWith("data:")) {
|
|
2448
|
+
if (payload.length > 0 && looksComplete(payload)) {
|
|
2449
|
+
yield payload;
|
|
2450
|
+
payload = "";
|
|
2451
|
+
}
|
|
2452
|
+
payload += (payload.length > 0 ? "\n" : "") + t.slice(5).trimStart();
|
|
2453
|
+
continue;
|
|
2454
|
+
}
|
|
2455
|
+
if (t === "" && payload.length > 0) {
|
|
2456
|
+
yield payload;
|
|
2457
|
+
payload = "";
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
if (buffer.length > 0) {
|
|
2462
|
+
const t = buffer.trim();
|
|
2463
|
+
if (t.startsWith("data:")) payload += (payload.length > 0 ? "\n" : "") + t.slice(5).trimStart();
|
|
2464
|
+
}
|
|
2465
|
+
if (payload.length > 0) yield payload;
|
|
2466
|
+
} finally {
|
|
2467
|
+
try {
|
|
2468
|
+
reader.releaseLock();
|
|
2469
|
+
} catch {}
|
|
2470
|
+
await body.cancel().catch(() => {});
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2009
2473
|
//#endregion
|
|
2010
2474
|
//#region src/keys.ts
|
|
2011
2475
|
/**
|
|
@@ -2133,6 +2597,26 @@ var KeysStore = class {
|
|
|
2133
2597
|
function isFn(v) {
|
|
2134
2598
|
return typeof v === "function";
|
|
2135
2599
|
}
|
|
2600
|
+
/**
|
|
2601
|
+
* 积分回灌(核心统一持久化,插件不落盘)。
|
|
2602
|
+
*
|
|
2603
|
+
* 插件的积分是异步拉的:重启后第一次 status() 拿不到真值,报 `CREDITS_UNKNOWN`
|
|
2604
|
+
* (-1)。这里做两件事:
|
|
2605
|
+
* - 报了真值 → 写入 supplier-config.json(下次重启还在)
|
|
2606
|
+
* - 报了 -1 → 用上次存的顶上,面板不会闪 0
|
|
2607
|
+
*
|
|
2608
|
+
* 插件不该自己把 0 当「未知」报上来 —— 0 是真值(用完了),核心分不清,
|
|
2609
|
+
* 会把缓存冲成 0。这条是本层存在的理由(2026-09-03)。
|
|
2610
|
+
*/
|
|
2611
|
+
function hydrateCredits(supplierId, store, accounts) {
|
|
2612
|
+
return accounts.map((a) => {
|
|
2613
|
+
const merged = store.putCredits(supplierId, a.uid, a.credits);
|
|
2614
|
+
return merged === a.credits ? a : {
|
|
2615
|
+
...a,
|
|
2616
|
+
credits: merged
|
|
2617
|
+
};
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2136
2620
|
/** 从模块导出解析出供应商模块对象(支持 export default / 命名导出 / factory)。 */
|
|
2137
2621
|
function resolveModule(mod, file) {
|
|
2138
2622
|
const cand = mod.default ?? mod;
|
|
@@ -2175,7 +2659,7 @@ function wrapModule(instance, env, source, kind = "external") {
|
|
|
2175
2659
|
if (typeof instance !== "object" || instance === null || typeof instance.id !== "string" || instance.id === "") throw new Error(`供应商模块无效: ${source}`);
|
|
2176
2660
|
const capabilities = /* @__PURE__ */ new Set();
|
|
2177
2661
|
for (const key of DIFF_CAPS) if (isFn(instance[key])) capabilities.add(key);
|
|
2178
|
-
const pool = new AccountPool();
|
|
2662
|
+
const pool = new AccountPool(instance.id);
|
|
2179
2663
|
const supplier = {
|
|
2180
2664
|
id: instance.id,
|
|
2181
2665
|
name: instance.name,
|
|
@@ -2186,7 +2670,7 @@ function wrapModule(instance, env, source, kind = "external") {
|
|
|
2186
2670
|
return {
|
|
2187
2671
|
id: now.id,
|
|
2188
2672
|
name: now.name,
|
|
2189
|
-
accounts: pool.decorate(now.accounts)
|
|
2673
|
+
accounts: pool.decorate(hydrateCredits(instance.id, env.store, now.accounts))
|
|
2190
2674
|
};
|
|
2191
2675
|
},
|
|
2192
2676
|
listModels: (force) => instance.listModels(force),
|
|
@@ -2204,7 +2688,6 @@ function wrapModule(instance, env, source, kind = "external") {
|
|
|
2204
2688
|
customModelIds: () => [...env.store.get(instance.id).custom],
|
|
2205
2689
|
accounts: () => instance.status().accounts,
|
|
2206
2690
|
chatOnce: (uid, req) => instance.chatOnce(uid, req),
|
|
2207
|
-
lastError: isFn(instance.lastError) ? () => instance.lastError() : void 0,
|
|
2208
2691
|
removeLink: isFn(instance.removeLink) ? (uid) => instance.removeLink(uid) : void 0,
|
|
2209
2692
|
dispose: () => instance.dispose(),
|
|
2210
2693
|
pool
|
|
@@ -2577,8 +3060,9 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
2577
3060
|
});
|
|
2578
3061
|
return;
|
|
2579
3062
|
}
|
|
2580
|
-
const
|
|
2581
|
-
const ok = await s.removeLink(
|
|
3063
|
+
const uid = JSON.parse(await readBody$1(req)).uid ?? "";
|
|
3064
|
+
const ok = await s.removeLink(uid);
|
|
3065
|
+
if (ok) store.clearCredits(s.id, uid);
|
|
2582
3066
|
writeJson$1(res, ok ? 200 : 404, ok ? { ok: true } : {
|
|
2583
3067
|
ok: false,
|
|
2584
3068
|
error: "link not found"
|
|
@@ -2632,14 +3116,17 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
2632
3116
|
}
|
|
2633
3117
|
const succeeded = results.filter((r) => r.status === "ok").length;
|
|
2634
3118
|
const already = results.filter((r) => r.status === "already").length;
|
|
3119
|
+
const failed = results.length - succeeded - already;
|
|
3120
|
+
const ok = failed === 0 && results.length > 0;
|
|
2635
3121
|
const payload = {
|
|
2636
|
-
ok
|
|
3122
|
+
ok,
|
|
2637
3123
|
total: uids.length,
|
|
2638
3124
|
succeeded,
|
|
2639
3125
|
already,
|
|
3126
|
+
failed,
|
|
2640
3127
|
results
|
|
2641
3128
|
};
|
|
2642
|
-
writeJson$1(res,
|
|
3129
|
+
writeJson$1(res, ok ? 200 : 400, payload);
|
|
2643
3130
|
}
|
|
2644
3131
|
});
|
|
2645
3132
|
return routes;
|