فهرست منبع

fix(llm): harden Messages transport and Files parity

Tianyi Cui 1 هفته پیش
والد
کامیت
7d3dab66a2

+ 1 - 1
apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml

@@ -4,7 +4,7 @@
 - id: llm-deepseek
   name: '@deepseek-ai/dsh-llm-deepseek'
   config:
-    protocol: chat-completions
+    protocol: !!js process.env.DSH_SNAPSHOT_PROTOCOL
     apiKeyEnv: DSH_SNAPSHOT_API_KEY
     baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
     thinking: disabled

+ 114 - 58
apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts

@@ -18,7 +18,9 @@ const IMAGE_OFFLOAD_CONFIG = fileURLToPath(new URL('./fixtures/image-offload.cor
 const SNAPSHOTS_DIR = fileURLToPath(new URL('../../../../../../snapshots/acp/', import.meta.url))
 const READ_IMAGE_WORKSPACE = fileURLToPath(new URL('../../../../../../snapshots/session/read-image/workspace/', import.meta.url))
 
-it('pins native DeepSeek Files offload and inline fallback in assembled requests', async () => {
+it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and inline fallback in assembled requests', async (protocol) => {
+  const filesPath = protocol === 'messages' ? '/v1/files' : '/files'
+  const modelPath = protocol === 'messages' ? '/v1/messages' : '/chat/completions'
   const requests: Record<string, unknown>[] = []
   const fileRequests: Array<{ method: string; path: string; bytes: number }> = []
   let rejectFiles = false
@@ -29,7 +31,7 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests
       void (async () => {
         const url = new URL(request.url ?? '/', 'http://localhost')
         const body = Buffer.concat(chunks)
-        if (url.pathname === '/files' && request.method === 'POST') {
+        if (url.pathname === filesPath && request.method === 'POST') {
           const headers = new Headers()
           for (const [name, value] of Object.entries(request.headers)) {
             if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value)
@@ -47,7 +49,14 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests
             return
           }
           const createdAt = Math.floor(Date.now() / 1_000)
-          response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({
+          response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(protocol === 'messages' ? {
+            id: 'file-api-snapshot-1',
+            type: 'file',
+            size_bytes: file.size,
+            created_at: new Date(createdAt * 1_000).toISOString(),
+            filename: 'dsh-snapshot.png',
+            mime_type: file.type,
+          } : {
             id: 'file-api-snapshot-1',
             object: 'file',
             bytes: file.size,
@@ -58,12 +67,26 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests
           }))
           return
         }
-        if (url.pathname !== '/chat/completions') {
+        if (url.pathname !== modelPath) {
           response.writeHead(404).end()
           return
         }
         requests.push(JSON.parse(body.toString('utf8')) as Record<string, unknown>)
         response.writeHead(200, { 'content-type': 'text/event-stream' })
+        if (protocol === 'messages') {
+          const toolCall = requests.length === 1
+          const events = [
+            { type: 'message_start', message: { id: 'offload-response', model: 'deepseek-v4-flash-vision-exp', usage: { input_tokens: 3, output_tokens: 0 } } },
+            { type: 'content_block_start', index: 0, content_block: toolCall
+              ? { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } }
+              : { type: 'text', text: 'DONE' } },
+            { type: 'content_block_stop', index: 0 },
+            { type: 'message_delta', delta: { stop_reason: toolCall ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 1 } },
+            { type: 'message_stop' },
+          ]
+          response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''))
+          return
+        }
         const events = requests.length === 1
           ? [
             'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}',
@@ -114,13 +137,14 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests
       fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'),
       workspaceDir: READ_IMAGE_WORKSPACE,
       env: {
+        DSH_SNAPSHOT_PROTOCOL: protocol,
         DSH_SNAPSHOT_API_KEY: 'snapshot-key',
         DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`,
       },
     })
     expect(result.stderr).toBe('')
     expect(requests).toHaveLength(2)
-    expect(fileRequests).toEqual([{ method: 'POST', path: '/files', bytes: 69 }])
+    expect(fileRequests).toEqual([{ method: 'POST', path: filesPath, bytes: 69 }])
     const attachmentDigest = 'b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640'
     const attachmentId = `sha256:${attachmentDigest}`
     const accessText = (cwd: string): string => {
@@ -138,69 +162,97 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests
         + ' Copy to a writable path ending in .png before editing.'
     }
     const normalizedAccess = accessText(result.cwd)
+    const imagePrefix = protocol === 'messages' ? '' : '\n'
+    const fileImage = protocol === 'messages'
+      ? { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } }
+      : { type: 'file', file_id: 'file-api-snapshot-1' }
     const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]`
     const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}`
     const normalizedToolImageHandle = `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}`
       .replaceAll(result.cwd, '{{cwd}}')
+    const runtimeContext = 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n'
+      + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n'
+      + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
     const messages = requests[0]?.messages as { content?: unknown }[] | undefined
     const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted'))
     expect(offloaded?.content).toEqual([
       { type: 'text', text: 'Compare the older image ' },
       { type: 'text', text: offloadedImage },
       { type: 'text', text: ' with the newer image ' },
-      { type: 'text', text: `\n${imageHandle}` },
-      { type: 'file', file_id: 'file-api-snapshot-1' },
+      { type: 'text', text: `${imagePrefix}${imageHandle}` },
+      fileImage,
       { type: 'text', text: ', then use read_image on red.png and reply with DONE.' },
+      ...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [],
     ])
 
-    const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{
-      role?: unknown
-      content?: unknown
-    }>
-    const toolMessage = followup.find(message => message.role === 'tool')
-    if (toolMessage === undefined || typeof toolMessage.content !== 'string') {
-      throw new Error('native read_image request has no tool content')
+    if (protocol === 'messages') {
+      const followup = requests[1]?.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
+      expect(followup).toHaveLength(3)
+      expect(followup[0]?.content.filter(block => block.type === 'image')).toEqual([])
+      expect(followup[0]?.content.filter(block => block.text === offloadedImage)).toHaveLength(2)
+      expect(followup[1]).toEqual({ role: 'assistant', content: [
+        { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } },
+      ] })
+      expect(followup[2]?.role).toBe('user')
+      expect(followup[2]?.content).toHaveLength(1)
+      const toolResult = followup[2]!.content[0]!
+      expect(toolResult.type).toBe('tool_result')
+      expect(toolResult.tool_use_id).toBe('native-read-image')
+      const content = toolResult.content as Array<Record<string, unknown>>
+      expect(content[0]?.type).toBe('text')
+      expect(content[0]?.text).toContain('image/png image, 1x1 px, 69 bytes')
+      expect(content.slice(1)).toEqual([
+        { type: 'text', text: `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` },
+        fileImage,
+      ])
+    } else {
+      const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{
+        role?: unknown
+        content?: unknown
+      }>
+      const toolMessage = followup.find(message => message.role === 'tool')
+      if (toolMessage === undefined || typeof toolMessage.content !== 'string') {
+        throw new Error('native read_image request has no tool content')
+      }
+      const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => (
+        cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`]
+      )))]
+      let toolContent = toolMessage.content
+      for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}')
+      toolMessage.content = toolContent
+      expect(followup).toEqual([
+        {
+          role: 'user',
+          content: `Compare the older image ${offloadedImage} with the newer image ${offloadedImage}, then use read_image on red.png and reply with DONE.`,
+        },
+        {
+          role: 'user',
+          content: runtimeContext,
+        },
+        {
+          role: 'assistant',
+          content: '',
+          tool_calls: [{
+            id: 'native-read-image',
+            type: 'function',
+            function: { name: 'read_image', arguments: '{"file_path":"red.png"}' },
+          }],
+        },
+        {
+          role: 'tool',
+          tool_call_id: 'native-read-image',
+          content: '<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n'
+            + `</content>\n${normalizedToolImageHandle}`,
+        },
+        {
+          role: 'user',
+          content: [
+            { type: 'text', text: 'Attached image(s) from tool result:' },
+            { type: 'file', file_id: 'file-api-snapshot-1' },
+          ],
+        },
+      ])
     }
