diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml
index 6a379a3532..cca3335220 100644
--- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
-2026-08-20-unified-image-request-pipeline.md: ada15d540539977c631e359ffdc7baa4fa84c78e
-2026-08-20-unified-image-request-pipeline.zh.md: 85c9a1f837d82cba2bc62b30402433f50c873cbe
+2026-08-20-unified-image-request-pipeline.md: 7af0bea0acb117844b258a0333e97e97facf493c
+2026-08-20-unified-image-request-pipeline.zh.md: ecdb71548190ba22573a5e05c3b3fde76c28d511
diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
index ada15d5405..7af0bea0ac 100644
--- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
+++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
@@ -22,15 +22,15 @@ Batch admission prepares and verifies every normalized attachment once before pu
### Deterministic request versions
-`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default; low detail uses 512 by 512 total pixels. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams.
+`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default. Its catalog uses one `imagePixelBudget` field: a positive integer selects an exact total-pixel budget, `low` selects 512 by 512 total pixels, and omission selects the route default. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams.
The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared.
-Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references.
+Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a deterministic per-image placeholder that retains its identity and current provider access facts, including nested tool-result images, while append-only session history keeps the original references.
### Stable handles
-Every retained request image is preceded by its complete attachment id and actual request dimensions. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection.
+Every retained request image is preceded by its display name or complete attachment id, actual request dimensions, and current provider access facts. The local provider supplies the absolute read-only normalized-object path, normalized dimensions, and media type. The descriptor states that normalization or request projection may have resized or re-encoded the upload, so the model cannot infer original upload properties from either representation. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. The path is derived from the logged reference and current provider root at request time; it does not enter the durable reference or session log.
### DeepSeek Files lifecycle
diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md
index 85c9a1f837..ecdb715481 100644
--- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md
@@ -22,15 +22,15 @@ Status: implemented
### 确定性请求版本
-`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB;low detail 使用总像素 512×512。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。
+`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB。其 catalog 只使用一个 `imagePixelBudget` 字段:正整数选择确切总像素预算,`low` 选择总像素 512×512,省略时使用路由默认值。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。
`variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。
-请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。
+请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成确定性的逐图占位文本,保留自己的身份和当前提供方访问事实,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。
### 稳定句柄
-每张保留请求图片前都有完整附件 ID 和实际请求尺寸。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。
+每张保留请求图片前都有显示名称或完整附件 ID、实际请求尺寸,以及当前提供方访问事实。本地提供方会给出规范化对象的绝对只读路径、规范化尺寸和媒体类型。描述会说明规范化或请求投影可能缩小或重新编码上传图片,因此模型不能从任一版本推断上传图片原本的属性。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。路径在请求时根据已记录引用和当前提供方根目录派生,不进入持久引用或会话日志。
### DeepSeek Files 生命周期
diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml
new file mode 100644
index 0000000000..16831b0ca3
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md
+2026-08-21-model-readable-image-paths.md: 4c2610ad48e47d2642e32ae53048d0897abd64e9
+2026-08-21-model-readable-image-paths.zh.md: 24335f1da99c382291988d5862bdd52e8d5d961c
diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md
new file mode 100644
index 0000000000..4c2610ad48
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md
@@ -0,0 +1,37 @@
+# Agent Note: Provider-resolved image paths in model requests
+
+Status: implemented
+
+English | [中文](2026-08-21-model-readable-image-paths.zh.md)
+
+## Problem
+
+An uploaded image becomes an opaque durable `ImageAttachmentRef`. Image-capable models receive its request preview, but the prior descriptor gives them no filesystem location for later image operations. Agents consequently search the workspace and temporary directories or ask the user where the file is. The request preview and stored normalized attachment may both differ from the upload, so their dimensions, format, and byte size cannot establish the upload's original properties.
+
+## Decision
+
+`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageAccess(ref)` resolves optional access facts from the current provider. The local provider derives an absolute immutable-object path from the resolved `DSH_HOME`, attachment storage version, and validated digest. A request version carries these facts transiently for serialization. A provider without model-readable local storage returns no access facts.
+
+The shared LLM image descriptor names the display name or full attachment id, the exact request-preview dimensions, and the provider-resolved normalized path when available. Local access text includes normalized dimensions and media type, identifies the object as read-only, directs the model to copy it to a writable path with the matching extension before editing, and states that normalization or request projection may have resized or re-encoded the upload. DeepSeek Files and pi-ai inline requests use the same descriptor.
+
+Request-size offload requires a per-image placeholder function; the previous shared placeholder constant and its byte-bound wrapper had no remaining production caller and are removed. DeepSeek and pi-ai replace each omitted occurrence with its own attachment identity and current access facts without reading or transforming the omitted object. Offload selection, byte accounting, and quantized prefix behavior remain unchanged.
+
+Descriptor identity comes from each occurrence's own durable reference, not from the prepared request version: versions are deduplicated per attachment id, so two uploads of the same content under different names share one version while each occurrence keeps its own display name. Access resolution validates the logged attachment id; a malformed reference in durable history fails the request at assembly, the earliest point that resolves it.
+
+Absolute paths stay out of session events. Model-visible path text is reconstructed from the logged attachment reference and the provider mounted for the current process. Restoring the same session with a different `DSH_HOME` therefore produces the path that is valid on that host. The attachment object remains immutable; model instructions require a writable copy for modifications.
+
+## Alternatives considered
+
+**Persist the absolute path in `ImageAttachmentRef`.** A durable host path becomes stale after moving a session, changing `DSH_HOME`, or mounting another provider. Resolving it at request time preserves portable history.
+
+**Teach each LLM adapter the `~/.dsh` layout.** Explicit `dshHome` and `$DSH_HOME` can select another root, and non-local providers may expose no path. The attachment provider owns this fact.
+
+**Add a dedicated crop or recovery tool.** Standard filesystem and image tools can operate after copying the normalized object. A new tool adds a model schema and access-policy surface without being necessary for path discovery.
+
+## Verification
+
+Package tests cover provider access defaults, local digest-to-path resolution, request-version access propagation, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder.
+
+## Consequences
+
+The selected model provider receives a host path that was previously local-only. This disclosure is required for the model to operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths change when the provider root changes, while deterministic image bytes and session references remain unchanged. A missing local object still fails when a model tool attempts to read it.
diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md
new file mode 100644
index 0000000000..24335f1da9
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md
@@ -0,0 +1,37 @@
+# Agent Note:在模型请求中提供由附件提供方解析的图片路径
+
+状态:已实现
+
+[English](2026-08-21-model-readable-image-paths.md) | 中文
+
+## 问题
+
+上传图片会变成不透明的持久 `ImageAttachmentRef`。支持图片的模型可以收到请求预览,但原有描述没有给出供后续图片操作使用的文件系统位置。因此 agent 会搜索工作区和临时目录,或询问用户文件在哪里。请求预览和存储的规范化附件都可能与上传图片不同,所以它们的尺寸、格式和字节数不能证明上传图片原本的属性。
+
+## 决策
+
+`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageAccess(ref)` 从当前提供方解析可选访问事实。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生不可变对象的绝对路径。请求版本只在序列化期间携带这些事实。没有模型可读本地存储的提供方不返回访问事实。
+
+共用 LLM 图片描述会写明显示名称或完整附件 ID、确切请求预览尺寸,以及当前可用的提供方规范化路径。本地访问文本还包含规范化尺寸和媒体类型,说明对象只供读取,要求模型在编辑前按匹配扩展名复制到可写路径,并指出规范化或请求投影可能缩小或重新编码上传图片。DeepSeek Files 与 pi-ai 内联请求使用同一描述。
+
+请求大小 offload 要求提供逐图占位文本函数;原先共享的占位常量及其字节上限包装函数已没有生产调用方,因此删除。DeepSeek 和 pi-ai 会把每个省略位置替换为该图片自己的附件身份和当前访问事实,无需读取或转换省略对象。Offload 的选择、字节计量和按固定步长变化的前缀行为保持不变。
+
+描述文本的身份来自每个出现位置自己的持久引用,而不是准备好的请求版本:请求版本按附件 ID 去重,同一内容以不同文件名上传两次会共享一个版本,但每个出现位置保留自己的显示名称。访问解析会校验已记录的附件 ID;持久历史中的畸形引用会在请求组装时失败,这是能解析它的最早时点。
+
+绝对路径不会进入会话事件。模型可见路径根据已记录附件引用和当前进程挂载的提供方重建。因此,同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径。附件对象保持不可变;模型指令要求先复制到可写位置再修改。
+
+## 考虑过的替代方案
+
+**把绝对路径持久保存到 `ImageAttachmentRef`。** 移动会话、更改 `DSH_HOME` 或挂载其他提供方后,持久宿主路径会失效。请求时解析可以保持历史可移植。
+
+**让每个 LLM 适配器了解 `~/.dsh` 布局。** 显式 `dshHome` 和 `$DSH_HOME` 可以选择其他根目录,非本地提供方也可能没有路径。该事实属于附件提供方。
+
+**增加专用裁剪或恢复工具。** 把规范化对象复制出来后,标准文件系统与图片工具已经可以处理它。新增工具会增加模型 schema 和访问策略范围,但路径发现不需要它。
+
+## 验证
+
+包测试覆盖提供方访问默认值、本地摘要到路径的解析、请求版本访问事实传播、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。
+
+## 后果
+
+所选模型提供方会收到此前只在本地存在的宿主路径。模型需要该信息才能操作存储图片,并且路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。提供方根目录变化时路径会变化,确定性图片字节和会话引用保持不变。本地对象缺失时,模型工具读取它仍会失败。
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index aed3574106..ea79995c64 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: a845fe22e13ed085765668c7ec8d54d6bbdf129a
-config-catalog.zh.md: 39ba9d48368f99483733292f997609ba3a8aa43e
+config-catalog.md: ffea1ee18359fd6f7cc9171d103e143b8bcd9e5b
+config-catalog.zh.md: c447104fcb00d0df3625f6cae89ca128bfa44b57
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index a845fe22e1..ffea1ee183 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -317,7 +317,7 @@ export interface Config {
}
```
-Source: [`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts)
+Source: [`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts)
@@ -941,12 +941,10 @@ export interface DeepSeekCatalogModel {
maxTokens?: number
/** Accepted request modalities; omission is text-only. */
inputModalities?: ModelModality[]
- /** Total-pixel budget for one deterministic request preview. */
- imagePixelBudget?: number
+ /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */
+ imagePixelBudget?: number | 'low'
/** Encoded-byte cap for one deterministic request preview. */
imageMaxBytes?: number
- /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */
- imageDetail?: 'auto' | 'low'
}
```
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 39ba9d4836..c447104fcb 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -319,7 +319,7 @@ export interface Config {
}
```
-来源:[`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts)
+来源:[`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts)
@@ -943,12 +943,10 @@ export interface DeepSeekCatalogModel {
maxTokens?: number
/** Accepted request modalities; omission is text-only. */
inputModalities?: ModelModality[]
- /** Total-pixel budget for one deterministic request preview. */
- imagePixelBudget?: number
+ /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */
+ imagePixelBudget?: number | 'low'
/** Encoded-byte cap for one deterministic request preview. */
imageMaxBytes?: number
- /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */
- imageDetail?: 'auto' | 'low'
}
```
diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml
index b93c9ef1ca..17c9d1bf47 100644
--- a/docs/subsystems/attachment.i18n.yaml
+++ b/docs/subsystems/attachment.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/attachment.md
-attachment.md: e6d0a53db2827a38a1535380319b6220aa37f0a4
-attachment.zh.md: 8328ec610d4d68624f75f00d6a397b13fdf31c4e
+attachment.md: fef6b3ad40f424a82049cddc761efa483fdda6f3
+attachment.zh.md: def50f79f33cec9b9262823d481a2d03429f0845
diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md
index e6d0a53db2..fef6b3ad40 100644
--- a/docs/subsystems/attachment.md
+++ b/docs/subsystems/attachment.md
@@ -10,7 +10,7 @@ Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachmen
## Identity and verified metadata
-`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it.
+`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. Consumers call `imageAccess()` when they need a path resolved by the mounted provider.
```ts type-equiv
/** Raster image formats accepted by the version-one attachment path. */
@@ -93,6 +93,14 @@ interface StoredImageAttachment {
}
```
+```ts type-equiv
+/** Provider-resolved ways for model tools to access one normalized attachment. */
+interface ImageAttachmentAccess {
+ /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */
+ readonlyPath: string
+}
+```
+
```ts type-equiv
/** Deterministic request-image policy selected by one exact model route. */
interface ImageRequestPolicy {
@@ -110,6 +118,8 @@ interface RequestImageAttachment {
variantId: ImageVariantId
/** Durable normalized attachment from which this request version was derived. */
attachment: ImageAttachmentRef
+ /** Transient provider access facts; never persisted in the durable reference. */
+ access?: ImageAttachmentAccess
/** Encoded request bytes. */
data: Uint8Array
mediaType: ImageMediaType
@@ -125,7 +135,7 @@ interface RequestImageAttachment {
}
```
-`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion.
+`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageAccess()` resolves current-provider access facts without storing host paths in session data. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion.
@@ -176,6 +186,13 @@ abstract saveImage(input: SaveImageAttachment): Promise
*/
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise
+/**
+ * Resolve provider-specific model-tool access without adding host facts to session history.
+ * @param ref - durable normalized attachment reference.
+ * @returns current-provider access facts, or undefined when this backend exposes no local path.
+ */
+imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined
+
/**
* Generate or read one deterministic model-request version from the stored normalized image.
* @param ref - durable provider-independent normalized attachment reference.
diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md
index 8328ec610d..def50f79f3 100644
--- a/docs/subsystems/attachment.zh.md
+++ b/docs/subsystems/attachment.zh.md
@@ -10,7 +10,7 @@
## 标识与经过校验的元数据
-`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。
+`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方需要路径时调用 `imageAccess()`,由当前挂载的提供方负责解析。
```ts type-equiv
/** Raster image formats accepted by the version-one attachment path. */
@@ -93,6 +93,14 @@ interface StoredImageAttachment {
}
```
+```ts type-equiv
+/** Provider-resolved ways for model tools to access one normalized attachment. */
+interface ImageAttachmentAccess {
+ /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */
+ readonlyPath: string
+}
+```
+
```ts type-equiv
/** Deterministic request-image policy selected by one exact model route. */
interface ImageRequestPolicy {
@@ -110,6 +118,8 @@ interface RequestImageAttachment {
variantId: ImageVariantId
/** Durable normalized attachment from which this request version was derived. */
attachment: ImageAttachmentRef
+ /** Transient provider access facts; never persisted in the durable reference. */
+ access?: ImageAttachmentAccess
/** Encoded request bytes. */
data: Uint8Array
mediaType: ImageMediaType
@@ -125,7 +135,7 @@ interface RequestImageAttachment {
}
```
-`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
+`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageAccess()` 解析当前提供方的访问信息,无需把宿主路径写入会话数据。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
@@ -176,6 +186,13 @@ abstract saveImage(input: SaveImageAttachment): Promise
*/
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise
+/**
+ * Resolve provider-specific model-tool access without adding host facts to session history.
+ * @param ref - durable normalized attachment reference.
+ * @returns current-provider access facts, or undefined when this backend exposes no local path.
+ */
+imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined
+
/**
* Generate or read one deterministic model-request version from the stored normalized image.
* @param ref - durable provider-independent normalized attachment reference.
diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts
index e0946004aa..3e8f3aa982 100644
--- a/examples/acp-agent/tests/acp.snapshot.ts
+++ b/examples/acp-agent/tests/acp.snapshot.ts
@@ -16,7 +16,6 @@ import {
} from '@deepseek-ai/dsh-acp-snapshot'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
-import { OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm'
/**
* The acp-agent example's snapshot suite: the scenario table for
@@ -807,16 +806,36 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests
expect(result.stderr).toBe('')
expect(requests).toHaveLength(2)
expect(fileRequests).toEqual([{ method: 'POST', path: '/files', bytes: 69 }])
+ const attachmentDigest = 'b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640'
+ const attachmentId = `sha256:${attachmentDigest}`
+ const accessText = (cwd: string): string => {
+ const attachmentPath = join(
+ cwd,
+ '.dsh',
+ 'attachments',
+ 'v1',
+ 'objects',
+ attachmentDigest.slice(0, 2),
+ attachmentDigest,
+ )
+ return ` Normalized copy (read-only; may be resized or re-encoded): ${JSON.stringify(attachmentPath)} (1x1px, image/png).`
+ + ' Source dimensions, format, and byte size may differ.'
+ + ' Copy to a writable path ending in .png before editing.'
+ }
+ const normalizedAccess = accessText(result.cwd)
+ const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]`
+ const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}`
+ const normalizedToolImageHandle = `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}`
+ .replaceAll(result.cwd, '{{cwd}}')
const messages = requests[0]?.messages as { content?: unknown }[] | undefined
const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted'))
expect(offloaded?.content).toEqual([
{ type: 'text', text: 'Compare the older image ' },
- { type: 'text', text: OFFLOADED_IMAGE_TEXT },
+ { type: 'text', text: offloadedImage },
{ type: 'text', text: ' with the newer image ' },
{
type: 'text',
- text: '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; '
- + 'request image 1x1px.',
+ text: `\n${imageHandle}`,
},
{ type: 'file', file_id: 'file-api-snapshot-1' },
{ type: 'text', text: ', then use read_image on red.png and reply with DONE.' },
@@ -839,7 +858,7 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests
expect(followup).toEqual([
{
role: 'user',
- content: `Compare the older image ${OFFLOADED_IMAGE_TEXT} with the newer image ${OFFLOADED_IMAGE_TEXT}, then use read_image on red.png and reply with DONE.`,
+ content: `Compare the older image ${offloadedImage} with the newer image ${offloadedImage}, then use read_image on red.png and reply with DONE.`,
},
{
role: 'user',
@@ -860,7 +879,7 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests
role: 'tool',
tool_call_id: 'native-read-image',
content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n'
- + '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; request image 1x1px.',
+ + `\n${normalizedToolImageHandle}`,
},
{
role: 'user',
@@ -891,15 +910,12 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests
expect(requests).toHaveLength(3)
const fallbackMessages = requests[2]?.messages as { content?: unknown }[] | undefined
const fallbackInput = fallbackMessages?.find(message => JSON.stringify(message.content).includes('[image omitted'))
+ const fallbackAccess = accessText(fallback.cwd)
expect(fallbackInput?.content).toEqual([
{ type: 'text', text: 'Compare the older image ' },
- { type: 'text', text: OFFLOADED_IMAGE_TEXT },
+ { type: 'text', text: `[image omitted to fit request image limits; ${attachmentId}.${fallbackAccess}]` },
{ type: 'text', text: ' with the newer image ' },
- {
- type: 'text',
- text: '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; '
- + 'request image 1x1px.',
- },
+ { type: 'text', text: `\nImage ${attachmentId}; request preview 1x1px.${fallbackAccess}` },
{ type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } },
{ type: 'text', text: ', then use read_image on red.png and reply with DONE.' },
])
diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml
index 3698abdcb2..a82a48a906 100644
--- a/packages/attachment/attachment-local/README.i18n.yaml
+++ b/packages/attachment/attachment-local/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md
-README.md: 3ed4ab3251b0a609807c76930226bec63f0164cd
-README.zh.md: 85abd10389acc46c2d89dd85628f5d201b089710
+README.md: e23fe75a32a873f3f3762c14060c92c66be3a12a
+README.zh.md: 646d1d8f26df382be7b0c2c18f2b6ed72661f3a4
diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md
index 3ed4ab3251..e23fe75a32 100644
--- a/packages/attachment/attachment-local/README.md
+++ b/packages/attachment/attachment-local/README.md
@@ -8,11 +8,11 @@ Admission accepts at most 20 images and 200MiB of encoded source bytes per messa
Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation.
-`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
+`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. At request assembly, `imageAccess` derives the absolute normalized-object path from that reference and the current provider root. The path is host-specific, read-only, and absent from durable history. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
## Model Experience
-Indirectly, through durable replay of historical user images and structured model image output after restart and fork.
+Indirectly, through request descriptors that give the model each retained or offloaded image's identity, dimensions, media type, current read-only normalized-object path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload.
#### KV Cache effect
diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md
index 85abd10389..646d1d8f26 100644
--- a/packages/attachment/attachment-local/README.zh.md
+++ b/packages/attachment/attachment-local/README.zh.md
@@ -8,11 +8,11 @@
请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。
-`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
+`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。组装请求时,`imageAccess` 根据该引用和当前提供方根目录派生规范化对象的绝对路径。该路径属于当前宿主,只供读取,不进入持久历史。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
## 模型体验
-该包通过重启和 fork 后对历史用户图片与结构化模型图片输出的持久回放间接影响模型。
+该包通过请求描述间接影响模型,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、当前只读规范化对象路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。
#### KV 缓存影响
diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts
index e9a1145ba5..31d9f38014 100644
--- a/packages/attachment/attachment-local/src/index.ts
+++ b/packages/attachment/attachment-local/src/index.ts
@@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type {
+ ImageAttachmentAccess,
ImageAttachmentLimits,
ImageAttachmentRef,
ImageRequestPolicy,
@@ -15,7 +16,7 @@ import type {
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import type { NormalizationPolicy } from './normalization.ts'
import { CompressionLimiter } from './compression-limiter.ts'
-import { commitPreparedImageFile, prepareImageFile, readImageFile, validateImageFile } from './store.ts'
+import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readImageFile, validateImageFile } from './store.ts'
import { readRequestImageFile, requestImageVariantId } from './request-image.ts'
export { canPassThroughNormalization, normalizeImage } from './normalization.ts'
@@ -207,6 +208,10 @@ export class LocalAttachmentStore extends AttachmentStore {
return readImageFile(this.root, ref, signal)
}
+ override imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess {
+ return { readonlyPath: normalizedImagePath(this.root, ref) }
+ }
+
override async readImageRequest(
ref: ImageAttachmentRef,
policy: ImageRequestPolicy,
@@ -230,12 +235,15 @@ export class LocalAttachmentStore extends AttachmentStore {
operation = undefined
}
if (operation === undefined) {
- const shared = new SharedRequest(sharedSignal => this.compression.run(async () => readRequestImageFile(
- this.root,
- stored ?? await this.readImage(ref, sharedSignal),
- policy,
- sharedSignal,
- )))
+ const shared = new SharedRequest(sharedSignal => this.compression.run(async () => ({
+ ...await readRequestImageFile(
+ this.root,
+ stored ?? await this.readImage(ref, sharedSignal),
+ policy,
+ sharedSignal,
+ ),
+ access: this.imageAccess(ref),
+ })))
operation = shared
this.requestInflight.set(key, shared)
void shared.promise.finally(() => {
diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts
index 5fbb8e9201..266d4f8e34 100644
--- a/packages/attachment/attachment-local/src/store.ts
+++ b/packages/attachment/attachment-local/src/store.ts
@@ -36,16 +36,23 @@ function displayName(value: string | undefined): string | undefined {
return clean === '' ? undefined : clean
}
-function objectPath(root: string, sha256: string): string {
- return join(root, 'objects', sha256.slice(0, 2), sha256)
-}
-
function ensureReference(ref: ImageAttachmentRef): string {
const match = ID_PATTERN.exec(String(ref.attachmentId))
if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF')
return match[1]
}
+/**
+ * Derive the absolute immutable-object path for one normalized attachment.
+ * @param root - absolute `DSH_HOME/attachments/v1` root.
+ * @param ref - durable normalized attachment reference.
+ * @returns provider-local path without reading the object.
+ */
+export function normalizedImagePath(root: string, ref: ImageAttachmentRef): string {
+ const sha256 = ensureReference(ref)
+ return join(root, 'objects', sha256.slice(0, 2), sha256)
+}
+
async function inspectMetadata(
data: Uint8Array,
declaredMediaType: ImageAttachmentRef['mediaType'],
@@ -199,7 +206,7 @@ export async function commitPreparedImageFile(
await ensureDurableDirectory(bucket, boundary)
await ensureDurableDirectory(staging, boundary)
const temporary = join(staging, randomUUID())
- const target = objectPath(root, sha256)
+ const target = normalizedImagePath(root, prepared.ref)
let handle
try {
handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
@@ -275,7 +282,7 @@ export async function readImageFile(
const sha256 = ensureReference(ref)
let data: Uint8Array
try {
- data = new Uint8Array(await readFile(objectPath(root, sha256), { signal }))
+ data = new Uint8Array(await readFile(normalizedImagePath(root, ref), { signal }))
} catch (error) {
signal?.throwIfAborted()
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts
index f8deea3c5c..e5c409b1d6 100644
--- a/packages/attachment/attachment-local/tests/index.spec.ts
+++ b/packages/attachment/attachment-local/tests/index.spec.ts
@@ -1,6 +1,7 @@
import { Context } from '@deepseek-ai/cordis'
+import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { existsSync } from 'node:fs'
-import { mkdtemp, rm } from 'node:fs/promises'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -37,6 +38,21 @@ describe('local attachment service', () => {
maxBytes: DEFAULT_NORMALIZED_IMAGE_MAX_BYTES,
})
expect(service.imageCompressionConcurrency).toBe(DEFAULT_IMAGE_COMPRESSION_CONCURRENCY)
+ const ref = {
+ attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
+ mediaType: 'image/png' as const,
+ bytes: 1,
+ width: 1,
+ height: 1,
+ }
+ expect(service.imageAccess(ref).readonlyPath).toBe(join(
+ service.root,
+ 'objects',
+ 'aa',
+ 'a'.repeat(64),
+ ))
+ expect(() => service.imageAccess({ ...ref, attachmentId: AttachmentId('invalid') }))
+ .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' }))
})
it('resolves and validates the instance image-compression concurrency', () => {
@@ -57,6 +73,18 @@ describe('local attachment service', () => {
))
const ref = await service.saveImage({ data, mediaType: 'image/png' })
await expect(service.readImage(ref)).resolves.toEqual({ ref, data })
+ const access = service.imageAccess(ref)
+ expect(access.readonlyPath).toBe(join(
+ dshHome,
+ 'attachments',
+ 'v1',
+ 'objects',
+ String(ref.attachmentId).slice('sha256:'.length, 'sha256:'.length + 2),
+ String(ref.attachmentId).slice('sha256:'.length),
+ ))
+ await expect(readFile(access.readonlyPath)).resolves.toEqual(Buffer.from(data))
+ const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 })
+ expect(request.access).toEqual(access)
} finally {
await rm(dshHome, { recursive: true, force: true })
}
diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml
index e27f25e933..1a55f27a2c 100644
--- a/packages/attachment/attachment/README.i18n.yaml
+++ b/packages/attachment/attachment/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md
-README.md: 3ad568c7308f1ab85cb4af3fcc2afd3cba9a611a
-README.zh.md: fadbb1c5bbf097c599da651055d63a1ed64cd579
+README.md: 63c6a41fca3d0d7ea87d9f30e682e7e4e7aa5906
+README.zh.md: 4eb08dd169e1d0f2b20ada050513caaf88a84891
diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md
index 3ad568c730..63c6a41fca 100644
--- a/packages/attachment/attachment/README.md
+++ b/packages/attachment/attachment/README.md
@@ -2,15 +2,15 @@
English | [中文](README.zh.md)
-The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
+The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, local storage paths, or base64 in session events.
-Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure.
+Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. `imageAccess` resolves optional current-provider access facts, such as an absolute read-only path, without adding host-specific data to the durable reference. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure.
`admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it.
## Model Experience
-Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions.
+Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions. A backend that offers model-tool access can also expose its current read-only normalized path; the descriptor states that normalization may have resized or re-encoded the upload.
#### KV Cache effect
diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md
index fadbb1c5bb..4eb08dd169 100644
--- a/packages/attachment/attachment/README.zh.md
+++ b/packages/attachment/attachment/README.zh.md
@@ -2,15 +2,15 @@
[English](README.md) | 中文
-持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
+持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL、本地存储路径或 base64。
-未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。
+未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。`imageAccess` 解析当前提供方可选的访问事实,例如绝对只读路径,同时避免把宿主信息加入持久引用。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。
`admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。
## 模型体验
-该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。
+该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。支持模型工具访问的后端还可以公开当前规范化附件的只读路径;描述会说明规范化过程可能缩小或重新编码上传图片。
#### KV 缓存影响
diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts
index 8b54926efa..e10ce2d214 100644
--- a/packages/attachment/attachment/src/index.ts
+++ b/packages/attachment/attachment/src/index.ts
@@ -4,6 +4,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
import { AttachmentError } from './error.ts'
import type {
ImageAttachmentLimits,
+ ImageAttachmentAccess,
ImageAttachmentRef,
ImageRequestPolicy,
RequestImageAttachment,
@@ -18,6 +19,7 @@ export { admitEncodedImages } from './admission.ts'
export type {
AttachmentId as AttachmentIdType,
EncodedImageAttachment,
+ ImageAttachmentAccess,
ImageAttachmentLimits,
ImageAttachmentRef,
ImageRequestPolicy,
@@ -107,6 +109,16 @@ export abstract class AttachmentStore extends Service {
*/
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise
+ /**
+ * Resolve provider-specific model-tool access without adding host facts to session history.
+ * @param ref - durable normalized attachment reference.
+ * @returns current-provider access facts, or undefined when this backend exposes no local path.
+ */
+ imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined {
+ void ref
+ return undefined
+ }
+
/**
* Generate or read one deterministic model-request version from the stored normalized image.
* @param ref - durable provider-independent normalized attachment reference.
diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts
index e23a7a7d4c..0b7d382b64 100644
--- a/packages/attachment/attachment/src/types.ts
+++ b/packages/attachment/attachment/src/types.ts
@@ -67,6 +67,12 @@ export interface StoredImageAttachment {
data: Uint8Array
}
+/** Provider-resolved ways for model tools to access one normalized attachment. */
+export interface ImageAttachmentAccess {
+ /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */
+ readonlyPath: string
+}
+
/** Deterministic request-image policy selected by one exact model route. */
export interface ImageRequestPolicy {
/** Maximum width multiplied by height after aspect-preserving projection. */
@@ -81,6 +87,8 @@ export interface RequestImageAttachment {
variantId: ImageVariantId
/** Durable normalized attachment from which this request version was derived. */
attachment: ImageAttachmentRef
+ /** Transient provider access facts; never persisted in the durable reference. */
+ access?: ImageAttachmentAccess
/** Encoded request bytes. */
data: Uint8Array
mediaType: ImageMediaType
diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts
index be784f0276..a28bcd6c1c 100644
--- a/packages/attachment/attachment/tests/index.spec.ts
+++ b/packages/attachment/attachment/tests/index.spec.ts
@@ -146,6 +146,12 @@ describe('AttachmentStore.readImageRequest', () => {
controller.abort(reason)
expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason)
})
+
+ it('exposes no provider-specific path by default', async () => {
+ const store = new RecordingStore(new Context())
+ const ref = await store.saveImage(image(1))
+ expect(store.imageAccess(ref)).toBeUndefined()
+ })
})
describe('isImageAdmissionError', () => {
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 6f7d362aa8..197ff6fadd 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -449,6 +449,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'the verified bytes and normalized attachment reference.',
throws: ['the signal reason when aborted, or a storage error when verification fails.'],
},
+ {
+ signature: 'imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined',
+ description: 'Resolve provider-specific model-tool access without adding host facts to session history.',
+ parameters: [{ name: 'ref', description: 'durable normalized attachment reference.' }],
+ returns: 'current-provider access facts, or undefined when this backend exposes no local path.',
+ },
{
signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise',
description: 'Generate or read one deterministic model-request version from the stored normalized image.',
@@ -3713,6 +3719,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'GrantRecord',
declaration: 'export interface GrantRecord {\n readonly kind: \'grant\';\n readonly payload: unknown;\n}',
},
+ {
+ name: 'ImageAttachmentAccess',
+ declaration: 'export interface ImageAttachmentAccess {\n readonlyPath: string;\n}',
+ },
{
name: 'ImageAttachmentLimits',
declaration: 'export interface ImageAttachmentLimits {\n maxImageBytes: number;\n maxImagesPerMessage: number;\n maxMessageImageBytes: number;\n maxImagePixels: number;\n maxImageDimension: number;\n mediaTypes: readonly ImageMediaType[];\n}',
@@ -4219,7 +4229,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'RequestImageAttachment',
- declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}',
+ declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n attachment: ImageAttachmentRef;\n access?: ImageAttachmentAccess;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}',
},
{
name: 'RequestRunOutcome',
diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml
index 8076a692e6..dc0fe9ccb6 100644
--- a/packages/llm/llm-deepseek/README.i18n.yaml
+++ b/packages/llm/llm-deepseek/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
-README.md: 7d50a8e99863637a06abf49d26a6eeb419cf1bbd
-README.zh.md: c1571149529a2d4e54d67b10f63b60bb2aa1abfd
+README.md: 31aa82dcc0dde68b7ed23edcd0dc477320d55505
+README.zh.md: dad1dbcea639130ff55430684d6478a86717154c
diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md
index 7d50a8e998..31aa82dcc0 100644
--- a/packages/llm/llm-deepseek/README.md
+++ b/packages/llm/llm-deepseek/README.md
@@ -52,9 +52,9 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only.
-An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references.
+An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget` to an exact positive integer or `low`; omission uses 640,000 total pixels, while `low` selects 512 by 512 total pixels. `imageMaxBytes` defaults to 1MiB. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id, actual request dimensions, and the local normalized-object path when its attachment provider exposes one. The text marks that path read-only, gives the matching extension for a writable copy, and states that the preview and normalized image may differ from the upload. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references.
-`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image.
+`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Each removed image becomes its own model-visible placeholder with its display name or attachment id and, when available, normalized dimensions, media type, and current read-only local path. This high-watermark projection avoids changing an old request prefix after every new image.
Inline fallback has an independent base64 budget. `maxInlineRequestImageBytes` defaults to 20MiB and `inlineImageOffloadByteQuantum` to 10MiB, so a history of 21 one-megabyte base64 payloads removes the oldest 11 and retains 10MiB. The calculation uses base64-expanded lengths. The prepared request versions are reused byte-for-byte; fallback does not decode or compress an image again. Successful mappings created before a later image fails remain indexed for future requests.
@@ -115,7 +115,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA`
#### What the model sees
-The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside stable attachment handles and request-image dimensions; a Files resolution failure sends all retained images as inline data URLs instead. An over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool.
+The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside stable attachment handles, request-preview dimensions, and the current normalized-object path when available; a Files resolution failure sends all retained images as inline data URLs instead. The descriptor tells the model that this read-only local copy may be resized or re-encoded and must not be used to infer upload properties. An over-budget older image keeps the same access facts in its per-image placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool.
#### Token effect
diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md
index c157114952..dad1dbcea6 100644
--- a/packages/llm/llm-deepseek/README.zh.md
+++ b/packages/llm/llm-deepseek/README.zh.md
@@ -52,9 +52,9 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。
-支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。
+支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可把 `imagePixelBudget` 设为确切正整数或 `low`;省略时使用总像素 640,000,`low` 选择总像素 512×512。`imageMaxBytes` 默认值为 1MiB。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸,以及附件提供方支持时的本地规范化对象路径。文本会把该路径标记为只读,指出复制到可写路径时应使用的匹配扩展名,并说明预览和规范化图片可能与上传图片不同。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。
-`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。
+`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。每张被移除的图片都有自己的模型可见占位文本,其中包含显示名称或附件 ID;如果当前提供方支持,还会包含规范化尺寸、媒体类型和当前只读本地路径。这种定量投影不会因每新增一张图片就改写较早的请求前缀。
内联回退使用独立的 base64 预算。`maxInlineRequestImageBytes` 默认为 20MiB,`inlineImageOffloadByteQuantum` 默认为 10MiB,因此由 21 个 1MiB base64 负载组成的历史会移除最旧的 11 个并保留 10MiB。计算使用 base64 膨胀后的长度。系统逐字节复用已经准备好的请求版本;回退不会再次解码或压缩图片。前面图片已经成功写入的上传映射会保留,供后续请求复用。
@@ -115,7 +115,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提
#### 模型看到的内容
-所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄和请求图片尺寸;Files 解析失败时,所有保留图片改用内联 data URL。超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。
+所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄、请求预览尺寸,以及当前可用的规范化对象路径;Files 解析失败时,所有保留图片改用内联 data URL。描述会告诉模型,该本地副本只供读取,可能经过缩小或重新编码,不能据此推断上传图片的属性。超出上限的较旧图片会在自己的占位文本中保留相同的访问事实。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。
#### Token 影响
diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts
index fe461fdb35..8b4140a7d1 100644
--- a/packages/llm/llm-deepseek/src/adapter.ts
+++ b/packages/llm/llm-deepseek/src/adapter.ts
@@ -8,7 +8,7 @@
* @module dsh-llm-deepseek/adapter
*/
-import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadRequestImagesWithPolicy, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
+import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
ContentBlock,
GenerateOptions,
@@ -58,12 +58,10 @@ export interface DeepSeekCatalogModel {
maxTokens?: number
/** Accepted request modalities; omission is text-only. */
inputModalities?: ModelModality[]
- /** Total-pixel budget for one deterministic request preview. */
- imagePixelBudget?: number
+ /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */
+ imagePixelBudget?: number | 'low'
/** Encoded-byte cap for one deterministic request preview. */
imageMaxBytes?: number
- /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */
- imageDetail?: 'auto' | 'low'
}
/**
@@ -206,10 +204,9 @@ function collectImageRefs(
* @internal
*/
export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy {
- let maxPixels: number
- if (model.imagePixelBudget !== undefined) maxPixels = model.imagePixelBudget
- else if (model.imageDetail === 'low') maxPixels = DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET
- else maxPixels = DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET
+ const maxPixels = model.imagePixelBudget === 'low'
+ ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET
+ : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET
return {
maxPixels,
maxBytes: model.imageMaxBytes === undefined
@@ -548,6 +545,7 @@ export class DeepSeekAdapter extends LlmAdapter {
byteQuantum: connection.imageOffloadByteQuantum,
countQuantum: connection.imageOffloadCountQuantum,
byteLength: ref => Math.min(ref.bytes, policy.maxBytes),
+ placeholder: ref => offloadedImageText(ref, attachments?.imageAccess(ref)),
})
const requestOptions = requestMessages === options.messages ? options : { ...options, messages: [...requestMessages] }
const requestImages = attachments === undefined || model === undefined
diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts
index e6faad74ce..747e37ca4c 100644
--- a/packages/llm/llm-deepseek/src/index.ts
+++ b/packages/llm/llm-deepseek/src/index.ts
@@ -151,9 +151,8 @@ const catalogModel: z = z.object({
contextWindow: z.number().step(1).min(1),
maxTokens: z.number().step(1).min(1),
inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']),
- imagePixelBudget: z.number().step(1).min(1),
+ imagePixelBudget: z.union([z.number().step(1).min(1), 'low']),
imageMaxBytes: z.number().step(1).min(1),
- imageDetail: z.union(['auto', 'low']),
})
export const Config: z = z.object({
@@ -225,13 +224,13 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not contain duplicates`)
}
const hasImage = inputModalities.includes('image')
- if (!hasImage && (model.imagePixelBudget !== undefined
- || model.imageMaxBytes !== undefined || model.imageDetail !== undefined)) {
+ if (!hasImage && (model.imagePixelBudget !== undefined || model.imageMaxBytes !== undefined)) {
throw new Error(`llm-deepseek: text-only catalog model "${model.id}" cannot declare image request limits`)
}
if (model.imagePixelBudget !== undefined
+ && model.imagePixelBudget !== 'low'
&& (!Number.isSafeInteger(model.imagePixelBudget) || model.imagePixelBudget <= 0)) {
- throw new Error(`llm-deepseek: catalog model "${model.id}" imagePixelBudget must be a positive safe integer`)
+ throw new Error(`llm-deepseek: catalog model "${model.id}" imagePixelBudget must be "low" or a positive safe integer`)
}
if (model.imageMaxBytes !== undefined
&& (!Number.isSafeInteger(model.imageMaxBytes) || model.imageMaxBytes <= 0)) {
@@ -248,12 +247,10 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
inputModalities: [...inputModalities],
...hasImage
? {
- imagePixelBudget: model.imagePixelBudget
- ?? (model.imageDetail === 'low'
- ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET
- : DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET),
+ imagePixelBudget: model.imagePixelBudget === 'low'
+ ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET
+ : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
imageMaxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES,
- ...model.imageDetail === undefined ? {} : { imageDetail: model.imageDetail },
}
: {},
}
diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts
index 3b22967d96..e1218ba16c 100644
--- a/packages/llm/llm-deepseek/src/serialize.ts
+++ b/packages/llm/llm-deepseek/src/serialize.ts
@@ -6,7 +6,7 @@
* @module dsh-llm-deepseek/serialize
*/
-import { contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm'
+import { contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
import type {
@@ -125,12 +125,13 @@ function assertSupportedImageRoles(messages: readonly Message[]): void {
/** Describe the exact request preview and its model-callable coordinate system. */
function imageHandle(
+ ref: ImageAttachmentRef,
version: RequestImageAttachment,
precededByContent: boolean,
): WireTextContentPart {
return {
type: 'text',
- text: `${precededByContent ? '\n' : ''}${requestImageHandleText(version)}`,
+ text: `${precededByContent ? '\n' : ''}${requestImageHandleText(ref, version)}`,
}
}
@@ -154,7 +155,7 @@ async function imageParts(
type: 'image_url',
image_url: { url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString('base64')}` },
}
- return [imageHandle(version, precededByContent), image]
+ return [imageHandle(block.attachment, version, precededByContent), image]
}
/** Convert user or nested tool-result blocks into ordered wire parts. */
@@ -415,6 +416,10 @@ export async function serializeRequestWithImages(
...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest },
...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum },
...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum },
+ placeholder: (ref) => {
+ const version = images.requestImages.get(ref.attachmentId)
+ return offloadedImageText(ref, version?.access)
+ },
})
const messages: WireMessage[] = []
if (options.system !== undefined) {
diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts
index 5a32a35664..9408f1595b 100644
--- a/packages/llm/llm-deepseek/tests/adapter.spec.ts
+++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts
@@ -109,7 +109,7 @@ function attachmentStoreOf(
} {
const readImageRequest = vi.fn(project)
return {
- store: { readImageRequest } as unknown as AttachmentStore,
+ store: { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore,
readImageRequest,
}
}
@@ -147,7 +147,7 @@ describe('request image policy', () => {
{ maxPixels: 640_000, maxBytes: 1024 * 1024 },
],
[
- { id: 'low', imageDetail: 'low' as const },
+ { id: 'low', imagePixelBudget: 'low' as const },
{ maxPixels: 512 * 512, maxBytes: 1024 * 1024 },
],
[
@@ -367,7 +367,7 @@ describe('DeepSeekAdapter against a mock server', () => {
role: 'user',
content: [
{ type: 'text', text: 'describe ' },
- { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request image 1x1px.`) as string },
+ { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request preview 1x1px.`) as string },
{ type: 'file', file_id: 'file-api-1' },
],
}],
@@ -434,7 +434,7 @@ describe('DeepSeekAdapter against a mock server', () => {
}))
const body = JSON.stringify(server.requests[0])
- expect(body.match(/older images are omitted first/g)).toHaveLength(11)
+ expect(body.match(/image omitted to fit request image limits/g)).toHaveLength(11)
expect(body.match(/"type":"image_url"/g)).toHaveLength(10)
})
@@ -600,7 +600,7 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(body.messages[0]).toMatchObject({
role: 'user',
content: [
- { type: 'text', text: expect.stringContaining('older images are omitted first') as string },
+ { type: 'text', text: expect.stringContaining(`image omitted to fit request image limits; ${old.attachmentId}`) as string },
{ type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string },
{ type: 'file', file_id: 'file-api-1' },
],
@@ -619,7 +619,7 @@ describe('DeepSeekAdapter against a mock server', () => {
{
id: 'vision-low',
inputModalities: ['text', 'image'],
- imageDetail: 'low',
+ imagePixelBudget: 'low',
imageMaxBytes: 512_000,
},
{
@@ -1907,8 +1907,9 @@ describe('plugin registration and config', () => {
})
it.each([
- ['imagePixelBudget', 0, /imagePixelBudget must be a positive safe integer/],
- ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be a positive safe integer/],
+ ['imagePixelBudget', 0, /imagePixelBudget must be "low" or a positive safe integer/],
+ ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be "low" or a positive safe integer/],
+ ['imagePixelBudget', 'auto', /imagePixelBudget must be "low" or a positive safe integer/],
['imageMaxBytes', 0, /imageMaxBytes must be a positive safe integer/],
['imageMaxBytes', 1.5, /imageMaxBytes must be a positive safe integer/],
] as const)('rejects per-model %s=%s', (field, value, message) => {
diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
index 4617ebdfed..fd6759c152 100644
--- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
+++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
@@ -208,7 +208,7 @@ describe('request-level dynamic configuration', () => {
const first = (server.requests[0] as { messages: Array<{ content: unknown }> }).messages[0]?.content
const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content
expect(JSON.stringify(first).match(/"type":"file"/g)).toHaveLength(2)
- expect(JSON.stringify(second)).toContain('[image omitted to keep the request within its image limit')
+ expect(JSON.stringify(second)).toContain('[image omitted to fit request image limits')
expect(JSON.stringify(second).match(/"type":"file"/g)).toHaveLength(1)
})
diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts
index 968ceabdaf..cb94b3c237 100644
--- a/packages/llm/llm-deepseek/tests/serialize.spec.ts
+++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts
@@ -367,7 +367,7 @@ describe('image serialization', () => {
role: 'user',
content: [
{ type: 'text', text: 'before' },
- { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request image 1x1px`) as string },
+ { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string },
{ type: 'file', file_id: 'file-api-image' },
{ type: 'text', text: 'after' },
],
@@ -392,7 +392,7 @@ describe('image serialization', () => {
expect(wire.messages).toEqual([{
role: 'user',
content: [
- { type: 'text', text: `Image ${ref.attachmentId}; request image 1x1px.` },
+ { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string },
{ type: 'image_url', image_url: { url } },
],
}])
@@ -411,12 +411,41 @@ describe('image serialization', () => {
expect(wire.messages).toEqual([{
role: 'user',
content: [
- { type: 'text', text: `Image ${ref.attachmentId}; request image 1x1px.` },
+ {
+ type: 'text',
+ text: `Image ${ref.attachmentId}; request preview 1x1px. It may be resized or re-encoded; source dimensions, format, and byte size may differ.`,
+ },
{ type: 'file', file_id: 'file-api-image' },
],
}])
})
+ it('includes provider-resolved normalized access in a retained image handle', async () => {
+ const ref = { ...imageRef(), name: 'diagram.png', width: 2048, height: 1024 }
+ const images = imageOptions([ref])
+ const version = images.requestImages.get(ref.attachmentId) as RequestImageAttachment
+ version.width = 1130
+ version.height = 565
+ version.access = { readonlyPath: '/tmp/dsh/objects/aa/object' }
+ const wire = await serializeRequestWithImages(request({
+ model: 'deepseek-v4-flash-vision-exp',
+ messages: [createUserMessage({
+ content: [{ type: 'image', attachment: ref }],
+ source: { kind: 'plugin', plugin: 'test' },
+ })],
+ }), images)
+
+ expect(wire.messages[0]).toMatchObject({
+ role: 'user',
+ content: [{
+ type: 'text',
+ text: expect.stringContaining('Image "diagram.png"') as string,
+ }, { type: 'file' }],
+ })
+ expect(JSON.stringify(wire.messages[0])).toContain('/tmp/dsh/objects/aa/object')
+ expect(JSON.stringify(wire.messages[0])).toContain('request preview 1130x565px')
+ })
+
it('rejects an image whose prepared request version is absent', async () => {
const ref = imageRef()
await expect(serializeMessagesWithImages([createUserMessage({
@@ -546,14 +575,14 @@ describe('image serialization', () => {
{
role: 'tool',
tool_call_id: 'before-system',
- content: expect.stringContaining('request image 1x1px') as string,
+ content: expect.stringContaining('request preview 1x1px') as string,
},
expect.objectContaining({ role: 'user' }),
{ role: 'system', content: 'system history' },
{
role: 'tool',
tool_call_id: 'before-assistant',
- content: expect.stringContaining('request image 1x1px') as string,
+ content: expect.stringContaining('request preview 1x1px') as string,
},
expect.objectContaining({ role: 'user' }),
{ role: 'assistant', content: 'assistant history' },
@@ -564,6 +593,9 @@ describe('image serialization', () => {
const resolveFileId = fileResolver()
const png = imageRef('image/png', 3)
const jpeg = imageRef('image/jpeg', 3)
+ const images = imageOptions([png, jpeg], resolveFileId, 4)
+ const pngVersion = images.requestImages.get(png.attachmentId) as RequestImageAttachment
+ pngVersion.access = { readonlyPath: '/tmp/dsh/objects/png' }
const wire = await serializeRequestWithImages(request({
model: 'deepseek-v4-flash-vision-exp',
messages: [createUserMessage({
@@ -573,12 +605,15 @@ describe('image serialization', () => {
],
source: { kind: 'plugin', plugin: 'test' },
})],
- }), imageOptions([png, jpeg], resolveFileId, 4))
+ }), images)
expect(wire.messages[0]).toMatchObject({
role: 'user',
content: [
- { type: 'text', text: expect.stringContaining('older images are omitted first') as string },
+ {
+ type: 'text',
+ text: expect.stringContaining(`image omitted to fit request image limits; ${png.attachmentId}. Normalized copy (read-only; may be resized or re-encoded): "/tmp/dsh/objects/png"`) as string,
+ },
{ type: 'text', text: expect.stringContaining(`Image ${jpeg.attachmentId}`) as string },
{ type: 'file', file_id: 'file-api-image' },
],
@@ -598,7 +633,7 @@ describe('image serialization', () => {
}), inlineImageOptions([ref], 80, 40))
const content = wire.messages[0]?.content
- expect(JSON.stringify(content).match(/older images are omitted first/g)).toHaveLength(11)
+ expect(JSON.stringify(content).match(/image omitted to fit request image limits/g)).toHaveLength(11)
expect(JSON.stringify(content).match(/"type":"image_url"/g)).toHaveLength(10)
})
diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml
index a7021eb1cd..67377a2daa 100644
--- a/packages/llm/llm-pi-ai/README.i18n.yaml
+++ b/packages/llm/llm-pi-ai/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
-README.md: 9ef6596490b614a3d4af3dd6b52eb9b9fe87a335
-README.zh.md: 10f366659a38f52f7700c0db7953b983fd0e623a
+README.md: 7f0c2a32315200e3244cb83d396bbf2ee30d728f
+README.zh.md: f11d4ba1d991cc70c1967eef8f8b880727df36ce
diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md
index 9ef6596490..7f0c2a3231 100644
--- a/packages/llm/llm-pi-ai/README.md
+++ b/packages/llm/llm-pi-ai/README.md
@@ -123,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i
A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`.
-Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with fixed text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual request-image dimensions. Harness app attribution wins a conflicting configured header name.
+Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with per-image text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id, actual request-image dimensions, and provider-resolved normalized-object path when available. Harness app attribution wins a conflicting configured header name.
The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`.
@@ -173,7 +173,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata
#### What the model sees
-The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id and actual request dimensions. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content.
+The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id, actual request dimensions, and current normalized-object path when available. The descriptor marks that path read-only and warns that normalization or request projection may have resized or re-encoded the upload. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image keeps its own identity and available local path in replacement text. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content.
#### Token effect
diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md
index 10f366659a..f11d4ba1d9 100644
--- a/packages/llm/llm-pi-ai/README.zh.md
+++ b/packages/llm/llm-pi-ai/README.zh.md
@@ -124,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示
**没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。
-受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界替换超预算的最旧图片;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际请求图片尺寸。若已配置标头中有同名项,则以 Harness 应用归因为准。
+受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界把超预算的最旧图片替换为逐图文本;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID、实际请求图片尺寸,以及当前提供方支持时的规范化对象路径。若已配置标头中有同名项,则以 Harness 应用归因为准。
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。
@@ -174,7 +174,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK
#### 模型看到的内容
-所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。
+所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸,以及当前可用的规范化对象路径。描述会把该路径标记为只读,并说明规范化或请求投影可能缩小或重新编码上传图片。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,每张被 offload 的图片会在替代文本中保留自己的身份和可用本地路径。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。
#### Token 影响
diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts
index 9faf457c9a..db99f86d8e 100644
--- a/packages/llm/llm-pi-ai/src/context.ts
+++ b/packages/llm/llm-pi-ai/src/context.ts
@@ -4,7 +4,7 @@
* @module dsh-llm-pi-ai/context
*/
-import { CallId, contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm'
+import { CallId, contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type {
AttachmentId,
@@ -57,7 +57,7 @@ async function userContent(
break
case 'image': {
const version = requestImages.get(block.attachment.attachmentId) as RequestImageAttachment
- content.push({ type: 'text', text: requestImageHandleText(version) })
+ content.push({ type: 'text', text: requestImageHandleText(block.attachment, version) })
content.push({
type: 'image',
data: Buffer.from(version.data).toString('base64'),
@@ -231,6 +231,7 @@ async function toPiContextWithImages(
...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes },
byteQuantum: 1,
byteLength: ref => Math.min(ref.bytes, requestImagePolicy.maxBytes),
+ placeholder: ref => offloadedImageText(ref, attachments.imageAccess(ref)),
})
const requestImages = await prepareRequestImages(requestMessages, attachments, requestImagePolicy, options.signal)
const exactMessages = offloadRequestImagesWithPolicy(requestMessages, {
@@ -238,6 +239,7 @@ async function toPiContextWithImages(
...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes },
byteQuantum: 1,
byteLength: ref => (requestImages.get(ref.attachmentId) as RequestImageAttachment).bytes,
+ placeholder: ref => offloadedImageText(ref, requestImages.get(ref.attachmentId)?.access),
})
const toolNames = new Map()
const messages: PiMessage[] = []
diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts
index 026ca2c608..a971b80c85 100644
--- a/packages/llm/llm-pi-ai/tests/context.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/context.spec.ts
@@ -6,7 +6,7 @@ import type {
ImageRequestPolicy,
RequestImageAttachment,
} from '@deepseek-ai/dsh-attachment'
-import { CallId, createMessage, createUserMessage, OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm'
+import { CallId, createMessage, createUserMessage, offloadedImageText } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { toPiContext } from '../src/context.ts'
import { toPiAssistant } from '../src/replay.ts'
@@ -43,7 +43,7 @@ function projectionStore(
Promise.resolve(requestImage(value, Uint8Array.of(1)))
)),
): AttachmentStore {
- return { readImageRequest } as unknown as AttachmentStore
+ return { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore
}
const attachments = projectionStore()
@@ -174,6 +174,26 @@ describe('pi-ai request context conversion', () => {
])
})
+ it('uses the shared normalized-path description for retained images', async () => {
+ const named = { ...ref, name: 'chart.png', width: 2048, height: 1024 }
+ const store = projectionStore(value => Promise.resolve({
+ ...requestImage(value, Uint8Array.of(1)),
+ width: 1130,
+ height: 565,
+ access: { readonlyPath: '/tmp/dsh/objects/aa/object' },
+ }))
+ const context = await toPiContext(request([user([{ type: 'image', attachment: named }])]), store)
+ expect(context.messages[0]).toMatchObject({
+ role: 'user',
+ content: [
+ { type: 'text', text: expect.stringContaining('Image "chart.png"') as string },
+ { type: 'image' },
+ ],
+ })
+ expect(JSON.stringify(context.messages[0])).toContain('/tmp/dsh/objects/aa/object')
+ expect(JSON.stringify(context.messages[0])).toContain('request preview 1130x565px')
+ })
+
it('recursively converts nested tool-result text and images', async () => {
const callId = CallId('nested-call')
const context = await toPiContext(request([user([{
@@ -252,7 +272,7 @@ describe('pi-ai request context conversion', () => {
role: 'toolResult',
toolCallId: 'shot-call',
toolName: 'unknown',
- content: [{ type: 'text', text: OFFLOADED_IMAGE_TEXT }],
+ content: [{ type: 'text', text: offloadedImageText(sized) }],
isError: false,
timestamp: 0,
},
@@ -293,7 +313,7 @@ describe('pi-ai request context conversion', () => {
expect(context.messages[0]).toMatchObject({
role: 'user',
content: [
- { type: 'text', text: OFFLOADED_IMAGE_TEXT },
+ { type: 'text', text: offloadedImageText(old) },
{ type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string },
{ type: 'image' },
],
@@ -302,6 +322,26 @@ describe('pi-ai request context conversion', () => {
expect(readImageRequest.mock.calls[0]?.[0]).toEqual(recent)
})
+ it('uses the generated request access when exact encoded bytes require offload', async () => {
+ const sized: ImageAttachmentRef = { ...ref, bytes: 3 }
+ const access = { readonlyPath: '/tmp/dsh-normalized-image' }
+ const readImageRequest = vi.fn((value: ImageAttachmentRef) => Promise.resolve({
+ ...requestImage(value, Uint8Array.of(1, 2, 3, 4)),
+ access,
+ }))
+
+ const context = await toPiContext(request([
+ user([{ type: 'image', attachment: sized }]),
+ ]), projectionStore(readImageRequest), undefined, 4)
+
+ expect(context.messages).toEqual([{
+ role: 'user',
+ content: offloadedImageText(sized, access),
+ timestamp: 0,
+ }])
+ expect(readImageRequest).toHaveBeenCalledTimes(1)
+ })
+
it('keeps every image at exactly the payload bound and drops all of them when even the newest cannot fit', async () => {
const sized: ImageAttachmentRef = { ...ref, bytes: 3 }
const exact = await toPiContext(request([
@@ -330,7 +370,7 @@ describe('pi-ai request context conversion', () => {
]), store, undefined, 8)
// All-text content collapses to the string form; the placeholder still reaches the model.
expect(oversized.messages).toEqual([
- { role: 'user', content: OFFLOADED_IMAGE_TEXT, timestamp: 0 },
+ { role: 'user', content: offloadedImageText({ ...ref, bytes: 300 }), timestamp: 0 },
])
expect(readImageRequest).not.toHaveBeenCalled()
})
@@ -351,7 +391,7 @@ describe('pi-ai request context conversion', () => {
const expected = [{
role: 'user',
content: [
- { type: 'text', text: OFFLOADED_IMAGE_TEXT },
+ { type: 'text', text: offloadedImageText(sized) },
{ type: 'text', text: expect.stringContaining(`Image ${sized.attachmentId}`) as string },
{ type: 'image', data: 'AQID', mimeType: 'image/png' },
],
diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts
index ed4df11a4d..1d53dbdab0 100644
--- a/packages/llm/llm-pi-ai/tests/convert.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts
@@ -63,7 +63,7 @@ function attachmentStore(readImageRequest: (
policy: ImageRequestPolicy,
signal?: AbortSignal,
) => Promise): AttachmentStore {
- return { readImageRequest } as unknown as AttachmentStore
+ return { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore
}
describe('toPiContext', () => {
diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml
index 08820bbfd9..3ac89ec262 100644
--- a/packages/llm/llm/README.i18n.yaml
+++ b/packages/llm/llm/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
-README.md: 59c5303bdae8d6391f3bb1595a527d677e2378bc
-README.zh.md: 313fc23590de2119bf07dd5c4c4fafe9daa94c56
+README.md: d08d28497c4821c578fac43515b3bebfa4ed8131
+README.zh.md: 3d7a183aa2319acd869d8e4bc68ec9493c3ea322
diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md
index 59c5303bda..d08d28497c 100644
--- a/packages/llm/llm/README.md
+++ b/packages/llm/llm/README.md
@@ -57,7 +57,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o
Message content is an array of typed blocks: `text`, `reasoning`, `image`, `tool-call`, `tool-result`. An `ImageBlock` carries only a durable `ImageAttachmentRef`; provider bytes and request dimensions are resolved later. The union remains merge-extensible through `ContentBlockMap`, so plugins can add further block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers.
-Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length.
+Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length and the required per-image placeholder text.
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. A successful `finish` may carry a `ReplayEnvelope` — opaque response-level replay metadata plus optional per-block entries aligned with the emitted block sequence. Assembly makes one keep/drop decision for content and metadata together: a `max-tokens` finish drops tool calls that may have been truncated, and the envelope loses the entry at each dropped position, so stored metadata always describes stored content.
@@ -91,11 +91,11 @@ Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-l
## Model Experience
-None, as the service adds no model-bound text, schema, or message; it only materializes and logs an adapter-configured reasoning effort.
+None, as adapters choose when to add the shared image descriptors and per-image placeholders exported by this package, while the LLM service itself only materializes and logs adapter-configured request facts.
#### KV Cache effect
-Pass-through; the registry preserves the assembled request prefix, while the selected adapter and provider own cache reuse and routing boundaries.
+Reasoning-effort materialization preserves the assembled request prefix. Image descriptors add deterministic text beside each image, and a request-limit transition replaces the deterministic oldest prefix with per-image text.
## Known Limitations and Deferred Work
diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md
index 313fc23590..3d7a183aa2 100644
--- a/packages/llm/llm/README.zh.md
+++ b/packages/llm/llm/README.zh.md
@@ -57,7 +57,7 @@
消息内容是类型化内容块数组:`text`、`reasoning`、`image`、`tool-call`、`tool-result`。`ImageBlock` 只携带持久 `ImageAttachmentRef`;提供方字节和请求尺寸之后再解析。联合仍从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加其他块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型或提供方间恢复或转换该状态。
-每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度。
+每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度和必填的逐图占位文本。
流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。成功的 `finish` 可以携带 `ReplayEnvelope`——不透明的响应级回放元数据,加上与发射块序列对齐的可选逐块条目。组装对内容与元数据只做一次保留/丢弃决定:`max-tokens` 结束会丢弃可能被截断的工具调用,数据在每个被丢弃的位置同步失去对应条目,因此存储的元数据始终描述存储的内容。
@@ -93,11 +93,11 @@
## 模型体验
-无。服务不添加任何与模型绑定的文本、schema 或消息;它只会填入并记录适配器配置的推理强度。
+无。适配器决定何时加入该包导出的共用图片描述和逐图占位文本,LLM 服务本身只会填入并记录适配器配置的请求事实。
#### KV Cache 影响
-透传;注册表保留已组装请求前缀,cache 复用与路由边界属于所选适配器和提供方。
+推理强度填入不会改变已组装的请求前缀。图片描述会在每张图片旁加入确定性文本;请求越过上限时,确定性的最旧前缀会替换为逐图文本。
diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts
index 4620275429..10076ad529 100644
--- a/packages/llm/llm/src/content.ts
+++ b/packages/llm/llm/src/content.ts
@@ -2,15 +2,38 @@
import type { ContentBlock } from './types.ts'
import type { Message } from './message.ts'
-import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
+import type { ImageAttachmentAccess, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
+import { assertNever } from './never.ts'
-/** Model-facing stand-in for an image removed to fit a provider request bound. */
-export const OFFLOADED_IMAGE_TEXT
- = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]'
+function quoted(value: string): string {
+ return JSON.stringify(value)
+}
+
+function imageIdentity(ref: ImageAttachmentRef): string {
+ return ref.name === undefined
+ ? String(ref.attachmentId)
+ : `${quoted(ref.name)} (${ref.attachmentId})`
+}
+
+function extension(mediaType: ImageMediaType): string {
+ switch (mediaType) {
+ case 'image/png': return '.png'
+ case 'image/jpeg': return '.jpg'
+ case 'image/webp': return '.webp'
+ case 'image/gif': return '.gif'
+ default: return assertNever(mediaType, 'image extension')
+ }
+}
+
+function normalizedAccessText(ref: ImageAttachmentRef, access: ImageAttachmentAccess): string {
+ return ` Normalized copy (read-only; may be resized or re-encoded): ${quoted(access.readonlyPath)} (${ref.width}x${ref.height}px, ${ref.mediaType}).`
+ + ' Source dimensions, format, and byte size may differ.'
+ + ` Copy to a writable path ending in ${extension(ref.mediaType)} before editing.`
+}
/**
* Stable text shown to a model that cannot accept one durable image reference.
- * @param ref - durable master reference omitted from the request.
+ * @param ref - durable normalized attachment omitted from the request.
* @returns deterministic text-only placeholder.
*/
export function textOnlyImageText(ref: ImageAttachmentRef): string {
@@ -19,12 +42,36 @@ export function textOnlyImageText(ref: ImageAttachmentRef): string {
}
/**
- * Stable model-facing handle for one exact request image.
+ * Stable model-facing handle for one exact request image. Identity comes from
+ * the occurrence's own durable reference: request versions are prepared per
+ * attachment id, so one shared version may serve occurrences whose display
+ * names differ.
+ * @param ref - the occurrence's durable normalized attachment.
* @param version - exact request image shown beside the text.
* @returns attachment handle and request-image dimensions.
*/
-export function requestImageHandleText(version: RequestImageAttachment): string {
- return `Image ${version.attachment.attachmentId}; request image ${version.width}x${version.height}px.`
+export function requestImageHandleText(ref: ImageAttachmentRef, version: RequestImageAttachment): string {
+ const preview = `Image ${imageIdentity(ref)}; request preview ${version.width}x${version.height}px.`
+ return version.access === undefined
+ ? `${preview} It may be resized or re-encoded; source dimensions, format, and byte size may differ.`
+ : preview + normalizedAccessText(ref, version.access)
+}
+
+/**
+ * Stable per-image placeholder for a request-limit omission.
+ * @param ref - durable normalized attachment omitted from this request.
+ * @param access - optional provider-resolved path for model tools.
+ * @returns identity, normalized metadata, and the available recovery path.
+ */
+export function offloadedImageText(
+ ref: ImageAttachmentRef,
+ access?: ImageAttachmentAccess,
+): string {
+ const identity = `image omitted to fit request image limits; ${imageIdentity(ref)}.`
+ if (access === undefined) {
+ return `[${identity} No local normalized image path is available; ask the user to attach it again if needed.]`
+ }
+ return `[${identity}${normalizedAccessText(ref, access)}]`
}
/**
@@ -57,8 +104,10 @@ export interface RequestImageOffloadPolicy {
byteQuantum?: number
/** Whether byte accounting uses raw file bytes or inline base64 length. */
representation: 'raw' | 'base64'
- /** Resolve the encoded request-version length; omission uses master attachment bytes. */
+ /** Resolve the encoded request-version length; omission uses normalized attachment bytes. */
byteLength?: (ref: ImageAttachmentRef) => number
+ /** Build the model-visible replacement for each omitted attachment. */
+ placeholder: (ref: ImageAttachmentRef) => string
}
/** Collect represented image lengths in request and nested-block order. */
@@ -83,17 +132,18 @@ function collectImageLengths(
function replaceOldestImages(
blocks: readonly ContentBlock[],
remaining: { count: number },
+ placeholder: (ref: ImageAttachmentRef) => string,
): ContentBlock[] {
let next: ContentBlock[] | undefined
for (const [index, block] of blocks.entries()) {
if (block.type === 'image' && remaining.count > 0) {
remaining.count -= 1
next ??= blocks.slice(0, index)
- next.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT })
+ next.push({ type: 'text', text: placeholder(block.attachment) })
continue
}
if (block.type === 'tool-result') {
- const content = replaceOldestImages(block.content, remaining)
+ const content = replaceOldestImages(block.content, remaining, placeholder)
if (content !== block.content) {
next ??= blocks.slice(0, index)
next.push({ ...block, content })
@@ -140,26 +190,6 @@ export function projectImagesForTextModel(messages: readonly Message[]): readonl
})
}
-/**
- * Return transient request messages whose oldest images are replaced until
- * their accumulated base64 payload fits the configured bound. The selection
- * is deterministic from durable message order and attachment metadata; a
- * provider can serialize the returned messages without reading omitted bytes.
- * @param messages - complete request history, oldest first.
- * @param maxRequestImageBytes - positive bound on total base64 image payload; undefined preserves every image.
- * @returns the original messages when they already fit, otherwise shallow message copies with replaced content trees.
- */
-export function offloadRequestImages(
- messages: readonly Message[],
- maxRequestImageBytes: number | undefined,
-): readonly Message[] {
- return offloadRequestImagesWithPolicy(messages, {
- representation: 'base64',
- ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes },
- byteQuantum: 1,
- })
-}
-
/**
* Return a deterministic transient projection whose oldest images are replaced
* in whole count and byte quanta after a route budget is exceeded. The target
@@ -196,7 +226,7 @@ export function offloadRequestImagesWithPolicy(
}
const remaining = { count }
return messages.map((message) => {
- const content = replaceOldestImages(message.content, remaining)
+ const content = replaceOldestImages(message.content, remaining, policy.placeholder)
return content === message.content ? message : { ...message, content }
})
}
diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts
index 6a0eb02c63..236cee2297 100644
--- a/packages/llm/llm/tests/content.spec.ts
+++ b/packages/llm/llm/tests/content.spec.ts
@@ -1,18 +1,30 @@
import { describe, expect, it } from 'vitest'
-import { AttachmentId } from '@deepseek-ai/dsh-attachment'
+import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
+import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
import {
CallId,
createUserMessage,
- OFFLOADED_IMAGE_TEXT,
- offloadRequestImages,
+ offloadedImageText,
offloadRequestImagesWithPolicy,
projectImagesForTextModel,
+ requestImageHandleText,
} from '../src/index.ts'
-import type { ContentBlock } from '../src/index.ts'
+import type { ContentBlock, Message } from '../src/index.ts'
const source = { kind: 'plugin' as const, plugin: 'test' }
-function image(bytes: number): ContentBlock {
+const OMITTED = '[omitted]'
+
+function offloadBase64(messages: readonly Message[], maxBytes: number | undefined): readonly Message[] {
+ return offloadRequestImagesWithPolicy(messages, {
+ representation: 'base64',
+ ...maxBytes === undefined ? {} : { maxBytes },
+ byteQuantum: 1,
+ placeholder: () => OMITTED,
+ })
+}
+
+function image(bytes: number): Extract {
return {
type: 'image',
attachment: {
@@ -25,15 +37,15 @@ function image(bytes: number): ContentBlock {
}
}
-describe('offloadRequestImages', () => {
+describe('base64 request-image offload', () => {
it('preserves every image when no payload bound is configured', () => {
const messages = [createUserMessage({ content: [image(300)], source })]
- expect(offloadRequestImages(messages, undefined)).toBe(messages)
+ expect(offloadBase64(messages, undefined)).toBe(messages)
})
it('preserves the original request when its base64 payload fits exactly', () => {
const messages = [createUserMessage({ content: [image(3), image(3)], source })]
- expect(offloadRequestImages(messages, 8)).toBe(messages)
+ expect(offloadBase64(messages, 8)).toBe(messages)
})
it('keeps five 3 MiB images at 20 MiB and offloads the oldest after one more raw byte', () => {
@@ -43,14 +55,14 @@ describe('offloadRequestImages', () => {
content: Array.from({ length: 5 }, () => image(rawImageBytes)),
source,
})]
- expect(offloadRequestImages(exact, maxRequestImageBytes)).toBe(exact)
+ expect(offloadBase64(exact, maxRequestImageBytes)).toBe(exact)
const over = [createUserMessage({
content: [image(rawImageBytes + 1), ...Array.from({ length: 4 }, () => image(rawImageBytes))],
source,
})]
- expect(offloadRequestImages(over, maxRequestImageBytes)[0]?.content).toEqual([
- { type: 'text', text: OFFLOADED_IMAGE_TEXT },
+ expect(offloadBase64(over, maxRequestImageBytes)[0]?.content).toEqual([
+ { type: 'text', text: OMITTED },
...Array.from({ length: 4 }, () => image(rawImageBytes)),
])
})
@@ -69,12 +81,12 @@ describe('offloadRequestImages', () => {
createUserMessage({ content: [shared, image(3)], source }),
]
- const fitted = offloadRequestImages(messages, 8)
+ const fitted = offloadBase64(messages, 8)
expect(fitted).not.toBe(messages)
expect(fitted[0]?.content).toEqual([{
type: 'tool-result',
toolCallId: CallId('shot'),
- content: [{ type: 'text', text: OFFLOADED_IMAGE_TEXT }],
+ content: [{ type: 'text', text: OMITTED }],
}])
expect(fitted[1]?.content).toEqual([shared, image(3)])
expect(messages[0]?.content[0]).toMatchObject({ type: 'tool-result', content: [shared] })
@@ -82,8 +94,8 @@ describe('offloadRequestImages', () => {
it('replaces a single image that cannot fit', () => {
const messages = [createUserMessage({ content: [image(300)], source })]
- expect(offloadRequestImages(messages, 8)[0]?.content)
- .toEqual([{ type: 'text', text: OFFLOADED_IMAGE_TEXT }])
+ expect(offloadBase64(messages, 8)[0]?.content)
+ .toEqual([{ type: 'text', text: OMITTED }])
})
it('keeps unchanged nested content while replacing a later image', () => {
@@ -93,9 +105,9 @@ describe('offloadRequestImages', () => {
content: [{ type: 'text' as const, text: 'kept' }],
}
const messages = [createUserMessage({ content: [nested, image(3)], source })]
- expect(offloadRequestImages(messages, 1)[0]?.content).toEqual([
+ expect(offloadBase64(messages, 1)[0]?.content).toEqual([
nested,
- { type: 'text', text: OFFLOADED_IMAGE_TEXT },
+ { type: 'text', text: OMITTED },
])
})
})
@@ -109,6 +121,7 @@ describe('offloadRequestImagesWithPolicy', () => {
representation: 'raw',
maxBytes: 128 * mib,
byteQuantum: 64 * mib,
+ placeholder: () => OMITTED,
})[0]?.content
expect(project(128)?.filter(block => block.type === 'image')).toHaveLength(128)
@@ -124,6 +137,7 @@ describe('offloadRequestImagesWithPolicy', () => {
representation: 'raw',
maxImages: 600,
countQuantum: 20,
+ placeholder: () => OMITTED,
})
expect(projected[0]?.content.filter(block => block.type === 'text')).toHaveLength(20)
expect(projected[0]?.content.filter(block => block.type === 'image')).toHaveLength(581)
@@ -135,12 +149,135 @@ describe('offloadRequestImagesWithPolicy', () => {
representation: 'raw',
maxBytes: 3,
byteLength: () => 2,
+ placeholder: () => OMITTED,
})
expect(projected[0]?.content).toEqual([
- { type: 'text', text: OFFLOADED_IMAGE_TEXT },
+ { type: 'text', text: OMITTED },
image(100),
])
})
+
+ it('builds a distinct placeholder from each omitted attachment', () => {
+ const first = image(3)
+ const second = image(3)
+ first.attachment = { ...first.attachment, name: 'first.png' }
+ second.attachment = { ...second.attachment, name: 'second.png' }
+ const projected = offloadRequestImagesWithPolicy([
+ createUserMessage({ content: [first, second], source }),
+ ], {
+ representation: 'raw',
+ maxBytes: 3,
+ placeholder: ref => `omitted:${ref.name}`,
+ })
+ expect(projected[0]?.content).toEqual([
+ { type: 'text', text: 'omitted:first.png' },
+ second,
+ ])
+ })
+})
+
+describe('model-facing image access', () => {
+ it('describes the request preview, immutable normalized path, and source uncertainty', () => {
+ const attachment = {
+ attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`),
+ mediaType: 'image/png' as const,
+ bytes: 4_000,
+ width: 2048,
+ height: 1536,
+ name: 'source "map".png',
+ }
+ const version = {
+ variantId: ImageVariantId(`sha256:${'c'.repeat(64)}`),
+ attachment,
+ access: { readonlyPath: '/tmp/.dsh/attachments/v1/objects/bb/object' },
+ data: Uint8Array.of(1),
+ mediaType: 'image/png' as const,
+ bytes: 1,
+ width: 923,
+ height: 692,
+ depth: 'uchar' as const,
+ space: 'srgb' as const,
+ hasAlpha: true,
+ }
+ expect(requestImageHandleText(attachment, version)).toBe(
+ `Image "source \\"map\\".png" (${attachment.attachmentId}); request preview 923x692px.`
+ + ' Normalized copy (read-only; may be resized or re-encoded): "/tmp/.dsh/attachments/v1/objects/bb/object" (2048x1536px, image/png).'
+ + ' Source dimensions, format, and byte size may differ.'
+ + ' Copy to a writable path ending in .png before editing.',
+ )
+ })
+
+ it('names each occurrence from its own reference when one prepared version is shared', () => {
+ const attachment = {
+ attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`),
+ mediaType: 'image/png' as const,
+ bytes: 4_000,
+ width: 8,
+ height: 8,
+ name: 'second.png',
+ }
+ const version = {
+ variantId: ImageVariantId(`sha256:${'c'.repeat(64)}`),
+ attachment,
+ data: Uint8Array.of(1),
+ mediaType: 'image/png' as const,
+ bytes: 1,
+ width: 8,
+ height: 8,
+ depth: 'uchar' as const,
+ space: 'srgb' as const,
+ hasAlpha: false,
+ }
+ expect(requestImageHandleText({ ...attachment, name: 'first.png' }, version))
+ .toContain('"first.png"')
+ })
+
+ it('keeps a useful omission identity with and without a local path', () => {
+ const ref = {
+ attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`),
+ mediaType: 'image/jpeg' as const,
+ bytes: 10,
+ width: 10,
+ height: 5,
+ name: 'photo.jpg',
+ }
+ expect(offloadedImageText(ref)).toContain('No local normalized image path is available')
+ expect(offloadedImageText(ref, { readonlyPath: '/tmp/object' })).toBe(
+ `[image omitted to fit request image limits; "photo.jpg" (${ref.attachmentId}).`
+ + ' Normalized copy (read-only; may be resized or re-encoded): "/tmp/object" (10x5px, image/jpeg).'
+ + ' Source dimensions, format, and byte size may differ.'
+ + ' Copy to a writable path ending in .jpg before editing.]',
+ )
+ })
+
+ it.each([
+ ['image/png', '.png'],
+ ['image/jpeg', '.jpg'],
+ ['image/webp', '.webp'],
+ ['image/gif', '.gif'],
+ ] as const)('names the writable extension for %s', (mediaType, suffix) => {
+ const ref = {
+ attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`),
+ mediaType,
+ bytes: 1,
+ width: 1,
+ height: 1,
+ }
+ expect(offloadedImageText(ref, { readonlyPath: '/tmp/object' }))
+ .toContain(`writable path ending in ${suffix}`)
+ })
+
+ it('rejects a media type that escaped the closed union at runtime', () => {
+ const ref = {
+ attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`),
+ mediaType: 'image/tiff' as unknown as ImageMediaType,
+ bytes: 1,
+ width: 1,
+ height: 1,
+ }
+ expect(() => offloadedImageText(ref, { readonlyPath: '/tmp/object' }))
+ .toThrow('unreachable variant in image extension: "image/tiff"')
+ })
})
describe('projectImagesForTextModel', () => {
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index d8bed47236..31217c1f31 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -335,6 +335,7 @@ export const LINK_MAP: Readonly> = {
ApprovalService: 'approval.md',
AskUserQuestionRequestEvent: 'user-questions.md',
EncodedImageAttachment: 'attachment.md',
+ ImageAttachmentAccess: 'attachment.md',
ImageAttachmentRef: 'attachment.md',
ImageRequestPolicy: 'attachment.md',
RequestImageAttachment: 'attachment.md',
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index 06cce11143..0195425c04 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -931,6 +931,11 @@
"symbol": "StoredImageAttachment",
"source": "packages/attachment/attachment/src/types.ts"
},
+ {
+ "doc": "docs/subsystems/attachment.md",
+ "symbol": "ImageAttachmentAccess",
+ "source": "packages/attachment/attachment/src/types.ts"
+ },
{
"doc": "docs/subsystems/attachment.md",
"symbol": "ImageRequestPolicy",