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.
@@ -62,7 +62,6 @@ function untilLabel(account: RouterAccount): string {
62
62
  }
63
63
 
64
64
  function statusBadge(account: RouterAccount): { text: string; cls: string } {
65
- if (account.disabled) return { text: '已禁用', cls: 'dshr-badge-danger' }
66
65
  if (account.cooling) return { text: '冷却中', cls: 'dshr-badge-warn' }
67
66
  return { text: '健康', cls: 'dshr-badge-ok' }
68
67
  }
@@ -666,7 +665,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
666
665
  <div className="dshr-linkList">
667
666
  {orderedAccounts.map((account, index) => {
668
667
  const badge = statusBadge(account)
669
- const coolingText = account.cooling || account.disabled
668
+ const coolingText = account.cooling
670
669
  ? `${account.reason ?? ''}${untilLabel(account) !== '' ? ` · ${untilLabel(account)}` : ''}`
671
670
  : ''
672
671
  return (
@@ -11,6 +11,7 @@
11
11
  import { mountRouterWorkspace } from './workspace-mount.tsx'
12
12
  import { RouterSettingsSection } from './settings-section.tsx'
13
13
  import { registerSettingsNavIcon } from './settings-nav-icon.ts'
14
+ import { rewriteRouterModelHint } from './model-hint-copy.ts'
14
15
  import './router.css'
15
16
 
16
17
  /**
@@ -65,4 +66,6 @@ export function apply(ctx: Ctx): void {
65
66
  ctx.effect(() => disposeSettings, 'dsh-router: settings section')
66
67
  // 换掉宿主的默认齿轮(契约没有 icon 字段,只能注册后认领自己的行)
67
68
  ctx.effect(() => registerSettingsNavIcon(SECTION_LABEL), 'dsh-router: settings nav icon')
69
+ // 设置-模型 里 Router 卡片的那句死路提示 → 正确说法(同样只能认领后就地改写)
70
+ ctx.effect(() => rewriteRouterModelHint(), 'dsh-router: models provider hint copy')
68
71
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 改写判据测试 —— 锁定「识别宿主提示」与「不自激」两条。
3
+ *
4
+ * 为什么要有:这个改写是按文案指纹认领宿主 DOM(见 model-hint-copy.ts)。
5
+ * 判据写错有两种坏法:漏认(提示原样留着)和自激(改完又被自己判定为需要改,
6
+ * 观察器反复触发)。两条都是纯函数可测的。
7
+ */
8
+ import test from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { routerHintRewrite, ROUTER_MODEL_HINT_COPY } from './model-hint-copy.ts'
11
+
12
+ test('认出宿主提示(中/英两种文案 + 命名空间指纹)', () => {
13
+ const zh = '其余字段在 settings.yaml 中,请直接编辑对应段。 (llm-dsh-router)'
14
+ const en = 'Other fields live in settings.yaml; edit that section directly. (llm-dsh-router)'
15
+ assert.equal(routerHintRewrite(zh), ROUTER_MODEL_HINT_COPY)
16
+ assert.equal(routerHintRewrite(en), ROUTER_MODEL_HINT_COPY)
17
+ })
18
+
19
+ test('别的 provider 的同类提示不动(指纹只属于 Router)', () => {
20
+ const deepseek = '其余字段在 settings.yaml 中,请直接编辑对应段。 (llm-deepseek)'
21
+ assert.equal(routerHintRewrite(deepseek), undefined)
22
+ })
23
+
24
+ test('已是目标文案时早退 —— 观察器不自激', () => {
25
+ assert.equal(routerHintRewrite(ROUTER_MODEL_HINT_COPY), undefined)
26
+ })
27
+
28
+ test('无关段落一律不动', () => {
29
+ assert.equal(routerHintRewrite(''), undefined)
30
+ assert.equal(routerHintRewrite('配置 DeepSeek 官方模型,即可开始使用。'), undefined)
31
+ })
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 设置-模型 → Router 卡片:把宿主那句走不通的提示换成正确说法。
3
+ *
4
+ * 为什么不能直接改源头:Router 的卡片走宿主的 "unknown" 布局(宿主只认得
5
+ * llm-deepseek / llm-pi-ai 两套表单),那句
6
+ * 「其余字段在 settings.yaml 中,请直接编辑对应段。 (llm-dsh-router)」
7
+ * 属于宿主 `settings.models` 的 locale 字典,而字典注册对同一个 (ns, locale)
8
+ * 只允许一个占位者(重复注册直接抛),插件覆写不了,契约也没给可覆写字段。
9
+ * 所以沿用本插件既有的认领手法(同 settings-nav-icon.ts):按文案指纹认出宿主
10
+ * 渲染的那一段,就地改写。
11
+ *
12
+ * 只写 `nodeValue`、不增删节点(节点归 React,摘掉会让它的引用落空)。React 重渲染
13
+ * 覆盖回来时观察器会再改一次;已是目标文案就早退,不会自激。
14
+ *
15
+ * 升级路径:宿主若给该提示长出可覆写字段(或 Router 有了专属布局),删掉本文件。
16
+ */
17
+
18
+ /** 指纹:语言无关(不受中英文切换影响),且只出现在 Router 卡片上。 */
19
+ const FINGERPRINT = '(llm-dsh-router)'
20
+
21
+ /** 正确说法:对齐 README「组合即模型」。 */
22
+ export const ROUTER_MODEL_HINT_COPY = '组合即模型 · 建好的组合会自动出现在 DSH 模型目录中,选中组合名即可直接使用。'
23
+
24
+ /**
25
+ * 这段文本要不要改写。
26
+ * @param text 段落当前文本
27
+ * @returns 需要改写时给出目标文案,否则 undefined
28
+ */
29
+ export function routerHintRewrite(text: string): string | undefined {
30
+ if (!text.includes(FINGERPRINT)) return undefined
31
+ if (text === ROUTER_MODEL_HINT_COPY) return undefined // 已改过,早退防自激
32
+ return ROUTER_MODEL_HINT_COPY
33
+ }
34
+
35
+ /**
36
+ * 持续把 Router 卡片里的宿主提示改写成 {@link ROUTER_MODEL_HINT_COPY}。
37
+ * @returns 清理函数:断开观察
38
+ */
39
+ export function rewriteRouterModelHint(): () => void {
40
+ const sync = (): void => {
41
+ for (const el of document.querySelectorAll<HTMLElement>('p[class*="advancedHint"]')) {
42
+ const next = routerHintRewrite(el.textContent ?? '')
43
+ // 宿主把整句渲染成一个文本节点(单个模板串)——直接整段替换
44
+ const node = el.firstChild
45
+ if (next === undefined || node?.nodeType !== Node.TEXT_NODE) continue
46
+ node.nodeValue = next
47
+ }
48
+ }
49
+
50
+ sync()
51
+ // 设置面板按需挂载、React 重渲染都会覆盖回去,子树变动时重新认领
52
+ const observer = new MutationObserver(sync)
53
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true })
54
+ return () => observer.disconnect()
55
+ }
package/src/index.ts CHANGED
@@ -31,9 +31,10 @@
31
31
  */
32
32
  import type { IncomingMessage, ServerResponse } from 'node:http'
33
33
  import { join } from 'node:path'
34
+ import Schema from '@deepseek-ai/schemastery'
34
35
  import { ROUTER_API_BASE, type RouterPeriod } from './shared.ts'
35
36
  import { Router } from './router/index.ts'
36
- import { RouterAdapter } from './llm/adapter.ts'
37
+ import { RouterAdapter, type RouterAttachmentStore } from './llm/adapter.ts'
37
38
  import { KeysStore } from './keys.ts'
38
39
  import { loadSuppliers, wrapModule, type LoadedSupplier } from './suppliers/loader.ts'
39
40
  import { supplierRoutes } from './suppliers/registry.ts'
@@ -94,6 +95,29 @@ interface Llm {
94
95
  registerAdapter: (providers: readonly string[], adapter: unknown) => () => void
95
96
  }
96
97
 
98
+ /**
99
+ * 设置服务(`ctx.settings`,dsh-settings)的最小切面:本插件只用 `installSection`
100
+ * 注册一个命名空间,让设置-模型页能列出 Router provider 卡片。dsh-settings 是
101
+ * 宿主注入的 peer service,这里不深绑它的类型,只声明用到的这一个方法。
102
+ */
103
+ interface SettingsServiceFace {
104
+ installSection: (
105
+ owner: CordisContext,
106
+ ns: string,
107
+ schema: unknown,
108
+ entry: unknown,
109
+ hooks: { setSource: (current: () => unknown) => void; onChange: () => void },
110
+ ) => void
111
+ }
112
+
113
+ /**
114
+ * cordis Context 的最小切面。`installSection(owner)` 只会对 owner 调
115
+ * `ctx.effect(...)` 并在卸载时读 `fiber.state`,所以这里只声明 `effect`。
116
+ */
117
+ interface CordisContext {
118
+ effect: (fn: () => () => void, label?: string) => unknown
119
+ }
120
+
97
121
  function writeJson(res: ServerResponse, status: number, body: unknown): void {
98
122
  const payload = JSON.stringify(body)
99
123
  res.writeHead(status, {
@@ -574,14 +598,35 @@ export function apply(rawContext: unknown): void {
574
598
  const combos = await router.combos()
575
599
  return combos.map((c) => ({ id: c.name }))
576
600
  }))
601
+ // Router 的配置(组合/密钥/签到)存在 core 自己的 state.json,走插件自己的
602
+ // 「路由系统」面板;设置-模型 这边**仍必须注册 `llm-dsh-router` 命名空间**。
603
+ // 原因:设置-模型 的 provider 列表只渲染 settingsNs 能在设置镜像里解析出来的
604
+ // 行(见 dsh-client-ui-settings-models 的 configurable 过滤)。只调
605
+ // registerConfigurableProviders 而不注册 section,Router 卡片**不会出现**。
606
+ // 空 schema = 卡片是入口/占位(该布局下不可提交),真正的配置在路由系统面板。
607
+ const routerSettingsSchema = Schema.object({})
608
+ ctx.inject(['settings'], (sctx: unknown) => {
609
+ const settings = (sctx as { settings?: SettingsServiceFace }).settings
610
+ if (settings === undefined) {
611
+ log('settings service absent — skip Router settings-section registration')
612
+ return
613
+ }
614
+ settings.installSection(rawContext as CordisContext, LLM_NS, routerSettingsSchema, {}, {
615
+ setSource: () => {},
616
+ onChange: () => {},
617
+ })
618
+ log(`Router settings section registered (${LLM_NS})`)
619
+ })
577
620
  // adapter:模型目录自动带出组合;对话转发到本插件 /v1(组合路由在 /v1 内完成)。
578
621
  // 带上组合的上下文窗口:没有它 dsh 的自动压缩算不出阈值、会静默关闭。
622
+ // 图片序列化需要读附件字节:把 ctx.attachments 传给 adapter(dsh-attachment
623
+ // 是宿主注入的 service,插件不直接 import 它,只依赖 duck-typed 切面)。
579
624
  disposers.push(ctx.llm.registerAdapter(['router'], new RouterAdapter('http://localhost:3080/v1', {
580
625
  comboModels: async () => (await router.combos()).map((c) => {
581
626
  const w = router.comboContextWindow(c)
582
627
  return { id: c.name, ...(w !== undefined ? { contextWindow: w } : {}) }
583
628
  }),
584
- })))
629
+ }, () => ctx.get('attachments') as RouterAttachmentStore | undefined)))
585
630
  log('llm provider (Router) + discovery + adapter registered ok')
