فهرست منبع

fix(images): parse listed missing Files ids

creatixchu 1 ماه پیش
والد
کامیت
c09a42ccb5

+ 2 - 2
.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: c487f583e4770b8d495404f08de67fd877dc48fd
-2026-08-20-unified-image-request-pipeline.zh.md: a82312d55ba59403e71e97ee483e2b5bbfebfb03
+2026-08-20-unified-image-request-pipeline.md: 72382b6130086ba5c36d386ffe7ebe413cd2243d
+2026-08-20-unified-image-request-pipeline.zh.md: 15560ad475af669cc4a2d9c46354a4da08528e0b

+ 2 - 2
.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md

@@ -36,7 +36,7 @@ Every retained request image is preceded by its complete attachment id, actual r
 
 The direct `deepseek-official` adapter uploads every retained request version through the OpenAI-compatible Files API and sends only `file_id` content blocks. There is no inline fallback. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by endpoint and API-key scope plus `variantId`. Uploads request seven days by default and record the returned `expires_at`; a mapping with no more than one hour remaining is replaced without a preceding retrieve call. The index never stores the API key.
 
-An upload is indexed only after the response returns a complete file object, matching byte count, and `expires_at`. A missing or inconsistent response leaves no local mapping, so a later request uploads again. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If chat reports an expired, deleted, missing, or invalid id and names one used id, only that mapping is removed. A stale-file response without a specific id removes every mapping used by that chat attempt. The affected request bytes are uploaded again and chat is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third chat attempt. One upload quota error deletes the configured number of oldest harness-owned `dsh-` files and retries once. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. The client enforces the documented 128MiB upload limit, 32MiB chat-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range.
+An upload is indexed only after the response returns a complete file object, matching byte count, and `expires_at`. A missing or inconsistent response leaves no local mapping, so a later request uploads again. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If chat reports expired, deleted, missing, or invalid ids and names one or more ids used by the request, only those mappings are removed. A stale-file response without a specific id removes every mapping used by that chat attempt. The affected request bytes are uploaded again and chat is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third chat attempt. One upload quota error deletes the configured number of oldest harness-owned `dsh-` files and retries once. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. The client enforces the documented 128MiB upload limit, 32MiB chat-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range.
 
 ### Diagnostics
 
@@ -64,7 +64,7 @@ Historical attachment objects that later disappear or fail integrity verificatio
 
 ## Verification
 
-Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants, bound transform concurrency, preserve cache and upload identity, map preview crops to the master, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from exact and ambiguous stale-id responses, delete quota files, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry.
+Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants, bound transform concurrency, preserve cache and upload identity, map preview crops to the master, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, delete quota files, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry.
 
 ## Consequences
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md

@@ -36,7 +36,7 @@ Status: implemented
 
 直接 `deepseek-official` 适配器通过 OpenAI 兼容 Files API 上传每张保留的请求版本,只发送 `file_id` 内容块,不提供内联回退。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期,并记录返回的 `expires_at`;本地映射剩余时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。
 
-只有上传响应返回完整文件对象、匹配的字节数和 `expires_at` 时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果 chat 报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的某个 ID,适配器只删除该映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次 chat 使用的全部映射。受影响的请求字节会重新上传,chat 只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次 chat。一次上传配额错误会删除配置数量的最旧 `dsh-` 文件,然后重试一次。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。客户端执行文档规定的 Files 单次上传 128MiB、chat 单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。
+只有上传响应返回完整文件对象、匹配的字节数和 `expires_at` 时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果 chat 报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次 chat 使用的全部映射。受影响的请求字节会重新上传,chat 只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次 chat。一次上传配额错误会删除配置数量的最旧 `dsh-` 文件,然后重试一次。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。客户端执行文档规定的 Files 单次上传 128MiB、chat 单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。
 
 ### 诊断
 
@@ -64,7 +64,7 @@ Status: implemented
 
 ## Verification
 
-包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体 singleflight、变换并发上限、缓存与上传身份、预览到主版本坐标映射、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、精确和模糊失效响应只恢复一次、配额删除、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。
+包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体 singleflight、变换并发上限、缓存与上传身份、预览到主版本坐标映射、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、配额删除、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。
 
 ## Consequences
 

+ 2 - 2
packages/llm/llm-deepseek/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
-README.md: da2044abe6f5201c1bed1ca6b529b34c34282ea8
-README.zh.md: d17d7a739640c31e9e88f154a11d5e24011e54f7
+README.md: ea82956d77f3157638aa078c56630994ccc61d75
+README.zh.md: ff8780f59a3caac7e08e5ab6e08c4b2b15d1b57d

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

