Преглед изворни кода

refactor(attachment): 求解器留在 llm-deepseek,存储层只接收目标尺寸

ImageRequestPolicy 改为 ImageRequestTarget,只含目标宽、高和字节目标;dsh-attachment 删除 token-grid 投影联合和 tokenGridProjection,只保留提供方无关的 requestImageDimensions 与 longEdgeDimensions。v41 求解器回到 llm-deepseek 的 image-tokens.ts,新增 deepSeekRequestImageDimensions,resolveRequestImageTarget 为每张图算出目标并加 4096 单边上限;pi-ai 路由按自身像素预算推导目标。

按 review 意见修正:v41 计价 Note 里已删除常量的过期句子就地改写;ProjectedDimensions 登记到 attachment 子系统页与 type-equiv 清单;Note 区分发送尺寸与提供方补齐后的网格;短边取整的措辞改为「按路由预测」。
creatixchu пре 1 недеља
родитељ
комит
ba30b73f7b
52 измењених фајлова са 460 додато и 549 уклоњено
  1. 2 2
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml
  2. 1 1
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md
  3. 1 1
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md
  4. 2 2
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.i18n.yaml
  5. 6 6
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md
  6. 6 6
      .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md
  7. 2 2
      docs/config-catalog.i18n.yaml
  8. 1 1
      docs/config-catalog.md
  9. 1 1
      docs/config-catalog.zh.md
  10. 2 2
      docs/subsystems/attachment.i18n.yaml
  11. 10 40
      docs/subsystems/attachment.md
  12. 13 43
      docs/subsystems/attachment.zh.md
  13. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  14. 1 1
      docs/subsystems/llm-streaming.md
  15. 1 1
      docs/subsystems/llm-streaming.zh.md
  16. 2 2
      packages/attachment/attachment-local/README.i18n.yaml
  17. 1 1
      packages/attachment/attachment-local/README.md
  18. 1 1
      packages/attachment/attachment-local/README.zh.md
  19. 6 6
      packages/attachment/attachment-local/src/index.ts
  20. 8 4
      packages/attachment/attachment-local/src/normalization.ts
  21. 31 54
      packages/attachment/attachment-local/src/request-image.ts
  22. 1 1
      packages/attachment/attachment-local/tests/index.spec.ts
  23. 1 1
      packages/attachment/attachment-local/tests/request-image-verification.spec.ts
  24. 33 38
      packages/attachment/attachment-local/tests/request-image.spec.ts
  25. 2 2
      packages/attachment/attachment/README.i18n.yaml
  26. 2 2
      packages/attachment/attachment/README.md
  27. 2 2
      packages/attachment/attachment/README.zh.md
  28. 6 8
      packages/attachment/attachment/src/index.ts
  29. 25 101
      packages/attachment/attachment/src/request-projection.ts
  30. 4 38
      packages/attachment/attachment/src/types.ts
  31. 4 4
      packages/attachment/attachment/tests/index.spec.ts
  32. 14 56
      packages/attachment/attachment/tests/request-projection.spec.ts
  33. 4 8
      packages/extensions/tool-cordis/src/api-catalog.ts
  34. 2 2
      packages/llm/llm-deepseek/README.i18n.yaml
  35. 1 1
      packages/llm/llm-deepseek/README.md
  36. 1 1
      packages/llm/llm-deepseek/README.zh.md
  37. 5 6
      packages/llm/llm-deepseek/src/adapter.ts
  38. 127 27
      packages/llm/llm-deepseek/src/image-tokens.ts
  39. 3 2
      packages/llm/llm-deepseek/src/index.ts
  40. 37 25
      packages/llm/llm-deepseek/src/request-pricing.ts
  41. 2 2
      packages/llm/llm-deepseek/tests/adapter.e2e.ts
  42. 19 12
      packages/llm/llm-deepseek/tests/adapter.spec.ts
  43. 2 2
      packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
  44. 24 1
      packages/llm/llm-deepseek/tests/image-tokens.spec.ts
  45. 1 1
      packages/llm/llm-pi-ai/src/adapter.ts
  46. 19 5
      packages/llm/llm-pi-ai/src/context.ts
  47. 5 4
      packages/llm/llm-pi-ai/tests/adapter.spec.ts
  48. 2 2
      packages/llm/llm-pi-ai/tests/context.spec.ts
  49. 5 5
      packages/llm/llm-pi-ai/tests/convert.spec.ts
  50. 2 2
      packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts
  51. 2 1
      scripts/gen-cordis-catalog.ts
  52. 3 8
      scripts/type-equiv.manifest.json

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.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/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md
-2026-09-10-deepseek-image-token-calculator-v41.md: 4e57118987ddb7c2e2574d0dc40b02d8acf49e1a
-2026-09-10-deepseek-image-token-calculator-v41.zh.md: 209ddb99616d5d94fd4f390706304a722137bd15
+2026-09-10-deepseek-image-token-calculator-v41.md: 668738221ebe3e634be7e4fe9d07c9627e0ceb4a
+2026-09-10-deepseek-image-token-calculator-v41.zh.md: a483282c6da667c2b8e32accb091e065419d44fc

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md

@@ -12,7 +12,7 @@ English | [中文](2026-09-10-deepseek-image-token-calculator-v41.zh.md)
 
 `image-tokens.ts` is rewritten as a verbatim port of the `v41` configuration. The constants are a 14px patch, 3:1 per-axis downsampling, a 544×544 total-pixel floor, and a 1024-token cap. The grid formula is `rows × (cols + 1) + 2` with no odd-row extra row, no parity correction, and no even-row trimming in the solver. There is no alignment pad, so the estimate is exact rather than a worst-case upper bound, and there is no aspect-ratio clamp, so extreme aspect ratios reach the cap through the solver's one-row and one-column branches. The over-budget path is a single closed-form solve followed by the published assertion; the decrementing retry loop existed only for the odd-row layout. The provider's fixpoint iteration over the projected dimensions is unchanged.
 
-The test vectors are re-pinned from the published calculator. The request-pricing tests, package README, and this note carry the new numbers; the pixel budget the harness applies before pricing (`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`, 640,000 total pixels) and the catalog model ids are unchanged.
+The test vectors are re-pinned from the published calculator. The request-pricing tests, package README, and this note carry the new numbers. This change left the 640,000 total-pixel projection the harness applied before pricing and the catalog model ids as they were; the [successor](2026-09-10-deepseek-v41-request-image-projection.md) later replaced that projection with the same grid, so omitting `imagePixelBudget` now projects onto the token grid while a positive integer or `low` keeps a total-pixel budget.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 `image-tokens.ts` 重写为 `v41` 配置的逐句移植。常量为 14px patch、每轴 3:1 降采样、544×544 总像素下限、1024 token 上限。网格公式为 `rows × (cols + 1) + 2`,没有奇数行额外行、没有奇偶校正、求解器也不再把行数截成偶数。没有对齐 pad,所以估算值是精确值而非最坏情况上界;没有宽高比钳制,所以极端长宽比会经求解器的单行和单列分支到达上限。超预算路径是一次闭式求解加上公开的断言;逐步递减的重试循环只服务于奇数行布局。提供方对投影尺寸的定点迭代保持不变。
 
-测试向量按公开计算器重新固定。request-pricing 测试、包 README 和本 note 使用新数字;harness 在定价前应用的像素预算(`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`,640,000 总像素)和 catalog 模型 id 不变
+测试向量按公开计算器重新固定。request-pricing 测试、包 README 和本 note 使用新数字。本次改动保留了 harness 在定价前应用的 640,000 总像素投影和 catalog 模型 id;[后续决策](2026-09-10-deepseek-v41-request-image-projection.zh.md)把该投影换成了同一套网格,现在省略 `imagePixelBudget` 走 token 网格,正整数或 `low` 仍走总像素预算
 
 ## 备选方案
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.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/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md
-2026-09-10-deepseek-v41-request-image-projection.md: 32b1eb4251ec12005879551bfad363a98034291b
-2026-09-10-deepseek-v41-request-image-projection.zh.md: 0360dcc655e1b366ca7169784b63e2650ee3dd09
+2026-09-10-deepseek-v41-request-image-projection.md: 87f79f244dcf61039fe27ef170ba654ee7a1a62a
+2026-09-10-deepseek-v41-request-image-projection.zh.md: a5d8a4e99a3c7b4f3fc26f385ae1e764b535fe15

+ 6 - 6
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md

@@ -6,26 +6,26 @@ English | [中文](2026-09-10-deepseek-v41-request-image-projection.zh.md)
 
 ## Problem
 
-The harness projected every DeepSeek request image under a 640,000 total-pixel budget, a value chosen for the retired V4 vision model and kept unchanged when the [token estimator moved to the `v41` calculator](2026-09-10-deepseek-image-token-calculator-v41.md). The current Flash model retains far more: it pads each edge to whole 14px patches, groups 3×3 patches into one token cell, and keeps the largest aspect-preserving grid whose token count `rows × (columns + 1) + 2` fits 1024. A square image keeps 1302×1302 pixels, a 16:9 image keeps 1708×966, and extreme aspect ratios keep up to about 1.8 million pixels. The 640,000-pixel projection therefore sent a 2000×2000 screenshot as 800×800, roughly 38% of the pixels the model would have used, and the estimator priced that reduced version at 422 tokens instead of the 994 the model charges for the full grid. The Vision guide's "about 1300×1300 total pixels" describes only the square case; the exact rule is the token grid.
+The harness projected every DeepSeek request image under a 640,000 total-pixel budget, a value chosen for the retired V4 vision model and kept unchanged when the [token estimator moved to the `v41` calculator](2026-09-10-deepseek-image-token-calculator-v41.md). The current Flash model retains far more: it pads each edge to whole 14px patches, groups 3×3 patches into one token cell, and keeps the largest aspect-preserving grid whose token count `rows × (columns + 1) + 2` fits 1024. A square image keeps 1302×1302 pixels, a 16:9 image keeps a 1708×966 grid, and extreme aspect ratios keep up to about 1.8 million pixels. The 640,000-pixel projection therefore sent a 2000×2000 screenshot as 800×800, roughly 38% of the pixels the model would have used, and the estimator priced that reduced version at 422 tokens instead of the 994 the model charges for the full grid. The Vision guide's "about 1300×1300 total pixels" describes only the square case; the exact rule is the token grid.
 
 Two smaller gaps sat beside it. The request version had no per-side cap: the provider rejects any image over 4096 pixels per side once a request carries 15 or more images, while normalization admits an 8192-pixel long edge, so a many-image session could fail on one thin image. The 1 MiB encoded-byte target was sized for 640,000-pixel outputs and would push a 1302×1302 photograph down the JPEG quality ladder.
 
 ## Decision
 
-`ImageRequestPolicy` in `dsh-attachment` becomes a projection rule plus an optional per-side cap plus the byte target. The projection is a closed union: `pixel-budget` keeps the previous total-pixel geometry, and `token-grid` carries the patch size, downsampling ratio, and token cap of the provider grid. `request-projection.ts` owns the grid solver as `tokenGridProjection`, and `deepSeekImageTokens` in `llm-deepseek` reuses it for pricing, so the projection that sizes the request and the estimator that prices it are one solver. `requestImageDimensions` applies the projection, then the per-side cap, and keeps the source long edge exact while rounding the short edge; the local store resizes by the long edge only, so the encoded dimensions equal the predicted ones and the pricing handle text names what is actually sent. The provider pads the short edge to whole patches on its side.
+The route chooses each request image's dimensions; the attachment provider only resizes and encodes to them. `ImageRequestPolicy` in `dsh-attachment` becomes `ImageRequestTarget`: a width, a height, and the byte target for one attachment. `readImageRequest` resizes by the source long edge alone without enlargement, so the encoder derives the short edge as the route predicted, and keys its cache by the attachment id, target dimensions, byte target, encoder settings, and the new `request-image-v6` transform version, so no earlier cache entry or upload mapping is reused. `dsh-attachment` keeps two provider-neutral geometry exports: `requestImageDimensions` for a total-pixel budget and `longEdgeDimensions` for an exact long edge with a rounded short edge.
 
-DeepSeek routes resolve the grid when `imagePixelBudget` is omitted, a `pixel-budget` projection for a positive integer or the 512×512 `low` preset, a 4096-pixel per-side cap on every request image so the image count never changes a projection, and a 2 MiB byte target. The pi-ai route keeps its 2048×2048 pixel budget. The request-image transform version moves to `request-image-v6`, and the cache descriptor records the projection fields in a fixed order and the per-side cap, so no earlier cache entry or upload mapping is reused. Sources within the grid are sent at their own dimensions; small images are never enlarged because the provider scales up below 544×544 pixels itself.
+`llm-deepseek` owns the provider rule. `image-tokens.ts` keeps the verbatim `v41` solver and adds `deepSeekRequestImageDimensions`: the source itself when its patch-padded grid fits the cap, otherwise the source aspect ratio at the solved grid's long edge, so a 3840×2160 source is sent as 1708×961 and the provider pads it to its 1708×966 grid. `resolveRequestImageTarget` applies that solver when `imagePixelBudget` is omitted, `requestImageDimensions` for a positive integer or the 512×512 `low` preset, then a 4096-pixel per-side cap on every request image so the image count never changes a target, and the route's 2 MiB byte target. Pricing prices `deepSeekImageTokens` of the same target, so the estimator and the sent image come from one solver. The pi-ai route derives its targets from its unchanged 2048×2048 pixel budget. Small images are never enlarged because the provider scales up below 544×544 pixels itself.
 
 ## Alternatives considered
 
 **Raise the pixel budget to 1302×1302.** A total-pixel budget is right only for squares: a 16:9 source would be sent at 1.69 million pixels when the grid keeps 1.65 million, and a 4:1 source when it keeps 1.59 million, while extreme ratios would lose detail the grid keeps. One rule that reproduces the provider removes the guesswork.
 
-**Send the solver's exact dimensions with a fill resize.** The solved grid edges are whole patches and differ from the source aspect ratio by under one patch. Filling that box would distort the image slightly even though the provider does the same on its side; the issue requires the aspect ratio preserved, the padding on the provider side does not change the token count, and pricing reproduces the provider from the sent dimensions either way.
+**A `token-grid` projection kind on the attachment policy, with the solver in `dsh-attachment`.** This was built first: the policy became a closed union of `pixel-budget` and `token-grid`, the solver moved into `request-projection.ts`, and `deepSeekImageTokens` imported it back. It put one provider's layout formula and patch constants into the provider-neutral package under a generic-looking name, needed an `unscaled` flag so the store could tell "send the source" from "send the solved size", and would grow a new union member for every provider rule. Handing the store a finished target keeps the provider rule beside the provider's pricing and leaves the store with no projection vocabulary at all.
 
