dsh-router-core 0.3.12 → 0.3.14

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.
@@ -31,10 +31,14 @@ export declare class AccountPool {
31
31
  /** uid → 连接级禁用 / 手动暂停。 */
32
32
  private byUid;
33
33
  private rrCursor;
34
- /** (supplier, model) → 块轮询驻留状态。 */
34
+ /** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
35
35
  private blocks;
36
+ /** (supplier, model, uid) → 该号的缓存质量样本(降权判据,跨驻留累计)。 */
37
+ private samples;
36
38
  /** (supplier, model, uid) → 缓存无效降权。 */
37
39
  private demoted;
40
+ /** (supplier, model) → Map<前缀指纹, uid>:前缀亲和的选号记忆(LRU 封顶)。 */
41
+ private affinity;
38
42
  constructor(supplierId?: string);
39
43
  private key;
40
44
  private entry;
@@ -51,26 +55,34 @@ export declare class AccountPool {
51
55
  /**
52
56
  * 按策略为「某个模型」选一个健康账号。
53
57
  *
54
- * round-robin 走**块轮询**:一次轮转内连续服务若干请求,而不是每请求换号。
55
- * 设计见 docs/pool-sticky-block.md。要点:
56
- * - 留守 = `计数 < N OR 命中率 < M`(OR,用户拍板):
57
- * 命中率达标(缓存热了)→ 换号保均衡;计数到 N → 换号止损。
58
- * 单号最多连续服务 N 次,不会因命中率低而死守到 N_max(打架场景关键)。
59
- * - 切号因子的思路:命中率是**留守**条件不是切号条件。反接会正反馈死锁
60
- * (切→cold miss→命中率 0%→触发切号→…),见文档 §3。
61
- * - `N_max` 绝对兜底:命中率永远不达标的号不能无限驻留(防死锁 + 防均衡归零)。
58
+ * round-robin = **前缀亲和定号 + 块统计只用来判定缓存无效**:
59
+ *
60
+ * 1. 算请求的前缀指纹(messages 前几条),查绑定表 → 命中且该号健康 → 用它。
61
+ * 这是第一优先:同一个会话的连续请求**永远落回同一个号**,前缀在该号的
62
+ * 缓存里只写一份。块轮询治不了跨会话驱逐(多会话大前缀在同一号互踩,
63
+ * 实测 3 会话 3.8% vs 单会话 82.5%),亲和才是根治。
64
+ * 2. 新指纹(或绑定的号不健康)→ 按游标挑一个号并**记下绑定**,之后就固定。
65
+ * 新会话用游标分发,所以不同会话天然散到不同号上(均衡)。
66
+ * 3. 块统计不再驱动轮转(亲和已经保证驻留,再轮转只会把会话踢出已热前缀),
67
+ * 只保留 `BLOCK_MAX` 次不达标 → 降权该号(缓存无效,可恢复)。
68
+ *
69
+ * 亲和失效(拿不到 messages / 指纹算不出)时**回退到块轮询**,行为与之前一致。
70
+ * 设计见 docs/pool-sticky-block.md §8。
62
71
  *
63
72
  * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
64
73
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
65
- * @param strategy fallback 取第一个健康 / round-robin 块轮转健康号
74
+ * @param strategy fallback 取第一个健康 / round-robin 前缀亲和
66
75
  * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
76
+ * @param messages 请求体的 messages(可选;给了才能算前缀指纹)
67
77
  * @returns 选中的 uid;无健康账号返回 undefined
68
78
  */
69
- pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string): string | undefined;
79
+ pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string, messages?: unknown): string | undefined;
80
+ /** 记下「前缀指纹 → 号」的绑定(LRU:超上限丢最久未用的)。 */
81
+ private bind;
70
82
  /** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
71
83
  private isDemoted;
72
84
  /**
73
- * 块是否还应驻留在当前号上。
85
+ * 驻留是否还应留在当前号上(**只用于无亲和的回退路径**)。
74
86
  *
75
87
  * OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
76
88
  * 单一账号内部:一个号先被轮到(冷启动/热),然后
@@ -80,22 +92,20 @@ export declare class AccountPool {
80
92
  *
81
93
  * 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
82
94
  * 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
83
- *
84
- * 每个号各自守着「这个会话在自己账号上的那段前缀」,轮流热;
85
- * 命中率 = 块的命中占比。单会话下每个号块都从冷到热各付一次,
86
- * 收益来自块内连续驻留(缺了它就跟每请求轮询一样)。
87
95
  */
88
96
  private blockKeeps;
89
97
  /**
90
- * 记录一次成功请求的缓存命中情况(块轮询的反馈信号)。
98
+ * 记录一次成功请求的缓存命中情况(降权判据的反馈信号)。
91
99
  *
92
- * 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错切。
100
+ * 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错降权。
101
+ * 统计按 (model, uid) 记,**不管这个号是不是「当前驻留」**——亲和开启后
102
+ * 多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
93
103
  * @param uid 实际服务的号
94
104
  * @param modelId 模型(块粒度)
95
105
  * @param cachedTokens 上游报的命中 token 数;0 = 全量重算
96
106
  */
97
107
  noteCache(uid: string, modelId: string, cachedTokens: number): void;
98
- /** 故障切走时作废当前块:换号后前缀归属变了,块计数不能沿用。 */
108
+ /** 故障切走时作废当前驻留:换号后前缀归属变了,驻留计数不能沿用。 */
99
109
  dropBlock(modelId: string): void;
100
110
  /** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
101
111
  private blockKey;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 算请求的前缀指纹。
3
+ * @param messages OpenAI 请求体的 `messages` 字段(未校验,来自外部输入)
4
+ * @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
5
+ */
6
+ export declare function prefixFingerprint(messages: unknown): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
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
- "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
70
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
71
- "@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
72
+ "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
73
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
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"
@@ -85,7 +89,7 @@
85
89
  }
86
90
  },
87
91
  "devDependencies": {
88
- "@deepseek-ai/dsh-llm": "0.1.2-rc.1",
92
+ "@deepseek-ai/dsh-llm": "0.1.5-rc.1",
89
93
  "@types/node": "^26.4.1",
90
94
  "@types/react": "~19.2.18",
91
95
  "@types/react-dom": "~19.2.7",
@@ -908,7 +908,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
908
908
  </>
909
909
  <>
910
910
  <label className="dshr-fieldLabel">连接池策略</label>
911
- <p className="dshr-muted">回退 = 按连接池顺序取第一个健康链接;轮询 = 轮流使用健康链接。</p>
911
+ <p className="dshr-muted">回退 = 按连接池顺序取第一个健康链接;轮询 = 按会话前缀粘住同一链接(前缀亲和,多会话不打架),拿不到会话信息时退回块轮转。</p>
912
912
  <select
913
913
  className="dshr-select dshr-input"
914
914
  value={poolStrategy}
@@ -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
+ })