586
631
  // 预热模型缓存:`comboContextWindow` 只读缓存、不打上游,缓存空着就
587
632
  // 报不出窗口。这里后台填一次,让第一次 resolveModel 就有值。
@@ -616,11 +661,25 @@ export function apply(rawContext: unknown): void {
616
661
  } catch (err) {
617
662
  registeredProviders = `error: ${(err as Error).message}`
618
663
  }
664
+ // 设置命名空间是否注册成功 —— 卡片不出现的**唯一**嫌疑点:provider 在目录里
665
+ // (上面的 directory)却没注册 section 时,设置-模型 的 configurable 过滤会
666
+ // 把该行整条丢掉,界面上什么都不显示、也没有任何报错。
667
+ let settingsNamespaces: unknown = 'n/a'
668
+ try {
669
+ const settings = ctx.get('settings') as { describe?: (opts?: unknown) => Array<{ ns: string }> } | undefined
670
+ settingsNamespaces = settings?.describe === undefined
671
+ ? 'no settings service'
672
+ : settings.describe({ redactSecrets: true }).map((d) => d.ns)
673
+ } catch (err) {
674
+ settingsNamespaces = `error: ${(err as Error).message}`
675
+ }
619
676
  writeJson(res, 200, {
620
677
  ok: true,
621
678
  llmAvailable: ctx.llm !== undefined,
622
679
  directory,
623
680
  registeredProviders,
681
+ settingsNamespaces,
682
+ routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes('llm-dsh-router'),
624
683
  })