-    const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => (
-      cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`]
-    )))]
-    let toolContent = toolMessage.content
-    for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}')
-    toolMessage.content = toolContent
-    expect(followup).toEqual([
-      {
-        role: 'user',
-        content: `Compare the older image ${offloadedImage} with the newer image ${offloadedImage}, then use read_image on red.png and reply with DONE.`,
-      },
-      {
-        role: 'user',
-        content: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n'
-          + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n'
-          + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).',
-      },
-      {
-        role: 'assistant',
-        content: '',
-        tool_calls: [{
-          id: 'native-read-image',
-          type: 'function',
-          function: { name: 'read_image', arguments: '{"file_path":"red.png"}' },
-        }],
-      },
-      {
-        role: 'tool',
-        tool_call_id: 'native-read-image',
-        content: '<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n'
-          + `</content>\n${normalizedToolImageHandle}`,
-      },
-      {
-        role: 'user',
-        content: [
-          { type: 'text', text: 'Attached image(s) from tool result:' },
-          { type: 'file', file_id: 'file-api-snapshot-1' },
-        ],
-      },
-    ])
 
     rejectFiles = true
     const fallback = await runScenario(input, {
@@ -210,14 +262,15 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests
       fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'),
       workspaceDir: READ_IMAGE_WORKSPACE,
       env: {
+        DSH_SNAPSHOT_PROTOCOL: protocol,
         DSH_SNAPSHOT_API_KEY: 'snapshot-fallback-key',
         DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`,
       },
     })
     expect(fallback.stderr).toBe('')
     expect(fileRequests).toEqual([
-      { method: 'POST', path: '/files', bytes: 69 },
-      { method: 'POST', path: '/files', bytes: 69 },
+      { method: 'POST', path: filesPath, bytes: 69 },
+      { method: 'POST', path: filesPath, bytes: 69 },
     ])
     expect(requests).toHaveLength(3)
     const fallbackMessages = requests[2]?.messages as { content?: unknown }[] | undefined
@@ -227,9 +280,12 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests
       { type: 'text', text: 'Compare the older image ' },
       { type: 'text', text: `[image omitted to fit request image limits; ${attachmentId}.${fallbackAccess}]` },
       { type: 'text', text: ' with the newer image ' },
-      { type: 'text', text: `\nImage ${attachmentId}; request preview 1x1px.${fallbackAccess}` },
-      { type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } },
+      { type: 'text', text: `${imagePrefix}Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` },
+      protocol === 'messages'
+        ? { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } }
+        : { type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } },
       { type: 'text', text: ', then use read_image on red.png and reply with DONE.' },