-**A function-valued projection on the policy.** The cache and upload identity is a digest over serialized policy fields, so the projection has to be data.
+**Send the solver's exact grid dimensions with a fill resize.** The solved grid edges are whole patches and differ from the source aspect ratio by under one patch. Filling that box would distort the image slightly even though the provider does the same on its side; the issue requires the aspect ratio preserved, the padding on the provider side does not change the token count, and pricing reproduces the provider from the sent dimensions either way.
 
 **Keep the 1 MiB target.** The target is not a cap: an output over it is still sent at the smallest ladder quality. At 1302×1302 a JPEG photograph at quality 85 lands between 400 KB and 1.2 MB, so 2 MiB keeps most images at the top quality and lets more PNG screenshots pass through losslessly, while the inline base64 fallback still holds about seven such images under its 20 MiB bound.
 
 ## Consequences
 
-A square source now reaches the model at up to 1302×1302 pixels and 994 tokens instead of 800×800 and 422, so image-heavy sessions reach compaction pressure sooner and the estimator matches provider usage for the sent version. Every existing request-image cache entry and DeepSeek Files API mapping is regenerated on the next request. Thin images keep their full grid until the per-side cap applies: an 8192×78 source costs 396 tokens under the grid but is sent as 4096×39. `llm-replay` does not project images, so keyless snapshots are unchanged, and the `llm-deepseek` adapter tests with mocked attachments pin the resolved policy and the projected handle text.
+A square source now reaches the model at up to 1302×1302 pixels and 994 tokens instead of 800×800 and 422, so image-heavy sessions reach compaction pressure sooner and the estimator matches provider usage for the sent version. Every existing request-image cache entry and DeepSeek Files API mapping is regenerated on the next request. Thin images keep their full grid until the per-side cap applies: an 8192×78 source costs 396 tokens under the grid but is sent as 4096×39. `llm-replay` does not project images, so keyless snapshots cannot record the sent dimensions; the `llm-deepseek` adapter tests pin the resolved targets and the projected handle text against a mock server, and the local store tests resize real images to targets.

+ 6 - 6
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md

@@ -6,26 +6,26 @@ Status: implemented
 
 ## 问题
 
-harness 此前把每张 DeepSeek 请求图片投影到 640,000 总像素预算内。这个值是为已下线的 V4 视觉模型选的,[token 预估器改用 `v41` 计算器](2026-09-10-deepseek-image-token-calculator-v41.zh.md)时没有改动它。当前 Flash 模型保留的远多于此:它把每条边补齐到整数个 14 px patch,把 3×3 个 patch 归为一个 token 格,再保留 token 数 `rows × (columns + 1) + 2` 不超过 1024 的最大等比网格。正方形图片保留 1302×1302 像素,16:9 图片保留 1708×966,极端宽高比最多保留约 180 万像素。因此 640,000 像素投影把一张 2000×2000 的截图缩成 800×800 发出,只有模型本可使用像素的约 38%,预估器为这个缩小版计 422 token,而模型对完整网格收 994 token。图像理解指南里的「约 1300×1300 总像素」只描述正方形的情况,确切规则是 token 网格。
+harness 此前把每张 DeepSeek 请求图片投影到 640,000 总像素预算内。这个值是为已下线的 V4 视觉模型选的,[token 预估器改用 `v41` 计算器](2026-09-10-deepseek-image-token-calculator-v41.zh.md)时没有改动它。当前 Flash 模型保留的远多于此:它把每条边补齐到整数个 14 px patch,把 3×3 个 patch 归为一个 token 格,再保留 token 数 `rows × (columns + 1) + 2` 不超过 1024 的最大等比网格。正方形图片保留 1302×1302 像素,16:9 图片保留 1708×966 的网格,极端宽高比最多保留约 180 万像素。因此 640,000 像素投影把一张 2000×2000 的截图缩成 800×800 发出,只有模型本可使用像素的约 38%,预估器为这个缩小版计 422 token,而模型对完整网格收 994 token。图像理解指南里的「约 1300×1300 总像素」只描述正方形的情况,确切规则是 token 网格。
 
 旁边还有两个较小的缺口。请求版本没有单边上限:请求包含 15 张及以上图片时,提供方拒绝任何单边超过 4096 像素的图片,而规范化允许 8192 像素长边,多图会话可能因一张细长图失败。1 MiB 编码字节目标是按 640,000 像素输出定的,会把 1302×1302 的照片压到 JPEG 质量阶梯的低档。
 
 ## 决策
 
-`dsh-attachment` 里的 `ImageRequestPolicy` 改为投影规则加可选单边上限加字节目标。投影是封闭联合类型:`pixel-budget` 保留原来的总像素几何,`token-grid` 携带提供方网格的 patch 大小、降采样比和 token 上限。`request-projection.ts` 以 `tokenGridProjection` 承载网格求解器,`llm-deepseek` 的 `deepSeekImageTokens` 复用它计价,于是决定请求尺寸的投影和为它计价的预估器是同一个求解器。`requestImageDimensions` 先应用投影,再应用单边上限,保持源图长边精确、短边四舍五入;本地存储只按长边缩放,因此编码后的尺寸等于预测尺寸,计价用的句柄文本写的就是实际发送的尺寸。提供方在自己那侧把短边补齐到整数个 patch
+请求图片的尺寸由路由决定,附件提供方只负责缩放和编码。`dsh-attachment` 里的 `ImageRequestPolicy` 改为 `ImageRequestTarget`,即一张附件的目标宽、高和字节目标。`readImageRequest` 只按源图长边缩放且不放大,编码器按路由预测的方式推出短边;缓存按附件 id、目标尺寸、字节目标、编码参数和新的 `request-image-v6` 变换版本取键,因此之前的缓存条目和上传映射都不会被复用。`dsh-attachment` 保留两个提供方无关的几何导出:按总像素预算的 `requestImageDimensions`,以及长边精确、短边四舍五入的 `longEdgeDimensions`
 
-DeepSeek 路由在省略 `imagePixelBudget` 时解析为网格,正整数或 512×512 的 `low` 预设解析为 `pixel-budget` 投影,每张请求图片都加 4096 像素单边上限,使图片数量不会改变投影,字节目标为 2 MiB。pi-ai 路由保留 2048×2048 像素预算。请求图片变换版本升到 `request-image-v6`,缓存描述符按固定顺序记录投影字段和单边上限,因此之前的缓存条目和上传映射都不会被复用。网格内的源图按自身尺寸发送;小图不放大,因为提供方自己会放大 544×544 像素以下的图片。
+提供方规则归 `llm-deepseek`。`image-tokens.ts` 保留逐字移植的 `v41` 求解器,并新增 `deepSeekRequestImageDimensions`:补齐 patch 后的网格在上限内就发源图本身,否则按源图宽高比取求解网格的长边,于是 3840×2160 的源图以 1708×961 发送,提供方再把它补齐到 1708×966 的网格。`resolveRequestImageTarget` 在省略 `imagePixelBudget` 时用这个求解器,正整数或 512×512 的 `low` 预设用 `requestImageDimensions`,然后对每张请求图片加 4096 像素单边上限,使图片数量不会改变目标,最后带上路由的 2 MiB 字节目标。计价对同一个目标算 `deepSeekImageTokens`,预估器和发出的图片来自同一个求解器。pi-ai 路由从它不变的 2048×2048 像素预算推导目标。小图不放大,因为提供方自己会放大 544×544 像素以下的图片。
 
 ## 备选方案
 
 **把像素预算提高到 1302×1302。** 总像素预算只对正方形正确:16:9 的源图会按 169 万像素发送而网格只保留 165 万,4:1 的源图网格只保留 159 万,极端比例又会丢掉网格本会保留的细节。一条复现提供方的规则消除了猜测。
 
-**用填充缩放发送求解器的精确尺寸。** 求解出的网格边长是整数个 patch,与源图宽高比相差不到一个 patch。填充到这个框会轻微变形,尽管提供方那侧也会这样做;issue 要求保持宽高比,提供方那侧的补齐不改变 token 数,而计价无论如何都从发送尺寸复现提供方
+**在附件策略上加 `token-grid` 投影种类,求解器放进 `dsh-attachment`。** 最初就是这样做的:策略变成 `pixel-budget` 和 `token-grid` 的封闭联合,求解器搬进 `request-projection.ts`,`deepSeekImageTokens` 再从那里引回来。这把一家提供方的布局公式和 patch 常量放进了提供方无关的包,还起了个看似通用的名字;存储层需要一个 `unscaled` 标志来区分「发源图」和「发求解尺寸」;以后每多一家提供方规则,联合就要多长一个分支。把算好的目标交给存储层,提供方规则和它的计价放在一起,存储层不需要任何投影词汇
 
-**策略里放函数形式的投影。** 缓存和上传身份是序列化策略字段的摘要,投影必须是数据
+**用填充缩放发送求解器的精确网格尺寸。** 求解出的网格边长是整数个 patch,与源图宽高比相差不到一个 patch。填充到这个框会轻微变形,尽管提供方那侧也会这样做;issue 要求保持宽高比,提供方那侧的补齐不改变 token 数,而计价无论如何都从发送尺寸复现提供方
 
 **保留 1 MiB 目标。** 目标不是上限:超过它的输出仍会以阶梯最小质量发送。1302×1302 的 JPEG 照片在质量 85 时约 400 KB 到 1.2 MB,2 MiB 让多数图片停在最高质量,也让更多 PNG 截图无损直发,而内联 base64 回退在 20 MiB 上界内仍能容纳约七张这样的图片。
 
 ## 后果
 
-正方形源图现在最多以 1302×1302 像素、994 token 到达模型,而不是 800×800 和 422,因此图片密集的会话更早触及 compaction 压力,预估器对发送版本的计价与提供方 usage 一致。所有已有的请求图片缓存条目和 DeepSeek Files API 映射在下次请求时重新生成。细长图在单边上限生效前保留完整网格:8192×78 的源图在网格下计 396 token,但以 4096×39 发送。`llm-replay` 不投影图片,keyless 快照不变;`llm-deepseek` 适配器用 mock 附件的测试固定了解析出的策略和投影后的句柄文本
+正方形源图现在最多以 1302×1302 像素、994 token 到达模型,而不是 800×800 和 422,因此图片密集的会话更早触及 compaction 压力,预估器对发送版本的计价与提供方 usage 一致。所有已有的请求图片缓存条目和 DeepSeek Files API 映射在下次请求时重新生成。细长图在单边上限生效前保留完整网格:8192×78 的源图在网格下计 396 token,但以 4096×39 发送。`llm-replay` 不投影图片,keyless 快照记录不到发送尺寸;`llm-deepseek` 适配器测试对着 mock 服务器固定了解析出的目标和投影后的句柄文本,本地存储测试把真实图片缩放到目标尺寸

+ 2 - 2
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: 259cbe8afe509cf448a54fa9fcddf297d4f2f588
-config-catalog.zh.md: 49e1064239f20fc6236c3dde6f2698791a9a1183
+config-catalog.md: de3b5c686121781860fae92a20f02e848508ee55
+config-catalog.zh.md: 9a2f1e86168ce5ebfe6a07ea258b4a94bba4c3fc

+ 1 - 1
docs/config-catalog.md