625
684
  })
626
685
 
@@ -519,6 +519,9 @@ test('模型目录:resolveModel 对每个组合声明推理等级(off/low/hi
519
519
  assert.equal(a.provider, 'router')
520
520
  assert.equal(a.id, 'c-a')
521
521
  assert.equal(a.name, '组合A')
522
+ // 图文模态:必须声明含 image,否则 runtime/subagent 的模态门禁会抛
523
+ // `MODEL_DOES_NOT_SUPPORT_IMAGES`(界面「当前模型不支持图片」)。
524
+ assert.deepEqual(a.inputModalities, ['text', 'image'], 'adapter 必须声明 image 模态,否则图片被门禁拦截')
522
525
  assert.ok(a.reasoning, '组合应声明推理等级,DSH 才允许显式 effort')
523
526
  assert.deepEqual(a.reasoning!.efforts.map((e) => e.id), ['off', 'low', 'high', 'max'])
524
527
  assert.equal(a.reasoning!.defaultEffort, 'high', '默认 High:调用方不指定时物化为 high')
@@ -543,3 +546,82 @@ test('resolveModel 透传组合的 contextWindow(自动压缩才能算阈值
543
546
  const b = await adapter.resolveModel('router', 'c-b')
544
547
  assert.equal(b.context, undefined, '没拿到就不声明 context,绝不填一个假窗口')
545
548
  })