+      ...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [],
     ])
   } finally {
     await new Promise<void>(resolve => server.close(() => { resolve() }))

+ 2 - 2
docs/persistence-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/persistence-catalog.md
-persistence-catalog.md: acb64b7be1715b58f9fbcdebfaaadf709865c15b
-persistence-catalog.zh.md: fab7c7b9f4b8c3ef9edb97035d23accb6ae378b3
+persistence-catalog.md: 0b58352834d747e7ee2decc5efbf6216cee232b1
+persistence-catalog.zh.md: 3a074a9d5c84d1925eba397a840f26b9e61e29e6

+ 6 - 6
docs/persistence-catalog.md

@@ -1218,7 +1218,7 @@ Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/
 'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest
 ```
 
-Source: [`packages/web/web-search-deepseek/src/provider.ts:83`](../packages/web/web-search-deepseek/src/provider.ts)
+Source: [`packages/web/web-search-deepseek/src/provider.ts:82`](../packages/web/web-search-deepseek/src/provider.ts)
 
 ## Resolved persistence types
 
@@ -3890,7 +3890,7 @@ SHA-256: `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331`
 
 SHA-256: `930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731`
 
-Sources: [`packages/web/web-search-deepseek/src/provider.ts:62`](../packages/web/web-search-deepseek/src/provider.ts)
+Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web/web-search-deepseek/src/provider.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -3915,7 +3915,7 @@ SHA-256: `b993441f8ce7d27b80e619e113e232ae8e62d3d5a8340f6bfe12d8c4c018e62f`
 
 SHA-256: `9a2a9029f8d7ede05336980d8342737557f487b913bfd28853d0ab5214600ab5`
 
-Sources: [`packages/web/web-search-deepseek/src/provider.ts:65`](../packages/web/web-search-deepseek/src/provider.ts)
+Sources: [`packages/web/web-search-deepseek/src/provider.ts:64`](../packages/web/web-search-deepseek/src/provider.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -3938,7 +3938,7 @@ SHA-256: `8f6d609794bf5afc01d7ccf3e03811d32f4b8047f58a2c9a98db0154c2ad7cb9`
 
 SHA-256: `4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c`
 
-Sources: [`packages/llm/llm/src/types.ts:54`](../packages/llm/llm/src/types.ts) · [`packages/web/web-search-deepseek/src/provider.ts:67`](../packages/web/web-search-deepseek/src/provider.ts)
+Sources: [`packages/llm/llm/src/types.ts:54`](../packages/llm/llm/src/types.ts) · [`packages/web/web-search-deepseek/src/provider.ts:66`](../packages/web/web-search-deepseek/src/provider.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -3961,7 +3961,7 @@ SHA-256: `4aed17ec726b5397c29f6cb4bcd7905dc1a12bb6140f1657f073cd24380f2af8`
 
 SHA-256: `2d11ca7b0d4493e244b74eba093227866b33249841e59a1e9bf56afed38604c3`
 
-Sources: [`packages/web/web-search-deepseek/src/provider.ts:72`](../packages/web/web-search-deepseek/src/provider.ts)
+Sources: [`packages/web/web-search-deepseek/src/provider.ts:71`](../packages/web/web-search-deepseek/src/provider.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -5718,7 +5718,7 @@ Sources: [`packages/todo/tool-todo/src/types.ts:21`](../packages/todo/tool-todo/
 
 SHA-256: `2517ba143a271508d3ca35126d5aca8f7f1facf5aaddce4adf011832042fa2b9`
 
-Sources: [`packages/web/web-search-deepseek/src/provider.ts:56`](../packages/web/web-search-deepseek/src/provider.ts)
+Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web/web-search-deepseek/src/provider.ts)
 
 | Property | Presence | Type |
 |---|---|---|

+ 6 - 6
docs/persistence-catalog.zh.md

@@ -1220,7 +1220,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest
 ```
 
-来源:[`packages/web/web-search-deepseek/src/provider.ts:83`](../packages/web/web-search-deepseek/src/provider.ts)
+来源:[`packages/web/web-search-deepseek/src/provider.ts:82`](../packages/web/web-search-deepseek/src/provider.ts)
 
 ## 已解析的持久化类型
 
@@ -3892,7 +3892,7 @@ SHA-256: `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331`
 
 SHA-256: `930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731`
 
-来源:[`packages/web/web-search-deepseek/src/provider.ts:62`](../packages/web/web-search-deepseek/src/provider.ts)
+来源:[`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web/web-search-deepseek/src/provider.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -3917,7 +3917,7 @@ SHA-256: `b993441f8ce7d27b80e619e113e232ae8e62d3d5a8340f6bfe12d8c4c018e62f`
 
 SHA-256: `9a2a9029f8d7ede05336980d8342737557f487b913bfd28853d0ab5214600ab5`
 
-来源:[`packages/web/web-search-deepseek/src/provider.ts:65`](../packages/web/web-search-deepseek/src/provider.ts)
+来源:[`packages/web/web-search-deepseek/src/provider.ts:64`](../packages/web/web-search-deepseek/src/provider.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -3940,7 +3940,7 @@ SHA-256: `8f6d609794bf5afc01d7ccf3e03811d32f4b8047f58a2c9a98db0154c2ad7cb9`
 
 SHA-256: `4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c`
 
-来源:[`packages/llm/llm/src/types.ts:54`](../packages/llm/llm/src/types.ts) · [`packages/web/web-search-deepseek/src/provider.ts:67`](../packages/web/web-search-deepseek/src/provider.ts)
+来源:[`packages/llm/llm/src/types.ts:54`](../packages/llm/llm/src/types.ts) · [`packages/web/web-search-deepseek/src/provider.ts:66`](../packages/web/web-search-deepseek/src/provider.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -3963,7 +3963,7 @@ SHA-256: `4aed17ec726b5397c29f6cb4bcd7905dc1a12bb6140f1657f073cd24380f2af8`
 
 SHA-256: `2d11ca7b0d4493e244b74eba093227866b33249841e59a1e9bf56afed38604c3`
 
-来源:[`packages/web/web-search-deepseek/src/provider.ts:72`](../packages/web/web-search-deepseek/src/provider.ts)
+来源:[`packages/web/web-search-deepseek/src/provider.ts:71`](../packages/web/web-search-deepseek/src/provider.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -5720,7 +5720,7 @@ SHA-256: `a99f76fd149051c363f960bfeeb5b9509587ecaf7591a42797e4bfe810d8987a`
 
 SHA-256: `2517ba143a271508d3ca35126d5aca8f7f1facf5aaddce4adf011832042fa2b9`
 
-来源:[`packages/web/web-search-deepseek/src/provider.ts:56`](../packages/web/web-search-deepseek/src/provider.ts)
+来源:[`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web/web-search-deepseek/src/provider.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|

+ 5 - 5
docs/persistence-schema.json

@@ -19825,7 +19825,7 @@
         "packages/web/web-search-deepseek/src/provider.ts#DeepSeekSearchLlmRequest"
       ],
       "sources": [
-        "packages/web/web-search-deepseek/src/provider.ts:56"
+        "packages/web/web-search-deepseek/src/provider.ts:55"
       ]
     },
     {
@@ -21149,7 +21149,7 @@
         "event:web/deepseek-search-llm-request.data.body.tools[0]"
       ],
       "sources": [
-        "packages/web/web-search-deepseek/src/provider.ts:72"
+        "packages/web/web-search-deepseek/src/provider.ts:71"
       ]
     },
     {
@@ -29674,7 +29674,7 @@
       ],
       "sources": [
         "packages/llm/llm/src/types.ts:54",
-        "packages/web/web-search-deepseek/src/provider.ts:67"
+        "packages/web/web-search-deepseek/src/provider.ts:66"
       ]
     },
     {
@@ -42729,7 +42729,7 @@
         "event:web/deepseek-search-llm-request.data.body"
       ],
       "sources": [
-        "packages/web/web-search-deepseek/src/provider.ts:62"
+        "packages/web/web-search-deepseek/src/provider.ts:61"
       ]
     },
     {
@@ -44241,7 +44241,7 @@
         "event:web/deepseek-search-llm-request.data.body.messages[0]"
       ],
       "sources": [
-        "packages/web/web-search-deepseek/src/provider.ts:65"
+        "packages/web/web-search-deepseek/src/provider.ts:64"
       ]
     },
     {

+ 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: b60141c35e0b6b6bdd9ad54ffcffa8860f8914c6
-README.zh.md: 79c1d172770834bcaba3581f44acfd631a54f4ca
+README.md: 8c7d2d083a8a315ea2d7bf5190b77222901488b1
+README.zh.md: e7904b221f7573e997abba126892b83f73a5b4f6

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

@@ -84,7 +84,7 @@ To select Chat Completions explicitly, patch the existing plugin:
     protocol: chat-completions
 ```
 
-`protocol` defaults to `messages`, with official root `https://api.deepseek.com/anthropic`; `chat-completions` uses `https://api.deepseek.com`. Shipped first-party compositions explicitly select Messages. Neither protocol requires `baseURL`: its official default applies when both `baseURL` and `$DEEPSEEK_BASE_URL` are absent. Switching protocols retains endpoint overrides, so users must supply an address compatible with the selected protocol. Chat appends `/chat/completions`; Messages appends `/v1/messages`. Apart from trailing slashes, neither infers or removes custom path suffixes such as `/v1`. Both share the `llm-deepseek` settings section, `apiKeyEnv`, and `deepseek-official`, so saved model selections remain valid.
+`protocol` defaults to `messages`, with official root `https://api.deepseek.com/anthropic`; `chat-completions` uses `https://api.deepseek.com`. Shipped first-party compositions explicitly select Messages. Neither protocol requires `baseURL`: its official default applies when both `baseURL` and `$DEEPSEEK_BASE_URL` are absent. Switching protocols retains endpoint overrides, so users must supply an address compatible with the selected protocol. An explicit `https://api.deepseek.com` override selects the Chat root: remove that override to use the official Messages default, or set it to `https://api.deepseek.com/anthropic`. Chat appends `/chat/completions`; Messages appends `/v1/messages`. Apart from trailing slashes, neither infers or removes custom path suffixes such as `/v1`. Both share the `llm-deepseek` settings section, `apiKeyEnv`, and `deepseek-official`, so saved model selections remain valid.
 
 Messages sends text, thinking, tool calls, and tool results as content blocks, reasoning effort as `output_config.effort`, and images as Files references or inline base64. Models declaring `systemPromptUpdate: in-history` retain the initial top-level system and send new system snapshots after their corresponding user/tool-result turn; undeclared models use the latest snapshot as the top-level system. Replay metadata identifies the Messages format, model, and signatures. Chat requests serialize durable content without those signatures. Invalid Messages replay metadata emits a warning and omits signatures while retaining text and tool history.
 
@@ -92,9 +92,9 @@ Messages sends text, thinking, tool calls, and tool results as content blocks, r
 
 An image-capable route chooses each durable reference's request target and resolves it into a deterministic request version. Omitting `imagePixelBudget` sizes the target on the published vision token grid of 14px patches, 3:1 downsampling, and at most 1024 tokens per image, so a square image keeps up to 1302×1302 pixels and a 16:9 image is sent as 1708×961 for the provider's 1708×966 grid; a positive integer replaces the grid with a total-pixel budget, and `low` uses 512×512 total pixels. Every request image is capped at 4096 pixels per side, the provider limit for requests carrying 15 or more images, and `imageMaxBytes` defaults to 2 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references.
 
-Both protocols normally upload those exact request bytes through their DeepSeek Files endpoint and send file-id references. Messages uses `/v1/files` under its configured base and includes `anthropic-beta: files-api-2025-04-14` on Files requests and Messages requests containing file ids; Chat uses `/files`. A failed or timed-out file resolution rebuilds the whole model request with inline base64 under the inline budget; one request never mixes file ids and inline images. Caller cancellation stops the request.
+Both protocols normally upload those exact request bytes through their DeepSeek Files endpoint and send file-id references. Messages uses `/v1/files` under its configured base and includes `anthropic-beta: files-api-2025-04-14` on Files requests and Messages requests containing file ids; Chat uses `/files`. Messages model requests and all Files requests reject redirects so credentials remain on the configured origin. A failed or timed-out file resolution rebuilds the whole model request with inline base64 under the inline budget; one request never mixes file ids and inline images. Caller cancellation stops the request.
 
-Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Messages file metadata omits remote expiry, so its local reuse deadline uses the original upload time plus `fileExpiresAfterSeconds`; this does not guarantee remote deletion. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry.
+Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Both uploads request expiry through `expires_after[anchor]=created_at` and `expires_after[seconds]`. Messages file metadata omits remote expiry, so its local reuse deadline uses the original upload time plus `fileExpiresAfterSeconds`; this does not guarantee remote deletion. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry.
 
 Files mode bounds retained request versions by `maxRequestFilesBytes` and `maxImagesPerRequest`; inline fallback has its own base64 budget. Both remove an oldest prefix in configured byte or count quanta. Each omitted image gets its own model-visible placeholder with its display name or attachment id and, when available, normalized dimensions, media type, and current read-only path. The stepped high-watermark policy avoids rewriting an old request prefix after every new image.
 

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

@@ -84,7 +84,7 @@ kind: "package-reference"
     protocol: chat-completions
 ```
 
-`protocol` 默认为 `messages`,官方根地址为 `https://api.deepseek.com/anthropic`;`chat-completions` 使用 `https://api.deepseek.com`。随产品交付的官方组合显式选择 Messages。两种协议都不要求填写 `baseURL`:当 `baseURL` 与 `$DEEPSEEK_BASE_URL` 均未设置时使用当前协议的官方默认值。切换协议保留已有端点覆盖,用户需要填写与选定协议兼容的地址。Chat 追加 `/chat/completions`,Messages 追加 `/v1/messages`;除去末尾斜线之外,不推测或删除自定义路径中的 `/v1` 等后缀。两种协议共用 `llm-deepseek` 设置、`apiKeyEnv` 与 `deepseek-official`,因此已保存的模型选择仍然有效。
+`protocol` 默认为 `messages`,官方根地址为 `https://api.deepseek.com/anthropic`;`chat-completions` 使用 `https://api.deepseek.com`。随产品交付的官方组合显式选择 Messages。两种协议都不要求填写 `baseURL`:当 `baseURL` 与 `$DEEPSEEK_BASE_URL` 均未设置时使用当前协议的官方默认值。切换协议保留已有端点覆盖,用户需要填写与选定协议兼容的地址。显式填写的 `https://api.deepseek.com` 是 Chat 根地址:删除该覆盖即可使用官方 Messages 默认值,也可以改填 `https://api.deepseek.com/anthropic`。Chat 追加 `/chat/completions`,Messages 追加 `/v1/messages`;除去末尾斜线之外,不推测或删除自定义路径中的 `/v1` 等后缀。两种协议共用 `llm-deepseek` 设置、`apiKeyEnv` 与 `deepseek-official`,因此已保存的模型选择仍然有效。
 
 Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据记录 Messages 格式、模型和签名;Chat 请求只序列化持久化内容,不发送这些签名。无效的 Messages 回放元数据产生警告并省略签名,不丢弃文本或工具历史。
 
@@ -92,9 +92,9 @@ Messages 以内容块发送文本、思考、工具调用和工具结果,以 `
 
 支持图片的路由为每个持久引用选定请求目标,再把它解析为确定性请求版本。省略 `imagePixelBudget` 时按官方公布的视觉 token 网格定目标,即 14 px patch、3:1 降采样、单图最多 1024 token,因此正方形图片最多保留 1302×1302 像素,16:9 图片以 1708×961 发送、对应提供方 1708×966 的网格;正整数会用总像素预算取代网格,`low` 使用总计 512×512 像素。每张请求图片单边最多 4096 像素,这是提供方对包含 15 张及以上图片的请求的限制;`imageMaxBytes` 默认为 2 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。
 
-两种协议通常通过各自的 DeepSeek Files 端点上传这些确切请求字节,并发送 file-id 引用。Messages 在配置的基址下使用 `/v1/files`,Files 请求与包含 file id 的 Messages 请求均携带 `anthropic-beta: files-api-2025-04-14`;Chat 使用 `/files`。文件解析失败或超时会按内联预算,用内联 base64 重建整份模型请求;一次请求绝不混用 file id 与内联图片。调用方取消会停止请求。
+两种协议通常通过各自的 DeepSeek Files 端点上传这些确切请求字节,并发送 file-id 引用。Messages 在配置的基址下使用 `/v1/files`,Files 请求与包含 file id 的 Messages 请求均携带 `anthropic-beta: files-api-2025-04-14`;Chat 使用 `/files`。Messages 模型请求与所有 Files 请求拒绝重定向,确保凭据仅发送到配置的源。文件解析失败或超时会按内联预算,用内联 base64 重建整份模型请求;一次请求绝不混用 file id 与内联图片。调用方取消会停止请求。
 
-缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。Messages 文件元数据不含远端过期时间,因此本地复用期限使用原始上传时间加 `fileExpiresAfterSeconds`;这不保证远端文件删除。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。
+缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。两种上传都通过 `expires_after[anchor]=created_at` 与 `expires_after[seconds]` 请求过期。Messages 文件元数据不含远端过期时间,因此本地复用期限使用原始上传时间加 `fileExpiresAfterSeconds`;这不保证远端文件删除。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。
 
 Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留请求版本;内联回退有独立 base64 预算。两种模式都按配置的字节或数量量子移除最旧前缀。每张省略图片都有自己的模型可见占位符,包含显示名或附件 id,以及可用时的规范化尺寸、媒体类型与当前只读路径。分阶高水位策略避免每新增一张图片都改写旧请求前缀。
 

+ 11 - 10
packages/llm/llm-deepseek/src/common/file-store.ts

@@ -8,8 +8,8 @@ import { deepSeekFileScope, DeepSeekUploadIndex } from './upload-index.ts'
 import type { DeepSeekUploadRecord } from './upload-index.ts'
 import type { DeepSeekProtocol } from './types.ts'
 
-/** DeepSeek chat accepts at most 32 MiB per image even when it is referenced by file id. */
-export const MAX_CHAT_IMAGE_BYTES = 32 * 1024 * 1024
+/** Shared Files-store limit for each request image, including file-id references. */
+export const MAX_IMAGE_BYTES = 32 * 1024 * 1024
 const OWNED_FILE_PREFIX = 'dsh-'
 
 /** Resolved file-store policy from the plugin configuration. */
@@ -191,8 +191,8 @@ export class DeepSeekFileStore {
     policy: DeepSeekFilePolicy,
     signal: AbortSignal,
   ): Promise<DeepSeekFileReference> {
-    if (version.bytes > MAX_CHAT_IMAGE_BYTES) {
-      throw new LlmError('DeepSeek chat image exceeds the 32 MiB per-image limit.', 'INVALID_REQUEST')
+    if (version.bytes > MAX_IMAGE_BYTES) {
+      throw new LlmError('DeepSeek image exceeds the 32 MiB per-image limit.', 'INVALID_REQUEST')
     }
     const scope = fileScope(connection)
     const now = this.now()
@@ -244,7 +244,7 @@ export class DeepSeekFileStore {
   }
 
   /**
-   * Invalidate one exact local mapping after the chat endpoint rejects its remote id.
+   * Invalidate one exact local mapping after a model request rejects its remote id.
    * @param version - request-image version whose remote generation failed.
    * @param fileId - exact rejected file id.
    * @param connection - endpoint and API-key snapshot.
@@ -313,11 +313,12 @@ export class DeepSeekFileStore {
       for (const file of page.data) {
         if (!file.filename.startsWith(OWNED_FILE_PREFIX)) continue
         owned.push({ id: file.id, createdAt: file.createdAt })
-        if (connection.protocol === 'messages') {
-          // Messages offers no ascending-order query; retain the oldest candidates across every page.
-          owned.sort((left, right) => left.createdAt - right.createdAt)
-          if (owned.length > count) owned.pop()
-        } else if (owned.length === count) break
+        if (connection.protocol === 'chat-completions' && owned.length === count) break
+      }
+      if (connection.protocol === 'messages') {
+        // Messages offers no ascending-order query; retain the oldest candidates across every page.
+        owned.sort((left, right) => left.createdAt - right.createdAt)
+        owned.splice(count)
       }
       if (!page.hasMore || page.lastId === undefined || page.lastId === after) break
       after = page.lastId

+ 13 - 8
packages/llm/llm-deepseek/src/common/files-api.ts

@@ -20,14 +20,15 @@ export const MAX_STORED_FILE_COUNT = 10_000
 /** Current per-key storage quota. */
 export const MAX_STORED_FILE_BYTES = 25 * 1024 * 1024 * 1024
 
-/** Validated file object returned by the OpenAI-compatible endpoint. */
+/** Validated file metadata normalized from either DeepSeek Files protocol. */
 export interface DeepSeekFileObject {
   id: DeepSeekFileIdType
   bytes: number
   createdAt: number
   filename: string
+  /** Chat Completions purpose; synthesized as `user_data` for Messages. */
   purpose: 'user_data'
-  /** Reported remote expiry for Chat Completions; Messages uploads derive a conservative local reuse deadline. */
+  /** Remote Chat Completions expiry or the upload-time Messages reuse deadline; Messages list/retrieve omit this field. */
   expiresAt?: number
 }
 
@@ -142,7 +143,7 @@ function providerErrorDetail(value: unknown): { message?: string; detail: string
   }
 }
 
-/** Direct client for protocol-specific Files endpoints, retaining the configured URL root. */
+/** Direct Files client retaining the configured URL root and refusing redirects before credentials can leave its origin. */
 export class DeepSeekFilesClient {
   private readonly baseURL: string
   private readonly apiKey: string
@@ -171,10 +172,12 @@ export class DeepSeekFilesClient {
       const headers = new Headers(attributionHeaders())
       if (this.protocol === 'messages') {
         headers.set('x-api-key', this.apiKey)
+        headers.set('anthropic-version', '2023-06-01')
         headers.set('anthropic-beta', MESSAGES_FILES_BETA)
       } else headers.set('authorization', `Bearer ${this.apiKey}`)
       response = await this.fetchImpl(`${this.baseURL}${path}`, {
         ...init,
+        redirect: 'error',
         headers,
         ...signal === undefined ? {} : { signal },
       })
@@ -233,7 +236,7 @@ export class DeepSeekFilesClient {
   /**
    * List one page of files. Ordering applies only to Chat Completions; Messages owns its page order.
    * @param options - pagination, ordering, and cancellation.
-   * @returns the validated page.
+   * @returns the validated page with null Messages cursors omitted.
    */
   async list(options: {
     after?: DeepSeekFileIdType
@@ -249,15 +252,17 @@ export class DeepSeekFilesClient {
     const value = await response.json() as unknown
     if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse('list')
     const wire = value as { object?: unknown; data?: unknown; first_id?: unknown; last_id?: unknown; has_more?: unknown }
+    const firstId = this.protocol === 'messages' ? wire.first_id ?? undefined : wire.first_id
+    const lastId = this.protocol === 'messages' ? wire.last_id ?? undefined : wire.last_id
     if ((this.protocol === 'chat-completions' && wire.object !== 'list') || !Array.isArray(wire.data) || typeof wire.has_more !== 'boolean'
-      || (wire.first_id !== undefined && typeof wire.first_id !== 'string')
-      || (wire.last_id !== undefined && typeof wire.last_id !== 'string')) {
+      || (firstId !== undefined && typeof firstId !== 'string')
+      || (lastId !== undefined && typeof lastId !== 'string')) {
       throw invalidResponse('list')
     }
     return {
       data: wire.data.map(item => this.parseFile(item, 'list')),
-      ...typeof wire.first_id === 'string' ? { firstId: DeepSeekFileId(wire.first_id) } : {},
-      ...typeof wire.last_id === 'string' ? { lastId: DeepSeekFileId(wire.last_id) } : {},
+      ...typeof firstId === 'string' ? { firstId: DeepSeekFileId(firstId) } : {},
+      ...typeof lastId === 'string' ? { lastId: DeepSeekFileId(lastId) } : {},
       hasMore: wire.has_more,
     }
   }

+ 1 - 1
packages/llm/llm-deepseek/src/index.ts

@@ -39,7 +39,7 @@ export {
   resolveRequestImageTarget,
 } from './common/request-pricing.ts'
 export { deepSeekImageTokens, deepSeekRequestImageDimensions } from './common/image-tokens.ts'
-export { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from './common/file-store.ts'
+export { DeepSeekFileStore, MAX_IMAGE_BYTES } from './common/file-store.ts'
 export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './common/file-store.ts'
 export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './common/files-api.ts'
 export type { DeepSeekFileObject, DeepSeekFilePage } from './common/files-api.ts'

+ 1 - 1
packages/llm/llm-deepseek/src/protocols/messages/adapter.ts

@@ -120,7 +120,7 @@ export class DeepSeekMessagesAdapter extends LlmAdapter {
       }, this.dependencies.prepareExtensions)
       signal.throwIfAborted()
       const response = await fetch(`${connection.baseURL.replace(/\/+$/u, '')}/v1/messages`, {
-        method: 'POST', signal, body: extensions.payload,
+        method: 'POST', signal, body: extensions.payload, redirect: 'error',
         headers: {
           ...attributionHeaders(),
           'content-type': 'application/json', 'accept': 'text/event-stream',

+ 16 - 9
packages/llm/llm-deepseek/tests/file-store.spec.ts

@@ -4,7 +4,7 @@ import { join } from 'node:path'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
-import { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from '../src/common/file-store.ts'
+import { DeepSeekFileStore, MAX_IMAGE_BYTES } from '../src/common/file-store.ts'
 import { DeepSeekFileId } from '../src/common/file-id.ts'
 import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/common/upload-index.ts'
 
@@ -68,7 +68,7 @@ function uploadFetch(now: () => number = () => NOW) {
 }
 
 describe('DeepSeekFileStore', () => {
-  it('separates native Files namespaces and refreshes native reuse from the original upload time', async () => {
+  it('separates native Files reuse, invalidation, and expiry from the chat namespace', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-native-file-store-'))
     roots.push(dir)
     let now = NOW
@@ -90,12 +90,19 @@ describe('DeepSeekFileStore', () => {
     expect(first.record.scope).not.toBe(chat.record.scope)
     const reopened = new DeepSeekFileStore({ index, fetch: fetchImpl, now: () => now })
     expect((await reopened.ensureUploaded(VERSION, native, POLICY)).record).toEqual(first.record)
+    await reopened.invalidate(VERSION, chat.record.fileId, native)
+    expect((await reopened.ensureUploaded(VERSION, native, POLICY)).record).toEqual(first.record)
     expect(uploads).toBe(2)
-    now = first.record.expiresAt - POLICY.refreshMarginSeconds * 1_000
+    await reopened.invalidate(VERSION, first.record.fileId, native)
+    const replacement = await reopened.ensureUploaded(VERSION, native, POLICY)
+    expect(replacement.record.fileId).not.toBe(first.record.fileId)
+    expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(chat.record)
+    expect(uploads).toBe(3)
+    now = replacement.record.expiresAt - POLICY.refreshMarginSeconds * 1_000
     const refreshed = await reopened.ensureUploaded(VERSION, native, POLICY)
-    expect(refreshed.record.fileId).not.toBe(first.record.fileId)
+    expect(refreshed.record.fileId).not.toBe(replacement.record.fileId)
     expect(refreshed.record.expiresAt).toBe(now + POLICY.expiresAfterSeconds * 1_000)
-    expect(uploads).toBe(3)
+    expect(uploads).toBe(4)
   })
 
   it('reclaims the oldest owned native file across descending pages before retrying an upload', async () => {
@@ -287,12 +294,12 @@ describe('DeepSeekFileStore', () => {
     await expect(retried).resolves.toMatchObject({ record: { fileId: 'file-api-retry' } })
   })
 
-  it('rejects a request version above the chat per-image limit before transport', async () => {
+  it.each(['chat-completions', 'messages'] as const)('rejects a request version above the %s per-image limit before transport', async (protocol) => {
     const fetchImpl = vi.fn() as typeof fetch
     const store = new DeepSeekFileStore({ now: () => NOW, fetch: fetchImpl })
-    const oversized = { ...VERSION, bytes: MAX_CHAT_IMAGE_BYTES + 1 }
-    await expect(store.ensureUploaded(oversized, CONNECTION, POLICY))
-      .rejects.toMatchObject({ code: 'INVALID_REQUEST' })
+    const oversized = { ...VERSION, bytes: MAX_IMAGE_BYTES + 1 }
+    await expect(store.ensureUploaded(oversized, { ...CONNECTION, protocol }, POLICY))
+      .rejects.toMatchObject({ code: 'INVALID_REQUEST', message: 'DeepSeek image exceeds the 32 MiB per-image limit.' })
     expect(fetchImpl).not.toHaveBeenCalled()
   })
 

+ 50 - 0
packages/llm/llm-deepseek/tests/files-api.spec.ts

@@ -1,3 +1,5 @@
+import { once } from 'node:events'
+import { createServer } from 'node:http'
 import { describe, expect, it, vi } from 'vitest'
 import { userAgent } from '@deepseek-ai/dsh-llm'
 import { DeepSeekFileId } from '../src/common/file-id.ts'
@@ -36,6 +38,7 @@ describe('DeepSeekFilesClient', () => {
       expect(requestUrl(url)).toBe('https://gateway.example/custom/route/v1/files')
       const headers = new Headers(init?.headers)
       expect(headers.get('x-api-key')).toBe('key')
+      expect(headers.get('anthropic-version')).toBe('2023-06-01')
       expect(headers.get('anthropic-beta')).toBe('files-api-2025-04-14')
       expect(headers.has('authorization')).toBe(false)
       const form = init?.body as FormData
@@ -66,6 +69,53 @@ describe('DeepSeekFilesClient', () => {
     await expect(client.delete(DeepSeekFileId('file-api-one'))).resolves.toBeUndefined()
   })
 
+  it('accepts an empty Messages list with null cursors', async () => {
+    const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example', apiKey: 'key',
+      fetch: async () => new Response(JSON.stringify({ data: [], first_id: null, last_id: null, has_more: false })),
+    })
+    await expect(client.list()).resolves.toEqual({ data: [], hasMore: false })
+  })
+
+  it.each(['first_id', 'last_id'])('rejects a Messages list with a numeric %s', async (cursor) => {
+    const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example', apiKey: 'key',
+      fetch: async () => new Response(JSON.stringify({ data: [], first_id: null, last_id: null, has_more: false, [cursor]: 1 })),
+    })
+    await expect(client.list()).rejects.toMatchObject({ code: 'INVALID_RESPONSE' })
+  })
+
+  it.for(['messages', 'chat-completions'] as const)('refuses a redirected %s Files request before contacting another origin', async (protocol, { onTestFinished }) => {
+    const forwarded: string[] = []
+    const origins: string[] = []
+    const destination = createServer((request, response) => {
+      forwarded.push(String(request.headers['x-api-key'] ?? request.headers.authorization))
+      response.end(JSON.stringify(protocol === 'messages' ? messagesFile() : file()))
+    })
+    const source = createServer((_request, response) => {
+      response.writeHead(307, { location: `${origins[0]}/file-api-one` })
+      response.end()
+    })
+    for (const server of [destination, source]) {
+      server.listen(0, '127.0.0.1')
+      onTestFinished(async () => {
+        server.closeAllConnections()
+        await new Promise<void>((resolve, reject) => {
+          server.close((error) => {
+            if (error) reject(error)
+            else resolve()
+          })
+        })
+      })
+      await once(server, 'listening')
+      const address = server.address()
+      if (address === null || typeof address === 'string') throw new Error('expected a TCP server address')
+      origins.push(`http://127.0.0.1:${address.port}`)
+    }
+    const client = new DeepSeekFilesClient({ protocol, baseURL: origins[1]!, apiKey: 'redirect-test-key' })
+    const error = await client.retrieve(DeepSeekFileId('file-api-one')).catch((cause: unknown) => cause)
+    expect(forwarded).toEqual([])
+    expect(error).toMatchObject({ code: 'TRANSPORT' })
+  })
+
   it.each([null, [], { type: 'wrong' }, { mime_type: null }, { created_at: 'invalid' }, { created_at: 123 }, { size_bytes: -1 }])('rejects malformed Messages file metadata %#', async (value) => {
     const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example', apiKey: 'key',
       fetch: async () => new Response(JSON.stringify(value === null || Array.isArray(value) ? value : messagesFile(value))),

+ 15 - 1
packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts

@@ -60,7 +60,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', ()
     await reply('PROMPT_CLEARED')
   })
 
