Explorar o código

fix(llm): resolve Messages endpoint suffix (#4241)

* fix(llm): resolve Messages endpoint suffix

* test(subagent): update Messages endpoint expectation

* fix(llm): preserve official Messages routes

* fix(llm): harden Messages endpoint resolution

* refactor(llm): clarify Messages API ownership

* fix(llm): recognize only exact Messages v1 roots
Magolor hai 3 semanas
pai
achega
bd421cce7b

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md
+2026-09-15-messages-v1-base-url.md: 97f7294c28988e6f115963033cdac6794d70d6f3
+2026-09-15-messages-v1-base-url.zh.md: ccb8c5930dbbfa6274770cc03087b4d8077fe27b

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md

@@ -0,0 +1,25 @@
+# Agent Note: Exact v1 recognition for Messages base URLs
+
+Status: implemented
+
+English | [中文](2026-09-15-messages-v1-base-url.zh.md)
+
+## Problem
+
+The Messages transport appends the Anthropic-standard `/v1` namespace to a configured base URL. A base that already ends in `/v1` previously produced `/v1/v1/messages`, while recognizing every `v`-plus-digit suffix as a provider version granted undocumented compatibility and could bypass the standard namespace.
+
+## Decision
+
+The shared Messages API owner trims trailing slashes and treats only a final path segment exactly equal to `v1` as the existing API version. It preserves that root and appends `/messages` or `/files`; every other base receives `/v1/messages` or `/v1/files`. The same resolved root scopes cached file uploads. The official `https://api.deepseek.com/anthropic` base therefore resolves to `/anthropic/v1`, and an explicit `/anthropic/v1` base remains unchanged.
+
+Chat Completions retains its independent URL behavior. The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them.
+
+## Alternatives considered
+
+**Recognize any final segment beginning with `v` and a digit.** This avoids repetition for more custom endpoints, but it turns a narrow duplicate-`v1` repair into an undocumented compatibility policy and can route requests outside the Anthropic-standard namespace.
+
+**Always append `/v1`.** This follows the standard path for unversioned roots but preserves the original duplicate path for callers whose configured base already ends in `/v1`.
+
+## Consequences
+
+Messages, Files, and file-cache identity use one deterministic rule. Exact `/v1` configurations remain compatible without changing the recommended unversioned base. Other version-like suffixes are not treated as API versions and therefore resolve beneath an added `/v1`; this deliberately gives up speculative proxy compatibility.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Messages 基址严格识别 v1
+
+Status: implemented
+
+[English](2026-09-15-messages-v1-base-url.md) | 中文
+
+## 问题
+
+Messages 传输会在配置的基址后追加 Anthropic 标准 `/v1` 命名空间。此前,已经以 `/v1` 结尾的基址会生成 `/v1/v1/messages`;而把所有 `v` 加数字的后缀都识别为提供方版本,会提供未经说明的兼容性,并可能绕过标准命名空间。
+
+## 决策
+
+共享 Messages API 所有者移除末尾斜线,仅把严格等于 `v1` 的最后路径段视为已有 API 版本。它保留该根地址并追加 `/messages` 或 `/files`;其他基址均追加 `/v1/messages` 或 `/v1/files`。缓存文件上传也使用同一个解析后的根地址划分作用域。因此,官方 `https://api.deepseek.com/anthropic` 基址解析为 `/anthropic/v1`,显式 `/anthropic/v1` 基址保持不变。
+
+Chat Completions 保留独立的 URL 行为。Messages 规则不会推断对 `v1beta`、`v2`、`v4` 或其他版本式后缀的支持;包含这些路径段的部署会在其下获得标准 `/v1` 命名空间。
+
+## 考虑过的替代方案
+
+**识别所有以 `v` 加数字开头的末尾路径段。** 这可以避免更多自定义端点重复版本,但会把范围有限的 `v1` 重复修复变成未经说明的兼容策略,并可能把请求路由到 Anthropic 标准命名空间之外。
+
+**始终追加 `/v1`。** 这对无版本根地址遵循标准路径,但仍会为已以 `/v1` 结尾的配置产生原有重复路径。
+
+## 结果
+
+Messages、Files 与文件缓存标识使用同一条确定性规则。严格匹配 `/v1` 的配置保持兼容,推荐的无版本基址无需改变。其他版本式后缀不被视为 API 版本,因此会在其下追加 `/v1`;这会有意放弃对代理的推测性兼容。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.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-09-07-deepseek-messages-adapter.md
-2026-09-07-deepseek-messages-adapter.md: 9a92f9e95931bebfa9fa6e7e64fbd7d27ec8306c
-2026-09-07-deepseek-messages-adapter.zh.md: 67f6c97c0a416cbddb7ec9d0de01e47e658036b7
+2026-09-07-deepseek-messages-adapter.md: 6b7aff250aacd4bb7d0af3b4e68ee5b2002c13e3
+2026-09-07-deepseek-messages-adapter.zh.md: 829e2189cb48a2acaa1ec27f7ccade0f163b58ad

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md

@@ -16,11 +16,11 @@ The adapter follows the [DeepSeek compatibility documentation](https://api-docs.
 
 Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; content validation such as tool argument parsing still fails explicitly. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning.
 
-Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages uses `/v1/files` with its required beta header, while Chat Completions uses `/files`. Cached ids remain scoped by configured endpoint and credential. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement.
+Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md), while Chat Completions appends `/files`. Messages Files requests carry the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned Messages roots share uploads. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement.
 
 System updates use the existing [route capability](2026-09-02-in-history-system-prompt-replacement.md) when explicitly declared for an endpoint/model. Messages retains the initial top-level system and emits later snapshots as native system turns after the corresponding user/tool-result turn, preserving previously sent prefixes. This placement differs from the loop's system-before-user admission; serialization changes neither the durable log nor conversation-turn order. Undeclared routes consolidate the latest snapshot at the top level, including direct compaction calls. Capability inference from protocol or model names is insufficient because support and update semantics depend on the deployed endpoint.
 
-Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default; a custom `baseURL` or environment override is never rewritten to match a protocol.
+Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. Messages follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default and appends `/chat/completions` without adding a version segment.
 
 Both transports use the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accept captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md

@@ -16,11 +16,11 @@ Status: implemented
 
 助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;工具参数等内容校验仍会正常报错。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。
 
-两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 使用 `/v1/files` 并携带必需的 beta 标头,Chat Completions 使用 `/files`。缓存 id 仍按配置的端点和凭据限定作用域。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。
+两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 遵循[严格匹配 `/v1` 的根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md),Chat Completions 则追加 `/files`。Messages Files 请求携带必需的 beta 标头。缓存 id 按解析后的 Files 根地址和凭据限定作用域,因此等价的 `/v1` 与无版本 Messages 根地址可以复用上传。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。
 
 系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](2026-09-02-in-history-system-prompt-replacement.zh.md)。Messages 保留初始顶层 system,在对应的用户或工具结果轮次之后,将后续快照发送为原生 system 轮次,保留此前发送的前缀。这个位置不同于循环先 system、后 user 的接纳顺序;序列化既不改写持久化日志,也不改变对话轮次的顺序。未声明能力的路由将最新快照归并到顶层,直接压缩调用也如此。仅凭协议或模型名称推断能力并不充分,因为支持情况和更新语义取决于实际部署的端点。
 
-Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值;不会为匹配协议而改写自定义 `baseURL` 或环境覆盖。
+Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。Messages 遵循严格匹配 `/v1` 的根地址规则,不根据其他版本式后缀推断兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值,追加 `/chat/completions` 而不增加版本段。
 
 两种传输都在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。
 

+ 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: 48df51396e60f071503e5babd721bca0dcd18249
-README.zh.md: 9fe86f7ea6b93de847247d4a8f8434930d7d664e
+README.md: 6121a19003a628204bfb5f8a3d6b5d969bd19104
+README.zh.md: 69e5c103e0edd8d16f606ed11a7aaf602ff5944e

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

@@ -83,7 +83,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 inherit this default. 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.
+`protocol` defaults to `messages`, with official root `https://api.deepseek.com/anthropic`; `chat-completions` uses `https://api.deepseek.com`. Shipped first-party compositions inherit this default. 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 and its Files API treat only an exact final `/v1` path segment as the existing Anthropic API version and append `/messages` or `/files`; every other base receives `/v1/messages` or `/v1/files`. The official Messages root therefore retains its recommended `/anthropic/v1` request paths without granting compatibility to arbitrary version-like suffixes. Trailing slashes do not change these results. Both protocols 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.
 

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

@@ -83,7 +83,7 @@ kind: "package-reference"
     protocol: chat-completions
 ```
 
-`protocol` 默认为 `messages`,官方根地址为 `https://api.deepseek.com/anthropic`;`chat-completions` 使用 `https://api.deepseek.com`。随产品交付的官方组合继承该默认值。两种协议都不要求填写 `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`,因此已保存的模型选择仍然有效。
+`protocol` 默认为 `messages`,官方根地址为 `https://api.deepseek.com/anthropic`;`chat-completions` 使用 `https://api.deepseek.com`。随产品交付的官方组合继承该默认值。两种协议都不要求填写 `baseURL`:当 `baseURL` 与 `$DEEPSEEK_BASE_URL` 均未设置时使用当前协议的官方默认值。切换协议保留已有端点覆盖,用户需要填写与选定协议兼容的地址。显式填写的 `https://api.deepseek.com` 是 Chat 根地址:删除该覆盖即可使用官方 Messages 默认值,也可以改填 `https://api.deepseek.com/anthropic`。Chat 追加 `/chat/completions`。Messages 与其 Files API 仅把末尾严格匹配的 `/v1` 路径段视为已有 Anthropic API 版本,并追加 `/messages` 或 `/files`;其他基址均追加 `/v1/messages` 或 `/v1/files`。因此,官方 Messages 根地址仍使用推荐的 `/anthropic/v1` 请求路径,同时不为任意版本式后缀提供兼容性。末尾斜线不改变这些结果。两种协议共用 `llm-deepseek` 设置、`apiKeyEnv` 与 `deepseek-official`,因此已保存的模型选择仍然有效。
 
 Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据记录 Messages 格式、模型和签名;Chat 请求只序列化持久化内容,不发送这些签名。无效的 Messages 回放元数据产生警告并省略签名,不丢弃文本或工具历史。
 

