dsh-router-core 0.1.0 → 0.2.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 +36 -12
- package/lib/client-registry.js +92 -66
- package/lib/client.js +92 -66
- package/lib/index.js +567 -204
- package/lib/types/router/account-pool.d.ts +41 -0
- package/lib/types/router/index.d.ts +43 -4
- package/lib/types/router/types.d.ts +12 -10
- package/lib/types/supplier-config.d.ts +0 -3
- 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 +7 -5
- package/src/client/SupplierDetail.tsx +58 -34
- package/src/client/router.css +9 -0
- package/src/index.ts +2 -2
- package/src/router/account-pool.test.ts +120 -0
- package/src/router/account-pool.ts +161 -0
- package/src/router/index.ts +233 -19
- package/src/router/models-cache.test.ts +186 -0
- package/src/router/types.ts +12 -7
- package/src/supplier-config.ts +1 -12
- package/src/suppliers/contract.ts +71 -8
- package/src/suppliers/loader.ts +19 -12
- package/src/suppliers/nvidia/plugin.ts +161 -118
- package/src/suppliers/opencode/plugin.ts +47 -94
- package/src/suppliers/openrouter/plugin.ts +67 -121
- package/src/suppliers/registry.test.ts +194 -0
- package/src/suppliers/registry.ts +72 -57
package/lib/index.js
CHANGED
|
@@ -10,12 +10,265 @@ 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
|
+
constructor(stateFile) {
|
|
175
|
+
this.fp = stateFile ? join(dirname(stateFile), "supplier-config.json") : "";
|
|
176
|
+
this.load();
|
|
177
|
+
}
|
|
178
|
+
/** 读取某供应商配置(不存在则返回默认并注册)。 */
|
|
179
|
+
get(supplierId) {
|
|
180
|
+
let cfg = this.bySupplier.get(supplierId);
|
|
181
|
+
if (!cfg) {
|
|
182
|
+
cfg = {
|
|
183
|
+
alias: "",
|
|
184
|
+
disabled: [],
|
|
185
|
+
custom: [],
|
|
186
|
+
poolOrder: [],
|
|
187
|
+
poolStrategy: "fallback"
|
|
188
|
+
};
|
|
189
|
+
this.bySupplier.set(supplierId, cfg);
|
|
190
|
+
}
|
|
191
|
+
return cfg;
|
|
192
|
+
}
|
|
193
|
+
setAlias(supplierId, alias) {
|
|
194
|
+
this.get(supplierId).alias = alias;
|
|
195
|
+
this.saveLocked();
|
|
196
|
+
}
|
|
197
|
+
setModelEnabled(supplierId, modelId, enabled) {
|
|
198
|
+
const cfg = this.get(supplierId);
|
|
199
|
+
cfg.disabled = enabled ? cfg.disabled.filter((m) => m !== modelId) : [.../* @__PURE__ */ new Set([...cfg.disabled, modelId])];
|
|
200
|
+
this.saveLocked();
|
|
201
|
+
}
|
|
202
|
+
setAllModelsEnabled(supplierId, enabled, allModelIds) {
|
|
203
|
+
const cfg = this.get(supplierId);
|
|
204
|
+
cfg.disabled = enabled ? [] : [...new Set(allModelIds)];
|
|
205
|
+
this.saveLocked();
|
|
206
|
+
}
|
|
207
|
+
addCustomModel(supplierId, modelId) {
|
|
208
|
+
const cfg = this.get(supplierId);
|
|
209
|
+
if (!cfg.custom.includes(modelId)) cfg.custom.push(modelId);
|
|
210
|
+
this.saveLocked();
|
|
211
|
+
}
|
|
212
|
+
removeCustomModel(supplierId, modelId) {
|
|
213
|
+
const cfg = this.get(supplierId);
|
|
214
|
+
cfg.custom = cfg.custom.filter((m) => m !== modelId);
|
|
215
|
+
cfg.disabled = cfg.disabled.filter((m) => m !== modelId);
|
|
216
|
+
this.saveLocked();
|
|
217
|
+
}
|
|
218
|
+
setPoolOrder(supplierId, uids) {
|
|
219
|
+
this.get(supplierId).poolOrder = [...new Set(uids)];
|
|
220
|
+
this.saveLocked();
|
|
221
|
+
}
|
|
222
|
+
setPoolStrategy(supplierId, strategy) {
|
|
223
|
+
if (strategy !== "fallback" && strategy !== "round-robin") return;
|
|
224
|
+
this.get(supplierId).poolStrategy = strategy;
|
|
225
|
+
this.saveLocked();
|
|
226
|
+
}
|
|
227
|
+
load() {
|
|
228
|
+
if (this.fp === "") return;
|
|
229
|
+
let f;
|
|
230
|
+
try {
|
|
231
|
+
f = JSON.parse(readFileSync(this.fp, "utf8"));
|
|
232
|
+
} catch {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
for (const [id, c] of Object.entries(f.suppliers ?? {})) this.bySupplier.set(id, {
|
|
236
|
+
alias: typeof c.alias === "string" ? c.alias : "",
|
|
237
|
+
disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === "string") : [],
|
|
238
|
+
custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === "string") : [],
|
|
239
|
+
poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === "string") : [],
|
|
240
|
+
poolStrategy: c.poolStrategy === "round-robin" ? "round-robin" : "fallback"
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
saveLocked() {
|
|
244
|
+
if (this.fp === "") return;
|
|
245
|
+
const f = { suppliers: {} };
|
|
246
|
+
for (const [id, cfg] of this.bySupplier) f.suppliers[id] = {
|
|
247
|
+
alias: cfg.alias,
|
|
248
|
+
disabled: [...cfg.disabled],
|
|
249
|
+
custom: [...cfg.custom],
|
|
250
|
+
poolOrder: [...cfg.poolOrder],
|
|
251
|
+
poolStrategy: cfg.poolStrategy
|
|
252
|
+
};
|
|
253
|
+
try {
|
|
254
|
+
const dir = dirname(this.fp);
|
|
255
|
+
if (dir !== "" && dir !== ".") mkdirSync(dir, { recursive: true });
|
|
256
|
+
const raw = JSON.stringify(f, null, 2);
|
|
257
|
+
const tmp = this.fp + ".tmp";
|
|
258
|
+
writeFileSync(tmp, raw, { mode: 384 });
|
|
259
|
+
renameSync(tmp, this.fp);
|
|
260
|
+
} catch {}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
//#endregion
|
|
13
264
|
//#region src/router/index.ts
|
|
14
265
|
/**
|
|
15
266
|
* dsh-router 路由器本体:供应商注册表 + OpenAI 兼容 /v1/* 处理。
|
|
16
267
|
* 仿 9router:组合 = 一组模型,请求 model 命中组合名时,按策略
|
|
17
268
|
* (fallback 顺序尝试 / round-robin 轮转)选中一个模型路由。
|
|
18
269
|
*/
|
|
270
|
+
/** 模型列表缓存有效期。 */
|
|
271
|
+
const MODELS_TTL_MS = 6e4;
|
|
19
272
|
function openAIError(code, msg) {
|
|
20
273
|
return { error: {
|
|
21
274
|
message: msg,
|
|
@@ -30,6 +283,76 @@ function writeJson$2(res, status, body) {
|
|
|
30
283
|
});
|
|
31
284
|
res.end(JSON.stringify(body));
|
|
32
285
|
}
|
|
286
|
+
/** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
|
|
287
|
+
function sinkRes() {
|
|
288
|
+
let status = 200;
|
|
289
|
+
let text = "";
|
|
290
|
+
const self = {
|
|
291
|
+
headersSent: false,
|
|
292
|
+
writableEnded: false,
|
|
293
|
+
writeHead: (code) => {
|
|
294
|
+
status = code;
|
|
295
|
+
return self;
|
|
296
|
+
},
|
|
297
|
+
write: (chunk) => {
|
|
298
|
+
if (chunk !== void 0) text += String(chunk);
|
|
299
|
+
return true;
|
|
300
|
+
},
|
|
301
|
+
end: (chunk) => {
|
|
302
|
+
if (chunk !== void 0) text += String(chunk);
|
|
303
|
+
return self;
|
|
304
|
+
},
|
|
305
|
+
flushHeaders: () => {},
|
|
306
|
+
status: () => status,
|
|
307
|
+
body: () => text
|
|
308
|
+
};
|
|
309
|
+
return self;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* 把 chatOnce 的结果写进 res(核心独占响应写入权)。
|
|
313
|
+
* 流式:SSE 头 + pipe 上游已转好的 OpenAI SSE;非流式:原样写 JSON。
|
|
314
|
+
*/
|
|
315
|
+
async function writeChatResult(res, r) {
|
|
316
|
+
if (!r.ok) return;
|
|
317
|
+
if (!("stream" in r)) {
|
|
318
|
+
res.writeHead(r.status, {
|
|
319
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
320
|
+
"Cache-Control": "no-store"
|
|
321
|
+
});
|
|
322
|
+
res.end(r.body);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
res.writeHead(200, {
|
|
326
|
+
"Content-Type": "text/event-stream",
|
|
327
|
+
"Cache-Control": "no-cache",
|
|
328
|
+
Connection: "keep-alive",
|
|
329
|
+
"X-Accel-Buffering": "no"
|
|
330
|
+
});
|
|
331
|
+
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
332
|
+
const onError = () => {
|
|
333
|
+
res.destroy();
|
|
334
|
+
};
|
|
335
|
+
res.once("error", onError);
|
|
336
|
+
try {
|
|
337
|
+
await r.stream.pipeTo(new WritableStream({ write: (chunk) => new Promise((resolve) => {
|
|
338
|
+
if (res.write(chunk)) resolve();
|
|
339
|
+
else res.once("drain", resolve);
|
|
340
|
+
}) }));
|
|
341
|
+
} catch {} finally {
|
|
342
|
+
res.removeListener("error", onError);
|
|
343
|
+
res.end();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
|
|
347
|
+
function extractUpstreamError(body) {
|
|
348
|
+
try {
|
|
349
|
+
const j = JSON.parse(body);
|
|
350
|
+
if (typeof j.error?.message === "string") return j.error.message;
|
|
351
|
+
if (typeof j.message === "string") return j.message;
|
|
352
|
+
if (typeof j.msg === "string") return j.msg;
|
|
353
|
+
} catch {}
|
|
354
|
+
return body.slice(0, 200);
|
|
355
|
+
}
|
|
33
356
|
/** 路由器。 */
|
|
34
357
|
var Router = class {
|
|
35
358
|
suppliers = [];
|
|
@@ -37,8 +360,13 @@ var Router = class {
|
|
|
37
360
|
customCombos = [];
|
|
38
361
|
/** round-robin 轮转游标(按组合 id 记忆)。 */
|
|
39
362
|
rrCursors = /* @__PURE__ */ new Map();
|
|
40
|
-
|
|
363
|
+
/** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
|
|
364
|
+
store;
|
|
365
|
+
/** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
|
|
366
|
+
modelsCache = /* @__PURE__ */ new Map();
|
|
367
|
+
constructor(stateFile = "", store) {
|
|
41
368
|
this.combosFp = stateFile ? join(dirname(stateFile), "combos.json") : "";
|
|
369
|
+
this.store = store ?? new SupplierConfigStore(stateFile);
|
|
42
370
|
this.loadCombos();
|
|
43
371
|
}
|
|
44
372
|
loadCombos() {
|
|
@@ -48,7 +376,7 @@ var Router = class {
|
|
|
48
376
|
if (Array.isArray(f.combos)) this.customCombos = f.combos.filter((c) => typeof c.id === "string" && c.id !== "").map((c) => {
|
|
49
377
|
const old = c;
|
|
50
378
|
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
|
-
const slash = m.
|
|
379
|
+
const slash = m.indexOf("/");
|
|
52
380
|
return slash >= 0 ? m.slice(slash + 1) : m;
|
|
53
381
|
});
|
|
54
382
|
return {
|
|
@@ -80,6 +408,7 @@ var Router = class {
|
|
|
80
408
|
const i = this.suppliers.findIndex((s) => s.id === id);
|
|
81
409
|
if (i < 0) return false;
|
|
82
410
|
const [s] = this.suppliers.splice(i, 1);
|
|
411
|
+
this.modelsCache.delete(id);
|
|
83
412
|
s?.dispose();
|
|
84
413
|
return true;
|
|
85
414
|
}
|
|
@@ -120,21 +449,67 @@ var Router = class {
|
|
|
120
449
|
async combos() {
|
|
121
450
|
return [...this.customCombos];
|
|
122
451
|
}
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
452
|
+
/**
|
|
453
|
+
* 取某供应商的模型(核心统一缓存)。
|
|
454
|
+
*
|
|
455
|
+
* 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
|
|
456
|
+
* `/combos`(组合加模型)共用。组合面板**不主动打上游**——它只是读缓存,
|
|
457
|
+
* 冷启动(缓存还没建)时才拉一次;真正刷新由详情页打开或「获取模型」
|
|
458
|
+
* 按钮(force)触发。
|
|
459
|
+
*
|
|
460
|
+
* 天花板:TTL 60s 是拍的。若上游模型列表变更很频繁,可调小;要彻底实时
|
|
461
|
+
* 就得让供应商暴露 etag/版本号,目前没有这个需求。
|
|
462
|
+
*/
|
|
463
|
+
async modelsOf(supplierId, force = false) {
|
|
464
|
+
const s = this.suppliers.find((x) => x.id === supplierId);
|
|
465
|
+
if (s === void 0) return [];
|
|
466
|
+
const hit = this.modelsCache.get(supplierId);
|
|
467
|
+
if (!force && hit !== void 0 && Date.now() - hit.fetchedAt < MODELS_TTL_MS) return hit.models;
|
|
468
|
+
const cfg = this.store.get(supplierId);
|
|
469
|
+
const custom = new Set(cfg.custom);
|
|
470
|
+
const disabled = new Set(cfg.disabled);
|
|
471
|
+
const models = (await Promise.resolve(s.listModels())).map((mm) => ({
|
|
472
|
+
...mm,
|
|
473
|
+
enabled: !disabled.has(mm.id),
|
|
474
|
+
custom: custom.has(mm.id) ? true : void 0
|
|
475
|
+
}));
|
|
476
|
+
const seen = new Set(models.map((mm) => mm.id));
|
|
477
|
+
for (const id of custom) if (!seen.has(id)) {
|
|
478
|
+
seen.add(id);
|
|
479
|
+
models.push({
|
|
480
|
+
id,
|
|
481
|
+
enabled: !disabled.has(id),
|
|
482
|
+
custom: true
|
|
135
483
|
});
|
|
136
|
-
}
|
|
137
|
-
|
|
484
|
+
}
|
|
485
|
+
this.modelsCache.set(supplierId, {
|
|
486
|
+
models,
|
|
487
|
+
fetchedAt: Date.now()
|
|
488
|
+
});
|
|
489
|
+
return models;
|
|
490
|
+
}
|
|
491
|
+
/** 失效某供应商的模型缓存(增删改模型后调用)。 */
|
|
492
|
+
invalidateModels(supplierId) {
|
|
493
|
+
this.modelsCache.delete(supplierId);
|
|
494
|
+
}
|
|
495
|
+
/** 可用模型(按供应商分组,仅启用),面板加模型用。
|
|
496
|
+
* 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
|
|
497
|
+
async supplierModels() {
|
|
498
|
+
return (await Promise.all(this.suppliers.map(async (s) => {
|
|
499
|
+
try {
|
|
500
|
+
const models = (await this.modelsOf(s.id)).filter((m) => m.enabled);
|
|
501
|
+
return {
|
|
502
|
+
supplier: {
|
|
503
|
+
id: s.id,
|
|
504
|
+
name: s.name,
|
|
505
|
+
alias: s.getAlias()
|
|
506
|
+
},
|
|
507
|
+
models
|
|
508
|
+
};
|
|
509
|
+
} catch {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
}))).filter((g) => g !== void 0);
|
|
138
513
|
}
|
|
139
514
|
validModels(models) {
|
|
140
515
|
return Array.isArray(models) && models.length > 0 && models.every((m) => typeof m === "string" && m !== "");
|
|
@@ -142,11 +517,15 @@ var Router = class {
|
|
|
142
517
|
validStrategy(strategy) {
|
|
143
518
|
return strategy === "fallback" || strategy === "round-robin";
|
|
144
519
|
}
|
|
145
|
-
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
520
|
+
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
521
|
+
* 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
|
|
522
|
+
* (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
|
|
523
|
+
* 命名空间一起吃掉,后面请求必然 404。 */
|
|
146
524
|
normalizeModelIds(models) {
|
|
525
|
+
const aliases = this.suppliers.map((s) => s.getAlias()).filter((a) => a !== "");
|
|
147
526
|
return models.map((m) => {
|
|
148
|
-
const
|
|
149
|
-
return
|
|
527
|
+
const prefix = aliases.find((a) => m.startsWith(`${a}/`));
|
|
528
|
+
return prefix === void 0 ? m : m.slice(prefix.length + 1);
|
|
150
529
|
});
|
|
151
530
|
}
|
|
152
531
|
/** 创建组合(name 唯一,非 default)。 */
|
|
@@ -257,10 +636,10 @@ var Router = class {
|
|
|
257
636
|
writeJson$2(res, 503, openAIError("no_healthy_supplier", `combo ${JSON.stringify(req.model)}: all models unavailable`));
|
|
258
637
|
return;
|
|
259
638
|
}
|
|
260
|
-
|
|
639
|
+
if (await this.chatWithModel(req, res, req.model)) return;
|
|
261
640
|
writeJson$2(res, 503, openAIError("no_healthy_supplier", "all suppliers unavailable"));
|
|
262
641
|
}
|
|
263
|
-
/** 把 model
|
|
642
|
+
/** 把 model 改写为组合选中的模型名后,遍历供应商 + 账号(策略全在核心)。 */
|
|
264
643
|
async chatWithModel(req, res, model) {
|
|
265
644
|
const clone = {
|
|
266
645
|
...req,
|
|
@@ -273,9 +652,86 @@ var Router = class {
|
|
|
273
652
|
} catch {
|
|
274
653
|
clone.rawBody = req.rawBody;
|
|
275
654
|
}
|
|
276
|
-
for (const s of this.suppliers) if (await
|
|
655
|
+
for (const s of this.suppliers) if (await this.chatWithSupplier(s, clone, res)) return true;
|
|
277
656
|
return false;
|
|
278
657
|
}
|
|
658
|
+
/**
|
|
659
|
+
* 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
|
|
660
|
+
* AccountState 处置 → 失败换号;写响应只在成功后发生。
|
|
661
|
+
*
|
|
662
|
+
* 流式一旦写出响应头就绑死(上游协议如此,9router 同样如此),所以
|
|
663
|
+
* 「拿到 ok:true 的流」即视为该账号成功,之后出错不再换号——要能回退
|
|
664
|
+
* 就得在写头前缓冲判定,代价是首字节延迟,目前未做。
|
|
665
|
+
*/
|
|
666
|
+
async chatWithSupplier(s, req, res) {
|
|
667
|
+
const pool = s.pool;
|
|
668
|
+
const cfg = this.store.get(s.id);
|
|
669
|
+
if (s.accounts !== void 0 && s.accounts().length === 0) {
|
|
670
|
+
const r = await s.chatOnce("", req);
|
|
671
|
+
if (!r.ok) {
|
|
672
|
+
if (r.state === "no_such_model") return false;
|
|
673
|
+
pool.noteFailure("", r.state, r.message);
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
await writeChatResult(res, r);
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
const tried = /* @__PURE__ */ new Set();
|
|
680
|
+
for (;;) {
|
|
681
|
+
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy);
|
|
682
|
+
if (uid === void 0) return false;
|
|
683
|
+
tried.add(uid);
|
|
684
|
+
const r = await s.chatOnce(uid, req);
|
|
685
|
+
if (!r.ok) {
|
|
686
|
+
if (r.state === "no_such_model") return false;
|
|
687
|
+
pool.noteFailure(uid, r.state, r.message);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
pool.noteSuccess(uid);
|
|
691
|
+
await writeChatResult(res, r);
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/** 测试某供应商的某模型是否可用。
|
|
696
|
+
* 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
|
|
697
|
+
* 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。 */
|
|
698
|
+
async testModel(supplierId, model) {
|
|
699
|
+
const s = this.suppliers.find((x) => x.id === supplierId);
|
|
700
|
+
if (s === void 0) return {
|
|
701
|
+
ok: false,
|
|
702
|
+
error: `unknown supplier ${JSON.stringify(supplierId)}`
|
|
703
|
+
};
|
|
704
|
+
const sink = sinkRes();
|
|
705
|
+
const req = {
|
|
706
|
+
model,
|
|
707
|
+
stream: false,
|
|
708
|
+
rawBody: JSON.stringify({
|
|
709
|
+
model,
|
|
710
|
+
messages: [{
|
|
711
|
+
role: "user",
|
|
712
|
+
content: "ping"
|
|
713
|
+
}],
|
|
714
|
+
stream: false,
|
|
715
|
+
max_tokens: 1
|
|
716
|
+
})
|
|
717
|
+
};
|
|
718
|
+
let served = false;
|
|
719
|
+
try {
|
|
720
|
+
served = await this.chatWithSupplier(s, req, sink);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
return {
|
|
723
|
+
ok: false,
|
|
724
|
+
error: err.message
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
if (served && sink.status() < 400) return { ok: true };
|
|
728
|
+
const fromSink = served && sink.status() >= 400 ? extractUpstreamError(sink.body()) : "";
|
|
729
|
+
const detail = fromSink !== "" ? fromSink : s.lastError?.();
|
|
730
|
+
return {
|
|
731
|
+
ok: false,
|
|
732
|
+
error: detail !== void 0 && detail !== "" ? `${detail}(账号/额度/限流问题,非模型问题)` : "所有账号都失败或账号都在冷却中(稍后重试)"
|
|
733
|
+
};
|
|
734
|
+
}
|
|
279
735
|
dispose() {
|
|
280
736
|
for (const s of this.suppliers) s.dispose();
|
|
281
737
|
this.suppliers = [];
|
|
@@ -720,7 +1176,6 @@ function resolveModule(mod, file) {
|
|
|
720
1176
|
if (typeof cand !== "object" || cand === null) throw new Error(`供应商模块无效: ${file}`);
|
|
721
1177
|
if (typeof cand.id !== "string" || cand.id === "") throw new Error(`供应商缺少 id: ${file}`);
|
|
722
1178
|
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
1179
|
return cand;
|
|
725
1180
|
}
|
|
726
1181
|
/** 加载单个供应商文件,返回模块对象或抛错。 */
|
|
@@ -729,7 +1184,7 @@ async function importSupplierFile(file) {
|
|
|
729
1184
|
}
|
|
730
1185
|
/**
|
|
731
1186
|
* 差异化能力(供应商 js 实现;其余为通用能力由核心提供)。
|
|
732
|
-
* testModel
|
|
1187
|
+
* testModel / 连接池 / 模型管理 / 别名 / 凭证存储均为核心通用能力,不在此列。
|
|
733
1188
|
*/
|
|
734
1189
|
const DIFF_CAPS = [
|
|
735
1190
|
"generateLoginUrl",
|
|
@@ -745,7 +1200,6 @@ async function loadOne(file, env, source) {
|
|
|
745
1200
|
if (typeof mod === "function") {
|
|
746
1201
|
instance = mod(env);
|
|
747
1202
|
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
1203
|
} else instance = mod;
|
|
750
1204
|
return wrapModule(instance, env, file, source);
|
|
751
1205
|
}
|
|
@@ -755,15 +1209,22 @@ async function loadOne(file, env, source) {
|
|
|
755
1209
|
*/
|
|
756
1210
|
function wrapModule(instance, env, source, kind = "external") {
|
|
757
1211
|
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
1212
|
const capabilities = /* @__PURE__ */ new Set();
|
|
760
1213
|
for (const key of DIFF_CAPS) if (isFn(instance[key])) capabilities.add(key);
|
|
1214
|
+
const pool = new AccountPool();
|
|
761
1215
|
const supplier = {
|
|
762
1216
|
id: instance.id,
|
|
763
1217
|
name: instance.name,
|
|
764
1218
|
priority: instance.priority ?? 0,
|
|
765
1219
|
icon: instance.icon,
|
|
766
|
-
status: () =>
|
|
1220
|
+
status: () => {
|
|
1221
|
+
const now = instance.status();
|
|
1222
|
+
return {
|
|
1223
|
+
id: now.id,
|
|
1224
|
+
name: now.name,
|
|
1225
|
+
accounts: pool.decorate(now.accounts)
|
|
1226
|
+
};
|
|
1227
|
+
},
|
|
767
1228
|
listModels: (force) => instance.listModels(force),
|
|
768
1229
|
modelsWithEnabled: (force) => {
|
|
769
1230
|
const cfg = env.store.get(instance.id);
|
|
@@ -777,10 +1238,12 @@ function wrapModule(instance, env, source, kind = "external") {
|
|
|
777
1238
|
},
|
|
778
1239
|
getAlias: () => env.store.get(instance.id).alias || instance.getAlias(),
|
|
779
1240
|
customModelIds: () => [...env.store.get(instance.id).custom],
|
|
780
|
-
|
|
781
|
-
|
|
1241
|
+
accounts: () => instance.status().accounts,
|
|
1242
|
+
chatOnce: (uid, req) => instance.chatOnce(uid, req),
|
|
1243
|
+
lastError: isFn(instance.lastError) ? () => instance.lastError() : void 0,
|
|
782
1244
|
removeLink: isFn(instance.removeLink) ? (uid) => instance.removeLink(uid) : void 0,
|
|
783
|
-
dispose: () => instance.dispose()
|
|
1245
|
+
dispose: () => instance.dispose(),
|
|
1246
|
+
pool
|
|
784
1247
|
};
|
|
785
1248
|
supplier.__module = instance;
|
|
786
1249
|
return {
|
|
@@ -862,41 +1325,36 @@ async function readBody$1(req, limit = 65536) {
|
|
|
862
1325
|
function cap(set, key) {
|
|
863
1326
|
return set.has(key);
|
|
864
1327
|
}
|
|
865
|
-
/**
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
});
|
|
1328
|
+
/** 账号状态指纹(积分 + 健康),用于判断刷新是否落地。 */
|
|
1329
|
+
function fingerprint(accounts) {
|
|
1330
|
+
return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}:${a.disabled}`).join("|");
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* 反复调插件已有的 status(),直到快照稳定再返回(是否有变化 = changed)。
|
|
1334
|
+
* 插件的积分刷新是 fire-and-forget(status() 内部异步拉),核心拿不到句柄,
|
|
1335
|
+
* 只能按指纹变化等它落地。天花板:缓存未过期时插件不会真拉上游,此时按钮
|
|
1336
|
+
* 退化为「重读一次状态」(冷却/禁用这类健康状态仍是 status() 实时算的)。
|
|
1337
|
+
* 升级路径:插件暴露可 await 的刷新能力,这里就不用轮询了。
|
|
1338
|
+
*/
|
|
1339
|
+
async function settleStatus(s, timeoutMs = 3e3) {
|
|
1340
|
+
const first = fingerprint(s.status().accounts);
|
|
1341
|
+
const deadline = Date.now() + timeoutMs;
|
|
1342
|
+
let last = first;
|
|
1343
|
+
let stable = 0;
|
|
1344
|
+
while (Date.now() < deadline) {
|
|
1345
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
1346
|
+
const now = fingerprint(s.status().accounts);
|
|
1347
|
+
stable = now === last ? stable + 1 : 0;
|
|
1348
|
+
last = now;
|
|
1349
|
+
if (stable >= 2) break;
|
|
888
1350
|
}
|
|
889
|
-
|
|
890
|
-
models,
|
|
891
|
-
fetchedAt: Date.now()
|
|
892
|
-
});
|
|
893
|
-
return models;
|
|
1351
|
+
return last !== first;
|
|
894
1352
|
}
|
|
895
1353
|
function mod(s) {
|
|
896
1354
|
return s.supplier.__module ?? {};
|
|
897
1355
|
}
|
|
898
1356
|
/** 为一个供应商生成端点(通用 + 差异化,返回 route 列表)。 */
|
|
899
|
-
function supplierRoutes(base, loaded, store) {
|
|
1357
|
+
function supplierRoutes(base, loaded, store, router) {
|
|
900
1358
|
const s = loaded.supplier;
|
|
901
1359
|
const m = mod(loaded);
|
|
902
1360
|
const c = loaded.capabilities;
|
|
@@ -906,7 +1364,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
906
1364
|
kind: "exact",
|
|
907
1365
|
path: `${p}/models`,
|
|
908
1366
|
handler: async (_req, res) => {
|
|
909
|
-
const models = await
|
|
1367
|
+
const models = await router.modelsOf(s.id);
|
|
910
1368
|
writeJson$1(res, 200, {
|
|
911
1369
|
ok: true,
|
|
912
1370
|
alias: s.getAlias(),
|
|
@@ -935,8 +1393,8 @@ function supplierRoutes(base, loaded, store) {
|
|
|
935
1393
|
path: `${p}/models/toggle`,
|
|
936
1394
|
handler: async (req, res) => {
|
|
937
1395
|
const body = JSON.parse(await readBody$1(req));
|
|
938
|
-
|
|
939
|
-
const models = await
|
|
1396
|
+
store.get(s.id);
|
|
1397
|
+
const models = await router.modelsOf(s.id);
|
|
940
1398
|
if (!body.id || !models.some((mm) => mm.id === body.id)) {
|
|
941
1399
|
writeJson$1(res, 400, {
|
|
942
1400
|
ok: false,
|
|
@@ -945,7 +1403,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
945
1403
|
return;
|
|
946
1404
|
}
|
|
947
1405
|
store.setModelEnabled(s.id, body.id, !!body.enabled);
|
|
948
|
-
|
|
1406
|
+
router.invalidateModels(s.id);
|
|
949
1407
|
writeJson$1(res, 200, { ok: true });
|
|
950
1408
|
}
|
|
951
1409
|
});
|
|
@@ -961,7 +1419,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
961
1419
|
});
|
|
962
1420
|
return;
|
|
963
1421
|
}
|
|
964
|
-
if ((await
|
|
1422
|
+
if ((await router.modelsOf(s.id)).some((mm) => mm.id === id)) {
|
|
965
1423
|
writeJson$1(res, 400, {
|
|
966
1424
|
ok: false,
|
|
967
1425
|
error: `模型 ${id} 已存在`
|
|
@@ -969,7 +1427,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
969
1427
|
return;
|
|
970
1428
|
}
|
|
971
1429
|
store.addCustomModel(s.id, id);
|
|
972
|
-
|
|
1430
|
+
router.invalidateModels(s.id);
|
|
973
1431
|
writeJson$1(res, 200, { ok: true });
|
|
974
1432
|
}
|
|
975
1433
|
});
|
|
@@ -987,7 +1445,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
987
1445
|
return;
|
|
988
1446
|
}
|
|
989
1447
|
store.removeCustomModel(s.id, body.id);
|
|
990
|
-
|
|
1448
|
+
router.invalidateModels(s.id);
|
|
991
1449
|
writeJson$1(res, 200, { ok: true });
|
|
992
1450
|
}
|
|
993
1451
|
});
|
|
@@ -996,9 +1454,9 @@ function supplierRoutes(base, loaded, store) {
|
|
|
996
1454
|
path: `${p}/models/bulk`,
|
|
997
1455
|
handler: async (req, res) => {
|
|
998
1456
|
const body = JSON.parse(await readBody$1(req));
|
|
999
|
-
const models = await
|
|
1457
|
+
const models = await router.modelsOf(s.id);
|
|
1000
1458
|
store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id));
|
|
1001
|
-
|
|
1459
|
+
router.invalidateModels(s.id);
|
|
1002
1460
|
writeJson$1(res, 200, { ok: true });
|
|
1003
1461
|
}
|
|
1004
1462
|
});
|
|
@@ -1054,7 +1512,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
1054
1512
|
handler: async (_req, res) => {
|
|
1055
1513
|
writeJson$1(res, 200, {
|
|
1056
1514
|
ok: true,
|
|
1057
|
-
models: await
|
|
1515
|
+
models: await router.modelsOf(s.id, true)
|
|
1058
1516
|
});
|
|
1059
1517
|
}
|
|
1060
1518
|
});
|
|
@@ -1063,7 +1521,7 @@ function supplierRoutes(base, loaded, store) {
|
|
|
1063
1521
|
path: `${p}/models/test`,
|
|
1064
1522
|
handler: async (req, res) => {
|
|
1065
1523
|
const body = JSON.parse(await readBody$1(req));
|
|
1066
|
-
const result = await
|
|
1524
|
+
const result = await router.testModel(s.id, body.id ?? "");
|
|
1067
1525
|
writeJson$1(res, result.ok ? 200 : 400, result);
|
|
1068
1526
|
}
|
|
1069
1527
|
});
|
|
@@ -1158,159 +1616,64 @@ function supplierRoutes(base, loaded, store) {
|
|
|
1158
1616
|
});
|
|
1159
1617
|
routes.push({
|
|
1160
1618
|
kind: "exact",
|
|
1161
|
-
path: `${p}/
|
|
1162
|
-
handler: async (
|
|
1163
|
-
|
|
1619
|
+
path: `${p}/links/refresh`,
|
|
1620
|
+
handler: async (_req, res) => {
|
|
1621
|
+
try {
|
|
1164
1622
|
writeJson$1(res, 200, {
|
|
1165
1623
|
ok: true,
|
|
1166
|
-
|
|
1624
|
+
changed: await settleStatus(s),
|
|
1625
|
+
accounts: s.status().accounts
|
|
1167
1626
|
});
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
const body = JSON.parse(await readBody$1(req));
|
|
1171
|
-
if (body.rule !== "all" && body.rule !== "first") {
|
|
1172
|
-
writeJson$1(res, 400, {
|
|
1627
|
+
} catch (err) {
|
|
1628
|
+
writeJson$1(res, 500, {
|
|
1173
1629
|
ok: false,
|
|
1174
|
-
error:
|
|
1630
|
+
error: err.message
|
|
1175
1631
|
});
|
|
1176
|
-
return;
|
|
1177
1632
|
}
|
|
1178
|
-
store.setCheckinRule(s.id, body.rule);
|
|
1179
|
-
writeJson$1(res, 200, { ok: true });
|
|
1180
1633
|
}
|
|
1181
1634
|
});
|
|
1182
1635
|
if (cap(c, "checkinNow")) routes.push({
|
|
1183
1636
|
kind: "exact",
|
|
1184
1637
|
path: `${p}/checkin`,
|
|
1185
1638
|
handler: async (_req, res) => {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
writeJson$1(res,
|
|
1639
|
+
const checkinOne = m.checkinNow;
|
|
1640
|
+
if (!checkinOne) {
|
|
1641
|
+
writeJson$1(res, 400, {
|
|
1642
|
+
ok: false,
|
|
1643
|
+
error: "该供应商不支持签到"
|
|
1644
|
+
});
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
const uids = s.status().accounts.map((a) => a.uid);
|
|
1648
|
+
const results = [];
|
|
1649
|
+
for (const uid of uids) try {
|
|
1650
|
+
results.push({
|
|
1651
|
+
uid,
|
|
1652
|
+
...await checkinOne.call(loaded.supplier, uid)
|
|
1653
|
+
});
|
|
1189
1654
|
} catch (err) {
|
|
1190
|
-
|
|
1655
|
+
results.push({
|
|
1656
|
+
uid,
|
|
1191
1657
|
ok: false,
|
|
1192
|
-
|
|
1658
|
+
status: "error",
|
|
1659
|
+
message: err.message
|
|
1193
1660
|
});
|
|
1194
1661
|
}
|
|
1662
|
+
const succeeded = results.filter((r) => r.status === "ok").length;
|
|
1663
|
+
const already = results.filter((r) => r.status === "already").length;
|
|
1664
|
+
const payload = {
|
|
1665
|
+
ok: succeeded + already > 0,
|
|
1666
|
+
total: uids.length,
|
|
1667
|
+
succeeded,
|
|
1668
|
+
already,
|
|
1669
|
+
results
|
|
1670
|
+
};
|
|
1671
|
+
writeJson$1(res, payload.ok ? 200 : 400, payload);
|
|
1195
1672
|
}
|
|
1196
1673
|
});
|
|
1197
1674
|
return routes;
|
|
1198
1675
|
}
|
|
1199
1676
|
//#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
1677
|
//#region src/credential-store.ts
|
|
1315
1678
|
/**
|
|
1316
1679
|
* 通用凭证存储 —— dsh-router 核心统一管账号凭证(SQLite,参考 9router 的
|
|
@@ -1399,7 +1762,7 @@ function apply(rawContext) {
|
|
|
1399
1762
|
const stateFile = process.env.TW2A_STATE_FILE ?? "data/state.json";
|
|
1400
1763
|
const store = new SupplierConfigStore(stateFile);
|
|
1401
1764
|
const credentials = new CredentialStore(process.env.TW2A_AUTH_DIR ?? join(dirname(stateFile), "auths"));
|
|
1402
|
-
const router = new Router(stateFile);
|
|
1765
|
+
const router = new Router(stateFile, store);
|
|
1403
1766
|
const keys = new KeysStore(stateFile);
|
|
1404
1767
|
const disposers = [];
|
|
1405
1768
|
const route = (path, handler) => {
|
|
@@ -1414,7 +1777,7 @@ function apply(rawContext) {
|
|
|
1414
1777
|
const supplierDisposers = /* @__PURE__ */ new Map();
|
|
1415
1778
|
const registerSupplierRoutes = (loaded) => {
|
|
1416
1779
|
const dis = [];
|
|
1417
|
-
for (const r of supplierRoutes(ROUTER_API_BASE, loaded, store)) dis.push(ctx.webServer.register(r));
|
|
1780
|
+
for (const r of supplierRoutes(ROUTER_API_BASE, loaded, store, router)) dis.push(ctx.webServer.register(r));
|
|
1418
1781
|
supplierDisposers.set(loaded.supplier.id, dis);
|
|
1419
1782
|
log(`supplier routes registered: ${loaded.supplier.id}`);
|
|
1420
1783
|
};
|