@@ -52,7 +52,7 @@ An image-capable catalog entry declares `inputModalities: [text, image]` and may
 
 `maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. 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, crop, 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 an expired, deleted, missing, or invalid file id and names a used id, the adapter removes only that mapping. 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 master attachment id, transform version, route pixel and byte budgets, crop, 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.
 
 One quota upload failure triggers deletion of the configured number of oldest `dsh-` files and 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.
 

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

@@ -52,7 +52,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
 
 `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 错误仍使请求失败。
 
 一次上传配额错误会触发删除配置数量的最旧 `dsh-` 文件,然后重试一次上传。`DeepSeekFilesClient.delete`、`DeepSeekFileStore.release` 和 `releaseAll` 提供主动远端空间回收。本包记录的当前提供方限制为 Files 单次上传 128MiB、chat 单图引用 32MiB、每个 API key 最多 10,000 个文件和 25GiB;默认 1MiB 请求版本低于两个单文件上限。
 

+ 17 - 8
packages/llm/llm-deepseek/src/adapter.ts

@@ -171,13 +171,22 @@ function collectImageRefs(
   }
 }
 
-function requestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy {
+/**
+ * Resolve the request-image budgets owned by one DeepSeek model route.
+ * @param model - Advertised model route and its optional image overrides.
+ * @returns Complete pixel and encoded-byte budgets.
+ * @internal
+ */
+export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy {
+  let maxPixels: number
+  if (model.imagePixelBudget !== undefined) maxPixels = model.imagePixelBudget
+  else if (model.imageDetail === 'low') maxPixels = DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET
+  else maxPixels = DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET
   return {
-    maxPixels: model.imagePixelBudget
-      ?? (model.imageDetail === 'low'
-        ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET
-        : DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET),
-    maxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES,
+    maxPixels,
+    maxBytes: model.imageMaxBytes === undefined
+      ? DEFAULT_REQUEST_IMAGE_MAX_BYTES
+      : model.imageMaxBytes,
   }
 }
 
@@ -189,7 +198,7 @@ async function prepareRequestImages(
 ): Promise<Map<AttachmentId, RequestImageAttachment>> {
   const refs = new Map<AttachmentId, ImageAttachmentRef>()
   for (const message of options.messages) collectImageRefs(message.content, refs)
-  const policy = requestImagePolicy(model)
+  const policy = resolveRequestImagePolicy(model)
   const orderedRefs = [...refs.values()]
   const projected = await attachments.readImageRequests(orderedRefs, policy, signal)
   return new Map(orderedRefs.map((ref, index) => (
@@ -211,7 +220,7 @@ interface UsedRequestFile {
 
 function providerRejectedFileId(detail: string): boolean {
   const file = /\bfile(?:[_ -]?(?:id|api|not[_ -]?found|deleted|expired))?/iu.test(detail)
-  const missing = /(?:expired|not[_ -]?found|deleted|does not exist)/iu.test(detail)
+  const missing = /(?:expired|not[_ -]?found|deleted|do(?:es)? not exist|not created under (?:this|your) account)/iu.test(detail)
   const invalidId = /(?:invalid.{0,20}file[_ -]?(?:id|api)|file[_ -]?(?:id|api).{0,20}invalid)/iu.test(detail)
   return file && (missing || invalidId)
 }

+ 211 - 4
packages/llm/llm-deepseek/tests/adapter.spec.ts

@@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis'
 import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
 import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
 import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
-import LlmRuntime, { createUserMessage,
+import LlmRuntime, { CallId, createUserMessage,
   CONTEXT_WINDOW_EXCEEDED_CODE,
   ProviderRequestId,
   QUOTA_EXCEEDED_CODE,
@@ -18,7 +18,7 @@ import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/d
 import { SessionId } from '@deepseek-ai/dsh-session'
 import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
 import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
-import { httpErrorCode } from '../src/adapter.ts'
+import { httpErrorCode, resolveRequestImagePolicy } from '../src/adapter.ts'
 import { assemble } from './assemble.ts'
 import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
 import type { Behavior } from './mock-server.ts'
@@ -109,6 +109,25 @@ function attachmentStoreOf(
   }
 }
 
+describe('request image policy', () => {
+  it.each([
+    [
+      { id: 'default' },
+      { maxPixels: 640_000, maxBytes: 1024 * 1024 },
+    ],
+    [
+      { id: 'low', imageDetail: 'low' as const },
+      { maxPixels: 512 * 512, maxBytes: 1024 * 1024 },
+    ],
+    [
+      { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 },
+      { maxPixels: 320_000, maxBytes: 512_000 },
+    ],
+  ])('resolves route-owned defaults and overrides for %s', (model, expected) => {
+    expect(resolveRequestImagePolicy(model)).toEqual(expected)
+  })
+})
+
 describe('DeepSeekAdapter against a mock server', () => {
   it('streams a text generation end to end through the assembler', async () => {
     const server = await mockServer([{ kind: 'sse', events: textEvents }])
@@ -187,6 +206,54 @@ describe('DeepSeekAdapter against a mock server', () => {
     expect(policies).toEqual([{ maxPixels: 640_000, maxBytes: 1024 * 1024 }])
   })
 
+  it('projects nested tool-result images with route-owned request budgets', async () => {
+    const server = await mockServer([
+      { kind: 'sse', events: textEvents },
+      { kind: 'sse', events: textEvents },
+    ])
+    const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref)))
+    const adapter = adapterOf({
+      baseURL: server.url,
+      models: [
+        {
+          id: 'vision-low',
+          inputModalities: ['text', 'image'],
+          imageDetail: 'low',
+          imageMaxBytes: 512_000,
+        },
+        {
+          id: 'vision-custom',
+          inputModalities: ['text', 'image'],
+          imagePixelBudget: 320_000,
+        },
+      ],
+    }, attachmentMocks.store)
+    const nested = createUserMessage({
+      content: [{
+        type: 'tool-result',
+        toolCallId: CallId('image-result'),
+        content: [{ type: 'image', attachment: imageRef }],
+      }],
+      source: { kind: 'plugin', plugin: 'test' },
+    })
+
+    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(
+      1,
+      [imageRef],
+      { maxPixels: 512 * 512, maxBytes: 512_000 },
+      expect.any(AbortSignal),
+    )
+    expect(attachmentMocks.readImageRequests).toHaveBeenNthCalledWith(
+      2,
+      [imageRef],
+      { maxPixels: 320_000, maxBytes: 1024 * 1024 },
+      expect.any(AbortSignal),
+    )
+  })
+
   it('reuses the exact request version between agent and compaction calls', async () => {
     const server = await mockServer([
       { kind: 'sse', events: textEvents },
@@ -219,7 +286,7 @@ describe('DeepSeekAdapter against a mock server', () => {
   })
 
   it('explains a provider rejection of a normalized image and retains the raw response as cause', async () => {
-    const raw = JSON.stringify({ error: { message: 'unsupported image payload' } })
+    const raw = JSON.stringify({ error: { message: 'unsupported image payload for file-api-1' } })
     const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }])
     const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store
     const adapter = adapterOf({
@@ -248,11 +315,72 @@ describe('DeepSeekAdapter against a mock server', () => {
       cause: { message: raw },
     })
     expect((failure as Error).message).toContain('image/png, 8-bit sRGBA, 1x1')
-    expect((failure as Error).message).toContain('unsupported image payload')
+    expect((failure as Error).message).toContain('unsupported image payload for file-api-1')
     expect((failure as Error).message).not.toBe(raw)
   })
 
+  it('identifies the sole image when a normalized rejection omits its file id', async () => {
+    const raw = JSON.stringify({ error: { message: 'unsupported image payload' } })
+    const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }])
+    const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store
+    const adapter = adapterOf({
+      baseURL: server.url,
+      models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }],
+    }, attachments)
+
+    await expect(drain(adapter.stream({
+      provider: 'deepseek-official',
+      model: 'deepseek-v4-flash-vision-exp',
+      messages: [createUserMessage({
+        content: [{ type: 'image', attachment: imageRef }],
+        source: { kind: 'plugin', plugin: 'test' },
+      })],
+    }))).rejects.toMatchObject({
+      message: expect.stringContaining(`normalized image "${imageRef.attachmentId}"`) as string,
+    })
+  })
+
+  it('lists every candidate when a normalized multi-image rejection names no file id', async () => {
+    const secondRef: ImageAttachmentRef = {
+      ...imageRef,
+      attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`),
+    }
+    const raw = JSON.stringify({ error: { message: 'unsupported image payload' } })
+    const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }])
+    const attachments = attachmentStoreOf((ref) => {
+      const first = ref.attachmentId === imageRef.attachmentId
+      return Promise.resolve({
+        ...requestImage(ref),
+        variantId: ImageVariantId(`sha256:${(first ? 'b' : 'd').repeat(64)}`),
+        master: first ? { ...ref, name: 'diagram.png' } : ref,
+        hasAlpha: false,
+      })
+    }).store
+    const adapter = adapterOf({
+      baseURL: server.url,
+      models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }],
+    }, attachments)
+
+    await expect(drain(adapter.stream({
+      provider: 'deepseek-official',
+      model: 'deepseek-v4-flash-vision-exp',
+      messages: [createUserMessage({
+        content: [
+          { type: 'image', attachment: imageRef },
+          { type: 'image', attachment: secondRef },
+        ],
+        source: { kind: 'plugin', plugin: 'test' },
+      })],
+    }))).rejects.toMatchObject({
+      code: 'INVALID_REQUEST',
+      message: expect.stringContaining('Candidate images: "diagram.png"') as string,
+      cause: { message: raw },
+    })
+  })
+
   it.each([
+    'file-api-1 expired',
+    'file_id file-api-10 invalid; file_id file-api-1 expired',
     'file_id file-api-1 expired',
     'file_not_found',
     'file_id file-api-1 deleted',
@@ -332,6 +460,71 @@ describe('DeepSeekAdapter against a mock server', () => {
       .toEqual([{ type: 'file', file_id: 'file-api-1' }, { type: 'file', file_id: 'file-api-3' }])
   })
 
+  it('invalidates every listed missing file id and preserves unlisted mappings', async () => {
+    const secondRef: ImageAttachmentRef = {
+      ...imageRef,
+      attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`),
+    }
+    const thirdRef: ImageAttachmentRef = {
+      ...imageRef,
+      attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`),
+    }
+    const server = await mockServer([
+      {
+        kind: 'http-error',
+        status: 400,
+        body: JSON.stringify({
+          error: {
+            message: 'path.to.object[index]: the following file_ids do not exist or are not created under your account: '
+              + 'file-api-1, file-api-3, file-api-unknown',
+          },
+        }),
+      },
+      { kind: 'sse', events: textEvents },
+    ])
+    const attachments = attachmentStoreOf((ref) => {
+      let digest = 'f'
+      if (ref.attachmentId === imageRef.attachmentId) digest = 'b'
+      else if (ref.attachmentId === secondRef.attachmentId) digest = 'd'
+      return Promise.resolve({
+        ...requestImage(ref),
+        variantId: ImageVariantId(`sha256:${digest.repeat(64)}`),
+      })
+    }).store
+    const adapter = adapterOf({
+      baseURL: server.url,
+      models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }],
+    }, attachments)
+
+    await drain(adapter.stream({
+      provider: 'deepseek-official',
+      model: 'deepseek-v4-flash-vision-exp',
+      messages: [createUserMessage({
+        content: [
+          { type: 'image', attachment: imageRef },
+          { type: 'image', attachment: secondRef },
+          { type: 'image', attachment: thirdRef },
+        ],
+        source: { kind: 'plugin', plugin: 'test' },
+      })],
+    }))
+
+    expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(5)
+    const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }>
+    expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'file'))
+      .toEqual([
+        { type: 'file', file_id: 'file-api-1' },
+        { type: 'file', file_id: 'file-api-2' },
+        { type: 'file', file_id: 'file-api-3' },
+      ])
+    expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file'))
+      .toEqual([
+        { type: 'file', file_id: 'file-api-4' },
+        { type: 'file', file_id: 'file-api-2' },
+        { type: 'file', file_id: 'file-api-5' },
+      ])
+  })
+
   it('invalidates every used mapping when a stale-file response does not identify one file id', async () => {
     const secondRef: ImageAttachmentRef = {
       ...imageRef,
@@ -644,6 +837,20 @@ describe('DeepSeekAdapter against a mock server', () => {
     })
   })
 
+  it('uses the HTTP status as the cause when an error response has no body', async () => {
+    const server = await mockServer([{ kind: 'http-error', status: 500, body: '' }])
+    const adapter = adapterOf({ baseURL: server.url })
+
+    await expect(drain(adapter.stream({
+      provider: 'deepseek-official',
+      model: 'deepseek-v4-flash',
+      messages: [],
+    }))).rejects.toMatchObject({
+      code: 'SERVER',
+      cause: { message: 'DeepSeek HTTP 500' },
+    })
+  })
+
   it('classifies an HTTP context-window failure with the canonical code', async () => {
     const server = await mockServer([{
       kind: 'http-error',