+ 5 - 2
packages/llm/llm-deepseek/src/common/file-store.ts

@@ -4,6 +4,7 @@ import type { RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
 import { LlmError } from '@deepseek-ai/dsh-llm'
 import { DeepSeekFilesClient, isFilesQuotaError } from './files-api.ts'
 import type { DeepSeekFileId } from './file-id.ts'
+import { messagesApiRoot } from './messages-api.ts'
 import { deepSeekFileScope, DeepSeekUploadIndex } from './upload-index.ts'
 import type { DeepSeekUploadRecord } from './upload-index.ts'
 import type { DeepSeekProtocol } from './types.ts'
@@ -48,8 +49,10 @@ interface SharedUpload {
 
 /** The Files resource's parent URL distinguishes custom protocol namespaces. */
 function fileScope(connection: DeepSeekFileConnection) {
-  const root = connection.baseURL.replace(/\/+$/u, '')
-  return deepSeekFileScope(connection.protocol === 'messages' ? `${root}/v1` : root, connection.apiKey)
+  return deepSeekFileScope(
+    connection.protocol === 'messages' ? messagesApiRoot(connection.baseURL) : connection.baseURL,
+    connection.apiKey,
+  )
 }
 
 function abortReason(signal: AbortSignal): Error {

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

@@ -4,11 +4,9 @@ import { attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm'
 import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
 import { DeepSeekFileId } from './file-id.ts'
 import type { DeepSeekFileId as DeepSeekFileIdType } from './file-id.ts'
+import { messagesApiRoot, MESSAGES_FILES_BETA } from './messages-api.ts'
 import type { DeepSeekProtocol } from './types.ts'
 
-/** Required opt-in for Messages file operations and file-referenced image requests. */
-export const MESSAGES_FILES_BETA = 'files-api-2025-04-14'
-
 /** Minimum provider-supported file lifetime. */
 export const MIN_FILE_EXPIRY_SECONDS = 3_600
 /** Maximum provider-supported file lifetime. */
@@ -155,11 +153,13 @@ export class DeepSeekFilesClient {
    * @param options - endpoint, API-key snapshot, and optional test transport.
    */
   constructor(options: FilesApiOptions) {
-    this.baseURL = options.baseURL.replace(/\/+$/u, '')
     this.apiKey = options.apiKey
     this.fetchImpl = options.fetch ?? globalThis.fetch
     this.protocol = options.protocol
-    this.path = this.protocol === 'messages' ? '/v1/files' : '/files'
+    this.baseURL = this.protocol === 'messages'
+      ? messagesApiRoot(options.baseURL)
+      : options.baseURL.replace(/\/+$/u, '')
+    this.path = '/files'
   }
 
   private parseFile(value: unknown, operation: string): DeepSeekFileObject {

+ 14 - 0
packages/llm/llm-deepseek/src/common/messages-api.ts

@@ -0,0 +1,14 @@
+/** Shared DeepSeek Messages API endpoint and header policy. @module dsh-llm-deepseek/messages-api */
+
+/** Required opt-in for Messages file operations and file-referenced image requests. */
+export const MESSAGES_FILES_BETA = 'files-api-2025-04-14'
+
+/**
+ * Resolve the API root without duplicating an explicit provider version path.
+ * @param baseURL - validated configured endpoint root.
+ * @returns the root beneath which Messages resources are exposed.
+ */
+export function messagesApiRoot(baseURL: string): string {
+  const base = baseURL.replace(/\/+$/u, '')
+  return new URL(base).pathname.endsWith('/v1') ? base : `${base}/v1`
+}

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

@@ -8,7 +8,7 @@ import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import { catalogModelInfo, modelInfo } from '../../common/model-info.ts'
 import type { DeepSeekAdapterOptions, DeepSeekConnectionOptions as Connection } from '../../common/types.ts'
 import type { DeepSeekFileStore } from '../../common/file-store.ts'
-import { MESSAGES_FILES_BETA } from '../../common/files-api.ts'
+import { MESSAGES_FILES_BETA, messagesApiRoot } from '../../common/messages-api.ts'
 import { FileResolutionFailure, RequestFiles } from '../../common/request-files.ts'
 import { prepareRequestExtensions } from '../../common/request-extensions.ts'
 import { imagePricing, inlineImages, prepareFileIds, prepareImages } from './images.ts'
@@ -119,7 +119,7 @@ export class DeepSeekMessagesAdapter extends LlmAdapter {
         ...options.purpose === undefined ? {} : { purpose: options.purpose },
       }, this.dependencies.prepareExtensions)
       signal.throwIfAborted()
-      const response = await fetch(`${connection.baseURL.replace(/\/+$/u, '')}/v1/messages`, {
+      const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, {
         method: 'POST', signal, body: extensions.payload, redirect: 'error',
         headers: {
           ...attributionHeaders(),

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

@@ -18,7 +18,7 @@ export interface WireMessage {
   content: WireBlock[]
 }
 
-/** JSON body submitted to /v1/messages. */
+/** JSON body submitted to the resolved Messages endpoint. */
 export interface WireRequest {
   model: string
   stream: true

+ 1 - 0
packages/llm/llm-deepseek/tests/file-store.spec.ts

@@ -88,6 +88,7 @@ describe('DeepSeekFileStore', () => {
     const first = await store.ensureUploaded(VERSION, native, POLICY)
     expect(first.record.scope).toBe(deepSeekFileScope(`${CONNECTION.baseURL}/v1`, CONNECTION.apiKey))
     expect(first.record.scope).not.toBe(chat.record.scope)
+    expect((await store.ensureUploaded(VERSION, { ...native, baseURL: `${native.baseURL}/v1/` }, POLICY)).record).toEqual(first.record)
     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)

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

@@ -53,6 +53,21 @@ describe('DeepSeekFilesClient', () => {
     expect(uploaded).toEqual({ id: 'file-api-one', bytes: 3, createdAt, filename: 'image.png', purpose: 'user_data', expiresAt: createdAt + 3_600 })
   })
 
+  it.each([
+    ['https://provider.example/anthropic/v1', 'https://provider.example/anthropic/v1/files'],
+    ['https://provider.example/v1beta/', 'https://provider.example/v1beta/v1/files'],
+  ])('resolves the Messages Files endpoint from %s', async (baseURL, expected) => {
+    const fetchImpl = vi.fn<typeof fetch>(async () => new Response(JSON.stringify(messagesFile())))
+    const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL, apiKey: 'key', fetch: fetchImpl })
+
+    await client.upload({
+      data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600,
+    })
+
+    expect(fetchImpl).toHaveBeenCalledOnce()
+    expect(requestUrl(fetchImpl.mock.calls[0]![0])).toBe(expected)
+  })
+
   it('maps Messages list cursors, file metadata and deletion without OpenAI-only fields', async () => {
     const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
       const url = new URL(requestUrl(input))

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

@@ -18,7 +18,8 @@ import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek'
 import * as Messages from '../../src/index.ts'
-import { DeepSeekFilesClient, MESSAGES_FILES_BETA } from '../../src/common/files-api.ts'
+import { DeepSeekFilesClient } from '../../src/common/files-api.ts'
+import { MESSAGES_FILES_BETA } from '../../src/common/messages-api.ts'
 import { assemble, options, user } from './helpers.ts'
 
 const IN_HISTORY_MODEL = process.env.DEEPSEEK_IN_HISTORY_MODEL

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

@@ -91,6 +91,24 @@ describe('direct Messages HTTP', () => {
     expect(llm.imageRequestPricing('deepseek-official', MODEL)).toBeDefined()
   })
 
+  it.each([
+    ['https://provider.example', 'https://provider.example/v1/messages'],
+    ['https://provider.example/v1/', 'https://provider.example/v1/messages'],
+    ['https://provider.example/v1beta', 'https://provider.example/v1beta/v1/messages'],
+    ['https://provider.example/v2', 'https://provider.example/v2/v1/messages'],
+    ['https://provider.example/anthropic', 'https://provider.example/anthropic/v1/messages'],
+    ['https://v1.provider.example', 'https://v1.provider.example/v1/messages'],
+  ])('resolves the Messages endpoint from %s', async (baseURL, expected) => {
+    const fetchImpl = vi.fn<typeof fetch>(async () => new Response(sse(textEvents), {
+      headers: { 'content-type': 'text/event-stream' },
+    }))
+    vi.stubGlobal('fetch', fetchImpl)
+
+    await chunks(adapter({ baseURL }).stream(options()))
+
+    expect(fetchImpl.mock.calls[0]?.[0]).toBe(expected)
+  })
+
   it.each([true, false])('maps non-2xx responses (JSON=%s)', async (json) => {
     const http = await endpoint((response) => { response.statusCode = 429; response.setHeader('retry-after', '3'); response.end(json ? JSON.stringify({ error: { type: 'rate_limit_error', message: 'slow down' } }) : '<html>busy</html>') })
     await expect(chunks(adapter({ baseURL: http.url }).stream(options()))).rejects.toMatchObject({ code: 'RATE_LIMIT', failure: { status: 429, providerRetryAfterMs: 3000 } })