@@ -1098,7 +1098,7 @@ export interface DeepSeekCatalogModel {
 
 Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 
-Source: [`packages/llm/llm-deepseek/src/index.ts:128`](../packages/llm/llm-deepseek/src/index.ts)
+Source: [`packages/llm/llm-deepseek/src/index.ts:129`](../packages/llm/llm-deepseek/src/index.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -1100,7 +1100,7 @@ export interface DeepSeekCatalogModel {
 
 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 
-来源:[`packages/llm/llm-deepseek/src/index.ts:128`](../packages/llm/llm-deepseek/src/index.ts)
+来源:[`packages/llm/llm-deepseek/src/index.ts:129`](../packages/llm/llm-deepseek/src/index.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 

+ 2 - 2
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: bbcf438646eb4cae78ef5b86ccbf9e04b927fa55
-attachment.zh.md: 0e3c58fc59cb319bde681bdfa7124c852cb6da19
+attachment.md: db1c1cfa15f5d0815236f96b6472cbbd864decbd
+attachment.zh.md: 82379de5049012c0912734dc94257d6b8f354ad1

Разлика између датотеке није приказан због своје велике величине
+ 10 - 40
docs/subsystems/attachment.md


+ 13 - 43
docs/subsystems/attachment.zh.md

@@ -126,52 +126,22 @@ interface StoredImageAttachment {
 ```
 
 ```ts type-equiv
-/** Aspect-preserving downscale rule of one request image; small images are never enlarged. */
-type ImageRequestProjection =
-  | {
-    /** Hard cap on width multiplied by height. */
-    kind: 'pixel-budget'
-    /** Maximum width multiplied by height after projection. */
-    maxPixels: number
-  }
-  | {
-    /**
-     * Largest aspect-preserving patch grid whose token count
-     * `rows × (columns + 1) + 2` fits `maxTokens`; the DeepSeek published vision layout.
-     */
-    kind: 'token-grid'
-    /** Patch edge in pixels; a downscaled edge is a whole number of patches. */
-    patchSize: number
-    /** Patches per token cell along each axis. */
-    downsampleRatio: number
-    /** Token cap for one image. */
-    maxTokens: number
-  }
-```
-
-```ts type-equiv
-/** Dimensions a `token-grid` projection retains for one image and the tokens it charges. */
-interface TokenGridProjection {
-  /** Retained width: the patch-padded source when it fits, otherwise the solved width. */
+/** Deterministic request-image target selected by one exact model route for one attachment. */
+interface ImageRequestTarget {
+  /** Target width in pixels; a target above the source keeps the source width. */
   width: number
-  /** Retained height: the patch-padded source when it fits, otherwise the solved height. */
+  /** Target height in pixels; a target above the source keeps the source height. */
   height: number
-  /** Tokens charged for the retained grid. */
-  tokens: number
-  /** Whether the patch-padded source already fits `maxTokens` without downscaling. */
-  unscaled: boolean
+  /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */
+  maxBytes: number
 }
 ```
 
 ```ts type-equiv
-/** Deterministic request-image policy selected by one exact model route. */
-interface ImageRequestPolicy {
-  /** Downscale rule applied before the per-side cap. */
-  projection: ImageRequestProjection
-  /** Maximum width and maximum height after projection; omission bounds the long edge by the projection alone. */
-  maxDimension?: number
-  /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */
-  maxBytes: number
+/** Integer width and height of one projected image. */
+interface ProjectedDimensions {
+  width: number
+  height: number
 }
 ```
 
@@ -197,7 +167,7 @@ interface RequestImageAttachment {
 }
 ```
 
-`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的投影规则、可选单边上限和编码字节目标派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
+`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切的路由目标尺寸和编码字节目标派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -320,11 +290,11 @@ fileHostPath(ref: FileAttachmentRef): string | undefined
 /**
  * Generate or read one deterministic model-request version from the stored normalized image.
  * @param ref - durable provider-independent normalized attachment reference.
- * @param policy - route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output.
+ * @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output.
  * @param signal - optional cancellation.
  * @returns request bytes and the cache/upload identity covering every transform input.
  */
-readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise<RequestImageAttachment>
+readImageRequest( ref: ImageAttachmentRef, target: ImageRequestTarget, signal?: AbortSignal, ): Promise<RequestImageAttachment>
 ```
 
 Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts)

+ 2 - 2
docs/subsystems/llm-streaming.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/llm-streaming.md
-llm-streaming.md: b3d2468a41833139e3c9c9ded69cbb863e5fd8d3
-llm-streaming.zh.md: 2d849206746ee44736ff402a364f913301ed87a8
+llm-streaming.md: 28492b46435e3288609129b0dfc5cb40dd5f7296
+llm-streaming.zh.md: fe02e2ebe5b558162e849e26ff05b8d633c07ba0

+ 1 - 1
docs/subsystems/llm-streaming.md

@@ -249,7 +249,7 @@ interface LlmFailure {
 
 ## Request-image pricing
 
-An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model projection and per-side cap, oldest-first offload) and prices retained images with the published vision accounting, while provider usage remains the authoritative anchor for completed requests.
+An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model request target, oldest-first offload) and prices retained images with the published vision accounting, while provider usage remains the authoritative anchor for completed requests.
 
 ```ts type-equiv
 /**

+ 1 - 1
docs/subsystems/llm-streaming.zh.md

@@ -251,7 +251,7 @@ interface LlmFailure {
 
 ## 请求图片定价
 
-提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的投影与单边上限、最旧优先 offload),并用官方公布的视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。
+提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的请求目标、最旧优先 offload),并用官方公布的视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。
 
 ```ts type-equiv
 /**

+ 2 - 2
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: 3689ee4d497cf2e6dbf74450a9ec71c5138170c3
-README.zh.md: 3c208d1626daa1d93c6e1cc6bd483ded21338f75
+README.md: cf055cf7682c7c97de25a58795ee15e4797ae63b
+README.zh.md: 0b14fed2c2eb972e12d655aba31fd0cfbd9e1473

+ 1 - 1
packages/attachment/attachment-local/README.md

@@ -85,7 +85,7 @@ Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`; eq
 
 Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 20 MiB, 64 million pixels, and 8192 pixels per side. It applies orientation, removes metadata and color profiles, and normalizes under a 2048×2048 total-pixel budget, an 8192-pixel long edge, and a 4 MiB encoded-byte target. Extreme aspect ratios therefore retain their short-edge resolution. Clean single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP input already within those limits passes through byte-identically; GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion.
 
-Request versions live below `<DSH_HOME>/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to the route projection, a total-pixel budget or a provider token grid, then to the route per-side cap; it resizes by the long edge only, so the encoder rounds the short edge exactly as the projection predicts, and then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, projection, per-side cap, byte target, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history.
+Request versions live below `<DSH_HOME>/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to the route-chosen target, resizing by the long edge only so the encoder derives the short edge as the route predicts, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, target dimensions, byte target, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history.
 
 Generic-file bytes have one canonical object at `<DSH_HOME>/attachments/v1/file-objects/<digest-prefix>/<digest>`. Each reference path at `<DSH_HOME>/attachments/v1/files/<digest-prefix>/<digest>/<name>` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes.
 

+ 1 - 1
packages/attachment/attachment-local/README.zh.md

@@ -85,7 +85,7 @@ kind: "package-reference"
 
 准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 20 MiB、6400 万像素与单边 8192 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此,即使宽高比极端,图片也会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。
 
-请求版本位于 `<DSH_HOME>/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下先缩放到路由投影,即总像素预算或提供方 token 网格,再缩放到路由单边上限。缩放只按长边给定,短边由编码器四舍五入,与投影预测的尺寸一致。随后通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、投影、单边上限、字节目标与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。
+请求版本位于 `<DSH_HOME>/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下缩放到路由选定的目标尺寸,缩放只按长边给定,短边由编码器按路由预测的方式推出,随后通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、目标尺寸、字节目标与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。
 
 通用文件字节的唯一规范对象位于 `<DSH_HOME>/attachments/v1/file-objects/<digest-prefix>/<digest>`。每条引用路径 `<DSH_HOME>/attachments/v1/files/<digest-prefix>/<digest>/<name>` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。
 

+ 6 - 6
packages/attachment/attachment-local/src/index.ts

@@ -8,7 +8,7 @@ import type {
   FileAttachmentRef,
   ImageAttachmentLimits,
   ImageAttachmentRef,
-  ImageRequestPolicy,
+  ImageRequestTarget,
   RequestImageAttachment,
   SaveFileAttachment,
   SaveFileStreamAttachment,
@@ -244,20 +244,20 @@ export class LocalAttachmentStore extends AttachmentStore {
 
   override async readImageRequest(
     ref: ImageAttachmentRef,
-    policy: ImageRequestPolicy,
+    target: ImageRequestTarget,
     signal?: AbortSignal,
   ): Promise<RequestImageAttachment> {
-    return this.requestVersion(ref, policy, undefined, signal)
+    return this.requestVersion(ref, target, undefined, signal)
   }
 
   private requestVersion(
     ref: ImageAttachmentRef,
-    policy: ImageRequestPolicy,
+    target: ImageRequestTarget,
     stored: StoredImageAttachment | undefined,
     signal: AbortSignal | undefined,
   ): Promise<RequestImageAttachment> {
     signal?.throwIfAborted()
-    const variantId = requestImageVariantId(ref, policy)
+    const variantId = requestImageVariantId(ref, target)
     const key = String(variantId)
     let operation = this.requestInflight.get(key)
     if (operation?.controller.signal.aborted) {
@@ -269,7 +269,7 @@ export class LocalAttachmentStore extends AttachmentStore {
         const request = await readRequestImageFile(
           this.root,
           stored ?? await this.readImage(ref, sharedSignal),
-          policy,
+          target,
           sharedSignal,
         )
         return request

+ 8 - 4
packages/attachment/attachment-local/src/normalization.ts

@@ -79,10 +79,14 @@ function preparedPipeline(data: Uint8Array, width: number, height: number): Shar
 
 /** Dimensions under the total-pixel budget, then the long-edge cap, without changing aspect ratio. */
 function initialDimensions(detected: DetectedImage, policy: NormalizationPolicy): { width: number; height: number } {
-  return requestImageDimensions(detected.width, detected.height, {
-    projection: { kind: 'pixel-budget', maxPixels: policy.maxPixels },
-    maxDimension: policy.maxDimension,
-  })
+  const budgeted = requestImageDimensions(detected.width, detected.height, policy.maxPixels)
+  const longEdge = Math.max(budgeted.width, budgeted.height)
+  if (longEdge <= policy.maxDimension) return budgeted
+  const scale = policy.maxDimension / longEdge
+  return {
+    width: Math.max(1, Math.floor(budgeted.width * scale)),
+    height: Math.max(1, Math.floor(budgeted.height * scale)),
+  }
 }
 
 /**

+ 31 - 54
packages/attachment/attachment-local/src/request-image.ts

@@ -4,12 +4,11 @@ import { createHash, randomUUID } from 'node:crypto'
 import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
 import { dirname, join } from 'node:path'
 import sharp, { type Sharp } from 'sharp'
-import { AttachmentError, ImageVariantId, requestImageDimensions } from '@deepseek-ai/dsh-attachment'
+import { AttachmentError, ImageVariantId } from '@deepseek-ai/dsh-attachment'
 import type {
   ImageMediaType,
   ImageAttachmentRef,
-  ImageRequestPolicy,
-  ImageRequestProjection,
+  ImageRequestTarget,
   RequestImageAttachment,
   StoredImageAttachment,
 } from '@deepseek-ai/dsh-attachment'
@@ -47,38 +46,19 @@ function checkedInteger(value: number, name: string): number {
   return value
 }
 
-function validatePolicy(policy: ImageRequestPolicy): void {
-  const { projection } = policy
-  if (projection.kind === 'pixel-budget') {
-    checkedInteger(projection.maxPixels, 'Image request maxPixels')
-  } else {
-    checkedInteger(projection.patchSize, 'Image request patchSize')
-    checkedInteger(projection.downsampleRatio, 'Image request downsampleRatio')
-    checkedInteger(projection.maxTokens, 'Image request maxTokens')
-  }
-  if (policy.maxDimension !== undefined) checkedInteger(policy.maxDimension, 'Image request maxDimension')
-  checkedInteger(policy.maxBytes, 'Image request maxBytes')
+function validateTarget(target: ImageRequestTarget): void {
+  checkedInteger(target.width, 'Image request width')
+  checkedInteger(target.height, 'Image request height')
+  checkedInteger(target.maxBytes, 'Image request maxBytes')
 }
 
-/** Projection fields in a fixed key order so equal policies digest identically. */
-function projectionDescriptor(projection: ImageRequestProjection): Record<string, number | string> {
-  return projection.kind === 'pixel-budget'
-    ? { kind: projection.kind, maxPixels: projection.maxPixels }
-    : {
-      kind: projection.kind,
-      patchSize: projection.patchSize,
-      downsampleRatio: projection.downsampleRatio,
-      maxTokens: projection.maxTokens,
-    }
-}
-
-function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): string {
+function descriptor(attachment: ImageAttachmentRef, target: ImageRequestTarget): string {
   return JSON.stringify({
     transformVersion: REQUEST_IMAGE_TRANSFORM_VERSION,
     attachmentId: attachment.attachmentId,
-    projection: projectionDescriptor(policy.projection),
-    maxDimension: policy.maxDimension ?? null,
-    encodedByteBudget: policy.maxBytes,
+    targetWidth: target.width,
+    targetHeight: target.height,
+    encodedByteBudget: target.maxBytes,
     encoding: {
       webpQualities: IMAGE_ENCODING_QUALITIES,
       webpEffort: WEBP_ENCODING_EFFORT,
@@ -90,23 +70,23 @@ function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy):
 }
 
 /**
- * Complete deterministic identity for one attachment and route-owned request policy.
+ * Complete deterministic identity for one attachment and route-chosen request target.
  * @param attachment - provider-independent durable normalized attachment reference.
- * @param policy - route-owned projection, per-side cap, and byte policy.
+ * @param target - route-chosen dimensions and byte target.
  * @returns branded digest over every request transform input.
  */
 export function requestImageVariantId(
   attachment: ImageAttachmentRef,
-  policy: ImageRequestPolicy,
+  target: ImageRequestTarget,
 ): ReturnType<typeof ImageVariantId> {
-  return ImageVariantId(`sha256:${digest(descriptor(attachment, policy))}`)
+  return ImageVariantId(`sha256:${digest(descriptor(attachment, target))}`)
 }
 
-/** Resize by the source long edge only, so the encoder rounds the short edge exactly as the projection did. */
-function pipeline(attachment: StoredImageAttachment, width: number, height: number): Sharp {
+/** Resize by the source long edge only, so the encoder derives the short edge as the route predicts. */
+function pipeline(attachment: StoredImageAttachment, target: ImageRequestTarget): Sharp {
   const byWidth = attachment.ref.width >= attachment.ref.height
   return sourcePipeline(attachment)
-    .resize({ ...byWidth ? { width } : { height }, withoutEnlargement: true })
+    .resize({ ...byWidth ? { width: target.width } : { height: target.height }, withoutEnlargement: true })
 }
 
 function sourcePipeline(attachment: StoredImageAttachment): Sharp {
@@ -115,13 +95,12 @@ function sourcePipeline(attachment: StoredImageAttachment): Sharp {
 
 async function createRequestImage(
   attachment: StoredImageAttachment,
-  policy: ImageRequestPolicy,
+  target: ImageRequestTarget,
   hasAlpha: boolean,
 ): Promise<EncodedRequestImage> {
-  const dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy)
-  if (dimensions.width === attachment.ref.width
-    && dimensions.height === attachment.ref.height
-    && attachment.data.byteLength <= policy.maxBytes) {
+  if (target.width >= attachment.ref.width
+    && target.height >= attachment.ref.height
+    && attachment.data.byteLength <= target.maxBytes) {
     return {
       data: attachment.data,
       mediaType: attachment.ref.mediaType,
@@ -130,8 +109,8 @@ async function createRequestImage(
     }
   }
   const encodedVersion = await encodeFirstWithinLimit(
-    encodingLadder(pipeline(attachment, dimensions.width, dimensions.height), hasAlpha),
-    policy.maxBytes,
+    encodingLadder(pipeline(attachment, target), hasAlpha),
+    target.maxBytes,
   )
   return isExhaustedEncoding(encodedVersion) ? encodedVersion.smallest : encodedVersion
 }
@@ -142,17 +121,15 @@ function cachePath(root: string, hash: string): string {
 
 async function readCached(
   path: string,
-  attachment: StoredImageAttachment,
-  policy: ImageRequestPolicy,
+  target: ImageRequestTarget,
   expectedAlpha: boolean,
   signal?: AbortSignal,
 ): Promise<VerifiedRequestImage | undefined> {
   try {
     const data = new Uint8Array(await readFile(path, { signal }))
     const detected = await probeImage(data)
-    const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy)
     if (detected.depth !== 'uchar' || detected.space !== 'srgb'
-      || detected.width > maximum.width || detected.height > maximum.height
+      || detected.width > target.width || detected.height > target.height
       || !encodedAlphaIsCompatible(expectedAlpha, detected)) return undefined
     return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height, hasAlpha: detected.hasAlpha }
   } catch (error: unknown) {
@@ -193,24 +170,24 @@ async function writeCached(path: string, data: Uint8Array): Promise<void> {
  * Generate or reuse one request image below the local attachment root.
  * @param root - absolute versioned attachment storage root.
  * @param attachment - verified normalized attachment bytes and reference.
- * @param policy - exact route request-image policy.
+ * @param target - exact route-chosen dimensions and byte target; a target above the source keeps the source size.
  * @param signal - optional cancellation for cache I/O and image transformation.
  * @returns verified request bytes and deterministic variant identity.
  */
 export async function readRequestImageFile(
   root: string,
   attachment: StoredImageAttachment,
-  policy: ImageRequestPolicy,
+  target: ImageRequestTarget,
   signal?: AbortSignal,
 ): Promise<RequestImageAttachment> {
   signal?.throwIfAborted()
-  validatePolicy(policy)
+  validateTarget(target)
   const source = await probeImage(attachment.data)
-  const variantId = requestImageVariantId(attachment.ref, policy)
+  const variantId = requestImageVariantId(attachment.ref, target)
   const hash = String(variantId).slice('sha256:'.length)
   const path = cachePath(root, hash)
-  const cached = await readCached(path, attachment, policy, source.hasAlpha, signal)
-  const created = cached ?? await createRequestImage(attachment, policy, source.hasAlpha)
+  const cached = await readCached(path, target, source.hasAlpha, signal)
+  const created = cached ?? await createRequestImage(attachment, target, source.hasAlpha)
   const version = cached ?? (created.data === attachment.data
     ? { ...created, hasAlpha: source.hasAlpha }
     : await verifyRequestImage(created, source.hasAlpha))

+ 1 - 1
packages/attachment/attachment-local/tests/index.spec.ts

@@ -85,7 +85,7 @@ describe('local attachment service', () => {
         String(ref.attachmentId).slice('sha256:'.length),
       ))
       await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data))
-      const request = await service.readImageRequest(ref, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1024 })
+      const request = await service.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1024 })
       expect(request).not.toHaveProperty('access')
 
       const fileData = Uint8Array.of(0, 1, 2, 255)

+ 1 - 1
packages/attachment/attachment-local/tests/request-image-verification.spec.ts

@@ -38,7 +38,7 @@ describe('request image verification', () => {
     const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
     control.mismatch = true
 
-    await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 1024 * 1024 }))
+    await expect(attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 }))
       .rejects.toMatchObject({
         code: 'ATTACHMENT_WRITE_FAILED',
         message: 'Encoded model-request image does not match its verified 8-bit sRGB metadata.',

+ 33 - 38
packages/attachment/attachment-local/tests/request-image.spec.ts

@@ -48,7 +48,7 @@ describe('local request-image cache', () => {
     const first = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })
     const second = await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' })
     const firstStored = await attachments.readImage(first)
-    const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 1_000 }, maxBytes: 1024 * 1024 }
+    const policy = { width: 8, height: 8, maxBytes: 1024 * 1024 }
 
     const request = await attachments.readImageRequest(first, policy)
     const batch = await Promise.all([first, second].map(
@@ -59,56 +59,51 @@ describe('local request-image cache', () => {
     expect(batch.map(value => value.attachment.attachmentId)).toEqual([first.attachmentId, second.attachmentId])
   })
 
-  it('rejects invalid request policies', async () => {
+  it('rejects invalid request targets', async () => {
     const attachments = await store()
     const attachment = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })
 
-    await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 0 }, maxBytes: 100 }))
-      .rejects.toThrow('Image request maxPixels must be a positive integer')
-    await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 100 }, maxBytes: 0 }))
+    await expect(attachments.readImageRequest(attachment, { width: 0, height: 4, maxBytes: 100 }))
+      .rejects.toThrow('Image request width must be a positive integer')
+    await expect(attachments.readImageRequest(attachment, { width: 8, height: 1.5, maxBytes: 100 }))
+      .rejects.toThrow('Image request height must be a positive integer')
+    await expect(attachments.readImageRequest(attachment, { width: 8, height: 4, maxBytes: 0 }))
       .rejects.toThrow('Image request maxBytes must be a positive integer')
-    await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 100 }, maxDimension: 0, maxBytes: 100 }))
-      .rejects.toThrow('Image request maxDimension must be a positive integer')
-    for (const field of ['patchSize', 'downsampleRatio', 'maxTokens'] as const) {
-      const projection = { kind: 'token-grid' as const, patchSize: 14, downsampleRatio: 3, maxTokens: 1024, [field]: 1.5 }
-      await expect(attachments.readImageRequest(attachment, { projection, maxBytes: 100 }))
-        .rejects.toThrow(`Image request ${field} must be a positive integer`)
-    }
   })
 