-  it('uploads and reuses a Files image across Messages requests', async () => {
+  it('uploads, lists, retrieves, and reuses a Files image across Messages requests', async () => {
     const ctx = await boot()
     await ctx.plugin(LocalAttachments)
     const fetchImpl = globalThis.fetch
@@ -96,6 +96,20 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', ()
     expect(uploads).toHaveLength(1)
     expect(bodies).toHaveLength(2)
     expect(bodies.every(body => body.includes(`"file_id":"${uploads[0]}"`) && !body.includes('"type":"base64"'))).toBe(true)
+    const fileId = Messages.DeepSeekFileId(uploads[0]!)
+    const retrieved = await files.retrieve(fileId)
+    expect(retrieved).toMatchObject({ id: fileId, bytes: attachment.bytes, purpose: 'user_data' })
+    expect(retrieved.expiresAt).toBeUndefined()
+    let page = await files.list({ limit: 1_000 })
+    const cursors = new Set<string>()
+    while (!page.data.some(file => file.id === fileId) && page.hasMore) {
+      expect(page.lastId).toBeDefined()
+      const after = page.lastId!
+      expect(cursors.has(after)).toBe(false)
+      cursors.add(after)
+      page = await files.list({ after, limit: 1_000 })
+    }
+    expect(page.data).toContainEqual(retrieved)
   })
 
   it.each(['off', 'low', 'high', 'max'])('streams text with %s effort', async (effort) => {

+ 24 - 0
packages/llm/llm-deepseek/tests/messages/adapter.spec.ts

@@ -96,6 +96,30 @@ describe('direct Messages HTTP', () => {
     await expect(chunks(adapter({ baseURL: http.url }).stream(options()))).rejects.toMatchObject({ code: 'RATE_LIMIT', failure: { status: 429, providerRetryAfterMs: 3000 } })
   })
 
+  it('refuses redirects before credentials reach another origin or request extensions are accepted', async () => {
+    const destination = await endpoint()
+    const source = await endpoint((response) => {
+      response.writeHead(307, { location: `${destination.url}/v1/messages` })
+      response.end()
+    })
+    const accept = vi.fn(async () => {})
+    const prepare = vi.fn(async () => ({ fields: {}, accept }))
+    const files = new DeepSeekFileStore()
+    const llm = new DeepSeekMessagesAdapter({
+      connection: () => Messages.resolveAdapterOptions({ protocol: 'messages', baseURL: source.url }),
+      apiKey: () => Promise.resolve('test-key'), userId: () => 'test-user',
+      attachments: () => undefined, imageAccess: () => undefined, files: () => files,
+      prepareExtensions: prepare,
+    })
+    const error = await chunks(llm.stream(options())).catch((cause: unknown) => cause)
+    expect(source.requests).toHaveLength(1)
+    expect(source.requests[0]?.headers['x-api-key']).toBe('test-key')
+    expect(destination.requests).toEqual([])
+    expect(error).toMatchObject({ code: 'TRANSPORT' })
+    expect(prepare).toHaveBeenCalledOnce()
+    expect(accept).not.toHaveBeenCalled()
+  })
+
   it('freezes endpoint and defaults for a prepared call while the next call sees new settings', async () => {
     const first = await endpoint(), second = await endpoint()
     let config = Messages.resolveAdapterOptions({ protocol: 'messages', baseURL: first.url, maxTokens: 10, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] })

+ 3 - 5
packages/web/web-search-deepseek/src/index.ts

@@ -1,7 +1,7 @@
 /**
  * Register a DeepSeek-backed provider in `ctx.web`. It calls the Anthropic-compatible Messages API
  * with native `web_search_20250305`. The provider reuses `DEEPSEEK_API_KEY` but not
- * `DEEPSEEK_BASE_URL`, because search and chat-completions use different bases.
+ * `DEEPSEEK_BASE_URL`; auxiliary search has its own endpoint configuration.
  * @module @deepseek-ai/dsh-web-search-deepseek
  */
 
@@ -74,10 +74,8 @@ export const Config: z<Config> = z.object({
 })
 
 /**
- * Environment variable naming this provider's endpoint. Deliberately distinct
- * from `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions adapter:
- * search speaks the Anthropic-compatible Messages API, so one variable cannot
- * serve both.
+ * Auxiliary-search endpoint, independent of the conversation adapter's
+ * `$DEEPSEEK_BASE_URL` and selected protocol.
  */
 const SEARCH_BASE_URL_ENV = 'DEEPSEEK_SEARCH_BASE_URL'
 

+ 3 - 4
packages/web/web-search-deepseek/src/provider.ts

@@ -27,10 +27,9 @@ import type {
 export const DEEPSEEK_PROVIDER_ID = 'deepseek-official'
 
 /**
- * Default endpoint: DeepSeek's Anthropic-compatible API, `/v1` included
- * (`/messages` is appended). This is NOT the chat-completions base
- * (`https://api.deepseek.com`) `@deepseek-ai/dsh-llm-deepseek` uses, so this
- * provider does NOT reuse `$DEEPSEEK_BASE_URL` — only the API key is shared.
+ * Default auxiliary-search endpoint, including `/v1`; `/messages` is appended.
+ * `$DEEPSEEK_SEARCH_BASE_URL` overrides it independently of the conversation
+ * adapter's endpoint. Both providers share the API key.
  */
 export const DEEPSEEK_DEFAULT_BASE_URL = 'https://api.deepseek.com/anthropic/v1'