dsh-router-core 0.3.13 → 0.3.15
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/docs/suppliers.md +8 -8
- package/lib/client-registry.js +57 -6
- package/lib/client.js +57 -6
- package/lib/index.js +158 -66
- package/lib/types/llm/adapter.d.ts +34 -8
- package/lib/types/router/account-pool.d.ts +11 -11
- package/lib/types/router/types.d.ts +0 -1
- package/lib/types/shared.d.ts +0 -1
- package/lib/types/suppliers/contract.d.ts +5 -5
- package/package.json +5 -1
- package/src/client/RouterView.tsx +1 -1
- package/src/client/SupplierDetail.tsx +1 -2
- package/src/client/index.tsx +3 -0
- package/src/client/model-hint-copy.test.ts +31 -0
- package/src/client/model-hint-copy.ts +55 -0
- package/src/index.ts +61 -2
- package/src/llm/adapter.test.ts +82 -0
- package/src/llm/adapter.ts +130 -17
- package/src/router/account-pool.test.ts +11 -10
- package/src/router/account-pool.ts +46 -45
- package/src/router/models-cache.test.ts +0 -1
- package/src/router/types.ts +0 -1
- package/src/shared.ts +0 -1
- package/src/suppliers/contract.ts +5 -5
- package/src/suppliers/loader.ts +1 -1
- package/src/suppliers/registry.test.ts +3 -4
- package/src/suppliers/registry.ts +1 -1
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename, dirname, join, resolve } from "node:path";
|
|
2
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
2
3
|
import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
5
|
import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, attributionHeaders } from "@deepseek-ai/dsh-llm";
|
|
@@ -105,42 +106,34 @@ const MINUTE = 6e4;
|
|
|
105
106
|
const RULES = {
|
|
106
107
|
ok: {
|
|
107
108
|
cooldown: 0,
|
|
108
|
-
disable: false,
|
|
109
109
|
counts: false
|
|
110
110
|
},
|
|
111
111
|
rate_limit: {
|
|
112
112
|
cooldown: "backoff",
|
|
113
|
-
disable: false,
|
|
114
113
|
counts: false
|
|
115
114
|
},
|
|
116
115
|
quota: {
|
|
117
116
|
cooldown: 10 * MINUTE,
|
|
118
|
-
disable: false,
|
|
119
117
|
counts: false
|
|
120
118
|
},
|
|
121
119
|
session_dead: {
|
|
122
|
-
cooldown:
|
|
123
|
-
disable: true,
|
|
120
|
+
cooldown: 30 * MINUTE,
|
|
124
121
|
counts: false
|
|
125
122
|
},
|
|
126
123
|
unavailable: {
|
|
127
124
|
cooldown: "transient",
|
|
128
|
-
disable: false,
|
|
129
125
|
counts: false
|
|
130
126
|
},
|
|
131
127
|
transport: {
|
|
132
128
|
cooldown: "transient",
|
|
133
|
-
disable: false,
|
|
134
129
|
counts: false
|
|
135
130
|
},
|
|
136
131
|
unknown: {
|
|
137
132
|
cooldown: "transient",
|
|
138
|
-
disable: false,
|
|
139
133
|
counts: false
|
|
140
134
|
},
|
|
141
135
|
no_such_model: {
|
|
142
136
|
cooldown: 0,
|
|
143
|
-
disable: false,
|
|
144
137
|
counts: false
|
|
145
138
|
}
|
|
146
139
|
};
|
|
@@ -182,7 +175,7 @@ var AccountPool = class {
|
|
|
182
175
|
supplierId;
|
|
183
176
|
/** (supplier, model, uid) → 冷却/退避。 */
|
|
184
177
|
cooldowns = /* @__PURE__ */ new Map();
|
|
185
|
-
/** uid →
|
|
178
|
+
/** uid → 连接级冷却 / 手动暂停。 */
|
|
186
179
|
byUid = /* @__PURE__ */ new Map();
|
|
187
180
|
rrCursor = 0;
|
|
188
181
|
/** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
|
|
@@ -212,46 +205,30 @@ var AccountPool = class {
|
|
|
212
205
|
}
|
|
213
206
|
return e;
|
|
214
207
|
}
|
|
215
|
-
/**
|
|
208
|
+
/** 某连接整体状态(连接级冷却/手动暂停)。 */
|
|
216
209
|
uidState(uid) {
|
|
217
210
|
let e = this.byUid.get(uid);
|
|
218
211
|
if (!e) {
|
|
219
212
|
e = {
|
|
220
213
|
until: 0,
|
|
221
|
-
disabled: false,
|
|
222
214
|
reason: ""
|
|
223
215
|
};
|
|
224
216
|
this.byUid.set(uid, e);
|
|
225
217
|
}
|
|
226
218
|
return e;
|
|
227
219
|
}
|
|
228
|
-
/**
|
|
220
|
+
/** 该连接是否处于连接级冷却/手动暂停中(跨模型生效,到期自愈)。 */
|
|
229
221
|
uidBlocked(uid, now) {
|
|
230
222
|
const e = this.byUid.get(uid);
|
|
231
|
-
|
|
232
|
-
blocked: false,
|
|
233
|
-
disabled: false
|
|
234
|
-
};
|
|
235
|
-
if (e.disabled) return {
|
|
236
|
-
blocked: true,
|
|
237
|
-
disabled: true
|
|
238
|
-
};
|
|
239
|
-
if (e.until > now) return {
|
|
240
|
-
blocked: true,
|
|
241
|
-
disabled: false
|
|
242
|
-
};
|
|
243
|
-
return {
|
|
244
|
-
blocked: false,
|
|
245
|
-
disabled: false
|
|
246
|
-
};
|
|
223
|
+
return e !== void 0 && e.until > now;
|
|
247
224
|
}
|
|
248
225
|
/**
|
|
249
226
|
* 给定要试的模型,该连接是否可服务:
|
|
250
|
-
* -
|
|
227
|
+
* - 不处于连接级冷却/手动暂停(跨模型,到期自愈);
|
|
251
228
|
* - 且此 (supplier, model, uid) 冷却单元不在冷却中。
|
|
252
229
|
*/
|
|
253
230
|
healthy(uid, model, now) {
|
|
254
|
-
if (this.uidBlocked(uid, now)
|
|
231
|
+
if (this.uidBlocked(uid, now)) return false;
|
|
255
232
|
const c = this.cooldowns.get(this.key(model, uid));
|
|
256
233
|
if (!c) return true;
|
|
257
234
|
return c.until <= now;
|
|
@@ -370,16 +347,17 @@ var AccountPool = class {
|
|
|
370
347
|
blockKey(modelId) {
|
|
371
348
|
return `${this.supplierId}${SEP}${modelId}`;
|
|
372
349
|
}
|
|
373
|
-
/**
|
|
350
|
+
/** 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。 */
|
|
374
351
|
noteFailure(uid, modelId, state, message) {
|
|
375
352
|
const rule = RULES[state];
|
|
376
|
-
if (rule.
|
|
353
|
+
if (rule.cooldown === 0) return;
|
|
354
|
+
if (state === "session_dead") {
|
|
377
355
|
const e = this.uidState(uid);
|
|
378
|
-
|
|
356
|
+
const cd = rule.cooldown;
|
|
357
|
+
e.until = Math.max(e.until, Date.now() + (typeof cd === "number" ? cd : 0));
|
|
379
358
|
e.reason = message;
|
|
380
359
|
return;
|
|
381
360
|
}
|
|
382
|
-
if (rule.cooldown === 0) return;
|
|
383
361
|
const e = this.entry(modelId, uid);
|
|
384
362
|
e.reason = message;
|
|
385
363
|
if (rule.cooldown === "backoff") {
|
|
@@ -393,7 +371,7 @@ var AccountPool = class {
|
|
|
393
371
|
}
|
|
394
372
|
e.until = Math.max(e.until, Date.now() + rule.cooldown);
|
|
395
373
|
}
|
|
396
|
-
/**
|
|
374
|
+
/** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。 */
|
|
397
375
|
noteSuccess(uid, modelId) {
|
|
398
376
|
const e = this.cooldowns.get(this.key(modelId, uid));
|
|
399
377
|
if (e) e.backoffLevel = 0;
|
|
@@ -405,7 +383,7 @@ var AccountPool = class {
|
|
|
405
383
|
e.reason = reason;
|
|
406
384
|
for (const [k, ce] of this.cooldowns) if (k.endsWith(`${SEP}${uid}`)) ce.backoffLevel = 0;
|
|
407
385
|
}
|
|
408
|
-
/**
|
|
386
|
+
/** 把核心的冷却叠加到插件报的「现在状态」上,产出面板态。
|
|
409
387
|
*
|
|
410
388
|
* 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
|
|
411
389
|
* 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
|
|
@@ -416,11 +394,10 @@ var AccountPool = class {
|
|
|
416
394
|
const now = Date.now();
|
|
417
395
|
return accounts.map((a) => {
|
|
418
396
|
const u = this.byUid.get(a.uid);
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
let
|
|
422
|
-
let
|
|
423
|
-
let reason = manualCooling ? u.reason : void 0;
|
|
397
|
+
const uidCooling = u !== void 0 && u.until > now;
|
|
398
|
+
let cooling = uidCooling;
|
|
399
|
+
let maxUntil = uidCooling ? u.until : 0;
|
|
400
|
+
let reason = uidCooling ? u.reason : void 0;
|
|
424
401
|
let err = 0;
|
|
425
402
|
for (const [k, ce] of this.cooldowns) {
|
|
426
403
|
if (!k.endsWith(`${SEP}${a.uid}`)) continue;
|
|
@@ -440,7 +417,6 @@ var AccountPool = class {
|
|
|
440
417
|
return {
|
|
441
418
|
...a,
|
|
442
419
|
cooling,
|
|
443
|
-
disabled,
|
|
444
420
|
err_count: err,
|
|
445
421
|
until: cooling && maxUntil > 0 ? new Date(maxUntil).toISOString() : void 0,
|
|
446
422
|
reason: reason !== void 0 && reason !== "" ? reason : void 0
|
|
@@ -2298,10 +2274,14 @@ var Router = class {
|
|
|
2298
2274
|
* 模型目录 = 组合(自动带出,不可改)。对话转发到本插件 /v1/chat/completions,
|
|
2299
2275
|
* 由现有路由按组合策略命中供应商模型。
|
|
2300
2276
|
*
|
|
2301
|
-
*
|
|
2277
|
+
* 图文流:adapter 声明图片 modal(`inputModalities` 含 `image`),把 user 消息里的
|
|
2278
|
+
* `image` 块序列化成 OpenAI 标准的 `image_url` base64 part,经 /v1 原样透传给
|
|
2279
|
+
* 命中的上游供应商。最终能否看图取决于**命中的那个上游模型**——组合里是异构
|
|
2280
|
+
* 供应商,可能在网关端声明了图片能力、某条上游却只收文本(此时由上游自行拒收)。
|
|
2281
|
+
* 图片字节从 `ctx.attachments` 读取(读不到的按稳定占位文本降级,绝不静默丢图)。
|
|
2302
2282
|
*/
|
|
2303
2283
|
/**
|
|
2304
|
-
* 把 DSH 消息序列化成 openai 兼容 wire
|
|
2284
|
+
* 把 DSH 消息序列化成 openai 兼容 wire 消息(文本 + 图片 + tool)。
|
|
2305
2285
|
*
|
|
2306
2286
|
* `system` 必须由调用方从 `options.system` 传进来:one-shot 调用方把系统提示词
|
|
2307
2287
|
* 放在 **options.system 这个独立槽位**,不放进 messages。不读这个槽位,
|
|
@@ -2313,8 +2293,12 @@ var Router = class {
|
|
|
2313
2293
|
* 可出现在任意位置以支持提示词热更新)。两条路径都必须支持 —— 与官方
|
|
2314
2294
|
* dsh-llm-deepseek 一致(serializeRequest 前置 options.system,再按位置序列化
|
|
2315
2295
|
* messages 里的 system 消息)。
|
|
2296
|
+
*
|
|
2297
|
+
* 图片:`image` 块被序列化成 OpenAI `content` 数组里的 `image_url` part(data URI
|
|
2298
|
+
* base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
|
|
2299
|
+
* **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
|
|
2316
2300
|
*/
|
|
2317
|
-
function wireMessages(options, system) {
|
|
2301
|
+
async function wireMessages(options, system, attachments) {
|
|
2318
2302
|
const out = [];
|
|
2319
2303
|
if (typeof system === "string" && system !== "") out.push({
|
|
2320
2304
|
role: "system",
|
|
@@ -2347,12 +2331,24 @@ function wireMessages(options, system) {
|
|
|
2347
2331
|
});
|
|
2348
2332
|
continue;
|
|
2349
2333
|
}
|
|
2350
|
-
const text = flattenText(message.content);
|
|
2351
2334
|
const toolResults = message.content.filter((b) => b.type === "tool-result");
|
|
2352
|
-
if (
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2335
|
+
if (hasImageBlock(message.content)) {
|
|
2336
|
+
const parts = await contentParts(message.content, attachments, options.signal);
|
|
2337
|
+
if (parts.length > 0) out.push({
|
|
2338
|
+
role: "user",
|
|
2339
|
+
content: parts
|
|
2340
|
+
});
|
|
2341
|
+
else if (toolResults.length === 0) out.push({
|
|
2342
|
+
role: "user",
|
|
2343
|
+
content: ""
|
|
2344
|
+
});
|
|
2345
|
+
} else {
|
|
2346
|
+
const text = flattenText(message.content);
|
|
2347
|
+
if (text.length > 0 || toolResults.length === 0) out.push({
|
|
2348
|
+
role: "user",
|
|
2349
|
+
content: text
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2356
2352
|
for (const result of toolResults) out.push({
|
|
2357
2353
|
role: "tool",
|
|
2358
2354
|
tool_call_id: result.toolCallId,
|
|
@@ -2361,9 +2357,73 @@ function wireMessages(options, system) {
|
|
|
2361
2357
|
}
|
|
2362
2358
|
return out;
|
|
2363
2359
|
}
|
|
2364
|
-
/**
|
|
2365
|
-
|
|
2366
|
-
|
|
2360
|
+
/**
|
|
2361
|
+
* 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
|
|
2362
|
+
* 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
|
|
2363
|
+
* attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
|
|
2364
|
+
*/
|
|
2365
|
+
async function contentParts(blocks, attachments, signal) {
|
|
2366
|
+
const parts = [];
|
|
2367
|
+
for (const block of blocks) {
|
|
2368
|
+
if (block.type === "text") {
|
|
2369
|
+
const text = block.text ?? "";
|
|
2370
|
+
if (text.length > 0) parts.push({
|
|
2371
|
+
type: "text",
|
|
2372
|
+
text
|
|
2373
|
+
});
|
|
2374
|
+
continue;
|
|
2375
|
+
}
|
|
2376
|
+
if (block.type === "image") {
|
|
2377
|
+
const ref = block.attachment;
|
|
2378
|
+
parts.push(...await imageParts(ref, attachments, signal));
|
|
2379
|
+
continue;
|
|
2380
|
+
}
|
|
2381
|
+
if (block.type === "tool-result") parts.push(...await contentParts(block.content, attachments, signal));
|
|
2382
|
+
}
|
|
2383
|
+
return parts;
|
|
2384
|
+
}
|
|
2385
|
+
/** 一张图片 → 一个 OpenAI image_url part(或读不到附件时的占位文本)。 */
|
|
2386
|
+
async function imageParts(ref, attachments, signal) {
|
|
2387
|
+
if (attachments === void 0) return [{
|
|
2388
|
+
type: "text",
|
|
2389
|
+
text: imagePlaceholder(ref.attachmentId)
|
|
2390
|
+
}];
|
|
2391
|
+
try {
|
|
2392
|
+
const img = await attachments.readImageRequest({
|
|
2393
|
+
attachmentId: ref.attachmentId,
|
|
2394
|
+
mediaType: ref.mediaType,
|
|
2395
|
+
bytes: ref.bytes,
|
|
2396
|
+
width: ref.width,
|
|
2397
|
+
height: ref.height
|
|
2398
|
+
}, {
|
|
2399
|
+
maxPixels: 64e4,
|
|
2400
|
+
maxBytes: 1048576
|
|
2401
|
+
}, signal);
|
|
2402
|
+
return [{
|
|
2403
|
+
type: "image_url",
|
|
2404
|
+
image_url: { url: `data:${img.mediaType};base64,${bytesToBase64(img.data)}` }
|
|
2405
|
+
}];
|
|
2406
|
+
} catch {
|
|
2407
|
+
return [{
|
|
2408
|
+
type: "text",
|
|
2409
|
+
text: imagePlaceholder(ref.attachmentId)
|
|
2410
|
+
}];
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
/** Uint8Array → base64(browser + node 双环境安全,不依赖 Buffer 全局)。 */
|
|
2414
|
+
function bytesToBase64(bytes) {
|
|
2415
|
+
let bin = "";
|
|
2416
|
+
const chunk = 32768;
|
|
2417
|
+
for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
2418
|
+
return btoa(bin);
|
|
2419
|
+
}
|
|
2420
|
+
/** 读不到图片字节时的稳定占位文本。 */
|
|
2421
|
+
function imagePlaceholder(attachmentId) {
|
|
2422
|
+
return `[image omitted because the attached bytes could not be read; attachment ${attachmentId}]`;
|
|
2423
|
+
}
|
|
2424
|
+
/** 组装 wire 请求体。图片序列化需要读 attachments,故为异步。 */
|
|
2425
|
+
async function wireRequest(options, attachments) {
|
|
2426
|
+
const messages = await wireMessages(options, options.system, attachments);
|
|
2367
2427
|
const body = {
|
|
2368
2428
|
model: options.model,
|
|
2369
2429
|
messages,
|
|
@@ -2410,6 +2470,14 @@ function applyReasoning(body, effort) {
|
|
|
2410
2470
|
function flattenText(blocks) {
|
|
2411
2471
|
return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
2412
2472
|
}
|
|
2473
|
+
/** 内容块里(含递归进 tool-result)是否存在 image 块。决定走数组 content 还是字符串。 */
|
|
2474
|
+
function hasImageBlock(blocks) {
|
|
2475
|
+
for (const b of blocks) {
|
|
2476
|
+
if (b.type === "image") return true;
|
|
2477
|
+
if (b.type === "tool-result" && hasImageBlock(b.content)) return true;
|
|
2478
|
+
}
|
|
2479
|
+
return false;
|
|
2480
|
+
}
|
|
2413
2481
|
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
2414
2482
|
/**
|
|
2415
2483
|
* 把上游 SSE 的 `data:` 载荷流翻译成 DSH 的 StreamChunk 事件流。
|
|
@@ -2643,10 +2711,12 @@ const ROUTER_REASONING_EFFORTS = [
|
|
|
2643
2711
|
var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
2644
2712
|
baseURL;
|
|
2645
2713
|
source;
|
|
2646
|
-
|
|
2714
|
+
resolveAttachments;
|
|
2715
|
+
constructor(baseURL, source, resolveAttachments) {
|
|
2647
2716
|
super();
|
|
2648
2717
|
this.baseURL = baseURL;
|
|
2649
2718
|
this.source = source;
|
|
2719
|
+
this.resolveAttachments = resolveAttachments ?? (() => void 0);
|
|
2650
2720
|
}
|
|
2651
2721
|
providerInfo(provider) {
|
|
2652
2722
|
return {
|
|
@@ -2670,14 +2740,14 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
|
2670
2740
|
return out;
|
|
2671
2741
|
}
|
|
2672
2742
|
/**
|
|
2673
|
-
*
|
|
2674
|
-
*
|
|
2675
|
-
*
|
|
2676
|
-
*
|
|
2677
|
-
*
|
|
2678
|
-
*
|
|
2743
|
+
* 声明图文:`inputModalities` 必须同时含 `text` 与 `image`。
|
|
2744
|
+
* - 含 `image`:runtime/subagent 的模态门禁放行(不再抛
|
|
2745
|
+
* `MODEL_DOES_NOT_SUPPORT_IMAGES` = 界面「当前模型不支持图片」),
|
|
2746
|
+
* `projectImagesForTextModel` 也不会把图片块投影成占位文本。
|
|
2747
|
+
* - 组合背后是异构供应商,**统一声明图片能力**;真正能否看图取决于命中
|
|
2748
|
+
* 的某个上游模型(供应商各模型 capability 不同,网关端不能一刀切拦截)。
|
|
2679
2749
|
*/
|
|
2680
|
-
static INPUT_MODALITIES = ["text"];
|
|
2750
|
+
static INPUT_MODALITIES = ["text", "image"];
|
|
2681
2751
|
async resolveModel(_provider, model) {
|
|
2682
2752
|
const found = (await this.source.comboModels()).find((m) => m.id === model || m.name === model);
|
|
2683
2753
|
return {
|
|
@@ -2693,7 +2763,7 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
|
2693
2763
|
};
|
|
2694
2764
|
}
|
|
2695
2765
|
async *stream(options) {
|
|
2696
|
-
const body = wireRequest(options);
|
|
2766
|
+
const body = await wireRequest(options, this.resolveAttachments());
|
|
2697
2767
|
const controller = new AbortController();
|
|
2698
2768
|
const onAbort = () => controller.abort();
|
|
2699
2769
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -3112,7 +3182,7 @@ function cap(set, key) {
|
|
|
3112
3182
|
}
|
|
3113
3183
|
/** 账号状态指纹(积分 + 健康),用于判断刷新是否落地。 */
|
|
3114
3184
|
function fingerprint(accounts) {
|
|
3115
|
-
return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}
|
|
3185
|
+
return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}`).join("|");
|
|
3116
3186
|
}
|
|
3117
3187
|
/**
|
|
3118
3188
|
* 反复调插件已有的 status(),直到快照稳定再返回(是否有变化 = changed)。
|
|
@@ -4161,13 +4231,26 @@ function apply(rawContext) {
|
|
|
4161
4231
|
disposers.push(ctx.llm.registerModelDiscovery(LLM_NS, async () => {
|
|
4162
4232
|
return (await router.combos()).map((c) => ({ id: c.name }));
|
|
4163
4233
|
}));
|
|
4234
|
+
const routerSettingsSchema = Schema.object({});
|
|
4235
|
+
ctx.inject(["settings"], (sctx) => {
|
|
4236
|
+
const settings = sctx.settings;
|
|
4237
|
+
if (settings === void 0) {
|
|
4238
|
+
log("settings service absent — skip Router settings-section registration");
|
|
4239
|
+
return;
|
|
4240
|
+
}
|
|
4241
|
+
settings.installSection(rawContext, LLM_NS, routerSettingsSchema, {}, {
|
|
4242
|
+
setSource: () => {},
|
|
4243
|
+
onChange: () => {}
|
|
4244
|
+
});
|
|
4245
|
+
log(`Router settings section registered (${LLM_NS})`);
|
|
4246
|
+
});
|
|
4164
4247
|
disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) => {
|
|
4165
4248
|
const w = router.comboContextWindow(c);
|
|
4166
4249
|
return {
|
|
4167
4250
|
id: c.name,
|
|
4168
4251
|
...w !== void 0 ? { contextWindow: w } : {}
|
|
4169
4252
|
};
|
|
4170
|
-
}) })));
|
|
4253
|
+
}) }, () => ctx.get("attachments"))));
|
|
4171
4254
|
log("llm provider (Router) + discovery + adapter registered ok");
|
|
4172
4255
|
(async () => {
|
|
4173
4256
|
for (const c of await router.combos()) for (const ref of c.models) {
|
|
@@ -4195,11 +4278,20 @@ function apply(rawContext) {
|
|
|
4195
4278
|
} catch (err) {
|
|
4196
4279
|
registeredProviders = `error: ${err.message}`;
|
|
4197
4280
|
}
|
|
4281
|
+
let settingsNamespaces = "n/a";
|
|
4282
|
+
try {
|
|
4283
|
+
const settings = ctx.get("settings");
|
|
4284
|
+
settingsNamespaces = settings?.describe === void 0 ? "no settings service" : settings.describe({ redactSecrets: true }).map((d) => d.ns);
|
|
4285
|
+
} catch (err) {
|
|
4286
|
+
settingsNamespaces = `error: ${err.message}`;
|
|
4287
|
+
}
|
|
4198
4288
|
writeJson(res, 200, {
|
|
4199
4289
|
ok: true,
|
|
4200
4290
|
llmAvailable: ctx.llm !== void 0,
|
|
4201
4291
|
directory,
|
|
4202
|
-
registeredProviders
|
|
4292
|
+
registeredProviders,
|
|
4293
|
+
settingsNamespaces,
|
|
4294
|
+
routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes("llm-dsh-router")
|
|
4203
4295
|
});
|
|
4204
4296
|
});
|
|
4205
4297
|
ctx.effect(() => () => {
|
|
@@ -4,9 +4,34 @@
|
|
|
4
4
|
* 模型目录 = 组合(自动带出,不可改)。对话转发到本插件 /v1/chat/completions,
|
|
5
5
|
* 由现有路由按组合策略命中供应商模型。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* 图文流:adapter 声明图片 modal(`inputModalities` 含 `image`),把 user 消息里的
|
|
8
|
+
* `image` 块序列化成 OpenAI 标准的 `image_url` base64 part,经 /v1 原样透传给
|
|
9
|
+
* 命中的上游供应商。最终能否看图取决于**命中的那个上游模型**——组合里是异构
|
|
10
|
+
* 供应商,可能在网关端声明了图片能力、某条上游却只收文本(此时由上游自行拒收)。
|
|
11
|
+
* 图片字节从 `ctx.attachments` 读取(读不到的按稳定占位文本降级,绝不静默丢图)。
|
|
8
12
|
*/
|
|
9
13
|
import { LlmAdapter, type GenerateOptions, type LlmModelInfo, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
14
|
+
/**
|
|
15
|
+
* dsh-attachment 的最小本地切面:adapter 只依赖「读一张图片的请求版本」
|
|
16
|
+
* 这一行为,不引入对 dsh-attachment 包编译期/运行期的依赖(它是宿主注入的
|
|
17
|
+
* devDependency)。本地声明与原包 `AttachmentStore.readImageRequest` 形状一致,
|
|
18
|
+
* 宿主注入真实 store 时天然满足。
|
|
19
|
+
*/
|
|
20
|
+
export interface RouterAttachmentStore {
|
|
21
|
+
readImageRequest(ref: {
|
|
22
|
+
attachmentId: string;
|
|
23
|
+
mediaType: string;
|
|
24
|
+
bytes: number;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
}, policy: {
|
|
28
|
+
maxPixels: number;
|
|
29
|
+
maxBytes: number;
|
|
30
|
+
}, signal?: AbortSignal): Promise<{
|
|
31
|
+
mediaType: string;
|
|
32
|
+
data: Uint8Array;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
10
35
|
/** 模型目录来源:组合。 */
|
|
11
36
|
export interface RouterAdapterSource {
|
|
12
37
|
/**
|
|
@@ -32,19 +57,20 @@ export declare function translateSse(payloads: AsyncIterable<string> | Iterable<
|
|
|
32
57
|
export declare class RouterAdapter extends LlmAdapter {
|
|
33
58
|
private readonly baseURL;
|
|
34
59
|
private readonly source;
|
|
35
|
-
|
|
60
|
+
private readonly resolveAttachments;
|
|
61
|
+
constructor(baseURL: string, source: RouterAdapterSource, resolveAttachments?: () => RouterAttachmentStore | undefined);
|
|
36
62
|
providerInfo(provider: string): {
|
|
37
63
|
id: string;
|
|
38
64
|
name: string;
|
|
39
65
|
};
|
|
40
66
|
listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
|
|
41
67
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
68
|
+
* 声明图文:`inputModalities` 必须同时含 `text` 与 `image`。
|
|
69
|
+
* - 含 `image`:runtime/subagent 的模态门禁放行(不再抛
|
|
70
|
+
* `MODEL_DOES_NOT_SUPPORT_IMAGES` = 界面「当前模型不支持图片」),
|
|
71
|
+
* `projectImagesForTextModel` 也不会把图片块投影成占位文本。
|
|
72
|
+
* - 组合背后是异构供应商,**统一声明图片能力**;真正能否看图取决于命中
|
|
73
|
+
* 的某个上游模型(供应商各模型 capability 不同,网关端不能一刀切拦截)。
|
|
48
74
|
*/
|
|
49
75
|
private static readonly INPUT_MODALITIES;
|
|
50
76
|
resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 账号池 —— dsh-router
|
|
2
|
+
* 账号池 —— dsh-router 核心策略:选号、冷却、遍历回退。
|
|
3
3
|
*
|
|
4
4
|
* 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里。
|
|
5
5
|
*
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* 失败而对其它模型也不可用,这是错的。
|
|
12
12
|
*
|
|
13
13
|
* - 冷却/限流退避/瞬时 → 键 = (supplierId, modelId, uid),只冷「这个号 × 这个模型」
|
|
14
|
-
* -
|
|
14
|
+
* - 凭证失效(session_dead) → 键 = uid 连接级冷却(登录态问题,该号所有模型都不可用);
|
|
15
|
+
* **没有永久禁用**——403 分不清凭证死活与风控误伤,冷却到期自愈
|
|
15
16
|
* - 手动冷却(面板) → 键 = uid(管理员整连接暂停)
|
|
16
17
|
*
|
|
17
18
|
* 为什么键里要带 supplierId:不同供应商**会有同名的模型 id**(如 traework 与
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
* 会把跨供应商的同名模型串到一起。尽管本池实例当前是单供应商(loader 每供应商
|
|
20
21
|
* 一个 AccountPool),键仍显式带 supplierId,语义自明且将来若池合并也安全。
|
|
21
22
|
*
|
|
22
|
-
*
|
|
23
|
+
* 天花板:冷却都是**内存态**,重启归零(与旧插件行为一致)。要跨重启保留
|
|
23
24
|
* 得落盘,届时加 stateFile 即可,接口不用变。
|
|
24
25
|
*/
|
|
25
26
|
import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts';
|
|
@@ -28,7 +29,7 @@ export declare class AccountPool {
|
|
|
28
29
|
private supplierId;
|
|
29
30
|
/** (supplier, model, uid) → 冷却/退避。 */
|
|
30
31
|
private cooldowns;
|
|
31
|
-
/** uid →
|
|
32
|
+
/** uid → 连接级冷却 / 手动暂停。 */
|
|
32
33
|
private byUid;
|
|
33
34
|
private rrCursor;
|
|
34
35
|
/** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
|
|
@@ -42,13 +43,13 @@ export declare class AccountPool {
|
|
|
42
43
|
constructor(supplierId?: string);
|
|
43
44
|
private key;
|
|
44
45
|
private entry;
|
|
45
|
-
/**
|
|
46
|
+
/** 某连接整体状态(连接级冷却/手动暂停)。 */
|
|
46
47
|
private uidState;
|
|
47
|
-
/**
|
|
48
|
+
/** 该连接是否处于连接级冷却/手动暂停中(跨模型生效,到期自愈)。 */
|
|
48
49
|
private uidBlocked;
|
|
49
50
|
/**
|
|
50
51
|
* 给定要试的模型,该连接是否可服务:
|
|
51
|
-
* -
|
|
52
|
+
* - 不处于连接级冷却/手动暂停(跨模型,到期自愈);
|
|
52
53
|
* - 且此 (supplier, model, uid) 冷却单元不在冷却中。
|
|
53
54
|
*/
|
|
54
55
|
private healthy;
|
|
@@ -109,13 +110,13 @@ export declare class AccountPool {
|
|
|
109
110
|
dropBlock(modelId: string): void;
|
|
110
111
|
/** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
|
|
111
112
|
private blockKey;
|
|
112
|
-
/**
|
|
113
|
+
/** 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。 */
|
|
113
114
|
noteFailure(uid: string, modelId: string, state: AccountState, message: string): void;
|
|
114
|
-
/**
|
|
115
|
+
/** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。 */
|
|
115
116
|
noteSuccess(uid: string, modelId: string): void;
|
|
116
117
|
/** 手动暂停整连接(面板/管理用途,跨模型)。ms<=0 表示解除。 */
|
|
117
118
|
cooldown(uid: string, ms: number, reason: string): void;
|
|
118
|
-
/**
|
|
119
|
+
/** 把核心的冷却叠加到插件报的「现在状态」上,产出面板态。
|
|
119
120
|
*
|
|
120
121
|
* 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
|
|
121
122
|
* 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
|
|
@@ -124,7 +125,6 @@ export declare class AccountPool {
|
|
|
124
125
|
*/
|
|
125
126
|
decorate(accounts: SupplierAccountNow[], modelId?: string): Array<SupplierAccountNow & {
|
|
126
127
|
cooling: boolean;
|
|
127
|
-
disabled: boolean;
|
|
128
128
|
err_count: number;
|
|
129
129
|
until?: string;
|
|
130
130
|
reason?: string;
|
package/lib/types/shared.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface SupplierEnv {
|
|
|
30
30
|
* 不报上来,它就会继续留在池里被轮转选中,每次都白撞一次同一个错误。
|
|
31
31
|
*
|
|
32
32
|
* 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
|
|
33
|
-
*
|
|
33
|
+
* 冷却。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
|
|
34
34
|
* 流内 `code=4008`,三个号里两个如此,round-robin 下 2/3 请求直接失败
|
|
35
35
|
* (2026-09-08 实测)——有了这条通道,坏号被冷一次就退出轮转。
|
|
36
36
|
*
|
|
@@ -43,7 +43,7 @@ export type SupplierFactory = (env: SupplierEnv) => SupplierModule;
|
|
|
43
43
|
/**
|
|
44
44
|
* 账户此刻的状态 —— 插件**解读**上游信号后的语义状态。
|
|
45
45
|
*
|
|
46
|
-
*
|
|
46
|
+
* 插件只报「现在怎么了」,不说「该怎么办」:冷却多久、要不要换号
|
|
47
47
|
* 都是核心的策略,见 `AccountState` 各值的处置表(docs/suppliers.md)。
|
|
48
48
|
*
|
|
49
49
|
* 天花板:目前是固定枚举。若将来某供应商需要更细的语义,再加值而不是放宽成
|
|
@@ -57,7 +57,7 @@ export type AccountState = 'ok' | 'rate_limit' | 'quota' | 'session_dead' | 'una
|
|
|
57
57
|
* 把它冷却掉(历史上正是这个坑)。
|
|
58
58
|
*/
|
|
59
59
|
| 'no_such_model';
|
|
60
|
-
/**
|
|
60
|
+
/** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却由核心叠加)。 */
|
|
61
61
|
export interface SupplierAccountNow {
|
|
62
62
|
uid: string;
|
|
63
63
|
nickname?: string;
|
|
@@ -92,7 +92,7 @@ export type ChatOnceResult =
|
|
|
92
92
|
status: number;
|
|
93
93
|
body: string;
|
|
94
94
|
}
|
|
95
|
-
/**
|
|
95
|
+
/** 失败:核心据此做冷却/换号。 */
|
|
96
96
|
| {
|
|
97
97
|
ok: false;
|
|
98
98
|
state: AccountState;
|
|
@@ -114,7 +114,7 @@ export interface SupplierModule {
|
|
|
114
114
|
* 应提供它——否则所有供应商弹窗都显示同一句通用提示(如默认会写 OpenRouter)。
|
|
115
115
|
*/
|
|
116
116
|
readonly apiKeyHint?: string;
|
|
117
|
-
/**
|
|
117
|
+
/** 报账号「现在状态」。冷却/错误累计由核心叠加,插件不算这些。 */
|
|
118
118
|
status(): SupplierStatusNow;
|
|
119
119
|
/** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
|
|
120
120
|
listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-router-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -64,11 +64,15 @@
|
|
|
64
64
|
"prepublishOnly": "npm run build && node scripts/check-publish.mjs --pack"
|
|
65
65
|
},
|
|
66
66
|
"license": "MIT",
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
69
|
+
},
|
|
67
70
|
"peerDependencies": {
|
|
68
71
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
69
72
|
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
|
|
70
73
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
|
|
71
74
|
"@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
|
|
75
|
+
"@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
|
|
72
76
|
"cordis": "^4.0.0-rc.7",
|
|
73
77
|
"react": "^18.2.0",
|
|
74
78
|
"react-dom": "^18.2.0"
|
|
@@ -100,7 +100,7 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
|
|
|
100
100
|
<div className="dshr-supplierGrid">
|
|
101
101
|
{group.map(supplier => {
|
|
102
102
|
const supplierAccounts = accounts.filter(a => a.supplier === supplier.id)
|
|
103
|
-
const healthy = supplierAccounts.filter(a => !a.cooling
|
|
103
|
+
const healthy = supplierAccounts.filter(a => !a.cooling).length
|
|
104
104
|
return (
|
|
105
105
|
<section key={supplier.id} className="dshr-supplierCard" onClick={() => setDetailSupplier(supplier)}>
|
|
106
106
|
<div className="dshr-supplierRow">
|