-  it('projects onto a token grid, caps the long edge, and keys the cache by projection', async () => {
+  it('resizes by the long edge to the exact target and keys the cache by target', async () => {
     const attachments = await store()
-    const grid = { kind: 'token-grid' as const, patchSize: 14, downsampleRatio: 3, maxTokens: 1024 }
+    const maxBytes = 2 * 1024 * 1024
     const square = await attachments.saveImage({ data: await image(2048, 2048), mediaType: 'image/png' })
     const small = await attachments.saveImage({ data: await image(800, 800), mediaType: 'image/png' })
     const thin = await attachments.saveImage({ data: await image(8000, 40), mediaType: 'image/png' })
     const wide = await attachments.saveImage({ data: await image(1920, 1080), mediaType: 'image/png' })
     const tall = await attachments.saveImage({ data: await image(1080, 1920), mediaType: 'image/png' })
 
-    const squareRequest = await attachments.readImageRequest(square, { projection: grid, maxBytes: 2 * 1024 * 1024 })
-    const smallRequest = await attachments.readImageRequest(small, { projection: grid, maxBytes: 2 * 1024 * 1024 })
-    const thinRequest = await attachments.readImageRequest(thin, { projection: grid, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 })
-    const budgeted = await attachments.readImageRequest(square, { projection: { kind: 'pixel-budget' as const, maxPixels: 1302 * 1302 }, maxBytes: 2 * 1024 * 1024 })
-    const uncapped = await attachments.readImageRequest(thin, { projection: grid, maxBytes: 2 * 1024 * 1024 })
-    const wideRequest = await attachments.readImageRequest(wide, { projection: grid, maxBytes: 2 * 1024 * 1024 })
-    const tallRequest = await attachments.readImageRequest(tall, { projection: grid, maxBytes: 2 * 1024 * 1024 })
+    const squareRequest = await attachments.readImageRequest(square, { width: 1302, height: 1302, maxBytes })
+    const smallRequest = await attachments.readImageRequest(small, { width: 800, height: 800, maxBytes })
+    const thinRequest = await attachments.readImageRequest(thin, { width: 4096, height: 20, maxBytes })
+    const wideRequest = await attachments.readImageRequest(wide, { width: 1708, height: 961, maxBytes })
+    const tallRequest = await attachments.readImageRequest(tall, { width: 961, height: 1708, maxBytes })
+    const smaller = await attachments.readImageRequest(square, { width: 1024, height: 1024, maxBytes })
+    const enlarged = await attachments.readImageRequest(thin, { width: 9000, height: 45, maxBytes })
 
     expect(squareRequest).toMatchObject({ width: 1302, height: 1302, mediaType: 'image/jpeg' })
     expect(smallRequest).toMatchObject({ width: 800, height: 800, mediaType: 'image/png' })
     expect(smallRequest.data).toEqual((await attachments.readImage(small)).data)
     expect(thinRequest).toMatchObject({ width: 4096, height: 20 })
-    expect(uncapped).toMatchObject({ width: 8000, height: 40 })
     expect(wideRequest).toMatchObject({ width: 1708, height: 961 })
     expect(tallRequest).toMatchObject({ width: 961, height: 1708 })
-    expect(budgeted.variantId).not.toBe(squareRequest.variantId)
-    expect(uncapped.variantId).not.toBe(thinRequest.variantId)
+    expect(enlarged).toMatchObject({ width: 8000, height: 40 })
+    expect(smaller.variantId).not.toBe(squareRequest.variantId)
+    expect(enlarged.variantId).not.toBe(thinRequest.variantId)
   })
 
   it('keeps the smallest ladder output when the encoded-byte target is unreachable', async () => {
     const attachments = await store()
     const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' })
 
-    const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1 })
+    const request = await attachments.readImageRequest(attachment, { width: 1, height: 1, maxBytes: 1 })
 
     expect(request.mediaType).toBe('image/jpeg')
     expect(request.bytes).toBeGreaterThan(1)
@@ -118,7 +113,7 @@ describe('local request-image cache', () => {
   it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => {
     const attachments = await store()
     const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' })
-    const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 4_096 }
+    const policy = { width: 22, height: 11, maxBytes: 4_096 }
     const initial = await attachments.readImageRequest(attachment, policy)
     const hash = String(initial.variantId).slice('sha256:'.length)
     const path = join(attachments.root, 'request-images', hash.slice(0, 2), hash)
@@ -167,10 +162,10 @@ describe('local request-image cache', () => {
       data: await image(2048, 1024), mediaType: 'image/png', name: 'wide.png',
     })
 
-    const squareRequest = await attachments.readImageRequest(square, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 })
-    const wideRequest = await attachments.readImageRequest(wide, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 })
-    const repeated = await attachments.readImageRequest(wide, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 })
-    const low = await attachments.readImageRequest(wide, { projection: { kind: 'pixel-budget' as const, maxPixels: 512 * 512 }, maxBytes: 1024 * 1024 })
+    const squareRequest = await attachments.readImageRequest(square, { width: 800, height: 800, maxBytes: 1024 * 1024 })
+    const wideRequest = await attachments.readImageRequest(wide, { width: 1130, height: 565, maxBytes: 1024 * 1024 })
+    const repeated = await attachments.readImageRequest(wide, { width: 1130, height: 565, maxBytes: 1024 * 1024 })
+    const low = await attachments.readImageRequest(wide, { width: 724, height: 362, maxBytes: 1024 * 1024 })
 
     expect(squareRequest).toMatchObject({ width: 800, height: 800 })
     expect(wideRequest).toMatchObject({ width: 1130, height: 565 })
@@ -210,8 +205,8 @@ describe('local request-image cache', () => {
     const photo = await attachments.saveImage({ data: photoSource, mediaType: 'image/png' })
     const alpha = await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' })
 
-    const photoRequest = await attachments.readImageRequest(photo, { projection: { kind: 'pixel-budget' as const, maxPixels: 128 * 128 }, maxBytes: 1024 * 1024 })
-    const alphaRequest = await attachments.readImageRequest(alpha, { projection: { kind: 'pixel-budget' as const, maxPixels: 128 * 128 }, maxBytes: 4_096 })
+    const photoRequest = await attachments.readImageRequest(photo, { width: 128, height: 128, maxBytes: 1024 * 1024 })
+    const alphaRequest = await attachments.readImageRequest(alpha, { width: 128, height: 128, maxBytes: 4_096 })
 
     expect(photoRequest.mediaType).toBe('image/jpeg')
     expect(alphaRequest.mediaType).toBe('image/webp')
@@ -227,7 +222,7 @@ describe('local request-image cache', () => {
     }).toColourspace('rgb16').png().toBuffer())
     const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
 
-    const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 1024 * 1024 })
+    const request = await attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 })
 
     expect(request.bytes).toBeLessThanOrEqual(1024 * 1024)
     expect(request.width * request.height).toBeLessThanOrEqual(16 * 16)
@@ -241,7 +236,7 @@ describe('local request-image cache', () => {
     const source = await complexOpaqueAlphaImage(64, 32)
     const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
 
-    const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 1024 * 1024 })
+    const request = await attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 })
 
     expect(request.mediaType).toBe('image/webp')
     await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: false })
@@ -263,7 +258,7 @@ describe('local request-image cache', () => {
     }).png().toBuffer())
     const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
 
-    const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 })
+    const request = await attachments.readImageRequest(attachment, { width: 800, height: 800, maxBytes: 1024 * 1024 })
 
     expect(request).toMatchObject({ width: 800, height: 800 })
     expect(request.bytes).toBeLessThanOrEqual(1024 * 1024)
@@ -276,7 +271,7 @@ describe('local request-image cache', () => {
     })
     const run = vi.spyOn(CompressionLimiter.prototype, 'run')
     const controller = new AbortController()
-    const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }
+    const policy = { width: 1130, height: 565, maxBytes: 1024 * 1024 }
 
     const cancelled = attachments.readImageRequest(attachment, policy, controller.signal)
     const completed = attachments.readImageRequest(attachment, policy)
