dsh-router-core 0.1.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.
Files changed (42) hide show
  1. package/README.md +151 -0
  2. package/cordis.patch.yml +9 -0
  3. package/lib/client-registry.js +3006 -0
  4. package/lib/client.js +3006 -0
  5. package/lib/index.js +1756 -0
  6. package/lib/types/credential-store.d.ts +15 -0
  7. package/lib/types/index.d.ts +6 -0
  8. package/lib/types/keys.d.ts +39 -0
  9. package/lib/types/llm/adapter.d.ts +31 -0
  10. package/lib/types/router/index.d.ts +73 -0
  11. package/lib/types/router/types.d.ts +80 -0
  12. package/lib/types/shared.d.ts +138 -0
  13. package/lib/types/supplier-config.d.ts +28 -0
  14. package/lib/types/suppliers/contract.d.ts +98 -0
  15. package/lib/types/suppliers/loader.d.ts +41 -0
  16. package/lib/types/suppliers/registry.d.ts +33 -0
  17. package/package.json +86 -0
  18. package/src/client/CombosTab.tsx +458 -0
  19. package/src/client/EndpointTab.tsx +360 -0
  20. package/src/client/Modal.tsx +46 -0
  21. package/src/client/RouterView.tsx +186 -0
  22. package/src/client/SupplierDetail.tsx +943 -0
  23. package/src/client/index.tsx +18 -0
  24. package/src/client/router.css +2096 -0
  25. package/src/client/sidebar-entry.ts +167 -0
  26. package/src/client/workspace-controller.ts +26 -0
  27. package/src/client/workspace-mount.tsx +117 -0
  28. package/src/credential-store.ts +64 -0
  29. package/src/index.ts +468 -0
  30. package/src/keys.ts +143 -0
  31. package/src/llm/adapter.ts +273 -0
  32. package/src/router/index.ts +273 -0
  33. package/src/router/types.ts +85 -0
  34. package/src/shared.ts +152 -0
  35. package/src/supplier-config.ts +144 -0
  36. package/src/suppliers/contract.ts +73 -0
  37. package/src/suppliers/loader.ts +191 -0
  38. package/src/suppliers/nvidia/plugin.ts +268 -0
  39. package/src/suppliers/opencode/plugin.ts +200 -0
  40. package/src/suppliers/openrouter/plugin.ts +282 -0
  41. package/src/suppliers/registry.ts +365 -0
  42. package/src/types.d.ts +45 -0
