diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml index ed4af5577d..c4f148394e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md -2026-08-21-deepseek-files-inline-fallback.md: c58b3e2257b426f1b5df8a4d6952e890a2bd2982 -2026-08-21-deepseek-files-inline-fallback.zh.md: 34625c6250d52a73ccaac3e33adbd2ed099aab5b +2026-08-21-deepseek-files-inline-fallback.md: 7442089038e2cf47f37661c0f098054d03f67aef +2026-08-21-deepseek-files-inline-fallback.zh.md: 0534514f9bc52f7871f43c288466643cebc7d69c diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md index c58b3e2257..7442089038 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md +++ b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md @@ -10,7 +10,7 @@ The direct DeepSeek vision route uses provider file ids so repeated requests do ## Decision -Files remains the preferred transport. Each request-image file resolution has the configurable `filesApiTimeoutMs` deadline, one minute by default and always below `streamIdleTimeoutMs`. Successful resolutions refresh the outer idle watchdog. Caller cancellation and the outer stream deadline remain terminal outcomes. +Files remains the preferred transport. Each request-image file resolution has the configurable `filesApiTimeoutMs` deadline, one minute by default. The stream idle deadline defaults to five minutes, so the Files deadline normally leaves time for inline fallback. A deployment may configure the stream idle deadline to expire first. Successful resolutions refresh the outer idle watchdog. Caller cancellation and the outer stream deadline remain terminal outcomes. A file resolution failure discards the transient file parts assembled for that chat attempt and rebuilds the complete image request with base64 data URLs. Every retained image uses the already prepared deterministic `RequestImageAttachment`; the fallback performs no additional decode, resize, or encode, and a chat request never mixes file ids with inline images. Upload mappings committed before a later image fails remain available to later requests. The next request tries Files again, so recovery requires no process-wide outage state. @@ -30,7 +30,7 @@ Provider chat errors keep their existing classifications. A stale file id is inv ## Verification -Serializer tests cover file and data-URL representations over the same request versions, all supported media types, tool-result placement, and 20-to-10 base64 offload. Adapter tests cover immediate resolution failure, failure after a partial set of file ids, deadline-triggered fallback, stale-id replacement failure, all-inline request bodies, caller cancellation without fallback, and generic chat failure without a transport switch. Configuration tests cover both inline bounds and the Files deadline relationship. +Serializer tests cover file and data-URL representations over the same request versions, all supported media types, tool-result placement, and 20-to-10 base64 offload. Adapter tests cover immediate resolution failure, failure after a partial set of file ids, deadline-triggered fallback, stale-id replacement failure, all-inline request bodies, caller cancellation without fallback, and generic chat failure without a transport switch. Configuration tests cover both inline bounds and independent Files and stream idle deadlines. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md index 34625c6250..0534514f9b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md @@ -10,7 +10,7 @@ DeepSeek 官方视觉路由使用提供方文件 ID,使重复请求不必再 ## Decision -Files 仍是首选传输方式。每张请求图片的文件解析都有可配置的 `filesApiTimeoutMs` 时限,默认一分钟,且始终小于 `streamIdleTimeoutMs`。每次成功解析都会刷新外层 idle watchdog。调用方取消和外层流时限仍直接终止请求。 +Files 仍是首选传输方式。每张请求图片的文件解析都有可配置的 `filesApiTimeoutMs` 时限,默认一分钟。stream idle 时限默认为五分钟,因此 Files 时限通常会为内联回退留出时间。部署也可以把 stream idle 时限设得更短,让它先终止请求。每次成功解析都会刷新外层 idle watchdog。调用方取消和外层流时限仍直接终止请求。 文件解析失败后,适配器会丢弃为该次 chat 尝试组装的临时文件块,并用 base64 data URL 重新组装完整图片请求。每张保留图片都复用已经准备好的确定性 `RequestImageAttachment`;回退不会再次解码、缩放或编码,同一个 chat 请求也不会混用 file ID 和内联图片。较早图片在后续图片失败前已经提交的上传映射会保留,供之后请求使用。下一次请求会重新尝试 Files,因此不需要保存进程级故障状态。 @@ -30,7 +30,7 @@ Files 仍是首选传输方式。每张请求图片的文件解析都有可配 ## Verification -序列化测试覆盖相同请求版本的文件和 data URL 表示、全部支持的媒体类型、工具结果位置,以及 20MiB 到 10MiB 的 base64 offload。适配器测试覆盖立即解析失败、部分 file ID 成功后的失败、时限触发的回退、失效 ID 替换失败、全内联请求体、调用方取消时不回退,以及普通 chat 错误不切换传输方式。配置测试覆盖两项内联预算和 Files 时限关系。 +序列化测试覆盖相同请求版本的文件和 data URL 表示、全部支持的媒体类型、工具结果位置,以及 20MiB 到 10MiB 的 base64 offload。适配器测试覆盖立即解析失败、部分 file ID 成功后的失败、时限触发的回退、失效 ID 替换失败、全内联请求体、调用方取消时不回退,以及普通 chat 错误不切换传输方式。配置测试覆盖两项内联预算,以及相互独立的 Files 和 stream idle 时限。 ## Consequences diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index e254c6267d..e434fefafc 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: 7a22955565027b30677e46a80a8b719bc7e61917 -README.zh.md: db1669509956d651dcb8948e1191a17cf9a0bfee +README.md: 8a62b7b587323de152ea3322ce310d48a41247cc +README.zh.md: 009732f4256c49d7ae8d702c41df532236f77c5f diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7a22955565..8a62b7b587 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -26,7 +26,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire imageOffloadByteQuantum: 67108864 # oldest-image removal advances in 64 MiB steps inlineImageOffloadByteQuantum: 10485760 # fallback removal advances in 10 MiB steps imageOffloadCountQuantum: 20 # count overflow advances in 20-image steps - filesApiTimeoutMs: 60000 # per-image Files resolution deadline; below streamIdleTimeoutMs + filesApiTimeoutMs: 60000 # per-image Files resolution deadline; one-minute default fileExpiresAfterSeconds: 604800 # uploaded image lifetime; 1 hour to 30 days fileRefreshMarginSeconds: 3600 # replace ids with less lifetime remaining fileQuotaCleanupBatch: 100 # oldest harness-owned files deleted before one quota retry @@ -58,7 +58,7 @@ An image-capable catalog entry declares `inputModalities: [text, image]` and may Inline fallback has an independent base64 budget. `maxInlineRequestImageBytes` defaults to 20MiB and `inlineImageOffloadByteQuantum` to 10MiB, so a history of 21 one-megabyte base64 payloads removes the oldest 11 and retains 10MiB. The calculation uses base64-expanded lengths. The prepared request versions are reused byte-for-byte; fallback does not decode or compress an image again. Successful mappings created before a later image fails remain indexed for future requests. -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 fallback 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. File resolution, including local index access and remote upload, has a per-image one-minute deadline by default; it must remain below `streamIdleTimeoutMs`. Each successful resolution refreshes the outer idle watchdog. Any resolution failure switches that request to inline mode, while explicit public file-management operations continue to report their own failures. +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 fallback 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. File resolution, including local index access and remote upload, has a per-image one-minute deadline by default. The default five-minute stream idle deadline therefore leaves time for inline fallback; a deployment may configure a shorter stream idle deadline when it wants that outer deadline to terminate the request first. Each successful resolution refreshes the outer idle watchdog. Any resolution failure switches that request to inline mode, while explicit public file-management operations continue to report their own failures. 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 db16695099..009732f425 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -26,7 +26,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: imageOffloadByteQuantum: 67108864 # oldest-image removal advances in 64 MiB steps inlineImageOffloadByteQuantum: 10485760 # fallback removal advances in 10 MiB steps imageOffloadCountQuantum: 20 # count overflow advances in 20-image steps - filesApiTimeoutMs: 60000 # per-image Files resolution deadline; below streamIdleTimeoutMs + filesApiTimeoutMs: 60000 # per-image Files resolution deadline; one-minute default fileExpiresAfterSeconds: 604800 # uploaded image lifetime; 1 hour to 30 days fileRefreshMarginSeconds: 3600 # replace ids with less lifetime remaining fileQuotaCleanupBatch: 100 # oldest harness-owned files deleted before one quota retry @@ -58,7 +58,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 内联回退使用独立的 base64 预算。`maxInlineRequestImageBytes` 默认为 20MiB,`inlineImageOffloadByteQuantum` 默认为 10MiB,因此由 21 个 1MiB base64 负载组成的历史会移除最旧的 11 个并保留 10MiB。计算使用 base64 膨胀后的长度。系统逐字节复用已经准备好的请求版本;回退不会再次解码或压缩图片。前面图片已经成功写入的上传映射会保留,供后续请求复用。 -上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及编码参数,因此 Files API 和内联回退引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换。文件解析包括本地索引访问和远端上传,默认每张图片的时限为一分钟,且必须小于 `streamIdleTimeoutMs`。每次成功解析都会刷新外层 idle watchdog。任何解析失败都会把该请求切换到内联模式;显式公共文件管理操作仍会报告自身错误。 +上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及编码参数,因此 Files API 和内联回退引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换。文件解析包括本地索引访问和远端上传,默认每张图片的时限为一分钟。默认的 stream idle 时限为五分钟,因此通常有时间执行内联回退;部署可以设置更短的 stream idle 时限,让外层时限先终止请求。每次成功解析都会刷新外层 idle watchdog。任何解析失败都会把该请求切换到内联模式;显式公共文件管理操作仍会报告自身错误。 同一作用域和 `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/index.ts b/packages/llm/llm-deepseek/src/index.ts index e8632c22da..3af0236d29 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -336,9 +336,6 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro `llm-deepseek: filesApiTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } - if (filesApiTimeoutMs >= streamIdleTimeoutMs) { - throw new Error('llm-deepseek: filesApiTimeoutMs must be below streamIdleTimeoutMs') - } const fileExpiresAfterSeconds = config.fileExpiresAfterSeconds ?? DEFAULT_FILE_EXPIRY_SECONDS if (!Number.isSafeInteger(fileExpiresAfterSeconds) || fileExpiresAfterSeconds < 3_600 diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 5ef87231bb..085b35063a 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -348,7 +348,7 @@ describe('DeepSeekAdapter against a mock server', () => { await pending expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(String(fetchSpy.mock.calls[0]?.[1]?.body)).toContain('image_url') + expect(fetchSpy.mock.calls[0]?.[1]?.body).toEqual(expect.stringContaining('image_url')) fetchSpy.mockRestore() }) @@ -1397,7 +1397,6 @@ describe('DeepSeekAdapter against a mock server', () => { }) const adapter = adapterOf({ baseURL: 'https://example.invalid', - filesApiTimeoutMs: 50, streamIdleTimeoutMs: 100, }) try { @@ -1432,7 +1431,6 @@ describe('DeepSeekAdapter against a mock server', () => { }) const adapter = adapterOf({ baseURL: 'https://example.invalid', - filesApiTimeoutMs: 50, streamIdleTimeoutMs: 100, }) try { @@ -2028,7 +2026,7 @@ describe('plugin registration and config', () => { })).rejects.toThrow(/streamIdleTimeoutMs/) }) - it('rejects invalid Files API timeout bounds for direct and plugin composition', async () => { + it('validates Files API timeout bounds independently of the stream idle deadline', async () => { expect(() => resolveAdapterOptions({ filesApiTimeoutMs: Number.POSITIVE_INFINITY })) .toThrow(/filesApiTimeoutMs.*positive finite/) expect(() => resolveAdapterOptions({ filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) @@ -2044,8 +2042,8 @@ describe('plugin registration and config', () => { baseURL: 'http://127.0.0.1:1', filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1, })).rejects.toThrow(/filesApiTimeoutMs/) - expect(() => resolveAdapterOptions({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 })) - .toThrow(/filesApiTimeoutMs must be below streamIdleTimeoutMs/) + expect(resolveAdapterOptions({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 })) + .toMatchObject({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 }) }) it('rejects invalid nested retryPolicy before registering the provider', async () => {