@@ -306,7 +301,7 @@ describe('local request-image cache', () => {
     const controller = new AbortController()
     const request = attachments.readImageRequest(
       attachment,
-      { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 },
+      { width: 1130, height: 565, maxBytes: 1024 * 1024 },
       controller.signal,
     )
     await vi.waitFor(() => {
@@ -339,7 +334,7 @@ describe('local request-image cache', () => {
       return actualRead(ref, signal)
     })
     const controller = new AbortController()
-    const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }
+    const policy = { width: 1130, height: 565, maxBytes: 1024 * 1024 }
     const cancelled = attachments.readImageRequest(attachment, policy, controller.signal)
     await vi.waitFor(() => {
       expect(calls).toBe(1)

+ 2 - 2
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: e17d6bea53127d54babad919bdd546388b742d5f
-README.zh.md: 5bf061304684cbdd8be00709ba9d1224b6c68ffa
+README.md: d2a667e2d12deadc5e82a83cca6565f0b5a6aae0
+README.zh.md: b5598eb8ccca39d8b5be346c585fd84d3dd38468

+ 2 - 2
packages/attachment/attachment/README.md

@@ -66,13 +66,13 @@ This section explains the design decisions behind the seam and the service opera
 - **Normalize and persist before event.** Every source is prepared and verified before the batch publishes in order, so the session log never references a partial or failed normalization.
 - **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion.
 - **Verify on read.** Reads check bytes and metadata against the logged reference before returning them, and request projections fully decode cached bytes, so a missing, corrupted, or swapped object fails closed.
-- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions under an explicit projection rule, per-side cap, and byte target, while execution filesystems may map the immutable host object to a model-readable process path.
+- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions at an explicit route-chosen target size and byte target, while execution filesystems may map the immutable host object to a model-readable process path.
 - **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain.
 - **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route.
 
 ### Service operations
 
-The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, projection rule, per-side cap, byte target, and encoder settings. The pure `requestImageDimensions` and `tokenGridProjection` exports compute each projection's aspect-preserving dimensions from a total-pixel budget or a provider token grid, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts).
+The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, target dimensions, byte target, and encoder settings. The pure `requestImageDimensions` and `longEdgeDimensions` exports compute aspect-preserving dimensions from a total-pixel budget or an exact long edge, so routes and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts).
 
 ### Source map
 

+ 2 - 2
packages/attachment/attachment/README.zh.md

@@ -66,13 +66,13 @@ kind: "package-reference"
 - **事件前完成规范化与持久化。** 每个源图都会在批次按序发布前完成准备与校验,因此会话日志绝不会引用部分完成或规范化失败的对象。
 - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。
 - **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象不会通过校验。
-- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按显式的投影规则、单边上限与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。
+- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按路由显式选定的目标尺寸与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。
 - **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。
 - **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装会为每条路由将其投影为确定性的句柄文本。
 
 ### 服务操作
 
-服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、投影规则、单边上限、字节目标及编码参数。纯函数导出 `requestImageDimensions` 与 `tokenGridProjection` 按总像素预算或提供方 token 网格计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。
+服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、目标尺寸、字节目标及编码参数。纯函数导出 `requestImageDimensions` 与 `longEdgeDimensions` 按总像素预算或精确长边计算保持宽高比的尺寸,使路由与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。
 
 ### 源码地图
 

+ 6 - 8
packages/attachment/attachment/src/index.ts

@@ -10,7 +10,7 @@ import type {
   FileAttachmentRef,
   ImageAttachmentLimits,
   ImageAttachmentRef,
-  ImageRequestPolicy,
+  ImageRequestTarget,
   RequestImageAttachment,
   SaveFileAttachment,
   SaveFileStreamAttachment,
@@ -22,7 +22,7 @@ export { AttachmentId, ImageVariantId } from './brand.ts'
 export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts'
 export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts'
 export { admitEncodedFile, admitEncodedImages } from './admission.ts'
-export { requestImageDimensions, tokenGridProjection } from './request-projection.ts'
+export { longEdgeDimensions, requestImageDimensions } from './request-projection.ts'
 export type { ProjectedDimensions } from './request-projection.ts'
 export type {
   AttachmentId as AttachmentIdType,
@@ -33,8 +33,7 @@ export type {
   FileAttachmentRef,
   ImageAttachmentLimits,
   ImageAttachmentRef,
-  ImageRequestPolicy,
-  ImageRequestProjection,
+  ImageRequestTarget,
   ImageMediaType,
   PromptContentPart,
   RequestImageAttachment,
@@ -42,7 +41,6 @@ export type {
   SaveFileStreamAttachment,
   SaveImageAttachment,
   StoredImageAttachment,
-  TokenGridProjection,
 } from './types.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -244,18 +242,18 @@ export abstract class AttachmentStore extends Service {
   /**
    * Generate or read one deterministic model-request version from the stored normalized image.
    * @param ref - durable provider-independent normalized attachment reference.
-   * @param policy - route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output.
+   * @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output.
    * @param signal - optional cancellation.
    * @returns request bytes and the cache/upload identity covering every transform input.
    */
   readImageRequest(
     ref: ImageAttachmentRef,
-    policy: ImageRequestPolicy,
+    target: ImageRequestTarget,
     signal?: AbortSignal,
   ): Promise<RequestImageAttachment> {
     signal?.throwIfAborted()
     void ref
-    void policy
+    void target
     return Promise.reject(new AttachmentError(
       'The mounted attachment provider cannot derive model-request images.',
       'ATTACHMENT_PROJECTION_UNSUPPORTED',

+ 25 - 101
packages/attachment/attachment/src/request-projection.ts

@@ -1,21 +1,26 @@
 /**
- * Pure request-projection geometry shared by attachment providers and
- * provider-side request pricing. @module @deepseek-ai/dsh-attachment/request-projection
+ * Pure request-projection geometry shared by model routes and provider-side
+ * request pricing. @module @deepseek-ai/dsh-attachment/request-projection
  */
 
-import type { ImageRequestPolicy, ImageRequestProjection, TokenGridProjection } from './types.ts'
-
 /** Integer width and height of one projected image. */
 export interface ProjectedDimensions {
   width: number
   height: number
 }
 
-const intDiv = (value: number, divisor: number): number => Math.floor(value / divisor)
-const ceilDiv = (value: number, divisor: number): number => Math.floor((value + divisor - 1) / divisor)
-
-/** Aspect-preserving integer dimensions within a hard total-pixel budget; small images are not enlarged. */
-function pixelBudgetDimensions(width: number, height: number, maxPixels: number): ProjectedDimensions {
+/**
+ * Compute aspect-preserving integer dimensions within a hard total-pixel budget.
+ * @param width - positive source width.
+ * @param height - positive source height.
+ * @param maxPixels - positive width-times-height cap.
+ * @returns inward-rounded dimensions; small images are not enlarged.
+ */
+export function requestImageDimensions(
+  width: number,
+  height: number,
+  maxPixels: number,
+): ProjectedDimensions {
   const scale = Math.min(1, Math.sqrt(maxPixels / (width * height)))
   if (scale === 1) return { width, height }
   if (width >= height) {
@@ -36,103 +41,22 @@ function pixelBudgetDimensions(width: number, height: number, maxPixels: number)
   return { width: projectedWidth, height: projectedHeight }
 }
 
-/** Token count of one grid: every row carries a separator, plus two framing tokens. */
-function gridTokens(rows: number, columns: number): number {
-  return rows * (columns + 1) + 2
-}
-
 /**
- * Project one image onto the token grid of a `token-grid` projection. The
- * provider pads each edge up to a whole patch, groups patches into token
- * cells, and keeps the padded source when its cell grid fits `maxTokens`;
- * otherwise it solves the largest aspect-preserving grid inside the cap, whose
- * edges are whole patches. The closed-form solve always lands inside the cap.
- * @param width - positive integer source width in pixels.
- * @param height - positive integer source height in pixels.
- * @param grid - patch size, per-axis downsampling ratio, and token cap.
- * @returns the dimensions the provider retains and the tokens it charges.
- */
-export function tokenGridProjection(
-  width: number,
-  height: number,
-  grid: Extract<ImageRequestProjection, { kind: 'token-grid' }>,
-): TokenGridProjection {
-  const { patchSize, downsampleRatio, maxTokens } = grid
-  const cells = (paddedLength: number): number => ceilDiv(intDiv(paddedLength, patchSize), downsampleRatio)
-  const paddedWidth = ceilDiv(width, patchSize) * patchSize
-  const paddedHeight = ceilDiv(height, patchSize) * patchSize
-  const directTokens = gridTokens(cells(paddedHeight), cells(paddedWidth))
-  if (directTokens <= maxTokens) {
-    return { width: paddedWidth, height: paddedHeight, tokens: directTokens, unscaled: true }
-  }
-  const cellSize = patchSize * downsampleRatio
-  const aspect = height / width
-  const idealColumns = Math.sqrt((maxTokens - 2) / aspect + 0.25) - 0.5
-  const idealRows = idealColumns * aspect
-  let bestWidth: number
-  let bestHeight: number
-  if (idealColumns < 1) {
-    bestWidth = cellSize
-    bestHeight = intDiv(maxTokens - 2, 2) * cellSize
-  } else if (idealRows < 1) {
-    bestWidth = (maxTokens - 3) * cellSize
-    bestHeight = cellSize
-  } else {
-    const columns = Math.trunc(idealColumns)
-    const rows = Math.trunc(idealRows)
-    const scale = Math.min(columns * cellSize / width, rows * cellSize / height)
-    bestWidth = Math.trunc(width * scale / patchSize) * patchSize
-    bestHeight = Math.trunc(height * scale / patchSize) * patchSize
-  }
-  return {
-    width: bestWidth,
-    height: bestHeight,
-    tokens: gridTokens(cells(bestHeight), cells(bestWidth)),
-    unscaled: false,
-  }
-}
-
-/** Aspect-preserving dimensions with an exact long edge; the short edge rounds to the nearest pixel. */
-function fromLongEdge(width: number, height: number, longEdge: number): ProjectedDimensions {
-  return width >= height
-    ? { width: longEdge, height: Math.max(1, Math.round(longEdge * height / width)) }
-    : { width: Math.max(1, Math.round(longEdge * width / height)), height: longEdge }
-}
-
-function projectDimensions(width: number, height: number, projection: ImageRequestProjection): ProjectedDimensions {
-  switch (projection.kind) {
-    case 'pixel-budget':
-      return pixelBudgetDimensions(width, height, projection.maxPixels)
-    case 'token-grid': {
-      const fit = tokenGridProjection(width, height, projection)
-      if (fit.unscaled) return { width, height }
-      return fromLongEdge(width, height, width >= height ? fit.width : fit.height)
-    }
-    /* v8 ignore next 4 -- ImageRequestProjection is a closed union; this branch is only the static exhaustiveness guard. */
-    default: {
-      const unreachable: never = projection
-      throw new Error(`unknown image request projection ${JSON.stringify(unreachable)}`)
-    }
-  }
-}
-
-/**
- * Compute the aspect-preserving integer dimensions of one request image:
- * the policy projection first, then the optional per-side cap. Small images
- * are never enlarged. A downscaled result keeps the source long edge exact
- * and rounds the short edge, matching a long-edge-only encoder resize.
+ * Compute aspect-preserving integer dimensions with an exact long edge; the
+ * short edge rounds to the nearest pixel, as an encoder resize by the
+ * long edge alone.
  * @param width - positive source width.
  * @param height - positive source height.
- * @param policy - route projection rule and optional per-side cap.
- * @returns integer dimensions.
+ * @param longEdge - positive target for the longer source edge.
+ * @returns the target dimensions; a long edge at or above the source returns the source unchanged.
  */
-export function requestImageDimensions(
+export function longEdgeDimensions(
   width: number,
   height: number,
-  policy: Pick<ImageRequestPolicy, 'projection' | 'maxDimension'>,
+  longEdge: number,
 ): ProjectedDimensions {
-  const projected = projectDimensions(width, height, policy.projection)
-  const longEdge = Math.max(projected.width, projected.height)
-  if (policy.maxDimension === undefined || longEdge <= policy.maxDimension) return projected
-  return fromLongEdge(width, height, policy.maxDimension)
+  if (longEdge >= Math.max(width, height)) return { width, height }
+  return width >= height
+    ? { width: longEdge, height: Math.max(1, Math.round(longEdge * height / width)) }
+    : { width: Math.max(1, Math.round(longEdge * width / height)), height: longEdge }
 }

+ 4 - 38
packages/attachment/attachment/src/types.ts

@@ -132,46 +132,12 @@ export interface StoredImageAttachment {
   data: Uint8Array
 }
 
-/** Aspect-preserving downscale rule of one request image; small images are never enlarged. */
-export type ImageRequestProjection =
-  | {
-    /** Hard cap on width multiplied by height. */
-    kind: 'pixel-budget'
-    /** Maximum width multiplied by height after projection. */
-    maxPixels: number
-  }
-  | {
-    /**
-     * Largest aspect-preserving patch grid whose token count
-     * `rows × (columns + 1) + 2` fits `maxTokens`; the DeepSeek published vision layout.
-     */
-    kind: 'token-grid'
-    /** Patch edge in pixels; a downscaled edge is a whole number of patches. */
-    patchSize: number
-    /** Patches per token cell along each axis. */
-    downsampleRatio: number
-    /** Token cap for one image. */
-    maxTokens: number
-  }
-
-/** Dimensions a `token-grid` projection retains for one image and the tokens it charges. */
-export interface TokenGridProjection {
-  /** Retained width: the patch-padded source when it fits, otherwise the solved width. */
+/** Deterministic request-image target selected by one exact model route for one attachment. */
+export interface ImageRequestTarget {
+  /** Target width in pixels; a target above the source keeps the source width. */
   width: number
-  /** Retained height: the patch-padded source when it fits, otherwise the solved height. */
+  /** Target height in pixels; a target above the source keeps the source height. */
   height: number
-  /** Tokens charged for the retained grid. */
-  tokens: number
-  /** Whether the patch-padded source already fits `maxTokens` without downscaling. */
-  unscaled: boolean
-}
-
-/** Deterministic request-image policy selected by one exact model route. */
-export interface ImageRequestPolicy {
-  /** Downscale rule applied before the per-side cap. */
-  projection: ImageRequestProjection
-  /** Maximum width and maximum height after projection; omission bounds the long edge by the projection alone. */
-  maxDimension?: number
   /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */
   maxBytes: number
 }

+ 4 - 4
packages/attachment/attachment/tests/index.spec.ts

@@ -8,7 +8,7 @@ import AttachmentStore, {
   isImageAdmissionError,
   type ImageAttachmentRef,
   type ImageMediaType,
-  type ImageRequestPolicy,
+  type ImageRequestTarget,
   type RequestImageAttachment,
   type SaveFileAttachment,
   type SaveImageAttachment,
@@ -56,7 +56,7 @@ class RecordingStore extends AttachmentStore {
 
   override readImageRequest(
     ref: ImageAttachmentRef,
-    _policy: ImageRequestPolicy,
+    _target: ImageRequestTarget,
   ): Promise<RequestImageAttachment> {
     this.calls.push(`request:${ref.name}`)
     return Promise.resolve({
@@ -154,12 +154,12 @@ describe('AttachmentStore.readImageRequest', () => {
   it('reports unsupported request projection while preserving cancellation', async () => {
     const store = new UnsupportedProjectionStore(new Context())
     const ref = await new RecordingStore(new Context()).saveImage(image(1))
-    await expect(store.readImageRequest(ref, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1 }))
+    await expect(store.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1 }))
       .rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' })
     const controller = new AbortController()
     const reason = new Error('cancel unsupported projection')
     controller.abort(reason)
-    expect(() => store.readImageRequest(ref, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1 }, controller.signal)).toThrow(reason)
+    expect(() => store.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1 }, controller.signal)).toThrow(reason)
   })
 
   it('rejects generic-file storage and exposes no provider-owned host path by default', async () => {

+ 14 - 56
packages/attachment/attachment/tests/request-projection.spec.ts

@@ -1,18 +1,14 @@
 import { describe, expect, it } from 'vitest'
-import { requestImageDimensions, tokenGridProjection } from '../src/index.ts'
-import type { ImageRequestProjection } from '../src/index.ts'
+import { longEdgeDimensions, requestImageDimensions } from '../src/index.ts'
 
-const PIXEL_BUDGET: ImageRequestProjection = { kind: 'pixel-budget', maxPixels: 640_000 }
-const TOKEN_GRID = { kind: 'token-grid', patchSize: 14, downsampleRatio: 3, maxTokens: 1024 } as const
-
-describe('pixel-budget projection', () => {
+describe('request image dimensions', () => {
   it.each([
     [4096, 4096, 800, 800],
     [4096, 2048, 1130, 565],
     [3840, 2160, 1066, 600],
     [320, 240, 320, 240],
   ])('projects %sx%s under 640,000 pixels as %sx%s', (width, height, expectedWidth, expectedHeight) => {
-    const projected = requestImageDimensions(width, height, { projection: PIXEL_BUDGET })
+    const projected = requestImageDimensions(width, height, 640_000)
     expect(projected).toEqual({
       width: expectedWidth,
       height: expectedHeight,
@@ -21,65 +17,27 @@ describe('pixel-budget projection', () => {
   })
 
   it('projects a portrait within the same total-pixel budget', () => {
-    const projected = requestImageDimensions(2160, 3840, { projection: PIXEL_BUDGET })
+    const projected = requestImageDimensions(2160, 3840, 640_000)
 
     expect(projected).toEqual({ width: 600, height: 1066 })
     expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
   })
 
   it('rounds a portrait inward when integer aspect rounding crosses the pixel cap', () => {
-    expect(requestImageDimensions(2, 4, { projection: { kind: 'pixel-budget', maxPixels: 5 } })).toEqual({ width: 1, height: 2 })
-  })
-})
-
-describe('token-grid projection', () => {
-  it('keeps the patch-padded source when its grid fits the token cap', () => {
-    expect(tokenGridProjection(800, 800, TOKEN_GRID)).toEqual({ width: 812, height: 812, tokens: 422, unscaled: true })
-    expect(tokenGridProjection(1302, 1302, TOKEN_GRID)).toEqual({ width: 1302, height: 1302, tokens: 994, unscaled: true })
-  })
-
-  it.each([
-    [1303, 1303, 1302, 1302, 994],
-    [4096, 4096, 1302, 1302, 994],
-    [3840, 2160, 1708, 966, 968],
-    [2160, 3840, 966, 1708, 986],
-    [4000, 1000, 2520, 630, 917],
-  ])('solves %sx%s onto the largest in-cap grid %sx%s', (width, height, expectedWidth, expectedHeight, tokens) => {
-    expect(tokenGridProjection(width, height, TOKEN_GRID)).toEqual({
-      width: expectedWidth,
-      height: expectedHeight,
-      tokens,
-      unscaled: false,
-    })
-    expect(expectedWidth % TOKEN_GRID.patchSize).toBe(0)
-    expect(expectedHeight % TOKEN_GRID.patchSize).toBe(0)
-  })
-
-  it('solves one-row and one-column grids for extreme aspect ratios', () => {
-    expect(tokenGridProjection(100_000, 1, TOKEN_GRID)).toEqual({ width: 42_882, height: 42, tokens: 1024, unscaled: false })
-    expect(tokenGridProjection(1, 100_000, TOKEN_GRID)).toEqual({ width: 42, height: 21_462, tokens: 1024, unscaled: false })
-  })
-
-  it('sends a fitting source at its own dimensions and a larger source at the solved grid', () => {
-    expect(requestImageDimensions(800, 800, { projection: TOKEN_GRID })).toEqual({ width: 800, height: 800 })
-    expect(requestImageDimensions(8192, 78, { projection: TOKEN_GRID })).toEqual({ width: 8192, height: 78 })
-    expect(requestImageDimensions(2048, 1024, { projection: TOKEN_GRID })).toEqual({ width: 1848, height: 924 })
-  })
-
-  it('keeps the solved long edge and rounds the short edge to the source aspect ratio', () => {
-    expect(requestImageDimensions(3840, 2160, { projection: TOKEN_GRID })).toEqual({ width: 1708, height: 961 })
-    expect(requestImageDimensions(1080, 2400, { projection: TOKEN_GRID })).toEqual({ width: 838, height: 1862 })
+    expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 })
   })
 })
 
-describe('per-side cap', () => {
-  it('scales the projected image down to the cap on its long edge', () => {
-    expect(requestImageDimensions(8192, 78, { projection: TOKEN_GRID, maxDimension: 4096 })).toEqual({ width: 4096, height: 39 })
-    expect(requestImageDimensions(1, 8192, { projection: TOKEN_GRID, maxDimension: 4096 })).toEqual({ width: 1, height: 4096 })
-    expect(requestImageDimensions(10_000, 100, { projection: PIXEL_BUDGET, maxDimension: 4096 })).toEqual({ width: 4096, height: 41 })
+describe('long-edge dimensions', () => {
+  it('keeps the long edge exact and rounds the short edge', () => {
+    expect(longEdgeDimensions(8000, 40, 4096)).toEqual({ width: 4096, height: 20 })
+    expect(longEdgeDimensions(10_000, 100, 4096)).toEqual({ width: 4096, height: 41 })
+    expect(longEdgeDimensions(1080, 2400, 1862)).toEqual({ width: 838, height: 1862 })
+    expect(longEdgeDimensions(1, 9000, 4096)).toEqual({ width: 1, height: 4096 })
   })
 
-  it('leaves an image within the cap untouched', () => {
-    expect(requestImageDimensions(2048, 2048, { projection: TOKEN_GRID, maxDimension: 4096 })).toEqual({ width: 1302, height: 1302 })
+  it('never enlarges a source at or below the long edge', () => {
+    expect(longEdgeDimensions(320, 240, 320)).toEqual({ width: 320, height: 240 })
+    expect(longEdgeDimensions(320, 240, 4096)).toEqual({ width: 320, height: 240 })
   })
 })

+ 4 - 8
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -548,9 +548,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         throws: ['an AttachmentError when the durable reference is invalid.'],
       },
       {
-        signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise<RequestImageAttachment>',
+        signature: 'readImageRequest( ref: ImageAttachmentRef, target: ImageRequestTarget, signal?: AbortSignal, ): Promise<RequestImageAttachment>',
         description: 'Generate or read one deterministic model-request version from the stored normalized image.',
-        parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }],
+        parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'target', description: 'route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }],
         returns: 'request bytes and the cache/upload identity covering every transform input.',
       },
     ],
@@ -4359,12 +4359,8 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     declaration: 'export type ImageMediaType = \'image/png\' | \'image/jpeg\' | \'image/webp\' | \'image/gif\';',
   },
   {
-    name: 'ImageRequestPolicy',
-    declaration: 'export interface ImageRequestPolicy {\n    projection: ImageRequestProjection;\n    maxDimension?: number;\n    maxBytes: number;\n}',
-  },
-  {
-    name: 'ImageRequestProjection',
-    declaration: 'export type ImageRequestProjection = {\n    kind: \'pixel-budget\';\n    maxPixels: number;\n} | {\n    kind: \'token-grid\';\n    patchSize: number;\n    downsampleRatio: number;\n    maxTokens: number;\n};',
+    name: 'ImageRequestTarget',
+    declaration: 'export interface ImageRequestTarget {\n    width: number;\n    height: number;\n    maxBytes: number;\n}',
   },
   {
     name: 'ImageVariantId',

+ 2 - 2
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: c99431b0d415ef82d4833d7ca38b531b3cd38937
-README.zh.md: 1d515c5ee8a855981e529b7fff03cbf18fa9dde7
+README.md: b3ccd5bc7ed8c66ac4c0a5212d834fa8551d553d
+README.zh.md: edfd5e89292f1e9ad478ccedb5601a7e95daea45

+ 1 - 1
packages/llm/llm-deepseek/README.md

@@ -74,7 +74,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 ### Streaming with thinking and images
 
-An image-capable route resolves each durable reference into a deterministic request version under its projection, per-side cap, and byte target. Omitting `imagePixelBudget` projects onto the published vision token grid of 14px patches, 3:1 downsampling, and at most 1024 tokens per image, so a square image keeps up to 1302×1302 pixels and other aspect ratios keep the largest grid inside the cap; a positive integer replaces the grid with a total-pixel budget, and `low` uses 512×512 total pixels. Every request image is capped at 4096 pixels per side, the provider limit for requests carrying 15 or more images, and `imageMaxBytes` defaults to 2 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references.
+An image-capable route chooses each durable reference's request target and resolves it into a deterministic request version. Omitting `imagePixelBudget` sizes the target on the published vision token grid of 14px patches, 3:1 downsampling, and at most 1024 tokens per image, so a square image keeps up to 1302×1302 pixels and a 16:9 image is sent as 1708×961 for the provider's 1708×966 grid; a positive integer replaces the grid with a total-pixel budget, and `low` uses 512×512 total pixels. Every request image is capped at 4096 pixels per side, the provider limit for requests carrying 15 or more images, and `imageMaxBytes` defaults to 2 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references.
 
 The adapter normally uploads those exact request bytes through the DeepSeek Files API and sends file-id blocks. A failed or timed-out file resolution rebuilds the whole chat request with the same request versions as base64 data URLs; one request never mixes file ids and inline images. Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry.
 

+ 1 - 1
packages/llm/llm-deepseek/README.zh.md

@@ -74,7 +74,7 @@ kind: "package-reference"
 
 ### 带 thinking 与图片的流式调用
 
-支持图片的路由会在自身投影、单边上限与字节目标内把每个持久引用解析为确定性请求版本。省略 `imagePixelBudget` 时按官方公布的视觉 token 网格投影,即 14 px patch、3:1 降采样、单图最多 1024 token,因此正方形图片最多保留 1302×1302 像素,其他宽高比保留上限内最大的网格;正整数会用总像素预算取代网格,`low` 使用总计 512×512 像素。每张请求图片单边最多 4096 像素,这是提供方对包含 15 张及以上图片的请求的限制;`imageMaxBytes` 默认为 2 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。
+支持图片的路由为每个持久引用选定请求目标,再把它解析为确定性请求版本。省略 `imagePixelBudget` 时按官方公布的视觉 token 网格定目标,即 14 px patch、3:1 降采样、单图最多 1024 token,因此正方形图片最多保留 1302×1302 像素,16:9 图片以 1708×961 发送、对应提供方 1708×966 的网格;正整数会用总像素预算取代网格,`low` 使用总计 512×512 像素。每张请求图片单边最多 4096 像素,这是提供方对包含 15 张及以上图片的请求的限制;`imageMaxBytes` 默认为 2 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。
 
 适配器通常通过 DeepSeek Files API 上传这些确切请求字节,并发送 file-id 块。文件解析失败或超时会用相同请求版本的 base64 data URL 重建整份 chat 请求;一次请求绝不混用 file id 与内联图片。缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。
 

+ 5 - 6
packages/llm/llm-deepseek/src/adapter.ts

@@ -38,7 +38,7 @@ import type {
 } from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
 import { serializeRequest, serializeRequestWithImages } from './serialize.ts'
 import type { ImageWireLocation, RequestDefaults } from './serialize.ts'
-import { deepSeekImageRequestPricing, resolveRequestImagePolicy } from './request-pricing.ts'
+import { deepSeekImageRequestPricing, resolveRequestImageMaxBytes, resolveRequestImageTarget } from './request-pricing.ts'
 import { DeepSeekFileStore } from './file-store.ts'
 import type { DeepSeekFilePolicy } from './file-store.ts'
 import type { DeepSeekFileId } from './file-id.ts'
@@ -229,10 +229,9 @@ async function prepareRequestImages(
 ): Promise<Map<AttachmentId, RequestImageAttachment>> {
   const refs = new Map<AttachmentId, ImageAttachmentRef>()
   for (const message of options.messages) collectImageRefs(message.content, refs)
-  const policy = resolveRequestImagePolicy(model)
   const orderedRefs = [...refs.values()]
   const projected = await Promise.all(orderedRefs.map(
-    ref => attachments.readImageRequest(ref, policy, signal),
+    ref => attachments.readImageRequest(ref, resolveRequestImageTarget(model, ref), signal),
   ))
   return new Map(orderedRefs.map((ref, index) => (
     [ref.attachmentId, projected[index] as RequestImageAttachment]
@@ -556,18 +555,18 @@ export class DeepSeekAdapter extends LlmAdapter {
 
     const fileConnection = { baseURL: connection.baseURL, apiKey }
     const model = connection.models.find(entry => entry.id === options.model)
-    const policy = model === undefined ? undefined : resolveRequestImagePolicy(model)
+    const maxBytes = model === undefined ? undefined : resolveRequestImageMaxBytes(model)
     const resolveImageAccess = attachments === undefined
       ? undefined
       : (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => this.config.resolveImageAccess?.(attachments, ref)
     const imageAccessOptions = resolveImageAccess === undefined ? {} : { resolveImageAccess }
-    const requestMessages = policy === undefined ? options.messages : offloadRequestImagesWithPolicy(options.messages, {
+    const requestMessages = maxBytes === undefined ? options.messages : offloadRequestImagesWithPolicy(options.messages, {
       representation: 'raw',
       maxBytes: connection.maxRequestFilesBytes,
       maxImages: connection.maxImagesPerRequest,
       byteQuantum: connection.imageOffloadByteQuantum,
       countQuantum: connection.imageOffloadCountQuantum,
-      byteLength: ref => Math.min(ref.bytes, policy.maxBytes),
+      byteLength: ref => Math.min(ref.bytes, maxBytes),
       placeholder: ref => offloadedImageText(ref, resolveImageAccess?.(ref)),
     })
     const requestOptions = requestMessages === options.messages ? options : { ...options, messages: [...requestMessages] }

+ 127 - 27
packages/llm/llm-deepseek/src/image-tokens.ts

@@ -1,39 +1,139 @@
 /**
  * DeepSeek vision-token accounting: the provider's published image-token
- * calculator (api-docs.deepseek.com, Token & Token Usage) in its current
- * `v41` configuration. The provider scales an image below 544×544 total
- * pixels up, then projects it onto the token grid shared with request
- * projection, iterating to a fixpoint. The count is exact: this configuration
- * has no alignment pad and no aspect-ratio clamp. Actual usage remains
- * authoritative.
+ * calculator (api-docs.deepseek.com, Token & Token Usage) ported verbatim in
+ * its current `v41` configuration. The provider scales an image below
+ * 544×544 total pixels up, aligns it to a 14px-patch grid, downsamples 3:1
+ * per axis into token cells, and caps one image at 1024 tokens by solving the
+ * largest aspect-preserving grid inside that budget. The count is exact: this
+ * configuration has no alignment pad and no aspect-ratio clamp. Actual usage
+ * remains authoritative.
  *
  * @module dsh-llm-deepseek/image-tokens
  */
 
-import { tokenGridProjection } from '@deepseek-ai/dsh-attachment'
-import type { ImageRequestProjection, TokenGridProjection } from '@deepseek-ai/dsh-attachment'
-
-/** Published DeepSeek vision grid: 14px patches, 3:1 per-axis downsampling, at most 1024 tokens per image. */
-export const DEEPSEEK_IMAGE_TOKEN_GRID = {
-  kind: 'token-grid',
-  patchSize: 14,
-  downsampleRatio: 3,
-  maxTokens: 1024,
-} as const satisfies ImageRequestProjection
-
+import { longEdgeDimensions } from '@deepseek-ai/dsh-attachment'
+import type { ProjectedDimensions } from '@deepseek-ai/dsh-attachment'
+/** Vision patch edge in pixels. */
+const PATCH_SIZE = 14
+/** Per-axis patch-to-token downsampling ratio. */
+const DOWNSAMPLE_RATIO = 3
+/** Provider cap on tokens for one request image. */
+const MAX_IMAGE_TOKENS = 1024
 /** Total-pixel floor; smaller images are scaled up before grid projection. */
 const MIN_PIXELS = 544 * 544
+/** Pixels covered by one token cell along either axis. */
+const CELL_SIZE = PATCH_SIZE * DOWNSAMPLE_RATIO
+
+const intDiv = (value: number, divisor: number): number => Math.floor(value / divisor)
+const ceilDiv = (value: number, divisor: number): number => Math.floor((value + divisor - 1) / divisor)
 
-/** One scale-up-then-project pass; the caller iterates it to a fixpoint. */
-function resizeOnce(width: number, height: number): TokenGridProjection {
-  const pixels = width * height
-  if (pixels >= MIN_PIXELS) return tokenGridProjection(width, height, DEEPSEEK_IMAGE_TOKEN_GRID)
-  const scale = Math.sqrt(MIN_PIXELS / pixels)
-  return tokenGridProjection(Math.trunc(width * scale), Math.trunc(height * scale), DEEPSEEK_IMAGE_TOKEN_GRID)
+interface GridResize {
+  readonly gridHeight: number
+  readonly gridWidth: number
+  readonly bestHeight: number
+  readonly bestWidth: number
+  readonly numTokens: number
 }
 
-function sameResize(a: TokenGridProjection, b: TokenGridProjection): boolean {
-  return a.width === b.width && a.height === b.height && a.tokens === b.tokens
+/** Token count of one grid: every row carries a separator, plus two framing tokens. */
+function gridTokens(gridHeight: number, gridWidth: number): number {
+  return gridHeight * (gridWidth + 1) + 2
+}
+
+/** Token-cell count along one padded pixel axis. */
+function gridCells(paddedLength: number): number {
+  return ceilDiv(intDiv(paddedLength, PATCH_SIZE), DOWNSAMPLE_RATIO)
+}
+
+/** Solve the largest grid within `budget` tokens preserving the aspect ratio. */
+function solveResizeRatio(height: number, width: number, budget: number): GridResize {
+  const aspect = height / width
+  const idealGridWidth = Math.sqrt((budget - 2) / aspect + 0.25) - 0.5
+  const idealGridHeight = idealGridWidth * aspect
+  let bestHeight: number
+  let bestWidth: number
+  if (idealGridWidth < 1) {
+    const solvedGridWidth = 1
+    const solvedGridHeight = intDiv(budget - 2, solvedGridWidth + 1)
+    bestWidth = solvedGridWidth * CELL_SIZE
+    bestHeight = solvedGridHeight * CELL_SIZE
+  } else if (idealGridHeight < 1) {
+    const solvedGridHeight = 1
+    const solvedGridWidth = intDiv(budget - 2, solvedGridHeight) - 1
+    bestWidth = solvedGridWidth * CELL_SIZE
+    bestHeight = solvedGridHeight * CELL_SIZE
+  } else {
+    const solvedGridWidth = Math.trunc(idealGridWidth)
+    const solvedGridHeight = Math.trunc(idealGridHeight)
+    const scale = Math.min(solvedGridWidth * CELL_SIZE / width, solvedGridHeight * CELL_SIZE / height)
+    bestWidth = Math.trunc(width * scale / PATCH_SIZE) * PATCH_SIZE
+    bestHeight = Math.trunc(height * scale / PATCH_SIZE) * PATCH_SIZE
+  }
+  const gridHeight = gridCells(bestHeight)
+  const gridWidth = gridCells(bestWidth)
+  return { gridHeight, gridWidth, bestHeight, bestWidth, numTokens: gridTokens(gridHeight, gridWidth) }
+}
+
+/** Project padded pixel dimensions onto the largest in-budget token grid. */
+function safeResize(height: number, width: number, paddedHeight: number, paddedWidth: number): GridResize {
+  const gridHeight = gridCells(paddedHeight)
+  const gridWidth = gridCells(paddedWidth)
+  const direct: GridResize = {
+    gridHeight,
+    gridWidth,
+    bestHeight: paddedHeight,
+    bestWidth: paddedWidth,
+    numTokens: gridTokens(gridHeight, gridWidth),
+  }
+  if (direct.numTokens <= MAX_IMAGE_TOKENS) return direct
+  const solved = solveResizeRatio(height, width, MAX_IMAGE_TOKENS)
+  /* v8 ignore next 3 -- the published solver's assertion; the closed-form
+     solve stays within the budget for every positive geometry. */
+  if (solved.numTokens > MAX_IMAGE_TOKENS) {
+    throw new Error(`deepseek image tokens: no grid fits the token budget for ${width}x${height}`)
+  }
+  return solved
+}
+
+/** One scale-pad-project pass; the caller iterates it to a fixpoint. */
+function resizeOnce(width: number, height: number): GridResize {
+  let scaledWidth = width
+  let scaledHeight = height
+  const pixels = scaledWidth * scaledHeight
+  if (pixels < MIN_PIXELS && pixels > 0) {
+    const scale = Math.sqrt(MIN_PIXELS / pixels)
+    scaledWidth = Math.trunc(scaledWidth * scale)
+    scaledHeight = Math.trunc(scaledHeight * scale)
+  }
+  const paddedWidth = ceilDiv(scaledWidth, PATCH_SIZE) * PATCH_SIZE
+  const paddedHeight = ceilDiv(scaledHeight, PATCH_SIZE) * PATCH_SIZE
+  return safeResize(scaledHeight, scaledWidth, paddedHeight, paddedWidth)
+}
+
+function sameResize(a: GridResize, b: GridResize): boolean {
+  return a.gridHeight === b.gridHeight
+    && a.gridWidth === b.gridWidth
+    && a.bestHeight === b.bestHeight
+    && a.bestWidth === b.bestWidth
+    && a.numTokens === b.numTokens
+}
+
+/**
+ * Dimensions the harness sends so the provider keeps the whole image: the
+ * source itself when its patch-padded grid fits the token cap, otherwise the
+ * source aspect ratio at the solved grid's long edge. The provider pads the
+ * short edge to whole patches on its side, so the token count equals the
+ * solved grid's. Small images are never enlarged.
+ * @param width - positive integer source width in pixels.
+ * @param height - positive integer source height in pixels.
+ * @returns the request dimensions to encode.
+ */
+export function deepSeekRequestImageDimensions(width: number, height: number): ProjectedDimensions {
+  const paddedWidth = ceilDiv(width, PATCH_SIZE) * PATCH_SIZE
+  const paddedHeight = ceilDiv(height, PATCH_SIZE) * PATCH_SIZE
+  if (gridTokens(gridCells(paddedHeight), gridCells(paddedWidth)) <= MAX_IMAGE_TOKENS) return { width, height }
+  const solved = solveResizeRatio(height, width, MAX_IMAGE_TOKENS)
+  return longEdgeDimensions(width, height, width >= height ? solved.bestWidth : solved.bestHeight)
 }
 
 /**
@@ -46,8 +146,8 @@ function sameResize(a: TokenGridProjection, b: TokenGridProjection): boolean {
 export function deepSeekImageTokens(width: number, height: number): number {
   let result = resizeOnce(width, height)
   for (let iteration = 1; iteration < 10; iteration += 1) {
-    const next = resizeOnce(result.width, result.height)
-    if (sameResize(next, result)) return result.tokens
+    const next = resizeOnce(result.bestWidth, result.bestHeight)
+    if (sameResize(next, result)) return result.numTokens
     result = next
   }
   /* v8 ignore next 2 -- the published solver's non-convergence guard; every

+ 3 - 2
packages/llm/llm-deepseek/src/index.ts

@@ -65,9 +65,10 @@ export {
   DEFAULT_REQUEST_IMAGE_MAX_BYTES,
   REQUEST_IMAGE_MAX_DIMENSION,
   deepSeekImageRequestPricing,
-  resolveRequestImagePolicy,
+  resolveRequestImageMaxBytes,
+  resolveRequestImageTarget,
 } from './request-pricing.ts'
-export { DEEPSEEK_IMAGE_TOKEN_GRID, deepSeekImageTokens } from './image-tokens.ts'
+export { deepSeekImageTokens, deepSeekRequestImageDimensions } from './image-tokens.ts'
 export { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from './file-store.ts'
 export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './file-store.ts'
 export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './files-api.ts'

+ 37 - 25
packages/llm/llm-deepseek/src/request-pricing.ts

@@ -1,6 +1,6 @@
 /**
  * Provider-side request-image pricing for DeepSeek routes: reproduces the
- * adapter's deterministic request projection (per-model projection,
+ * adapter's deterministic request projection (per-model request target,
  * oldest-first offload under the raw-byte and count budgets) and prices every
  * retained image with the published vision-token accounting. Consumed
  * synchronously by the token meter through `LlmAdapter.imageRequestPricing`;
@@ -11,9 +11,9 @@
 
 import { offloadedImageText, offloadedImagePrefixCount, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm'
 import type { ImageAttachmentAccessResolver, LlmImageRequestPrice, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm'
-import { requestImageDimensions } from '@deepseek-ai/dsh-attachment'
-import type { ImageAttachmentRef, ImageRequestPolicy, ImageRequestProjection } from '@deepseek-ai/dsh-attachment'
-import { DEEPSEEK_IMAGE_TOKEN_GRID, deepSeekImageTokens } from './image-tokens.ts'
+import { longEdgeDimensions, requestImageDimensions } from '@deepseek-ai/dsh-attachment'
+import type { ImageAttachmentRef, ImageRequestTarget } from '@deepseek-ai/dsh-attachment'
+import { deepSeekImageTokens, deepSeekRequestImageDimensions } from './image-tokens.ts'
 import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
 
 /** Default bound on accumulated file-referenced image bytes per request. */
@@ -31,25 +31,37 @@ export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 2 * 1024 * 1024
 export const REQUEST_IMAGE_MAX_DIMENSION = 4096
 
 /**
- * Resolve the request-image policy owned by one DeepSeek model route: the
- * published token grid unless the model overrides it with a pixel budget,
- * the provider per-side limit, and the encoded-byte target.
+ * Resolve the encoded-byte target one DeepSeek model route applies to every request image.
  * @param model - Advertised model route and its optional image overrides.
- * @returns Complete projection, per-side cap, and encoded-byte target.
+ * @returns the route's encoded-byte target.
  * @internal
  */
-export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy {
-  const projection: ImageRequestProjection = model.imagePixelBudget === undefined
-    ? DEEPSEEK_IMAGE_TOKEN_GRID
-    : {
-      kind: 'pixel-budget',
-      maxPixels: model.imagePixelBudget === 'low' ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET : model.imagePixelBudget,
-    }
-  return {
-    projection,
-    maxDimension: REQUEST_IMAGE_MAX_DIMENSION,
-    maxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES,
-  }
+export function resolveRequestImageMaxBytes(model: DeepSeekCatalogModel): number {
+  return model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES
+}
+
+/**
+ * Resolve the deterministic request target one DeepSeek model route chooses
+ * for one source image: the published token grid unless the model overrides
+ * it with a pixel budget, then the provider per-side limit, then the route's
+ * encoded-byte target. Small images are never enlarged.
+ * @param model - Advertised model route and its optional image overrides.
+ * @param source - intrinsic dimensions of the normalized attachment.
+ * @returns Complete request dimensions and encoded-byte target.
+ * @internal
+ */
+export function resolveRequestImageTarget(
+  model: DeepSeekCatalogModel,
+  source: Pick<ImageAttachmentRef, 'width' | 'height'>,
+): ImageRequestTarget {
+  const budget = model.imagePixelBudget === 'low' ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET : model.imagePixelBudget
+  const projected = budget === undefined
+    ? deepSeekRequestImageDimensions(source.width, source.height)
+    : requestImageDimensions(source.width, source.height, budget)
+  const capped = Math.max(projected.width, projected.height) > REQUEST_IMAGE_MAX_DIMENSION
+    ? longEdgeDimensions(source.width, source.height, REQUEST_IMAGE_MAX_DIMENSION)
+    : projected
+  return { ...capped, maxBytes: resolveRequestImageMaxBytes(model) }
 }
 
 /**
@@ -86,11 +98,11 @@ export function deepSeekImageRequestPricing(
   if (catalogModel?.inputModalities?.includes('image') !== true) {
     return { priceImages: images => images.map(textOnlyPrice) }
   }
-  const policy = resolveRequestImagePolicy(catalogModel)
+  const maxBytes = resolveRequestImageMaxBytes(catalogModel)
   return {
     priceImages: (images) => {
       const offloaded = offloadedImagePrefixCount(
-        images.map(ref => Math.min(ref.bytes, policy.maxBytes)),
+        images.map(ref => Math.min(ref.bytes, maxBytes)),
         {
           maxBytes: connection.maxRequestFilesBytes,
           maxImages: connection.maxImagesPerRequest,
@@ -102,10 +114,10 @@ export function deepSeekImageRequestPricing(
         if (index < offloaded) {
           return { visualTokens: 0, text: offloadedImageText(ref, resolveAccess?.(ref)) }
         }
-        const dimensions = requestImageDimensions(ref.width, ref.height, policy)
+        const target = resolveRequestImageTarget(catalogModel, ref)
         return {
-          visualTokens: deepSeekImageTokens(dimensions.width, dimensions.height),
-          text: requestImageHandleText(ref, dimensions, resolveAccess?.(ref)),
+          visualTokens: deepSeekImageTokens(target.width, target.height),
+          text: requestImageHandleText(ref, target, resolveAccess?.(ref)),
         }
       })
     },

+ 2 - 2
packages/llm/llm-deepseek/tests/adapter.e2e.ts

@@ -14,7 +14,7 @@ import LocalAttachments from '@deepseek-ai/dsh-attachment-local'
 import type {
   ImageAttachmentLimits,
   ImageAttachmentRef,
-  ImageRequestPolicy,
+  ImageRequestTarget,
   RequestImageAttachment,
   SaveImageAttachment,
   StoredImageAttachment,
@@ -92,7 +92,7 @@ class E2eAttachmentStore extends AttachmentStore {
 
   override readImageRequest(
     _ref: ImageAttachmentRef,
-    _policy: ImageRequestPolicy,
+    _target: ImageRequestTarget,
     _signal?: AbortSignal,
   ): Promise<RequestImageAttachment> {
     return Promise.resolve(this.version)

+ 19 - 12
packages/llm/llm-deepseek/tests/adapter.spec.ts

@@ -20,9 +20,9 @@ import { SessionId } from '@deepseek-ai/dsh-session'
 import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
 import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
 import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
-import { DEEPSEEK_IMAGE_TOKEN_GRID, DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
+import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
 import { httpErrorCode } from '../src/adapter.ts'
-import { resolveRequestImagePolicy } from '../src/request-pricing.ts'
+import { resolveRequestImageTarget } from '../src/request-pricing.ts'
 import { assemble } from './assemble.ts'
 import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
 import type { Behavior } from './mock-server.ts'
@@ -141,22 +141,29 @@ function successfulSseResponse(): Response {
   })
 }
 
-describe('request image policy', () => {
+describe('request image target', () => {
   it.each([
     [
       { id: 'default' },
-      { projection: DEEPSEEK_IMAGE_TOKEN_GRID, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 },
+      { width: 1302, height: 1302, maxBytes: 2 * 1024 * 1024 },
     ],
     [
       { id: 'low', imagePixelBudget: 'low' as const },
-      { projection: { kind: 'pixel-budget' as const, maxPixels: 512 * 512 }, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 },
+      { width: 512, height: 512, maxBytes: 2 * 1024 * 1024 },
     ],
     [
       { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 },
-      { projection: { kind: 'pixel-budget' as const, maxPixels: 320_000 }, maxDimension: 4096, maxBytes: 512_000 },
+      { width: 565, height: 565, maxBytes: 512_000 },
     ],
-  ])('resolves route-owned defaults and overrides for %s', (model, expected) => {
-    expect(resolveRequestImagePolicy(model)).toEqual(expected)
+  ])('resolves route-owned defaults and overrides for %s on a 4096x4096 source', (model, expected) => {
+    expect(resolveRequestImageTarget(model, { width: 4096, height: 4096 })).toEqual(expected)
+  })
+
+  it('caps every request image at the provider per-side limit', () => {
+    expect(resolveRequestImageTarget({ id: 'default' }, { width: 8192, height: 78 }))
+      .toEqual({ width: 4096, height: 39, maxBytes: 2 * 1024 * 1024 })
+    expect(resolveRequestImageTarget({ id: 'custom', imagePixelBudget: 640_000 }, { width: 10_000, height: 100 }))
+      .toEqual({ width: 4096, height: 41, maxBytes: 2 * 1024 * 1024 })
   })
 
   it('answers image request pricing from the current connection snapshot', () => {
@@ -407,7 +414,7 @@ describe('DeepSeekAdapter against a mock server', () => {
       bytes: 3,
     }])
     expect(signalSeen[0]).toBeInstanceOf(AbortSignal)
-    expect(policies).toEqual([{ projection: DEEPSEEK_IMAGE_TOKEN_GRID, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }])
+    expect(policies).toEqual([{ width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }])
   })
 
   it('falls back to one all-base64 request when Files API resolution fails', async () => {
@@ -621,7 +628,7 @@ describe('DeepSeekAdapter against a mock server', () => {
 
     expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith(
       recent,
-      { projection: DEEPSEEK_IMAGE_TOKEN_GRID, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 },
+      { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 },
       expect.any(AbortSignal),
     )
     const body = server.requests[0] as { messages: unknown[] }
@@ -672,13 +679,13 @@ describe('DeepSeekAdapter against a mock server', () => {
     expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith(
       1,
       imageRef,
-      { projection: { kind: 'pixel-budget' as const, maxPixels: 512 * 512 }, maxDimension: 4096, maxBytes: 512_000 },
+      { width: 1, height: 1, maxBytes: 512_000 },
       expect.any(AbortSignal),
     )
     expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith(
       2,
       imageRef,
-      { projection: { kind: 'pixel-budget' as const, maxPixels: 320_000 }, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 },
+      { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 },
       expect.any(AbortSignal),
     )
   })

+ 2 - 2
packages/llm/llm-deepseek/tests/dynamic-config.spec.ts

@@ -8,7 +8,7 @@ import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-
 import type {
   ImageAttachmentLimits,
   ImageAttachmentRef,
-  ImageRequestPolicy,
+  ImageRequestTarget,
   RequestImageAttachment,
   SaveImageAttachment,
   StoredImageAttachment,
@@ -70,7 +70,7 @@ class StaticAttachmentStore extends AttachmentStore {
 
   override readImageRequest(
     ref: ImageAttachmentRef,
-    _policy: ImageRequestPolicy,
+    _target: ImageRequestTarget,
     _signal?: AbortSignal,
   ): Promise<RequestImageAttachment> {
     return Promise.resolve({

+ 24 - 1
packages/llm/llm-deepseek/tests/image-tokens.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { deepSeekImageTokens } from '../src/image-tokens.ts'
+import { deepSeekImageTokens, deepSeekRequestImageDimensions } from '../src/image-tokens.ts'
 
 describe('DeepSeek image tokens', () => {
   // Reference values from the provider's published image token calculator
@@ -47,3 +47,26 @@ describe('DeepSeek image tokens', () => {
     expect(deepSeekImageTokens(89, 2076)).toBe(254)
   })
 })
+
+describe('DeepSeek request image dimensions', () => {
+  it.each([
+    [800, 800, 800, 800],
+    [1302, 1302, 1302, 1302],
+    [8192, 78, 8192, 78],
+    [1, 9000, 1, 9000],
+  ])('sends %sx%s unchanged because its padded grid fits the cap', (width, height, expectedWidth, expectedHeight) => {
+    expect(deepSeekRequestImageDimensions(width, height)).toEqual({ width: expectedWidth, height: expectedHeight })
+  })
+
+  it.each([
+    [1303, 1303, 1302, 1302],
+    [2048, 2048, 1302, 1302],
+    [2048, 1024, 1848, 924],
+    [3840, 2160, 1708, 961],
+    [1080, 2400, 838, 1862],
+  ])('downscales %sx%s to %sx%s at the solved long edge', (width, height, expectedWidth, expectedHeight) => {
+    const sent = deepSeekRequestImageDimensions(width, height)
+    expect(sent).toEqual({ width: expectedWidth, height: expectedHeight })
+    expect(deepSeekImageTokens(sent.width, sent.height)).toBe(deepSeekImageTokens(width, height))
+  })
+})

+ 1 - 1
packages/llm/llm-pi-ai/src/adapter.ts

@@ -373,7 +373,7 @@ export class PiAiAdapter extends LlmAdapter {
           resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref),
           maxRequestImageBytes: profile.maxRequestImageBytes,
           requestImagePolicy: {
-            projection: { kind: 'pixel-budget', maxPixels: profile.requestImagePixelBudget },
+            maxPixels: profile.requestImagePixelBudget,
             maxBytes: profile.requestImageMaxBytes,
           },
         }, onReplayDegrade)

+ 19 - 5
packages/llm/llm-pi-ai/src/context.ts

@@ -11,11 +11,12 @@ import type {
   AttachmentId,
   AttachmentStore,
   ImageAttachmentRef,
-  ImageRequestPolicy,
+  ImageRequestTarget,
   RequestImageAttachment,
 } from '@deepseek-ai/dsh-attachment'
 import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai'
 import { toPiAssistant } from './replay.ts'
+import { requestImageDimensions } from '@deepseek-ai/dsh-attachment'
 import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET } from './config.ts'
 
 /** Join the text blocks of a harness message. */
@@ -102,14 +103,14 @@ function collectImageRefs(
 async function prepareRequestImages(
   messages: readonly Message[],
   attachments: AttachmentStore,
-  policy: ImageRequestPolicy,
+  budget: PiImageRequestBudget,
   signal?: AbortSignal,
 ): Promise<Map<AttachmentId, RequestImageAttachment>> {
   const refs = new Map<AttachmentId, ImageAttachmentRef>()
   for (const message of messages) collectImageRefs(message.content, refs)
   const orderedRefs = [...refs.values()]
   const prepared = await Promise.all(orderedRefs.map(
-    ref => attachments.readImageRequest(ref, policy, signal),
+    ref => attachments.readImageRequest(ref, requestImageTarget(ref, budget), signal),
   ))
   const versions = new Map<AttachmentId, RequestImageAttachment>()
   for (const [index, ref] of orderedRefs.entries()) {
@@ -222,7 +223,20 @@ export interface PiImageRequestContext {
   /** Request-level bound on base64-encoded image payload; omission leaves every image in place. */
   maxRequestImageBytes?: number
   /** Route pixel and raw encoded-byte budgets. */
-  requestImagePolicy?: ImageRequestPolicy
+  requestImagePolicy?: PiImageRequestBudget
+}
+
+/** Per-route budgets from which each request image's target is derived. */
+export interface PiImageRequestBudget {
+  /** Total-pixel budget; larger sources are downscaled proportionally. */
+  maxPixels: number
+  /** Encoded-byte target for one request image. */
+  maxBytes: number
+}
+
+/** Deterministic request target for one source under the route budgets. */
+function requestImageTarget(ref: ImageAttachmentRef, budget: PiImageRequestBudget): ImageRequestTarget {
+  return { ...requestImageDimensions(ref.width, ref.height, budget.maxPixels), maxBytes: budget.maxBytes }
 }
 
 /**
@@ -272,7 +286,7 @@ async function toPiContextWithImages(
 ): Promise<PiContext> {
   const { attachments, resolveImageAccess, maxRequestImageBytes } = images
   const requestImagePolicy = images.requestImagePolicy ?? {
-    projection: { kind: 'pixel-budget', maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET },
+    maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
     maxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES,
   }
   assertSupportedImageRoles(options.messages)

+ 5 - 4
packages/llm/llm-pi-ai/tests/adapter.spec.ts

@@ -4,7 +4,7 @@ import { AttachmentId, AttachmentStore, ImageVariantId } from '@deepseek-ai/dsh-
 import type {
   ImageAttachmentLimits,
   ImageAttachmentRef,
-  ImageRequestPolicy,
+  ImageRequestTarget,
   RequestImageAttachment,
   SaveImageAttachment,
   StoredImageAttachment,
@@ -254,7 +254,7 @@ describe('PiAiAdapter provider routing', () => {
       Promise.resolve({ ref, data: Uint8Array.of(1) }))
     const readImageRequest = vi.fn((
       value: ImageAttachmentRef,
-      _policy: ImageRequestPolicy,
+      _target: ImageRequestTarget,
       _signal?: AbortSignal,
     ): Promise<RequestImageAttachment> => (
       Promise.resolve({
@@ -299,7 +299,7 @@ describe('PiAiAdapter provider routing', () => {
 
       override readImageRequest(
         value: ImageAttachmentRef,
-        policy: ImageRequestPolicy,
+        policy: ImageRequestTarget,
         signal?: AbortSignal,
       ): Promise<RequestImageAttachment> {
         return readImageRequest(value, policy, signal)
@@ -325,7 +325,8 @@ describe('PiAiAdapter provider routing', () => {
 
     expect(result.finish.kind).toBe('error')
     expect(readImageRequest).toHaveBeenCalledWith(ref, {
-      projection: { kind: 'pixel-budget' as const, maxPixels: 2048 * 2048 },
+      width: 1,
+      height: 1,
       maxBytes: 1024 * 1024,
     }, expect.any(AbortSignal))
     expect(JSON.stringify(server.requests[0])).toContain(MODEL_IMAGE_PATH)

+ 2 - 2
packages/llm/llm-pi-ai/tests/context.spec.ts

@@ -3,7 +3,7 @@ import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
 import type {
   AttachmentStore,
   ImageAttachmentRef,
-  ImageRequestPolicy,
+  ImageRequestTarget,
   RequestImageAttachment,
 } from '@deepseek-ai/dsh-attachment'
 import { ToolCallId, createMessage, createUserMessage, offloadedImageText } from '@deepseek-ai/dsh-llm'
@@ -38,7 +38,7 @@ function requestImage(value: ImageAttachmentRef, data: Uint8Array): RequestImage
 function projectionStore(
   readImageRequest: (
     value: ImageAttachmentRef,
-    policy: ImageRequestPolicy,
+    target: ImageRequestTarget,
     signal?: AbortSignal,
   ) => Promise<RequestImageAttachment> = vi.fn((value: ImageAttachmentRef) => (
     Promise.resolve(requestImage(value, Uint8Array.of(1)))

+ 5 - 5
packages/llm/llm-pi-ai/tests/convert.spec.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it, vi } from 'vitest'
 import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
-import type { AttachmentStore, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
+import type { AttachmentStore, ImageAttachmentRef, ImageRequestTarget, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
 import { createUserMessage, ToolCallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, createMessage } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
 import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai'
@@ -62,7 +62,7 @@ function requestVersion(ref: ImageAttachmentRef): RequestImageAttachment {
 
 function attachmentStore(readImageRequest: (
   ref: ImageAttachmentRef,
-  policy: ImageRequestPolicy,
+  policy: ImageRequestTarget,
   signal?: AbortSignal,
 ) => Promise<RequestImageAttachment>): AttachmentStore {
   return { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore
@@ -105,7 +105,7 @@ describe('toPiContext', () => {
       width: 1,
       height: 1,
     }
-    const readImageRequest = vi.fn((value: ImageAttachmentRef, _policy: ImageRequestPolicy) => (
+    const readImageRequest = vi.fn((value: ImageAttachmentRef, _target: ImageRequestTarget) => (
       Promise.resolve(requestVersion(value))
     ))
     const context = await toPiContext({
@@ -119,7 +119,7 @@ describe('toPiContext', () => {
 
     expect(readImageRequest).toHaveBeenCalledWith(
       attachment,
-      { projection: { kind: 'pixel-budget' as const, maxPixels: 2048 * 2048 }, maxBytes: 1024 * 1024 },
+      { width: 1, height: 1, maxBytes: 1024 * 1024 },
       undefined,
     )
     expect(context.messages[0]).toEqual({
@@ -141,7 +141,7 @@ describe('toPiContext', () => {
       width: 1,
       height: 1,
     }
-    const readImageRequest = vi.fn((value: ImageAttachmentRef, _policy: ImageRequestPolicy) => (
+    const readImageRequest = vi.fn((value: ImageAttachmentRef, _target: ImageRequestTarget) => (
       Promise.resolve(requestVersion(value))
     ))
     const context = await toPiContext({

+ 2 - 2
packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts

@@ -5,7 +5,7 @@ import { AttachmentId, AttachmentStore, ImageVariantId } from '@deepseek-ai/dsh-
 import type {
   ImageAttachmentLimits,
   ImageAttachmentRef,
-  ImageRequestPolicy,
+  ImageRequestTarget,
   RequestImageAttachment,
   SaveImageAttachment,
   StoredImageAttachment,
@@ -91,7 +91,7 @@ async function harness(image?: StoredImageAttachment): Promise<Context> {
         return Promise.resolve(fixture)
       }
 
-      override readImageRequest(ref: ImageAttachmentRef, _policy: ImageRequestPolicy): Promise<RequestImageAttachment> {
+      override readImageRequest(ref: ImageAttachmentRef, _target: ImageRequestTarget): Promise<RequestImageAttachment> {
         if (ref.attachmentId !== fixture.ref.attachmentId) {
           return Promise.reject(new Error('unknown e2e attachment fixture'))
         }

+ 2 - 1
scripts/gen-cordis-catalog.ts

@@ -378,7 +378,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   SaveFileStreamAttachment: 'attachment.md',
   ImageAttachmentAccess: 'llm-streaming.md',
   ImageAttachmentRef: 'attachment.md',
-  ImageRequestPolicy: 'attachment.md',
+  ImageRequestTarget: 'attachment.md',
+  ProjectedDimensions: 'attachment.md',
   PromptContentPart: 'attachment.md',
   RequestImageAttachment: 'attachment.md',
   SaveImageAttachment: 'attachment.md',

+ 3 - 8
scripts/type-equiv.manifest.json

@@ -1003,18 +1003,13 @@
     },
     {
       "doc": "docs/subsystems/attachment.md",
-      "symbol": "ImageRequestProjection",
+      "symbol": "ImageRequestTarget",
       "source": "packages/attachment/attachment/src/types.ts"
     },
     {
       "doc": "docs/subsystems/attachment.md",
-      "symbol": "TokenGridProjection",
-      "source": "packages/attachment/attachment/src/types.ts"
-    },
-    {
-      "doc": "docs/subsystems/attachment.md",
-      "symbol": "ImageRequestPolicy",
-      "source": "packages/attachment/attachment/src/types.ts"
+      "symbol": "ProjectedDimensions",
+      "source": "packages/attachment/attachment/src/request-projection.ts"
     },
     {
       "doc": "docs/subsystems/attachment.md",

Неке датотеке нису приказане због велике количине промена