package/lib/index.js ADDED
@@ -0,0 +1,1756 @@
1
+ import { basename, dirname, join } from "node:path";
2
+ import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
3
+ import { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
4
+ import { randomBytes } from "node:crypto";
5
+ import { pathToFileURL } from "node:url";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ //#region src/shared.ts
8
+ /** Same-origin API base the client panel fetches (host half serves it). */
9
+ const ROUTER_API_BASE = "/router/api";
10
+ /** Bearer key env / credential reference protecting /v1/*. */
11
+ const API_KEY_REF = "TW2A_API_KEY";
12
+ //#endregion
13
+ //#region src/router/index.ts
14
+ /**
15
+ * dsh-router 路由器本体:供应商注册表 + OpenAI 兼容 /v1/* 处理。
16
+ * 仿 9router:组合 = 一组模型,请求 model 命中组合名时,按策略
17
+ * (fallback 顺序尝试 / round-robin 轮转)选中一个模型路由。
18
+ */
19
+ function openAIError(code, msg) {
20
+ return { error: {
21
+ message: msg,
22
+ type: "api_error",
23
+ code
24
+ } };
25
+ }
26
+ function writeJson$2(res, status, body) {
27
+ res.writeHead(status, {
28
+ "Content-Type": "application/json; charset=utf-8",
29
+ "Cache-Control": "no-store"
30
+ });
31
+ res.end(JSON.stringify(body));
32
+ }
33
+ /** 路由器。 */
34
+ var Router = class {
35
+ suppliers = [];
36
+ combosFp = "";
37
+ customCombos = [];
38
+ /** round-robin 轮转游标(按组合 id 记忆)。 */
39
+ rrCursors = /* @__PURE__ */ new Map();
40
+ constructor(stateFile = "") {
41
+ this.combosFp = stateFile ? join(dirname(stateFile), "combos.json") : "";
42
+ this.loadCombos();
43
+ }
44
+ loadCombos() {
45
+ if (this.combosFp === "") return;
46
+ try {
47
+ const f = JSON.parse(readFileSync(this.combosFp, "utf8"));
48
+ if (Array.isArray(f.combos)) this.customCombos = f.combos.filter((c) => typeof c.id === "string" && c.id !== "").map((c) => {
49
+ const old = c;
50
+ 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.lastIndexOf("/");
52
+ return slash >= 0 ? m.slice(slash + 1) : m;
53
+ });
54
+ return {
55
+ id: c.id,
56
+ name: typeof c.name === "string" ? c.name : c.id,
57
+ strategy: c.strategy === "round-robin" ? "round-robin" : "fallback",
58
+ models
59
+ };
60
+ }).filter((c) => c.models.length > 0);
61
+ } catch {}
62
+ }
63
+ saveCombos() {
64
+ if (this.combosFp === "") return;
65
+ try {
66
+ const dir = dirname(this.combosFp);
67
+ if (dir !== "" && dir !== ".") mkdirSync(dir, { recursive: true });
68
+ const raw = JSON.stringify({ combos: this.customCombos }, null, 2);
69
+ const tmp = this.combosFp + ".tmp";
70
+ writeFileSync(tmp, raw, { mode: 384 });
71
+ renameSync(tmp, this.combosFp);
72
+ } catch {}
73
+ }
74
+ add(supplier) {
75
+ this.suppliers.push(supplier);
76
+ this.suppliers.sort((a, b) => a.priority - b.priority);
77
+ }
78
+ /** 移除供应商(外部插件卸载时注销)。 */
79
+ removeSupplier(id) {
80
+ const i = this.suppliers.findIndex((s) => s.id === id);
81
+ if (i < 0) return false;
82
+ const [s] = this.suppliers.splice(i, 1);
83
+ s?.dispose();
84
+ return true;
85
+ }
86
+ /** 返回全部供应商状态(面板用)。 */
87
+ status() {
88
+ return { suppliers: this.suppliers.map((s) => s.status()) };
89
+ }
90
+ /** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。 */
91
+ aliases() {
92
+ return this.suppliers.map((s) => ({
93
+ id: s.id,
94
+ name: s.name,
95
+ alias: s.getAlias()
96
+ }));
97
+ }
98
+ /**
99
+ * OpenAI 兼容模型列表(/v1/models):组合(自动带出,不可改)+ 手动添加的模型。
100
+ * pi-ai 等 DSH provider 通过它发现 Router 的模型目录。
101
+ */
102
+ async listModels() {
103
+ const seen = /* @__PURE__ */ new Set();
104
+ const out = [];
105
+ const combos = await this.combos();
106
+ for (const c of combos) if (!seen.has(c.name)) {
107
+ seen.add(c.name);
108
+ out.push({ id: c.name });
109
+ }
110
+ for (const s of this.suppliers) try {
111
+ const ids = s.customModelIds?.();
112
+ for (const id of ids ?? []) if (!seen.has(id)) {
113
+ seen.add(id);
114
+ out.push({ id });
115
+ }
116
+ } catch {}
117
+ return out;
118
+ }
119
+ /** 组合列表(面板用):用户自定义组合。 */
120
+ async combos() {
121
+ return [...this.customCombos];
122
+ }
123
+ /** 可用模型(按供应商分组,仅启用),面板加模型用。 */
124
+ async supplierModels() {
125
+ const out = [];
126
+ for (const s of this.suppliers) try {
127
+ const models = (await s.modelsWithEnabled()).filter((m) => m.enabled);
128
+ out.push({
129
+ supplier: {
130
+ id: s.id,
131
+ name: s.name,
132
+ alias: s.getAlias()
133
+ },
134
+ models
135
+ });
136
+ } catch {}
137
+ return out;
138
+ }
139
+ validModels(models) {
140
+ return Array.isArray(models) && models.length > 0 && models.every((m) => typeof m === "string" && m !== "");
141
+ }
142
+ validStrategy(strategy) {
143
+ return strategy === "fallback" || strategy === "round-robin";
144
+ }
145
+ /** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。 */
146
+ normalizeModelIds(models) {
147
+ return models.map((m) => {
148
+ const slash = m.lastIndexOf("/");
149
+ return slash >= 0 ? m.slice(slash + 1) : m;
150
+ });
151
+ }
152
+ /** 创建组合(name 唯一,非 default)。 */
153
+ createCombo(name, strategy, models) {
154
+ const clean = name.trim();
155
+ if (clean === "" || clean === "default") return {
156
+ ok: false,
157
+ error: "组合名无效"
158
+ };
159
+ if (!/^[A-Za-z0-9._-]+$/.test(clean)) return {
160
+ ok: false,
161
+ error: "组合名只能含字母、数字、-、_ 和 ."
162
+ };
163
+ if (this.customCombos.some((c) => c.name === clean)) return {
164
+ ok: false,
165
+ error: `组合 ${clean} 已存在`
166
+ };
167
+ if (!this.validModels(models)) return {
168
+ ok: false,
169
+ error: "至少需要一个模型"
170
+ };
171
+ if (!this.validStrategy(strategy)) return {
172
+ ok: false,
173
+ error: "策略无效"
174
+ };
175
+ const combo = {
176
+ id: clean,
177
+ name: clean,
178
+ strategy,
179
+ models: this.normalizeModelIds(models)
180
+ };
181
+ this.customCombos.push(combo);
182
+ this.saveCombos();
183
+ return {
184
+ ok: true,
185
+ combo
186
+ };
187
+ }
188
+ /** 更新组合(按 id)。 */
189
+ updateCombo(id, name, strategy, models) {
190
+ const target = this.customCombos.find((c) => c.id === id);
191
+ if (!target) return {
192
+ ok: false,
193
+ error: "组合不存在"
194
+ };
195
+ const clean = name.trim();
196
+ if (clean === "" || clean === "default") return {
197
+ ok: false,
198
+ error: "组合名无效"
199
+ };
200
+ if (!/^[A-Za-z0-9._-]+$/.test(clean)) return {
201
+ ok: false,
202
+ error: "组合名只能含字母、数字、-、_ 和 ."
203
+ };
204
+ if (this.customCombos.some((c) => c.id !== id && c.name === clean)) return {
205
+ ok: false,
206
+ error: `组合 ${clean} 已存在`
207
+ };
208
+ if (!this.validModels(models)) return {
209
+ ok: false,
210
+ error: "至少需要一个模型"
211
+ };
212
+ if (!this.validStrategy(strategy)) return {
213
+ ok: false,
214
+ error: "策略无效"
215
+ };
216
+ target.name = clean;
217
+ target.strategy = strategy;
218
+ target.models = this.normalizeModelIds(models);
219
+ this.saveCombos();
220
+ return { ok: true };
221
+ }
222
+ /** 删除组合(按 id,default 不可删)。 */
223
+ removeCombo(id) {
224
+ if (id === "default") return {
225
+ ok: false,
226
+ error: "默认组合不可删除"
227
+ };
228
+ const idx = this.customCombos.findIndex((c) => c.id === id);
229
+ if (idx === -1) return {
230
+ ok: false,
231
+ error: "组合不存在"
232
+ };
233
+ this.customCombos.splice(idx, 1);
234
+ this.saveCombos();
235
+ return { ok: true };
236
+ }
237
+ /** 按组合名查组合(含 `/` 的模型名不匹配)。 */
238
+ comboByName(name) {
239
+ if (name.includes("/")) return void 0;
240
+ return this.customCombos.find((c) => c.name === name);
241
+ }
242
+ /**
243
+ * 处理 /v1/chat/completions。
244
+ * - model 命中组合名 → 在组合模型里按策略选一个,交给供应商;失败按回退顺序尝试剩余模型。
245
+ * - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
246
+ */
247
+ async chatCompletions(req, res) {
248
+ const combo = this.comboByName(req.model);
249
+ if (combo) {
250
+ const start = combo.strategy === "round-robin" ? (this.rrCursors.get(combo.id) ?? 0) % combo.models.length : 0;
251
+ if (combo.strategy === "round-robin") this.rrCursors.set(combo.id, (this.rrCursors.get(combo.id) ?? 0) + 1);
252
+ for (let i = 0; i < combo.models.length; i++) {
253
+ const model = combo.models[(start + i) % combo.models.length];
254
+ if (model === void 0) continue;
255
+ if (await this.chatWithModel(req, res, model)) return;
256
+ }
257
+ writeJson$2(res, 503, openAIError("no_healthy_supplier", `combo ${JSON.stringify(req.model)}: all models unavailable`));
258
+ return;
259
+ }
260
+ for (const s of this.suppliers) if (await s.chatCompletions(req, res)) return;
261
+ writeJson$2(res, 503, openAIError("no_healthy_supplier", "all suppliers unavailable"));
262
+ }
263
+ /** 把 model 改写为组合选中的模型名后尝试供应商。 */
264
+ async chatWithModel(req, res, model) {
265
+ const clone = {
266
+ ...req,
267
+ model
268
+ };
269
+ try {
270
+ const obj = JSON.parse(req.rawBody);
271
+ obj.model = model;
272
+ clone.rawBody = JSON.stringify(obj);
273
+ } catch {
274
+ clone.rawBody = req.rawBody;
275
+ }
276
+ for (const s of this.suppliers) if (await s.chatCompletions(clone, res)) return true;
277
+ return false;
278
+ }
279
+ dispose() {
280
+ for (const s of this.suppliers) s.dispose();
281
+ this.suppliers = [];
282
+ }
283
+ };
284
+ //#endregion
285
+ //#region src/llm/adapter.ts
286
+ /**
287
+ * DSH llm adapter for dsh-router(provider 固定卡片,名字 Router)。
288
+ *
289
+ * 模型目录 = 组合(自动带出,不可改)。对话转发到本插件 /v1/chat/completions,
290
+ * 由现有路由按组合策略命中供应商模型。
291
+ *
292
+ * 纯文本流:组合为文本模型,本 adapter 不处理图片附件。
293
+ */
294
+ /** 解析 SSE 文本,产出 `data:` 载荷(含结尾 [DONE])。 */
295
+ function* parseSseText(text) {
296
+ let data = "";
297
+ for (const line of text.split("\n")) {
298
+ if (line.startsWith("data:")) {
299
+ const value = line.slice(5).trimStart();
300
+ if (data.length > 0) data += "\n";
301
+ data += value;
302
+ continue;
303
+ }
304
+ if (line === "" && data.length > 0) {
305
+ yield data;
306
+ data = "";
307
+ }
308
+ }
309
+ if (data.length > 0) yield data;
310
+ }
311
+ /** 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。 */
312
+ function wireMessages(options) {
313
+ const out = [];
314
+ for (const message of options.messages) {
315
+ if (message.role === "system") {
316
+ out.push({
317
+ role: "system",
318
+ content: flattenText(message.content)
319
+ });
320
+ continue;
321
+ }
322
+ if (message.role === "assistant") {
323
+ const text = flattenText(message.content);
324
+ const toolCalls = message.content.filter((b) => b.type === "tool-call").map((b) => ({
325
+ id: b.id,
326
+ type: "function",
327
+ function: {
328
+ name: b.name,
329
+ arguments: b.arguments
330
+ }
331
+ }));
332
+ const reasoning = message.content.filter((b) => b.type === "reasoning").map((b) => b.text).join("");
333
+ out.push({
334
+ role: "assistant",
335
+ content: text,
336
+ ...toolCalls.length > 0 ? { tool_calls: toolCalls } : {},
337
+ ...reasoning.length > 0 ? { reasoning_content: reasoning } : {}
338
+ });
339
+ continue;
340
+ }
341
+ const text = flattenText(message.content);
342
+ const toolResults = message.content.filter((b) => b.type === "tool-result");
343
+ if (text.length > 0 || toolResults.length === 0) out.push({
344
+ role: "user",
345
+ content: text
346
+ });
347
+ for (const result of toolResults) out.push({
348
+ role: "tool",
349
+ tool_call_id: result.toolCallId,
350
+ content: flattenText(result.content) || "(no output)"
351
+ });
352
+ }
353
+ return out;
354
+ }
355
+ /** 组装 wire 请求体。 */
356
+ function wireRequest(options) {
357
+ const messages = wireMessages(options);
358
+ const body = {
359
+ model: options.model,
360
+ messages,
361
+ stream: true,
362
+ stream_options: { include_usage: true }
363
+ };
364
+ const tools = options.tools?.map((tool) => ({
365
+ type: "function",
366
+ function: {
367
+ name: tool.name,
368
+ description: tool.description,
369
+ parameters: tool.parameters
370
+ }
371
+ }));
372
+ if (tools !== void 0 && tools.length > 0) body.tools = tools;
373
+ if (options.temperature !== void 0) body.temperature = options.temperature;
374
+ if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
375
+ if (options.stop !== void 0) body.stop = options.stop;
376
+ return body;
377
+ }
378
+ function flattenText(blocks) {
379
+ return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
380
+ }
381
+ /** openai SSE payload → DSH StreamChunk 流。 */
382
+ async function* translate(payloads) {
383
+ let nextIndex = 0;
384
+ let textBlock;
385
+ let reasoningBlock;
386
+ const toolBlocks = /* @__PURE__ */ new Map();
387
+ const order = [];
388
+ let pendingFinish;
389
+ let pendingUsage;
390
+ const open = (kind) => {
391
+ const block = {
392
+ index: nextIndex++,
393
+ kind,
394
+ text: ""
395
+ };
396
+ order.push(block);
397
+ return block;
398
+ };
399
+ for (const payload of payloads) {
400
+ if (payload === "[DONE]") {
401
+ for (const block of order) yield {
402
+ type: "block-end",
403
+ index: block.index,
404
+ block: closeBlock(block)
405
+ };
406
+ if (pendingUsage !== void 0) yield {
407
+ type: "usage",
408
+ usage: pendingUsage
409
+ };
410
+ const reason = pendingFinish ?? { kind: "stop" };
411
+ yield {
412
+ type: "finish",
413
+ reason: reason.kind === "stop" && order.length === 0 ? {
414
+ kind: "error",
415
+ failure: {
416
+ message: "model returned a completed response with no content",
417
+ code: EMPTY_RESPONSE_CODE
418
+ }
419
+ } : reason
420
+ };
421
+ return;
422
+ }
423
+ let chunk;
424
+ try {
425
+ chunk = JSON.parse(payload);
426
+ } catch {
427
+ throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, "MALFORMED_RESPONSE");
428
+ }
429
+ for (const choice of chunk.choices ?? []) {
430
+ const delta = choice.delta ?? {};
431
+ const reasoning = delta.reasoning_content;
432
+ if (typeof reasoning === "string" && reasoning.length > 0) {
433
+ if (!reasoningBlock) {
434
+ reasoningBlock = open("reasoning");
435
+ yield {
436
+ type: "block-start",
437
+ index: reasoningBlock.index,
438
+ blockType: "reasoning"
439
+ };
440
+ }
441
+ reasoningBlock.text += reasoning;
442
+ yield {
443
+ type: "reasoning-delta",
444
+ index: reasoningBlock.index,
445
+ text: reasoning
446
+ };
447
+ }
448
+ const content = delta.content;
449
+ if (typeof content === "string" && content.length > 0) {
450
+ if (!textBlock) {
451
+ textBlock = open("text");
452
+ yield {
453
+ type: "block-start",
454
+ index: textBlock.index,
455
+ blockType: "text"
456
+ };
457
+ }
458
+ textBlock.text += content;
459
+ yield {
460
+ type: "text-delta",
461
+ index: textBlock.index,
462
+ text: content
463
+ };
464
+ }
465
+ const toolCalls = delta.tool_calls;
466
+ for (const call of toolCalls ?? []) {
467
+ const key = call.index ?? 0;
468
+ let block = toolBlocks.get(key);
469
+ if (!block) {
470
+ block = open("tool-call");
471
+ toolBlocks.set(key, block);
472
+ yield {
473
+ type: "block-start",
474
+ index: block.index,
475
+ blockType: "tool-call"
476
+ };
477
+ }
478
+ if (call.id !== void 0) block.callId = call.id;
479
+ if (call.function?.name !== void 0) block.name = call.function.name;
480
+ const fragment = call.function?.arguments ?? "";
481
+ block.text += fragment;
482
+ yield {
483
+ type: "tool-call-delta",
484
+ index: block.index,
485
+ id: CallId(block.callId ?? ""),
486
+ ...block.name !== void 0 ? { name: block.name } : {},
487
+ argumentsDelta: fragment
488
+ };
489
+ }
490
+ if (typeof choice.finish_reason === "string") pendingFinish = mapFinishReason(choice.finish_reason);
491
+ }
492
+ if (chunk.usage !== void 0) pendingUsage = chunk.usage;
493
+ }
494
+ throw new LlmError("SSE payload stream ended without [DONE]", "STREAM_CLOSED");
495
+ }
496
+ function mapFinishReason(reason) {
497
+ switch (reason) {
498
+ case "stop": return { kind: "stop" };
499
+ case "tool_calls": return { kind: "tool-calls" };
500
+ case "length": return { kind: "max-tokens" };
501
+ default: return {
502
+ kind: "error",
503
+ failure: {
504
+ message: `model stopped: ${reason}`,
505
+ code: reason.toUpperCase()
506
+ }
507
+ };
508
+ }
509
+ }
510
+ function closeBlock(block) {
511
+ switch (block.kind) {
512
+ case "text": return {
513
+ type: "text",
514
+ text: block.text
515
+ };
516
+ case "reasoning": return {
517
+ type: "reasoning",
518
+ text: block.text
519
+ };
520
+ case "tool-call": return {
521
+ type: "tool-call",
522
+ id: CallId(block.callId ?? ""),
523
+ name: block.name ?? "",
524
+ arguments: block.text
525
+ };
526
+ }
527
+ }
528
+ /**
529
+ * DSH adapter:provider `router`。模型目录 = 组合;stream 转发到本插件
530
+ * /v1/chat/completions(组合路由在 /v1 内完成)。
531
+ */
532
+ var RouterAdapter = class extends LlmAdapter {
533
+ baseURL;
534
+ source;
535
+ constructor(baseURL, source) {
536
+ super();
537
+ this.baseURL = baseURL;
538
+ this.source = source;
539
+ }
540
+ providerInfo(provider) {
541
+ return {
542
+ id: provider,
543
+ name: "Router"
544
+ };
545
+ }
546
+ async listModels(_provider) {
547
+ const combos = await this.source.comboModels();
548
+ const seen = /* @__PURE__ */ new Set();
549
+ const out = [];
550
+ for (const m of combos) {
551
+ if (seen.has(m.id)) continue;
552
+ seen.add(m.id);
553
+ out.push({
554
+ provider: "router",
555
+ id: m.id,
556
+ name: m.name ?? m.id
557
+ });
558
+ }
559
+ return out;
560
+ }
561
+ async *stream(options) {
562
+ const body = wireRequest(options);
563
+ const controller = new AbortController();
564
+ const onAbort = () => controller.abort();
565
+ options.signal?.addEventListener("abort", onAbort, { once: true });
566
+ let resp;
567
+ try {
568
+ resp = await fetch(`${this.baseURL}/chat/completions`, {
569
+ method: "POST",
570
+ headers: {
571
+ "content-type": "application/json",
572
+ accept: "text/event-stream"
573
+ },
574
+ body: JSON.stringify(body),
575
+ signal: controller.signal
576
+ });
577
+ } catch (error) {
578
+ throw new LlmError(`dsh-router upstream call failed: ${error.message}`, "TRANSPORT", { cause: error });
579
+ } finally {
580
+ options.signal?.removeEventListener("abort", onAbort);
581
+ }
582
+ if (!resp.ok) {
583
+ const detail = await resp.text().catch(() => "");
584
+ throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, "TRANSPORT");
585
+ }
586
+ yield* translate(parseSseText(resp.body === null ? "" : await resp.text()));
587
+ }
588
+ };
589
+ //#endregion
590
+ //#region src/keys.ts
591
+ /**
592
+ * 密钥库 + 路由设置(requireApiKey)—— 9router「端点与密钥」核心。
593
+ * 持久化到 data/keys.json(与 pool 的 stateFile 同目录)。
594
+ *
595
+ * /v1/* 鉴权规则:
596
+ * - requireApiKey=false(默认)→ 不鉴权
597
+ * - requireApiKey=true → Bearer 必须是「启用的库内 key」或 TW2A_API_KEY env
598
+ */
599
+ function newId() {
600
+ return randomBytes(6).toString("hex");
601
+ }
602
+ function generateKey() {
603
+ return `dshr-${randomBytes(24).toString("hex")}`;
604
+ }
605
+ function mask(k) {
606
+ if (k.length <= 10) return k;
607
+ return `${k.slice(0, 6)}${"•".repeat(k.length - 10)}${k.slice(-4)}`;
608
+ }
609
+ /** 密钥库。 */
610
+ var KeysStore = class {
611
+ fp = "";
612
+ keys = [];
613
+ require = false;
614
+ constructor(stateFile) {
615
+ this.fp = stateFile ? join(dirname(stateFile), "keys.json") : "";
616
+ this.load();
617
+ }
618
+ load() {
619
+ if (this.fp === "") return;
620
+ let raw;
621
+ try {
622
+ raw = readFileSync(this.fp, "utf8");
623
+ } catch {
624
+ return;
625
+ }
626
+ try {
627
+ const f = JSON.parse(raw);
628
+ this.keys = Array.isArray(f.keys) ? f.keys : [];
629
+ this.require = !!f.requireApiKey;
630
+ } catch {}
631
+ }
632
+ save() {
633
+ if (this.fp === "") return;
634
+ try {
635
+ const dir = dirname(this.fp);
636
+ if (dir !== "" && dir !== ".") mkdirSync(dir, { recursive: true });
637
+ const raw = JSON.stringify({
638
+ keys: this.keys,
639
+ requireApiKey: this.require
640
+ }, null, 2);
641
+ const tmp = this.fp + ".tmp";
642
+ writeFileSync(tmp, raw, { mode: 384 });
643
+ renameSync(tmp, this.fp);
644
+ } catch {}
645
+ }
646
+ list() {
647
+ return this.keys.map((k) => ({
648
+ id: k.id,
649
+ name: k.name,
650
+ key: k.key,
651
+ masked: mask(k.key),
652
+ isActive: k.isActive,
653
+ createdAt: k.createdAt
654
+ }));
655
+ }
656
+ /** 创建 key,返回明文(仅此一次)。 */
657
+ create(name) {
658
+ const entry = {
659
+ id: newId(),
660
+ name: name.trim() !== "" ? name.trim() : `Key ${this.keys.length + 1}`,
661
+ key: generateKey(),
662
+ isActive: true,
663
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
664
+ };
665
+ this.keys.push(entry);
666
+ this.save();
667
+ return {
668
+ entry,
669
+ key: entry.key
670
+ };
671
+ }
672
+ remove(id) {
673
+ const before = this.keys.length;
674
+ this.keys = this.keys.filter((k) => k.id !== id);
675
+ if (this.keys.length === before) return false;
676
+ this.save();
677
+ return true;
678
+ }
679
+ setActive(id, isActive) {
680
+ const k = this.keys.find((k) => k.id === id);
681
+ if (!k) return false;
682
+ k.isActive = isActive;
683
+ this.save();
684
+ return true;
685
+ }
686
+ /** requireApiKey 开关。 */
687
+ get requireApiKey() {
688
+ return this.require;
689
+ }
690
+ set requireApiKey(v) {
691
+ this.require = v;
692
+ this.save();
693
+ }
694
+ /** 校验 Bearer key:requireApiKey 关 → 放行;开 → 库内启用 key 或 TW2A_API_KEY env。 */
695
+ verify(bearer) {
696
+ if (!this.require) return true;
697
+ if (bearer === void 0 || bearer === "") return false;
698
+ if (this.keys.some((k) => k.isActive && k.key === bearer)) return true;
699
+ const envKey = process.env[API_KEY_REF];
700
+ return envKey !== void 0 && envKey !== "" && envKey === bearer;
701
+ }
702
+ };
703
+ //#endregion
704
+ //#region src/suppliers/loader.ts
705
+ /**
706
+ * 供应商 loader —— 扫描 suppliers 目录下的 *.js,动态 import,按契约包装成
707
+ * 内部 Supplier,并暴露"能力检测"(capabilities)供通用路由/前端按能力渲染。
708
+ *
709
+ * 加载来源(两处,profile 优先):
710
+ * 1. 内置:<plugin>/lib/suppliers/*.js(随插件分发)
711
+ * 2. 用户:~/.dsh/profiles/web/suppliers/*.js(用户自定义)
712
+ */
713
+ function isFn(v) {
714
+ return typeof v === "function";
715
+ }
716
+ /** 从模块导出解析出供应商模块对象(支持 export default / 命名导出 / factory)。 */
717
+ function resolveModule(mod, file) {
718
+ const cand = mod.default ?? mod;
719
+ if (typeof cand === "function") return cand;
720
+ if (typeof cand !== "object" || cand === null) throw new Error(`供应商模块无效: ${file}`);
721
+ if (typeof cand.id !== "string" || cand.id === "") throw new Error(`供应商缺少 id: ${file}`);
722
+ 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
+ return cand;
725
+ }
726
+ /** 加载单个供应商文件,返回模块对象或抛错。 */
727
+ async function importSupplierFile(file) {
728
+ return resolveModule(await import(pathToFileURL(file).href), file);
729
+ }
730
+ /**
731
+ * 差异化能力(供应商 js 实现;其余为通用能力由核心提供)。
732
+ * testModel 是必要差异化能力(核心必须),不在此列。
733
+ */
734
+ const DIFF_CAPS = [
735
+ "generateLoginUrl",
736
+ "completeLogin",
737
+ "addApiKey",
738
+ "pollLogin",
739
+ "checkinNow"
740
+ ];
741
+ /** 从文件构造 LoadedSupplier。 */
742
+ async function loadOne(file, env, source) {
743
+ const mod = await importSupplierFile(file);
744
+ let instance;
745
+ if (typeof mod === "function") {
746
+ instance = mod(env);
747
+ 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
+ } else instance = mod;
750
+ return wrapModule(instance, env, file, source);
751
+ }
752
+ /**
753
+ * 从 SupplierModule 实例构造 LoadedSupplier(能力检测 + Supplier 包装)。
754
+ * 文件加载(loadOne)与外部插件供应商(cordis service)共用。
755
+ */
756
+ function wrapModule(instance, env, source, kind = "external") {
757
+ 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
+ const capabilities = /* @__PURE__ */ new Set();
760
+ for (const key of DIFF_CAPS) if (isFn(instance[key])) capabilities.add(key);
761
+ const supplier = {
762
+ id: instance.id,
763
+ name: instance.name,
764
+ priority: instance.priority ?? 0,
765
+ icon: instance.icon,
766
+ status: () => instance.status(),
767
+ listModels: (force) => instance.listModels(force),
768
+ modelsWithEnabled: (force) => {
769
+ const cfg = env.store.get(instance.id);
770
+ const disabled = new Set(cfg.disabled);
771
+ const custom = new Set(cfg.custom);
772
+ return Promise.resolve(instance.listModels(force)).then((list) => list.map((mm) => ({
773
+ ...mm,
774
+ enabled: !disabled.has(mm.id),
775
+ custom: custom.has(mm.id) ? true : void 0
776
+ })));
777
+ },
778
+ getAlias: () => env.store.get(instance.id).alias || instance.getAlias(),
779
+ customModelIds: () => [...env.store.get(instance.id).custom],
780
+ chatCompletions: (req, res) => instance.chatCompletions(req, res),
781
+ testModel: (id) => instance.testModel(id),
782
+ removeLink: isFn(instance.removeLink) ? (uid) => instance.removeLink(uid) : void 0,
783
+ dispose: () => instance.dispose()
784
+ };
785
+ supplier.__module = instance;
786
+ return {
787
+ supplier,
788
+ capabilities,
789
+ source: kind
790
+ };
791
+ }
792
+ /** 扫描目录下所有 *.js(一层)。 */
793
+ function scanDir(dir) {
794
+ try {
795
+ return readdirSync(dir).filter((f) => f.endsWith(".js")).map((f) => join(dir, f));
796
+ } catch {
797
+ return [];
798
+ }
799
+ }
800
+ /** 加载全部供应商(内置 + 用户,用户覆盖内置同 id)。 */
801
+ async function loadSuppliers(opts) {
802
+ const env = {
803
+ dataDir: opts.dataDir,
804
+ log: opts.log,
805
+ store: opts.store,
806
+ credentials: opts.credentials
807
+ };
808
+ const byId = /* @__PURE__ */ new Map();
809
+ const errors = [];
810
+ const files = [...scanDir(opts.builtinDir).map((f) => ({
811
+ f,
812
+ builtin: true
813
+ })), ...scanDir(opts.userDir).map((f) => ({
814
+ f,
815
+ builtin: false
816
+ }))];
817
+ for (const { f, builtin } of files) try {
818
+ const loaded = await loadOne(f, env, builtin ? "builtin" : "user");
819
+ if (byId.get(loaded.supplier.id) && builtin) continue;
820
+ byId.set(loaded.supplier.id, loaded);
821
+ opts.log(`supplier ${loaded.supplier.id} loaded (${builtin ? "builtin" : "user"}) from ${basename(f)}`);
822
+ } catch (err) {
823
+ errors.push({
824
+ id: basename(f, ".js"),
825
+ file: f,
826
+ error: err.message
827
+ });
828
+ opts.log(`supplier load failed ${basename(f)}: ${err.message}`);
829
+ }
830
+ return {
831
+ suppliers: [...byId.values()],
832
+ errors
833
+ };
834
+ }
835
+ //#endregion
836
+ //#region src/suppliers/registry.ts
837
+ function writeJson$1(res, status, body) {
838
+ const payload = JSON.stringify(body);
839
+ res.writeHead(status, {
840
+ "content-type": "application/json; charset=utf-8",
841
+ "content-length": Buffer.byteLength(payload)
842
+ });
843
+ res.end(payload);
844
+ }
845
+ async function readBody$1(req, limit = 65536) {
846
+ const chunks = [];
847
+ let size = 0;
848
+ return await new Promise((resolve, reject) => {
849
+ req.on("data", (chunk) => {
850
+ size += chunk.length;
851
+ if (size > limit) {
852
+ reject(/* @__PURE__ */ new Error("body_too_large"));
853
+ req.destroy();
854
+ return;
855
+ }
856
+ chunks.push(chunk);
857
+ });
858
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
859
+ req.on("error", reject);
860
+ });
861
+ }
862
+ function cap(set, key) {
863
+ return set.has(key);
864
+ }
865
+ /** 模型列表统一缓存(dsh-router 核心管;插件只管拉取,不缓存)。 */
866
+ const modelsCache = /* @__PURE__ */ new Map();
867
+ const MODELS_TTL_MS = 6e4;
868
+ /** 拉取并缓存某供应商模型(force 时强制刷新)。custom 模型并入显示。 */
869
+ async function cachedModels(s, cfg, force = false) {
870
+ const hit = modelsCache.get(s.id);
871
+ if (!force && hit && Date.now() - hit.fetchedAt < MODELS_TTL_MS) return hit.models;
872
+ const list = await Promise.resolve(s.listModels());
873
+ const custom = new Set(cfg.custom);
874
+ const disabled = new Set(cfg.disabled);
875
+ const models = list.map((mm) => ({
876
+ ...mm,
877
+ enabled: !disabled.has(mm.id),
878
+ custom: custom.has(mm.id) ? true : void 0
879
+ }));
880
+ const seen = new Set(models.map((mm) => mm.id));
881
+ for (const id of custom) if (!seen.has(id)) {
882
+ seen.add(id);
883
+ models.push({
884
+ id,
885
+ enabled: !disabled.has(id),
886
+ custom: true
887
+ });
888
+ }
889
+ modelsCache.set(s.id, {
890
+ models,
891
+ fetchedAt: Date.now()
892
+ });
893
+ return models;
894
+ }
895
+ function mod(s) {
896
+ return s.supplier.__module ?? {};
897
+ }
898
+ /** 为一个供应商生成端点(通用 + 差异化,返回 route 列表)。 */
899
+ function supplierRoutes(base, loaded, store) {
900
+ const s = loaded.supplier;
901
+ const m = mod(loaded);
902
+ const c = loaded.capabilities;
903
+ const p = `${base}/suppliers/${s.id}`;
904
+ const routes = [];
905
+ routes.push({
906
+ kind: "exact",
907
+ path: `${p}/models`,
908
+ handler: async (_req, res) => {
909
+ const models = await cachedModels(s, store.get(s.id));
910
+ writeJson$1(res, 200, {
911
+ ok: true,
912
+ alias: s.getAlias(),
913
+ models
914
+ });
915
+ }
916
+ });
917
+ routes.push({
918
+ kind: "exact",
919
+ path: `${p}/alias`,
920
+ handler: async (req, res) => {
921
+ const clean = (JSON.parse(await readBody$1(req)).alias ?? "").trim();
922
+ if (clean === "" || !/^[A-Za-z0-9_-]+$/.test(clean)) {
923
+ writeJson$1(res, 400, {
924
+ ok: false,
925
+ error: "前缀只能包含字母、数字、- 和 _"
926
+ });
927
+ return;
928
+ }
929
+ store.setAlias(s.id, clean);
930
+ writeJson$1(res, 200, { ok: true });
931
+ }
932
+ });
933
+ routes.push({
934
+ kind: "exact",
935
+ path: `${p}/models/toggle`,
936
+ handler: async (req, res) => {
937
+ const body = JSON.parse(await readBody$1(req));
938
+ const cfg = store.get(s.id);
939
+ const models = await cachedModels(s, cfg);
940
+ if (!body.id || !models.some((mm) => mm.id === body.id)) {
941
+ writeJson$1(res, 400, {
942
+ ok: false,
943
+ error: "模型不存在"
944
+ });
945
+ return;
946
+ }
947
+ store.setModelEnabled(s.id, body.id, !!body.enabled);
948
+ modelsCache.delete(s.id);
949
+ writeJson$1(res, 200, { ok: true });
950
+ }
951
+ });
952
+ routes.push({
953
+ kind: "exact",
954
+ path: `${p}/models/add`,
955
+ handler: async (req, res) => {
956
+ const id = (JSON.parse(await readBody$1(req)).id ?? "").trim();
957
+ if (id === "") {
958
+ writeJson$1(res, 400, {
959
+ ok: false,
960
+ error: "模型 id 不能为空"
961
+ });
962
+ return;
963
+ }
964
+ if ((await cachedModels(s, store.get(s.id))).some((mm) => mm.id === id)) {
965
+ writeJson$1(res, 400, {
966
+ ok: false,
967
+ error: `模型 ${id} 已存在`
968
+ });
969
+ return;
970
+ }
971
+ store.addCustomModel(s.id, id);
972
+ modelsCache.delete(s.id);
973
+ writeJson$1(res, 200, { ok: true });
974
+ }
975
+ });
976
+ routes.push({
977
+ kind: "exact",
978
+ path: `${p}/models/remove`,
979
+ handler: async (req, res) => {
980
+ const body = JSON.parse(await readBody$1(req));
981
+ const cfg = store.get(s.id);
982
+ if (!body.id || !cfg.custom.includes(body.id)) {
983
+ writeJson$1(res, 400, {
984
+ ok: false,
985
+ error: `模型 ${body.id} 不是自定义模型`
986
+ });
987
+ return;
988
+ }
989
+ store.removeCustomModel(s.id, body.id);
990
+ modelsCache.delete(s.id);
991
+ writeJson$1(res, 200, { ok: true });
992
+ }
993
+ });
994
+ routes.push({
995
+ kind: "exact",
996
+ path: `${p}/models/bulk`,
997
+ handler: async (req, res) => {
998
+ const body = JSON.parse(await readBody$1(req));
999
+ const models = await cachedModels(s, store.get(s.id));
1000
+ store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id));
1001
+ modelsCache.delete(s.id);
1002
+ writeJson$1(res, 200, { ok: true });
1003
+ }
1004
+ });
1005
+ routes.push({
1006
+ kind: "exact",
1007
+ path: `${p}/pool/order`,
1008
+ handler: async (req, res) => {
1009
+ if (req.method === "GET") {
1010
+ writeJson$1(res, 200, {
1011
+ ok: true,
1012
+ order: store.get(s.id).poolOrder
1013
+ });
1014
+ return;
1015
+ }
1016
+ const body = JSON.parse(await readBody$1(req));
1017
+ if (!Array.isArray(body.uids) || body.uids.some((u) => typeof u !== "string")) {
1018
+ writeJson$1(res, 400, {
1019
+ ok: false,
1020
+ error: "顺序必须是 uid 数组"
1021
+ });
1022
+ return;
1023
+ }
1024
+ store.setPoolOrder(s.id, body.uids);
1025
+ writeJson$1(res, 200, { ok: true });
1026
+ }
1027
+ });
1028
+ routes.push({
1029
+ kind: "exact",
1030
+ path: `${p}/pool/strategy`,
1031
+ handler: async (req, res) => {
1032
+ if (req.method === "GET") {
1033
+ writeJson$1(res, 200, {
1034
+ ok: true,
1035
+ strategy: store.get(s.id).poolStrategy
1036
+ });
1037
+ return;
1038
+ }
1039
+ const body = JSON.parse(await readBody$1(req));
1040
+ if (body.strategy !== "fallback" && body.strategy !== "round-robin") {
1041
+ writeJson$1(res, 400, {
1042
+ ok: false,
1043
+ error: "策略无效"
1044
+ });
1045
+ return;
1046
+ }
1047
+ store.setPoolStrategy(s.id, body.strategy);
1048
+ writeJson$1(res, 200, { ok: true });
1049
+ }
1050
+ });
1051
+ routes.push({
1052
+ kind: "exact",
1053
+ path: `${p}/models/fetch`,
1054
+ handler: async (_req, res) => {
1055
+ writeJson$1(res, 200, {
1056
+ ok: true,
1057
+ models: await cachedModels(s, store.get(s.id), true)
1058
+ });
1059
+ }
1060
+ });
1061
+ routes.push({
1062
+ kind: "exact",
1063
+ path: `${p}/models/test`,
1064
+ handler: async (req, res) => {
1065
+ const body = JSON.parse(await readBody$1(req));
1066
+ const result = await s.testModel(body.id ?? "");
1067
+ writeJson$1(res, result.ok ? 200 : 400, result);
1068
+ }
1069
+ });
1070
+ routes.push({
1071
+ kind: "exact",
1072
+ path: `${p}/login`,
1073
+ handler: async (_req, res) => {
1074
+ if (!cap(c, "generateLoginUrl")) {
1075
+ writeJson$1(res, 400, {
1076
+ ok: false,
1077
+ error: "该供应商不支持添加链接"
1078
+ });
1079
+ return;
1080
+ }
1081
+ const r = await m.generateLoginUrl.call(loaded.supplier);
1082
+ if (typeof r === "string") writeJson$1(res, 200, {
1083
+ ok: true,
1084
+ loginUrl: r
1085
+ });
1086
+ else writeJson$1(res, r.ok === false ? 400 : 200, r);
1087
+ }
1088
+ });
1089
+ routes.push({
1090
+ kind: "exact",
1091
+ path: `${p}/login/callback`,
1092
+ handler: async (req, res) => {
1093
+ if (!cap(c, "completeLogin")) {
1094
+ writeJson$1(res, 400, {
1095
+ ok: false,
1096
+ error: "该供应商不支持添加链接"
1097
+ });
1098
+ return;
1099
+ }
1100
+ const body = JSON.parse(await readBody$1(req));
1101
+ try {
1102
+ writeJson$1(res, 200, {
1103
+ ok: true,
1104
+ account: await m.completeLogin.call(loaded.supplier, body.callbackUrl ?? "")
1105
+ });
1106
+ } catch (err) {
1107
+ writeJson$1(res, 400, {
1108
+ ok: false,
1109
+ error: err.message
1110
+ });
1111
+ }
1112
+ }
1113
+ });
1114
+ routes.push({
1115
+ kind: "exact",
1116
+ path: `${p}/links/add`,
1117
+ handler: async (req, res) => {
1118
+ if (!cap(c, "addApiKey")) {
1119
+ writeJson$1(res, 400, {
1120
+ ok: false,
1121
+ error: "该供应商不支持添加 API key"
1122
+ });
1123
+ return;
1124
+ }
1125
+ const body = JSON.parse(await readBody$1(req));
1126
+ try {
1127
+ const r = await m.addApiKey.call(loaded.supplier, {
1128
+ name: (body.name ?? "").trim(),
1129
+ apiKey: (body.apiKey ?? "").trim()
1130
+ });
1131
+ writeJson$1(res, r.ok === false ? 400 : 200, r);
1132
+ } catch (err) {
1133
+ writeJson$1(res, 400, {
1134
+ ok: false,
1135
+ error: err.message
1136
+ });
1137
+ }
1138
+ }
1139
+ });
1140
+ routes.push({
1141
+ kind: "exact",
1142
+ path: `${p}/links/remove`,
1143
+ handler: async (req, res) => {
1144
+ if (typeof s.removeLink !== "function") {
1145
+ writeJson$1(res, 400, {
1146
+ ok: false,
1147
+ error: "该供应商不支持删除链接"
1148
+ });
1149
+ return;
1150
+ }
1151
+ const body = JSON.parse(await readBody$1(req));
1152
+ const ok = await s.removeLink(body.uid ?? "");
1153
+ writeJson$1(res, ok ? 200 : 404, ok ? { ok: true } : {
1154
+ ok: false,
1155
+ error: "link not found"
1156
+ });
1157
+ }
1158
+ });
1159
+ routes.push({
1160
+ kind: "exact",
1161
+ path: `${p}/checkin/rule`,
1162
+ handler: async (req, res) => {
1163
+ if (req.method === "GET") {
1164
+ writeJson$1(res, 200, {
1165
+ ok: true,
1166
+ rule: store.get(s.id).checkinRule
1167
+ });
1168
+ return;
1169
+ }
1170
+ const body = JSON.parse(await readBody$1(req));
1171
+ if (body.rule !== "all" && body.rule !== "first") {
1172
+ writeJson$1(res, 400, {
1173
+ ok: false,
1174
+ error: "rule 必须是 all 或 first"
1175
+ });
1176
+ return;
1177
+ }
1178
+ store.setCheckinRule(s.id, body.rule);
1179
+ writeJson$1(res, 200, { ok: true });
1180
+ }
1181
+ });
1182
+ if (cap(c, "checkinNow")) routes.push({
1183
+ kind: "exact",
1184
+ path: `${p}/checkin`,
1185
+ handler: async (_req, res) => {
1186
+ try {
1187
+ const result = await m.checkinNow.call(loaded.supplier);
1188
+ writeJson$1(res, result.ok === false ? 400 : 200, result);
1189
+ } catch (err) {
1190
+ writeJson$1(res, 500, {
1191
+ ok: false,
1192
+ error: err.message
1193
+ });
1194
+ }
1195
+ }
1196
+ });
1197
+ return routes;
1198
+ }
1199
+ //#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
+ //#region src/credential-store.ts
1315
+ /**
1316
+ * 通用凭证存储 —— dsh-router 核心统一管账号凭证(SQLite,参考 9router 的
1317
+ * providerConnections 表:凭证作为不透明 JSON blob 存单表 data 列)。
1318
+ *
1319
+ * 凭证是供应商特有的不透明数据(js 定义格式),核心只负责存储/增删/生命周期,
1320
+ * 不解析内容。库文件:`{authDir}/credentials.sqlite`,表:
1321
+ * credentials(supplier TEXT, uid TEXT, data TEXT, PRIMARY KEY(supplier, uid))
1322
+ */
1323
+ /** 通用凭证存储。 */
1324
+ var CredentialStore = class {
1325
+ db;
1326
+ constructor(authDir) {
1327
+ if (authDir !== "" && authDir !== ".") mkdirSync(authDir, { recursive: true });
1328
+ const dbFile = join(authDir, "credentials.sqlite");
1329
+ this.db = new DatabaseSync(dbFile);
1330
+ this.db.exec(`CREATE TABLE IF NOT EXISTS credentials (
1331
+ supplier TEXT NOT NULL,
1332
+ uid TEXT NOT NULL,
1333
+ data TEXT NOT NULL,
1334
+ PRIMARY KEY (supplier, uid)
1335
+ )`);
1336
+ }
1337
+ /** 某供应商的所有凭证 uid。 */
1338
+ list(supplierId) {
1339
+ return this.db.prepare("SELECT uid FROM credentials WHERE supplier = ? ORDER BY rowid").all(supplierId).map((r) => r.uid);
1340
+ }
1341
+ /** 读某凭证(不透明 blob)。不存在返回 undefined。 */
1342
+ get(supplierId, uid) {
1343
+ const row = this.db.prepare("SELECT data FROM credentials WHERE supplier = ? AND uid = ?").get(supplierId, uid);
1344
+ if (row === void 0) return void 0;
1345
+ try {
1346
+ return JSON.parse(row.data);
1347
+ } catch {
1348
+ return;
1349
+ }
1350
+ }
1351
+ /** 存凭证(upsert)。 */
1352
+ save(supplierId, uid, data) {
1353
+ this.db.prepare("INSERT INTO credentials (supplier, uid, data) VALUES (?, ?, ?) ON CONFLICT(supplier, uid) DO UPDATE SET data = excluded.data").run(supplierId, uid, JSON.stringify(data));
1354
+ }
1355
+ /** 删凭证。 */
1356
+ remove(supplierId, uid) {
1357
+ this.db.prepare("DELETE FROM credentials WHERE supplier = ? AND uid = ?").run(supplierId, uid);
1358
+ }
1359
+ /** 关闭连接(可选)。 */
1360
+ close() {
1361
+ this.db.close();
1362
+ }
1363
+ };
1364
+ //#endregion
1365
+ //#region src/index.ts
1366
+ /** Plugin identity for cordis.yml rows. */
1367
+ const name = "dsh-router";
1368
+ /** Services required before mounting: the webserver (routes) + llm (设置-模型). */
1369
+ const inject = ["webServer", "llm"];
1370
+ function writeJson(res, status, body) {
1371
+ const payload = JSON.stringify(body);
1372
+ res.writeHead(status, {
1373
+ "Content-Type": "application/json; charset=utf-8",
1374
+ "Cache-Control": "no-store"
1375
+ });
1376
+ res.end(payload);
1377
+ }
1378
+ function readBody(req, limit) {
1379
+ return new Promise((resolve, reject) => {
1380
+ const chunks = [];
1381
+ let size = 0;
1382
+ req.on("data", (chunk) => {
1383
+ size += chunk.length;
1384
+ if (size > limit) {
1385
+ reject(/* @__PURE__ */ new Error("body_too_large"));
1386
+ req.destroy();
1387
+ return;
1388
+ }
1389
+ chunks.push(chunk);
1390
+ });
1391
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
1392
+ req.on("error", reject);
1393
+ });
1394
+ }
1395
+ /** Plugin body. */
1396
+ function apply(rawContext) {
1397
+ const ctx = rawContext;
1398
+ const log = (msg) => ctx.logger.info(`[dsh-router] ${msg}`);
1399
+ const stateFile = process.env.TW2A_STATE_FILE ?? "data/state.json";
1400
+ const store = new SupplierConfigStore(stateFile);
1401
+ const credentials = new CredentialStore(process.env.TW2A_AUTH_DIR ?? join(dirname(stateFile), "auths"));
1402
+ const router = new Router(stateFile);
1403
+ const keys = new KeysStore(stateFile);
1404
+ const disposers = [];
1405
+ const route = (path, handler) => {
1406
+ disposers.push(ctx.webServer.register({
1407
+ kind: "exact",
1408
+ path,
1409
+ handler
1410
+ }));
1411
+ };
1412
+ const loadedSuppliers = [];
1413
+ /** 每个供应商的 webServer 路由注销函数(注销时单独清理)。 */
1414
+ const supplierDisposers = /* @__PURE__ */ new Map();
1415
+ const registerSupplierRoutes = (loaded) => {
1416
+ const dis = [];
1417
+ for (const r of supplierRoutes(ROUTER_API_BASE, loaded, store)) dis.push(ctx.webServer.register(r));
1418
+ supplierDisposers.set(loaded.supplier.id, dis);
1419
+ log(`supplier routes registered: ${loaded.supplier.id}`);
1420
+ };
1421
+ /** 注销一个供应商(路由 + 路由器 + 列表)。 */
1422
+ const unregisterSupplier = (id) => {
1423
+ const i = loadedSuppliers.findIndex((l) => l.supplier.id === id);
1424
+ if (i < 0) return;
1425
+ const [loaded] = loadedSuppliers.splice(i, 1);
1426
+ for (const dispose of supplierDisposers.get(id) ?? []) dispose();
1427
+ supplierDisposers.delete(id);
1428
+ router.removeSupplier(id);
1429
+ loaded?.supplier.dispose();
1430
+ log(`supplier unregistered: ${id}`);
1431
+ };
1432
+ const dataDir = dirname(stateFile);
1433
+ const registerLoaded = (loaded) => {
1434
+ loadedSuppliers.push(loaded);
1435
+ router.add(loaded.supplier);
1436
+ registerSupplierRoutes(loaded);
1437
+ };
1438
+ /** 当前由 router.suppliers service 加载的供应商 id(外部插件卸载时全部注销)。 */
1439
+ let externalSupplierIds = [];
1440
+ const loadExternal = (suppliers) => {
1441
+ for (const [sid, factory] of Object.entries(suppliers)) {
1442
+ if (externalSupplierIds.includes(sid)) continue;
1443
+ try {
1444
+ const loaded = wrapModule(factory({
1445
+ dataDir,
1446
+ log,
1447
+ store,
1448
+ credentials
1449
+ }), {
1450
+ dataDir,
1451
+ log,
1452
+ store,
1453
+ credentials
1454
+ }, `service router.suppliers.${sid}`);
1455
+ registerLoaded(loaded);
1456
+ externalSupplierIds.push(loaded.supplier.id);
1457
+ log(`external supplier loaded: ${sid}`);
1458
+ } catch (err) {
1459
+ ctx.logger.warn(`[dsh-router] external supplier ${sid} load failed: ${err.message}`);
1460
+ }
1461
+ }
1462
+ };
1463
+ const unloadExternal = () => {
1464
+ for (const id of externalSupplierIds.splice(0)) unregisterSupplier(id);
1465
+ };
1466
+ ctx.on("internal/service", (name, value) => {
1467
+ if (name !== "router.suppliers") return;
1468
+ if (value) loadExternal(value);
1469
+ else unloadExternal();
1470
+ });
1471
+ ctx.inject(["router.suppliers"], (sctx) => {
1472
+ const c = sctx;
1473
+ const service = c.get?.("router.suppliers") ?? c.router?.suppliers;
1474
+ if (service) loadExternal(service);
1475
+ });
1476
+ (async () => {
1477
+ try {
1478
+ const userDir = join(process.env.DSH_PROFILE_DIR ?? join(process.env.HOME ?? "", ".dsh", "profiles", "web"), "suppliers");
1479
+ const { suppliers, errors } = await loadSuppliers({
1480
+ builtinDir: join(import.meta.dirname, "suppliers"),
1481
+ userDir,
1482
+ dataDir,
1483
+ store,
1484
+ credentials,
1485
+ log
1486
+ });
1487
+ for (const err of errors) ctx.logger.warn(`[dsh-router] ${err.error}`);
1488
+ for (const loaded of suppliers) registerLoaded(loaded);
1489
+ log(`suppliers loaded: ${suppliers.map((s) => s.supplier.id).join(", ") || "(none)"}`);
1490
+ } catch (err) {
1491
+ ctx.logger.warn(`[dsh-router] suppliers load failed: ${err.message}`);
1492
+ }
1493
+ })();
1494
+ const withV1Auth = (handler) => {
1495
+ return async (req, res) => {
1496
+ const authz = req.headers.authorization ?? "";
1497
+ const bearer = authz.length >= 7 && authz.slice(0, 7).toLowerCase() === "Bearer ".toLowerCase() ? authz.slice(7) : void 0;
1498
+ if (!keys.verify(bearer)) {
1499
+ writeJson(res, 401, { error: {
1500
+ message: "missing or invalid API key",
1501
+ type: "api_error",
1502
+ code: "invalid_api_key"
1503
+ } });
1504
+ return;
1505
+ }
1506
+ await handler(req, res);
1507
+ };
1508
+ };
1509
+ route("/v1/models", withV1Auth(async (_req, res) => {
1510
+ writeJson(res, 200, {
1511
+ object: "list",
1512
+ data: await router.listModels()
1513
+ });
1514
+ }));
1515
+ route("/v1/chat/completions", withV1Auth(async (req, res) => {
1516
+ let body;
1517
+ try {
1518
+ body = await readBody(req, 8 << 20);
1519
+ } catch {
1520
+ writeJson(res, 413, { error: {
1521
+ message: "request body too large",
1522
+ type: "api_error",
1523
+ code: "request_too_large"
1524
+ } });
1525
+ return;
1526
+ }
1527
+ let peek;
1528
+ try {
1529
+ peek = JSON.parse(body);
1530
+ } catch {
1531
+ writeJson(res, 400, { error: {
1532
+ message: "invalid JSON body",
1533
+ type: "api_error",
1534
+ code: "invalid_request"
1535
+ } });
1536
+ return;
1537
+ }
1538
+ await router.chatCompletions({
1539
+ rawBody: body,
1540
+ stream: !!peek.stream,
1541
+ model: typeof peek.model === "string" ? peek.model : ""
1542
+ }, res);
1543
+ }));
1544
+ route(`${ROUTER_API_BASE}/health`, (_req, res) => {
1545
+ const { suppliers } = router.status();
1546
+ writeJson(res, 200, {
1547
+ ok: true,
1548
+ suppliers: suppliers.map((s) => {
1549
+ const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id);
1550
+ return {
1551
+ id: s.id,
1552
+ name: s.name,
1553
+ icon: loaded?.supplier.icon,
1554
+ capabilities: loaded ? [...loaded.capabilities] : [],
1555
+ source: loaded?.source ?? "external"
1556
+ };
1557
+ })
1558
+ });
1559
+ });
1560
+ route(`${ROUTER_API_BASE}/status`, (_req, res) => {
1561
+ const { suppliers } = router.status();
1562
+ writeJson(res, 200, {
1563
+ ok: true,
1564
+ accounts: suppliers.flatMap((s) => s.accounts.map((a) => ({
1565
+ ...a,
1566
+ supplier: s.id
1567
+ })))
1568
+ });
1569
+ });
1570
+ route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
1571
+ try {
1572
+ writeJson(res, 200, {
1573
+ ok: true,
1574
+ models: await router.listModels()
1575
+ });
1576
+ } catch (error) {
1577
+ writeJson(res, 500, {
1578
+ ok: false,
1579
+ error: error.message
1580
+ });
1581
+ }
1582
+ });
1583
+ route(`${ROUTER_API_BASE}/combos`, async (_req, res) => {
1584
+ const [combos, groups] = await Promise.all([router.combos(), router.supplierModels().catch(() => [])]);
1585
+ writeJson(res, 200, {
1586
+ ok: true,
1587
+ combos,
1588
+ groups,
1589
+ aliases: router.aliases()
1590
+ });
1591
+ });
1592
+ route(`${ROUTER_API_BASE}/combos/create`, async (req, res) => {
1593
+ let body;
1594
+ try {
1595
+ body = JSON.parse(await readBody(req, 65536));
1596
+ } catch {
1597
+ writeJson(res, 400, {
1598
+ ok: false,
1599
+ error: "invalid JSON body"
1600
+ });
1601
+ return;
1602
+ }
1603
+ const result = router.createCombo(body.name ?? "", body.strategy ?? "fallback", body.models ?? []);
1604
+ writeJson(res, result.ok ? 200 : 400, result);
1605
+ });
1606
+ route(`${ROUTER_API_BASE}/combos/update`, async (req, res) => {
1607
+ let body;
1608
+ try {
1609
+ body = JSON.parse(await readBody(req, 65536));
1610
+ } catch {
1611
+ writeJson(res, 400, {
1612
+ ok: false,
1613
+ error: "invalid JSON body"
1614
+ });
1615
+ return;
1616
+ }
1617
+ const result = router.updateCombo(body.id ?? "", body.name ?? "", body.strategy ?? "fallback", body.models ?? []);
1618
+ writeJson(res, result.ok ? 200 : 400, result);
1619
+ });
1620
+ route(`${ROUTER_API_BASE}/combos/remove`, async (req, res) => {
1621
+ let body;
1622
+ try {
1623
+ body = JSON.parse(await readBody(req, 65536));
1624
+ } catch {
1625
+ writeJson(res, 400, {
1626
+ ok: false,
1627
+ error: "invalid JSON body"
1628
+ });
1629
+ return;
1630
+ }
1631
+ const result = router.removeCombo(body.id ?? "");
1632
+ writeJson(res, result.ok ? 200 : 400, result);
1633
+ });
1634
+ route(`${ROUTER_API_BASE}/keys`, async (req, res) => {
1635
+ if (req.method === "POST") {
1636
+ let body;
1637
+ try {
1638
+ body = JSON.parse(await readBody(req, 65536));
1639
+ } catch {
1640
+ writeJson(res, 400, {
1641
+ ok: false,
1642
+ error: "invalid JSON body"
1643
+ });
1644
+ return;
1645
+ }
1646
+ const { key } = keys.create(typeof body.name === "string" ? body.name : "");
1647
+ writeJson(res, 201, {
1648
+ ok: true,
1649
+ key
1650
+ });
1651
+ return;
1652
+ }
1653
+ writeJson(res, 200, {
1654
+ ok: true,
1655
+ keys: keys.list()
1656
+ });
1657
+ });
1658
+ route(`${ROUTER_API_BASE}/keys/delete`, async (req, res) => {
1659
+ let body;
1660
+ try {
1661
+ body = JSON.parse(await readBody(req, 65536));
1662
+ } catch {
1663
+ writeJson(res, 400, {
1664
+ ok: false,
1665
+ error: "invalid JSON body"
1666
+ });
1667
+ return;
1668
+ }
1669
+ const ok = typeof body.id === "string" && keys.remove(body.id);
1670
+ writeJson(res, ok ? 200 : 404, ok ? { ok: true } : {
1671
+ ok: false,
1672
+ error: "key not found"
1673
+ });
1674
+ });
1675
+ route(`${ROUTER_API_BASE}/keys/toggle`, async (req, res) => {
1676
+ let body;
1677
+ try {
1678
+ body = JSON.parse(await readBody(req, 65536));
1679
+ } catch {
1680
+ writeJson(res, 400, {
1681
+ ok: false,
1682
+ error: "invalid JSON body"
1683
+ });
1684
+ return;
1685
+ }
1686
+ const ok = typeof body.id === "string" && keys.setActive(body.id, !!body.isActive);
1687
+ writeJson(res, ok ? 200 : 404, ok ? { ok: true } : {
1688
+ ok: false,
1689
+ error: "key not found"
1690
+ });
1691
+ });
1692
+ route(`${ROUTER_API_BASE}/settings`, async (req, res) => {
1693
+ if (req.method === "PATCH") {
1694
+ let body;
1695
+ try {
1696
+ body = JSON.parse(await readBody(req, 65536));
1697
+ } catch {
1698
+ writeJson(res, 400, {
1699
+ ok: false,
1700
+ error: "invalid JSON body"
1701
+ });
1702
+ return;
1703
+ }
1704
+ if (typeof body.requireApiKey === "boolean") keys.requireApiKey = body.requireApiKey;
1705
+ }
1706
+ writeJson(res, 200, {
1707
+ ok: true,
1708
+ requireApiKey: keys.requireApiKey
1709
+ });
1710
+ });
1711
+ if (ctx.llm !== void 0) {
1712
+ const LLM_NS = "llm-dsh-router";
1713
+ try {
1714
+ const providerReg = ctx.llm.registerConfigurableProviders([{
1715
+ provider: "router",
1716
+ displayName: "Router",
1717
+ settingsNs: LLM_NS,
1718
+ settingsPath: []
1719
+ }]);
1720
+ disposers.push(providerReg);
1721
+ disposers.push(ctx.llm.registerModelDiscovery(LLM_NS, async () => {
1722
+ return (await router.combos()).map((c) => ({ id: c.name }));
1723
+ }));
1724
+ disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) => ({ id: c.name })) })));
1725
+ log("llm provider (Router) + discovery + adapter registered ok");
1726
+ } catch (err) {
1727
+ ctx.logger.warn(`[dsh-router] llm registration failed: ${err.message}`);
1728
+ }
1729
+ } else log("llm service NOT available — skip 设置-模型 registration");
1730
+ route(`${ROUTER_API_BASE}/debug/llm`, async (_req, res) => {
1731
+ let directory = "n/a";
1732
+ let registeredProviders = "n/a";
1733
+ try {
1734
+ directory = ctx.llm !== void 0 ? ctx.llm.listConfigurableProviders() : "no llm";
1735
+ } catch (err) {
1736
+ directory = `error: ${err.message}`;
1737
+ }
1738
+ try {
1739
+ registeredProviders = ctx.llm !== void 0 ? ctx.llm.listProviders() : "no llm";
1740
+ } catch (err) {
1741
+ registeredProviders = `error: ${err.message}`;
1742
+ }
1743
+ writeJson(res, 200, {
1744
+ ok: true,
1745
+ llmAvailable: ctx.llm !== void 0,
1746
+ directory,
1747
+ registeredProviders
1748
+ });
1749
+ });
1750
+ ctx.effect(() => () => {
1751
+ for (const dispose of disposers.splice(0)) dispose();
1752
+ router.dispose();
1753
+ }, "dsh-router: teardown");
1754
+ }
1755
+ //#endregion
1756
+ export { apply, inject, name };