From 2491e12fd81f0bcd0d8ed18f28878a5742cd1897 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 21 Aug 2026 13:19:50 +0800 Subject: [PATCH] refactor(attachment): normalize image storage API --- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 26 ++-- ...08-20-unified-image-request-pipeline.zh.md | 24 ++-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 12 +- docs/config-catalog.zh.md | 12 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 55 ++++--- docs/subsystems/attachment.zh.md | 55 ++++--- .../system-prompt.expected.md | 6 +- packages/acp/acp/tests/dispose.spec.ts | 2 +- packages/acp/acp/tests/harness.ts | 9 +- packages/acp/acp/tests/turns.spec.ts | 6 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 8 +- .../attachment/attachment-local/README.zh.md | 8 +- .../attachment-local/src/encoding.ts | 2 +- .../attachment/attachment-local/src/index.ts | 61 ++++---- .../src/{canonical.ts => normalization.ts} | 63 ++++---- .../attachment-local/src/request-image.ts | 72 +++++----- .../attachment/attachment-local/src/store.ts | 80 +++++------ .../attachment-local/tests/index.spec.ts | 18 +-- ...anonical.spec.ts => normalization.spec.ts} | 136 +++++++++--------- .../tests/request-image-verification.spec.ts | 4 +- .../tests/request-image.spec.ts | 78 +++++----- .../attachment-local/tests/store.spec.ts | 42 +++--- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 4 +- packages/attachment/attachment/README.zh.md | 4 +- packages/attachment/attachment/src/index.ts | 42 ++---- packages/attachment/attachment/src/types.ts | 42 ++---- .../attachment/attachment/tests/index.spec.ts | 37 ++--- .../extensions/tool-cordis/src/api-catalog.ts | 32 ++--- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 6 +- packages/fs/tool-fs/README.zh.md | 6 +- packages/fs/tool-fs/src/read-image.ts | 44 +++--- packages/fs/tool-fs/tests/read-image.spec.ts | 38 ++--- .../command-goal/tests/command-goal.spec.ts | 9 +- .../apiproxy/tests/api-proxy-models.spec.ts | 15 +- .../commands/tests/commands.spec.ts | 10 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 6 +- packages/llm/llm-deepseek/README.zh.md | 6 +- packages/llm/llm-deepseek/src/adapter.ts | 6 +- packages/llm/llm-deepseek/src/file-store.ts | 6 +- packages/llm/llm-deepseek/src/upload-index.ts | 26 ++-- .../llm/llm-deepseek/tests/adapter.e2e.ts | 15 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 25 ++-- .../llm-deepseek/tests/dynamic-config.spec.ts | 10 +- .../llm/llm-deepseek/tests/file-store.spec.ts | 4 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 4 +- .../llm-deepseek/tests/upload-index.spec.ts | 63 ++++---- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-pi-ai/src/config.ts | 2 +- packages/llm/llm-pi-ai/src/context.ts | 11 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 5 +- packages/llm/llm-pi-ai/tests/context.spec.ts | 20 +-- packages/llm/llm-pi-ai/tests/convert.spec.ts | 13 +- .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 5 +- packages/llm/llm/src/content.ts | 2 +- .../mcp/mcp-client/tests/mcp-client.spec.ts | 9 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 5 +- scripts/gen-cordis-catalog.ts | 2 - scripts/gen-tool-catalog.ts | 4 +- scripts/test-invariants.ts | 3 +- 68 files changed, 612 insertions(+), 748 deletions(-) rename packages/attachment/attachment-local/src/{canonical.ts => normalization.ts} (77%) rename packages/attachment/attachment-local/tests/{canonical.spec.ts => normalization.spec.ts} (63%) diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index e95c7faa2a..1c6145c359 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: f0ef01de3b22c7132e7f698d0948a0da945726ba -2026-08-20-unified-image-request-pipeline.zh.md: b1a14ac418987ab8bfee9b731ad38cb48e21753e +2026-08-20-unified-image-request-pipeline.md: 6a3bae8a970677c32bbfb7966d2bc13d4e504804 +2026-08-20-unified-image-request-pipeline.zh.md: 10a4aed0b5ca9168c6a6ee4ec0258a210b50d531 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index f0ef01de3b..6a3bae8a97 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -1,4 +1,4 @@ -# Agent Note: Unified image masters, request versions, and provider files +# Agent Note: Unified normalized attachments, request versions, and provider files Status: implemented @@ -10,23 +10,23 @@ Durable image history, provider resolution, inline request size, and remote file ## Decision -The image path has two explicit versions. The attachment backend owns a provider-independent durable master. Each image-capable model route owns a deterministic request policy, and the attachment backend derives and caches the exact request version from the master. Session history contains only the master reference; inline bytes and provider file ids remain transient request projections. +The image path has two explicit versions. The attachment backend owns a provider-independent durable normalized attachment. Each image-capable model route owns a deterministic request policy, and the attachment backend derives and caches the exact request version from that attachment. Session history contains only the normalized attachment reference; inline bytes and provider file ids remain transient request projections. -### Provider-independent master +### Provider-independent normalized attachment -Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source is fully decoded under configurable 20MiB, 64,000,000-pixel, and 8192px-per-side limits. Preparation applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while limiting the long edge to `masterMaxDimension`, 2048px by default. `sourceWidth` and `sourceHeight` record orientation-applied dimensions when preparation reduces the raster. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source is fully decoded under configurable 20MiB, 64,000,000-pixel, and 8192px-per-side limits. Normalization applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while limiting the long edge to `normalizedImageMaxDimension`, 2048px by default. When scaling reduces the raster, `originalDimensions` records its orientation-applied width and height before normalization. -The master has an independent `masterMaxBytes` safety cap, 4MiB by default. Alpha is never flattened. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color input tries PNG, with palette encoding only when no alpha channel is present, followed by WebP qualities 85, 80, and 75. Other alpha input tries WebP at those qualities; other opaque input tries JPEG. Candidates execute in order and stop at the first result within the cap. Dimensions shrink only after every candidate at one size exceeds the cap. The source extension does not classify a PNG as low color. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within both master limits passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference. +The normalized attachment has an independent `normalizedImageMaxBytes` safety cap, 4MiB by default. Alpha is never flattened. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color input tries PNG, with palette encoding only when no alpha channel is present, followed by WebP qualities 85, 80, and 75. Other alpha input tries WebP at those qualities; other opaque input tries JPEG. Candidates execute in order and stop at the first result within the cap. Dimensions shrink only after every candidate at one size exceeds the cap. The source extension does not classify a PNG as low color. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within both normalization limits passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference. -Batch admission prepares and verifies every master once before publishing any member. Validation failure starts no writes. Publication uses those prepared bytes directly, so a large batch does not repeat full decoding and encoding during commit. A later storage failure returns no partial references; already published immutable objects may remain unreachable under the existing storage rule. +Batch admission prepares and verifies every normalized attachment once before publishing any member. Validation failure starts no writes. Publication uses those prepared bytes directly, so a large batch does not repeat full decoding and encoding during commit. A later storage failure returns no partial references; already published immutable objects may remain unreachable under the existing storage rule. ### Deterministic request versions -`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default; low detail uses 512 by 512 total pixels. A 2048 by 1024 master projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. +`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default; low detail uses 512 by 512 total pixels. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. -The `variantId` and cache path cover the master attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the master byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. `AttachmentStore.readImageRequests` preserves input order while the local implementation runs master and request transforms through one FIFO limiter. `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every master has been prepared. +The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. -Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(masterBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained masters are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references. +Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references. ### Stable handles @@ -40,15 +40,15 @@ An upload is indexed only after the response returns a complete file object, mat ### Diagnostics -A 16-bit RGB or RGBA PNG is normal admitted input and converts to 8-bit sRGB/sRGBA. If local conversion fails, `read_image` names the path, detected 16-bit PNG, required canonical form, and manual conversion remedy. If DeepSeek rejects a normalized request version, the primary error names the attachment or display name, durable message and image position, normalized media type, 8-bit sRGB/sRGBA depth, dimensions, and provider message. An ambiguous multi-image rejection lists every candidate. The raw provider body remains the error cause rather than the only visible message. +A 16-bit RGB or RGBA PNG is normal admitted input and converts to 8-bit sRGB/sRGBA. If local conversion fails, `read_image` names the path, detected 16-bit PNG, required normalized form, and manual conversion remedy. If DeepSeek rejects a normalized request version, the primary error names the attachment or display name, durable message and image position, normalized media type, 8-bit sRGB/sRGBA depth, dimensions, and provider message. An ambiguous multi-image rejection lists every candidate. The raw provider body remains the error cause rather than the only visible message. Historical attachment objects that later disappear or fail integrity verification remain fail-loud. Durable quarantine and verified recovery require session events and are tracked by [Quarantine unreadable historical attachments](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md). ## Alternatives considered -**Use one 1MiB canonical image for storage and requests.** This makes model resolution determine durable image detail and combines local storage, inline expansion, Files quota, and model pixels into one setting. Independent master and request policies keep those responsibilities explicit. +**Use one 1MiB normalized attachment for storage and requests.** This makes model resolution determine durable image detail and combines local storage, inline expansion, Files quota, and model pixels into one setting. Independent normalization and request policies keep those responsibilities explicit. -**Reject images above provider dimensions or at the encoding quality floor.** A provider limit is route-specific and future requests may use another model. Proportional master preparation and request projection accept ordinary large images while bounding each later representation. +**Reject images above provider dimensions or at the encoding quality floor.** A provider limit is route-specific and future requests may use another model. Proportional normalization and request projection accept ordinary large images while bounding each later representation. **Treat PNG as a screenshot and reject 16-bit PNG.** File format does not reveal pixel complexity, and 16-bit RGB/RGBA is a convertible sample depth rather than an unsupported image type. Pixel sampling and post-conversion probes give the required facts. @@ -66,4 +66,4 @@ Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion ## Consequences -Durable masters consume up to the independent local safety cap, while request caches and remote Files consume additional derived storage. Deterministic identities and singleflight make that work reusable across turns and sessions sharing the same DSH home. Two simultaneous transforms reduce batch latency while increasing peak RSS relative to serial execution; deployments with tighter memory can set the limit to one. Encoder or transform-version changes create new future identities without rewriting existing history. DeepSeek image requests now depend on Files API availability; bounded stale-id recovery handles inconsistent remote state, while a general Files outage remains a visible request failure. Missing or corrupt durable masters still require the separate quarantine design. +Normalized attachments consume up to the independent local safety cap, while request caches and remote Files consume additional derived storage. Deterministic identities and singleflight make that work reusable across turns and sessions sharing the same DSH home. Two simultaneous transforms reduce batch latency while increasing peak RSS relative to serial execution; deployments with tighter memory can set the limit to one. Encoder or transform-version changes create new future identities without rewriting existing history. DeepSeek image requests now depend on Files API availability; bounded stale-id recovery handles inconsistent remote state, while a general Files outage remains a visible request failure. Missing or corrupt durable attachments still require the separate quarantine design. diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index b1a14ac418..10a4aed0b5 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 统一图片主版本、请求版本与提供方文件 +# Agent Note: 统一规范化附件、请求版本与提供方文件 Status: implemented @@ -10,23 +10,23 @@ Status: implemented ## Decision -图片路径有两个显式版本。附件后端拥有提供方无关的持久主版本。每条支持图片的模型路由拥有确定性请求策略,附件后端从主版本派生并缓存确切请求版本。会话历史只包含主版本引用;内联字节和提供方文件 ID 都是瞬时请求投影。 +图片路径有两个显式版本。附件后端拥有提供方无关的持久规范化附件。每条支持图片的模型路由拥有确定性请求策略,附件后端从该附件派生并缓存确切请求版本。会话历史只包含规范化附件引用;内联字节和提供方文件 ID 都是瞬时请求投影。 -### 提供方无关的主版本 +### 提供方无关的规范化附件 -每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图会在可配置的 20MiB、64,000,000 像素和单边 8192px 限制内完整解码。处理会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `masterMaxDimension`,默认 2048px。处理缩小光栅时,`sourceWidth` 和 `sourceHeight` 记录应用方向后的源尺寸。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图会在可配置的 20MiB、64,000,000 像素和单边 8192px 限制内完整解码。规范化过程会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`,默认 2048px。缩放减小光栅时,`originalDimensions` 记录规范化之前、应用方向之后的输入宽高。 -主版本有独立的 `masterMaxBytes` 安全上限,默认 4MiB。透明通道绝不铺平。系统通过 nearest-neighbour 对有界样本判断色彩复杂度,不会通过像素平均把高频图片误判为低色数。确认的低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明输入依次尝试这些质量的 WebP;其他非透明输入依次尝试这些质量的 JPEG。候选按顺序执行,首个不超过上限的结果会立即返回。同一尺寸的候选全部超限后才会缩小尺寸。源扩展名不会把 PNG 归类为低色数图片。处于两个主版本上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。 +规范化附件有独立的 `normalizedImageMaxBytes` 安全上限,默认 4MiB。透明通道绝不铺平。系统通过 nearest-neighbour 对有界样本判断色彩复杂度,不会通过像素平均把高频图片误判为低色数。确认的低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明输入依次尝试这些质量的 WebP;其他非透明输入依次尝试这些质量的 JPEG。候选按顺序执行,首个不超过上限的结果会立即返回。同一尺寸的候选全部超限后才会缩小尺寸。源扩展名不会把 PNG 归类为低色数图片。处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。 -批量准入在发布任何成员前,为每张图片各准备并验证一次主版本。校验失败不会开始写入。发布直接使用这些已准备字节,因此大批次不会在提交时重复完整解码和编码。之后发生的存储失败不会返回部分引用;按现有存储规则,已经发布的不可变对象可能保持不可达。 +批量准入在发布任何成员前,为每张图片各准备并验证一次规范化附件。校验失败不会开始写入。发布直接使用这些已准备字节,因此大批次不会在提交时重复完整解码和编码。之后发生的存储失败不会返回部分引用;按现有存储规则,已经发布的不可变对象可能保持不可达。 ### 确定性请求版本 -`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB;low detail 使用总像素 512×512。2048×1024 主版本在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 +`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB;low detail 使用总像素 512×512。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 -`variantId` 和缓存路径覆盖主附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用主版本字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。`AttachmentStore.readImageRequests` 保持输入顺序,本地实现则通过一个 FIFO 限流器运行主版本和请求版本变换。`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部主版本准备完成后,批次仍按顺序发布。 +`variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 -请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(主版本字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的主版本,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。 +请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。 ### 稳定句柄 @@ -46,9 +46,9 @@ Status: implemented ## Alternatives considered -**使用一份 1MiB 规范图片同时负责存储和请求。** 这种做法让模型分辨率决定持久图片细节,并把本地存储、内联膨胀、Files 配额和模型像素合并成一个设置。独立的主版本和请求策略会明确区分这些职责。 +**使用一份 1MiB 规范化附件同时负责存储和请求。** 这种做法让模型分辨率决定持久图片细节,并把本地存储、内联膨胀、Files 配额和模型像素合并成一个设置。独立的规范化和请求策略会明确区分这些职责。 -**拒绝超过提供方尺寸或达到编码质量下限的图片。** 提供方限制属于具体路由,未来请求可能改用另一个模型。按比例准备主版本和投影请求版本可以接纳普通大图,同时约束每种后续表示。 +**拒绝超过提供方尺寸或达到编码质量下限的图片。** 提供方限制属于具体路由,未来请求可能改用另一个模型。按比例规范化和投影请求版本可以接纳普通大图,同时约束每种后续表示。 **把 PNG 当作截图,并拒绝 16-bit PNG。** 文件格式不能说明像素复杂度,16-bit RGB/RGBA 是可转换位深,不是不支持的图片类型。像素采样和转换后探测能提供所需事实。 @@ -66,4 +66,4 @@ Status: implemented ## Consequences -持久主版本最多占用独立的本地安全上限,请求缓存和远端 Files 还会占用额外派生存储。确定性身份和 singleflight 使这些成本可以被共享同一 DSH home 的轮次和会话复用。同时执行两个变换会降低批次延迟,但峰值 RSS 高于串行执行;内存更紧张的部署可以把上限设为 1。编码器或变换策略版本变化会为未来内容产生新身份,不会改写已有历史。DeepSeek 图片请求现在依赖 Files API 可用性;有界的陈旧 ID 恢复会处理远端状态不一致,一般 Files 故障仍会成为可见请求失败。缺失或损坏的持久主版本仍需要单独的隔离设计。 +持久规范化附件最多占用独立的本地安全上限,请求缓存和远端 Files 还会占用额外派生存储。确定性身份和 singleflight 使这些成本可以被共享同一 DSH home 的轮次和会话复用。同时执行两个变换会降低批次延迟,但峰值 RSS 高于串行执行;内存更紧张的部署可以把上限设为 1。编码器或变换策略版本变化会为未来内容产生新身份,不会改写已有历史。DeepSeek 图片请求现在依赖 Files API 可用性;有界的陈旧 ID 恢复会处理远端状态不一致,一般 Files 故障仍会成为可见请求失败。缺失或损坏的持久附件仍需要单独的隔离设计。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 276fad138a..a7d0db5eec 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: d288fe3b85f1599da6ecef3dcf59c04c4e8c85d5 -config-catalog.zh.md: 266465fd09312c5dde9df4453c34f3aa774db7e2 +config-catalog.md: 8152b3c3280a7b85543d6cfeec850c8b3a25ca47 +config-catalog.zh.md: 95fd49e380e0cc9322fe8d12d0bdaf8dd310efac diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d288fe3b85..8152b3c328 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -337,16 +337,16 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number - /** Long-edge pixel cap of the stored provider-independent master version. */ - masterMaxDimension?: number - /** Encoded-byte safety cap of the stored provider-independent master version. */ - masterMaxBytes?: number - /** Maximum simultaneous master or request-image transformations in this service instance. */ + /** Long-edge pixel cap of the stored provider-independent normalized image. */ + normalizedImageMaxDimension?: number + /** Encoded-byte safety cap of the stored provider-independent normalized image. */ + normalizedImageMaxBytes?: number + /** Maximum simultaneous normalization or request-image transformations in this service instance. */ imageCompressionConcurrency?: number } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 266465fd09..95fd49e380 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -339,16 +339,16 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number - /** Long-edge pixel cap of the stored provider-independent master version. */ - masterMaxDimension?: number - /** Encoded-byte safety cap of the stored provider-independent master version. */ - masterMaxBytes?: number - /** Maximum simultaneous master or request-image transformations in this service instance. */ + /** Long-edge pixel cap of the stored provider-independent normalized image. */ + normalizedImageMaxDimension?: number + /** Encoded-byte safety cap of the stored provider-independent normalized image. */ + normalizedImageMaxBytes?: number + /** Maximum simultaneous normalization or request-image transformations in this service instance. */ imageCompressionConcurrency?: number } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index ee14a0698f..b93c9ef1ca 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/attachment.md -attachment.md: 7c55bc192088f67ae7d117bc150aa0ea6fdf8b09 -attachment.zh.md: d5a140e283c1b7aa6ee5c991c2932ff65de0b88e +attachment.md: e6d0a53db2827a38a1535380319b6220aa37f0a4 +attachment.zh.md: 8328ec610d4d68624f75f00d6a397b13fdf31c4e diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 7c55bc1920..e6d0a53db2 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -18,7 +18,7 @@ type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' ``` ```ts type-equiv -/** Durable, serializable metadata for one immutable image object. */ +/** Durable, serializable reference to one immutable normalized image. */ interface ImageAttachmentRef { /** Opaque storage identifier; never a filesystem path or bearer URL. */ attachmentId: AttachmentId @@ -32,10 +32,14 @@ interface ImageAttachmentRef { height: number /** Optional display name stripped of local path information. */ name?: string - /** Perceived source width before master-version downscaling; present only when it differs from {@link width}. */ - sourceWidth?: number - /** Perceived source height before master-version downscaling; present only when it differs from {@link height}. */ - sourceHeight?: number + /** + * Input dimensions after applying EXIF orientation and before normalization + * scaling. Present only when normalization reduced the image. + */ + originalDimensions?: { + width: number + height: number + } } ``` @@ -52,7 +56,7 @@ interface ImageAttachmentLimits { } ``` -The local backend admits at most 20 images and 200 MiB of encoded source data per message. One source may use up to 20 MiB, 64,000,000 pixels, and 8192 pixels on either side. These source limits precede the independent 2048-pixel, 4 MiB master preparation stage. +The local backend admits at most 20 images and 200 MiB of encoded source data per message. One source may use up to 20 MiB, 64,000,000 pixels, and 8192 pixels on either side. These source limits precede the independent normalization stage, which limits the long edge to 2048 pixels and encoded data to 4 MiB by default. The reference records intrinsic dimensions and encoded length so clients can lay out history without decoding first, while every authoritative read still re-checks digest, media signature, dimensions, and metadata against the object. @@ -100,12 +104,12 @@ interface ImageRequestPolicy { ``` ```ts type-equiv -/** Cached request version derived from one provider-independent master attachment. */ +/** Cached request version derived from one provider-independent normalized attachment. */ interface RequestImageAttachment { - /** Cache and upload-index key over the master id, policy, and fixed encoder parameters. */ + /** Cache and upload-index key over the attachment id, policy, and fixed encoder parameters. */ variantId: ImageVariantId - /** Durable master reference from which this request version was derived. */ - master: ImageAttachmentRef + /** Durable normalized attachment from which this request version was derived. */ + attachment: ImageAttachmentRef /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -121,7 +125,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares a provider-independent 2048px, 4MiB master and atomically commits it before returning its reference. `saveImages()` prepares every validated master once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a master from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. `readImageRequests()` lets an implementation apply its configured transform concurrency to an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -149,48 +153,37 @@ abstract validateImage(input: SaveImageAttachment): Promise /** * Validate and durably commit one ordered image batch. * @param inputs - encoded images in owning-message order. - * @returns durable master references in the same order after every member succeeds. + * @returns durable normalized attachment references in the same order after every member succeeds. */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** * Validate and durably commit one image before its owning session event is appended. - * Implementations may store a prepared master version of the submitted raster; - * the returned reference always describes the stored bytes, while `source` - * preserves the submitted raster's intrinsic facts for callers that report - * or map coordinates against the original. + * The returned reference describes the persisted normalized image. When + * normalization reduces the raster, its `originalDimensions` records the + * orientation-applied input dimensions. * @param input - encoded bytes, declared media type, and optional display name. - * @returns the durable content-addressed reference beside the submitted source facts. + * @returns the durable content-addressed normalized image reference. */ -abstract saveImage(input: SaveImageAttachment): Promise +abstract saveImage(input: SaveImageAttachment): Promise /** * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. * @param signal - optional cancellation for backend read and verification work. - * @returns the verified bytes and master reference. + * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise /** - * Generate or read one deterministic model-request version from the stored master image. - * @param ref - durable provider-independent master reference. + * Generate or read one deterministic model-request version from the stored normalized image. + * @param ref - durable provider-independent normalized attachment reference. * @param policy - exact route pixel and encoded-byte budget. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise - -/** - * Generate or read an ordered batch of deterministic model-request versions. - * Implementations may use their own bounded transform concurrency while preserving input order. - * @param refs - durable provider-independent master references in request order. - * @param policy - exact route pixel and encoded-byte budget shared by the batch. - * @param signal - optional cancellation. - * @returns request versions in the same order as `refs`. - */ -async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index d5a140e283..8328ec610d 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -18,7 +18,7 @@ type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' ``` ```ts type-equiv -/** Durable, serializable metadata for one immutable image object. */ +/** Durable, serializable reference to one immutable normalized image. */ interface ImageAttachmentRef { /** Opaque storage identifier; never a filesystem path or bearer URL. */ attachmentId: AttachmentId @@ -32,10 +32,14 @@ interface ImageAttachmentRef { height: number /** Optional display name stripped of local path information. */ name?: string - /** Perceived source width before master-version downscaling; present only when it differs from {@link width}. */ - sourceWidth?: number - /** Perceived source height before master-version downscaling; present only when it differs from {@link height}. */ - sourceHeight?: number + /** + * Input dimensions after applying EXIF orientation and before normalization + * scaling. Present only when normalization reduced the image. + */ + originalDimensions?: { + width: number + height: number + } } ``` @@ -52,7 +56,7 @@ interface ImageAttachmentLimits { } ``` -本地后端每条消息最多准入 20 张图片,源图编码数据总量不超过 200 MiB。单张源图不得超过 20 MiB、64,000,000 像素和单边 8192 像素。这些源文件限制先于独立的 2048 像素、4 MiB 主版本处理阶段执行。 +本地后端每条消息最多准入 20 张图片,源图编码数据总量不超过 200 MiB。单张源图不得超过 20 MiB、64,000,000 像素和单边 8192 像素。这些源文件限制先于独立的规范化阶段执行;该阶段默认把长边限制为 2048 像素,把编码数据限制为 4 MiB。 引用记录固有尺寸和编码长度,使客户端无需先解码即可排布历史记录;每次权威读取仍会根据对象重新校验摘要、媒体签名、尺寸和元数据。 @@ -100,12 +104,12 @@ interface ImageRequestPolicy { ``` ```ts type-equiv -/** Cached request version derived from one provider-independent master attachment. */ +/** Cached request version derived from one provider-independent normalized attachment. */ interface RequestImageAttachment { - /** Cache and upload-index key over the master id, policy, and fixed encoder parameters. */ + /** Cache and upload-index key over the attachment id, policy, and fixed encoder parameters. */ variantId: ImageVariantId - /** Durable master reference from which this request version was derived. */ - master: ImageAttachmentRef + /** Durable normalized attachment from which this request version was derived. */ + attachment: ImageAttachmentRef /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -121,7 +125,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备提供方无关的 2048px、4MiB 主版本,并在返回引用前以原子方式提交。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的主版本,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的主版本。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。`readImageRequests()` 允许实现按自身配置的变换并发处理有序批次。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,默认同时执行两项变换。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -149,48 +153,37 @@ abstract validateImage(input: SaveImageAttachment): Promise /** * Validate and durably commit one ordered image batch. * @param inputs - encoded images in owning-message order. - * @returns durable master references in the same order after every member succeeds. + * @returns durable normalized attachment references in the same order after every member succeeds. */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** * Validate and durably commit one image before its owning session event is appended. - * Implementations may store a prepared master version of the submitted raster; - * the returned reference always describes the stored bytes, while `source` - * preserves the submitted raster's intrinsic facts for callers that report - * or map coordinates against the original. + * The returned reference describes the persisted normalized image. When + * normalization reduces the raster, its `originalDimensions` records the + * orientation-applied input dimensions. * @param input - encoded bytes, declared media type, and optional display name. - * @returns the durable content-addressed reference beside the submitted source facts. + * @returns the durable content-addressed normalized image reference. */ -abstract saveImage(input: SaveImageAttachment): Promise +abstract saveImage(input: SaveImageAttachment): Promise /** * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. * @param signal - optional cancellation for backend read and verification work. - * @returns the verified bytes and master reference. + * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise /** - * Generate or read one deterministic model-request version from the stored master image. - * @param ref - durable provider-independent master reference. + * Generate or read one deterministic model-request version from the stored normalized image. + * @param ref - durable provider-independent normalized attachment reference. * @param policy - exact route pixel and encoded-byte budget. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise - -/** - * Generate or read an ordered batch of deterministic model-request versions. - * Implementations may use their own bounded transform concurrency while preserving input order. - * @param refs - durable provider-independent master references in request order. - * @param policy - exact route pixel and encoded-byte budget shared by the batch. - * @param signal - optional cancellation. - * @returns request versions in the same order as `refs`. - */ -async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index 678de3e53f..0d2c35c8c6 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -363,8 +363,10 @@ interface ToolOutputMap { width: number; height: number; name?: string; - sourceWidth?: number; - sourceHeight?: number; + originalDimensions?: { + width: number; + height: number; + }; }; }; send_message: { diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index e5a4a66a3b..4aa32f078c 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -30,7 +30,7 @@ describe('ACP connection ownership', () => { it('disposal drains asynchronous assistant image delivery before releasing sessions', async () => { const script: StreamChunk[][] = [] harness = await makeBridgeHarness({ script }) - const { ref } = await harness.attachments!.saveImage({ data: Uint8Array.of(4), mediaType: 'image/png' }) + const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(4), mediaType: 'image/png' }) script.push([ { type: 'block-start', index: 0, blockType: 'image' }, { type: 'block-end', index: 0, block: { type: 'image', attachment: ref } }, diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index 7c0532e92d..ce6e93794f 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -13,7 +13,7 @@ import { type Stream, } from '@agentclientprotocol/sdk' import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SavedImageAttachment, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { type GenerateOptions, LlmAdapter, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -99,7 +99,7 @@ class MemoryAttachmentStore extends AttachmentStore { if (input.data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') } - saveImage(input: SaveImageAttachment): Promise { + saveImage(input: SaveImageAttachment): Promise { this.saved.push(input) const digest = createHash('sha256').update(input.data).digest('hex') const ref: ImageAttachmentRef = { @@ -110,10 +110,7 @@ class MemoryAttachmentStore extends AttachmentStore { height: 1, } this.objects.set(ref.attachmentId, { ref, data: Uint8Array.from(input.data) }) - return Promise.resolve({ - ref, - source: { mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height }, - }) + return Promise.resolve(ref) } async readImage(ref: ImageAttachmentRef): Promise { diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index c9b229caf1..71e2a21e43 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -44,7 +44,7 @@ describe('ACP prompt lifecycle', () => { it('delivers a committed assistant image as verified ACP base64', async () => { const script: StreamChunk[][] = [] harness = await makeBridgeHarness({ script }) - const { ref } = await harness.attachments!.saveImage({ data: Uint8Array.of(1), mediaType: 'image/png' }) + const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(1), mediaType: 'image/png' }) script.push([ { type: 'block-start', index: 0, blockType: 'image' }, { @@ -68,7 +68,7 @@ describe('ACP prompt lifecycle', () => { it('preserves committed text/image/text order on the ACP wire', async () => { const script: StreamChunk[][] = [] harness = await makeBridgeHarness({ script }) - const { ref } = await harness.attachments!.saveImage({ data: Uint8Array.of(2), mediaType: 'image/jpeg' }) + const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(2), mediaType: 'image/jpeg' }) script.push([ { type: 'block-start', index: 0, blockType: 'text' }, { type: 'block-end', index: 0, block: { type: 'text', text: 'before' } }, @@ -92,7 +92,7 @@ describe('ACP prompt lifecycle', () => { it('does not settle a prompt before ordered output delivery drains', async () => { const script: StreamChunk[][] = [] harness = await makeBridgeHarness({ script }) - const { ref } = await harness.attachments!.saveImage({ data: Uint8Array.of(3), mediaType: 'image/png' }) + const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(3), mediaType: 'image/png' }) script.push([ { type: 'block-start', index: 0, blockType: 'image' }, { type: 'block-end', index: 0, block: { type: 'image', attachment: ref } }, diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index a8bfa1b322..412e9a4cb6 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md -README.md: d4831f864dbb061319008242395e2c8ff6d9f642 -README.zh.md: 45bddf47ea5f68c15778040de5b29817e8f62956 +README.md: 849363ce53c6186359ecad34aecb1c2a48f07441 +README.zh.md: f0fe90c2569f60df48998e46d5b05a0d024959df diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index d4831f864d..849363ce53 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. -Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent master. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `masterMaxDimension` (2048px by default). The master has its own `masterMaxBytes` safety cap (4MiB by default). Alpha is retained. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both master limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and a converted master are each fully decoded once. `saveImages` prepares and verifies every master once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` safety cap (4MiB by default). Alpha is retained. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. -Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored master under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It also executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the master id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. `readImageRequests` schedules batches through the service's FIFO limiter. `imageCompressionConcurrency` controls simultaneous master and request transforms from 1 through 8 and defaults to 2; file publication remains ordered after preparation. +Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. @@ -16,11 +16,11 @@ Indirectly, through durable replay of historical user images and structured mode #### KV Cache effect -Master preparation and request projection are deterministic. An unchanged master and route policy reuse identical cached request bytes on later turns. +Normalization and request projection are deterministic. An unchanged attachment and route policy reuse identical cached request bytes on later turns. ## Known Limitations and Deferred Work - Objects are retained indefinitely; reference-aware garbage collection is deferred. - The local backend assumes the host and provider adapter share this filesystem service. - Animated GIF sources keep only their first frame; animation is outside the version-one image contract. -- The master and request encoders are pinned by the installed sharp/libvips build; an encoder or transform-version upgrade re-addresses future masters or request variants while existing objects stay valid. +- The normalization and request encoders are pinned by the installed sharp/libvips build; an encoder or transform-version upgrade re-addresses future normalized attachments or request variants while existing objects stay valid. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 45bddf47ea..f0fe90c256 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -4,9 +4,9 @@ 这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 -每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的主版本:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `masterMaxDimension`(默认 2048px)。主版本有独立的 `masterMaxBytes` 安全上限(默认 4MiB)。透明通道会保留。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个主版本上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的主版本各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次主版本,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 安全上限(默认 4MiB)。透明通道会保留。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 -请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的主版本缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选仍按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含主版本 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。`readImageRequests` 通过服务的 FIFO 限流器调度批次。`imageCompressionConcurrency` 控制同时执行的主版本和请求版本变换,范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 +请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 @@ -16,11 +16,11 @@ #### KV 缓存影响 -主版本准备和请求投影都是确定性的。主版本和路由策略不变时,之后各轮会复用相同的缓存请求字节。 +规范化和请求投影都是确定性的。附件和路由策略不变时,之后各轮会复用相同的缓存请求字节。 ## 已知限制与待完成工作 - 对象会无限期保留;基于引用的垃圾回收尚未实现。 - 本地后端假定宿主与提供方适配器共享同一个文件系统服务。 - 动态 GIF 源图只保留首帧;动画在版本一图片契约之外。 -- 主版本和请求版本编码器由安装的 sharp/libvips 构建钉定;编码器或变换策略版本升级会让未来的主版本或请求变体产生新地址,已有对象保持有效。 +- 规范化和请求版本编码器由安装的 sharp/libvips 构建钉定;编码器或变换策略版本升级会让未来的规范化附件或请求变体产生新地址,已有对象保持有效。 diff --git a/packages/attachment/attachment-local/src/encoding.ts b/packages/attachment/attachment-local/src/encoding.ts index 963edda672..bf83d48cf9 100644 --- a/packages/attachment/attachment-local/src/encoding.ts +++ b/packages/attachment/attachment-local/src/encoding.ts @@ -1,4 +1,4 @@ -/** Shared lazy candidate execution for master and request-image encoders. */ +/** Shared lazy candidate execution for normalization and request-image encoders. */ /** One encoded candidate carrying its complete bytes. */ export interface EncodedCandidate { diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 9007544047..e9a1145ba5 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -10,17 +10,16 @@ import type { ImageRequestPolicy, RequestImageAttachment, SaveImageAttachment, - SavedImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' -import type { MasterImagePolicy } from './canonical.ts' +import type { NormalizationPolicy } from './normalization.ts' import { CompressionLimiter } from './compression-limiter.ts' import { commitPreparedImageFile, prepareImageFile, readImageFile, validateImageFile } from './store.ts' import { readRequestImageFile, requestImageVariantId } from './request-image.ts' -export { isMasterImage, prepareMasterImage } from './canonical.ts' -export type { MasterImage, MasterImagePolicy } from './canonical.ts' +export { canPassThroughNormalization, normalizeImage } from './normalization.ts' +export type { NormalizedImage, NormalizationPolicy } from './normalization.ts' export { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile, validateImageFile } from './store.ts' export type { PreparedImageFile } from './store.ts' export { readRequestImageFile, requestImageDimensions, requestImageVariantId } from './request-image.ts' @@ -36,13 +35,13 @@ export const DEFAULT_MAX_IMAGE_PIXELS = 64_000_000 /** Default per-side pixel cap for one submitted image. */ export const DEFAULT_MAX_IMAGE_DIMENSION = 8192 /** - * Default long-edge target of the stored image master. A larger source + * Default long-edge target of the stored normalized image. A larger source * is admitted and downscaled to this edge, so admission bounds what rides * every later model request without refusing ordinary large sources. */ -export const DEFAULT_MASTER_MAX_DIMENSION = 2048 -/** Default independent safety cap for one stored master version. */ -export const DEFAULT_MASTER_MAX_BYTES = 4 * 1024 * 1024 +export const DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION = 2048 +/** Default independent safety cap for one stored normalized image. */ +export const DEFAULT_NORMALIZED_IMAGE_MAX_BYTES = 4 * 1024 * 1024 /** Conservative default number of simultaneous native image transformations per store. */ export const DEFAULT_IMAGE_COMPRESSION_CONCURRENCY = 2 /** Maximum configurable native image transformations per store. */ @@ -62,11 +61,11 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number - /** Long-edge pixel cap of the stored provider-independent master version. */ - masterMaxDimension?: number - /** Encoded-byte safety cap of the stored provider-independent master version. */ - masterMaxBytes?: number - /** Maximum simultaneous master or request-image transformations in this service instance. */ + /** Long-edge pixel cap of the stored provider-independent normalized image. */ + normalizedImageMaxDimension?: number + /** Encoded-byte safety cap of the stored provider-independent normalized image. */ + normalizedImageMaxBytes?: number + /** Maximum simultaneous normalization or request-image transformations in this service instance. */ imageCompressionConcurrency?: number } @@ -140,8 +139,8 @@ export class LocalAttachmentStore extends AttachmentStore { maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION), - masterMaxDimension: z.number().step(1).min(1).default(DEFAULT_MASTER_MAX_DIMENSION), - masterMaxBytes: z.number().step(1).min(1).default(DEFAULT_MASTER_MAX_BYTES), + normalizedImageMaxDimension: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION), + normalizedImageMaxBytes: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_BYTES), imageCompressionConcurrency: z.number().step(1).min(1).max(MAX_IMAGE_COMPRESSION_CONCURRENCY) .default(DEFAULT_IMAGE_COMPRESSION_CONCURRENCY), }) @@ -149,8 +148,8 @@ export class LocalAttachmentStore extends AttachmentStore { /** Absolute versioned storage root. */ readonly root: string readonly imageLimits: ImageAttachmentLimits - /** Resolved provider-independent master-version storage policy. */ - readonly masterPolicy: Readonly + /** Resolved provider-independent normalization policy. */ + readonly normalizationPolicy: Readonly /** Resolved instance-level compression limit. */ readonly imageCompressionConcurrency: number private readonly compression: CompressionLimiter @@ -167,9 +166,9 @@ export class LocalAttachmentStore extends AttachmentStore { maxImageDimension: config.maxImageDimension ?? DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), }) - this.masterPolicy = Object.freeze({ - maxDimension: config.masterMaxDimension ?? DEFAULT_MASTER_MAX_DIMENSION, - maxBytes: config.masterMaxBytes ?? DEFAULT_MASTER_MAX_BYTES, + this.normalizationPolicy = Object.freeze({ + maxDimension: config.normalizedImageMaxDimension ?? DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, + maxBytes: config.normalizedImageMaxBytes ?? DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, }) const compressionConcurrency = config.imageCompressionConcurrency ?? DEFAULT_IMAGE_COMPRESSION_CONCURRENCY if (!Number.isSafeInteger(compressionConcurrency) @@ -184,22 +183,22 @@ export class LocalAttachmentStore extends AttachmentStore { } async validateImage(input: SaveImageAttachment): Promise { - await this.compression.run(() => validateImageFile(input, this.imageLimits, this.masterPolicy)) + await this.compression.run(() => validateImageFile(input, this.imageLimits, this.normalizationPolicy)) } override async saveImages(inputs: readonly SaveImageAttachment[]): Promise { this.validateImageBatch(inputs) const prepared = await Promise.all(inputs.map(input => this.compression.run( - () => prepareImageFile(input, this.imageLimits, this.masterPolicy), + () => prepareImageFile(input, this.imageLimits, this.normalizationPolicy), ))) const refs: ImageAttachmentRef[] = [] - for (const image of prepared) refs.push((await commitPreparedImageFile(this.root, image)).ref) + for (const image of prepared) refs.push(await commitPreparedImageFile(this.root, image)) return refs } - async saveImage(input: SaveImageAttachment): Promise { + async saveImage(input: SaveImageAttachment): Promise { const prepared = await this.compression.run( - () => prepareImageFile(input, this.imageLimits, this.masterPolicy), + () => prepareImageFile(input, this.imageLimits, this.normalizationPolicy), ) return commitPreparedImageFile(this.root, prepared) } @@ -216,18 +215,10 @@ export class LocalAttachmentStore extends AttachmentStore { return this.requestVersion(ref, policy, undefined, signal) } - override async readImageRequests( - refs: readonly ImageAttachmentRef[], - policy: ImageRequestPolicy, - signal?: AbortSignal, - ): Promise { - return Promise.all(refs.map(ref => this.requestVersion(ref, policy, undefined, signal))) - } - private requestVersion( ref: ImageAttachmentRef, policy: ImageRequestPolicy, - master: StoredImageAttachment | undefined, + stored: StoredImageAttachment | undefined, signal: AbortSignal | undefined, ): Promise { signal?.throwIfAborted() @@ -241,7 +232,7 @@ export class LocalAttachmentStore extends AttachmentStore { if (operation === undefined) { const shared = new SharedRequest(sharedSignal => this.compression.run(async () => readRequestImageFile( this.root, - master ?? await this.readImage(ref, sharedSignal), + stored ?? await this.readImage(ref, sharedSignal), policy, sharedSignal, ))) diff --git a/packages/attachment/attachment-local/src/canonical.ts b/packages/attachment/attachment-local/src/normalization.ts similarity index 77% rename from packages/attachment/attachment-local/src/canonical.ts rename to packages/attachment/attachment-local/src/normalization.ts index 513e5bbcc7..acfec63c0f 100644 --- a/packages/attachment/attachment-local/src/canonical.ts +++ b/packages/attachment/attachment-local/src/normalization.ts @@ -1,4 +1,4 @@ -/** Deterministic provider-independent master-image encoding. */ +/** Deterministic provider-independent image normalization. */ import sharp, { type Sharp } from 'sharp' import { AttachmentError } from '@deepseek-ai/dsh-attachment' @@ -7,23 +7,23 @@ import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' import { detectImage } from './image.ts' import type { DetectedImage } from './image.ts' -/** Deployment-resolved storage policy for the provider-independent master version. */ -export interface MasterImagePolicy { +/** Deployment-resolved policy for the persisted normalized attachment. */ +export interface NormalizationPolicy { /** Long-edge cap in pixels; larger sources are downscaled proportionally. */ maxDimension: number - /** Independent safety cap for encoded master bytes. */ + /** Independent safety cap for encoded normalized image bytes. */ maxBytes: number } -/** Master bytes beside the facts recorded by a durable reference. */ -export interface MasterImage { +/** Normalized bytes beside the facts recorded by a durable reference. */ +export interface NormalizedImage { data: Uint8Array mediaType: ImageMediaType width: number height: number } -const MASTER_QUALITIES = [85, 80, 75] as const +const NORMALIZATION_QUALITIES = [85, 80, 75] as const const LOW_COLOUR_SAMPLE_EDGE = 128 const LOW_COLOUR_LIMIT = 256 const MIN_SCALE_STEP = 0.9 @@ -34,7 +34,7 @@ async function encode( mediaType: 'image/png' | 'image/jpeg' | 'image/webp', quality?: number, palette = true, -): Promise { +): Promise { const encoded = mediaType === 'image/png' ? pipeline.png({ compressionLevel: 9, palette }) : mediaType === 'image/webp' @@ -45,13 +45,17 @@ async function encode( } /** - * Whether bytes already satisfy the master-version storage contract. + * Whether bytes already satisfy the normalization requirements. * @param detected - fully decoded source facts. * @param bytes - encoded source length. - * @param policy - resolved master limits. + * @param policy - resolved normalization limits. * @returns whether the source can pass through byte-identically. */ -export function isMasterImage(detected: DetectedImage, bytes: number, policy: MasterImagePolicy): boolean { +export function canPassThroughNormalization( + detected: DetectedImage, + bytes: number, + policy: NormalizationPolicy, +): boolean { return detected.mediaType !== 'image/gif' && !detected.animated && !detected.carriesMetadata @@ -87,8 +91,11 @@ export async function hasLowColourCount(pipeline: Sharp): Promise { return true } -/** Assert that a re-encoded master is an 8-bit sRGB/sRGBA single-frame image with matching facts. */ -async function verifyMaster(image: MasterImage, expectedAlpha: boolean | undefined): Promise { +/** Assert that a normalized output is an 8-bit sRGB/sRGBA single-frame image with matching facts. */ +async function verifyNormalizedImage( + image: NormalizedImage, + expectedAlpha: boolean | undefined, +): Promise { const detected = await detectImage(image.data) if (detected.mediaType !== image.mediaType || detected.width !== image.width @@ -99,7 +106,7 @@ async function verifyMaster(image: MasterImage, expectedAlpha: boolean | undefin || detected.space !== 'srgb' || (expectedAlpha !== undefined && detected.hasAlpha !== expectedAlpha)) { throw new AttachmentError( - 'Canonical image conversion did not produce a single-frame 8-bit sRGB image with matching metadata.', + 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', 'ATTACHMENT_WRITE_FAILED', ) } @@ -130,36 +137,36 @@ function encodingAttemptsAtSize( height: number, hasAlpha: boolean, lowColour: boolean, -): Array<() => Promise> { +): Array<() => Promise> { const prepared = preparedPipeline(data, width, height) - const webp = MASTER_QUALITIES.map(quality => ( + const webp = NORMALIZATION_QUALITIES.map(quality => ( () => encode(prepared.clone(), 'image/webp', quality) )) if (lowColour) { return [() => encode(prepared.clone(), 'image/png', undefined, !hasAlpha), ...webp] } if (hasAlpha) return webp - return MASTER_QUALITIES.map(quality => ( + return NORMALIZATION_QUALITIES.map(quality => ( () => encode(prepared.clone(), 'image/jpeg', quality) )) } /** - * Produce the 2048px provider-independent master version of one fully decoded source. + * Produce the persisted provider-independent normalized version of one fully decoded source. * The source is passed through only when it is already clean, single-frame, 8-bit sRGB/sRGBA, - * and inside both master limits. Re-encoding never removes transparency. After the fixed + * and inside both normalization limits. Re-encoding never removes transparency. After the fixed * quality floor is reached, dimensions continue shrinking until the independent byte cap holds. * @param data - complete admitted source bytes. * @param detected - fully decoded source facts. - * @param policy - resolved independent master limits. - * @returns verified provider-independent master bytes and metadata. + * @param policy - resolved independent normalization limits. + * @returns verified provider-independent normalized bytes and metadata. */ -export async function prepareMasterImage( +export async function normalizeImage( data: Uint8Array, detected: DetectedImage, - policy: MasterImagePolicy, -): Promise { - if (isMasterImage(detected, data.byteLength, policy)) { + policy: NormalizationPolicy, +): Promise { + if (canPassThroughNormalization(detected, data.byteLength, policy)) { return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height } } try { @@ -174,7 +181,7 @@ export async function prepareMasterImage( policy.maxBytes, ) if (!isExhaustedEncoding(encoded)) { - return await verifyMaster(encoded, detected.mediaType === 'image/gif' ? undefined : detected.hasAlpha) + return await verifyNormalizedImage(encoded, detected.mediaType === 'image/gif' ? undefined : detected.hasAlpha) } if (width === 1 && height === 1) break const sizeScale = Math.sqrt(policy.maxBytes / encoded.smallest.data.byteLength) * 0.95 @@ -190,10 +197,10 @@ export async function prepareMasterImage( ? `${detected.depth === 'ushort' ? '16-bit' : detected.depth} PNG` : `${detected.depth} ${detected.mediaType.slice('image/'.length).toUpperCase()}` throw new AttachmentError( - `The ${source} could not be converted to the canonical 8-bit sRGB form.`, + `The ${source} could not be converted to the normalized 8-bit sRGB form.`, 'ATTACHMENT_WRITE_FAILED', { cause: error }, ) } - throw new AttachmentError('Image cannot be encoded within the configured master-image byte cap.', 'IMAGE_TOO_LARGE') + throw new AttachmentError('Image cannot be encoded within the configured normalized-image byte cap.', 'IMAGE_TOO_LARGE') } diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index c37a473d4c..b7c9068bfb 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -12,12 +12,12 @@ import type { RequestImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { hasLowColourCount } from './canonical.ts' +import { hasLowColourCount } from './normalization.ts' import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' import { detectImage, probeImage } from './image.ts' /** Transform version included in every cache and upload-index identity. */ -export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v3' +export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v4' /** DeepSeek request versions normally fit at these two preferred qualities. */ export const REQUEST_IMAGE_QUALITIES = [85, 80] as const @@ -80,10 +80,10 @@ function validatePolicy(policy: ImageRequestPolicy): void { checkedInteger(policy.maxBytes, 'Image request maxBytes') } -function descriptor(master: ImageAttachmentRef, policy: ImageRequestPolicy): string { +function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): string { return JSON.stringify({ transformVersion: REQUEST_IMAGE_TRANSFORM_VERSION, - masterAttachmentId: master.attachmentId, + attachmentId: attachment.attachmentId, routePixelBudget: policy.maxPixels, encodedByteBudget: policy.maxBytes, encoding: { @@ -97,25 +97,25 @@ function descriptor(master: ImageAttachmentRef, policy: ImageRequestPolicy): str } /** - * Complete deterministic identity for one master and route-owned request policy. - * @param master - provider-independent durable master reference. + * Complete deterministic identity for one attachment and route-owned request policy. + * @param attachment - provider-independent durable normalized attachment reference. * @param policy - route-owned pixel and byte policy. * @returns branded digest over every request transform input. */ export function requestImageVariantId( - master: ImageAttachmentRef, + attachment: ImageAttachmentRef, policy: ImageRequestPolicy, ): ReturnType { - return ImageVariantId(`sha256:${digest(descriptor(master, policy))}`) + return ImageVariantId(`sha256:${digest(descriptor(attachment, policy))}`) } -function pipeline(master: StoredImageAttachment, width: number, height: number): Sharp { - return sourcePipeline(master) +function pipeline(attachment: StoredImageAttachment, width: number, height: number): Sharp { + return sourcePipeline(attachment) .resize({ width, height, fit: 'inside', withoutEnlargement: true }) } -function sourcePipeline(master: StoredImageAttachment): Sharp { - return sharp(master.data, { failOn: 'error', limitInputPixels: false }).toColourspace('srgb') +function sourcePipeline(attachment: StoredImageAttachment): Sharp { + return sharp(attachment.data, { failOn: 'error', limitInputPixels: false }).toColourspace('srgb') } async function encoded( @@ -134,13 +134,13 @@ async function encoded( } function encodingAttempts( - master: StoredImageAttachment, + attachment: StoredImageAttachment, width: number, height: number, hasAlpha: boolean, lowColour: boolean, ): Array<() => Promise> { - const prepared = pipeline(master, width, height) + const prepared = pipeline(attachment, width, height) const webp = REQUEST_IMAGE_QUALITIES.map(quality => ( () => encoded(prepared.clone(), 'image/webp', quality) )) @@ -152,25 +152,25 @@ function encodingAttempts( } async function createRequestImage( - master: StoredImageAttachment, + attachment: StoredImageAttachment, policy: ImageRequestPolicy, hasAlpha: boolean, ): Promise { - let dimensions = requestImageDimensions(master.ref.width, master.ref.height, policy.maxPixels) - if (dimensions.width === master.ref.width - && dimensions.height === master.ref.height - && master.data.byteLength <= policy.maxBytes) { + let dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) + if (dimensions.width === attachment.ref.width + && dimensions.height === attachment.ref.height + && attachment.data.byteLength <= policy.maxBytes) { return { - data: master.data, - mediaType: master.ref.mediaType, - width: master.ref.width, - height: master.ref.height, + data: attachment.data, + mediaType: attachment.ref.mediaType, + width: attachment.ref.width, + height: attachment.ref.height, } } - const lowColour = await hasLowColourCount(sourcePipeline(master)) + const lowColour = await hasLowColourCount(sourcePipeline(attachment)) for (;;) { const encodedVersion = await encodeFirstWithinLimit( - encodingAttempts(master, dimensions.width, dimensions.height, hasAlpha, lowColour), + encodingAttempts(attachment, dimensions.width, dimensions.height, hasAlpha, lowColour), policy.maxBytes, ) if (!isExhaustedEncoding(encodedVersion)) return encodedVersion @@ -190,7 +190,7 @@ function cachePath(root: string, hash: string): string { async function readCached( path: string, - master: StoredImageAttachment, + attachment: StoredImageAttachment, policy: ImageRequestPolicy, expectedAlpha: boolean, signal?: AbortSignal, @@ -198,7 +198,7 @@ async function readCached( try { const data = new Uint8Array(await readFile(path, { signal })) const detected = await probeImage(data) - const maximum = requestImageDimensions(master.ref.width, master.ref.height, policy.maxPixels) + const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) if (data.byteLength > policy.maxBytes || detected.depth !== 'uchar' || detected.space !== 'srgb' || detected.width > maximum.width || detected.height > maximum.height || detected.hasAlpha !== expectedAlpha) return undefined @@ -240,33 +240,33 @@ async function writeCached(path: string, data: Uint8Array): Promise { /** * Generate or reuse one request image below the local attachment root. * @param root - absolute versioned attachment storage root. - * @param master - verified stored master bytes and reference. + * @param attachment - verified normalized attachment bytes and reference. * @param policy - exact route request-image policy. * @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, - master: StoredImageAttachment, + attachment: StoredImageAttachment, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise { signal?.throwIfAborted() validatePolicy(policy) - const source = await probeImage(master.data) - const variantId = requestImageVariantId(master.ref, policy) + const source = await probeImage(attachment.data) + const variantId = requestImageVariantId(attachment.ref, policy) const hash = String(variantId).slice('sha256:'.length) const path = cachePath(root, hash) - const cached = await readCached(path, master, policy, source.hasAlpha, signal) - const created = cached ?? await createRequestImage(master, policy, source.hasAlpha) - const version = cached ?? (created.data === master.data + const cached = await readCached(path, attachment, policy, source.hasAlpha, signal) + const created = cached ?? await createRequestImage(attachment, policy, source.hasAlpha) + const version = cached ?? (created.data === attachment.data ? { ...created, hasAlpha: source.hasAlpha } : await verifyRequestImage(created, source.hasAlpha)) signal?.throwIfAborted() - if (cached === undefined && version.data !== master.data) await writeCached(path, version.data) + if (cached === undefined && version.data !== attachment.data) await writeCached(path, version.data) return { variantId, - master: master.ref, + attachment: attachment.ref, data: version.data, mediaType: version.mediaType, bytes: version.data.byteLength, diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index ba45256416..5fbb8e9201 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -12,12 +12,10 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, - SavedImageAttachment, - SourceImageInfo, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { prepareMasterImage } from './canonical.ts' -import type { MasterImagePolicy } from './canonical.ts' +import { normalizeImage } from './normalization.ts' +import type { NormalizationPolicy } from './normalization.ts' import { detectImage, probeImage } from './image.ts' import type { DetectedImage } from './image.ts' @@ -52,71 +50,69 @@ async function inspectMetadata( data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], limits: ImageAttachmentLimits, -): Promise<{ detected: DetectedImage; source: SourceImageInfo }> { +): Promise { if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') const detected = await detectImage(data, { maxPixels: limits.maxImagePixels, maxDimension: limits.maxImageDimension }) if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') - return { - detected, - source: { mediaType: detected.mediaType, bytes: data.byteLength, width: detected.width, height: detected.height }, - } + return detected } /** * Run the full admission policy for one image without touching storage, - * including master-version preparation: a batch whose members all validate - * cannot later be refused by the master byte cap during publication. + * including normalization: a batch whose members all validate cannot later + * be refused by the normalized image byte cap during publication. * @param input - encoded bytes and declared metadata. * @param limits - resolved source admission policy. - * @param policy - resolved master-version storage policy. - * @returns completion after the raster has been decoded and its master version proven to fit. + * @param policy - resolved normalization policy. + * @returns completion after the raster has been decoded and its normalized version proven to fit. */ export async function validateImageFile( input: SaveImageAttachment, limits: ImageAttachmentLimits, - policy: MasterImagePolicy, + policy: NormalizationPolicy, ): Promise { await prepareImageFile(input, limits, policy) } -/** Fully prepared master object, verified before any batch member is persisted. */ -export interface PreparedImageFile extends SavedImageAttachment { - /** Deterministic master bytes whose digest is {@link ref.attachmentId}. */ +/** Fully prepared normalized object, verified before any batch member is persisted. */ +export interface PreparedImageFile { + /** Deterministic normalized bytes whose digest is {@link ref.attachmentId}. */ data: Uint8Array + /** Durable reference describing {@link data}. */ + ref: ImageAttachmentRef } /** * Decode, normalize, and verify one submitted image without touching storage. * @param input - submitted encoded bytes and declared media type. * @param limits - source admission policy. - * @param policy - independent master-version storage policy. + * @param policy - independent normalization policy. * @returns immutable reference facts beside bytes ready for atomic publication. */ export async function prepareImageFile( input: SaveImageAttachment, limits: ImageAttachmentLimits, - policy: MasterImagePolicy, + policy: NormalizationPolicy, ): Promise { if (input.data.byteLength > limits.maxImageBytes) { throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') } - const { detected, source } = await inspectMetadata(input.data, input.mediaType, limits) - const master = await prepareMasterImage(input.data, detected, policy) - const sha256 = digest(master.data) + const detected = await inspectMetadata(input.data, input.mediaType, limits) + const normalized = await normalizeImage(input.data, detected, policy) + const sha256 = digest(normalized.data) const name = displayName(input.name) - const downscaled = source.width !== master.width || source.height !== master.height + const downscaled = detected.width !== normalized.width || detected.height !== normalized.height return { - data: master.data, + data: normalized.data, ref: { attachmentId: AttachmentId(`sha256:${sha256}`), - mediaType: master.mediaType, - width: master.width, - height: master.height, - bytes: master.data.byteLength, + mediaType: normalized.mediaType, + width: normalized.width, + height: normalized.height, + bytes: normalized.data.byteLength, ...(name !== undefined ? { name } : {}), - ...downscaled ? { sourceWidth: source.width, sourceHeight: source.height } : {}, + ...downscaled ? { originalDimensions: { width: detected.width, height: detected.height } } : {}, }, - source, } } @@ -180,18 +176,18 @@ async function ensureDurableHome(path: string): Promise { } /** - * Publish one already verified master below a versioned attachment root. + * Publish one already verified normalized image below a versioned attachment root. * @param root - absolute `DSH_HOME/attachments/v1` root. - * @param prepared - deterministic master bytes, reference, and source facts. - * @returns durable content-addressed reference beside the submitted source facts. + * @param prepared - deterministic normalized bytes and reference. + * @returns durable content-addressed normalized image reference. */ export async function commitPreparedImageFile( root: string, prepared: PreparedImageFile, -): Promise { - const master = prepared.data +): Promise { + const normalized = prepared.data const sha256 = ensureReference(prepared.ref) - if (digest(master) !== sha256 || master.byteLength !== prepared.ref.bytes) { + if (digest(normalized) !== sha256 || normalized.byteLength !== prepared.ref.bytes) { throw new AttachmentError('Prepared attachment bytes do not match their reference.', 'ATTACHMENT_CORRUPT') } const bucket = join(root, 'objects', sha256.slice(0, 2)) @@ -207,7 +203,7 @@ export async function commitPreparedImageFile( let handle try { handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) - await handle.writeFile(master) + await handle.writeFile(normalized) await handle.sync() await handle.close() handle = undefined @@ -242,7 +238,7 @@ export async function commitPreparedImageFile( if (error instanceof AttachmentError) throw error throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) } - return { ref: prepared.ref, source: prepared.source } + return prepared.ref } /** @@ -250,15 +246,15 @@ export async function commitPreparedImageFile( * @param root - absolute `DSH_HOME/attachments/v1` root. * @param input - submitted encoded bytes and declared media type. * @param limits - resolved source admission policy. - * @param policy - resolved master-version storage policy. - * @returns durable content-addressed reference beside submitted source facts. + * @param policy - resolved normalization policy. + * @returns durable content-addressed normalized image reference. */ export async function saveImageFile( root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits, - policy: MasterImagePolicy, -): Promise { + policy: NormalizationPolicy, +): Promise { return commitPreparedImageFile(root, await prepareImageFile(input, limits, policy)) } diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index c3c7693614..f8deea3c5c 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -6,8 +6,8 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import sharp from 'sharp' import LocalAttachmentStore, { - DEFAULT_MASTER_MAX_BYTES, - DEFAULT_MASTER_MAX_DIMENSION, + DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, + DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, DEFAULT_IMAGE_COMPRESSION_CONCURRENCY, DEFAULT_MAX_IMAGE_BYTES, DEFAULT_MAX_IMAGE_DIMENSION, @@ -32,9 +32,9 @@ describe('local attachment service', () => { maxImageDimension: DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }) - expect(service.masterPolicy).toEqual({ - maxDimension: DEFAULT_MASTER_MAX_DIMENSION, - maxBytes: DEFAULT_MASTER_MAX_BYTES, + expect(service.normalizationPolicy).toEqual({ + maxDimension: DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, + maxBytes: DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, }) expect(service.imageCompressionConcurrency).toBe(DEFAULT_IMAGE_COMPRESSION_CONCURRENCY) }) @@ -55,7 +55,7 @@ describe('local attachment service', () => { 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) - const { ref } = await service.saveImage({ data, mediaType: 'image/png' }) + const ref = await service.saveImage({ data, mediaType: 'image/png' }) await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) } finally { await rm(dshHome, { recursive: true, force: true }) @@ -86,7 +86,7 @@ describe('local attachment service', () => { } }) - it.each([3, 4] as const)('admits a 16-bit %s-channel PNG as an 8-bit master object', async (channels) => { + it.each([3, 4] as const)('admits a 16-bit %s-channel PNG as an 8-bit normalized object', async (channels) => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-16-bit-')) try { const service = new LocalAttachmentStore(new Context(), { dshHome }) @@ -95,7 +95,7 @@ describe('local attachment service', () => { }).toColourspace('rgb16').png().toBuffer()) const saved = await service.saveImage({ data: source, mediaType: 'image/png' }) - const stored = await service.readImage(saved.ref) + const stored = await service.readImage(saved) const metadata = await sharp(stored.data).metadata() expect(stored.data).not.toEqual(source) @@ -108,7 +108,7 @@ describe('local attachment service', () => { it('prepares every batch member before any write', async () => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-batch-')) try { - const service = new LocalAttachmentStore(new Context(), { dshHome, masterMaxBytes: 1 }) + const service = new LocalAttachmentStore(new Context(), { dshHome, normalizedImageMaxBytes: 1 }) const valid = Uint8Array.from(Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', diff --git a/packages/attachment/attachment-local/tests/canonical.spec.ts b/packages/attachment/attachment-local/tests/normalization.spec.ts similarity index 63% rename from packages/attachment/attachment-local/tests/canonical.spec.ts rename to packages/attachment/attachment-local/tests/normalization.spec.ts index 8aa30511d6..4b530988d1 100644 --- a/packages/attachment/attachment-local/tests/canonical.spec.ts +++ b/packages/attachment/attachment-local/tests/normalization.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import sharp from 'sharp' -import { hasLowColourCount, isMasterImage, prepareMasterImage } from '../src/canonical.ts' -import type { MasterImagePolicy } from '../src/canonical.ts' +import { hasLowColourCount, canPassThroughNormalization, normalizeImage } from '../src/normalization.ts' +import type { NormalizationPolicy } from '../src/normalization.ts' import { detectImage } from '../src/image.ts' -const POLICY: MasterImagePolicy = { maxDimension: 2048, maxBytes: 4 * 1024 * 1024 } +const POLICY: NormalizationPolicy = { maxDimension: 2048, maxBytes: 4 * 1024 * 1024 } /** Deterministic pseudo-random RGB noise; PNG cannot compress it below raw size. */ function noisePixels(width: number, height: number): Uint8Array { @@ -31,29 +31,29 @@ async function flatImage(width: number, height: number, format: 'png' | 'jpeg' | return new Uint8Array(await image.toFormat(format, format === 'webp' && alpha ? { lossless: true } : {}).toBuffer()) } -describe('isMasterImage', () => { +describe('canPassThroughNormalization', () => { it('accepts an in-budget clean PNG/JPEG/WebP and refuses GIF, animation, metadata, oversized edges, and oversized bytes', () => { const clean = { animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false } - expect(isMasterImage({ mediaType: 'image/png', width: 2048, height: 4, ...clean }, 100, POLICY)).toBe(true) - expect(isMasterImage({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false) - expect(isMasterImage({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) - expect(isMasterImage({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) - expect(isMasterImage({ mediaType: 'image/png', width: 4, height: 4, ...clean, depth: 'ushort' }, 100, POLICY)).toBe(false) - expect(isMasterImage({ mediaType: 'image/png', width: 4, height: 4, ...clean, space: 'rgb16' }, 100, POLICY)).toBe(false) - expect(isMasterImage({ mediaType: 'image/jpeg', width: 2049, height: 4, ...clean }, 100, POLICY)).toBe(false) - expect(isMasterImage({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/png', width: 2048, height: 4, ...clean }, 100, POLICY)).toBe(true) + expect(canPassThroughNormalization({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, depth: 'ushort' }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, space: 'rgb16' }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 2049, height: 4, ...clean }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false) }) }) -describe('prepareMasterImage', () => { - it('passes an already-canonical source through byte-identically', async () => { +describe('normalizeImage', () => { + it('passes an already-normalized source through byte-identically', async () => { const data = await flatImage(6, 4, 'webp') const detected = await detectImage(data) - const canonical = await prepareMasterImage(data, detected, POLICY) + const normalized = await normalizeImage(data, detected, POLICY) - expect(canonical.data).toBe(data) - expect(canonical).toMatchObject({ mediaType: 'image/webp', width: 6, height: 4 }) + expect(normalized.data).toBe(data) + expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 6, height: 4 }) }) it.each([3, 4] as const)('converts a 16-bit %s-channel PNG to 8-bit sRGB without passthrough', async (channels) => { @@ -63,11 +63,11 @@ describe('prepareMasterImage', () => { const detected = await detectImage(data) expect(detected).toMatchObject({ depth: 'ushort', space: 'rgb16', hasAlpha: channels === 4 }) - const canonical = await prepareMasterImage(data, detected, POLICY) + const normalized = await normalizeImage(data, detected, POLICY) - expect(canonical.data).not.toBe(data) - expect(canonical.data).not.toEqual(data) - await expect(detectImage(canonical.data)).resolves.toMatchObject({ + expect(normalized.data).not.toBe(data) + expect(normalized.data).not.toEqual(data) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ depth: 'uchar', space: 'srgb', hasAlpha: channels === 4, width: 7, height: 5, }) }) @@ -76,19 +76,19 @@ describe('prepareMasterImage', () => { const data = await flatImage(10, 6, 'png') const detected = await detectImage(data) - const canonical = await prepareMasterImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const normalized = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) - expect(canonical).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) - await expect(detectImage(canonical.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) - const again = await prepareMasterImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) - expect(again.data).toEqual(canonical.data) + expect(normalized).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) + const again = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) + expect(again.data).toEqual(normalized.data) }) - it('re-encodes the canonical output of a resize into itself (idempotence)', async () => { + it('re-encodes the normalized output of a resize into itself (idempotence)', async () => { const data = await flatImage(10, 6, 'png') - const first = await prepareMasterImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const first = await normalizeImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) - const second = await prepareMasterImage(first.data, await detectImage(first.data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const second = await normalizeImage(first.data, await detectImage(first.data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(second.data).toBe(first.data) }) @@ -97,19 +97,19 @@ describe('prepareMasterImage', () => { const data = await flatImage(6, 4, 'gif') const detected = await detectImage(data) - const canonical = await prepareMasterImage(data, detected, POLICY) + const normalized = await normalizeImage(data, detected, POLICY) - expect(canonical.mediaType).toBe('image/png') - await expect(detectImage(canonical.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 6, height: 4, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) + expect(normalized.mediaType).toBe('image/png') + await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 6, height: 4, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) }) it('keeps a low-colour alpha source on PNG when the budget holds', async () => { const data = await flatImage(9, 5, 'webp', true) const detected = await detectImage(data) - const canonical = await prepareMasterImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes }) + const normalized = await normalizeImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes }) - expect(canonical).toMatchObject({ mediaType: 'image/png', width: 4, height: 2 }) + expect(normalized).toMatchObject({ mediaType: 'image/png', width: 4, height: 2 }) }) it('retains an all-opaque alpha channel while converting a low-colour image', async () => { @@ -117,13 +117,13 @@ describe('prepareMasterImage', () => { create: { width: 10, height: 6, channels: 4, background: { r: 12, g: 200, b: 64, alpha: 1 } }, }).png().toBuffer()) - const canonical = await prepareMasterImage(data, await detectImage(data), { + const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes, }) - expect(canonical).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) - await expect(detectImage(canonical.data)).resolves.toMatchObject({ hasAlpha: true }) + expect(normalized).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true }) }) it('keeps transparency when the byte cap requires another encoding and smaller dimensions', async () => { @@ -140,20 +140,20 @@ describe('prepareMasterImage', () => { } const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 4 } }).png().toBuffer()) - const canonical = await prepareMasterImage(data, await detectImage(data), { maxDimension: side, maxBytes: 1_024 }) + const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: side, maxBytes: 1_024 }) - expect(canonical.data.byteLength).toBeLessThanOrEqual(1_024) - expect(canonical.width).toBeLessThan(side) - await expect(detectImage(canonical.data)).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' }) + expect(normalized.data.byteLength).toBeLessThanOrEqual(1_024) + expect(normalized.width).toBeLessThan(side) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' }) }) it('re-encodes an oversized photographic JPEG as JPEG', async () => { const data = await noiseImage(64, 32, 'jpeg') const detected = await detectImage(data) - const canonical = await prepareMasterImage(data, detected, { maxDimension: 32, maxBytes: POLICY.maxBytes }) + const normalized = await normalizeImage(data, detected, { maxDimension: 32, maxBytes: POLICY.maxBytes }) - expect(canonical).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 }) + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 }) }) it('classifies a photographic PNG by pixels and uses an opaque photographic encoding', async () => { @@ -174,21 +174,21 @@ describe('prepareMasterImage', () => { const detected = await detectImage(data) const budget = { maxDimension: 128, maxBytes: POLICY.maxBytes } - const canonical = await prepareMasterImage(data, detected, budget) + const normalized = await normalizeImage(data, detected, budget) - expect(canonical.mediaType).toBe('image/jpeg') - expect(canonical).toMatchObject({ width: 128, height: 128 }) - expect(canonical.data.byteLength).toBeLessThanOrEqual(budget.maxBytes) + expect(normalized.mediaType).toBe('image/jpeg') + expect(normalized).toMatchObject({ width: 128, height: 128 }) + expect(normalized.data.byteLength).toBeLessThanOrEqual(budget.maxBytes) }) it('shrinks dimensions after the quality floor instead of refusing an oversized encoding', async () => { const data = await noiseImage(64, 64, 'png') - const canonical = await prepareMasterImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 512 }) + const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 512 }) - expect(canonical.data.byteLength).toBeLessThanOrEqual(512) - expect(canonical.width).toBeLessThan(64) - expect(canonical.height).toBeLessThan(64) + expect(normalized.data.byteLength).toBeLessThanOrEqual(512) + expect(normalized.width).toBeLessThan(64) + expect(normalized.height).toBeLessThan(64) }) it('re-encodes an in-budget oriented JPEG, baking rotation and stripping metadata', async () => { @@ -199,11 +199,11 @@ describe('prepareMasterImage', () => { // Orientation 6 rotates 90°: the perceived source is 2x4. expect(detected).toMatchObject({ width: 2, height: 4, carriesMetadata: true }) - const canonical = await prepareMasterImage(data, detected, POLICY) + const normalized = await normalizeImage(data, detected, POLICY) - expect(canonical.data).not.toBe(data) - expect(canonical).toMatchObject({ width: 2, height: 4 }) - await expect(detectImage(canonical.data)).resolves.toMatchObject({ width: 2, height: 4, carriesMetadata: false }) + expect(normalized.data).not.toBe(data) + expect(normalized).toMatchObject({ width: 2, height: 4 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ width: 2, height: 4, carriesMetadata: false }) }) it('re-encodes an in-budget image with an ICC profile and strips the profile', async () => { @@ -213,10 +213,10 @@ describe('prepareMasterImage', () => { const detected = await detectImage(data) expect(detected.carriesMetadata).toBe(true) - const canonical = await prepareMasterImage(data, detected, POLICY) + const normalized = await normalizeImage(data, detected, POLICY) - expect(canonical.data).not.toBe(data) - await expect(detectImage(canonical.data)).resolves.toMatchObject({ carriesMetadata: false }) + expect(normalized.data).not.toBe(data) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ carriesMetadata: false }) }) it('maps an encoder fault on undecodable bytes to a storage failure', async () => { @@ -224,10 +224,10 @@ describe('prepareMasterImage', () => { mediaType: 'image/png', width: 5000, height: 5000, animated: false, carriesMetadata: false, depth: 'ushort', space: 'rgb16', hasAlpha: true, } as const - await expect(prepareMasterImage(Uint8Array.of(1, 2, 3), detected, POLICY)) + await expect(normalizeImage(Uint8Array.of(1, 2, 3), detected, POLICY)) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', - message: 'The 16-bit PNG could not be converted to the canonical 8-bit sRGB form.', + message: 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', }) }) @@ -245,23 +245,23 @@ describe('prepareMasterImage', () => { hasAlpha: false, } as const - await expect(prepareMasterImage(Uint8Array.of(1, 2, 3), detected, POLICY)) + await expect(normalizeImage(Uint8Array.of(1, 2, 3), detected, POLICY)) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', - message: `The ${source} could not be converted to the canonical 8-bit sRGB form.`, + message: `The ${source} could not be converted to the normalized 8-bit sRGB form.`, }) }) - it('rejects a converted master whose verified alpha metadata disagrees with the source facts', async () => { + it('rejects a converted normalized image whose verified alpha metadata disagrees with the source facts', async () => { const data = await flatImage(8, 8, 'png', true) const detected = await detectImage(data) - await expect(prepareMasterImage(data, { ...detected, hasAlpha: false }, { + await expect(normalizeImage(data, { ...detected, hasAlpha: false }, { maxDimension: 4, maxBytes: POLICY.maxBytes, })).rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', - message: 'Canonical image conversion did not produce a single-frame 8-bit sRGB image with matching metadata.', + message: 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', }) }) }) @@ -339,13 +339,13 @@ describe('hasLowColourCount', () => { `)).removeAlpha().png().toBuffer()) - const master = await prepareMasterImage(source, await detectImage(source), { + const normalized = await normalizeImage(source, await detectImage(source), { maxDimension: 512, maxBytes: POLICY.maxBytes, }) - const stats = await sharp(master.data).greyscale().stats() + const stats = await sharp(normalized.data).greyscale().stats() - expect(master).toMatchObject({ mediaType: 'image/png', width: 512, height: 256 }) + expect(normalized).toMatchObject({ mediaType: 'image/png', width: 512, height: 256 }) expect(stats.channels[0]?.min).toBeLessThan(80) expect(stats.channels[0]?.max).toBeGreaterThan(240) }) diff --git a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts index aae96e0b1d..32bc005c94 100644 --- a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts @@ -35,10 +35,10 @@ describe('request image verification', () => { const source = new Uint8Array(await sharp({ create: { width: 64, height: 32, channels: 3, background: { r: 12, g: 34, b: 56 } }, }).png().toBuffer()) - const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) control.mismatch = true - await expect(attachments.readImageRequest(master, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })) + await expect(attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', message: 'Encoded model-request image does not match its verified 8-bit sRGB metadata.', diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index c38e8ce137..7052da89e8 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -54,43 +54,45 @@ describe('request image dimensions', () => { }) describe('local request-image cache', () => { - it('passes through an in-budget master and reads a request batch in input order', async () => { + it('passes through an in-budget attachment and composes ordered request reads', async () => { const attachments = await store() - const first = (await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })).ref - const second = (await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' })).ref - const firstMaster = await attachments.readImage(first) + 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 = { maxPixels: 1_000, maxBytes: 1024 * 1024 } const request = await attachments.readImageRequest(first, policy) - const batch = await attachments.readImageRequests([first, second], policy) + const batch = await Promise.all([first, second].map( + attachment => attachments.readImageRequest(attachment, policy), + )) - expect(request.data).toEqual(firstMaster.data) - expect(batch.map(value => value.master.attachmentId)).toEqual([first.attachmentId, second.attachmentId]) + expect(request.data).toEqual(firstStored.data) + expect(batch.map(value => value.attachment.attachmentId)).toEqual([first.attachmentId, second.attachmentId]) }) it('rejects invalid request policies', async () => { const attachments = await store() - const master = (await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' }) - await expect(attachments.readImageRequest(master, { maxPixels: 0, maxBytes: 100 })) + await expect(attachments.readImageRequest(attachment, { maxPixels: 0, maxBytes: 100 })) .rejects.toThrow('Image request maxPixels must be a positive integer') - await expect(attachments.readImageRequest(master, { maxPixels: 100, maxBytes: 0 })) + await expect(attachments.readImageRequest(attachment, { maxPixels: 100, maxBytes: 0 })) .rejects.toThrow('Image request maxBytes must be a positive integer') }) it('refuses a one-pixel request that cannot meet the encoded-byte budget', async () => { const attachments = await store() - const master = (await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' }) - await expect(attachments.readImageRequest(master, { maxPixels: 1, maxBytes: 1 })) + await expect(attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 })) .rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) }) it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => { const attachments = await store() - const master = (await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' }) const policy = { maxPixels: 16 * 16, maxBytes: 4_096 } - const initial = await attachments.readImageRequest(master, policy) + 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) const noisyPixels = new Uint8Array(64 * 64 * 3) @@ -124,19 +126,19 @@ describe('local request-image cache', () => { Uint8Array.of(1, 2, 3), ]) { await writeFile(path, invalid) - const regenerated = await attachments.readImageRequest(master, policy) + const regenerated = await attachments.readImageRequest(attachment, policy) expect(regenerated.data).toEqual(initial.data) } }) it('derives stable square and wide previews and separates route budgets in the cache key', async () => { const attachments = await store() - const square = (await attachments.saveImage({ + const square = await attachments.saveImage({ data: await image(2048, 2048), mediaType: 'image/png', name: 'square.png', - })).ref - const wide = (await attachments.saveImage({ + }) + const wide = await attachments.saveImage({ data: await image(2048, 1024), mediaType: 'image/png', name: 'wide.png', - })).ref + }) const squareRequest = await attachments.readImageRequest(square, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) const wideRequest = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) @@ -178,8 +180,8 @@ describe('local request-image cache', () => { const alphaSource = new Uint8Array(await sharp(alphaPixels, { raw: { width: side, height: side, channels: 4 }, }).png().toBuffer()) - const photo = (await attachments.saveImage({ data: photoSource, mediaType: 'image/png' })).ref - const alpha = (await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' })).ref + 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, { maxPixels: 128 * 128, maxBytes: 1024 * 1024 }) const alphaRequest = await attachments.readImageRequest(alpha, { maxPixels: 128 * 128, maxBytes: 4_096 }) @@ -195,9 +197,9 @@ describe('local request-image cache', () => { const source = new Uint8Array(await sharp({ create: { width: 64, height: 32, channels, background: { r: 12, g: 34, b: 56, alpha: 0.5 } }, }).toColourspace('rgb16').png().toBuffer()) - const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(master, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) expect(request.width * request.height).toBeLessThanOrEqual(16 * 16) @@ -211,9 +213,9 @@ describe('local request-image cache', () => { const source = new Uint8Array(await sharp({ create: { width: 64, height: 32, channels: 4, background: { r: 12, g: 34, b: 56, alpha: 1 } }, }).png().toBuffer()) - const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(master, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: true }) }) @@ -232,9 +234,9 @@ describe('local request-image cache', () => { const source = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 3 }, }).png().toBuffer()) - const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(master, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) expect(request).toMatchObject({ width: 800, height: 800 }) expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) @@ -242,15 +244,15 @@ describe('local request-image cache', () => { it('shares one request transform between concurrent callers without sharing cancellation', async () => { const attachments = await store() - const master = (await attachments.saveImage({ + const attachment = await attachments.saveImage({ data: await image(2048, 1024), mediaType: 'image/png', name: 'shared.png', - })).ref + }) const run = vi.spyOn(CompressionLimiter.prototype, 'run') const controller = new AbortController() const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } - const cancelled = attachments.readImageRequest(master, policy, controller.signal) - const completed = attachments.readImageRequest(master, policy) + const cancelled = attachments.readImageRequest(attachment, policy, controller.signal) + const completed = attachments.readImageRequest(attachment, policy) const reason = new Error('cancel one waiter') controller.abort(reason) @@ -262,9 +264,9 @@ describe('local request-image cache', () => { it('aborts the underlying request transform after its only waiter cancels', async () => { const attachments = await store() - const master = (await attachments.saveImage({ + const attachment = await attachments.saveImage({ data: await image(2048, 1024), mediaType: 'image/png', name: 'cancelled.png', - })).ref + }) let readSignal: AbortSignal | undefined const read = vi.spyOn(attachments, 'readImage').mockImplementation((_ref, signal) => { readSignal = signal @@ -276,7 +278,7 @@ describe('local request-image cache', () => { }) const controller = new AbortController() const request = attachments.readImageRequest( - master, + attachment, { maxPixels: 640_000, maxBytes: 1024 * 1024 }, controller.signal, ) @@ -293,9 +295,9 @@ describe('local request-image cache', () => { it('normalizes a non-Error cancellation and replaces an aborted shared transform', async () => { const attachments = await store() - const master = (await attachments.saveImage({ + const attachment = await attachments.saveImage({ data: await image(2048, 1024), mediaType: 'image/png', name: 'replace.png', - })).ref + }) const actualRead = attachments.readImage.bind(attachments) let calls = 0 vi.spyOn(attachments, 'readImage').mockImplementation((ref, signal) => { @@ -311,13 +313,13 @@ describe('local request-image cache', () => { }) const controller = new AbortController() const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } - const cancelled = attachments.readImageRequest(master, policy, controller.signal) + const cancelled = attachments.readImageRequest(attachment, policy, controller.signal) await vi.waitFor(() => { expect(calls).toBe(1) }) controller.abort('cancelled') - const replacement = attachments.readImageRequest(master, policy) + const replacement = attachments.readImageRequest(attachment, policy) await expect(cancelled).rejects.toMatchObject({ message: 'Attachment request cancelled with a non-Error reason.', diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index f0c127c174..ad29f856ec 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -7,7 +7,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import sharp from 'sharp' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' -import type { MasterImagePolicy } from '../src/canonical.ts' +import type { NormalizationPolicy } from '../src/normalization.ts' import { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile } from '../src/store.ts' const fsControl = vi.hoisted(() => ({ @@ -39,7 +39,7 @@ const PNG = Uint8Array.from(Buffer.from( 'base64', )) -const POLICY: MasterImagePolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 } +const POLICY: NormalizationPolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 } const LIMITS: ImageAttachmentLimits = { maxImageBytes: 1024, @@ -107,7 +107,7 @@ describe('local attachment store', () => { it('creates and persists a missing nested home directory against the filesystem root', async () => { const storageRoot = join(await root(), 'home', 'attachments', 'v1') - const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) }) @@ -121,7 +121,7 @@ describe('local attachment store', () => { const sha256 = createHash('sha256').update(PNG).digest('hex') const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) - expect(first.ref).toEqual({ + expect(first).toEqual({ attachmentId: `sha256:${sha256}`, mediaType: 'image/png', bytes: PNG.byteLength, @@ -129,17 +129,16 @@ describe('local attachment store', () => { height: 1, name: 'pixel.png', }) - expect(first.source).toEqual({ mediaType: 'image/png', bytes: PNG.byteLength, width: 1, height: 1 }) - expect(second.ref.attachmentId).toBe(first.ref.attachmentId) + expect(second.attachmentId).toBe(first.attachmentId) expect(new Uint8Array(await readFile(object))).toEqual(PNG) if (process.platform !== 'win32') { expect((await stat(object)).mode & 0o777).toBe(0o600) expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) } - await expect(readImageFile(storageRoot, first.ref)).resolves.toEqual({ ref: first.ref, data: PNG }) + await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) - it('stores the image master of an oversized source and reads it back verified', async () => { + it('stores the normalized image of an oversized source and reads it back verified', async () => { const storageRoot = await root() const oversized = new Uint8Array(await sharp({ create: { width: 4, height: 4, channels: 3, background: { r: 9, g: 9, b: 9 } }, @@ -149,24 +148,29 @@ describe('local attachment store', () => { data: oversized, mediaType: 'image/png', name: 'big.png', }, { ...LIMITS, maxImagePixels: 64 }, { maxDimension: 2, maxBytes: 1024 * 1024 }) - expect(saved.source).toEqual({ mediaType: 'image/png', bytes: oversized.byteLength, width: 4, height: 4 }) - expect(saved.ref).toMatchObject({ mediaType: 'image/png', width: 2, height: 2, name: 'big.png' }) - expect(saved.ref.bytes).not.toBe(oversized.byteLength) - const read = await readImageFile(storageRoot, saved.ref) - expect(read.data.byteLength).toBe(saved.ref.bytes) - expect(String(saved.ref.attachmentId)).toBe(`sha256:${createHash('sha256').update(read.data).digest('hex')}`) + expect(saved).toMatchObject({ + mediaType: 'image/png', + width: 2, + height: 2, + name: 'big.png', + originalDimensions: { width: 4, height: 4 }, + }) + expect(saved.bytes).not.toBe(oversized.byteLength) + const read = await readImageFile(storageRoot, saved) + expect(read.data.byteLength).toBe(saved.bytes) + expect(String(saved.attachmentId)).toBe(`sha256:${createHash('sha256').update(read.data).digest('hex')}`) }) it('keeps admitted history readable after deployment limits become stricter', async () => { const storageRoot = await root() - const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) }) it('forwards read cancellation to the filesystem and preserves its reason', async () => { const storageRoot = await root() - const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) const controller = new AbortController() fsControl.readSignals.length = 0 @@ -205,12 +209,12 @@ describe('local attachment store', () => { const unnamed = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png', name: '\u0000', }, LIMITS, POLICY) - expect(unnamed.ref).not.toHaveProperty('name') + expect(unnamed).not.toHaveProperty('name') }) it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => { const storageRoot = await root() - const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) const sha256 = String(ref.attachmentId).slice('sha256:'.length) const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) await chmod(object, 0o600) @@ -242,7 +246,7 @@ describe('local attachment store', () => { .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) await writeFile(target, PNG) - const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) await expect(readImageFile(storageRoot, { ...ref, width: ref.width + 1 })) .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) }) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index bbccf584c6..e27f25e933 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md -README.md: 66ce5f308cfa1ce6a028dbd248ceef1fdcc31a7c -README.zh.md: 4470956987330a451e3717d419a111def98dd6cb +README.md: 3ad568c7308f1ab85cb4af3fcc2afd3cba9a611a +README.zh.md: fadbb1c5bbf097c599da651055d63a1ed64cd579 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 66ce5f308c..3ad568c730 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent master image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. +The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every validated master once before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and resolves `SavedImageAttachment`: the returned `ref` describes the stored master while `source` (`SourceImageInfo`) preserves the submitted raster's media type, byte length, and orientation-applied dimensions. `readImage` verifies that master against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the master id, transform version, pixel and byte budgets, and encoder settings; `readImageRequests` preserves ordered results while implementations apply their own bounded concurrency. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. `admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 4470956987..fadbb1c5bb 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的图片主版本,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 +持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前为全部成员各准备一次经过验证的主版本,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并解析为 `SavedImageAttachment`:返回的 `ref` 描述实际存储的主版本,而 `source`(`SourceImageInfo`)保留所提交光栅的媒体类型、字节长度和应用方向后的尺寸。`readImage` 根据已记录的元数据校验该主版本。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖主版本 ID、变换策略版本、像素和字节预算及编码参数;`readImageRequests` 保持结果顺序,并由实现施加自己的有界并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 `admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 85401fad23..8b54926efa 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -8,7 +8,6 @@ import type { ImageRequestPolicy, RequestImageAttachment, SaveImageAttachment, - SavedImageAttachment, StoredImageAttachment, } from './types.ts' @@ -25,8 +24,6 @@ export type { ImageMediaType, RequestImageAttachment, SaveImageAttachment, - SavedImageAttachment, - SourceImageInfo, StoredImageAttachment, } from './types.ts' @@ -80,40 +77,39 @@ export abstract class AttachmentStore extends Service { /** * Validate and durably commit one ordered image batch. * @param inputs - encoded images in owning-message order. - * @returns durable master references in the same order after every member succeeds. + * @returns durable normalized attachment references in the same order after every member succeeds. */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise { this.validateImageBatch(inputs) for (const input of inputs) await this.validateImage(input) const refs: ImageAttachmentRef[] = [] - for (const input of inputs) refs.push((await this.saveImage(input)).ref) + for (const input of inputs) refs.push(await this.saveImage(input)) return refs } /** * Validate and durably commit one image before its owning session event is appended. - * Implementations may store a prepared master version of the submitted raster; - * the returned reference always describes the stored bytes, while `source` - * preserves the submitted raster's intrinsic facts for callers that report - * or map coordinates against the original. + * The returned reference describes the persisted normalized image. When + * normalization reduces the raster, its `originalDimensions` records the + * orientation-applied input dimensions. * @param input - encoded bytes, declared media type, and optional display name. - * @returns the durable content-addressed reference beside the submitted source facts. + * @returns the durable content-addressed normalized image reference. */ - abstract saveImage(input: SaveImageAttachment): Promise + abstract saveImage(input: SaveImageAttachment): Promise /** * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. * @param signal - optional cancellation for backend read and verification work. - * @returns the verified bytes and master reference. + * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise /** - * Generate or read one deterministic model-request version from the stored master image. - * @param ref - durable provider-independent master reference. + * Generate or read one deterministic model-request version from the stored normalized image. + * @param ref - durable provider-independent normalized attachment reference. * @param policy - exact route pixel and encoded-byte budget. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. @@ -132,24 +128,6 @@ export abstract class AttachmentStore extends Service { )) } - /** - * Generate or read an ordered batch of deterministic model-request versions. - * Implementations may use their own bounded transform concurrency while preserving input order. - * @param refs - durable provider-independent master references in request order. - * @param policy - exact route pixel and encoded-byte budget shared by the batch. - * @param signal - optional cancellation. - * @returns request versions in the same order as `refs`. - */ - async readImageRequests( - refs: readonly ImageAttachmentRef[], - policy: ImageRequestPolicy, - signal?: AbortSignal, - ): Promise { - const versions: RequestImageAttachment[] = [] - for (const ref of refs) versions.push(await this.readImageRequest(ref, policy, signal)) - return versions - } - } export default AttachmentStore diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 04f7362d38..e23a7a7d4c 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -7,7 +7,7 @@ export type { AttachmentId } from './brand.ts' /** Raster image formats accepted by the version-one attachment path. */ export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' -/** Durable, serializable metadata for one immutable image object. */ +/** Durable, serializable reference to one immutable normalized image. */ export interface ImageAttachmentRef { /** Opaque storage identifier; never a filesystem path or bearer URL. */ attachmentId: AttachmentId @@ -21,10 +21,14 @@ export interface ImageAttachmentRef { height: number /** Optional display name stripped of local path information. */ name?: string - /** Perceived source width before master-version downscaling; present only when it differs from {@link width}. */ - sourceWidth?: number - /** Perceived source height before master-version downscaling; present only when it differs from {@link height}. */ - sourceHeight?: number + /** + * Input dimensions after applying EXIF orientation and before normalization + * scaling. Present only when normalization reduced the image. + */ + originalDimensions?: { + width: number + height: number + } } /** Deployment-resolved limits used by upload admission and request buffering. */ @@ -71,12 +75,12 @@ export interface ImageRequestPolicy { maxBytes: number } -/** Cached request version derived from one provider-independent master attachment. */ +/** Cached request version derived from one provider-independent normalized attachment. */ export interface RequestImageAttachment { - /** Cache and upload-index key over the master id, policy, and fixed encoder parameters. */ + /** Cache and upload-index key over the attachment id, policy, and fixed encoder parameters. */ variantId: ImageVariantId - /** Durable master reference from which this request version was derived. */ - master: ImageAttachmentRef + /** Durable normalized attachment from which this request version was derived. */ + attachment: ImageAttachmentRef /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -90,23 +94,3 @@ export interface RequestImageAttachment { /** Whether the encoded request version retains an alpha channel. */ hasAlpha: boolean } - -/** Intrinsic facts of the submitted source raster, before master-version preparation. */ -export interface SourceImageInfo { - /** Media type verified from the submitted bytes. */ - mediaType: ImageMediaType - /** Exact submitted encoded byte length. */ - bytes: number - /** Perceived source width in pixels, with any EXIF orientation applied, so it shares axes with the stored raster. */ - width: number - /** Perceived source height in pixels, with any EXIF orientation applied, so it shares axes with the stored raster. */ - height: number -} - -/** Commit result pairing the durable reference with the submitted source raster it was derived from. */ -export interface SavedImageAttachment { - /** Durable reference describing the stored bytes. */ - ref: ImageAttachmentRef - /** Submitted source raster facts; equals the `ref` fields when the store kept the submitted bytes. */ - source: SourceImageInfo -} diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 589a4322d9..be784f0276 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -10,7 +10,6 @@ import AttachmentStore, { type ImageRequestPolicy, type RequestImageAttachment, type SaveImageAttachment, - type SavedImageAttachment, type StoredImageAttachment, } from '../src/index.ts' @@ -35,20 +34,17 @@ class RecordingStore extends AttachmentStore { if (value === this.rejectValidationAt) throw new Error(`invalid:${value}`) } - async saveImage(input: SaveImageAttachment): Promise { + async saveImage(input: SaveImageAttachment): Promise { const value = input.data[0] ?? 0 this.calls.push(`save:${value}`) if (value === this.rejectSaveAt) throw new Error(`write:${value}`) return { - ref: { - attachmentId: AttachmentId(`sha256:${String(value).padStart(64, '0')}`), - mediaType: input.mediaType, - bytes: input.data.byteLength, - width: 1, - height: 1, - ...input.name === undefined ? {} : { name: input.name }, - }, - source: { mediaType: input.mediaType, bytes: input.data.byteLength, width: 1, height: 1 }, + attachmentId: AttachmentId(`sha256:${String(value).padStart(64, '0')}`), + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...input.name === undefined ? {} : { name: input.name }, } } @@ -63,7 +59,7 @@ class RecordingStore extends AttachmentStore { this.calls.push(`request:${ref.name}`) return Promise.resolve({ variantId: ImageVariantId(`sha256:${String(ref.bytes).padStart(64, '0')}`), - master: ref, + attachment: ref, data: Uint8Array.of(ref.bytes), mediaType: ref.mediaType, bytes: 1, @@ -83,7 +79,7 @@ class UnsupportedProjectionStore extends AttachmentStore { return Promise.resolve() } - saveImage(): Promise { + saveImage(): Promise { throw new Error('not used') } @@ -139,21 +135,10 @@ describe('AttachmentStore.saveImages', () => { }) }) -describe('AttachmentStore.readImageRequests', () => { - it('uses the default serial projection and preserves input order', async () => { - const store = new RecordingStore(new Context()) - const refs = await store.saveImages([image(1), image(2)]) - store.calls.length = 0 - - const versions = await store.readImageRequests(refs, { maxPixels: 1, maxBytes: 1 }) - - expect(store.calls).toEqual(['request:1.png', 'request:2.png']) - expect(versions.map(version => version.master.name)).toEqual(['1.png', '2.png']) - }) - +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))).ref + const ref = await new RecordingStore(new Context()).saveImage(image(1)) await expect(store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 })) .rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' }) const controller = new AbortController() diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 0dbe8f0535..464a3d8f5c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -440,33 +440,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async saveImages(inputs: readonly SaveImageAttachment[]): Promise', description: 'Validate and durably commit one ordered image batch.', parameters: [{ name: 'inputs', description: 'encoded images in owning-message order.' }], - returns: 'durable master references in the same order after every member succeeds.', + returns: 'durable normalized attachment references in the same order after every member succeeds.', }, { - signature: 'abstract saveImage(input: SaveImageAttachment): Promise', - description: 'Validate and durably commit one image before its owning session event is appended. Implementations may store a prepared master version of the submitted raster; the returned reference always describes the stored bytes, while `source` preserves the submitted raster\'s intrinsic facts for callers that report or map coordinates against the original.', + signature: 'abstract saveImage(input: SaveImageAttachment): Promise', + description: 'Validate and durably commit one image before its owning session event is appended. The returned reference describes the persisted normalized image. When normalization reduces the raster, its `originalDimensions` records the orientation-applied input dimensions.', parameters: [{ name: 'input', description: 'encoded bytes, declared media type, and optional display name.' }], - returns: 'the durable content-addressed reference beside the submitted source facts.', + returns: 'the durable content-addressed normalized image reference.', }, { signature: 'abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise', description: 'Read one image and verify that bytes still match the recorded reference.', parameters: [{ name: 'ref', description: 'durable reference from the session log.' }, { name: 'signal', description: 'optional cancellation for backend read and verification work.' }], - returns: 'the verified bytes and master reference.', + returns: 'the verified bytes and normalized attachment reference.', throws: ['the signal reason when aborted, or a storage error when verification fails.'], }, { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', - description: 'Generate or read one deterministic model-request version from the stored master image.', - parameters: [{ name: 'ref', description: 'durable provider-independent master reference.' }, { name: 'policy', description: 'exact route pixel and encoded-byte budget.' }, { name: 'signal', description: 'optional cancellation.' }], + 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: 'exact route pixel and encoded-byte budget.' }, { name: 'signal', description: 'optional cancellation.' }], returns: 'request bytes and the cache/upload identity covering every transform input.', }, - { - signature: 'async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', - description: 'Generate or read an ordered batch of deterministic model-request versions. Implementations may use their own bounded transform concurrency while preserving input order.', - parameters: [{ name: 'refs', description: 'durable provider-independent master references in request order.' }, { name: 'policy', description: 'exact route pixel and encoded-byte budget shared by the batch.' }, { name: 'signal', description: 'optional cancellation.' }], - returns: 'request versions in the same order as `refs`.', - }, ], }, { @@ -3462,7 +3456,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ImageAttachmentRef', - declaration: 'export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n sourceWidth?: number;\n sourceHeight?: number;\n}', + declaration: 'export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n originalDimensions?: {\n width: number;\n height: number;\n };\n}', }, { name: 'ImageBlock', @@ -3942,7 +3936,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RequestImageAttachment', - declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n master: ImageAttachmentRef;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', + declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', }, { name: 'RequestRunOutcome', @@ -4028,10 +4022,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SandboxPolicyRequest', declaration: 'export interface SandboxPolicyRequest {\n session?: Session;\n mode?: SandboxMode;\n}', }, - { - name: 'SavedImageAttachment', - declaration: 'export interface SavedImageAttachment {\n ref: ImageAttachmentRef;\n source: SourceImageInfo;\n}', - }, { name: 'SaveImageAttachment', declaration: 'export interface SaveImageAttachment {\n data: Uint8Array;\n mediaType: ImageMediaType;\n name?: string;\n}', @@ -4436,10 +4426,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillViewOptions', declaration: 'export interface SkillViewOptions extends SkillLookupOptions {\n readonly scope?: ScopeKey | undefined;\n}', }, - { - name: 'SourceImageInfo', - declaration: 'export interface SourceImageInfo {\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n}', - }, { name: 'SpawnTeammateRequest', declaration: 'export interface SpawnTeammateRequest {\n readonly name: string;\n readonly description: string;\n readonly prompt: ContentBlock[];\n readonly context: \'fresh\' | \'fork\';\n readonly provider: string;\n readonly signal: AbortSignal;\n}', diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 6c590d54b4..3ef67e88c0 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md -README.md: ab01840f122d6e0df2782b86840432914b27ebd0 -README.zh.md: ef738a3715b6db45d386d56ba2a776960dd341c1 +README.md: 763cb831233da5b1f14c73e353920e9d6a87ced9 +README.zh.md: aa55de59452e7779ef278748abac17837800ba02 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index ab01840f12..763cb83123 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -38,7 +38,7 @@ All keys are optional; the defaults are the shipped read caps. Field names are snake_case to match Claude Code and existing harness tool schemas. -Structured successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. The image source fields appear only when master preparation downscaled the submitted raster. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`; execution-local structured values are not added to `tool/result`, while image renderers emit the durable image blocks that the result logs. +Structured successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, originalDimensions?: { width, height } } }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. `originalDimensions` appears only when normalization downscaled the submitted raster and records its orientation-applied input size. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`; execution-local structured values are not added to `tool/result`, while image renderers emit the durable image blocks that the result logs. ## The tool is the executor; policy is an event gate @@ -127,7 +127,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -A successful `read_image` returns ``, `image`, and a `` envelope naming the media type, master dimensions, and byte size, followed by the image itself as a native image block. The result is logged with its durable reference before the next model request. +A successful `read_image` returns ``, `image`, and a `` envelope naming the media type, normalized dimensions, and byte size, followed by the image itself as a native image block. The result is logged with its durable reference before the next model request. #### Token effect @@ -155,7 +155,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`. A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`. A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. #### Token effect diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index ef738a3715..aa55de5945 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -38,7 +38,7 @@ await ctx.plugin(ToolFs) // this package — re 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -结构化成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。图片 source 字段只在主版本准备缩小了提交光栅时出现。原生渲染器会保留下方带行号的读取结果和变更确认。`write` 和 `edit` 从这些值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;仅用于执行的结构化值不会添加到 `tool/result`,图片渲染器则会发出由结果记录的持久图片块。 +结构化成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, originalDimensions?: { width, height } } }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。`originalDimensions` 只在规范化过程缩小提交光栅时出现,并记录应用方向后的输入尺寸。原生渲染器会保留下方带行号的读取结果和变更确认。`write` 和 `edit` 从这些值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;仅用于执行的结构化值不会添加到 `tool/result`,图片渲染器则会发出由结果记录的持久图片块。 ## 工具就是执行器;策略是事件门禁 @@ -127,7 +127,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -成功的 `read_image` 返回 ``、`image` 和写明媒体类型、主版本尺寸与字节数的 `` 信封,随后是作为原生图像块的图像本身。结果会随持久引用写入会话日志,然后才进入下一次模型请求。 +成功的 `read_image` 返回 ``、`image` 和写明媒体类型、规范化尺寸与字节数的 `` 信封,随后是作为原生图像块的图像本身。结果会随持久引用写入会话日志,然后才进入下一次模型请求。 #### Token 影响 @@ -155,7 +155,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,不会重复陈旧恢复指令;write 则使用带防护的创建。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,不会重复陈旧恢复指令;write 则使用带防护的创建。 #### Token 影响 diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index bbf49d568c..b1cbad9bb9 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -38,8 +38,14 @@ const IMAGE_VALUE_SCHEMA = { width: { type: 'integer', required: true }, height: { type: 'integer', required: true }, name: { type: 'string' }, - sourceWidth: { type: 'integer' }, - sourceHeight: { type: 'integer' }, + originalDimensions: { + type: 'object', + additionalProperties: false, + properties: { + width: { type: 'integer', required: true }, + height: { type: 'integer', required: true }, + }, + }, }, } as const @@ -53,10 +59,11 @@ export interface ImageReadValue { width: number height: number name?: string - /** Intrinsic width of the file on disk; present only when storage downscaled it. */ - sourceWidth?: number - /** Intrinsic height of the file on disk; present only when storage downscaled it. */ - sourceHeight?: number + /** Orientation-applied file dimensions before normalization; present only when storage reduced it. */ + originalDimensions?: { + width: number + height: number + } } } @@ -105,8 +112,9 @@ export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachme width: image.width, height: image.height, ...image.name === undefined ? {} : { name: image.name }, - ...image.sourceWidth === undefined ? {} : { sourceWidth: image.sourceWidth }, - ...image.sourceHeight === undefined ? {} : { sourceHeight: image.sourceHeight }, + ...image.originalDimensions === undefined ? {} : { + originalDimensions: { ...image.originalDimensions }, + }, } } @@ -120,15 +128,15 @@ export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachme */ export function formatImageReadOutput(displayPath: string, image: ImageReadValue['image']): string { let scaled = '' - if (image.sourceWidth !== undefined && image.sourceHeight !== undefined) { + if (image.originalDimensions !== undefined) { // Integer rounding can give the two axes slightly different ratios, so the // advice names one multiplier only when both round to the same value. - const x = (image.sourceWidth / image.width).toFixed(2) - const y = (image.sourceHeight / image.height).toFixed(2) + const x = (image.originalDimensions.width / image.width).toFixed(2) + const y = (image.originalDimensions.height / image.height).toFixed(2) const advice = x === y ? `multiply coordinates by ${x}` : `multiply x coordinates by ${x} and y coordinates by ${y}` - scaled = ` (downscaled from ${image.sourceWidth}x${image.sourceHeight} px; ${advice} to locate features in the original file)` + scaled = ` (downscaled from ${image.originalDimensions.width}x${image.originalDimensions.height} px; ${advice} to locate features in the original file)` } return `${displayPath} image @@ -208,11 +216,8 @@ export function applyReadImageTool(ctx: Context): void { // Persist before returning: the image block must reference a durably // committed object by the time the tool/result event is appended. let ref: ImageAttachmentRef - let source: { width: number; height: number } try { - const saved = await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) }) - ref = saved.ref - source = saved.source + ref = await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) }) } catch (error: unknown) { if (!(error instanceof AttachmentError)) throw error // Dimension refusals stay recoverable tool errors: an oversized image @@ -238,7 +243,7 @@ export function applyReadImageTool(ctx: Context): void { } if (error.code === 'ATTACHMENT_WRITE_FAILED' && /16-bit PNG/iu.test(error.message)) { throw new Error( - `cannot read "${target.displayPath}": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, + `cannot read "${target.displayPath}": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, { cause: error }, ) } @@ -250,7 +255,6 @@ export function applyReadImageTool(ctx: Context): void { ) } ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec) - const downscaled = source.width !== ref.width || source.height !== ref.height const value: ImageReadValue = { path: target.displayPath, image: { @@ -260,7 +264,9 @@ export function applyReadImageTool(ctx: Context): void { width: ref.width, height: ref.height, ...ref.name === undefined ? {} : { name: ref.name }, - ...downscaled ? { sourceWidth: source.width, sourceHeight: source.height } : {}, + ...ref.originalDimensions === undefined ? {} : { + originalDimensions: { ...ref.originalDimensions }, + }, }, } return value diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 16e07d93a8..6b33b3dcb3 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -21,7 +21,7 @@ import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import { AttachmentError, AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, SavedImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { applyReadImageTool, @@ -170,8 +170,8 @@ describe('imageRefFromValue', () => { const base = { attachmentId: 'sha256:00', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 } expect(imageRefFromValue(base)).toEqual(base) expect(imageRefFromValue({ ...base, name: 'a.png' })).toEqual({ ...base, name: 'a.png' }) - expect(imageRefFromValue({ ...base, sourceWidth: 4, sourceHeight: 2 })) - .toEqual({ ...base, sourceWidth: 4, sourceHeight: 2 }) + expect(imageRefFromValue({ ...base, originalDimensions: { width: 4, height: 2 } })) + .toEqual({ ...base, originalDimensions: { width: 4, height: 2 } }) }) }) @@ -347,7 +347,7 @@ describe('argument and service preconditions', () => { throw new Error('unreachable: admission refuses before validation') } - saveImage(_input: SaveImageAttachment): Promise { + saveImage(_input: SaveImageAttachment): Promise { throw new Error('unreachable: admission refuses before save') } @@ -424,7 +424,7 @@ describe('image admission failures', () => { return Promise.resolve() } - async saveImage(_input: SaveImageAttachment): Promise { + async saveImage(_input: SaveImageAttachment): Promise { throw FailingStore.failure } @@ -442,15 +442,15 @@ describe('image admission failures', () => { expect(text(storageFault)).toContain('Unable to persist image attachment.') FailingStore.failure = new AttachmentError( - 'The 16-bit PNG could not be converted to the canonical 8-bit sRGB form.', + 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', 'ATTACHMENT_WRITE_FAILED', ) const sixteenBit = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(text(sixteenBit)).toContain( - `cannot read "${join(dir, 'red.png')}": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, + `cannot read "${join(dir, 'red.png')}": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, ) - FailingStore.failure = new AttachmentError('Image cannot be encoded within the configured canonical byte target.', 'IMAGE_TOO_LARGE') + FailingStore.failure = new AttachmentError('Image cannot be encoded within the configured normalized-image byte cap.', 'IMAGE_TOO_LARGE') const overBudget = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(overBudget.isError).toBe(true) expect(text(overBudget)).toContain('cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy') @@ -492,11 +492,8 @@ describe('image admission failures', () => { return Promise.resolve() } - async saveImage(input: SaveImageAttachment): Promise { - return { - ref: { attachmentId: AttachmentId('sha256:feed'), mediaType: input.mediaType, bytes: input.data.length, width: 1, height: 1 }, - source: { mediaType: input.mediaType, bytes: input.data.length, width: 1, height: 1 }, - } + async saveImage(input: SaveImageAttachment): Promise { + return { attachmentId: AttachmentId('sha256:feed'), mediaType: input.mediaType, bytes: input.data.length, width: 1, height: 1 } } readImage(_ref: ImageAttachmentRef): Promise { @@ -513,7 +510,7 @@ describe('image admission failures', () => { }) it('names the on-disk dimensions and coordinate multiplier when storage downscales', async () => { - /** Store whose image master halves the source on both sides. */ + /** Store whose normalized image halves the input on both sides. */ class DownscalingStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = Object.freeze({ maxImageBytes: 1024, @@ -528,10 +525,14 @@ describe('image admission failures', () => { return Promise.resolve() } - async saveImage(input: SaveImageAttachment): Promise { + async saveImage(input: SaveImageAttachment): Promise { return { - ref: { attachmentId: AttachmentId('sha256:feed'), mediaType: input.mediaType, bytes: 7, width: 2, height: 1 }, - source: { mediaType: input.mediaType, bytes: input.data.length, width: 4, height: 2 }, + attachmentId: AttachmentId('sha256:feed'), + mediaType: input.mediaType, + bytes: 7, + width: 2, + height: 1, + originalDimensions: { width: 4, height: 2 }, } } @@ -549,7 +550,8 @@ describe('image admission failures', () => { it('names per-axis multipliers when integer rounding makes the ratios differ', () => { const envelope = formatImageReadOutput('/img/photo.jpg', { - attachmentId: 'sha256:feed', mediaType: 'image/jpeg', bytes: 9, width: 2, height: 1, sourceWidth: 5, sourceHeight: 2, + attachmentId: 'sha256:feed', mediaType: 'image/jpeg', bytes: 9, width: 2, height: 1, + originalDimensions: { width: 5, height: 2 }, }) expect(envelope).toContain('downscaled from 5x2 px; multiply x coordinates by 2.50 and y coordinates by 2.00 to locate features in the original file') }) diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index aa163784df..2127844646 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -243,11 +243,8 @@ describe('/goal image attachments', () => { const saveImage = (input: { mediaType: string; name?: string }) => { saved += 1 return Promise.resolve({ - ref: { - attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, - ...input.name === undefined ? {} : { name: input.name }, - }, - source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + ...input.name === undefined ? {} : { name: input.name }, }) } test.ctx.provide('attachments', { @@ -259,7 +256,7 @@ describe('/goal image attachments', () => { saveImage, async saveImages(inputs: readonly { mediaType: string; name?: string }[]) { const refs = [] - for (const input of inputs) refs.push((await saveImage(input)).ref) + for (const input of inputs) refs.push(await saveImage(input)) return refs }, }) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 99f99c3432..1317220ef3 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -134,15 +134,12 @@ describe('Web session model selection', () => { const { ctx, agent, sessionId } = await harness() const validateImage = vi.fn((_input: { data: Uint8Array }) => Promise.resolve()) const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({ - ref: { - attachmentId: `att-${String(input.data[0])}`, - mediaType: input.mediaType, - bytes: input.data.byteLength, - width: 1, - height: 1, - ...input.name === undefined ? {} : { name: input.name }, - }, - source: { mediaType: input.mediaType, bytes: input.data.byteLength, width: 1, height: 1 }, + attachmentId: `att-${String(input.data[0])}`, + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...input.name === undefined ? {} : { name: input.name }, })) const attachments = { imageLimits: { diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 85806a1d36..a95ee024dc 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -479,11 +479,8 @@ describe('image attachments', () => { saveImage: vi.fn((input: { mediaType: string; name?: string }) => { saved += 1 return Promise.resolve({ - ref: { - attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, - ...input.name === undefined ? {} : { name: input.name }, - }, - source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + ...input.name === undefined ? {} : { name: input.name }, }) }), validateImageBatch(inputs: readonly unknown[]) { @@ -595,8 +592,7 @@ describe('image attachments', () => { store.saveImage.mockImplementationOnce((input: { mediaType: string }) => { controller.abort('operator cancelled during admission') return Promise.resolve({ - ref: { attachmentId: 'att-late', mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, - source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, + attachmentId: 'att-late', mediaType: input.mediaType, bytes: 3, width: 1, height: 1, }) }) ctx.provide('attachments', store) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index bea18ff3ac..c4db847155 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: bb7f6a520701134cd43ff6223ef4efbf82d02eb4 -README.zh.md: 934c189232711655aa785a7497f5bb6dff1cbb46 +README.md: d17d520c2444d8a0195d997f4df4ff5e0f05befd +README.zh.md: cc823897894102df0dc1da17478eee6ba7ebd21d diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index bb7f6a5207..d17d520c24 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -49,11 +49,11 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 master becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. It never falls back to an inline data URL. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. It never falls back to an inline data URL. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. -`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained masters are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. +`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. -Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the master attachment id, transform version, route pixel and byte budgets, and encoder parameters, so Files API and inline-capable adapters refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports expired, deleted, missing, or invalid file ids and names one or more ids used by the request, the adapter removes exactly those mappings. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload; permission and filesystem I/O failures still fail the request. +Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the normalized attachment id, transform version, route pixel and byte budgets, and encoder parameters, so Files API and inline-capable adapters refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports expired, deleted, missing, or invalid file ids and names one or more ids used by the request, the adapter removes exactly those mappings. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload; permission and filesystem I/O failures still fail the request. Concurrent resolution of one scoped `variantId` shares one Files upload with waiter-local cancellation. One quota upload failure first paginates and collects the configured number of oldest `dsh-` files, then deletes that set before one upload retry. `DeepSeekFilesClient.delete`, `DeepSeekFileStore.release`, and `releaseAll` expose explicit remote-space reclamation. The current provider limits represented by this package are 128MiB per Files upload, 32MiB per chat-referenced image, 10,000 stored files, and 25GiB per API key; the default 1MiB request version remains below the two per-file limits. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 934c189232..cc82389789 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -49,11 +49,11 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 主版本会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块,不会回退到内联 data URL。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块,不会回退到内联 data URL。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 -`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的主版本。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 +`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 -上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖主附件 ID、变换策略版本、路由像素和字节预算及编码参数,因此 Files API 和支持内联的适配器引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换;权限和文件系统 I/O 错误仍使请求失败。 +上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及编码参数,因此 Files API 和支持内联的适配器引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换;权限和文件系统 I/O 错误仍使请求失败。 同一作用域和 `variantId` 的并发解析共享一次 Files 上传,每个等待方可以单独取消。一次上传配额错误会先分页收集配置数量的最旧 `dsh-` 文件,再删除这些文件并重试一次上传。`DeepSeekFilesClient.delete`、`DeepSeekFileStore.release` 和 `releaseAll` 提供主动远端空间回收。本包记录的当前提供方限制为 Files 单次上传 128MiB、chat 单图引用 32MiB、每个 API key 最多 10,000 个文件和 25GiB;默认 1MiB 请求版本低于两个单文件上限。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 30817c738e..9c4756f3d3 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -200,7 +200,9 @@ async function prepareRequestImages( for (const message of options.messages) collectImageRefs(message.content, refs) const policy = resolveRequestImagePolicy(model) const orderedRefs = [...refs.values()] - const projected = await attachments.readImageRequests(orderedRefs, policy, signal) + const projected = await Promise.all(orderedRefs.map( + ref => attachments.readImageRequest(ref, policy, signal), + )) return new Map(orderedRefs.map((ref, index) => ( [ref.attachmentId, projected[index] as RequestImageAttachment] ))) @@ -250,7 +252,7 @@ function normalizedImageFacts( file: { version: RequestImageAttachment; location: ImageWireLocation }, ): string { const version = file.version - const name = version.master.name ?? version.master.attachmentId + const name = version.attachment.name ?? version.attachment.attachmentId const colour = version.hasAlpha ? 'sRGBA' : 'sRGB' return `"${name}" at message ${file.location.message}, image ${file.location.image} ` + `(${version.mediaType}, 8-bit ${colour}, ${version.width}x${version.height})` diff --git a/packages/llm/llm-deepseek/src/file-store.ts b/packages/llm/llm-deepseek/src/file-store.ts index 0757b42db2..fde86fa44e 100644 --- a/packages/llm/llm-deepseek/src/file-store.ts +++ b/packages/llm/llm-deepseek/src/file-store.ts @@ -102,9 +102,9 @@ function extension(mediaType: RequestImageAttachment['mediaType']): 'png' | 'jpe } function filename(version: RequestImageAttachment): string { - const master = String(version.master.attachmentId).slice('sha256:'.length, 'sha256:'.length + 16) + const attachment = String(version.attachment.attachmentId).slice('sha256:'.length, 'sha256:'.length + 16) const variant = String(version.variantId).slice('sha256:'.length, 'sha256:'.length + 8) - return `${OWNED_FILE_PREFIX}${master}-${variant}.${extension(version.mediaType)}` + return `${OWNED_FILE_PREFIX}${attachment}-${variant}.${extension(version.mediaType)}` } /** User-scoped durable file-id reuse for the DeepSeek route. */ @@ -204,7 +204,7 @@ export class DeepSeekFileStore { } return { scope, - masterAttachmentId: version.master.attachmentId, + attachmentId: version.attachment.attachmentId, variantId: version.variantId, fileId: remote.id, bytes: remote.bytes, diff --git a/packages/llm/llm-deepseek/src/upload-index.ts b/packages/llm/llm-deepseek/src/upload-index.ts index 297e1021c1..d442bb55fe 100644 --- a/packages/llm/llm-deepseek/src/upload-index.ts +++ b/packages/llm/llm-deepseek/src/upload-index.ts @@ -13,8 +13,8 @@ import type { DeepSeekFileId as DeepSeekFileIdType, DeepSeekFileScope as DeepSee /** One durable remote upload mapping. Unix times are milliseconds. */ export interface DeepSeekUploadRecord { scope: DeepSeekFileScopeType - /** Provider-independent master attachment from which the uploaded request version was derived. */ - masterAttachmentId: AttachmentId + /** Provider-independent normalized attachment from which the uploaded request version was derived. */ + attachmentId: AttachmentId /** Complete request transformation identity, including route budgets and encoder parameters. */ variantId: ImageVariantIdType fileId: DeepSeekFileIdType @@ -24,7 +24,7 @@ export interface DeepSeekUploadRecord { } interface StoredIndex { - formatVersion: 2 + formatVersion: 3 records: DeepSeekUploadRecord[] } @@ -61,7 +61,7 @@ function parseRecord(value: unknown): DeepSeekUploadRecord { } const record = value as Record if (typeof record.scope !== 'string' || !/^[0-9a-f]{64}$/u.test(record.scope) - || typeof record.masterAttachmentId !== 'string' || !/^sha256:[0-9a-f]{64}$/u.test(record.masterAttachmentId) + || typeof record.attachmentId !== 'string' || !/^sha256:[0-9a-f]{64}$/u.test(record.attachmentId) || typeof record.variantId !== 'string' || !/^sha256:[0-9a-f]{64}$/u.test(record.variantId) || typeof record.fileId !== 'string' || record.fileId.length === 0 || !Number.isSafeInteger(record.bytes) || (record.bytes as number) < 0 @@ -71,7 +71,7 @@ function parseRecord(value: unknown): DeepSeekUploadRecord { } return { scope: DeepSeekFileScope(record.scope), - masterAttachmentId: record.masterAttachmentId as AttachmentId, + attachmentId: record.attachmentId as AttachmentId, variantId: ImageVariantId(record.variantId), fileId: DeepSeekFileId(record.fileId), bytes: record.bytes as number, @@ -91,7 +91,7 @@ function parseIndex(text: string): StoredIndex { throw new InvalidUploadIndexError('llm-deepseek: upload index is not an object') } const index = value as { formatVersion?: unknown; records?: unknown } - if (index.formatVersion !== 2 || !Array.isArray(index.records)) { + if (index.formatVersion !== 3 || !Array.isArray(index.records)) { throw new InvalidUploadIndexError('llm-deepseek: unsupported upload index format') } const records = index.records.map(parseRecord) @@ -101,7 +101,7 @@ function parseIndex(text: string): StoredIndex { if (keys.has(key)) throw new InvalidUploadIndexError('llm-deepseek: upload index contains duplicate mappings') keys.add(key) } - return { formatVersion: 2, records } + return { formatVersion: 3, records } } function reusable(record: DeepSeekUploadRecord, now: number, refreshMarginMs: number): boolean { @@ -114,9 +114,9 @@ export class DeepSeekUploadIndex { readonly path: string /** - * @param path - explicit test path; omission uses `DSH_HOME/llm-deepseek/files-v2.json`. + * @param path - explicit test path; omission uses `DSH_HOME/llm-deepseek/files-v3.json`. */ - constructor(path = join(resolveDshHome(), 'llm-deepseek', 'files-v2.json')) { + constructor(path = join(resolveDshHome(), 'llm-deepseek', 'files-v3.json')) { this.path = path } @@ -125,7 +125,7 @@ export class DeepSeekUploadIndex { return parseIndex(await readFile(this.path, 'utf8')) } catch (error: unknown) { if (absent(error) || error instanceof InvalidUploadIndexError) { - return { formatVersion: 2, records: [] } + return { formatVersion: 3, records: [] } } throw error } @@ -184,7 +184,7 @@ export class DeepSeekUploadIndex { && !(record.scope === candidate.scope && record.variantId === candidate.variantId) )) records.push(candidate) - await this.save({ formatVersion: 2, records }) + await this.save({ formatVersion: 3, records }) return { record: candidate, accepted: true } }) } @@ -206,7 +206,7 @@ export class DeepSeekUploadIndex { const records = index.records.filter(record => !( record.scope === scope && record.variantId === variantId && record.fileId === fileId )) - if (records.length !== index.records.length) await this.save({ formatVersion: 2, records }) + if (records.length !== index.records.length) await this.save({ formatVersion: 3, records }) }) } @@ -219,7 +219,7 @@ export class DeepSeekUploadIndex { await withFileLock(this.path, async () => { const index = await this.load() const records = index.records.filter(record => record.scope !== scope) - if (records.length !== index.records.length) await this.save({ formatVersion: 2, records }) + if (records.length !== index.records.length) await this.save({ formatVersion: 3, records }) }) } } diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 3858f214a0..ee3bea8435 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -13,7 +13,6 @@ import type { ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, - SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -58,7 +57,7 @@ class E2eAttachmentStore extends AttachmentStore { } readonly version: RequestImageAttachment = { variantId: ImageVariantId(`sha256:${randomBytes(32).toString('hex')}`), - master: this.ref, + attachment: this.ref, data: TEST_PNG, mediaType: 'image/png', bytes: TEST_PNG.byteLength, @@ -73,16 +72,8 @@ class E2eAttachmentStore extends AttachmentStore { return Promise.resolve() } - saveImage(_input: SaveImageAttachment): Promise { - return Promise.resolve({ - ref: this.ref, - source: { - mediaType: this.ref.mediaType, - bytes: this.ref.bytes, - width: this.ref.width, - height: this.ref.height, - }, - }) + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve(this.ref) } readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 08b3706758..baac1ee39e 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -77,7 +77,7 @@ const imageRef: ImageAttachmentRef = { function requestImage(ref = imageRef): RequestImageAttachment { return { variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - master: ref, + attachment: ref, data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', bytes: 3, @@ -94,18 +94,11 @@ function attachmentStoreOf( ): { store: AttachmentStore readImageRequest: ReturnType> - readImageRequests: ReturnType } { const readImageRequest = vi.fn(project) - const readImageRequests = vi.fn(async ( - refs: readonly ImageAttachmentRef[], - policy: unknown, - signal?: AbortSignal, - ) => Promise.all(refs.map(ref => readImageRequest(ref, policy, signal)))) return { - store: { readImageRequest, readImageRequests } as unknown as AttachmentStore, + store: { readImageRequest } as unknown as AttachmentStore, readImageRequest, - readImageRequests, } } @@ -233,8 +226,8 @@ describe('DeepSeekAdapter against a mock server', () => { })], })) - expect(attachmentMocks.readImageRequests).toHaveBeenCalledWith( - [recent], + expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith( + recent, { maxPixels: 640_000, maxBytes: 1024 * 1024 }, expect.any(AbortSignal), ) @@ -283,15 +276,15 @@ describe('DeepSeekAdapter against a mock server', () => { await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-low', messages: [nested] })) await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-custom', messages: [nested] })) - expect(attachmentMocks.readImageRequests).toHaveBeenNthCalledWith( + expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( 1, - [imageRef], + imageRef, { maxPixels: 512 * 512, maxBytes: 512_000 }, expect.any(AbortSignal), ) - expect(attachmentMocks.readImageRequests).toHaveBeenNthCalledWith( + expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( 2, - [imageRef], + imageRef, { maxPixels: 320_000, maxBytes: 1024 * 1024 }, expect.any(AbortSignal), ) @@ -395,7 +388,7 @@ describe('DeepSeekAdapter against a mock server', () => { return Promise.resolve({ ...requestImage(ref), variantId: ImageVariantId(`sha256:${(first ? 'b' : 'd').repeat(64)}`), - master: first ? { ...ref, name: 'diagram.png' } : ref, + attachment: first ? { ...ref, name: 'diagram.png' } : ref, hasAlpha: false, }) }).store diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index c0a2e29750..4617ebdfed 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -10,7 +10,6 @@ import type { ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, - SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -46,11 +45,8 @@ class StaticAttachmentStore extends AttachmentStore { return Promise.resolve() } - saveImage(_input: SaveImageAttachment): Promise { - return Promise.resolve({ - ref: IMAGE_REF, - source: { mediaType: IMAGE_REF.mediaType, bytes: IMAGE_REF.bytes, width: IMAGE_REF.width, height: IMAGE_REF.height }, - }) + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve(IMAGE_REF) } readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { @@ -64,7 +60,7 @@ class StaticAttachmentStore extends AttachmentStore { ): Promise { return Promise.resolve({ variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - master: ref, + attachment: ref, data: Uint8Array.of(1, 2, 3), mediaType: ref.mediaType, bytes: 3, diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts index 069ff47c9d..d6d154033b 100644 --- a/packages/llm/llm-deepseek/tests/file-store.spec.ts +++ b/packages/llm/llm-deepseek/tests/file-store.spec.ts @@ -17,7 +17,7 @@ const REF: ImageAttachmentRef = { } const VERSION: RequestImageAttachment = { variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - master: REF, + attachment: REF, data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', bytes: 3, @@ -328,7 +328,7 @@ describe('DeepSeekFileStore', () => { accepted: false, record: { scope: deepSeekFileScope(CONNECTION.baseURL, CONNECTION.apiKey), - masterAttachmentId: VERSION.master.attachmentId, + attachmentId: VERSION.attachment.attachmentId, variantId: VERSION.variantId, fileId: DeepSeekFileId('file-api-winner'), bytes: 3, diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 1b14a0c320..547713a74c 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -39,7 +39,7 @@ function requestVersion(ref: ImageAttachmentRef): RequestImageAttachment { const hash = String(ref.attachmentId).slice('sha256:'.length) return { variantId: ImageVariantId(`sha256:${hash}`), - master: ref, + attachment: ref, data: new Uint8Array(ref.bytes), mediaType: ref.mediaType, bytes: ref.bytes, @@ -545,7 +545,7 @@ describe('image serialization', () => { ], }) expect(resolveFileId).toHaveBeenCalledTimes(1) - expect(resolveFileId.mock.calls[0]?.[0]).toMatchObject({ master: { mediaType: 'image/jpeg' } }) + expect(resolveFileId.mock.calls[0]?.[0]).toMatchObject({ attachment: { mediaType: 'image/jpeg' } }) }) it('rejects an unprepared image while computing exact request bytes', async () => { diff --git a/packages/llm/llm-deepseek/tests/upload-index.spec.ts b/packages/llm/llm-deepseek/tests/upload-index.spec.ts index 480772f5fb..2cad8a22be 100644 --- a/packages/llm/llm-deepseek/tests/upload-index.spec.ts +++ b/packages/llm/llm-deepseek/tests/upload-index.spec.ts @@ -22,7 +22,7 @@ describe('DeepSeekUploadIndex', () => { const second = deepSeekFileScope('https://api.deepseek.com', 'second-key') const record = { scope: first, - masterAttachmentId: ATTACHMENT, + attachmentId: ATTACHMENT, variantId: VARIANT, fileId: DeepSeekFileId('file-api-one'), bytes: 3, @@ -41,7 +41,7 @@ describe('DeepSeekUploadIndex', () => { const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const scope = deepSeekFileScope('https://api.deepseek.com', 'key') const first = { - scope, masterAttachmentId: ATTACHMENT, variantId: VARIANT, + scope, attachmentId: ATTACHMENT, variantId: VARIANT, fileId: DeepSeekFileId('file-api-first'), bytes: 3, createdAt: 1, expiresAt: 10_000, } const duplicate = { ...first, fileId: DeepSeekFileId('file-api-duplicate') } @@ -62,7 +62,7 @@ describe('DeepSeekUploadIndex', () => { const scope = deepSeekFileScope('https://api.deepseek.com', 'key') const record = { scope, - masterAttachmentId: ATTACHMENT, + attachmentId: ATTACHMENT, variantId: VARIANT, fileId: DeepSeekFileId('file-api-repaired'), bytes: 3, @@ -73,7 +73,7 @@ describe('DeepSeekUploadIndex', () => { await expect(index.get(scope, VARIANT, 1, 1)).resolves.toBeUndefined() await expect(index.commit(record, 1, 1)).resolves.toEqual({ record, accepted: true }) await expect(index.get(scope, VARIANT, 1, 1)).resolves.toEqual(record) - expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject({ formatVersion: 2 }) + expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject({ formatVersion: 3 }) }) it.each([ @@ -81,48 +81,49 @@ describe('DeepSeekUploadIndex', () => { '[]', '{}', '{"formatVersion":1,"records":[]}', - '{"formatVersion":2,"records":null}', - '{"formatVersion":2,"records":[null]}', - '{"formatVersion":2,"records":[[]]}', - '{"formatVersion":2,"records":[{}]}', - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'x'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + '{"formatVersion":2,"records":[]}', + '{"formatVersion":3,"records":null}', + '{"formatVersion":3,"records":[null]}', + '{"formatVersion":3,"records":[[]]}', + '{"formatVersion":3,"records":[{}]}', + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'x'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: 'wrong', variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: 'wrong', variantId: VARIANT, fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: 'wrong', + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: 'wrong', fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: '', bytes: 3, createdAt: 1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: -1, createdAt: 1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: 1.5, createdAt: 1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: 3, createdAt: -1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: 3, createdAt: 1.5, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: -1, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: 1.5, })}]}`, ])('treats an invalid persisted index as empty %#', async (text) => { @@ -140,10 +141,10 @@ describe('DeepSeekUploadIndex', () => { const path = join(dir, 'index.json') const scope = deepSeekFileScope('https://api.deepseek.com', 'key') const record = { - scope, masterAttachmentId: ATTACHMENT, variantId: VARIANT, + scope, attachmentId: ATTACHMENT, variantId: VARIANT, fileId: DeepSeekFileId('file-api-one'), bytes: 3, createdAt: 1, expiresAt: 10_000, } - await writeFile(path, JSON.stringify({ formatVersion: 2, records: [record, record] }), 'utf8') + await writeFile(path, JSON.stringify({ formatVersion: 3, records: [record, record] }), 'utf8') const index = new DeepSeekUploadIndex(path) await expect(index.get(scope, VARIANT, 1, 1)).resolves.toBeUndefined() }) @@ -154,7 +155,7 @@ describe('DeepSeekUploadIndex', () => { const first = deepSeekFileScope('https://api.deepseek.com', 'first') const second = deepSeekFileScope('https://api.deepseek.com', 'second') const expired = { - scope: first, masterAttachmentId: ATTACHMENT, variantId: VARIANT, + scope: first, attachmentId: ATTACHMENT, variantId: VARIANT, fileId: DeepSeekFileId('file-api-expired'), bytes: 3, createdAt: 1, expiresAt: 2, } const live = { diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 038224198d..42364c1d5c 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 8f4d1537d8ccec3e89c0553f877541d11b285f66 -README.zh.md: 354851018de0ea79b82215c3d970266cd2be5763 +README.md: 43472de90803481deebb9bc91586a4b85e443db5 +README.zh.md: 76b3dc9dec2a4bf83933319aa065954a191e595b diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 8f4d1537d8..43472de908 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -123,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent master under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading masters, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with fixed text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual request-image dimensions. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with fixed text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual request-image dimensions. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -173,7 +173,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id and actual request dimensions. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Offloaded masters are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id and actual request dimensions. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 354851018d..76b3dc9dec 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -124,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的主版本派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取主版本前,`maxRequestImageBytes` 先按请求版本的保守上界替换超预算的最旧图片;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际请求图片尺寸。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界替换超预算的最旧图片;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际请求图片尺寸。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -174,7 +174,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。系统不会读取或转换被 offload 的主版本。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 #### Token 影响 diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 5473d931de..5fdcc2cdb2 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -52,7 +52,7 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 * Deployments behind stricter gateways lower it per route. */ export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 -/** Default total-pixel budget preserves the complete 2048px local master. */ +/** Default total-pixel budget preserves the complete 2048px normalized attachment. */ export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048 /** Default raw encoded-byte cap before inline base64 expansion. */ export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 5d2df24d18..9faf457c9a 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -56,10 +56,7 @@ async function userContent( if (block.text.length > 0) content.push({ type: 'text', text: block.text }) break case 'image': { - const version = requestImages.get(block.attachment.attachmentId) - if (version === undefined) { - throw new LlmError(`pi-ai request image ${block.attachment.attachmentId} was not prepared`, 'INVALID_REQUEST') - } + const version = requestImages.get(block.attachment.attachmentId) as RequestImageAttachment content.push({ type: 'text', text: requestImageHandleText(version) }) content.push({ type: 'image', @@ -106,7 +103,9 @@ async function prepareRequestImages( const refs = new Map() for (const message of messages) collectImageRefs(message.content, refs) const orderedRefs = [...refs.values()] - const prepared = await attachments.readImageRequests(orderedRefs, policy, signal) + const prepared = await Promise.all(orderedRefs.map( + ref => attachments.readImageRequest(ref, policy, signal), + )) const versions = new Map() for (const [index, ref] of orderedRefs.entries()) { versions.set(ref.attachmentId, prepared[index] as RequestImageAttachment) @@ -238,7 +237,7 @@ async function toPiContextWithImages( representation: 'base64', ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, - byteLength: ref => requestImages.get(ref.attachmentId)?.bytes ?? ref.bytes, + byteLength: ref => (requestImages.get(ref.attachmentId) as RequestImageAttachment).bytes, }) const toolNames = new Map() const messages: PiMessage[] = [] diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 09befeaa4b..e2ed0233d9 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -6,7 +6,6 @@ import type { ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, - SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -246,7 +245,7 @@ describe('PiAiAdapter provider routing', () => { ): Promise => ( Promise.resolve({ variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - master: value, + attachment: value, data: Uint8Array.of(1), mediaType: value.mediaType, bytes: 1, @@ -272,7 +271,7 @@ describe('PiAiAdapter provider routing', () => { return Promise.reject(new Error('not used')) } - saveImage(_input: SaveImageAttachment): Promise { + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('not used')) } diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index da1dcaf28b..026ca2c608 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -22,7 +22,7 @@ const ref: ImageAttachmentRef = { function requestImage(value: ImageAttachmentRef, data: Uint8Array): RequestImageAttachment { return { variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - master: value, + attachment: value, data, mediaType: value.mediaType, bytes: data.byteLength, @@ -43,14 +43,7 @@ function projectionStore( Promise.resolve(requestImage(value, Uint8Array.of(1))) )), ): AttachmentStore { - return { - readImageRequest, - readImageRequests: ( - refs: readonly ImageAttachmentRef[], - policy: Parameters[1], - signal?: AbortSignal, - ) => Promise.all(refs.map(value => readImageRequest(value, policy, signal))), - } as unknown as AttachmentStore + return { readImageRequest } as unknown as AttachmentStore } const attachments = projectionStore() @@ -418,13 +411,4 @@ describe('pi-ai request context conversion', () => { )).toThrow(/assistant image output/) }) - it('rejects an attachment service that omits a requested image version', async () => { - const store = { - readImageRequests: vi.fn(() => Promise.resolve([])), - } as unknown as AttachmentStore - await expect(toPiContext( - request([user([{ type: 'image', attachment: ref }])]), - store, - )).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) - }) }) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index ccfb321d3b..c540f2d50b 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -46,7 +46,7 @@ async function collect(stream: AsyncIterable): Promise Promise): AttachmentStore { - return { - readImageRequest, - readImageRequests: ( - refs: readonly ImageAttachmentRef[], - policy: ImageRequestPolicy, - signal?: AbortSignal, - ) => Promise.all( - refs.map(ref => readImageRequest(ref, policy, signal)), - ), - } as unknown as AttachmentStore + return { readImageRequest } as unknown as AttachmentStore } describe('toPiContext', () => { diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 93732e75d3..f651aa1f9a 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -7,7 +7,6 @@ import type { ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, - SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -81,7 +80,7 @@ async function harness(image?: StoredImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } - saveImage(_input: SaveImageAttachment): Promise { + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } @@ -98,7 +97,7 @@ async function harness(image?: StoredImageAttachment): Promise { } return Promise.resolve({ variantId: ImageVariantId(`sha256:${'f'.repeat(64)}`), - master: fixture.ref, + attachment: fixture.ref, data: fixture.data, mediaType: fixture.ref.mediaType, bytes: fixture.data.byteLength, diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index c30a62dccb..4620275429 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -24,7 +24,7 @@ export function textOnlyImageText(ref: ImageAttachmentRef): string { * @returns attachment handle and request-image dimensions. */ export function requestImageHandleText(version: RequestImageAttachment): string { - return `Image ${version.master.attachmentId}; request image ${version.width}x${version.height}px.` + return `Image ${version.attachment.attachmentId}; request image ${version.width}x${version.height}px.` } /** diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 9f4854e2d8..b72b8faad4 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -3,7 +3,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { Context } from '@deepseek-ai/cordis' import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SavedImageAttachment, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { CallId, LlmAdapter, LlmRuntime } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -86,7 +86,7 @@ class RecordingAttachmentStore extends AttachmentStore { return Promise.resolve() } - saveImage(input: SaveImageAttachment): Promise { + saveImage(input: SaveImageAttachment): Promise { this.saved.push(input) const marker = input.data[0] ?? 0 const ref: ImageAttachmentRef = { @@ -96,10 +96,7 @@ class RecordingAttachmentStore extends AttachmentStore { width: 1, height: 1, } - return Promise.resolve({ - ref, - source: { mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height }, - }) + return Promise.resolve(ref) } readImage(_ref: ImageAttachmentRef): Promise { diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index d1b4be3058..8285147953 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -653,8 +653,7 @@ describe('/plan', () => { const saveImage = (input: { mediaType: string }) => { saved += 1 return Promise.resolve({ - ref: { attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, - source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, }) } ctx.provide('attachments', { @@ -666,7 +665,7 @@ describe('/plan', () => { saveImage, async saveImages(inputs: readonly { mediaType: string }[]) { const refs = [] - for (const input of inputs) refs.push((await saveImage(input)).ref) + for (const input of inputs) refs.push(await saveImage(input)) return refs }, }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a5ed9feff9..522e98df96 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -296,8 +296,6 @@ export const LINK_MAP: Readonly> = { ImageRequestPolicy: 'attachment.md', RequestImageAttachment: 'attachment.md', SaveImageAttachment: 'attachment.md', - SavedImageAttachment: 'attachment.md', - SourceImageInfo: 'attachment.md', StoredImageAttachment: 'attachment.md', ShellExecRequest: 'shell.md', ShellExecSpec: 'shell.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 805316352b..874f564483 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -25,7 +25,7 @@ import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SavedImageAttachment, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import UserQuestionService from '@deepseek-ai/dsh-user-questions' import PlanModeController from '@deepseek-ai/dsh-plan-mode' import WebRuntime from '@deepseek-ai/dsh-web' @@ -83,7 +83,7 @@ class CatalogAttachmentStore extends AttachmentStore { return Promise.reject(new Error('gen-tool-catalog: attachment validation is unreachable during schema harvest')) } - override saveImage(_input: SaveImageAttachment): Promise { + override saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('gen-tool-catalog: attachment writes are unreachable during schema harvest')) } diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 4f57edc2f8..a3b96a90a3 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -12,7 +12,6 @@ import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, - SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -126,7 +125,7 @@ class TestAttachmentStore extends AttachmentStore { return Promise.reject(new Error('test invariant attachment store does not validate images')) } - saveImage(_input: SaveImageAttachment): Promise { + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('test invariant attachment store does not save images')) }