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/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: 0,
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
- if (!e) return {
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).blocked) return false;
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.disable) {
353
+ if (rule.cooldown === 0) return;
354
+ if (state === "session_dead") {
377
355
  const e = this.uidState(uid);
378
- e.disabled = true;
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 disabled = u?.disabled ?? false;
420
- const manualCooling = u !== void 0 && !u.disabled && u.until > now;
421
- let cooling = manualCooling;
422
- let maxUntil = manualCooling ? u.until : 0;
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
- * 纯文本流:组合为文本模型,本 adapter 不处理图片附件。
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 消息(纯文本 + tool)。
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 (text.length > 0 || toolResults.length === 0) out.push({
2353
- role: "user",
2354
- content: text
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
- /** 组装 wire 请求体。 */
2365
- function wireRequest(options) {
2366
- const messages = wireMessages(options, options.system);
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
- constructor(baseURL, source) {
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
- * 只收文本。`resolveModel` 声明 `inputModalities: ['text']` 是**必须**的:
2674
- * 0.1.5 的 runtime 只在 `inputModalities` 存在且不含 `image` 时才把 image 块
2675
- * 投影成占位文本(见 dsh-llm adapterStream 的 projectImagesForTextModel)。
2676
- * 不声明 = "unknown" = 不投影,而 wireMessages 只读 text 块 → 图片**静默
2677
- * 丢失**,模型看到一条没有图片的用户消息却毫无提示。声明后由 runtime 统一
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}:${a.disabled}`).join("|");
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
- * 纯文本流:组合为文本模型,本 adapter 不处理图片附件。
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
- constructor(baseURL: string, source: RouterAdapterSource);
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
- * 只收文本。`resolveModel` 声明 `inputModalities: ['text']` 是**必须**的:
43
- * 0.1.5 的 runtime 只在 `inputModalities` 存在且不含 `image` 时才把 image 块
44
- * 投影成占位文本(见 dsh-llm adapterStream 的 projectImagesForTextModel)。
45
- * 不声明 = "unknown" = 不投影,而 wireMessages 只读 text 块 → 图片**静默
46
- * 丢失**,模型看到一条没有图片的用户消息却毫无提示。声明后由 runtime 统一
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
- * - 禁用(session_dead) → 键 = uid(登录态问题,该号所有模型都不可用)
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;
@@ -13,7 +13,6 @@ export interface SupplierAccount {
13
13
  cooling: boolean;
14
14
  until?: string;
15
15
  reason?: string;
16
- disabled: boolean;
17
16
  err_count?: number;
18
17
  }
19
18
  /** 供应商面板状态。 */
@@ -22,7 +22,6 @@ export interface RouterAccount {
22
22
  cooling: boolean;
23
23
  until?: string;
24
24
  reason?: string;
25
- disabled: boolean;
26
25
  err_count?: number;
27
26
  /** 所属供应商 id。 */
28
27
  supplier?: string;
@@ -30,7 +30,7 @@ export interface SupplierEnv {
30
30
  * 不报上来,它就会继续留在池里被轮转选中,每次都白撞一次同一个错误。
31
31
  *
32
32
  * 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
33
- * 冷却/禁用。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
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.13",
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 && !a.disabled).length
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">