549
+
550
+ /* ---------------- 图片序列化:图片必须真正到上游(不静默丢图) ---------------- */
551
+
552
+ /** 假附件 store:返回指定 mediaType + bytes 的请求版本。 */
553
+ function fakeAttachments(mediaType = 'image/png', data = new TextEncoder().encode('<png-bytes>')): {
554
+ readImageRequest: () => Promise<{ mediaType: string; data: Uint8Array }>
555
+ } {
556
+ return {
557
+ readImageRequest: async () => ({ mediaType, data }),
558
+ }
559
+ }
560
+
561
+ /** 用给定 attachments 跑一次 stream(),返回上游收到的 body。 */
562
+ async function captureBodyWith(options: Record<string, unknown>, attachments?: unknown): Promise<Record<string, unknown>> {
563
+ let captured: Record<string, unknown> = {}
564
+ globalThis.fetch = (async (_url: string, init?: { body?: string }) => {
565
+ captured = JSON.parse(init?.body ?? '{}') as Record<string, unknown>
566
+ return new Response('data: [DONE]\n\n', { status: 200 })
567
+ }) as typeof fetch
568
+ const adapter = new RouterAdapter(
569
+ 'http://x',
570
+ { comboModels: async () => [] },
571
+ () => attachments as never,
572
+ )
573
+ for await (const _c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(3000), ...options } as never)) {
574
+ void _c
575
+ }
576
+ return captured
577
+ }
578
+
579
+ test('wire:图片块被序列化成 OpenAI image_url base64 part(不再静默丢图)', async () => {
580
+ const ref = {
581
+ attachmentId: 'sha256:abcdef1234567890',
582
+ mediaType: 'image/png',
583
+ bytes: 11,
584
+ width: 4,
585
+ height: 2,
586
+ }
587
+ const body = await captureBodyWith({
588
+ messages: [{ role: 'user', content: [
589
+ { type: 'text', text: '看这张图' },
590
+ { type: 'image', attachment: ref },
591
+ ] }],
592
+ }, fakeAttachments())
593
+ const user = (body.messages as Array<{ role: string; content: unknown }>).find((m) => m.role === 'user')
594
+ const parts = (user?.content ?? []) as Array<{ type: string; text?: string; image_url?: { url: string } }>
595
+ assert.equal(parts.length, 2, 'text + image 两个 part')
596
+ assert.equal(parts[0]?.type, 'text')
597
+ assert.equal(parts[0]?.text, '看这张图')
598
+ assert.equal(parts[1]?.type, 'image_url', '图片必须是 image_url part,不是占位文本')
599
+ const b64 = btoa(String.fromCharCode(...new TextEncoder().encode('<png-bytes>')))
600
+ assert.equal(parts[1]?.image_url?.url, `data:image/png;base64,${b64}`, 'data URI 必须是 base64 编码的真实字节')
601
+ })
602
+
603
+ test('wire:纯文本 user 消息仍是紧凑字符串(不回归 wire 形式)', async () => {
604
+ const body = await captureBodyWith({
605
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
606
+ }, fakeAttachments())
607
+ const user = (body.messages as Array<{ role: string; content: unknown }>).find((m) => m.role === 'user')
608
+ assert.equal(user?.content, 'hi', '无图时必须保持字符串 content')
609
+ })
610
+
611
+ test('wire:无 attachments 时图片回退占位文本,绝不静默丢图', async () => {
612
+ const ref = {
613
+ attachmentId: 'sha256:abcdef1234567890',
614
+ mediaType: 'image/png',
615
+ bytes: 11,
616
+ width: 4,
617
+ height: 2,
618
+ }
619
+ const body = await captureBodyWith({
620
+ messages: [{ role: 'user', content: [{ type: 'image', attachment: ref }] }],
621
+ }, undefined)
622
+ const user = (body.messages as Array<{ role: string; content: unknown }>).find((m) => m.role === 'user')
623
+ const parts = (user?.content ?? []) as Array<{ type: string; text?: string }>
624
+ assert.equal(parts.length, 1)
625
+ assert.equal(parts[0]?.type, 'text')
626
+ assert.ok((parts[0]?.text ?? '').includes('image omitted'), '读不到附件时必须给占位文本,图片不能无声消失')
627
+ })
@@ -4,7 +4,11 @@
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 {
10
14
  EMPTY_RESPONSE_CODE,
@@ -22,6 +26,29 @@ import {
22
26
  } from '@deepseek-ai/dsh-llm'
23
27
  import { mergeUsage, normalizeUsage, toTokenUsage, type UsageTokens } from '../router/usage-tokens.ts'
24
28
 
29
+ /**
30
+ * dsh-attachment 的最小本地切面:adapter 只依赖「读一张图片的请求版本」
31
+ * 这一行为,不引入对 dsh-attachment 包编译期/运行期的依赖(它是宿主注入的
32
+ * devDependency)。本地声明与原包 `AttachmentStore.readImageRequest` 形状一致,
33
+ * 宿主注入真实 store 时天然满足。
34
+ */
35
+ export interface RouterAttachmentStore {
36
+ readImageRequest(
37
+ ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
38
+ policy: { maxPixels: number; maxBytes: number },
39
+ signal?: AbortSignal,
40
+ ): Promise<{ mediaType: string; data: Uint8Array }>
41
+ }
42
+
43
+ /** 一张 image 块的 attachment ref 的最小形状(value 类型,够用即可)。 */
44
+ interface RouterImgLike {
45
+ attachmentId: string
46
+ mediaType: string
47
+ bytes: number
48
+ width: number
49
+ height: number
50
+ }
51
+
25
52
  /** 模型目录来源:组合。 */
26
53
  export interface RouterAdapterSource {
27
54
  /**
@@ -34,7 +61,7 @@ export interface RouterAdapterSource {
34
61
  }
35
62
 
36
63
  /**
37
- * 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。
64
+ * 把 DSH 消息序列化成 openai 兼容 wire 消息(文本 + 图片 + tool)。
38
65
  *
39
66
  * `system` 必须由调用方从 `options.system` 传进来:one-shot 调用方把系统提示词
40
67
  * 放在 **options.system 这个独立槽位**,不放进 messages。不读这个槽位,
@@ -46,8 +73,12 @@ export interface RouterAdapterSource {
46
73
  * 可出现在任意位置以支持提示词热更新)。两条路径都必须支持 —— 与官方
47
74
  * dsh-llm-deepseek 一致(serializeRequest 前置 options.system,再按位置序列化
48
75
  * messages 里的 system 消息)。
76
+ *
77
+ * 图片:`image` 块被序列化成 OpenAI `content` 数组里的 `image_url` part(data URI
78
+ * base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
79
+ * **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
49
80
  */
50
- function wireMessages(options: GenerateOptions, system?: string): Array<Record<string, unknown>> {
81
+ async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined): Promise<Array<Record<string, unknown>>> {
51
82
  const out: Array<Record<string, unknown>> = []
52
83
  if (typeof system === 'string' && system !== '') {
53
84
  out.push({ role: 'system', content: system })
@@ -76,9 +107,16 @@ function wireMessages(options: GenerateOptions, system?: string): Array<Record<s
76
107
  continue
77
108
  }
78
109
  // user / tool-result
79
- const text = flattenText(message.content)
80
110
  const toolResults = message.content.filter((b) => b.type === 'tool-result')
81
- if (text.length > 0 || toolResults.length === 0) out.push({ role: 'user', content: text })
111
+ // 有图 → content 数组;纯文本 → 保持紧凑字符串 wire 形式不回归。
112
+ if (hasImageBlock(message.content)) {
113
+ const parts = await contentParts(message.content, attachments, options.signal)
114
+ if (parts.length > 0) out.push({ role: 'user', content: parts })
115
+ else if (toolResults.length === 0) out.push({ role: 'user', content: '' })
116
+ } else {
117
+ const text = flattenText(message.content)
118
+ if (text.length > 0 || toolResults.length === 0) out.push({ role: 'user', content: text })
119
+ }
82
120
  for (const result of toolResults) {
83
121
  // 空结果就发空串,不要替换成 '(no output)' 之类的字面量 —— 模型会
84
122
  // 以为工具真的打印了那句话(9router 也是补 content: "")。
@@ -88,9 +126,73 @@ function wireMessages(options: GenerateOptions, system?: string): Array<Record<s
88
126
  return out
89
127
  }
90
128
 
91
- /** 组装 wire 请求体。 */
92
- function wireRequest(options: GenerateOptions): Record<string, unknown> {
93
- const messages = wireMessages(options, options.system)
129
+ /**
130
+ * 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
131
+ * 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
132
+ * attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
133
+ */
134
+ async function contentParts(
135
+ blocks: ReadonlyArray<{ type: string }>,
136
+ attachments: RouterAttachmentStore | undefined,
137
+ signal: AbortSignal | undefined,
138
+ ): Promise<Array<Record<string, unknown>>> {
139
+ const parts: Array<Record<string, unknown>> = []
140
+ for (const block of blocks) {
141
+ if (block.type === 'text') {
142
+ const text = (block as { text?: string }).text ?? ''
143
+ if (text.length > 0) parts.push({ type: 'text', text })
144
+ continue
145
+ }
146
+ if (block.type === 'image') {
147
+ const ref = (block as unknown as { attachment: RouterImgLike }).attachment
148
+ parts.push(...(await imageParts(ref, attachments, signal)))
149
+ continue
150
+ }
151
+ if (block.type === 'tool-result') {
152
+ parts.push(...(await contentParts((block as unknown as { content: ReadonlyArray<{ type: string }> }).content, attachments, signal)))
153
+ }
154
+ // 其它块(reasoning/tool-call)不属于 user content,忽略
155
+ }
156
+ return parts
157
+ }
158
+
159
+ /** 一张图片 → 一个 OpenAI image_url part(或读不到附件时的占位文本)。 */
160
+ async function imageParts(
161
+ ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
162
+ attachments: RouterAttachmentStore | undefined,
163
+ signal: AbortSignal | undefined,
164
+ ): Promise<Array<Record<string, unknown>>> {
165
+ if (attachments === undefined) return [{ type: 'text', text: imagePlaceholder(ref.attachmentId) }]
166
+ try {
167
+ const img = await attachments.readImageRequest(
168
+ { attachmentId: ref.attachmentId, mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height },
169
+ { maxPixels: 64e4, maxBytes: 1024 * 1024 },
170
+ signal,
171
+ )
172
+ return [{ type: 'image_url', image_url: { url: `data:${img.mediaType};base64,${bytesToBase64(img.data)}` } }]
173
+ } catch {
174
+ return [{ type: 'text', text: imagePlaceholder(ref.attachmentId) }]
175
+ }
176
+ }
177
+
178
+ /** Uint8Array → base64(browser + node 双环境安全,不依赖 Buffer 全局)。 */
179
+ function bytesToBase64(bytes: Uint8Array): string {
180
+ let bin = ''
181
+ const chunk = 0x8000
182
+ for (let i = 0; i < bytes.length; i += chunk) {
183
+ bin += String.fromCharCode(...bytes.subarray(i, i + chunk))
184
+ }
185
+ return btoa(bin)
186
+ }
187
+
188
+ /** 读不到图片字节时的稳定占位文本。 */
189
+ function imagePlaceholder(attachmentId: string): string {
190
+ return `[image omitted because the attached bytes could not be read; attachment ${attachmentId}]`
191
+ }
192
+
193
+ /** 组装 wire 请求体。图片序列化需要读 attachments,故为异步。 */
194
+ async function wireRequest(options: GenerateOptions, attachments: RouterAttachmentStore | undefined): Promise<Record<string, unknown>> {
195
+ const messages = await wireMessages(options, options.system, attachments)
94
196
  const body: Record<string, unknown> = {
95
197
  model: options.model,
96
198
  messages,
@@ -136,6 +238,15 @@ function flattenText(blocks: readonly { type: string; text?: string }[]): string
136
238
  return blocks.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')
137
239
  }
138
240
 
241
+ /** 内容块里(含递归进 tool-result)是否存在 image 块。决定走数组 content 还是字符串。 */
242
+ function hasImageBlock(blocks: readonly { type: string }[]): boolean {
243
+ for (const b of blocks) {
244
+ if (b.type === 'image') return true
245
+ if (b.type === 'tool-result' && hasImageBlock((b as unknown as { content: ReadonlyArray<{ type: string }> }).content)) return true
246
+ }
247
+ return false
248
+ }
249
+
139
250
  interface OpenBlock {
140
251
  index: number
141
252
  kind: 'text' | 'reasoning' | 'tool-call'
@@ -379,11 +490,13 @@ const ROUTER_REASONING_EFFORTS: readonly LlmReasoningEffortInfo[] = [
379
490
  export class RouterAdapter extends LlmAdapter {
380
491
  private readonly baseURL: string
381
492
  private readonly source: RouterAdapterSource
493
+ private readonly resolveAttachments: () => RouterAttachmentStore | undefined
382
494
 
383
- constructor(baseURL: string, source: RouterAdapterSource) {
495
+ constructor(baseURL: string, source: RouterAdapterSource, resolveAttachments?: () => RouterAttachmentStore | undefined) {
384
496
  super()
385
497
  this.baseURL = baseURL
386
498
  this.source = source
499
+ this.resolveAttachments = resolveAttachments ?? (() => undefined)
387
500
  }
388
501
 
389
502
  providerInfo(provider: string): { id: string; name: string } {
@@ -403,14 +516,14 @@ export class RouterAdapter extends LlmAdapter {
403
516
  }
404
517
 
405
518
  /**
406
- * 只收文本。`resolveModel` 声明 `inputModalities: ['text']` 是**必须**的:
407
- * 0.1.5 的 runtime 只在 `inputModalities` 存在且不含 `image` 时才把 image 块
408
- * 投影成占位文本(见 dsh-llm adapterStream 的 projectImagesForTextModel)。
409
- * 不声明 = "unknown" = 不投影,而 wireMessages 只读 text 块 → 图片**静默
410
- * 丢失**,模型看到一条没有图片的用户消息却毫无提示。声明后由 runtime 统一
411
- * 投影,我们仍只处理文本。
519
+ * 声明图文:`inputModalities` 必须同时含 `text` 与 `image`。
520
+ * - 含 `image`:runtime/subagent 的模态门禁放行(不再抛
521
+ * `MODEL_DOES_NOT_SUPPORT_IMAGES` = 界面「当前模型不支持图片」),
522
+ * `projectImagesForTextModel` 也不会把图片块投影成占位文本。
523
+ * - 组合背后是异构供应商,**统一声明图片能力**;真正能否看图取决于命中
524
+ * 的某个上游模型(供应商各模型 capability 不同,网关端不能一刀切拦截)。
412
525
  */
413
- private static readonly INPUT_MODALITIES: readonly ModelModality[] = ['text']
526
+ private static readonly INPUT_MODALITIES: readonly ModelModality[] = ['text', 'image']
414
527
 
415
528
  async resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo> {
416
529
  const combos = await this.source.comboModels()
@@ -430,7 +543,7 @@ export class RouterAdapter extends LlmAdapter {
430
543
  }
431
544
 
432
545
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
433
- const body = wireRequest(options)
546
+ const body = await wireRequest(options, this.resolveAttachments())
434
547
  const controller = new AbortController()
435
548
  const onAbort = (): void => controller.abort()
436
549
  options.signal?.addEventListener('abort', onAbort, { once: true })
@@ -1,10 +1,11 @@
1
1
  /**
2
- * 账号池(核心策略)测试 —— 选号/冷却/禁用/遍历回退。
2
+ * 账号池(核心策略)测试 —— 选号/冷却/遍历回退。
3
3
  *
4
4
  * 关键语义(v2):
5
5
  * - 冷却单元 = (supplierId, modelId, uid)。某连接在模型 A 上失败,只冷
6
6
  * (A, 该连接),该连接调模型 B 不受影响。
7
- * - 禁用(session_dead) = 连接级(uid):该号所有模型都不可用。
7
+ * - session_dead = 连接级(uid)可恢复冷却:该号所有模型都不可用,到期自愈。
8
+ * **没有永久禁用**——403 分不清凭证死活与风控误伤,误伤号不该被钉死。
8
9
  * - no_such_model 不惩罚账号(模型不属于本供应商不是号的错)。
9
10
  * 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
10
11
  */
@@ -327,16 +328,18 @@ test('【颗粒度】同一连接对不同模型的退避独立累加/清零', (
327
328
  assert.equal(p.decorate(list, 'm1')[0]?.err_count, 0)
328
329
  })
329
330
 
330
- // ============ 连接级禁用(跨模型) ============
331
+ // ============ 连接级冷却(跨模型, session_dead) ============
331
332
 
332
- test('【颗粒度】session_dead 是连接级禁用:跨该号所有模型', () => {
333
+ test('【颗粒度】session_dead 是连接级冷却:跨该号所有模型,到期自愈', () => {
333
334
  const p = pool()
334
335
  const list = accs(['a'])
335
336
  p.noteFailure('a', 'm1', 'session_dead', '凭证失效')
336
337
  // 该号在 m1、m2 上都不可用(登录态问题,不是模型问题)
337
338
  assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined)
338
339
  assert.equal(p.pick(list, [], 'fallback', 'm2'), undefined)
339
- assert.equal(p.decorate(list)[0]?.disabled, true)
340
+ const d = p.decorate(list)[0]
341
+ assert.equal(d?.cooling, true)
342
+ assert.ok(d?.until !== undefined, 'session_dead 冷却必须有 until(可恢复)')
340
343
  })
341
344
 
342
345
  test('处置:未知错误第一次就冷却(坏号不留在池里等下一次撞)', () => {
@@ -347,7 +350,7 @@ test('处置:未知错误第一次就冷却(坏号不留在池里等下一
347
350
  assert.equal(p.pick(list.filter((x) => x.uid === 'a'), [], 'fallback', 'm1'), undefined)
348
351
  })
349
352
 
350
- test('decorate:把冷却/禁用叠加到插件报的「现在状态」上(聚合展示)', () => {
353
+ test('decorate:把冷却叠加到插件报的「现在状态」上(聚合展示)', () => {
351
354
  const p = pool()
352
355
  const list: SupplierAccountNow[] = [
353
356
  { uid: 'ok', credits: 5, state: 'ok' },
@@ -359,10 +362,9 @@ test('decorate:把冷却/禁用叠加到插件报的「现在状态」上(
359
362
  const out = p.decorate(list)
360
363
  const by = new Map(out.map((a) => [a.uid, a]))
361
364
  assert.equal(by.get('ok')?.cooling, false)
362
- assert.equal(by.get('ok')?.disabled, false)
363
365
  assert.equal(by.get('cool')?.cooling, true)
364
366
  assert.equal(by.get('cool')?.until !== undefined, true)
365
- assert.equal(by.get('dead')?.disabled, true)
367
+ assert.equal(by.get('dead')?.cooling, true, 'session_dead 连接级冷却也展示为冷却中')
366
368
  // 插件报的字段要原样透出(不能被 decorate 吃掉)
367
369
  assert.equal(by.get('ok')?.credits, 5)
368
370
  })
@@ -375,7 +377,6 @@ test('no_such_model 不惩罚账号:不冷却、不计数', () => {
375
377
  assert.equal(p.pick(list, [], 'fallback', 'm1'), 'a')
376
378
  const out = p.decorate(list, 'm1')[0]
377
379
  assert.equal(out?.cooling, false)
378
- assert.equal(out?.disabled, false)
379
380
  assert.equal(out?.err_count, 0)
380
381
  })
381
382
 
@@ -389,7 +390,7 @@ test('状态表:各 AccountState 的处置符合预期', () => {
389
390
  const by = new Map(p.decorate(list).map((a) => [a.uid, a]))
390
391
  assert.equal(by.get('rate')?.cooling, true)
391
392
  assert.equal(by.get('quota')?.cooling, true)
392
- assert.equal(by.get('dead')?.disabled, true)
393
+ assert.equal(by.get('dead')?.cooling, true, 'session_dead = 连接级冷却')
393
394
  assert.equal(by.get('unavail')?.cooling, true, '瞬时错误第一次就冷却')
394
395
  assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined, '四个号都不可服务')
395
396
  })