Merge pull request #4629 from deepseek-harness/worktree/dynamic-conversation/messages-only

Make the official DeepSeek adapter Messages-only
This commit is contained in:
Tianyi Cui authored and GitHub committed 2026-09-20 17:10:58 +08:00
commit 3f33c06f40
145 files changed
+4856 -7776

No files matched your search

@@ -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-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
2026-09-15-messages-v1-base-url.md: d9014756eacd1752495bfecb52860634ece08ae1
2026-09-15-messages-v1-base-url.zh.md: 8f5803e4bdc68195164e187d1aa8cd537f71c5d6
@@ -12,7 +12,7 @@ The Messages transport appends the Anthropic-standard `/v1` namespace to a confi
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.
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
@@ -12,7 +12,7 @@ Messages 传输会在配置的基址后追加 Anthropic 标准 `/v1` 命名空
共享 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` 命名空间。
Messages 规则不推断 `v1beta`、`v2`、`v4` 或其他类似版本后缀的支持;包含这些路径段的部署会在其下接收标准 `/v1` 命名空间。
## 考虑过的替代方案
@@ -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-09-16-messages-historical-tool-input.md
2026-09-16-messages-historical-tool-input.md: 201b0f21ad041395a4c4101d6919b2878e22c9ab
2026-09-16-messages-historical-tool-input.zh.md: bcc637982420124b401ef65c668b068394b6d915
2026-09-16-messages-historical-tool-input.md: 85e4047bb5274eb7c7adb458d564e1268d4ea81e
2026-09-16-messages-historical-tool-input.zh.md: 5a8c31dbee32094f124102ccac9ffb7b8b81b177
@@ -10,7 +10,7 @@ Chat Completions retains tool arguments as strings, including malformed JSON fro
## Decision
The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again.
The [Messages serializer](../../../../packages/llm/llm-deepseek/src/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again.
This supersedes the historical argument rejection in the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md). New Messages responses still require valid object arguments before successful completion; output-limit truncation retains its existing pruning behavior. No Session event, persistence type, or protocol configuration changes.
@@ -10,7 +10,7 @@ Chat Completions 将工具参数保留为字符串,其中可能包含失败调
## 决策
[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。
[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。
这取代了 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的历史参数拒绝规则。新生成的 Messages 响应在成功完成前仍要求工具参数是有效对象;达到输出上限时仍按现有规则裁剪。不改变 Session 事件、持久化类型或协议配置。
@@ -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-09-18-messages-input-history-compatibility.md
2026-09-18-messages-input-history-compatibility.md: 1775276deec6180f1fd186b11cb7a757a3328a54
2026-09-18-messages-input-history-compatibility.zh.md: 50367e33c29df0679a44aca1fe680d2bedc8c263
2026-09-18-messages-input-history-compatibility.md: dd974fb1ae883716002a1381f4c374dee792b3b3
2026-09-18-messages-input-history-compatibility.zh.md: 6e850328db63d5ae5cb508aa3529e9f2507182b0
@@ -10,7 +10,7 @@ Saved subagent settlement notices can contain child reasoning and tool calls in
## Decision
The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks.
The [Messages serializer](../../../../packages/llm/llm-deepseek/src/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks.
This partially supersedes the settlement decision's rejection of serializer tolerance and the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md)'s input rejection. Notice construction still projects nonempty child text for every parent provider. Canonical child output and ordinary assistant reasoning and tool calls remain available to their existing consumers. The omission is based on input role and block type, not the notice source or creation date, so it also applies to newly supplied user and tool-result content.
@@ -10,7 +10,7 @@ Status: implemented
## 决策
[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。
[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。
这部分取代了结算决策对序列化器容错的拒绝,以及 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的输入拒绝规则。通知构建仍为每种父级提供方投影子级的非空文本。子级的规范输出以及普通助手推理和工具调用仍可供现有消费者使用。省略依据是输入角色和块类型,而不是通知来源或创建日期,因此也适用于新提供的用户和工具结果内容。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
2026-08-20-unified-image-request-pipeline.md: 7431a474b55a9dae82b1c9cd36b770b31fe39602
2026-08-20-unified-image-request-pipeline.zh.md: 247a57b8017f513c552204d951ef6931f7f9d045
2026-08-20-unified-image-request-pipeline.md: b79eeb7d908d664362851c28bda64dab163aac89
2026-08-20-unified-image-request-pipeline.zh.md: 444234715256c48167b0b45dbd8843a9c80079f7
@@ -34,7 +34,7 @@ Every retained request image is preceded by its display name or complete attachm
### DeepSeek Files lifecycle
The direct `deepseek-official` adapter normally uploads every retained request version through its selected protocol’s Files API and sends file-id references. [Messages](2026-09-07-deepseek-messages-adapter.md) and Chat Completions share the lifecycle while retaining their own endpoint and wire formats. A [bounded inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by Files endpoint and API-key scope plus `variantId`. Uploads request seven days by default. Chat records the returned `expires_at`; Messages metadata omits expiry, so its local reuse deadline uses the original upload time plus the configured lifetime without promising remote deletion. A mapping with no more than one hour of reuse remaining is replaced without a preceding retrieve call. The index never stores the API key.
The direct `deepseek-official` adapter uploads every retained request version through the [Messages Files API](2026-09-07-deepseek-messages-adapter.md) and sends file-id references. A [bounded inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. Uploaded ids are indexed by Files endpoint and API-key scope plus `variantId`. Uploads request seven days by default. Messages metadata omits expiry, so local reuse uses the original upload time plus the configured lifetime without promising remote deletion. A mapping with no more than one hour of reuse remaining is replaced without a preceding retrieve call. The index never stores the API key.
An upload is indexed only after the response returns a valid native file object with a matching byte count and the client establishes the protocol’s reuse deadline. A missing or inconsistent response leaves no local mapping, so a later request uploads again. Concurrent upload resolution for one scoped `variantId` shares one provider operation; one waiter cannot cancel another, and the upload stops when every waiter has cancelled. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If the model endpoint reports expired, deleted, missing, or invalid ids and names one or more ids used by the request, only those mappings are removed. A stale-file response without a specific id removes every mapping used by that model request. The affected request bytes are uploaded again and the model request is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third model request. One upload quota error first lists the configured number of oldest harness-owned `dsh-` files, then deletes that collected set and retries once; deleting after pagination keeps provider cursors valid. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. Every Files request carries the shared Harness `User-Agent`. The client enforces a 128MiB upload limit, a 32MiB request-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range.
@@ -34,7 +34,7 @@ Status: implemented
### DeepSeek Files 生命周期
直接 `deepseek-official` 适配器通常通过所选协议的 Files API 上传每张保留的请求版本,并发送 file-id 引用。[Messages](2026-09-07-deepseek-messages-adapter.zh.md) 与 Chat Completions 共享生命周期,各自保留端点和协议格式。文件解析失败时,[有界内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)会发送相同的确定性请求版本。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按 Files 端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期。Chat 记录返回的 `expires_at`;Messages 元数据不含过期时间,因此本地复用期限使用原始上传时间加配置的生存期,但不保证远端文件删除。本地映射剩余复用时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。
直连 `deepseek-official` 适配器通过 [Messages Files API](2026-09-07-deepseek-messages-adapter.zh.md) 上传每个保留的请求版本,并发送 file-id 引用。文件解析失败时,[有界内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)发送相同的确定性请求版本。上传 id 按 Files 端点、API-key 作用域及 `variantId` 建立索引。上传默认请求七天有效期。Messages 元数据不含过期时间,因此本地复用使用原始上传时间加配置的有效期,不承诺远端删除。剩余复用时间不超过一小时的映射会直接替换,不先调用 retrieve。索引从不存储 API 密钥。
只有上传响应返回有效的原生文件对象、字节数匹配,且客户端确定了该协议的复用期限时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。同一作用域和 `variantId` 的并发解析共享一次提供方上传;单个等待方无法取消其他等待方,全部等待方取消时才会停止上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果模型端点报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次模型请求使用的全部映射。受影响的请求字节会重新上传,模型请求只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次模型请求。一次上传配额错误会先列出配置数量的最旧 `dsh-` 文件,再删除收集到的文件并重试一次;分页完成后才删除,避免游标失效。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。每个 Files 请求都携带 Harness 的共享 `User-Agent`。客户端执行 Files 单次上传 128MiB、请求单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。
@@ -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: b79c2526461843a9534d6a997029281a584e0bed
2026-09-07-deepseek-messages-adapter.zh.md: 49a967828e22803296ae3d4c8eef943b9ec8e3c5
2026-09-07-deepseek-messages-adapter.md: 29f712e57c8048ddb0c05b5f56fd4793c52bec5f
2026-09-07-deepseek-messages-adapter.zh.md: 3cf60fc2101fb835423b74a6532e5edcc5bee911
@@ -6,29 +6,29 @@ English | [中文](2026-09-07-deepseek-messages-adapter.zh.md)
## Problem
Deployments expose DeepSeek through Anthropic Messages gateways as well as chat-completions. Messages represents thinking, signatures, tool calls, tool results, and cumulative usage differently. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns.
Messages represents thinking, signatures, tool calls, tool results, and cumulative usage as native protocol fields. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns.
## Decision
The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves multiple protocols under one `deepseek-official` route and `llm-deepseek` settings namespace. `common/` shares configuration, the model catalog, capability resolution, and Files lifecycle; `protocols/chat-completions/` and `protocols/messages/` own serialization, stream conversion, and transport. Cordis YAML selects the implementation through `protocol`, defaulting to `messages`; shipped first-party compositions inherit that default. The existing `PreparedAdapterCall` freezes protocol, endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration.
The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves Messages under one `deepseek-official` route and `llm-deepseek` settings namespace. The [single-protocol decision](../simplification/2026-09-19-deepseek-messages-only.md) owns the transport scope. `PreparedAdapterCall` freezes the endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration.
The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. Both protocols forward explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration.
The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. The adapter forwards explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration.
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; historical tool arguments use the [empty-input fallback](../bug-fix/2026-09-16-messages-historical-tool-input.md) when Messages cannot represent them. 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 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.
The adapter prefers Files references for deterministic request images, with upload caching, refresh, quota recovery, and attachment offload. The Files client follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md) and sends the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned roots share uploads. 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 image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement.
JSON syntax failure after a successful HTTP response does not establish a transport failure. Files decoding uses the existing `INVALID_RESPONSE` code with operation context instead of relabeling the failure as `TRANSPORT` or adding a retry; the [provider README](../../../../packages/llm/llm-deepseek/README.md) owns the error fields.
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. 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.
Web displays DeepSeek with an endpoint and credential reference. Without an endpoint override, resolution uses `https://api.deepseek.com/anthropic`. Overrides must support Messages. The adapter follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog advertises the capabilities declared by its entries.
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.
The adapter uses the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accepts 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.
## Alternatives considered
**Separate plugins per protocol.** This duplicates credentials, catalogs, settings cards, and provider identities, and forces users to reselect models when the wire protocol changes. Protocol folders inside one plugin retain implementation isolation; Responses can add an implementation without changing the user configuration structure.
**Separate Messages provider identity.** This duplicates credentials, catalogs, and settings cards, and forces users to reselect models when adopting Messages. The `deepseek-official` identity keeps those user choices stable.
**Delegate the new route to pi-ai or the Anthropic SDK.** Both provide maintained protocol implementations, but the requested direct adapter needs DeepSeek-specific configuration, attachment policy, credential resolution, and retry ownership. A small stream translator with a maintained SSE parser keeps these responsibilities explicit; the library-backed adapter remains available independently.
@@ -38,6 +38,6 @@ Both transports use the existing [request-extension registry](../architecture/20
## Consequences
The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol.
The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages serves the shared base, Web, and standalone first-party compositions. Explicit endpoint overrides must support Messages.
Verification covers wire fixtures, real Loader composition, per-file unit coverage, [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml) with [unknown replay versions](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy.
@@ -6,29 +6,29 @@ Status: implemented
## 问题
部署可以通过 Anthropic Messages 网关或 chat-completions 调用 DeepSeek。Messages 对思考、签名、工具调用、工具结果和累计用量的表示不同。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。
Messages 以原生协议字段表示思考、签名、工具调用、工具结果和累计用量。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。
## 决策
[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)通过一个 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间支持多个协议。`common/` 共享配置、模型目录、能力解析和 Files 生命周期;`protocols/chat-completions/` 与 `protocols/messages/` 分别负责协议序列化、流转换和传输。`protocol` 配置在 Cordis YAML 中选择实现,默认 `messages`;随产品交付的官方组合继承该默认值。已有 `PreparedAdapterCall` 冻结协议、端点、凭据引用与模型能力,重试保持同一代配置,后续调用读取新配置。
[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)在唯一的 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间下提供 Messages。[单协议决策](../simplification/2026-09-19-deepseek-messages-only.zh.md)拥有传输范围。`PreparedAdapterCall` 固定端点、凭据引用与模型能力;重试保留该代配置,后续调用读取新配置。
适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。两种协议都转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。
适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。适配器转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。
助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;Messages 无法表示历史工具参数时使用[空输入兜底](../bug-fix/2026-09-16-messages-historical-tool-input.zh.md)。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。
两种协议均优先为确定性请求图片使用 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 预算,以及最旧前缀卸载。
适配器优先使用确定性请求图片的 Files 引用,并管理上传缓存、刷新、配额恢复与附件省略。Files 客户端遵循[精确 `/v1` 根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md)并发送必需的 beta 标头。缓存 id 按解析后的 Files 根地址与凭据限定作用域,因此等价的 `/v1` 与无版本根地址共用上传。元数据不含过期时间,因此本地复用以原始上传时间为界,不断言远端删除。Files 解析失败会按独立内联图片预算重建完整请求;调用方取消会停止它。图片策略在请求和 token 计量中保留 128 MiB 保留图片预算、20 MiB 内联 base64 预算及最旧前缀省略。
成功 HTTP 响应之后的 JSON 语法错误不足以证明传输故障。Files 解码复用已有的 `INVALID_RESPONSE` code 并补充操作上下文,不将其改标为 `TRANSPORT` 或添加重试;错误字段由 [provider README](../../../../packages/llm/llm-deepseek/README.zh.md) 维护。
系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](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`。切换协议保留已有端点覆盖,部署者负责其兼容性。Messages 遵循严格匹配 `/v1` 的根地址规则,不根据其他版本式后缀推断兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值,追加 `/chat/completions` 而不增加版本段。
Web 显示 DeepSeek,并提供端点与凭据引用。没有端点覆盖时,解析使用 `https://api.deepseek.com/anthropic`。覆盖地址必须支持 Messages。适配器遵循精确 `/v1` 根地址规则,不从其他类似版本的后缀推断兼容性。单一模型目录公布各条目声明的能力。
两种传输都在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。
适配器在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。
## 考虑过的替代方案
**每个协议独立插件。** 这会重复凭据配置、模型目录、设置卡片和 provider ID,并迫使用户在底层协议变化时重选模型。单插件中的协议目录保留实现隔离;Responses 可以增加自己的实现而不改变用户配置结构。
**独立的 Messages 提供方身份。** 这会重复凭据、目录与设置卡,并迫使用户在采用 Messages 时重新选择模型。`deepseek-official` 身份使这些用户选择保持稳定。
**把新路由委托给 pi-ai 或 Anthropic SDK。** 两者均提供持续维护的协议实现,但所需的直接适配器需要 DeepSeek 专属配置、附件策略、凭证解析和重试所有权。小型流转换器配合持续维护的 SSE 解析器使这些职责保持明确;库实现适配器仍可独立使用。
@@ -38,6 +38,6 @@ Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseU
## 结果
该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。
该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 服务共享 base、Web 和独立的官方组合。显式端点覆盖必须支持 Messages。
验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。
@@ -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/simplification/2026-09-19-deepseek-messages-only.md
2026-09-19-deepseek-messages-only.md: c10ecae950bb12f4556021de21c9716ad2906227
2026-09-19-deepseek-messages-only.zh.md: 3cfadac6ebc0b4bb1587a8b72e9b169d2ade2ee9
@@ -0,0 +1,25 @@
# Agent Note: One Messages transport for the official DeepSeek route
Status: implemented
English | [中文](2026-09-19-deepseek-messages-only.zh.md)
## Problem
The official DeepSeek route needs Messages thinking replay, image uploads, and in-history updates. Selecting a second transport duplicates serializers, stream handling, Files wire formats, configuration branches, and fixtures without adding a required capability to this route.
## Decision
`dsh-llm-deepseek` uses Messages exclusively. One adapter resolves a request-local configuration snapshot and performs the request. Configuration exposes one endpoint and no protocol selector. The Files client uses Messages authentication, metadata, and pagination directly; quota cleanup scans every page before selecting the oldest owned files.
The [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md) continues to own native thinking replay, system-message placement, image recovery, and request-extension acceptance. Replay discriminators and upload-index namespaces identify durable data and retain their existing values. The separate pi-ai adapter retains its provider-specific protocols.
## Alternatives considered
**Keep Chat Completions behind an optional selector.** This retains the duplicated implementation and test matrix even though the official route requires only Messages.
**Keep a generic protocol dispatcher with one implementation.** The dispatcher adds a second dependency representation and forwards every adapter operation without selecting anything.
## Consequences
The official route requires a Messages-compatible endpoint. Its mock-backed recovery, Loader, CLI, and Web tests exercise Messages; removing a wire implementation does not remove those behavior checks. Committed Session generations remain readable through provider-neutral content and the retained Messages replay metadata.
@@ -0,0 +1,25 @@
# Agent Note: One Messages transport for the official DeepSeek route
Status: implemented
[English](2026-09-19-deepseek-messages-only.md) | 中文
## 问题
官方 DeepSeek 路由需要 Messages 思考回放、图片上传与历史内更新。选择第二种传输会重复序列化器、流处理、Files 协议格式、配置分支与夹具,却没有为这条路由增加必需能力。
## 决策
`dsh-llm-deepseek` 仅使用 Messages。一个适配器解析请求局部的配置快照并执行请求。配置暴露一个端点,不提供协议选择器。Files 客户端直接使用 Messages 认证、元数据与分页;配额清理扫描全部页面后再选择最旧的自有文件。
[Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)继续拥有原生思考回放、系统消息位置、图片恢复与请求扩展接受规则。回放判别值和上传索引命名空间标识持久数据,并保留现有值。独立的 pi-ai 适配器保留各提供方协议。
## 考虑过的替代方案
**在可选选择器后保留 Chat Completions。** 即使官方路由只需要 Messages,这仍会保留重复实现与测试矩阵。
**保留只有一个实现的通用协议分派器。** 分派器增加第二种依赖表示,并转发每个适配器操作,却没有进行任何选择。
## 后果
官方路由要求兼容 Messages 的端点。其基于 mock 的恢复、Loader、CLI 与 Web 测试使用 Messages;移除一种协议实现不会移除这些行为检查。已提交的 Session 代际继续通过提供方无关内容与保留的 Messages 回放元数据读取。
@@ -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/testing/2026-06-19-real-api-e2e-ci.md
2026-06-19-real-api-e2e-ci.md: b218d77f0817416b01459fbb70f5cacffe048ee8
2026-06-19-real-api-e2e-ci.zh.md: 52c988dad00ddb3ee7f0cd2d23da9771f0900808
2026-06-19-real-api-e2e-ci.md: bdb20b3d5413077a4ddf1c1d9293b3b808cd6c8c
2026-06-19-real-api-e2e-ci.zh.md: b95f7df62e0a57d4c58fc5c12a83905dd848c55e
@@ -49,7 +49,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS
- **Step-scoped secret.** `DEEPSEEK_API_KEY` is set in the `env:` of only the preflight and e2e steps, never job-level — so checkout/setup-node/install never see it. A compromised install-time lifecycle script in a dependency cannot read a secret that isn't in its environment.
- **`permissions: contents: read`.** The job only reads the repo to run tests; it needs no write scopes (no PR comments, no status writes), so the `GITHUB_TOKEN` is dropped to least privilege.
- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint.
- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com/anthropic` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint.
- **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value or its length.
### Scope, runtime shape
@@ -49,7 +49,7 @@ repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试
- **步骤级 secret。** `DEEPSEEK_API_KEY` 仅在 preflight 和 e2e 步骤的 `env:` 中设置,从不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。
- **`permissions: contents: read`。** job 仅读取仓库以运行测试;不需要写权限(无 PR 评论、无 status 写入),因此 `GITHUB_TOKEN` 降至最小权限。
- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。
- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com/anthropic`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。
- **不回显 secret。** preflight 仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。
### 范围与运行时形态
+3 -6
View File
@@ -520,7 +520,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
successText: 'ACP BUILT PROFILE OK',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-'))
writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n')
const child = execa(process.execPath, [dshBin, '--profile', 'acp'], {
cwd: home,
reject: false,
@@ -606,7 +605,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
successText: 'published headless profile reached the mock',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n')
try {
const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
DSH_HOME: home,
@@ -618,7 +616,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(result.stdout).toBe('published headless profile reached the mock')
expect(result.stderr).toBe('dsh: reasoning:\nInspecting the published entry.')
expect(server.requests.length).toBeGreaterThan(0)
expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
expect(server.requests.every(request => request.path === '/v1/messages')).toBe(true)
expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
} finally {
await server.close()
@@ -743,7 +741,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
successText: 'launching endpoint reached the mock',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n')
const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
createEnvironmentProbeProfile(home, project)
@@ -766,8 +763,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(result.stdout).not.toContain(apiKey)
expect(result.stderr).not.toContain(apiKey)
expect(server.requests).toHaveLength(1)
expect(server.requests[0]?.path).toBe('/chat/completions')
expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
expect(server.requests[0]?.path).toBe('/v1/messages')
expect(server.requests[0]?.headers['x-api-key']).toBe(apiKey)
expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
} finally {
await server.close()
@@ -4,7 +4,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: !!js process.env.DSH_SNAPSHOT_PROTOCOL
apiKeyEnv: DSH_SNAPSHOT_API_KEY
baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
thinking: disabled
@@ -18,9 +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.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'
it('pins Messages Files offload and inline fallback in assembled requests', async () => {
const filesPath = '/v1/files'
const modelPath = '/v1/messages'
const requests: Record<string, unknown>[] = []
const fileRequests: Array<{ method: string; path: string; bytes: number }> = []
let rejectFiles = false
@@ -49,21 +49,13 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
return
}
const createdAt = Math.floor(Date.now() / 1_000)
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(protocol === 'messages' ? {
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({
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,
created_at: createdAt,
filename: 'dsh-snapshot.png',
purpose: 'user_data',
expires_at: createdAt + Number(form.get('expires_after[seconds]')),
}))
return
}
@@ -73,35 +65,18 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
}
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}]}',
'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'data: [DONE]',
'',
]
: [
'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}',
'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'data: [DONE]',
'',
]
response.end(events.join('\n\n'))
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
})().catch((error: unknown) => {
response.writeHead(500, { 'content-type': 'text/plain' }).end(String(error))
})
@@ -137,7 +112,6 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
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}`,
},
@@ -162,14 +136,9 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
+ ' 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 fileImage = { type: 'image', source: { 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`).'
@@ -179,80 +148,31 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
{ type: 'text', text: 'Compare the older image ' },
{ type: 'text', text: offloadedImage },
{ type: 'text', text: ' with the newer image ' },
{ type: 'text', text: `${imagePrefix}${imageHandle}` },
{ type: 'text', text: imageHandle },
fileImage,
{ type: 'text', text: ', then use read_image on red.png and reply with DONE.' },
...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [],
{ type: 'text', text: runtimeContext },
])
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 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,
])
rejectFiles = true
const fallback = await runScenario(input, {
@@ -262,7 +182,6 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
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}`,
},
@@ -280,12 +199,10 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in
{ 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: `${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: `Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } },
{ type: 'text', text: ', then use read_image on red.png and reply with DONE.' },
...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [],
{ type: 'text', text: runtimeContext },
])
} finally {
await new Promise<void>(resolve => server.close(() => { resolve() }))
@@ -83,7 +83,9 @@ async function expectHeadlessStream(normalized: string, expectedPath: string): P
}
/** Serve one deterministic DeepSeek-compatible response while retaining its request body. */
async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean; protocol?: 'messages' } = {}): Promise<DeepSeekDefaultsServer> {
async function deepseekDefaultsServer(
options: { waitForTitleRequest?: boolean; piAiCompatibility?: true } = {},
): Promise<DeepSeekDefaultsServer> {
const requests: JsonObject[] = []
const paths: string[] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
@@ -103,7 +105,7 @@ async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean;
timer = setTimeout(write, 60)
return
}
if (options.protocol === 'messages') {
if (options.piAiCompatibility !== true) {
response.end([
{ type: 'message_start', message: { id: 'defaults-response', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
@@ -582,7 +584,7 @@ describe('headless stream-json snapshots', () => {
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('keeps provider comments alive and sends DeepSeek defaults through the one-shot app', async () => {
const server = await deepseekDefaultsServer({ protocol: 'messages' })
const server = await deepseekDefaultsServer()
try {
const result = await runLoaderSmoke({
label: 'DeepSeek adapter defaults headless stream-json snapshot',
@@ -638,7 +640,7 @@ describe('headless stream-json snapshots', () => {
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('keeps the compatibility stream open until the title request arrives', async () => {
const server = await deepseekDefaultsServer({ waitForTitleRequest: true })
const server = await deepseekDefaultsServer({ waitForTitleRequest: true, piAiCompatibility: true })
try {
const response = await fetch(server.url, {
method: 'POST',
@@ -675,7 +677,7 @@ describe('headless stream-json snapshots', () => {
})
it('sends pi-ai DeepSeek compatibility through the one-shot app', async () => {
const server = await deepseekDefaultsServer({ waitForTitleRequest: true })
const server = await deepseekDefaultsServer({ waitForTitleRequest: true, piAiCompatibility: true })
try {
const result = await runLoaderSmoke({
label: 'pi-ai DeepSeek compatibility headless stream-json snapshot',
+1 -1
View File
@@ -24,7 +24,6 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', ()
beforeAll(async () => {
replayFixture = await selectedSessionFixture(FIXTURE, false)
scaffold = await launchWebScaffold({
deepSeekMessages: true,
replayFixture,
paceMs: 5,
replayProviders: [{
@@ -36,6 +35,7 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', ()
}],
}],
})
await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
@@ -1,4 +1,4 @@
/** Opt-in Web Messages configuration, credential reuse, and recovery from a saved Chat Completions selection. */
/** Web DeepSeek configuration, credential reuse, and saved model selection. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -12,14 +12,14 @@ import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './s
const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url))
describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-in', () => {
describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages settings', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, deepSeekMessages: true })
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
@@ -68,7 +68,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-
await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({
name: 'Messages Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history',
})
expect(scaffold.ctx.settings.get('llm-deepseek')).toMatchObject({ protocol: 'messages' })
expect(scaffold.ctx.settings.get('llm-deepseek')).not.toHaveProperty('protocol')
expect(settings).not.toContain('sk-e2e-')
const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-messages')
@@ -84,7 +84,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps a saved Chat Completions selection available after the YAML protocol switch', async () => {
it('keeps a saved DeepSeek selection available after editing provider settings', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default'))
await page.keyboard.press('Escape')
await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
@@ -21,7 +21,7 @@
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com/anthropic
- text: 请填写与当前连接配置兼容的 API 地址。
- text: 请填写兼容 Anthropic Messages 协议的 API 地址。
- region "模型目录":
- text: 模型目录 正在使用适配器默认模型
- textbox "模型 ID 1":
@@ -21,7 +21,7 @@
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com/anthropic
- text: 请填写与当前连接配置兼容的 API 地址。
- text: 请填写兼容 Anthropic Messages 协议的 API 地址。
- region "模型目录":
- text: 模型目录 正在使用适配器默认模型
- textbox "模型 ID 1":
@@ -21,7 +21,7 @@
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com/anthropic
- text: 请填写与当前连接配置兼容的 API 地址。
- text: 请填写兼容 Anthropic Messages 协议的 API 地址。
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
+8 -19
View File
@@ -261,10 +261,9 @@ class RouteOnlyAdapter extends LlmAdapter {
}
}
function replayProviders(contextWindow: number | undefined, messages: boolean): typeof REPLAY_PROVIDERS {
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
return REPLAY_PROVIDERS.map(provider => ({
...provider,
id: messages ? 'deepseek-messages' : provider.id,
models: provider.models.map(model => ({
...model,
...contextWindow === undefined ? {} : { contextWindow },
@@ -391,8 +390,6 @@ export interface LaunchOptions {
* keyless first-run configuration lane; the default disables the adapter.
*/
deepSeekMissingCredential?: boolean
/** Record or replay a Messages scenario; older scenarios explicitly retain their recorded Chat Completions route. */
deepSeekMessages?: boolean
/** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */
welcomeNoticePending?: boolean
/**
@@ -494,7 +491,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
}
const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
const messages = options.deepSeekMessages === true
const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
let credentialEnvironmentRestored = false
const restoreCredentialEnvironment = (): void => {
@@ -571,13 +567,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// Without HMR the profile applies configuration changes at its next start.
...options.profile?.hmr === false ? [{ id: 'hmr', disabled: true }] : [],
{ id: 'session-log-deepseek', config: { enabled: false } },
// The historical Messages fixture retains its recorded route during replay;
// live configuration uses the shared DeepSeek route. Explicit overlays win.
...messages
? [{ id: 'agent-default-model', config: { provider: mode === 'record' || maskDeepSeekCredential ? 'deepseek-official' : 'deepseek-messages', model: maskDeepSeekCredential ? 'deepseek-flash' : 'deepseek-v4-flash' } }]
: mode === 'record' || options.deepSeekMissingCredential === true
? []
: [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
...mode === 'record' || options.deepSeekMissingCredential === true
? []
: [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
...extraOverlayPatches,
// The roster's shipped presets are the plugin's own, bundled inside
// `dsh-agent-presets` and prepended by it. Pin only the machine-local
@@ -693,10 +685,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
baseURL: options.deepSeekSearch.baseURL,
},
}],
...maskDeepSeekCredential && !messages ? [] : [
{ id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential,
config: messages ? {} : { protocol: 'chat-completions' } },
],
{ id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential },
]
const patches: PatchOptions[] = [...basePatches, ...surfacePatches, ...overlayPatches]
@@ -870,7 +859,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
if (mode !== 'record' && replayFixture !== undefined) {
replayHandle = installLlmReplay(ctx, {
file: replayFixture,
providers: (options.replayProviders ?? replayProviders(options.replayContextWindow, messages)).map(provider => ({
providers: (options.replayProviders ?? replayProviders(options.replayContextWindow)).map(provider => ({
...provider,
...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }),
})),
@@ -885,8 +874,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// a fixture would, with streaming that still fails loud: the scenario
// issues no model calls, and one that slipped in must not pass quietly.
ctx.effect(() => ctx.llm.registerAdapter(
replayProviders(options.replayContextWindow, messages).map(provider => provider.id),
new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)),
replayProviders(options.replayContextWindow).map(provider => provider.id),
new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
), 'web e2e scaffold: route-only adapter')
}
if (publicProxy === undefined || publicHost === undefined || publicPrefix === undefined) {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 92b24a0948883b2974c5cb8ab650bd3db3b50326
config-catalog.zh.md: 4ba0378810f3db4797c0d41fa5230f7069438b3f
config-catalog.md: ed3f3f691a0082a07926e6a2108cf7d929464a55
config-catalog.zh.md: 00968de695ebde8599be7ca23e246fa6e00e4578
-5
View File
@@ -1161,8 +1161,6 @@ Requires: `llm`
* reasoning effort resolves to `high`.
*/
export interface Config {
/** Wire protocol; defaults to messages. Configure through Cordis YAML. */
protocol?: DeepSeekProtocol
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
@@ -1203,9 +1201,6 @@ export interface Config {
retryPolicy?: RetryPolicyConfig
}
/** Supported wire implementations; Responses is not yet implemented. */
export type DeepSeekProtocol = 'chat-completions' | 'messages'
/** One optional model entry advertised by the direct-fetch adapter. */
export interface DeepSeekCatalogModel {
/** Wire model id accepted by the configured endpoint. */
-5
View File
@@ -1163,8 +1163,6 @@ export interface Config {
* reasoning effort resolves to `high`.
*/
export interface Config {
/** Wire protocol; defaults to messages. Configure through Cordis YAML. */
protocol?: DeepSeekProtocol
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
@@ -1205,9 +1203,6 @@ export interface Config {
retryPolicy?: RetryPolicyConfig
}
/** Supported wire implementations; Responses is not yet implemented. */
export type DeepSeekProtocol = 'chat-completions' | 'messages'
/** One optional model entry advertised by the direct-fetch adapter. */
export interface DeepSeekCatalogModel {
/** Wire model id accepted by the configured endpoint. */
@@ -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/deepseek-llm-api-wire-extensions.md
deepseek-llm-api-wire-extensions.md: f45508f375d3e624b4d5eded26393a737c3a0907
deepseek-llm-api-wire-extensions.zh.md: ff72d62a9cdc2a33f6d6fd5fb08975b73839cae7
deepseek-llm-api-wire-extensions.md: 47ecc9db5438d5992f3b84c1ba3cc9b15561dc2b
deepseek-llm-api-wire-extensions.zh.md: d79f97fc3a029cce04801cdf58eb713007b2eb43
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](deepseek-llm-api-wire-extensions.zh.md)
This reference defines every DeepSeek Harness-specific HTTP header and additive JSON field sent by [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.md) on `deepseek-official` Messages and Chat Completions requests. It does not redefine fields owned by the upstream DeepSeek API. The provider-neutral LLM interface and `llm-pi-ai` do not implement these additions.
This reference defines every DeepSeek Harness-specific HTTP header and additive JSON field sent by [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.md) on `deepseek-official` Messages requests. It does not redefine fields owned by the upstream DeepSeek API. The provider-neutral LLM interface and `llm-pi-ai` do not implement these additions.
The adapter sends the additions to its resolved `baseURL`, including a configured gateway. They remain outside `messages`, system prompts, and tool schemas, so they do not add model-input tokens or alter the model-visible prefix.
+1 -1
View File
@@ -2,7 +2,7 @@
[English](deepseek-llm-api-wire-extensions.md) | 中文
本参考文档定义 [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.zh.md) 在 `deepseek-official` Messages 与 Chat Completions 请求中发送的全部 DeepSeek Harness 特有 HTTP 标头和附加 JSON 字段。本文不重复定义 DeepSeek 上游 API 持有的字段。提供方无关的 LLM(大语言模型)接口与 `llm-pi-ai` 均不实现这些扩展。
本参考定义 [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.zh.md) 在 `deepseek-official` Messages 请求中发送的全部 DeepSeek Harness 专用 HTTP 标头与附加 JSON 字段。它不重新定义上游 DeepSeek API 自有字段。提供方无关的 LLM 接口与 `llm-pi-ai` 不实现这些扩展。
适配器将这些扩展发送至已解析的 `baseURL`,包括已配置的网关。扩展位于 `messages`、系统提示词和工具 schema 之外,因此不会增加模型输入 token,也不会改变模型可见前缀。
@@ -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/user/develop/practice/llm-adapter.md
llm-adapter.md: 7e30847cdf265bca8f55aac40f73686101ec185d
llm-adapter.zh.md: 22a3c54f45088a41362c1a8e903d2441ed4fc93b
llm-adapter.md: 32e412b10ade376e88d23be4de8cb2de976706f0
llm-adapter.zh.md: bfa9842ce583f0486aff6269ad78c1f465f43671
+1 -1
View File
@@ -146,7 +146,7 @@ The first argument lists provider routes handled by the adapter. `GenerateOption
The repository contains complete implementations:
- `packages/llm/llm-deepseek/` — DeepSeek API adapter using the OpenAI-compatible format
- `packages/llm/llm-deepseek/` — DeepSeek adapter using the Messages API
- `packages/llm/llm-pi-ai/` — Pi AI adapter using a different API format
Compare the two shipped adapters to see the same harness contract implemented over different provider SDKs.
+1 -1
View File
@@ -146,7 +146,7 @@ ctx.llm.registerAdapter(['my-provider'], adapter)
仓库中包含以下两个完整实现:
- `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式)
- `packages/llm/llm-deepseek/` — 使用 Messages API 的 DeepSeek 适配器
- `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式)
对比这两个已交付的适配器,可以看到同一套 harness 契约如何在不同提供方 SDK 之上实现。
+1 -1
View File
@@ -449,7 +449,7 @@
# preset; products with a stricter network policy override tool-web. DeepSeek
# search resolves the same DEEPSEEK_API_KEY
# credential the Models page manages for chat, at each search; its Messages
# endpoint is separate from the chat-completions endpoint, so it takes its own
# endpoint is configured independently from the model route, so it takes its own
# base-URL override. Anonymous fetch accepts only public HTTP(S) destinations,
# resolves and validates every destination, and pins every actual connection.
# Search is a full auxiliary model request with server-side retrieval, so this
+2 -2
View File
@@ -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/bundle/web-app/README.md
README.md: 00f523098ada6646a2cf22ce57ca5fef6c6d9afb
README.zh.md: 2a3ea0d7d480ab2a29aa1d4adcb01836a9b70007
README.md: 8fea1287610a0a71f2a351a1b8e00af87b20ba44
README.zh.md: 603108991cd74ba379b3b9d8de9a2ff11bf7e101
+2 -2
View File
@@ -36,9 +36,9 @@ dsh --profile web --no-open --port 8080
After startup you see a `dsh web:` line whose root URL carries a fresh process token. Unless `--no-open` or an SSH session suppresses it, the default browser opens that URL, receives a signed cookie, and redirects to the same directory without the token. You know it worked when the page loads and you can chat with the agent. Two failures to expect: if the frontend is not built, startup stops with a build hint (`pnpm run build` in a checkout); if the browser cannot be opened, a credential-free diagnostic prints to stderr while the server keeps running — open the printed startup URL yourself.
**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#choose-a-protocol) defaults to Messages; set `protocol: chat-completions` in Cordis YAML to select Chat Completions. Web has no protocol selector.
**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#endpoint-and-wire-format) uses the Messages API.
Saved model selections override the composition default. Both protocols share `deepseek-official` and `llm-deepseek` settings, so switching preserves model selections and credential references. Endpoint overrides retain their values; the settings card lets users supply a compatible API address.
Saved model selections override the composition default. The settings card accepts a Messages-compatible API address and a credential reference.
### Configuration
+2 -2
View File
@@ -36,9 +36,9 @@ dsh --profile web --no-open --port 8080
启动后你会看到 `dsh web:` 行,其根 URL 携带新的进程 token。除非 `--no-open` 或 SSH 会话抑制,否则默认浏览器会打开该 URL、取得签名 cookie,再重定向到不含认证参数的同一目录。页面加载且你可以与 agent 对话,就说明成功了。两种可预期的失败:前端未构建时,启动会以构建提示停止(checkout 中运行 `pnpm run build`);浏览器无法打开时,stderr 会打印不含凭据的诊断,但服务器会继续运行——请自行打开已打印的启动 URL。
**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#choose-a-protocol)默认使用 Messages;在 Cordis YAML 中设置 `protocol: chat-completions` 可选择 Chat Completions。Web 不提供协议选择器。
**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#endpoint-and-wire-format)使用 Messages API。
已保存的模型选择优先于组合默认值。两种协议共用 `deepseek-official` 与 `llm-deepseek` 设置,因此切换协议不改变模型选择或复制凭据。端点覆盖保持原值;设置卡片允许用户填写兼容的 API 地址。
已保存的模型选择覆盖组合默认值。设置卡接受兼容 Messages 的 API 地址与凭据引用。
### 配置
@@ -101,20 +101,31 @@ function renderTab(state: Partial<PluginManagerState> = {}, config: Partial<Conf
setRowEnabled: vi.fn(),
dismissNotice: vi.fn(),
}
const renderSlot: PluginManagerPageProps['renderSlot'] = (name, owner, opts) => {
if (!('view' in owner) || (owner.view !== 'summary' && owner.view !== 'page')) {
throw new Error('Configuration slot requires a summary or page view')
}
return bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`]?.(owner.view) ?? null
const unusedStandardHook = (): never => { throw new Error('Plugin manager fixture does not provide global state') }
const standard = {
usePanelInfo: unusedStandardHook,
useWorkspaces: unusedStandardHook,
useSessions: unusedStandardHook,
useSessionStatus: unusedStandardHook,
useSessionRetainInfo: unusedStandardHook,
useResource: unusedStandardHook,
}
const props = {
const props: PluginManagerPageProps = {
...standard,
t,
resolveText,
...actions,
usePluginManager: bindSnapshotSelector(store),
useConfigLedger: bindSnapshotSelector(ledger),
renderSlot,
} as PluginManagerPageProps
renderSlot: (name, owner, opts) => {
const body = bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`]
if (body === undefined) return null
if (!('view' in owner) || (owner.view !== 'summary' && owner.view !== 'page')) {
throw new Error('Plugin configuration fixture requires a summary or page view')
}
return body(owner.view)
},
}
const { rerender } = render(<PluginManagerPage {...props} />)
return {
store,
@@ -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/client/ui-settings-models/README.md
README.md: c1567814b5de39f9b8819fcc8848e36c0a6ff842
README.zh.md: baa0f02e8274a7146f9eade36d3ad464ac944b27
README.md: a47f9a6dfa1faa9257c9b726140838e4858d33bb
README.zh.md: 925f8e976559ef7e975fcd164406f8c42f0b0098
+1 -1
View File
@@ -37,7 +37,7 @@ The primary field on an editor card is a single **API key** input — the page n
The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `settings.yaml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and input types; unrelated model fields survive edits.
The DeepSeek card edits the shared `llm-deepseek` endpoint, credentials, and model catalog without a protocol selector. When Cordis YAML selects Messages, the public endpoint placeholder is `https://api.deepseek.com/anthropic`. Saving the card preserves protocol configuration.
The DeepSeek card edits the `llm-deepseek` endpoint, credentials, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`.
Expand **Customized settings → Model options** to edit each model. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `settings.yaml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process.
@@ -37,7 +37,7 @@ kind: "package-reference"
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。
`llm-deepseek` 的 DeepSeek 卡片编辑共用的端点、凭据和模型目录,不提供协议选择器。Cordis YAML 选择 Messages 时,官方端点占位符为 `https://api.deepseek.com/anthropic`;保存卡片不会改写协议配置。
`llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。
展开**自定义设置 → 模型选项**编辑模型。两个提供方家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供方默认值,最后回退为文本。已知 pi-ai 提供方会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `settings.yaml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。
@@ -414,7 +414,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
type="text"
value={stringAt(draft, 'baseURL') ?? ''}
placeholder={family === 'deepseek'
? t(stringAt(fallback, 'protocol') === 'messages' ? 'deepSeekMessagesBaseUrl' : 'deepSeekChatBaseUrl')
? t('deepSeekBaseUrl')
: stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')}
aria-describedby={family === 'deepseek' ? `${props.provider}-endpoint-hint` : undefined}
aria-label={t('baseUrl')}
@@ -35,9 +35,8 @@ export const en = {
customized: 'Customized settings',
baseUrl: 'Base URL',
baseUrlDefault: 'Provider default',
deepSeekChatBaseUrl: 'https://api.deepseek.com',
deepSeekMessagesBaseUrl: 'https://api.deepseek.com/anthropic',
deepSeekEndpointHint: 'Use an endpoint compatible with the configured connection.',
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic',
deepSeekEndpointHint: 'Use an API endpoint compatible with Anthropic Messages.',
models: 'Models',
modelsInherited: 'Using the adapter defaults',
modelsCustomized: 'Customized model catalog',
@@ -147,9 +146,8 @@ export const zh: { [Key in keyof typeof en]: string } = {
customized: '自定义设置',
baseUrl: 'API 地址',
baseUrlDefault: '提供方默认',
deepSeekChatBaseUrl: 'https://api.deepseek.com',
deepSeekMessagesBaseUrl: 'https://api.deepseek.com/anthropic',
deepSeekEndpointHint: '请填写与当前连接配置兼容的 API 地址。',
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic',
deepSeekEndpointHint: '请填写兼容 Anthropic Messages 协议的 API 地址。',
models: '模型目录',
modelsInherited: '正在使用适配器默认模型',
modelsCustomized: '已自定义模型目录',
@@ -618,7 +618,7 @@ describe('ModelsSection', () => {
const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
// The deepseek placeholder is pinned to the public endpoint, not the
// effective value (which may reflect a launch-environment override).
expect(baseURL.placeholder).toBe('https://api.deepseek.com')
expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic')
fireEvent.change(baseURL, { target: { value: 'https://next2' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
@@ -666,11 +666,11 @@ describe('ModelsSection', () => {
])
})
it('edits the shared DeepSeek card while preserving the YAML protocol selection', async () => {
it('edits the DeepSeek endpoint and credential without a protocol selection', async () => {
const namespace: SettingsNamespaceView = {
...wireNamespaces()[0]!,
ns: 'llm-deepseek',
value: { protocol: 'messages', apiKeyEnv: 'DEEPSEEK_API_KEY', models: DEFAULT_DEEPSEEK_MODELS },
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', models: DEFAULT_DEEPSEEK_MODELS },
user: {},
}
const { face, mutate, set } = scriptedFace({
@@ -1102,7 +1102,7 @@ describe('ModelsSection', () => {
/>)
fireEvent.click(screen.getByText(en.customized))
const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
expect(baseURL.placeholder).toBe('https://api.deepseek.com')
expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic')
fireEvent.change(baseURL, { target: { value: 'https://x' } })
expect(baseURL.value).toBe('https://x')
fireEvent.change(baseURL, { target: { value: '' } })
@@ -10,9 +10,10 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import { ImageVariantId } from '@deepseek-ai/dsh-attachment'
import { serializeRequestWithImages } from '@deepseek-ai/dsh-llm-deepseek/src/protocols/chat-completions/serialize.ts'
import { resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
import { inlineImages } from '@deepseek-ai/dsh-llm-deepseek/src/images.ts'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createAssistantMessage, createToolResultMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm'
import { createAssistantMessage, createToolResultMessage, createUserMessage, projectOffloadedImages, offloadedImageText, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { isReplacementSurfaceEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
@@ -32,15 +33,15 @@ class ScriptedAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
if (this.serializeSummary && options.purpose === 'compaction') {
await serializeRequestWithImages(options, {
representation: { kind: 'base64' },
requestImages: new Map([image('first').attachment].map(ref => [ref.attachmentId, {
inlineImages(
projectOffloadedImages(options.messages, ref => offloadedImageText(ref)),
new Map([image('first').attachment].map(ref => [ref.attachmentId, {
variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), attachment: ref,
data: new Uint8Array(ref.bytes), mediaType: ref.mediaType, bytes: ref.bytes,
width: ref.width, height: ref.height, depth: 'uchar', space: 'srgb', hasAlpha: false,
}])),
maxRequestImageBytes: 1,
})
resolveAdapterOptions({ maxInlineRequestImageBytes: 1, inlineImageOffloadByteQuantum: 1 }),
)
}
const entry = this.script.shift()
if (entry === undefined) throw new Error('script exhausted')
@@ -132,7 +133,7 @@ describe('summary image offload', () => {
expect(decisions(agent.session)).toEqual([])
})
it('recovers the real summary serializer with a tighter image budget and fresh pricing', async () => {
it('recovers real summary image preparation with a tighter budget and fresh pricing', async () => {
const { compact, agent, adapter } = await summaryHarness([textResponse('answer'), textResponse('checkpoint')])
const span = await seedImages(agent, ['first', 'second'])
adapter.serializeSummary = true
@@ -17,11 +17,18 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import { renderPrompt, renderContextSnapshot } from '@deepseek-ai/dsh-system-prompt'
import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { serializeRequest } from '@deepseek-ai/dsh-llm-deepseek/src/protocols/chat-completions/serialize.ts'
import { resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
import { serialize } from '@deepseek-ai/dsh-llm-deepseek/src/serialize.ts'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import TeamService from '../../agent-team/src/index.ts'
import * as toolTeam from '../src/index.ts'
function serializeRequest(request: GenerateOptions) {
const connection = resolveAdapterOptions({ models: [{ id: request.model, systemPromptUpdate: 'in-history' }] })
return serialize(request, connection, request.messages, new Map(), () => undefined)
}
const SIGNAL = new AbortController().signal
const TOOL_NAMES = [
'spawn_teammate',
@@ -263,18 +270,18 @@ describe('dsh-tool-team', () => {
await waitNoAgent(ctx, childId)
const childRequest = serializeRequest(adapter.requests[1]!)
expect(childRequest.tools).toEqual(parentRequest.tools)
expect(childRequest.messages[0]).toEqual(parentRequest.messages[0])
expect(JSON.stringify(childRequest.messages[0])).not.toContain('Your Team role')
expect(childRequest.system).toEqual(parentRequest.system)
expect(childRequest.system).not.toContain('Your Team role')
if (mode === 'fork') {
expect(childRequest.messages.slice(0, parentRequest.messages.length)).toEqual(parentRequest.messages)
expect(adapter.requests[1]!.messages.slice(0, parentHistory.length)).toEqual(parentHistory)
} else {
expect(JSON.stringify(childRequest.messages)).not.toContain('Parent task')
}
expect(childRequest.messages).toContainEqual({
role: 'user',
content: '<system-reminder>\nYou are teammate "reviewer".\n</system-reminder>\n\nReview the work',
})
expect(childRequest.messages.at(-1)?.content.slice(0, 2)).toEqual([
{ type: 'text', text: '<system-reminder>\nYou are teammate "reviewer".\n</system-reminder>\n\n' },
{ type: 'text', text: 'Review the work' },
])
await using persisted = await ctx.sessionPersistence.open(childId, 'read')
const { events } = await persisted.read()
const initial = events.findLast(event => event.type === 'user/message' && event.data.source.kind === 'user')
@@ -302,7 +309,7 @@ describe('dsh-tool-team', () => {
const childRequest = serializeRequest(adapter.requests[1]!)
expect(childRequest.tools).toEqual(parentRequest.tools)
expect(childRequest.messages.slice(0, parentRequest.messages.length)).toEqual(parentRequest.messages)
expect(childRequest.messages.at(-1)?.content).toBe('Continue independently')
expect(childRequest.messages.at(-1)?.content).toContainEqual({ type: 'text', text: 'Continue independently' })
expect(JSON.stringify(childRequest.messages)).not.toContain('system-reminder')
expect(handle.agent.session.snapshotEvents().slice(0, seed.length)).toEqual(seed)
expect(ctx.agentTeams.listMembers(handle.agent).map(member => member.name)).toEqual(['lead'])
@@ -336,7 +343,7 @@ describe('dsh-tool-team', () => {
expect(fork.tools).toEqual(original.tools)
expect(fork.messages.slice(0, original.messages.length)).toEqual(original.messages)
expect(forkRequest.messages.slice(0, inheritedHistory.length)).toEqual(inheritedHistory)
expect(fork.messages).toContainEqual({ role: 'user', content: 'Continue independently' })
expect(fork.messages.at(-1)?.content).toContainEqual({ type: 'text', text: 'Continue independently' })
expect(JSON.stringify(fork.messages)).not.toContain('You are the Team Lead')
expect(handle.agent.session.snapshotEvents().slice(0, seed.length)).toEqual(seed)
} finally {
@@ -372,7 +379,7 @@ describe('dsh-tool-team', () => {
await waitNoAgent(ctx, childId)
const reminder = '<system-reminder>\nYou are teammate "reviewer".\n</system-reminder>\n\n'
const first = serializeRequest(adapter.requests[0]!).messages
expect(first).toContainEqual({ role: 'user', content: `${reminder}Review the work` })
expect(first.at(-1)?.content.slice(0, 2)).toEqual([{ type: 'text', text: reminder }, { type: 'text', text: 'Review the work' }])
expect(adapter.requests[1]!.messages.filter(message => message.content.some(block =>
block.type === 'text' && block.text === reminder))).toHaveLength(1)
expect(JSON.stringify(serializeRequest(adapter.requests[2]!).messages)).not.toContain('You are teammate')
@@ -411,8 +418,10 @@ describe('dsh-tool-team', () => {
})
expect(spawned.isError, text(spawned)).toBe(false)
await waitNoAgent(ctx, spawnedChildId(ctx, lead, spawned))
expect(serializeRequest(adapter.requests[0]!).messages.at(-1)?.content)
.toBe('<system-reminder>\nYou are teammate "reviewer".\n</system-reminder>\n\nReview the work')
expect(serializeRequest(adapter.requests[0]!).messages.at(-1)?.content).toEqual([
{ type: 'text', text: '<system-reminder>\nYou are teammate "reviewer".\n</system-reminder>\n\n' },
{ type: 'text', text: 'Review the work' },
])
})
it('returns actionable no-progress output and renders structured wait cancellation', async () => {
+2 -2
View File
@@ -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/README.md
README.md: 9f7e8f444345bb60677a57679ad559f1e06be83f
README.zh.md: fbb27ed1567fa3319a7fc1bdf61b6436113e0aba
README.md: cbd9e386f0f219eea4f41470bd5da076db00ebe0
README.zh.md: a3a31dfddd38a07ac95aae037f157ac12763974e
+1 -1
View File
@@ -25,7 +25,7 @@ The llm group provides the harness's model-call capability: one provider-neutral
| Package | Role | ctx key |
|---|---|---|
| [`llm/`](llm/README.md) | Streams one model call through a registered provider adapter and shares the harness message, block, and chunk vocabulary | `ctx.llm` |
| [`llm-deepseek/`](llm-deepseek/README.md) | Serves the `deepseek-official` route with direct DeepSeek chat-completions, thinking, and image input | registers on `ctx.llm` |
| [`llm-deepseek/`](llm-deepseek/README.md) | Serves the `deepseek-official` route through DeepSeek Messages with thinking and image input | registers on `ctx.llm` |
| [`llm-pi-ai/`](llm-pi-ai/README.md) | Serves configured provider routes through pi-ai catalogs and wire protocols, including hand-declared gateways | registers on `ctx.llm` |
| [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.md) | Registers lifecycle-owned top-level fields on official DeepSeek requests | `ctx.deepseekLlmApiExtensions` |
| [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.md) | Contributes the active Loader package inventory to official DeepSeek requests | contributes `dsh_plugin_packages` |
+1 -1
View File
@@ -25,7 +25,7 @@ llm 组提供 harness 的模型调用能力:一个提供方无关的服务,
| 包 | 职责 | ctx key |
|---|---|---|
| [`llm/`](llm/README.zh.md) | 通过已注册的提供方适配器流式发起一次模型调用,并共享 harness 的消息、块与分片词汇 | `ctx.llm` |
| [`llm-deepseek/`](llm-deepseek/README.zh.md) | 以 DeepSeek chat-completions 直连、thinking 与图片输入服务 `deepseek-official` 路由 | 注册到 `ctx.llm` |
| [`llm-deepseek/`](llm-deepseek/README.zh.md) | 通过 DeepSeek Messages 为 `deepseek-official` 路由提供思考与图片输入 | 注册到 `ctx.llm` |
| [`llm-pi-ai/`](llm-pi-ai/README.zh.md) | 通过 pi-ai 目录与协议格式服务配置的提供方路由,包括手工声明的网关 | 注册到 `ctx.llm` |
| [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.zh.md) | 在官方 DeepSeek 请求上注册具有生命周期归属的顶层字段 | `ctx.deepseekLlmApiExtensions` |
| [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.zh.md) | 为官方 DeepSeek 请求贡献当前启用的 Loader 包清单 | 贡献 `dsh_plugin_packages` |
@@ -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/deepseek-llm-api-extensions/README.md
README.md: 45a4182d77eb293b55d39d043bf8a1e1075873cf
README.zh.md: 0131791ca5837d3143c17640bf10cb1f4d215555
README.md: 1505c644a32a8c90c38720ac6229eb3b654693a1
README.zh.md: d27eca175c19bb914045958d1d748f427a4971b7
@@ -27,7 +27,7 @@ Provider-specific registry for additive top-level fields on official DeepSeek LL
- `prepare(request)` snapshots the registered providers, prepares them concurrently, clones and freezes returned JSON values, and returns `{ fields, accept }`. A preparation failure rejects before HTTP dispatch; request cancellation stops awaiting providers even when one ignores its signal.
- `accept()` runs every captured post-2xx callback once. Concurrent calls join the same settlement, every callback settles before failures are reported, and several failures become one `AggregateError`.
Both Chat Completions and Messages prepare these fields. Each provider sees the exact serialized base body in the selected protocol, the request `AbortSignal`, plus optional `sessionId` and auxiliary-call `purpose`. It must stop its own work promptly after cancellation and returns `undefined` when its field does not apply to that request. A prepared operation retains the providers it captured even if HMR removes their registrations before HTTP acceptance.
Each provider sees the exact serialized Messages body, the request `AbortSignal`, plus optional `sessionId` and auxiliary-call `purpose`. It must stop its own work promptly after cancellation and returns `undefined` when its field does not apply to that request. A prepared operation retains the providers it captured even if HMR removes their registrations before HTTP acceptance.
The registry owns addition and lifecycle, not field semantics. `@deepseek-ai/dsh-session-log-deepseek` owns `dsh_session_log`; `@deepseek-ai/dsh-plugin-package-inventory-deepseek` owns `dsh_plugin_packages`. The provider-neutral LLM seam and `llm-pi-ai` do not consume this registry.
@@ -27,7 +27,7 @@ kind: "package-reference"
- `prepare(request)` 对已注册提供方取快照,并发准备贡献,克隆并冻结返回的 JSON 值,然后返回 `{ fields, accept }`。准备失败会在 HTTP 分发前拒绝请求;请求取消后,即使某个提供方忽略信号,注册表也会停止等待。
- `accept()` 对每个捕获的 2xx 后回调只运行一次。并发调用会等待同一次结算,所有回调都在报告失败前完成,多个失败会合并为一个 `AggregateError`。
Chat Completions 和 Messages 都会准备这些字段。每个提供方都会看到所选协议中确切的已序列化基础请求体、请求 `AbortSignal`,以及可选的 `sessionId` 与辅助调用 `purpose`。提供方必须在取消后迅速停止自身工作;字段不适用于当前请求时返回 `undefined`。即使 HMR(热模块替换)在 HTTP 接受前移除了注册,已准备的操作仍会保留其捕获的提供方。
每个提供方都会看到确切的已序列化 Messages 请求体、请求 `AbortSignal`,以及可选的 `sessionId` 与辅助调用 `purpose`。提供方必须在取消后迅速停止自身工作;字段不适用于当前请求时返回 `undefined`。即使 HMR(热模块替换)在 HTTP 接受前移除了注册,已准备的操作仍会保留其捕获的提供方。
注册表拥有字段添加与生命周期,不拥有字段语义。`@deepseek-ai/dsh-session-log-deepseek` 拥有 `dsh_session_log`;`@deepseek-ai/dsh-plugin-package-inventory-deepseek` 拥有 `dsh_plugin_packages`。提供方无关的 LLM seam 与 `llm-pi-ai` 都不消费该注册表。
+2 -2
View File
@@ -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: 5bad4d36296c350ef2bc88ad2ff58fec33b93805
README.zh.md: 1c1c5a8255c3cd894e0aeecfe49079a78fd03eeb
README.md: e03b1024bf83fa39603c3c2413c3270df70b7bda
README.zh.md: 9f14e175d95ff0cac3e77c972a9bbc940c9d9e9f
+21 -48
View File
@@ -1,5 +1,5 @@
---
description: "Configure DeepSeek Messages, Chat Completions overrides, reasoning, and image input through one first-party provider."
description: "Configure DeepSeek Messages, reasoning, and image input."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Stream DeepSeek models through `deepseek-official` with Messages by default, or select Chat Completions in Cordis YAML. Both protocols share credentials, endpoint settings, image handling, and the model catalog. Valid settings changes affect subsequent calls while in-flight calls retain their configuration. Web shows one DeepSeek provider with an editable API base and key. This package can run beside the [pi-ai adapter](../llm-pi-ai/README.md).
Stream DeepSeek models through `deepseek-official` using the Messages API. Configure the endpoint, credentials, reasoning, and image handling from Cordis YAML or Web settings. Valid settings changes affect subsequent calls while in-flight calls retain their configuration. This package can run beside the [pi-ai adapter](../llm-pi-ai/README.md).
## Table of Contents
@@ -27,11 +27,11 @@ Stream DeepSeek models through `deepseek-official` with Messages by default, or
Mount this plugin when a composition streams DeepSeek models through the harness LLM service. It registers the single `deepseek-official` route and resolves connection facts per request, so a composition entry plus an optional user settings section drive the whole adapter.
Both protocols accept the LLM service's [request-only user inputs](../llm/README.md#use-this-package) alongside durable history; omitting request-only identity and attribution does not alter provider content.
The adapter accepts the LLM service's [request-only user inputs](../llm/README.md#use-this-package) alongside durable history; omitting request-only identity and attribution does not alter provider content.
### When to choose it
Choose this adapter for DeepSeek's official API or a gateway that supports the selected protocol through `baseURL`. Choose `dsh-llm-pi-ai` when the same composition also routes other providers or hand-declared gateways through pi-ai's catalogs; the two adapters can be mounted together because their route names do not collide. Registering any other adapter for `deepseek-official` fails with `DUPLICATE_ADAPTER`.
Choose this adapter for DeepSeek's official API or a Messages-compatible gateway through `baseURL`. Choose `dsh-llm-pi-ai` when the same composition also routes other providers or hand-declared gateways through pi-ai's catalogs; the two adapters can be mounted together because their route names do not collide. Registering any other adapter for `deepseek-official` fails with `DUPLICATE_ADAPTER`.
### Minimal configuration
@@ -51,9 +51,8 @@ A request selects the route with `provider: deepseek-official`; the model id pas
| Field | Default | Meaning |
|---|---|---|
| `protocol` | `messages` | Choose `messages` or `chat-completions` in Cordis YAML; Web has no protocol selector |
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved per request through the credentials seam, then the environment |
| `baseURL` | Selected protocol’s official root | Explicit value, then `$DEEPSEEK_BASE_URL`, then the selected protocol default |
| `baseURL` | `https://api.deepseek.com/anthropic` | Explicit value, then `$DEEPSEEK_BASE_URL`, then the official root |
| `thinking` | `enabled` | Deployment policy; `disabled` locks every request to `off` |
| `reasoningEffort` | `high` | Default effort: `off`, `low`, `high`, or `max` |
| `maxTokens` | `256,000` | Per-request output cap; a model's own cap and explicit request values win |
@@ -76,32 +75,24 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
When [proactive compaction](../../compaction/compaction-basic/README.md#use-this-package) is enabled, `models[].contextWindow` (or `defaultContextWindow` when absent) must exceed the effective request `maxTokens` plus the compaction policy’s `headroomTokens`. Requests without an explicit output cap use the model’s `maxTokens` or the adapter default. For small-window deployments, configure headroom within that capacity; lower `thresholdRatio` to compact earlier.
<a id="choose-a-protocol"></a>
### Choose a protocol
<a id="endpoint-and-wire-format"></a>
### Endpoint and wire format
To select Chat Completions explicitly, patch the existing plugin:
The official root is `https://api.deepseek.com/anthropic`. An explicit `baseURL` or `$DEEPSEEK_BASE_URL` supplies a Messages-compatible root. Model and Files requests append `/v1/messages` and `/v1/files`, except that an exact final `/v1` segment is reused. Trailing slashes do not change these results. A base URL must use HTTP(S) without credentials, query, or fragment.
```yaml
- id: llm-deepseek
config:
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 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.
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 preserves the model and thinking signatures. Invalid replay metadata emits a warning and omits signatures while retaining text and tool history.
### Streaming with thinking and images
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`. 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.
The adapter normally uploads those exact request bytes through `/v1/files` and sends file-id references. Files requests and model requests containing file ids include `anthropic-beta: files-api-2025-04-14`. All 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. 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.
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. 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.
`reasoningEffort` selects the advertised default. Exact-model metadata exposes ordered `off`, `low`, `high`, and `max` efforts with selection guidance when deployment policy permits thinking. `low`, `high`, and `max` enable thinking and serialize as `reasoning_effort` for Chat Completions or `output_config.effort` for Messages, while adapter-owned `off` sends `thinking.type: disabled` instead. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O, and `thinking: disabled` rejects any non-`off` effort at plugin load. Requests with `purpose: 'session-title'` force thinking off to reserve output for visible title text. Both protocols forward an explicit `temperature`; DeepSeek accepts it with thinking enabled but ignores its value in that mode.
`reasoningEffort` selects the advertised default. Exact-model metadata exposes ordered `off`, `low`, `high`, and `max` efforts with selection guidance when deployment policy permits thinking. `low`, `high`, and `max` enable thinking and serialize as `output_config.effort`, while adapter-owned `off` sends `thinking.type: disabled` instead. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O, and `thinking: disabled` rejects any non-`off` effort at plugin load. Requests with `purpose: 'session-title'` force thinking off to reserve output for visible title text. The adapter forwards an explicit `temperature`; DeepSeek accepts it with thinking enabled but ignores its value in that mode.
### Dynamic configuration
@@ -109,10 +100,12 @@ Connection facts are re-read once per operation through the optional settings an
### Provider-specific request fields
For either protocol, when `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registered top-level fields from the exact serialized base request before `fetch`. Preparation or field collisions fail before HTTP; after a 2xx response, the adapter accepts every captured contribution before consuming SSE. Transport and non-2xx failures do not accept them. Shipped compositions use this for the default-on incremental `dsh_session_log` field and the default-on active `dsh_plugin_packages` inventory; both stay outside model input.
When `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registered top-level fields from the exact serialized base request before `fetch`. Preparation or field collisions fail before HTTP; after a 2xx response, the adapter accepts every captured contribution before consuming SSE. Transport and non-2xx failures do not accept them. Shipped compositions use this for the default-on incremental `dsh_session_log` field and the default-on active `dsh_plugin_packages` inventory; both stay outside model input.
### Failures and recovery
Configuration accepts Messages only and has no `protocol` field. If resolution reports `protocol is not configurable`, remove `protocol` from the `llm-deepseek` section in `<harness home>/settings.yaml` and from any matching Cordis entry or overlay. Keep the intended `baseURL`, `apiKeyEnv`, and `models` fields. An invalid live section keeps the complete last good configuration; saving other fields in the Models card does not remove an unknown property. Edit the configuration file, then let settings reload or restart the profile; composition edits require a profile restart.
Successful Files responses must contain valid JSON. JSON decoding failures from upload, list, retrieve, and delete throw `INVALID_RESPONSE` with the operation and HTTP status in the message, the status in `LlmError.failure`, and the original parser error as `cause`. Body-read transport and cancellation errors retain their identity.
Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_<status>` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request with no key anywhere fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key.
@@ -133,17 +126,7 @@ The plugin is built on one explicit resolve step and one registration fact. `res
### Source map
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Settings, credentials, and provider registration |
| [`src/config.ts`](src/config.ts) | Schema and request-local configuration resolution |
| [`src/adapter.ts`](src/adapter.ts) | Protocol dispatch with frozen prepared-call configuration |
| [`src/common/models.ts`](src/common/models.ts) | Shared model catalog |
| [`src/common/model-info.ts`](src/common/model-info.ts) | Shared model capabilities and reasoning choices |
| [`src/common/file-store.ts`](src/common/file-store.ts) | Shared Files cache, refresh, quota cleanup, and cancellation |
| [`src/common/files-api.ts`](src/common/files-api.ts) | Protocol-specific Files endpoints and response mapping |
| [`src/protocols/chat-completions/adapter.ts`](src/protocols/chat-completions/adapter.ts) | Chat transport, image projection, and request extensions |
| [`src/protocols/messages/adapter.ts`](src/protocols/messages/adapter.ts) | Messages transport, image projection, request extensions, and native replay |
[`src/index.ts`](src/index.ts) registers the provider and resolves settings and credentials. [`src/adapter.ts`](src/adapter.ts) owns the request lifecycle; [`src/serialize.ts`](src/serialize.ts) and [`src/translate.ts`](src/translate.ts) map model input and streamed output. [`src/file-store.ts`](src/file-store.ts) owns upload reuse and recovery through [`src/files-api.ts`](src/files-api.ts).
### Wire flow
@@ -177,11 +160,11 @@ Read these pages when the package-level contract is not enough. They move from t
#### What the model sees
The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. This silent fallback also applies after switching from Chat Completions. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results; omission of these two block types matches Chat Completions and the pi-ai adapter. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`.
The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`.
#### Token effect
Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence selected by a logged image-offload decision as its placeholder text and each retained image at its projected dimensions with the published vision accounting (14px patch grid, 3:1 downsampling, 544×544 scale-up floor, 1024-token cap). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries. Cache-read usage is reported when available. Messages totals include uncached input, output, cache-read, and cache-write tokens. Chat Completions uses `prompt_tokens + completion_tokens` and omits `totalTokens` if a supplied `total_tokens` disagrees.
Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence selected by a logged image-offload decision as its placeholder text and each retained image at its projected dimensions with the published vision accounting (14px patch grid, 3:1 downsampling, 544×544 scale-up floor, 1024-token cap). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries. Cache-read usage is reported when available. Messages totals include uncached input, output, cache-read, and cache-write tokens.
#### KV Cache effect
@@ -203,7 +186,6 @@ Loop-retained response blocks append to the next request and preserve its earlie
## Known Limitations and Deferred Work
- Responses is not implemented; configuration rejects `responses`.
<a id="known-limitations-and-deferred-work"></a>
@@ -213,23 +195,14 @@ These limits define where the adapter stops and future work begins. They are cur
- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape.
- **`tool_choice` is not mapped** — not part of the core vocabulary (shared with the pi-ai twin).
- **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy or interception configuration.
- **Chat Completions skips plugin-added content block types** — core text and supported image blocks are serialized, and empty tool output crosses the wire as the literal `(no output)`.
- **Messages in-history system updates require a retained user or tool-result turn** — if all user input after an update is omitted and the preceding wire turn is assistant, serialization fails with `UNSUPPORTED_CONTENT` before the next assistant or at the end of the request. Text or an empty tool result can retain that turn. Moving the update to an earlier turn is not supported; the [input-history decision](../../../.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md) records the ordering constraint.
- **Images are input-only durable attachments** — direct external URLs and assistant image output are not supported; DeepSeek input normally uses the Files API and uses inline base64 only for per-request recovery.
- The default catalog pre-registers `deepseek-flash` and its text/image and in-history capabilities without probing gateway availability. Requests can fail with `INVALID_REQUEST` until the gateway enables the id. With `DEEPSEEK_API_KEY` and a supporting gateway configured, `DEEPSEEK_FLASH_E2E=1` enables the Chat Completions check in [this package's e2e suite](tests/adapter.e2e.ts).
- The [Messages system-update e2e checks](tests/messages/adapter.e2e.ts) require `DEEPSEEK_IN_HISTORY_MODEL` to name a supported model, such as `deepseek-flash`, and run with `high` effort. They skip when that variable is unset or empty; ordinary `off` text checks remain enabled with credentials. Known instruction-following instability with thinking disabled makes these system-update checks unsuitable for `off`.
- The default catalog advertises `deepseek-flash` and its text/image and in-history capabilities without probing gateway availability. Requests can fail with `INVALID_REQUEST` until the gateway enables the id.
- Real API checks in [adapter.e2e.ts](tests/adapter.e2e.ts) and [runtime.e2e.ts](tests/runtime.e2e.ts) require `DEEPSEEK_API_KEY`. Set `DEEPSEEK_IN_HISTORY_MODEL` to a supported nonempty model id to run system-update checks: the adapter suite uses `high` effort, while the runtime suite compares cache reuse with thinking disabled and can be sensitive to instruction-following instability. The runtime image cases additionally require `DEEPSEEK_FLASH_E2E=1` or `DEEPSEEK_VISION_E2E=1`.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
This Dev Note is non-authoritative working context: undecided directions and notes for maintainers. Shipped behavior and accepted rationale live in the sections above, the package code, and the linked Agent Notes.
- OpenRouter-specific app attribution headers are deferred to a future explicit OpenRouter adapter or mode; OpenAI-compatible gateway requests carry only the shared attribution baseline.
- The `off` reasoning effort never crosses the wire as `reasoning_effort: 'off'`; it serializes as `thinking: { type: 'disabled' }` and omits the field, which keeps the wire spelling valid for gateways that reject unknown effort values.
</details>
None.
**Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam.
+22 -49
View File
@@ -1,5 +1,5 @@
---
description: "通过同一官方提供方配置 DeepSeek Messages、Chat Completions 覆盖、推理与图片输入。"
description: "配置 DeepSeek Messages、推理与图片输入。"
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
通过 `deepseek-official` 流式调用 DeepSeek 模型,默认使用 Messages,也可在 Cordis YAML 中选择 Chat Completions。两种协议共用凭据、端点配置、图片处理和模型目录。有效的设置更改在后续请求生效,进行中的请求保留原配置。Web 显示一个 DeepSeek 提供方,并提供 API 地址和密钥编辑。本包可与 [pi-ai 适配器](../llm-pi-ai/README.zh.md)并用。
通过 `deepseek-official` 使用 Messages API 流式调用 DeepSeek 模型。在 Cordis YAML 或 Web 设置中配置端点、凭据、推理与图片处理。有效的设置更改在后续请求生效,进行中的请求保留原配置。本包可与 [pi-ai 适配器](../llm-pi-ai/README.zh.md)并用。
## 目录
@@ -27,11 +27,11 @@ kind: "package-reference"
当组合需要通过 harness LLM(大语言模型)服务流式调用 DeepSeek 模型时挂载本插件。它注册唯一的 `deepseek-official` 路由,并按请求解析连接事实,因此组合条目加可选用户设置分节即可驱动整个适配器。
两种协议都接受 LLM 服务的[仅供请求使用的 user 输入](../llm/README.zh.md#use-this-package),并可将其与持久历史混用;省略请求输入的身份与来源不会改变提供方内容。
适配器接受 LLM 服务的[仅供请求使用的 user 输入](../llm/README.zh.md#use-this-package),并可将其与持久历史混用;省略请求输入的身份与来源不会改变提供方内容。
### 何时选择
面向 DeepSeek 官方 API,或通过 `baseURL` 连接支持所选协议的网关时,选择本适配器。当同一组合还要通过 pi-ai 目录路由其他提供方或手工声明的网关时,选择 `dsh-llm-pi-ai`;两个适配器可以同时挂载,因为它们的路由名不冲突。为 `deepseek-official` 注册任何其他适配器会以 `DUPLICATE_ADAPTER` 失败。
面向 DeepSeek 官方 API,或通过 `baseURL` 连接兼容 Messages 的网关时,选择本适配器。当同一组合还要通过 pi-ai 目录路由其他提供方或手工声明的网关时,选择 `dsh-llm-pi-ai`;两个适配器可以同时挂载,因为它们的路由名不冲突。为 `deepseek-official` 注册任何其他适配器会以 `DUPLICATE_ADAPTER` 失败。
### 最小配置
@@ -51,9 +51,8 @@ kind: "package-reference"
| 字段 | 默认值 | 含义 |
|---|---|---|
| `protocol` | `messages` | Cordis YAML 中选择 `messages` 或 `chat-completions`;Web 不提供选择器 |
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 按请求解析的凭据引用:先经凭据 seam,再到环境变量 |
| `baseURL` | 按协议选择官方根地址 | 显式值优先,其次 `$DEEPSEEK_BASE_URL`,最后采用当前协议的官方端点 |
| `baseURL` | `https://api.deepseek.com/anthropic` | 显式值优先,其次为 `$DEEPSEEK_BASE_URL`,最后为官方根地址 |
| `thinking` | `enabled` | 部署策略;`disabled` 把所有请求锁定为 `off` |
| `reasoningEffort` | `high` | 默认强度:`off`、`low`、`high` 或 `max` |
| `maxTokens` | `256,000` | 单次请求输出上限;模型自身上限与显式请求值优先 |
@@ -76,32 +75,24 @@ kind: "package-reference"
启用[主动压缩](../../compaction/compaction-basic/README.zh.md#use-this-package)时,`models[].contextWindow`(未声明时使用 `defaultContextWindow`)必须大于生效请求的 `maxTokens` 与压缩策略 `headroomTokens` 之和。请求未覆盖输出上限时,使用模型的 `maxTokens` 或适配器默认值。小窗口部署应在容量范围内配置余量;降低 `thresholdRatio` 可以提早压缩。
<a id="choose-a-protocol"></a>
### 选择协议
<a id="endpoint-and-wire-format"></a>
### 端点与协议格式
通过补丁为已有插件显式选择 Chat Completions:
官方根地址为 `https://api.deepseek.com/anthropic`。显式 `baseURL` 或 `$DEEPSEEK_BASE_URL` 提供兼容 Messages 的根地址。模型与 Files 请求分别追加 `/v1/messages` 和 `/v1/files`,但末尾严格匹配的 `/v1` 路径段会直接复用。末尾斜线不改变这些结果。基址必须使用 HTTP(S),且不含凭据、查询或片段。
```yaml
- id: llm-deepseek
config:
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 与其 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 回放元数据产生警告并省略签名,不丢弃文本或工具历史。
Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据保留模型与思考签名。无效的回放元数据产生警告并省略签名,不丢弃文本或工具历史。
### 带 thinking 与图片的流式调用
支持图片的路由为每个持久引用选定请求目标,再把它解析为确定性请求版本。省略 `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`。Messages 模型请求与所有 Files 请求拒绝重定向,确保凭据仅发送到配置的源。文件解析失败或超时会按内联预算,用内联 base64 重建整份模型请求;一次请求绝不混用 file id 与内联图片。调用方取消会停止请求。
适配器通常通过 `/v1/files` 上传这些确切请求字节,并发送 file-id 引用。Files 请求与包含 file id 的模型请求均携带 `anthropic-beta: files-api-2025-04-14`。全部请求拒绝重定向,确保凭据仅发送到配置的源。文件解析失败或超时会按内联预算,用内联 base64 重建整份模型请求;一次请求绝不混用 file id 与内联图片。调用方取消会停止请求。
缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。两种上传都通过 `expires_after[anchor]=created_at` 与 `expires_after[seconds]` 请求过期。Messages 文件元数据不含远端过期时间,因此本地复用期限使用原始上传时间加 `fileExpiresAfterSeconds`;这不保证远端文件删除。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。
缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。上传通过 `expires_after[anchor]=created_at` 与 `expires_after[seconds]` 请求过期。Messages 文件元数据不含远端过期时间,因此本地复用期限使用原始上传时间加 `fileExpiresAfterSeconds`;这不保证远端文件删除。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。
Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留请求版本;内联回退有独立 base64 预算。两种模式都按配置的字节或数量量子移除最旧前缀。每张省略图片都有自己的模型可见占位符,包含显示名或附件 id,以及可用时的规范化尺寸、媒体类型与当前只读路径。分阶高水位策略避免每新增一张图片都改写旧请求前缀。
`reasoningEffort` 选择公布的默认值。当部署策略允许 thinking 时,确切模型元数据会按顺序公开 `off`、`low`、`high` 与 `max` 强度及选择指引。`low`、`high` 与 `max` 启用 thinking,在 Chat Completions 中以 `reasoning_effort` 序列化,在 Messages 中以 `output_config.effort` 序列化,适配器自有的 `off` 则发送 `thinking.type: disabled`。不支持的取值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败;`thinking: disabled` 会在插件加载时拒绝任何非 `off` 强度。`purpose: 'session-title'` 的请求会强制关闭 thinking,把有界输出留给可见标题文本。两种协议都转发显式 `temperature`;DeepSeek 在启用 thinking 时接受该参数,但忽略其值。
`reasoningEffort` 选择公布的默认值。当部署策略允许 thinking 时,确切模型元数据会按顺序公开 `off`、`low`、`high` 与 `max` 强度及选择指引。`low`、`high` 与 `max` 启用 thinking,并以 `output_config.effort` 序列化,适配器自有的 `off` 则发送 `thinking.type: disabled`。不支持的取值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败;`thinking: disabled` 会在插件加载时拒绝任何非 `off` 强度。`purpose: 'session-title'` 的请求会强制关闭 thinking,把有界输出留给可见标题文本。适配器转发显式 `temperature`;DeepSeek 在启用 thinking 时接受该参数,但忽略其值。
### 动态配置
@@ -109,10 +100,12 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
### 提供方专用请求字段
两种协议中,存在 `ctx.deepseekLlmApiExtensions` 时,适配器都会在 `fetch` 前根据确切序列化基础请求准备已注册顶层字段。准备或字段冲突在 HTTP 前失败;2xx 响应后,适配器会在消费 SSE(Server-Sent Events)前接受每项已捕获贡献。传输与非 2xx 失败不会接受它们。随产品交付的组合用它提供默认启用的增量 `dsh_session_log` 字段和默认启用的活跃 `dsh_plugin_packages` 清单;两者都留在模型输入之外。
存在 `ctx.deepseekLlmApiExtensions` 时,适配器会在 `fetch` 前根据确切序列化基础请求准备已注册顶层字段。准备或字段冲突在 HTTP 前失败;2xx 响应后,适配器会在消费 SSE(Server-Sent Events)前接受每项已捕获贡献。传输与非 2xx 失败不会接受它们。随产品交付的组合用它提供默认启用的增量 `dsh_session_log` 字段和默认启用的活跃 `dsh_plugin_packages` 清单;两者都留在模型输入之外。
### 失败与恢复
配置仅接受 Messages,不提供 `protocol` 字段。若解析报告 `protocol is not configurable`,请从 `<harness home>/settings.yaml` 的 `llm-deepseek` 分节以及对应 Cordis 条目或 overlay 中删除 `protocol`,保留需要的 `baseURL`、`apiKeyEnv` 和 `models` 字段。无效的实时分节会继续使用完整的最后有效配置;在模型设置卡中保存其他字段不会移除未知属性。请编辑配置文件,等待设置重新加载或重启 profile;组装配置变更需要重启 profile。
成功的 Files 响应必须包含有效 JSON。上传、列举、获取和删除操作的 JSON 解码失败抛出 `INVALID_RESPONSE`,消息包含操作名称与 HTTP 状态,`LlmError.failure` 保留该状态,`cause` 保留原始解析错误。读取响应体时的传输和取消错误保留其原有身份。
非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_<status>`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。任何位置都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。
@@ -131,19 +124,9 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
插件建立在一个显式解析步骤与一条注册事实之上。`resolveAdapterOptions()` 是从原始配置到已校验连接事实的唯一路径,适配器通过 thunk 每次操作重新读取这些事实——基址、目录、请求默认值、图片与 Files 策略及空闲预算都会作用于下一个请求,而进行中的流保持其启动时的事实。注册时捕获的唯一事实是重试策略:解析值变化时,插件会在一个同步区段内原位重新注册路由,因此任何请求都观察不到空档。
### 源码地图
### 源码导航
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | settings、凭据与提供方注册 |
| [`src/config.ts`](src/config.ts) | schema 与请求配置解析 |
| [`src/adapter.ts`](src/adapter.ts) | 按协议分派,并冻结已准备请求的配置 |
| [`src/common/models.ts`](src/common/models.ts) | 共享模型目录 |
| [`src/common/model-info.ts`](src/common/model-info.ts) | 共享模型能力与推理选项 |
| [`src/common/file-store.ts`](src/common/file-store.ts) | 共享 Files 缓存、刷新、配额清理与取消 |
| [`src/common/files-api.ts`](src/common/files-api.ts) | 各协议的 Files 端点与响应映射 |
| [`src/protocols/chat-completions/adapter.ts`](src/protocols/chat-completions/adapter.ts) | Chat 传输、图片投影与请求扩展 |
| [`src/protocols/messages/adapter.ts`](src/protocols/messages/adapter.ts) | Messages 传输、图片投影、请求扩展与原生回放 |
[`src/index.ts`](src/index.ts) 注册提供方并解析设置与凭据。[`src/adapter.ts`](src/adapter.ts) 管理请求生命周期;[`src/serialize.ts`](src/serialize.ts) 和 [`src/translate.ts`](src/translate.ts) 映射模型输入与流式输出。[`src/file-store.ts`](src/file-store.ts) 通过 [`src/files-api.ts`](src/files-api.ts) 管理上传复用与恢复。
### 协议流程
@@ -177,11 +160,11 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
#### 模型看到什么
所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。从 Chat Completions 切换后也适用此静默兜底。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块;对这两类块的省略行为与 Chat Completions 和 pi-ai 适配器一致。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。
所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。
#### Token 影响
提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把日志中的图片省略决策选中的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的视觉计量规则(14 px patch 网格、3:1 降采样、544×544 放大下限、单图 1024 token 上限)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试。可用时报告缓存读取用量。Messages 的 token 总数包含未缓存输入、输出、缓存读取与缓存写入 token。Chat Completions 使用 `prompt_tokens + completion_tokens`,提供方给出的 `total_tokens` 不一致时省略 `totalTokens`。
提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把日志中的图片省略决策选中的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的视觉计量规则(14 px patch 网格、3:1 降采样、544×544 放大下限、单图 1024 token 上限)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试。可用时报告缓存读取用量。Messages 的 token 总数包含未缓存输入、输出、缓存读取与缓存写入 token。
#### KV Cache 影响
@@ -203,7 +186,6 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复
## 已知限制与延期工作
- Responses 协议尚未实现,配置值 `responses` 会被拒绝。
<a id="known-limitations-and-deferred-work"></a>
@@ -213,23 +195,14 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复
- **设置中的 `models` 列表会整体替换组合列表**——设置层按字段合并,数组只算一个字段;按条目合并目录需要带键的形状。
- **不映射 `tool_choice`**——不属于核心词汇(与 pi-ai 孪生共享)。
- **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**——没有共享代理或拦截配置。
- **Chat Completions 跳过插件新增的内容块类型**——核心文本与受支持图片块会被序列化,空工具输出以字面量 `(no output)` 过线。
- **Messages 历史内 system 更新需要保留用户或工具结果轮次**——若更新后的全部用户输入都被省略,且前一个协议轮次是 assistant,序列化会在下一个 assistant 之前或请求结束处以 `UNSUPPORTED_CONTENT` 失败。文本或空工具结果可以保留该轮次。不支持将更新移到更早的轮次;[输入历史决策](../../../.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md)记录了排序约束。
- **图片是仅用于输入的持久附件**——不支持直接外部 URL 与 assistant 图片输出;DeepSeek 输入通常使用 Files API,仅在单次请求恢复时使用内联 base64。
- 默认目录预注册 `deepseek-flash` 及其文本、图片和历史内更新能力,不探测网关可用性。网关开放该 ID 前,请求可能以 `INVALID_REQUEST` 失败。配置 `DEEPSEEK_API_KEY` 和支持该 ID 的网关后,设置 `DEEPSEEK_FLASH_E2E=1` 可启用[本包 e2e 测试文件](tests/adapter.e2e.ts)中的 Chat Completions 协议验证。
- [Messages system 更新 e2e](tests/messages/adapter.e2e.ts) 要求通过 `DEEPSEEK_IN_HISTORY_MODEL` 指定支持该能力的模型,例如 `deepseek-flash`,并使用 `high` 思考强度。该变量未设置或为空时跳过;普通 `off` 文本检查仍在有凭据时运行。关闭思考时已知的指令遵循不稳定,使这些 system 更新检查不适合使用 `off`。
- 默认目录公布 `deepseek-flash` 及其文本、图片和历史内更新能力,不探测网关可用性。网关开放该 ID 前,请求可能以 `INVALID_REQUEST` 失败。
- [adapter.e2e.ts](tests/adapter.e2e.ts) 与 [runtime.e2e.ts](tests/runtime.e2e.ts) 中的真实 API 检查需要 `DEEPSEEK_API_KEY`。将 `DEEPSEEK_IN_HISTORY_MODEL` 设为受支持的非空模型 ID 可运行 system 更新检查:适配器套件使用 `high` 思考强度,运行时套件则在关闭思考时比较缓存复用,可能受到指令遵循不稳定的影响。运行时图片用例还需要 `DEEPSEEK_FLASH_E2E=1` 或 `DEEPSEEK_VISION_E2E=1`。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者的工作上下文——点击展开</summary>
本开发备注是不具权威性的工作上下文:尚未决定的探索方向与维护者备注。已交付的行为与既定理由以上文、包代码和相关 Agent Note 为准。
- OpenRouter 专属应用归因标头延期到未来显式 OpenRouter 适配器或模式;OpenAI 兼容网关请求只携带共享归因基线。
- `off` 推理强度绝不会以 `reasoning_effort: 'off'` 过线;它序列化为 `thinking: { type: 'disabled' }` 并省略该字段,从而对拒绝未知强度取值的网关保持协议拼写有效。
</details>
无。
**运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-llm-deepseek",
"description": "DeepSeek adapter with Chat Completions and Messages protocols",
"description": "DeepSeek Messages adapter",
"version": "0.1.6-alpha.2",
"publishConfig": {
"access": "public"
+125 -43
View File
@@ -1,58 +1,140 @@
/** Select a DeepSeek wire implementation from one validated configuration generation. */
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { DeepSeekAdapterOptions } from './common/types.ts'
import { ChatCompletionsAdapter } from './protocols/chat-completions/adapter.ts'
import { DeepSeekFileStore } from './common/file-store.ts'
import { DeepSeekMessagesAdapter } from './protocols/messages/adapter.ts'
/** Direct Messages transport with one cancellable lifecycle per model request. */
/** One provider route with protocol-local transport and shared credentials and model configuration. */
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, ImageAttachmentAccessResolver, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { DeepSeekLlmApiJson } from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { catalogModelInfo, modelInfo } from './model-info.ts'
import type { DeepSeekAdapterOptions, DeepSeekConnectionOptions as Connection } from './types.ts'
import { DeepSeekFileStore } from './file-store.ts'
import { MESSAGES_FILES_BETA, messagesApiRoot } from './messages-api.ts'
import { FileResolutionFailure, RequestFiles } from './request-files.ts'
import { prepareRequestExtensions } from './request-extensions.ts'
import { imagePricing, inlineImages, prepareFileIds, prepareImages } from './images.ts'
import { serialize } from './serialize.ts'
import { parseSse } from './sse.ts'
import { translate } from './translate.ts'
import { providerError, providerErrorDetail } from './transport.ts'
/** DeepSeek provider using Messages content and native thinking replay. */
export class DeepSeekAdapter extends LlmAdapter {
private readonly files: DeepSeekFileStore
private readonly imageAccess: ImageAttachmentAccessResolver = (ref) => {
const attachments = this.dependencies.resolveAttachments?.()
return attachments === undefined ? undefined : this.dependencies.resolveImageAccess?.(attachments, ref)
}
constructor(private readonly dependencies: DeepSeekAdapterOptions) {
super()
this.files = dependencies.resolveFiles?.() ?? new DeepSeekFileStore()
}
private implementation(): LlmAdapter {
override providerInfo(provider: string) { return { id: provider, name: 'DeepSeek' } }
override providerRetryPolicy(_provider: string) { return this.dependencies.options().retryPolicy }
override listModels(provider: string) {
const connection = this.dependencies.options()
switch (connection.protocol) {
case 'messages':
return new DeepSeekMessagesAdapter({
connection: () => connection,
apiKey: this.dependencies.resolveApiKey,
userId: this.dependencies.resolveUserId,
attachments: () => this.dependencies.resolveAttachments?.(),
imageAccess: (ref) => {
const attachments = this.dependencies.resolveAttachments?.()
return attachments === undefined ? undefined : this.dependencies.resolveImageAccess?.(attachments, ref)
},
files: () => this.files,
prepareExtensions: this.dependencies.prepareExtensions,
...this.dependencies.onReplayDegrade === undefined ? {} : { onReplayDegrade: this.dependencies.onReplayDegrade },
})
case 'chat-completions':
return new ChatCompletionsAdapter({ ...this.dependencies, options: () => connection, resolveFiles: () => this.files })
/* v8 ignore next -- protocol is validated at configuration resolution. */
default: return assertNever(connection.protocol, 'DeepSeek protocol')
return Promise.resolve(connection.models.map(model => catalogModelInfo(provider, model)))
}
override resolveModel(provider: string, model: string, _signal?: AbortSignal) {
return Promise.resolve(modelInfo(this.dependencies.options(), provider, model))
}
override imageRequestPricing(_provider: string, model: string) {
return imagePricing(this.dependencies.options(), model, this.imageAccess)
}
override prepareCall(provider: string, model: string, _signal?: AbortSignal): Promise<PreparedAdapterCall> {
const connection = this.dependencies.options()
return Promise.resolve({ model: modelInfo(connection, provider, model), stream: options => this.generate(options, connection) })
}
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
return this.generate(options, this.dependencies.options())
}
private async * generate(options: GenerateOptions, connection: Connection): AsyncGenerator<StreamChunk> {
const consumer = new AbortController()
const signal = options.signal === undefined ? consumer.signal : AbortSignal.any([consumer.signal, options.signal])
using watchdog = idleWatchdog(signal, connection.streamIdleTimeoutMs, 'MESSAGES_IDLE')
const iterator = this.request(options, connection, watchdog.signal, () => { watchdog.pulse() })
try {
while (true) {
const next = await watchdog.next(iterator)
if (next.done) return
yield next.value
}
} catch (error) {
if (timeoutOf(watchdog.signal, 'MESSAGES_IDLE') !== undefined) throw new LlmError('DeepSeek Messages stream idle timeout', 'TIMEOUT', { cause: error })
if (options.signal?.aborted) throw new LlmError('DeepSeek Messages request aborted', 'ABORTED', { cause: error })
if (error instanceof LlmError) throw error
throw new LlmError('DeepSeek Messages transport failed', 'TRANSPORT', { cause: error })
} finally {
consumer.abort()
try { await iterator.return(undefined) } catch (_abortedRequestCleanup) {
// The request already settled; aborting its reader cannot replace that outcome.
}
}
}
override providerInfo(provider: string) { return this.implementation().providerInfo(provider) }
override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) }
override listModels(provider: string) { return this.implementation().listModels(provider) }
override resolveModel(provider: string, model: string, signal?: AbortSignal) {
return this.implementation().resolveModel(provider, model, signal)
}
override imageRequestPricing(provider: string, model: string) {
return this.implementation().imageRequestPricing(provider, model)
}
override prepareCall(provider: string, model: string, signal?: AbortSignal): Promise<PreparedAdapterCall> {
return this.implementation().prepareCall(provider, model, signal)
}
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
return this.implementation().stream(options)
private async * request(
options: GenerateOptions, connection: Connection, signal: AbortSignal, activity: () => void,
): AsyncGenerator<StreamChunk> {
signal.throwIfAborted()
const { messages, versions } = await prepareImages(
options.messages, connection, options.model, this.dependencies.resolveAttachments?.(), this.imageAccess, signal,
)
const key = await this.dependencies.resolveApiKey(connection)
const files = new RequestFiles(this.files, { baseURL: connection.baseURL, apiKey: key },
connection.filePolicy, connection.filesApiTimeoutMs, signal, activity)
let inline = false
while (true) {
signal.throwIfAborted()
files.beginAttempt()
let fileIds: Awaited<ReturnType<typeof prepareFileIds>> | undefined
if (!inline) {
try {
fileIds = await prepareFileIds(messages, versions, files)
} catch (error) {
if (!(error instanceof FileResolutionFailure)) throw error
inline = true
continue
}
}
const history = inline ? inlineImages(messages, versions, connection) : messages
const body = serialize(options, connection, history, versions, this.imageAccess, (reason) => {
this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason })
}, fileIds)
const extensions = await prepareRequestExtensions(body as Readonly<Record<string, DeepSeekLlmApiJson>>, {
signal,
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
...options.purpose === undefined ? {} : { purpose: options.purpose },
}, this.dependencies.prepareExtensions)
signal.throwIfAborted()
const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, {
method: 'POST', signal, body: extensions.payload, redirect: 'error',
headers: {
...attributionHeaders(),
'content-type': 'application/json', 'accept': 'text/event-stream',
'x-api-key': key, 'anthropic-version': '2023-06-01',
...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA },
'x-deepseek-harness-user-id': this.dependencies.resolveUserId(),
...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) },
...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {},
},
})
if (!response.ok) {
const text = await response.text()
let raw: unknown
try { raw = JSON.parse(text) } catch (_nonJsonGatewayError) {
// HTTP status is authoritative when a gateway does not return JSON.
}
const detail = providerErrorDetail(raw)
if (await files.retry(detail)) continue
const failure = providerError(raw, response.status, response.headers)
const message = files.errorMessage(response.status, failure.message, detail)
throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) })
}
await extensions.accept()
if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE')
yield* translate(parseSse(response.body, activity), options.model)
return
}
}
}
+11 -22
View File
@@ -5,10 +5,10 @@ import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { DeepSeekCatalogModel, DeepSeekConnectionOptions, DeepSeekProtocol } from './common/types.ts'
import { DEFAULT_MODELS } from './common/models.ts'
import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES, DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM, DEFAULT_FILE_EXPIRY_SECONDS, DEFAULT_FILE_REFRESH_MARGIN_SECONDS, DEFAULT_FILE_QUOTA_CLEANUP_BATCH, DEFAULT_FILES_API_TIMEOUT_MS } from './common/defaults.ts'
import { DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES } from './common/request-pricing.ts'
import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './types.ts'
import { DEFAULT_MODELS } from './models.ts'
import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES, DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM, DEFAULT_FILE_EXPIRY_SECONDS, DEFAULT_FILE_REFRESH_MARGIN_SECONDS, DEFAULT_FILE_QUOTA_CLEANUP_BATCH, DEFAULT_FILES_API_TIMEOUT_MS } from './defaults.ts'
import { DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES } from './request-pricing.ts'
const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
@@ -23,8 +23,6 @@ const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModa
* reasoning effort resolves to `high`.
*/
export interface Config {
/** Wire protocol; defaults to messages. Configure through Cordis YAML. */
protocol?: DeepSeekProtocol
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
@@ -78,7 +76,6 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({
})
export const Config: z<Config> = z.object({
protocol: z.union(['chat-completions', 'messages']).default('messages'),
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
baseURL: z.string(),
thinking: z.union(['enabled', 'disabled']),
@@ -101,10 +98,7 @@ export const Config: z<Config> = z.object({
})
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
/** Official Messages protocol root. */
export const MESSAGES_BASE_URL = 'https://api.deepseek.com/anthropic'
export const PUBLIC_BASE_URL = 'https://api.deepseek.com/anthropic'
/** Environment variable naming this provider's endpoint, honored only from trusted layers. */
const BASE_URL_ENV = 'DEEPSEEK_BASE_URL'
@@ -204,9 +198,8 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
*/
export function resolveAdapterOptions(config: Config, environment?: LaunchEnvironmentSnapshot): ResolvedDeepSeekOptions {
// Settings updates can reach this resolver without schema validation.
const protocol: string = config.protocol ?? 'messages'
if (protocol !== 'chat-completions' && protocol !== 'messages') {
throw new Error('llm-deepseek: protocol must be chat-completions or messages')
if (Object.hasOwn(config, 'protocol')) {
throw new Error('llm-deepseek: protocol is not configurable; remove it and use a Messages-compatible baseURL')
}
if (config.thinking === 'disabled'
&& config.reasoningEffort !== undefined
@@ -289,16 +282,12 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro
|| fileQuotaCleanupBatch > 1_000) {
throw new Error('llm-deepseek: fileQuotaCleanupBatch must be an integer from 1 through 1000')
}
const baseURL = config.baseURL ?? environment?.get(BASE_URL_ENV)?.value
?? (protocol === 'messages' ? MESSAGES_BASE_URL : PUBLIC_BASE_URL)
if (protocol === 'messages') {
const parsed = new URL(baseURL)
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error('llm-deepseek: Messages baseURL must be an HTTP(S) root without credentials, query, or fragment')
}
const baseURL = config.baseURL ?? environment?.get(BASE_URL_ENV)?.value ?? PUBLIC_BASE_URL
const parsed = new URL(baseURL)
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error('llm-deepseek: Messages baseURL must be an HTTP(S) root without credentials, query, or fragment')
}
return {
protocol,
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
baseURL,
defaults: {
@@ -7,7 +7,6 @@ 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'
/** Shared Files-store limit for each request image, including file-id references. */
export const MAX_IMAGE_BYTES = 32 * 1024 * 1024
@@ -24,8 +23,6 @@ export interface DeepSeekFilePolicy {
export interface DeepSeekFileConnection {
baseURL: string
apiKey: string
/** Files wire protocol selected by the resolved connection. */
protocol: DeepSeekProtocol
}
/** Result of one file-id resolution. */
@@ -47,10 +44,10 @@ interface SharedUpload {
waiters: number
}
/** The Files resource's parent URL distinguishes custom protocol namespaces. */
/** The Files resource's parent URL identifies the upload namespace. */
function fileScope(connection: DeepSeekFileConnection) {
return deepSeekFileScope(
connection.protocol === 'messages' ? messagesApiRoot(connection.baseURL) : connection.baseURL,
messagesApiRoot(connection.baseURL),
connection.apiKey,
)
}
@@ -139,7 +136,6 @@ export class DeepSeekFileStore {
return new DeepSeekFilesClient({
baseURL: connection.baseURL,
apiKey: connection.apiKey,
protocol: connection.protocol,
...this.fetchImpl === undefined ? {} : { fetch: this.fetchImpl },
})
}
@@ -306,23 +302,19 @@ export class DeepSeekFileStore {
const client = this.client(connection)
let after: DeepSeekFileId | undefined
const owned: { id: DeepSeekFileId; createdAt: number }[] = []
while (connection.protocol === 'messages' || owned.length < count) {
while (true) {
const page = await client.list({
...after === undefined ? {} : { after },
limit: 1_000,
order: 'asc',
...signal === undefined ? {} : { signal },
})
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 === '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)
}
// The API 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
}
@@ -1,11 +1,10 @@
/** DeepSeek Files API transport for Chat Completions and Messages endpoints. @module dsh-llm-deepseek/files-api */
/** DeepSeek Files API transport. @module dsh-llm-deepseek/files-api */
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'
/** Minimum provider-supported file lifetime. */
export const MIN_FILE_EXPIRY_SECONDS = 3_600
@@ -18,15 +17,13 @@ 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 metadata normalized from either DeepSeek Files protocol. */
/** Validated provider file metadata. */
export interface DeepSeekFileObject {
id: DeepSeekFileIdType
bytes: number
createdAt: number
filename: string
/** Chat Completions purpose; synthesized as `user_data` for Messages. */
purpose: 'user_data'
/** Remote Chat Completions expiry or the upload-time Messages reuse deadline; Messages list/retrieve omit this field. */
/** Upload-time reuse deadline; list and retrieve responses omit this field. */
expiresAt?: number
}
@@ -74,20 +71,9 @@ export function isFilesQuotaError(error: unknown): error is DeepSeekFilesError {
interface FilesApiOptions {
baseURL: string
apiKey: string
protocol: DeepSeekProtocol
fetch?: typeof fetch
}
interface WireFileObject {
id?: unknown
object?: unknown
bytes?: unknown
created_at?: unknown
filename?: unknown
purpose?: unknown
expires_at?: unknown
}
function invalidResponse(operation: string): LlmError {
return new LlmError(`DeepSeek Files API returned an invalid ${operation} response.`, 'INVALID_RESPONSE')
}
@@ -107,39 +93,25 @@ async function responseJson(response: Response, operation: string): Promise<unkn
function parseFileObject(value: unknown, operation: string): DeepSeekFileObject {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse(operation)
const wire = value as WireFileObject
const wire = value as Record<string, unknown>
const createdAt = typeof wire.created_at === 'string' ? Math.floor(Date.parse(wire.created_at) / 1_000) : NaN
if (typeof wire.id !== 'string' || wire.id.length === 0
|| wire.object !== 'file'
|| !Number.isSafeInteger(wire.bytes) || (wire.bytes as number) < 0
|| !Number.isSafeInteger(wire.created_at) || (wire.created_at as number) < 0
|| wire.type !== 'file'
|| typeof wire.mime_type !== 'string'
|| typeof wire.size_bytes !== 'number' || !Number.isSafeInteger(wire.size_bytes) || wire.size_bytes < 0
|| !Number.isSafeInteger(createdAt) || createdAt < 0
|| typeof wire.filename !== 'string' || wire.filename.length === 0
|| wire.purpose !== 'user_data'
|| (wire.expires_at !== undefined
&& (!Number.isSafeInteger(wire.expires_at) || (wire.expires_at as number) < 0))) {
) {
throw invalidResponse(operation)
}
return {
id: DeepSeekFileId(wire.id),
bytes: wire.bytes as number,
createdAt: wire.created_at as number,
bytes: wire.size_bytes,
createdAt,
filename: wire.filename,
purpose: 'user_data',
...wire.expires_at === undefined ? {} : { expiresAt: wire.expires_at as number },
}
}
/** Normalize the Messages wire object without interpreting omitted expiration as permanence. */
function parseMessagesFile(value: unknown, operation: string): DeepSeekFileObject {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse(operation)
const wire = value as Record<string, unknown>
const createdAt = typeof wire.created_at === 'string' ? Math.floor(Date.parse(wire.created_at) / 1_000) : NaN
if (wire.type !== 'file' || typeof wire.mime_type !== 'string' || !Number.isSafeInteger(createdAt)) throw invalidResponse(operation)
return parseFileObject({
id: wire.id, object: 'file', bytes: wire.size_bytes, created_at: createdAt,
filename: wire.filename, purpose: 'user_data',
}, operation)
}
function providerErrorDetail(value: unknown): { message?: string; detail: string } {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { detail: '' }
const error = (value as { error?: unknown }).error
@@ -159,8 +131,6 @@ export class DeepSeekFilesClient {
private readonly baseURL: string
private readonly apiKey: string
private readonly fetchImpl: typeof fetch
private readonly protocol: DeepSeekProtocol
private readonly path: string
/**
* @param options - endpoint, API-key snapshot, and optional test transport.
@@ -168,26 +138,16 @@ export class DeepSeekFilesClient {
constructor(options: FilesApiOptions) {
this.apiKey = options.apiKey
this.fetchImpl = options.fetch ?? globalThis.fetch
this.protocol = options.protocol
this.baseURL = this.protocol === 'messages'
? messagesApiRoot(options.baseURL)
: options.baseURL.replace(/\/+$/u, '')
this.path = '/files'
}
private parseFile(value: unknown, operation: string): DeepSeekFileObject {
return this.protocol === 'messages' ? parseMessagesFile(value, operation) : parseFileObject(value, operation)
this.baseURL = messagesApiRoot(options.baseURL)
}
private async request(path: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
let response: Response
try {
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}`)
headers.set('x-api-key', this.apiKey)
headers.set('anthropic-version', '2023-06-01')
headers.set('anthropic-beta', MESSAGES_FILES_BETA)
response = await this.fetchImpl(`${this.baseURL}${path}`, {
...init,
redirect: 'error',
@@ -235,45 +195,40 @@ export class DeepSeekFilesClient {
throw new LlmError('DeepSeek file expiry must be between 3600 and 2592000 seconds.', 'INVALID_REQUEST')
}
const form = new FormData()
if (this.protocol === 'chat-completions') form.set('purpose', 'user_data')
form.set('expires_after[anchor]', 'created_at')
form.set('expires_after[seconds]', String(input.expiresAfterSeconds))
form.set('file', new Blob([Uint8Array.from(input.data).buffer], { type: input.mediaType }), input.filename)
const response = await this.request(this.path, { method: 'POST', body: form }, input.signal)
const file = this.parseFile(await responseJson(response, 'upload'), 'upload')
if (this.protocol === 'messages') return { ...file, expiresAt: file.createdAt + input.expiresAfterSeconds }
if (file.expiresAt === undefined) throw invalidResponse('upload')
return { ...file, expiresAt: file.expiresAt }
const response = await this.request('/files', { method: 'POST', body: form }, input.signal)
const file = parseFileObject(await responseJson(response, 'upload'), 'upload')
return { ...file, expiresAt: file.createdAt + input.expiresAfterSeconds }
}
/**
* 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 with null Messages cursors omitted.
* List one provider-ordered page of files.
* @param options - pagination and cancellation.
* @returns the validated page with null cursors omitted.
*/
async list(options: {
after?: DeepSeekFileIdType
limit?: number
order?: 'asc' | 'desc'
signal?: AbortSignal
} = {}): Promise<DeepSeekFilePage> {
const query = new URLSearchParams(this.protocol === 'messages' ? {} : { purpose: 'user_data' })
if (options.after !== undefined) query.set(this.protocol === 'messages' ? 'after_id' : 'after', options.after)
const query = new URLSearchParams()
if (options.after !== undefined) query.set('after_id', options.after)
if (options.limit !== undefined) query.set('limit', String(options.limit))
if (options.order !== undefined && this.protocol === 'chat-completions') query.set('order', options.order)
const response = await this.request(`${this.path}?${query.toString()}`, { method: 'GET' }, options.signal)
const response = await this.request(`/files?${query.toString()}`, { method: 'GET' }, options.signal)
const value: unknown = await responseJson(response, 'list')
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'
const wire = value as { data?: unknown; first_id?: unknown; last_id?: unknown; has_more?: unknown }
const firstId = wire.first_id ?? undefined
const lastId = wire.last_id ?? undefined
if (!Array.isArray(wire.data) || typeof wire.has_more !== 'boolean'
|| (firstId !== undefined && typeof firstId !== 'string')
|| (lastId !== undefined && typeof lastId !== 'string')) {
throw invalidResponse('list')
}
return {
data: wire.data.map(item => this.parseFile(item, 'list')),
data: wire.data.map(item => parseFileObject(item, 'list')),
...typeof firstId === 'string' ? { firstId: DeepSeekFileId(firstId) } : {},
...typeof lastId === 'string' ? { lastId: DeepSeekFileId(lastId) } : {},
hasMore: wire.has_more,
@@ -287,8 +242,8 @@ export class DeepSeekFilesClient {
* @returns the validated file object.
*/
async retrieve(fileId: DeepSeekFileIdType, signal?: AbortSignal): Promise<DeepSeekFileObject> {
const response = await this.request(`${this.path}/${encodeURIComponent(fileId)}`, { method: 'GET' }, signal)
return this.parseFile(await responseJson(response, 'retrieve'), 'retrieve')
const response = await this.request(`/files/${encodeURIComponent(fileId)}`, { method: 'GET' }, signal)
return parseFileObject(await responseJson(response, 'retrieve'), 'retrieve')
}
/**
@@ -297,12 +252,10 @@ export class DeepSeekFilesClient {
* @param signal - request cancellation.
*/
async delete(fileId: DeepSeekFileIdType, signal?: AbortSignal): Promise<void> {
const response = await this.request(`${this.path}/${encodeURIComponent(fileId)}`, { method: 'DELETE' }, signal)
const response = await this.request(`/files/${encodeURIComponent(fileId)}`, { method: 'DELETE' }, signal)
const value: unknown = await responseJson(response, 'delete')
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse('delete')
const wire = value as { id?: unknown; object?: unknown; deleted?: unknown; type?: unknown }
if (wire.id !== fileId || (this.protocol === 'messages'
? wire.type !== 'file_deleted'
: wire.object !== 'file' || wire.deleted !== true)) throw invalidResponse('delete')
const wire = value as { id?: unknown; type?: unknown }
if (wire.id !== fileId || wire.type !== 'file_deleted') throw invalidResponse('delete')
}
}
@@ -3,12 +3,12 @@
import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, projectOffloadedImages, requiredImageOffload } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, ImageAttachmentAccessResolver, RequestMessage } from '@deepseek-ai/dsh-llm'
import type { DeepSeekConnectionOptions as Connection } from '../../common/types.ts'
import { resolveRequestImageTarget } from '../../common/request-pricing.ts'
import type { DeepSeekFileId } from '../../common/file-id.ts'
import type { RequestFiles } from '../../common/request-files.ts'
import type { DeepSeekConnectionOptions as Connection } from './types.ts'
import { resolveRequestImageTarget } from './request-pricing.ts'
import type { DeepSeekFileId } from './file-id.ts'
import type { RequestFiles } from './request-files.ts'
export { deepSeekImageRequestPricing as imagePricing } from '../../common/request-pricing.ts'
export { deepSeekImageRequestPricing as imagePricing } from './request-pricing.ts'
function bounds(connection: Connection, representation: 'raw' | 'base64') {
return {
+15 -17
View File
@@ -1,4 +1,4 @@
/** Register DeepSeek with protocol selection and request-local settings and credentials. */
/** Register DeepSeek Messages with request-local settings and credentials. */
import type { Context } from '@deepseek-ai/cordis'
import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-fs'
@@ -10,7 +10,7 @@ import { DeepSeekAdapter } from './adapter.ts'
import { Config, resolveAdapterOptions } from './config.ts'
import type { ResolvedDeepSeekOptions } from './config.ts'
export { Config, resolveAdapterOptions, PUBLIC_BASE_URL, MESSAGES_BASE_URL } from './config.ts'
export { Config, resolveAdapterOptions, PUBLIC_BASE_URL } from './config.ts'
export type { ResolvedDeepSeekOptions } from './config.ts'
export {
DEFAULT_CONTEXT_WINDOW,
@@ -24,10 +24,9 @@ export {
DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES,
DEFAULT_MAX_TOKENS,
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
} from './common/defaults.ts'
} from './defaults.ts'
export { DeepSeekAdapter } from './adapter.ts'
export type { DeepSeekProtocol } from './common/types.ts'
export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './common/types.ts'
export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './types.ts'
export {
DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET,
DEFAULT_MAX_IMAGES_PER_REQUEST,
@@ -37,18 +36,17 @@ export {
deepSeekImageRequestPricing,
resolveRequestImageMaxBytes,
resolveRequestImageTarget,
} from './common/request-pricing.ts'
export { deepSeekImageTokens, deepSeekRequestImageDimensions } from './common/image-tokens.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'
export { DeepSeekFileId } from './common/file-id.ts'
export type { DeepSeekFileId as DeepSeekFileIdType } from './common/file-id.ts'
export { DeepSeekUploadIndex, deepSeekFileScope } from './common/upload-index.ts'
export type { DeepSeekUploadRecord } from './common/upload-index.ts'
export type { RequestDefaults } from './common/types.ts'
export type * from './protocols/chat-completions/types.ts'
} from './request-pricing.ts'
export { deepSeekImageTokens, deepSeekRequestImageDimensions } from './image-tokens.ts'
export { DeepSeekFileStore, MAX_IMAGE_BYTES } from './file-store.ts'
export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './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 './files-api.ts'
export type { DeepSeekFileObject, DeepSeekFilePage } from './files-api.ts'
export { DeepSeekFileId } from './file-id.ts'
export type { DeepSeekFileId as DeepSeekFileIdType } from './file-id.ts'
export { DeepSeekUploadIndex, deepSeekFileScope } from './upload-index.ts'
export type { DeepSeekUploadRecord } from './upload-index.ts'
export type { RequestDefaults } from './types.ts'
export const name = 'llm-deepseek'
export const inject = ['llm']
@@ -1,4 +1,4 @@
/** Default catalog shared by every DeepSeek protocol. */
/** Default DeepSeek model catalog. */
import { DEFAULT_CONTEXT_WINDOW } from './defaults.ts'
import type { DeepSeekCatalogModel } from './types.ts'
@@ -1,378 +0,0 @@
/**
* `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible)
* chat-completions endpoint, emitting harness StreamChunks. The adapter is
* transport-only: connection facts arrive through a thunk resolved once per
* operation and the bearer token through a per-request resolver, so the
* registering plugin owns validation, layering, and credential policy.
*
* @module dsh-llm-deepseek/adapter
*/
import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
import type {
ContentBlock,
GenerateOptions,
ImageAttachmentAccess,
LlmModelInfo,
LlmProviderInfo,
PreparedAdapterCall,
LlmResolvedModelInfo,
ResolvedRetryPolicy,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import type {
AttachmentId,
AttachmentStore,
ImageAttachmentRef,
RequestImageAttachment,
} from '@deepseek-ai/dsh-attachment'
import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import type {
DeepSeekLlmApiJson,
} from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
import { serializeRequest, serializeRequestWithImages } from './serialize.ts'
import { deepSeekImageRequestPricing, resolveRequestImageTarget } from '../../common/request-pricing.ts'
import { catalogModelInfo, modelInfo } from '../../common/model-info.ts'
import type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from '../../common/types.ts'
import type { DeepSeekFileStore } from '../../common/file-store.ts'
import { FileResolutionFailure, RequestFiles } from '../../common/request-files.ts'
import { prepareRequestExtensions } from '../../common/request-extensions.ts'
import { parseSse } from './sse.ts'
import { translate } from './translate.ts'
import type { WireError, WireRequest } from './types.ts'
const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT'
function collectImageRefs(
content: readonly ContentBlock[],
refs: Map<AttachmentId, ImageAttachmentRef>,
): void {
for (const block of content) {
if (block.type === 'image' && block.offloaded !== true) refs.set(block.attachment.attachmentId, block.attachment)
}
}
async function prepareRequestImages(
options: GenerateOptions,
attachments: AttachmentStore,
model: DeepSeekCatalogModel,
signal: AbortSignal,
): Promise<Map<AttachmentId, RequestImageAttachment>> {
const refs = new Map<AttachmentId, ImageAttachmentRef>()
for (const message of options.messages) collectImageRefs(message.content, refs)
const orderedRefs = [...refs.values()]
const projected = await Promise.all(orderedRefs.map(
ref => attachments.readImageRequest(ref, resolveRequestImageTarget(model, ref), signal),
))
return new Map(orderedRefs.map((ref, index) => (
[ref.attachmentId, projected[index] as RequestImageAttachment]
)))
}
function providerRetryAfterMs(value: string | null): number | undefined {
if (value === null) return undefined
if (/^\d+$/.test(value)) {
const delay = Number(value) * 1_000
return Number.isFinite(delay) && delay > 0 ? delay : undefined
}
const delay = Date.parse(value) - Date.now()
return Number.isFinite(delay) && delay > 0 ? delay : undefined
}
function requestId(headers: Headers): ReturnType<typeof ProviderRequestId> | undefined {
const value = headers.get('x-request-id') ?? headers.get('x-deepseek-request-id')
return value === null || value.length === 0 ? undefined : ProviderRequestId(value)
}
/**
* Map an HTTP status to a stable LlmError code.
* @param status - status of a non-2xx provider response.
* @param error - parsed provider error body, when available.
* @returns the normalized harness error code.
*/
export function httpErrorCode(status: number, error?: WireError['error']): string {
if (status === 401 || status === 403) return 'AUTH'
if (status === 413) return 'INVALID_REQUEST'
const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ')
if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE
if (status === 429) return 'RATE_LIMIT'
if (status === 400) {
if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE
return 'INVALID_REQUEST'
}
if (status >= 500) return 'SERVER'
return `HTTP_${status}`
}
/**
* The first real `LlmAdapter`. One instance serves every model name it was
* registered under (the harness model name IS the wire model name).
*
* One stable signal reaches both initial fetch and body reads. Caller aborts
* map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`.
*/
export class ChatCompletionsAdapter extends LlmAdapter {
private readonly files: DeepSeekFileStore
constructor(private readonly config: DeepSeekAdapterOptions & { resolveFiles: () => DeepSeekFileStore }) {
super()
this.files = config.resolveFiles()
}
override providerInfo(provider: string): LlmProviderInfo {
return { id: provider, name: 'DeepSeek' }
}
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
return this.config.options().retryPolicy
}
override imageRequestPricing(_provider: string, model: string): ReturnType<LlmAdapter['imageRequestPricing']> {
// The same access resolution the serializer uses, so priced handle and
// placeholder text matches what the request actually sends.
const attachments = this.config.resolveAttachments?.()
const resolveAccess = attachments === undefined
? undefined
: (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => (
this.config.resolveImageAccess?.(attachments, ref)
)
return deepSeekImageRequestPricing(this.config.options(), model, resolveAccess)
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve(this.config.options().models.map(model => catalogModelInfo(provider, model)))
}
override resolveModel(
provider: string,
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
return Promise.resolve(modelInfo(this.config.options(), provider, model))
}
override prepareCall(provider: string, model: string, _signal?: AbortSignal): Promise<PreparedAdapterCall> {
const connection = this.config.options()
return Promise.resolve({
model: modelInfo(connection, provider, model),
stream: options => this.streamWithConnection(options, connection),
})
}
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
return this.streamWithConnection(options, this.config.options())
}
private async * streamWithConnection(
options: GenerateOptions,
connection: DeepSeekConnectionOptions,
): AsyncIterable<StreamChunk> {
// One resolution per stream call: connection facts and the credential
// freeze here and hold for this whole request, so an in-flight stream
// never observes a configuration change and the next call re-resolves.
// The key resolves *from this snapshot*, so an endpoint and the secret
// sent to it can never come from different configuration generations.
const hasImages = options.messages.some(message => contentHasImage(message.content))
let attachments: AttachmentStore | undefined
if (hasImages) {
const model = connection.models.find(entry => entry.id === options.model)
if (model?.inputModalities?.includes('image') !== true) {
throw new LlmError(
`DeepSeek model "${options.model}" does not accept image input.`,
'UNSUPPORTED_CONTENT',
)
}
attachments = this.config.resolveAttachments?.()
if (attachments === undefined) {
throw new LlmError(
'DeepSeek image conversion requires the durable attachment service.',
'UNSUPPORTED_CONTENT',
)
}
}
const apiKey = await this.config.resolveApiKey(connection)
const userId = this.config.resolveUserId()
const consumer = new AbortController()
const upstream = options.signal === undefined
? consumer.signal
: AbortSignal.any([options.signal, consumer.signal])
using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE)
const iterator = this.request(
options,
watchdog.signal,
connection,
apiKey,
userId,
attachments,
() => { watchdog.pulse() },
)[Symbol.asyncIterator]()
let exhausted = false
try {
while (true) {
const result = await watchdog.next(iterator)
if (result.done) {
exhausted = true
return
}
yield result.value
}
} catch (error: unknown) {
if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) {
throw new LlmError(
`DeepSeek stream idle timeout after ${connection.streamIdleTimeoutMs}ms`,
'TIMEOUT',
{ cause: error },
)
}
if (options.signal?.aborted) {
throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error })
}
if (error instanceof LlmError) throw error
throw new LlmError(`DeepSeek API stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error })
} finally {
consumer.abort('DeepSeek stream consumer stopped')
if (!exhausted && iterator.return !== undefined) {
try {
await iterator.return()
} catch (_abortedTransportTeardown) {
// The consumer controller already owns termination; a return-time abort cannot add a second outcome.
}
}
}
}
private async * request(
options: GenerateOptions,
signal: AbortSignal,
connection: DeepSeekConnectionOptions,
apiKey: string,
userId: AnonymousUserId,
attachments: AttachmentStore | undefined,
onActivity: () => void,
): AsyncIterable<StreamChunk> {
const headers = {
'authorization': `Bearer ${apiKey}`,
'content-type': 'application/json',
'accept': 'text/event-stream',
...attributionHeaders(),
'x-deepseek-harness-user-id': String(userId),
...options.sessionId !== undefined
? { 'x-deepseek-harness-session-id': String(options.sessionId) }
: {},
...options.purpose === 'compaction'
? { 'x-deepseek-harness-compact': '1' }
: {},
}
const fileConnection = { baseURL: connection.baseURL, apiKey, protocol: connection.protocol }
const model = connection.models.find(entry => entry.id === options.model)
const resolveImageAccess = attachments === undefined
? undefined
: (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => this.config.resolveImageAccess?.(attachments, ref)
const imageAccessOptions = resolveImageAccess === undefined ? {} : { resolveImageAccess }
const requestOptions = options
const requestImages = attachments === undefined || model === undefined
? new Map<AttachmentId, RequestImageAttachment>()
: await prepareRequestImages(requestOptions, attachments, model, signal)
let representation: 'file' | 'base64' = 'file'
const requestFiles = new RequestFiles(
this.files, fileConnection, connection.filePolicy, connection.filesApiTimeoutMs, signal, onActivity,
)
while (true) {
requestFiles.beginAttempt()
let body: WireRequest
if (attachments === undefined) {
body = serializeRequest(requestOptions, connection.defaults)
} else if (representation === 'base64') {
body = await serializeRequestWithImages(requestOptions, {
representation: { kind: 'base64' },
requestImages,
...imageAccessOptions,
maxRequestImageBytes: connection.maxInlineRequestImageBytes,
maxImagesPerRequest: connection.maxImagesPerRequest,
byteQuantum: connection.inlineImageOffloadByteQuantum,
countQuantum: connection.imageOffloadCountQuantum,
}, connection.defaults)
} else {
try {
body = await serializeRequestWithImages(requestOptions, {
representation: {
kind: 'file',
resolveFileId: (version, _block, location) => requestFiles.resolve(version, location),
},
requestImages,
...imageAccessOptions,
maxRequestImageBytes: connection.maxRequestFilesBytes,
maxImagesPerRequest: connection.maxImagesPerRequest,
byteQuantum: connection.imageOffloadByteQuantum,
countQuantum: connection.imageOffloadCountQuantum,
}, connection.defaults)
} catch (error: unknown) {
if (!(error instanceof FileResolutionFailure)) throw error
representation = 'base64'
continue
}
}
const extensions = await prepareRequestExtensions(body as unknown as Readonly<Record<string, DeepSeekLlmApiJson>>, {
signal,
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
...options.purpose === undefined ? {} : { purpose: options.purpose },
}, this.config.prepareExtensions)
// TODO(http): adopt the Cordis HTTP service when shared transport configuration
// outweighs its additional runtime dependencies.
let response: Response
try {
response = await fetch(`${connection.baseURL}/chat/completions`, {
method: 'POST',
headers,
body: extensions.payload,
signal,
})
} catch (error: unknown) {
if (signal.aborted) throw error
throw new LlmError(
`DeepSeek API request to ${connection.baseURL} failed`,
'TRANSPORT',
{ cause: error },
)
}
if (!response.ok) {
let message = `DeepSeek API error (HTTP ${response.status})`
let providerError: WireError['error']
const rawResponse = await response.text()
try {
const parsed = JSON.parse(rawResponse) as WireError
providerError = parsed.error
if (providerError?.message) message = providerError.message
} catch {
// The HTTP status remains authoritative when a gateway returns malformed JSON.
}
const detail = [providerError?.code, providerError?.type, providerError?.message]
.filter((field): field is string => typeof field === 'string')
.join(' ')
if (await requestFiles.retry(detail)) continue
message = requestFiles.errorMessage(response.status, message, detail)
const delay = providerRetryAfterMs(response.headers.get('retry-after'))
const id = requestId(response.headers)
throw new LlmError(message, httpErrorCode(response.status, providerError), {
cause: new Error(rawResponse.length > 0 ? rawResponse : `DeepSeek HTTP ${response.status}`),
status: response.status,
...delay === undefined ? {} : { providerRetryAfterMs: delay },
...id === undefined ? {} : { requestId: id },
})
}
await extensions.accept()
if (!response.body) {
throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE')
}
yield* translate(parseSse(response.body, onActivity))
return
}
}
}
@@ -1,444 +0,0 @@
/**
* Serialize harness messages into DeepSeek chat completions. Text-only
* requests retain string user content; the image path resolves durable
* attachments into ordered file-id or inline parts. Tool-role messages map to
* string-only `tool` wire messages; their image parts follow in one shared
* trailing user message.
* @module dsh-llm-deepseek/serialize
*/
import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, projectOffloadedImages, requestImageHandleText, requiredImageOffload } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message, RequestMessage } from '@deepseek-ai/dsh-llm'
import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
import type {
WireImageContentPart,
WireMessage,
WireRequest,
WireTextContentPart,
WireTool,
WireUserContentPart,
} from './types.ts'
import type { RequestDefaults } from '../../common/types.ts'
interface ResolvedThinking {
thinking?: 'enabled' | 'disabled'
reasoningEffort?: 'low' | 'high' | 'max'
}
/** Provider representation for every retained image in one request. */
export type ImageRequestRepresentation =
| {
kind: 'file'
/** Resolve a retained request version to a reusable DeepSeek file id. */
resolveFileId: (
version: RequestImageAttachment,
block: Extract<ContentBlock, { type: 'image' }>,
location: ImageWireLocation,
) => Promise<string>
}
| { kind: 'base64' }
/** Dependencies required only when the request contains image input. */
export interface ImageSerializationOptions {
/** One representation used for every retained image in this request. */
representation: ImageRequestRepresentation
/** Request versions prepared for the conservatively retained normalized attachments, keyed by attachment id. */
requestImages: ReadonlyMap<ImageAttachmentRef['attachmentId'], RequestImageAttachment>
/** Resolve current tool access independently from deterministic request-image versions. */
resolveImageAccess?: ImageAttachmentAccessResolver
/** Positive bound on accumulated represented image bytes. */
maxRequestImageBytes: number
/** Maximum represented images in one request. */
maxImagesPerRequest?: number
/** Represented-byte removal step applied after the request exceeds its byte bound. */
byteQuantum?: number
/** Image-count removal step applied after the request exceeds its count bound. */
countQuantum?: number
}
export type { ImageWireLocation } from '../../common/request-files.ts'
import type { ImageWireLocation } from '../../common/request-files.ts'
const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:'
/** Validate the adapter-owned effort before resolving its DeepSeek wire fields. */
function reasoningEffort(effort: NonNullable<GenerateOptions['reasoningEffort']>): 'off' | 'low' | 'high' | 'max' {
if (effort === 'off' || effort === 'low' || effort === 'high' || effort === 'max') {
return effort as 'off' | 'low' | 'high' | 'max'
}
throw new LlmError(
`DeepSeek does not support reasoning effort "${effort}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
/** Resolve one legal thinking/effort pair without exposing `off` as a wire effort. */
function resolveThinking(options: GenerateOptions, defaults: RequestDefaults): ResolvedThinking {
if (options.purpose === 'session-title') return { thinking: 'disabled' }
const effort = options.reasoningEffort === undefined
? defaults.reasoningEffort
: reasoningEffort(options.reasoningEffort)
if (defaults.thinking === 'disabled' && effort !== undefined && effort !== 'off') {
throw new LlmError(
`DeepSeek deployment does not support reasoning effort "${effort}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
if (effort === 'off') return { thinking: 'disabled' }
if (effort === 'low' || effort === 'high' || effort === 'max') {
return { thinking: 'enabled', reasoningEffort: effort }
}
return defaults.thinking === undefined ? {} : { thinking: defaults.thinking }
}
/** Join the text blocks of a message (used for user and tool-role content). */
function flattenText(blocks: readonly ContentBlock[]): string {
return blocks
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
/** Reject core image content before any text-flattening path can silently erase it. */
function assertTextOnly(blocks: readonly ContentBlock[]): void {
if (contentHasImage(blocks)) {
throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT')
}
}
/** Reject developer history before text serialization or image offloading. */
function assertSupportedRole(message: RequestMessage): void {
// Developer history is persisted for V4; provider serialization is intentionally deferred.
if (message.role === 'developer') throw new LlmError('Developer messages are not supported yet', 'UNSUPPORTED_CONTENT')
if (message.content.some(block => block.type === 'tool-addition' || block.type === 'tool-removal')) {
throw new LlmError('Tool-change blocks require developer role', 'UNSUPPORTED_CONTENT')
}
}
/** Reject unsupported roles, tool-change blocks, and image roles before history conversion. */
function assertSupportedHistory(messages: readonly RequestMessage[]): void {
for (const message of messages) {
assertSupportedRole(message)
if (message.role !== 'user' && message.role !== 'tool' && contentHasImage(message.content)) {
throw new LlmError(
`The DeepSeek chat-completions adapter cannot represent image content in a ${message.role} message.`,
'UNSUPPORTED_CONTENT',
)
}
}
}
/** Describe the exact request preview and its model-callable coordinate system. */
function imageHandle(
ref: ImageAttachmentRef,
version: RequestImageAttachment,
resolveAccess: ImageAttachmentAccessResolver | undefined,
precededByContent: boolean,
): WireTextContentPart {
return {
type: 'text',
text: `${precededByContent ? '\n' : ''}${requestImageHandleText(ref, version, resolveAccess?.(ref))}`,
}
}
/** Resolve one durable image into its descriptor and transient DeepSeek image part. */
async function imageParts(
block: Extract<ContentBlock, { type: 'image' }>,
images: ImageSerializationOptions,
location: ImageWireLocation,
precededByContent: boolean,
): Promise<[WireTextContentPart, WireImageContentPart]> {
const version = images.requestImages.get(block.attachment.attachmentId)
if (version === undefined) {
throw new LlmError(
`DeepSeek request image ${block.attachment.attachmentId} was not prepared.`,
'INVALID_REQUEST',
)
}
const image: WireImageContentPart = images.representation.kind === 'file'
? { type: 'file', file_id: await images.representation.resolveFileId(version, block, location) }
: {
type: 'image_url',
image_url: { url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString('base64')}` },
}
return [imageHandle(block.attachment, version, images.resolveImageAccess, precededByContent), image]
}
/** Convert one message's content blocks into ordered wire parts. */
async function contentParts(
blocks: readonly ContentBlock[],
images: ImageSerializationOptions,
message: number,
nextImage: { value: number },
): Promise<WireUserContentPart[]> {
const parts: WireUserContentPart[] = []
for (const block of blocks) {
switch (block.type) {
case 'text':
if (block.text.length > 0) parts.push({ type: 'text', text: block.text })
break
case 'image':
nextImage.value += 1
parts.push(...await imageParts(block, images, { message, image: nextImage.value }, parts.length > 0))
break
default:
// Other merge-extensible blocks are not DeepSeek user-input vocabulary.
break
}
}
return parts
}
/** Keep text-only user messages on the compact string wire form. */
function userContent(parts: readonly WireUserContentPart[]): string | WireUserContentPart[] {
const text: string[] = []
for (const part of parts) {
if (part.type !== 'text') return [...parts]
text.push(part.text)
}
return text.join('')
}
/** Serialize one assistant message (text + reasoning + tool calls). */
function serializeAssistant(message: Message): WireMessage {
const text = flattenText(message.content)
const reasoning = message.content
.filter(block => block.type === 'reasoning')
.map(block => block.text)
.join('')
const toolCalls = message.content
.filter(block => block.type === 'tool-call')
.map(block => ({
id: block.id,
type: 'function' as const,
function: { name: block.name, arguments: block.arguments },
}))
return {
role: 'assistant',
// Text-less turns send "" — NEVER null. Pure tool-call turns: the
// official samples replay message.content verbatim (which is "") and
// some gateways reject null outright. Reasoning-ONLY turns (the model
// can answer entirely in the reasoning channel, e.g. a v4-flash
// greeting): the live API rejects null-content/no-tool_calls assistant
// messages with a 400 ("content or tool_calls must be set"), and since
// the message sits durably in the session log, a null here bricks every
// later turn of that session.
content: text,
// CoT passback on every reasoning-carrying turn. The official rule
// (guides/thinking_mode.mdx) requires it on tool-call turns and ignores it
// elsewhere; a gateway re-encoding the conversation for another vendor
// recovers that turn's upstream thinking signature by hashing this exact
// text, which a tool-call-free turn carries nowhere else.
...reasoning.length > 0 ? { reasoning_content: reasoning } : {},
...toolCalls.length > 0 ? { tool_calls: toolCalls } : {},
}
}
/**
* Serialize the conversation. Tool-role messages map one-to-one onto
* `{role: 'tool'}` wire messages; user messages contribute their text as a
* plain `{role: 'user'}` wire message.
* @param messages - the harness conversation, in order.
* @returns the wire messages; order preserved.
*/
export function serializeMessages(messages: RequestMessage[]): WireMessage[] {
const wire: WireMessage[] = []
for (const message of messages) {
assertSupportedRole(message)
assertTextOnly(message.content)
if (message.role === 'system') {
wire.push({ role: 'system', content: flattenText(message.content) })
continue
}
if (message.role === 'assistant') {
wire.push(serializeAssistant(message))
continue
}
if (message.role === 'tool') {
wire.push({
role: 'tool',
tool_call_id: message.toolCallId,
// Empty tool output still needs SOME content on the wire.
content: flattenText(message.content) || '(no output)',
})
continue
}
// User text always lands on the wire, including empty ones.
wire.push({ role: 'user', content: flattenText(message.content) })
}
return wire
}
/**
* Serialize image-capable history after resolving durable attachments.
* Consecutive tool messages keep string `tool` wire messages and share one
* following user message containing their images.
* @param messages - request history whose offloaded occurrences are already placeholder text.
* @param images - prepared request versions, one provider representation, and its budget.
* @returns ordered DeepSeek wire messages.
*/
export async function serializeMessagesWithImages(
messages: readonly RequestMessage[],
images: ImageSerializationOptions,
): Promise<WireMessage[]> {
assertSupportedHistory(messages)
const wire: WireMessage[] = []
let pendingToolImages: WireImageContentPart[] = []
const flushToolImages = (): void => {
if (pendingToolImages.length === 0) return
wire.push({
role: 'user',
content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages],
})
pendingToolImages = []
}
for (const [messageIndex, message] of messages.entries()) {
const nextImage = { value: 0 }
if (message.role === 'system') {
flushToolImages()
wire.push({ role: 'system', content: flattenText(message.content) })
continue
}
if (message.role === 'assistant') {
flushToolImages()
wire.push(serializeAssistant(message))
continue
}
if (message.role === 'tool') {
const parts = await contentParts(message.content, images, messageIndex + 1, nextImage)
const imageParts = parts.filter((part): part is WireImageContentPart => part.type !== 'text')
const text = parts.filter(part => part.type === 'text').map(part => part.text).join('')
wire.push({
role: 'tool',
tool_call_id: message.toolCallId,
content: text || '(no output)',
})
pendingToolImages.push(...imageParts)
continue
}
// User text serializes as a user message, including empty content. This
// keeps the image-capable route aligned with the text-only route.
const content = userContent(await contentParts(message.content, images, messageIndex + 1, nextImage))
flushToolImages()
wire.push({ role: 'user', content })
}
flushToolImages()
return wire
}
/** Assemble request fields shared by text-only and image-capable conversion. */
function requestWithMessages(
options: GenerateOptions,
messages: WireMessage[],
defaults: RequestDefaults,
): WireRequest {
// Deferred definitions are persisted for V4; provider loading is intentionally deferred.
if (options.tools?.some(tool => tool.deferLoading === true)) {
throw new LlmError('Deferred tool loading is not supported yet', 'UNSUPPORTED_CONTENT')
}
const tools: WireTool[] | undefined = options.tools?.map(tool => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
},
}))
const resolvedThinking = resolveThinking(options, defaults)
return {
model: options.model,
messages,
stream: true,
stream_options: { include_usage: true },
...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {},
...resolvedThinking.reasoningEffort !== undefined
? { reasoning_effort: resolvedThinking.reasoningEffort }
: {},
...tools !== undefined && tools.length > 0 ? { tools } : {},
...options.temperature !== undefined ? { temperature: options.temperature } : {},
...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens },
...options.stop !== undefined ? { stop: options.stop } : {},
}
}
/**
* Build the full wire request. Always streaming (`stream: true`, usage
* reporting on); optional fields are omitted rather than sent as null, so
* provider defaults apply.
* @param options - the harness request (model, history, system, tools, sampling).
* @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire.
* @returns the chat-completions request body.
*/
export function serializeRequest(
options: GenerateOptions,
defaults: RequestDefaults = {},
): WireRequest {
const messages: WireMessage[] = []
if (options.system !== undefined) {
messages.push({ role: 'system', content: options.system })
}
messages.push(...serializeMessages(options.messages))
return requestWithMessages(options, messages, defaults)
}
/**
* Reject a request whose retained occurrences, at their exact request-version
* byte lengths under this representation, still exceed the route budget. The
* failure names how many more oldest retained occurrences need durable
* omission before the request can be retried.
*/
function assertRetainedImagesFit(messages: readonly RequestMessage[], images: ImageSerializationOptions): void {
const representation = images.representation.kind === 'file' ? 'raw' : 'base64'
const offloadImages = requiredImageOffload(messages, {
representation,
maxBytes: images.maxRequestImageBytes,
...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest },
...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum },
...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum },
}, (block) => {
const version = images.requestImages.get(block.attachment.attachmentId)
if (version === undefined) {
throw new LlmError(`DeepSeek request image ${block.attachment.attachmentId} was not prepared.`, 'INVALID_REQUEST')
}
return version.bytes
})
if (offloadImages > 0) {
throw new LlmError(
`DeepSeek ${representation} request images exceed the route budget; ${offloadImages} more oldest occurrence(s) must be offloaded.`,
IMAGE_OFFLOAD_REQUIRED_CODE,
{ offloadImages },
)
}
}
/**
* Build one image-capable request while keeping durable bytes out of session
* messages. Offloaded occurrences become per-image text; retained occurrences
* must fit the route budget at their exact request-version byte lengths.
* @param options - harness request containing image-capable user content.
* @param images - request versions, optional current access resolver, and request bounds.
* @param defaults - adapter-level thinking defaults.
* @returns the fully materialized DeepSeek request body.
*/
export async function serializeRequestWithImages(
options: GenerateOptions,
images: ImageSerializationOptions,
defaults: RequestDefaults = {},
): Promise<WireRequest> {
assertSupportedHistory(options.messages)
assertRetainedImagesFit(options.messages, images)
const requestMessages = projectOffloadedImages(
options.messages,
ref => offloadedImageText(ref, images.resolveImageAccess?.(ref)),
)
const messages: WireMessage[] = []
if (options.system !== undefined) {
messages.push({ role: 'system', content: options.system })
}
messages.push(...await serializeMessagesWithImages(requestMessages, images))
return requestWithMessages(options, messages, defaults)
}
@@ -1,40 +0,0 @@
/**
* Decode an SSE byte stream into event `data` payloads. Framing — chunk
* reassembly, UTF-8/CRLF/BOM handling, comment and non-data field skipping,
* multi-`data:` joining — is `eventsource-parser`'s. Comments are reported
* only through an optional transport-activity callback. This module keeps the
* DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns final
* flushing, and EOF before it raises {@link LlmError}. Framing is spec-strict:
* an event dispatches only on its blank-line terminator, so an unterminated
* tail at EOF is truncation, not a flushable payload.
*
* @module dsh-llm-deepseek/sse
*/
import { EventSourceParserStream } from 'eventsource-parser/stream'
import { LlmError } from '@deepseek-ai/dsh-llm'
/** The terminal payload DeepSeek (and OpenAI) send after the last chunk. */
export const DONE = '[DONE]'
/**
* Parse an SSE byte stream into data payloads. Yields `[DONE]` as the final
* value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends
* without it (truncated response — the model call cannot be trusted).
* @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence.
* @param onComment - optional transport-activity callback; comments never enter the yielded payload stream.
* @returns each event's data payload in arrival order, the `[DONE]` sentinel last.
*/
export async function* parseSse(
stream: ReadableStream<BufferSource>,
onComment?: (comment: string) => void,
): AsyncGenerator<string> {
const events = stream
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream({ onComment }))
for await (const { data } of events) {
yield data
if (data === DONE) return
}
throw new LlmError('SSE stream ended without [DONE]', 'STREAM_CLOSED')
}
@@ -1,210 +0,0 @@
/**
* Translate DeepSeek SSE payloads with one stateful harness block per content, reasoning, or tool
* call index. An empty initial reasoning delta does not open a block. Finish reason and the latest
* usage are deferred until `[DONE]`, covering both finish-attached and trailing usage-only shapes
* while ensuring no chunk follows `finish`.
*
* Translate DeepSeek wire chunks into the harness `StreamChunk` protocol.
* @module dsh-llm-deepseek/translate
*/
import { brandString } from '@deepseek-ai/dsh-brand'
import { EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, StreamChunk, TokenUsage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { DONE } from './sse.ts'
import type { WireChunk, WireUsage } from './types.ts'
/** One open block under assembly. */
interface OpenBlock {
index: number
kind: 'text' | 'reasoning' | 'tool-call'
text: string
/** tool-call only, absent until a delta carries a non-empty value. */
callId?: string | undefined
name?: string | undefined
}
/**
* Map the wire finish_reason vocabulary to the harness FinishReason.
* @param reason - the wire `finish_reason` string.
* @returns the mapped reason; unrecognized values (content_filter, …) become `{kind: 'error'}` with the uppercased value as `code`.
*/
export function mapFinishReason(reason: string): FinishReason {
switch (reason) {
case 'stop': return { kind: 'stop' }
case 'tool_calls': return { kind: 'tool-calls' }
case 'length': return { kind: 'max-tokens' }
default:
// content_filter, insufficient_system_resource, future additions.
return {
kind: 'error',
failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() },
}
}
}
/**
* Map wire usage fields. DeepSeek's `prompt_tokens` INCLUDES cache hits
* (`prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens`,
* api/create-chat-completion); the harness TokenUsage convention is
* DISJOINT counts, so cache reads are subtracted out of `inputTokens`.
* @param usage - wire usage from the finish chunk or the trailing usage-only chunk.
* @returns disjoint harness counts; an exact total is present only when the
* aggregate prompt/completion counters are valid and agree with any wire total.
*/
export function mapUsage(usage: WireUsage): TokenUsage {
const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens
const reasoning = usage.completion_tokens_details?.reasoning_tokens
const combined = usage.prompt_tokens + usage.completion_tokens
const hasExactTotal = Number.isSafeInteger(usage.prompt_tokens)
&& usage.prompt_tokens >= 0
&& Number.isSafeInteger(usage.completion_tokens)
&& usage.completion_tokens >= 0
&& Number.isSafeInteger(combined)
&& (usage.total_tokens === undefined || usage.total_tokens === combined)
return {
inputTokens: usage.prompt_tokens - (cacheRead ?? 0),
outputTokens: usage.completion_tokens,
...hasExactTotal ? { totalTokens: combined } : {},
...cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {},
...reasoning !== undefined ? { reasoningTokens: reasoning } : {},
}
}
/**
* Accept one streamed identity field for a tool call. `id` and `name` are
* identity, not accumulation: the wire sends each once, on the call's first
* delta. A continuation delta that re-sends the field empty — or `null`, which
* some OpenAI-compatible gateways fill in — means "no update", never "clear".
* @param current - the identity established by an earlier delta of this call.
* @param incoming - the field as parsed from this delta. The wire type is a
* claim about a remote encoder, so anything but a non-empty string leaves the
* established value alone rather than overwriting it.
* @returns the identity in force after this delta.
*/
function acceptIdentity(current: string | undefined, incoming: unknown): string | undefined {
return typeof incoming === 'string' && incoming.length > 0 ? incoming : current
}
/** Assemble the final ContentBlock for one open block. */
function closeBlock(block: OpenBlock): ContentBlock {
switch (block.kind) {
case 'text': return { type: 'text', text: block.text }
case 'reasoning': return { type: 'reasoning', text: block.text }
case 'tool-call': return {
type: 'tool-call',
id: brandString<ToolCallId>(block.callId ?? ''),
name: block.name ?? '',
arguments: block.text,
}
}
}
/**
* Consume SSE data payloads (ending with `[DONE]`) and yield StreamChunks.
* Malformed JSON payloads abort the stream with `MALFORMED_RESPONSE`.
* @param payloads - SSE data payloads from {@link parseSse}, `[DONE]`-terminated.
* @returns deltas as they arrive; `block-end`s, `usage`, and `finish` are all deferred to the `[DONE]` sentinel.
* A `stop` (or absent) finish with no opened blocks is a degenerate provider completion and maps to an
* `EMPTY_RESPONSE` error finish instead of a successful empty message.
*/
export async function* translate(payloads: AsyncIterable<string>): AsyncGenerator<StreamChunk> {
let nextIndex = 0
let textBlock: OpenBlock | undefined
let reasoningBlock: OpenBlock | undefined
const toolBlocks = new Map<number, OpenBlock>()
const order: OpenBlock[] = []
let pendingFinish: FinishReason | undefined
let pendingUsage: TokenUsage | undefined
function open(kind: OpenBlock['kind']): OpenBlock {
const block: OpenBlock = { index: nextIndex++, kind, text: '' }
order.push(block)
return block
}
for await (const payload of payloads) {
if (payload === DONE) {
for (const block of order) {
yield { type: 'block-end', index: block.index, block: closeBlock(block) }
}
if (pendingUsage) yield { type: 'usage', usage: pendingUsage }
const reason = pendingFinish ?? { kind: 'stop' as const }
yield {
type: 'finish',
reason: reason.kind === 'stop' && order.length === 0
? {
kind: 'error',
failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE },
}
: reason,
}
return
}
let chunk: WireChunk
try {
chunk = JSON.parse(payload) as WireChunk
} catch {
throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, 'MALFORMED_RESPONSE')
}
for (const choice of chunk.choices ?? []) {
const delta = choice.delta
// Reasoning first: thinking mode interleaves it before text. The
// empty-string first chunk must not open a block.
const reasoning = delta?.reasoning_content
if (typeof reasoning === 'string' && reasoning.length > 0) {
if (!reasoningBlock) {
reasoningBlock = open('reasoning')
yield { type: 'block-start', index: reasoningBlock.index, blockType: 'reasoning' }
}
reasoningBlock.text += reasoning
yield { type: 'reasoning-delta', index: reasoningBlock.index, text: reasoning }
}
const content = delta?.content
if (typeof content === 'string' && content.length > 0) {
if (!textBlock) {
textBlock = open('text')
yield { type: 'block-start', index: textBlock.index, blockType: 'text' }
}
textBlock.text += content
yield { type: 'text-delta', index: textBlock.index, text: content }
}
for (const call of delta?.tool_calls ?? []) {
let block = toolBlocks.get(call.index)
if (!block) {
block = open('tool-call')
toolBlocks.set(call.index, block)
yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
}
block.callId = acceptIdentity(block.callId, call.id)
block.name = acceptIdentity(block.name, call.function?.name)
const fragment = call.function?.arguments ?? ''
block.text += fragment
yield {
type: 'tool-call-delta',
index: block.index,
id: brandString<ToolCallId>(block.callId ?? ''),
...block.name !== undefined ? { name: block.name } : {},
argumentsDelta: fragment,
}
}
if (typeof choice.finish_reason === 'string') {
pendingFinish = mapFinishReason(choice.finish_reason)
}
}
// Usage may arrive attached to the finish chunk or as a trailing
// usage-only chunk — keep the latest.
if (chunk.usage) pendingUsage = mapUsage(chunk.usage)
}
// parseSse guarantees the [DONE] sentinel (or throws); reaching here means
// the payload source violated that contract.
throw new LlmError('SSE payload stream ended without [DONE]', 'STREAM_CLOSED')
}
@@ -1,183 +0,0 @@
/**
* DeepSeek chat-completions wire format (OpenAI-compatible). Types only.
*
* Source of truth: the official API docs at
* `~/repos/deepsuite-docs/apps/docs/docs` (api/create-chat-completion,
* guides/thinking_mode.mdx, guides/tool_calls.md), cross-checked against
* live streams from the internal endpoint (2026-06).
*
* @module dsh-llm-deepseek/types
*/
/** Request body for `POST {baseURL}/chat/completions`. */
export interface WireRequest {
model: string
messages: WireMessage[]
stream: true
stream_options: { include_usage: true }
/** Thinking-mode toggle (top level, NOT inside extra_body on the wire). */
thinking?: { type: 'enabled' | 'disabled' }
/** Thinking effort (official levels). */
reasoning_effort?: 'low' | 'high' | 'max'
tools?: WireTool[]
temperature?: number
max_tokens?: number
/**
* Stop sequences (OpenAI `stop`): generation halts as soon as the model
* produces any one of these strings. Mapped from `GenerateOptions.stop`.
*/
stop?: string[]
}
/** System-role message: a single string of instructions. */
export interface WireSystemMessage {
role: 'system'
content: string
}
/** Text part inside a multimodal user message. */
export interface WireTextContentPart {
type: 'text'
text: string
}
/** Files API reference inside a multimodal user message. */
export interface WireFileContentPart {
type: 'file'
file_id: string
}
/** Inline base64 data URL inside a multimodal user message. */
export interface WireImageUrlContentPart {
type: 'image_url'
image_url: { url: string }
}
/** One image representation accepted by a multimodal user message. */
export type WireImageContentPart = WireFileContentPart | WireImageUrlContentPart
/** Ordered input part accepted by a multimodal user message. */
export type WireUserContentPart = WireTextContentPart | WireImageContentPart
/** User-role message: text-only string or ordered multimodal input. */
export interface WireUserMessage {
role: 'user'
content: string | WireUserContentPart[]
}
/** Tool-role message: the result of one tool call, keyed by its call id. */
export interface WireToolMessage {
role: 'tool'
tool_call_id: string
content: string
}
/** One entry of the request `messages` array, discriminated on `role`. */
export type WireMessage =
| WireSystemMessage
| WireUserMessage
| WireAssistantMessage
| WireToolMessage
/**
* Assistant-role history message. The harness replays `content: ""` (never
* null) on tool-call-only turns — some gateways reject null — and sends null
* only when the turn carried neither text nor tool calls.
*/
export interface WireAssistantMessage {
role: 'assistant'
content: string | null
/**
* CoT passback, present on every turn whose assistant content carried
* reasoning. REQUIRED on tool-call turns in thinking mode (see
* guides/thinking_mode.mdx § Tool Calls); DeepSeek ignores it elsewhere,
* while a gateway re-encoding for another vendor recovers that turn's
* thinking signature by hashing it.
*/
reasoning_content?: string
tool_calls?: WireToolCall[]
}
/** A completed tool call replayed on an assistant history message; `arguments` is the raw JSON string. */
export interface WireToolCall {
id: string
type: 'function'
function: { name: string; arguments: string }
}
/** One entry of the request `tools` array; `parameters` is a JSON Schema object. */
export interface WireTool {
type: 'function'
function: {
name: string
description: string
parameters: Record<string, unknown>
}
}
/** One parsed SSE `data:` payload (a chat.completion.chunk). */
export interface WireChunk {
choices?: WireChoice[]
/** Arrives attached to the finish chunk and/or as a trailing usage-only chunk. */
usage?: WireUsage | null
}
/** One streamed choice (requests always ask for a single one); `finish_reason` is non-null only on its terminal chunk. */
export interface WireChoice {
delta?: WireDelta
finish_reason?: string | null
}
/** The incremental content of one streamed choice; any subset of fields may be present per chunk. */
export interface WireDelta {
role?: string
/** Visible text. Null/empty on reasoning/tool-call chunks. */
content?: string | null
/**
* Thinking-mode CoT. The FIRST chunk carries an empty string (must not
* open a reasoning block); absent entirely in non-thinking mode.
*/
reasoning_content?: string | null
tool_calls?: WireToolCallDelta[]
}
/** A streamed fragment of one tool call; fragments sharing an `index` concatenate into one call. */
export interface WireToolCallDelta {
/** Disambiguates parallel tool calls; stable across a call's deltas. */
index: number
/**
* Carried by the first delta of each call. Gateways observed in the wild
* repeat it on continuation deltas as `''` or `null`; both mean "unchanged".
*/
id?: string | null
type?: 'function'
function?: {
/** Carried by the first delta of each call, with the same `''`/`null` repetition as {@link WireToolCallDelta.id}. */
name?: string | null
/** Argument JSON fragment (concatenate across deltas). */
arguments?: string | null
}
}
/**
* Wire token accounting. `prompt_tokens` INCLUDES cache hits (it equals
* `prompt_cache_hit_tokens + prompt_cache_miss_tokens`); `mapUsage` subtracts
* them to keep the harness convention of disjoint counts.
* `prompt_tokens_details.cached_tokens` is the OpenAI-compat spelling of the
* hit count.
*/
export interface WireUsage {
prompt_tokens: number
completion_tokens: number
/** Provider-reported aggregate across prompt and completion tokens. */
total_tokens?: number
prompt_cache_hit_tokens?: number
prompt_cache_miss_tokens?: number
prompt_tokens_details?: { cached_tokens?: number }
completion_tokens_details?: { reasoning_tokens?: number }
}
/** Non-2xx error body. */
export interface WireError {
error?: { message?: string; type?: string; code?: string }
}
@@ -1,152 +0,0 @@
/** Direct Messages transport with one cancellable lifecycle per model request. */
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, ImageAttachmentAccessResolver, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type { DeepSeekLlmApiJson } from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
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, 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'
import { serialize } from './serialize.ts'
import { parseSse } from './sse.ts'
import { translate } from './translate.ts'
import { providerError, providerErrorDetail } from './transport.ts'
/** Request-local dependencies supplied by the owning Cordis plugin. */
export interface AdapterDependencies {
/** Resolve a single validated configuration generation. */
connection(): Connection
/** Resolve the key named by that same generation. */
apiKey(connection: Connection): Promise<string>
/** Stable anonymous Harness identity. */
userId(): string
/** Current attachment service; absence is valid for text requests. */
attachments(): AttachmentStore | undefined
/** Current execution-world attachment path. */
imageAccess: ImageAttachmentAccessResolver
/** Process-wide Files upload reuse and recovery. */
files(): DeepSeekFileStore
/** Prepare plugin-contributed fields for this exact HTTP request. */
prepareExtensions: DeepSeekAdapterOptions['prepareExtensions']
/** Report discarded replay metadata without exposing durable content or signatures. */
onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
}
/** DeepSeek provider using Messages content and native thinking replay. */
export class DeepSeekMessagesAdapter extends LlmAdapter {
constructor(private readonly dependencies: AdapterDependencies) { super() }
override providerInfo(provider: string) { return { id: provider, name: 'DeepSeek' } }
override providerRetryPolicy(_provider: string) { return this.dependencies.connection().retryPolicy }
override listModels(provider: string) {
const connection = this.dependencies.connection()
return Promise.resolve(connection.models.map(model => catalogModelInfo(provider, model)))
}
override resolveModel(provider: string, model: string) {
return Promise.resolve(modelInfo(this.dependencies.connection(), provider, model))
}
override imageRequestPricing(_provider: string, model: string) {
return imagePricing(this.dependencies.connection(), model, this.dependencies.imageAccess)
}
override prepareCall(provider: string, model: string): Promise<PreparedAdapterCall> {
const connection = this.dependencies.connection()
return Promise.resolve({ model: modelInfo(connection, provider, model), stream: options => this.generate(options, connection) })
}
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
return this.generate(options, this.dependencies.connection())
}
private async * generate(options: GenerateOptions, connection: Connection): AsyncGenerator<StreamChunk> {
const consumer = new AbortController()
const signal = options.signal === undefined ? consumer.signal : AbortSignal.any([consumer.signal, options.signal])
using watchdog = idleWatchdog(signal, connection.streamIdleTimeoutMs, 'MESSAGES_IDLE')
const iterator = this.request(options, connection, watchdog.signal, () => { watchdog.pulse() })
try {
while (true) {
const next = await watchdog.next(iterator)
if (next.done) return
yield next.value
}
} catch (error) {
if (timeoutOf(watchdog.signal, 'MESSAGES_IDLE') !== undefined) throw new LlmError('DeepSeek Messages stream idle timeout', 'TIMEOUT', { cause: error })
if (options.signal?.aborted) throw new LlmError('DeepSeek Messages request aborted', 'ABORTED', { cause: error })
if (error instanceof LlmError) throw error
throw new LlmError('DeepSeek Messages transport failed', 'TRANSPORT', { cause: error })
} finally {
consumer.abort()
try { await iterator.return(undefined) } catch (_abortedRequestCleanup) {
// The request already settled; aborting its reader cannot replace that outcome.
}
}
}
private async * request(
options: GenerateOptions, connection: Connection, signal: AbortSignal, activity: () => void,
): AsyncGenerator<StreamChunk> {
signal.throwIfAborted()
const { messages, versions } = await prepareImages(
options.messages, connection, options.model, this.dependencies.attachments(), this.dependencies.imageAccess, signal,
)
const key = await this.dependencies.apiKey(connection)
const files = new RequestFiles(this.dependencies.files(), { baseURL: connection.baseURL, apiKey: key, protocol: 'messages' },
connection.filePolicy, connection.filesApiTimeoutMs, signal, activity)
let inline = false
while (true) {
signal.throwIfAborted()
files.beginAttempt()
let fileIds: Awaited<ReturnType<typeof prepareFileIds>> | undefined
if (!inline) {
try {
fileIds = await prepareFileIds(messages, versions, files)
} catch (error) {
if (!(error instanceof FileResolutionFailure)) throw error
inline = true
continue
}
}
const history = inline ? inlineImages(messages, versions, connection) : messages
const body = serialize(options, connection, history, versions, this.dependencies.imageAccess, (reason) => {
this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason })
}, fileIds)
const extensions = await prepareRequestExtensions(body as unknown as Readonly<Record<string, DeepSeekLlmApiJson>>, {
signal,
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
...options.purpose === undefined ? {} : { purpose: options.purpose },
}, this.dependencies.prepareExtensions)
signal.throwIfAborted()
const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, {
method: 'POST', signal, body: extensions.payload, redirect: 'error',
headers: {
...attributionHeaders(),
'content-type': 'application/json', 'accept': 'text/event-stream',
'x-api-key': key, 'anthropic-version': '2023-06-01',
...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA },
'x-deepseek-harness-user-id': this.dependencies.userId(),
...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) },
...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {},
},
})
if (!response.ok) {
const text = await response.text()
let raw: unknown
try { raw = JSON.parse(text) } catch (_nonJsonGatewayError) {
// HTTP status is authoritative when a gateway does not return JSON.
}
const detail = providerErrorDetail(raw)
if (await files.retry(detail)) continue
const failure = providerError(raw, response.status, response.headers)
const message = files.errorMessage(response.status, failure.message, detail)
throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) })
}
await extensions.accept()
if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE')
yield* translate(parseSse(response.body, activity), options.model)
return
}
}
}
@@ -56,9 +56,9 @@ function validateReplay(message: Message, model: string): ReplayBlock[] | undefi
const blocks = envelope.blocks.map((value, index): ReplayBlock => {
const block = object(value, 'INVALID_REPLAY_STATE')
if (block.type !== message.content[index]?.type
|| !['text', 'reasoning', 'tool-call'].includes(String(block.type))) return fail('block type mismatch')
|| (block.type !== 'text' && block.type !== 'reasoning' && block.type !== 'tool-call')) return fail('block type mismatch')
if (block.signature !== undefined && (block.type !== 'reasoning' || typeof block.signature !== 'string')) return fail('invalid signature')
return block as unknown as ReplayBlock
return { type: block.type, ...typeof block.signature === 'string' ? { signature: block.signature } : {} }
})
return response.model === model ? blocks : undefined
}
@@ -5,9 +5,9 @@ import type { DeepSeekLlmApiExtensionRequest, PreparedDeepSeekLlmApiExtensions }
import type { DeepSeekAdapterOptions } from './types.ts'
/**
* Merge contributions without replacing protocol-owned fields. Preparation and
* acceptance failures retain the same error category across DeepSeek protocols.
* @param body - serialized protocol request before extension fields.
* Merge contributions without replacing Messages fields. Preparation and
* acceptance failures report REQUEST_EXTENSION.
* @param body - serialized Messages request before extension fields.
* @param options - request identity, purpose, and cancellation.
* @param prepare - contributor registry captured for this adapter.
* @returns HTTP payload and a commit to invoke only after a successful HTTP response.
@@ -3,10 +3,10 @@
import { LlmError, requestImageHandleText } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message, RequestMessage } from '@deepseek-ai/dsh-llm'
import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
import type { DeepSeekConnectionOptions as Connection } from '../../common/types.ts'
import type { DeepSeekFileId } from '../../common/file-id.ts'
import type { DeepSeekConnectionOptions as Connection } from './types.ts'
import type { DeepSeekFileId } from './file-id.ts'
import { readReplay } from './replay.ts'
import type { WireBlock, WireInput, WireMessage, WireRequest } from './types.ts'
import type { WireBlock, WireInput, WireMessage, WireRequest } from './wire-types.ts'
function unsupported(type: string): never {
throw new LlmError(`DeepSeek Messages cannot represent ${type}`, 'UNSUPPORTED_CONTENT')
@@ -1,4 +1,4 @@
/** Shared catalog and request-local dependencies for DeepSeek protocols. */
/** Model catalog and request-local dependencies for DeepSeek Messages. */
import type { ModelModality, SystemPromptUpdate, ResolvedRetryPolicy, ImageAttachmentAccess } from '@deepseek-ai/dsh-llm'
import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
@@ -6,9 +6,6 @@ import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
import type { DeepSeekLlmApiExtensionRequest, PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
import type { DeepSeekFileStore, DeepSeekFilePolicy } from './file-store.ts'
/** Supported wire implementations; Responses is not yet implemented. */
export type DeepSeekProtocol = 'chat-completions' | 'messages'
/** One optional model entry advertised by the direct-fetch adapter. */
export interface DeepSeekCatalogModel {
/** Wire model id accepted by the configured endpoint. */
@@ -46,9 +43,7 @@ export interface DeepSeekCatalogModel {
* makes a configuration change reach the next request without re-registration.
*/
export interface DeepSeekConnectionOptions {
/** Wire protocol selected by plugin configuration. */
protocol: DeepSeekProtocol
/** Root compatible with the selected protocol; custom paths remain unchanged. */
/** Messages API root; custom paths remain unchanged. */
baseURL: string
/**
* Credential reference of this same resolution, resolved per request.
@@ -94,7 +89,7 @@ export interface DeepSeekAdapterOptions {
/** Current validated connection facts; called once per operation. */
options: () => DeepSeekConnectionOptions
/**
* Resolve the bearer token for the connection facts of one request. The
* Resolve the API key for the connection facts of one request. The
* snapshot is passed in — never re-read — so the key can only ever come
* from the same resolution as the endpoint it is sent to. Throws `LlmError`
* `MISSING_CREDENTIAL` when no key is available anywhere.
@@ -13,13 +13,13 @@ export type WireBlock = WireInput
| { type: 'tool_result'; tool_use_id: string; content: WireInput[]; is_error?: boolean }
/** Conversation turn; capable routes retain later system updates in message history. */
export interface WireMessage {
export type WireMessage = {
role: 'user' | 'assistant' | 'system'
content: WireBlock[]
}
/** JSON body submitted to the resolved Messages endpoint. */
export interface WireRequest {
export type WireRequest = {
model: string
stream: true
max_tokens: number
+245 -429
View File
@@ -1,466 +1,282 @@
import { readFileSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
/**
* Real Messages round trips use the official root and require credentials.
* System-update checks additionally require DEEPSEEK_IN_HISTORY_MODEL.
*/
import { randomUUID } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { randomBytes } from 'node:crypto'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { createRequire } from 'node:module'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context, LoggerLevel } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import LlmRuntime, { createToolResultMessage, createUserMessage, ToolCallId, ReasoningEffortId, createMessage, createSystemMessage } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
import LocalAttachments from '@deepseek-ai/dsh-attachment-local'
import type {
ImageAttachmentLimits,
ImageAttachmentRef,
ImageRequestTarget,
RequestImageAttachment,
SaveImageAttachment,
StoredImageAttachment,
} from '@deepseek-ai/dsh-attachment'
import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
import LlmRuntime, { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { Message } from '@deepseek-ai/dsh-llm'
import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
import type { WireMessage, WireRequest } from '../src/protocols/chat-completions/types.ts'
import { assemble, type AssembledResult } from './assemble.ts'
import * as Messages from '../src/index.ts'
import { DeepSeekFilesClient } from '../src/files-api.ts'
import { MESSAGES_FILES_BETA } from '../src/messages-api.ts'
import { assemble, options, user, sourceModuleLoader } from './helpers.ts'
/**
* Real-API e2e for the direct-fetch adapter: V4 Flash across thinking modes
* and a max-effort tool round trip with reasoning passback. The suite skips
* entirely without $DEEPSEEK_API_KEY; the pre-release vision smoke additionally
* requires $DEEPSEEK_VISION_E2E=1, and the Flash image/system-update smoke
* requires $DEEPSEEK_FLASH_E2E=1 (see vitest.e2e.config.ts).
*/
const FLASH = 'deepseek-v4-flash'
const VISION = 'deepseek-v4-flash-vision-exp'
const VISION_E2E_ENABLED = process.env.DEEPSEEK_VISION_E2E === '1'
/** A model whose endpoint reads the latest `system` message at any position; unset skips the in-history smoke. */
const IN_HISTORY_MODEL = process.env.DEEPSEEK_IN_HISTORY_MODEL
const TEST_PNG = Uint8Array.from(readFileSync(
new URL('../../llm-pi-ai/tests/fixtures/qr-code.png', import.meta.url),
))
const contexts: Context[] = []
let identityHome: string
class E2eAttachmentStore extends AttachmentStore {
readonly imageLimits: ImageAttachmentLimits = {
maxImageBytes: TEST_PNG.byteLength,
maxImagesPerMessage: 1,
maxMessageImageBytes: TEST_PNG.byteLength,
maxImagePixels: 256 * 256,
maxImageDimension: 256,
mediaTypes: ['image/png'],
}
readonly ref: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${randomBytes(32).toString('hex')}`),
mediaType: 'image/png',
bytes: TEST_PNG.byteLength,
width: 256,
height: 256,
name: 'files-api-e2e.png',
}
readonly version: RequestImageAttachment = {
variantId: ImageVariantId(`sha256:${randomBytes(32).toString('hex')}`),
attachment: this.ref,
data: TEST_PNG,
mediaType: 'image/png',
bytes: TEST_PNG.byteLength,
width: 256,
height: 256,
depth: 'uchar',
space: 'srgb',
hasAlpha: false,
}
validateImage(_input: SaveImageAttachment): Promise<void> {
return Promise.resolve()
}
saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
return Promise.resolve(this.ref)
}
readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise<StoredImageAttachment> {
return Promise.resolve({ ref, data: TEST_PNG })
}
override readImageRequest(
_ref: ImageAttachmentRef,
_target: ImageRequestTarget,
_signal?: AbortSignal,
): Promise<RequestImageAttachment> {
return Promise.resolve(this.version)
}
}
beforeEach(async () => {
identityHome = await mkdtemp(join(tmpdir(), 'dsh-e2e-user-id-'))
vi.stubEnv('DSH_HOME', identityHome)
const cleanups: (() => Promise<unknown>)[] = []
afterEach(async () => {
while (cleanups.length) await cleanups.pop()!()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
async function harness(model: string, config: Partial<Config> = {}) {
async function boot(models?: Messages.Config['models']) {
const home = await mkdtemp(join(tmpdir(), 'dsh-messages-e2e-'))
cleanups.push(() => rm(home, { recursive: true, force: true }))
vi.stubEnv('DSH_HOME', home)
const ctx = new Context()
contexts.push(ctx)
cleanups.push(() => ctx.fiber.dispose())
await ctx.plugin(LlmRuntime)
await ctx.plugin(E2eAttachmentStore)
await ctx.plugin(LlmDeepSeek, {
protocol: 'chat-completions',
baseURL: LlmDeepSeek.PUBLIC_BASE_URL,
...model === VISION ? { models: [{ id: VISION, inputModalities: ['text', 'image'] }] } : {},
...config,
await ctx.plugin(Messages, {
baseURL: Messages.PUBLIC_BASE_URL,
maxTokens: 4096,
...models === undefined ? {} : { models },
})
return ctx
}
const tool = { name: 'lookup_value', description: 'Read the requested value. Always call this tool to obtain a value.', parameters: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] } }
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
vi.unstubAllGlobals()
vi.unstubAllEnvs()
await rm(identityHome, { recursive: true, force: true })
})
function ask(text: string): Message[] {
return [createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
})]
}
function textOf(result: AssembledResult): string {
return result.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
const weatherTool: ToolSchema = {
name: 'get_weather',
description: 'Get the current weather for a city.',
parameters: {
type: 'object',
properties: { city: { type: 'string', description: 'City name' } },
required: ['city'],
},
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => {
it.skipIf(process.env.DEEPSEEK_FLASH_E2E !== '1')('deepseek-flash accepts images and retains system updates', async () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmRuntime)
await ctx.plugin(LocalAttachments)
await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: LlmDeepSeek.PUBLIC_BASE_URL, maxTokens: 4096 })
const model = 'deepseek-flash'
await expect(ctx.llm.resolveModelInfo('deepseek-official', model)).resolves.toMatchObject({
inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history',
})
const attachment = await ctx.attachments.saveImage({ data: readFileSync(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' })
const message = ask('What is the dominant color of this image?')[0]!
const history: Message[] = [
createSystemMessage('Answer with one English color word.'),
{ ...message, content: [...message.content, { type: 'image', attachment }] },
]
const reply = async () => {
const response = await assemble(ctx, { model, messages: history, reasoningEffort: ReasoningEffortId('high') })
expect(response.finish.kind, JSON.stringify(response.finish)).toBe('stop')
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () => {
it.skipIf(!IN_HISTORY_MODEL).each([false, true])('updates system instructions during a conversation, in-history=%s', async (inHistory) => {
const model = IN_HISTORY_MODEL as string
// Each case owns the capability, even for a model with an in-history catalog default.
const ctx = await boot([{ id: model, ...inHistory ? { systemPromptUpdate: 'in-history' as const } : {} }])
const history: Message[] = [createSystemMessage('Reply to every user message with exactly PROMPT_FIRST.'), user('Answer now.')]
const reply = async (expected: string) => {
const saved = JSON.stringify(history)
const response = await assemble(ctx.llm.stream(options({ model, messages: history, reasoningEffort: ReasoningEffortId('high') })), model)
expect(response.assembler.finish.kind).toBe('stop')
expect(response.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain(expected)
expect(JSON.stringify(history)).toBe(saved)
history.push(response.message)
return textOf(response).trim().toLowerCase()
}
expect(await reply()).toMatch(/^red[.!]?$/)
history.push(createSystemMessage('Reply to every user message with exactly banana.'), ...ask('Answer now.'))
expect(await reply()).toBe('banana')
history.push(...ask('Answer again.'))
expect(await reply()).toBe('banana')
await reply('PROMPT_FIRST')
history.push(createSystemMessage('Reply to every user message with exactly PROMPT_SECOND.'), user('Answer again.'))
await reply('PROMPT_SECOND')
const withoutSystem = history.filter(message => message.role !== 'system')
history.splice(0, history.length, createSystemMessage(''), ...withoutSystem, user('Reply with exactly PROMPT_CLEARED.'))
await reply('PROMPT_CLEARED')
})
it.skipIf(!VISION_E2E_ENABLED)('uses the built-in official route to upload, reference, and delete one image', async () => {
const key = process.env.DEEPSEEK_API_KEY
if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
const baseURL = LlmDeepSeek.PUBLIC_BASE_URL
const ctx = await harness(VISION, { baseURL })
await ctx.plugin(E2eAttachmentStore)
const attachments = ctx.attachments as E2eAttachmentStore
let uploadedFile: LlmDeepSeek.DeepSeekFileIdType | undefined
const nativeFetch = globalThis.fetch
const observedFetch: typeof fetch = async (input, init) => {
const response = await nativeFetch(input, init)
const url = new URL(input instanceof Request ? input.url : input)
const method = init?.method ?? (input instanceof Request ? input.method : 'GET')
if (method === 'POST' && url.pathname.endsWith('/files') && response.ok) {
const value = await response.clone().json() as { id?: unknown }
if (typeof value.id === 'string') uploadedFile = LlmDeepSeek.DeepSeekFileId(value.id)
it('uploads, lists, retrieves, reuses, and replaces a deleted Files image across Messages requests', async () => {
const ctx = await boot()
await ctx.plugin(LocalAttachments)
const fetchImpl = globalThis.fetch
const uploads: string[] = []
const bodies: string[] = []
const files = new DeepSeekFilesClient({
baseURL: Messages.PUBLIC_BASE_URL, apiKey: process.env.DEEPSEEK_API_KEY as string, fetch: fetchImpl,
})
const ownedFiles = new Set<ReturnType<typeof Messages.DeepSeekFileId>>()
cleanups.push(async () => {
for (const id of ownedFiles) await files.delete(id)
})
vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
const response = await fetchImpl(input, init)
if (url === `${Messages.PUBLIC_BASE_URL}/v1/files` && init?.method === 'POST' && response.ok) {
const file = await response.clone().json() as { id: string }
uploads.push(file.id)
ownedFiles.add(Messages.DeepSeekFileId(file.id))
}
if (url === `${Messages.PUBLIC_BASE_URL}/v1/messages`) {
expect(new Headers(init?.headers).get('anthropic-beta')).toBe(MESSAGES_FILES_BETA)
if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request')
bodies.push(init.body)
}
return response
})
const attachment = await ctx.attachments.saveImage({ data: await readFile(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' })
const message = user('What is the dominant color of this image? Reply with one English color word.')
const request = options({
model: 'deepseek-flash', reasoningEffort: ReasoningEffortId('off'),
messages: [{ ...message, content: [...message.content, { type: 'image', attachment }] }],
})
for (let run = 0; run < 2; run++) {
const result = await assemble(ctx.llm.stream(request), request.model)
expect(result.assembler.finish.kind).toBe('stop')
expect(result.message.content.filter(block => block.type === 'text').map(block => block.text).join('').toLowerCase()).toContain('red')
}
vi.stubGlobal('fetch', observedFetch)
const files = new LlmDeepSeek.DeepSeekFilesClient({ protocol: 'chat-completions', baseURL, apiKey: key })
try {
const result = await assemble(ctx, {
model: VISION,
messages: [createUserMessage({
content: [
{ type: 'text', text: 'Briefly describe this image.' },
{ type: 'image', attachment: attachments.ref },
],
source: { kind: 'user' },
})],
maxTokens: 100,
})
expect(
result.finish.kind,
`DeepSeek vision result: ${JSON.stringify(result.finish)}`,
).toBe('stop')
expect(textOf(result).trim().length).toBeGreaterThan(0)
expect(uploadedFile).toMatch(/^file-api-/u)
} finally {
if (uploadedFile !== undefined) await files.delete(uploadedFile)
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 })
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)
await files.delete(fileId)
ownedFiles.delete(fileId)
const recovered = await assemble(ctx.llm.stream(request), request.model)
expect(recovered.assembler.finish.kind).toBe('stop')
expect(recovered.message.content.filter(block => block.type === 'text').map(block => block.text).join('').toLowerCase()).toContain('red')
expect(uploads).toHaveLength(2)
expect(uploads[1]).not.toBe(fileId)
expect(bodies).toHaveLength(4)
expect(bodies[2]).toContain(`"file_id":"${fileId}"`)
expect(bodies[3]).toContain(`"file_id":"${uploads[1]}"`)
expect(bodies[3]).not.toContain('"type":"base64"')
})
it('accepts the session-log and plugin-package request extension fields', async () => {
const ctx = new Context()
contexts.push(ctx)
it.each([false, true])('submits Loader package inventory and records HTTP acceptance with session-log enabled=%s', async (enabled) => {
const ctx = await boot()
await ctx.plugin(Loader)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(DeepSeekLlmApiExtensionRegistry)
await ctx.plugin(SessionLogDeepSeek, { enabled: true })
await ctx.plugin(PluginPackageInventoryDeepSeek)
await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: LlmDeepSeek.PUBLIC_BASE_URL, thinking: 'disabled' })
const session = ctx.sessions.create(SessionId('real-extension-fields'))
await ctx.plugin(SessionLogDeepSeek, enabled ? {} : { enabled: false })
ctx.baseUrl = import.meta.url
// Select the source module while Loader owns its active package entry.
ctx.loader.internal = sourceModuleLoader(async (specifier) => {
if (specifier !== '@deepseek-ai/dsh-plugin-package-inventory-deepseek') throw new Error(`unexpected Loader import: ${specifier}`)
return PluginPackageInventoryDeepSeek
})
await ctx.loader.create({ name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' })
await ctx.loader.await()
const packagePath = createRequire(import.meta.url).resolve('@deepseek-ai/dsh-plugin-package-inventory-deepseek/package.json')
const packageIdentity = JSON.parse(await readFile(packagePath, 'utf8')) as { name: string; version: string }
const session = ctx.sessions.create(SessionId(`real-messages-extensions-${randomUUID()}`))
session.append('turn/start', { turn: 1 })
const result = await assemble(ctx, {
model: FLASH,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
sessionId: session.id,
const fetchImpl = globalThis.fetch
let afterSeq: number = -1
let throughSeq = 0
let requests = 0
vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (url !== `${Messages.PUBLIC_BASE_URL}/v1/messages`) return fetchImpl(input, init)
if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request')
const body = JSON.parse(init.body) as Record<string, unknown>
expect(body).toMatchObject({ dsh_plugin_packages: {
version: 1, packages: [{ name: packageIdentity.name, version: packageIdentity.version }],
} })
if (enabled) {
expect(body).toMatchObject({ dsh_session_log: {
version: 1, sessionFormatVersion: session.header.version, session: { id: session.id },
afterSeq, throughSeq,
events: Array.from({ length: throughSeq - afterSeq }, (_, index) => ({ seq: afterSeq + index + 1 })),
} })
} else expect(body).not.toHaveProperty('dsh_session_log')
expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(afterSeq)
const response = await fetchImpl(input, init)
expect(response.ok).toBe(true)
expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(afterSeq)
requests += 1
return response
})
expect(result.finish.kind).toBe('stop')
expect(textOf(result).toLowerCase()).toContain('pong')
expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(0)
})
it('serves a real request with the key held only by a credentials-local document', async () => {
const key = process.env.DEEPSEEK_API_KEY
if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-'))
try {
// JSON.stringify quotes the value: YAML is a JSON superset, so a real
// key survives whatever characters it happens to carry.
await writeFile(join(dir, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 })
// Scrub the ambient variable so only the credential seam can supply the
// key: this request proves the per-request resolution path end to end.
vi.stubEnv('DEEPSEEK_API_KEY', '')
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmRuntime)
await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: LlmDeepSeek.PUBLIC_BASE_URL })
const result = await assemble(ctx, {
model: FLASH,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
})
expect(result.finish.kind).toBe('stop')
expect(textOf(result).toLowerCase()).toContain('pong')
} finally {
vi.unstubAllEnvs()
await rm(dir, { recursive: true, force: true })
}
})
it('flash dynamically switches from off to low', async () => {
const ctx = await harness(FLASH, { reasoningEffort: 'off' })
const withoutThinking = await assemble(ctx,{
model: FLASH,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
})
expect(withoutThinking.finish.kind).toBe('stop')
expect(textOf(withoutThinking).toLowerCase()).toContain('pong')
expect(withoutThinking.message.content.some(block => block.type === 'reasoning')).toBe(false)
expect(withoutThinking.usage?.inputTokens).toBeGreaterThan(0)
expect(withoutThinking.usage?.outputTokens).toBeGreaterThan(0)
const withThinking = await assemble(ctx,{
model: FLASH,
reasoningEffort: ReasoningEffortId('low'),
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
maxTokens: 2000,
})
expect(withThinking.finish.kind).toBe('stop')
expect(withThinking.message.content.some(block => block.type === 'reasoning')).toBe(true)
expect(textOf(withThinking)).toContain('9.8')
expect(withThinking.usage?.reasoningTokens).toBeGreaterThan(0)
})
it(
'flash + thinking enabled (effort max): tool-call round trip with reasoning passback',
async () => {
const ctx = await harness(FLASH, { thinking: 'enabled' })
// Turn 1: the model must call the tool (and think before it).
const first = await assemble(ctx,{
model: FLASH,
reasoningEffort: ReasoningEffortId('max'),
messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
tools: [weatherTool],
maxTokens: 2000,
})
expect(
first.finish.kind,
`DeepSeek Flash tool-call turn finished as ${JSON.stringify(first.finish)}`,
).toBe('tool-calls')
const call = first.message.content.find(block => block.type === 'tool-call')
expect(call).toBeDefined()
expect(call!.name).toBe('get_weather')
expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string })
// Turn 2: send the tool result back WITH the assistant's reasoning
// block in history (the official thinking+tools passback rule).
const second = await assemble(ctx,{
model: FLASH,
reasoningEffort: ReasoningEffortId('max'),
messages: [
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
createMessage({
role: 'assistant', content: first.message.content,
source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
createToolResultMessage({
callId: ToolCallId(call!.id),
content: [{ type: 'text', text: 'Sunny, 22°C' }],
isError: false,
}),
],
tools: [weatherTool],
maxTokens: 2000,
})
expect(
second.finish.kind,
`DeepSeek Flash tool-result turn finished as ${JSON.stringify(second.finish)}`,
).toBe('stop')
expect(textOf(second).toLowerCase()).toMatch(/sunny|22/)
},
)
it.skipIf(IN_HISTORY_MODEL === undefined)(
'an in-history model follows a mid-history system message and keeps the cached prefix',
async () => {
const model = IN_HISTORY_MODEL as string
const ctx = await harness(model, {
thinking: 'disabled',
models: [{ id: model, systemPromptUpdate: 'in-history' }],
})
await expect(ctx.llm.resolveModelInfo('deepseek-official', model))
.resolves.toMatchObject({ systemPromptUpdate: 'in-history' })
const system = (text: string) => createSystemMessage(text)
// A nonce before the padding isolates the provider cache across runs and retries.
const nonce = randomBytes(16).toString('hex')
const padding = Array.from({ length: 40 }, (_, index) => `Rule ${String(index + 1)}: keep every answer short and factual.`).join('\n')
// Both update strategies use identical prompt bytes, with the changed instruction near the head.
const prompt = (word: string) => `Test ${nonce}. When the user says ping, reply with exactly the word: ${word}\n${padding}`
const initial = prompt('pong')
const latest = prompt('banana')
const history = [system(initial), ...ask('ping')]
const wireRequests: WireMessage[][] = []
const nativeFetch = globalThis.fetch
const observedFetch: typeof fetch = async (input, init) => {
if (init?.method !== 'POST' || typeof init.body !== 'string') return nativeFetch(input, init)
const body = JSON.parse(init.body) as WireRequest
if (body.model === model) {
wireRequests.push(body.messages)
if (wireRequests.length === 1) {
expect(body.messages).toEqual([
{ role: 'system', content: initial }, { role: 'user', content: 'ping' },
])
} else if (wireRequests.length === 2) {
expect(JSON.stringify(body.messages)).toBe(JSON.stringify(wireRequests[0]))
} else if (wireRequests.length === 3) {
expect(JSON.stringify(body.messages.slice(0, history.length)))
.toBe(JSON.stringify(wireRequests[0]))
expect(body.messages[3]).toEqual({ role: 'system', content: latest })
} else if (wireRequests.length === 4) {
expect(body.messages[0]).toEqual(wireRequests[2]![3])
expect(body.messages.slice(1)).toEqual([
wireRequests[2]![1], wireRequests[2]![2], wireRequests[2]![4],
])
}
}
return nativeFetch(input, init)
for (let run = 0; run < 2; run++) {
afterSeq = SessionLogDeepSeek.acceptedThrough(session)
throughSeq = Number(session.seq) - 1
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(options({ sessionId: session.id, reasoningEffort: ReasoningEffortId('off'), messages: [user('Reply with exactly PONG.')] }))) {
expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(enabled ? throughSeq : -1)
assembler.push(chunk)
}
vi.stubGlobal('fetch', observedFetch)
try {
const first = await assemble(ctx, { model, messages: history, maxTokens: 50 })
expect(first.finish.kind).toBe('stop')
expect(textOf(first).trim()).toBe('pong')
const initialTokens = first.usage?.inputTokens ?? 0
expect(initialTokens).toBeGreaterThan(0)
// Measure reusable tokens rather than assuming a provider cache-block size.
const warm = await assemble(ctx, { model, messages: history, maxTokens: 50 })
expect(warm.finish.kind).toBe('stop')
expect(textOf(warm).trim()).toBe('pong')
const reusableTokens = warm.usage?.cacheReadTokens ?? 0
expect(reusableTokens).toBeLessThanOrEqual(initialTokens)
expect(reusableTokens).toBeGreaterThan(0)
const assistant = createMessage({
role: 'assistant', content: first.message.content, source: first.message.source,
})
const updated = await assemble(ctx, {
model,
messages: [...history, assistant, system(latest), ...ask('ping')],
maxTokens: 50,
})
expect(updated.finish.kind).toBe('stop')
expect(textOf(updated).trim()).toBe('banana')
const replaced = await assemble(ctx, {
model,
messages: [system(latest), ...ask('ping'), assistant, ...ask('ping')],
maxTokens: 50,
})
expect(replaced.finish.kind).toBe('stop')
expect(textOf(replaced).trim()).toBe('banana')
expect(wireRequests).toHaveLength(4)
const cached = updated.usage?.cacheReadTokens ?? 0
expect(replaced.usage?.cacheReadTokens).toBeDefined()
const baselineCached = replaced.usage?.cacheReadTokens ?? 0
expect(cached).toBeGreaterThanOrEqual(reusableTokens)
expect(cached).toBeGreaterThan(baselineCached)
} finally {
vi.stubGlobal('fetch', nativeFetch)
}
},
)
it('streams raw chunks in protocol order', async () => {
const ctx = await harness(FLASH, { thinking: 'disabled' })
const kinds: string[] = []
for await (const chunk of ctx.llm.stream({
provider: 'deepseek-official',
model: FLASH,
messages: ask('Count from 1 to 5, digits only.'),
maxTokens: 50,
})) {
kinds.push(chunk.type)
expect(assembler.finish.kind).toBe('stop')
expect(assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('')).toContain('PONG')
expect(requests).toBe(run + 1)
if (run === 0) session.append('step/start', { turn: 1, step: 1 })
}
expect(kinds[0]).toBe('block-start')
expect(kinds.at(-1)).toBe('finish')
expect(kinds.filter(kind => kind === 'finish')).toHaveLength(1)
// usage precedes finish (deferred-emit contract)
expect(kinds.indexOf('usage')).toBeLessThan(kinds.indexOf('finish'))
expect(session.snapshotEvents().filter(event => event.type === 'session-log-deepseek/delivery-accepted')).toHaveLength(enabled ? 2 : 0)
})
it.each(['off', 'low', 'high', 'max'])('streams text with %s effort', async (effort) => {
const ctx = await boot()
const result = await assemble(ctx.llm.stream(options({ reasoningEffort: ReasoningEffortId(effort), temperature: 0, messages: [user('Reply with exactly PONG.')] })))
expect(result.assembler.finish).toEqual({ kind: 'stop' })
expect(result.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('PONG')
expect(result.assembler.usage?.outputTokens).toBeGreaterThan(0)
expect(result.message.source.replayState).toBeDefined()
})
it('replays a JSON-persisted thinking/tool turn and an error result before continuing', async () => {
const ctx = await boot()
const history: Message[] = [user('Use lookup_value with key secret. If the tool returns an error, report its exact error text and stop.')]
const request = options({ messages: history, tools: [tool], reasoningEffort: ReasoningEffortId('high') })
const first = await assemble(ctx.llm.stream(request))
expect(first.assembler.finish.kind).toBe('tool-calls')
const calls = first.message.content.filter(block => block.type === 'tool-call')
expect(calls).toHaveLength(1)
const restored = JSON.parse(JSON.stringify(first.message)) as Message
history.push(restored)
for (const call of calls) history.push(createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: 'LOOKUP_DENIED_731' }], isError: true }))
const second = await assemble(ctx.llm.stream({ ...request, messages: history }))
expect(second.assembler.finish.kind).toBe('stop')
expect(second.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('LOOKUP_DENIED_731')
history.push(second.message, user('Reply with exactly DONE.'))
const third = await assemble(ctx.llm.stream({ ...request, messages: history }))
expect(third.assembler.finish.kind).toBe('stop')
})
it('continues persisted foreign history containing malformed tool arguments', async () => {
const ctx = await boot()
const callId = ToolCallId('historical_lookup')
const history = [
user('Look up the secret value.'),
createAssistantMessage({ source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, content: [
{ type: 'tool-call', id: callId, name: 'lookup_value', arguments: '{"key":"the "secret""}' },
] }),
createToolResultMessage({ callId, content: [{ type: 'text', text: 'Invalid arguments: expected an object' }], isError: true }),
user('Do not retry the lookup. Reply with exactly HISTORY_RECOVERED_731.'),
]
const saved = JSON.stringify(history)
const restored = JSON.parse(saved) as Message[]
const response = await assemble(ctx.llm.stream(options({ messages: restored, tools: [tool], reasoningEffort: ReasoningEffortId('off') })))
expect(response.assembler.finish.kind).toBe('stop')
expect(response.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('HISTORY_RECOVERED_731')
expect(JSON.stringify(restored)).toBe(saved)
})
it('cancels an active stream without committing a successful response', async () => {
const ctx = await boot()
const controller = new AbortController()
const stream = ctx.llm.stream(options({ signal: controller.signal, messages: [user('List the integers from one to one thousand, one per line.')] }))
let cancelled = false
let finish: unknown
for await (const chunk of stream) {
if (!cancelled && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta')) { cancelled = true; controller.abort() }
if (chunk.type === 'finish') finish = chunk.reason
}
expect(cancelled).toBe(true)
expect(finish).toMatchObject({ kind: 'aborted' })
})
it('continues a thinking/tool turn after degrading unusable persisted replay metadata', async () => {
const ctx = await boot()
const warnings: unknown[][] = []
ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } })
const history: Message[] = [user('Use lookup_value with key secret. Then reply with the exact tool result and stop.')]
const request = options({ messages: history, tools: [tool], reasoningEffort: ReasoningEffortId('high') })
const first = await assemble(ctx.llm.stream(request))
expect(first.assembler.finish.kind).toBe('tool-calls')
const calls = first.message.content.filter(block => block.type === 'tool-call')
expect(calls).toHaveLength(1)
const restored = JSON.parse(JSON.stringify(first.message)) as typeof first.message
restored.source.replayState = { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] }
const saved = JSON.stringify(restored)
history.push(restored, ...calls.map(call => createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: 'REPLAY_RECOVERED_731' }], isError: false })))
const second = await assemble(ctx.llm.stream({ ...request, messages: history }))
expect(second.assembler.finish.kind).toBe('stop')
expect(second.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('REPLAY_RECOVERED_731')
expect(warnings).toEqual([[expect.stringContaining('unsupported kind or version')]])
expect(JSON.stringify(restored)).toBe(saved)
})
})
File diff suppressed because it is too large. Load diff
@@ -131,7 +131,7 @@ async function boot(dir: string, config: object): Promise<Harness> {
const settingsFiber = ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false })
await settingsFiber
await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', ...config })
await ctx.plugin(LlmDeepSeek, { ...config })
return { ctx, settingsFiber }
}
@@ -149,7 +149,7 @@ describe('request-level dynamic configuration', () => {
const { ctx } = await boot(dir, { baseURL: serverA.url })
await prompt(ctx)
expect(serverA.headers[0]?.authorization).toBe('Bearer first-key')
expect(serverA.headers[0]?.['x-api-key']).toBe('first-key')
await ctx.settings.update(NS, { baseURL: serverB.url })
await ctx.credentials.set(KEY_REF, 'second-key')
@@ -157,7 +157,7 @@ describe('request-level dynamic configuration', () => {
await prompt(ctx)
// No restart, no re-registration: the next request resolved both facts.
expect(serverA.requests).toHaveLength(1)
expect(serverB.headers[0]?.authorization).toBe('Bearer second-key')
expect(serverB.headers[0]?.['x-api-key']).toBe('second-key')
})
it('starts keyless and serves the next request once the key arrives', async () => {
@@ -171,7 +171,7 @@ describe('request-level dynamic configuration', () => {
await expect(access(join(dir, '.anonymous-user-id'))).rejects.toMatchObject({ code: 'ENOENT' })
await ctx.credentials.set(KEY_REF, 'sk-arrived')
await prompt(ctx)
expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived')
expect(server.headers[0]?.['x-api-key']).toBe('sk-arrived')
await expect(access(join(dir, '.anonymous-user-id'))).resolves.toBeUndefined()
})
@@ -246,10 +246,10 @@ describe('request-level dynamic configuration', () => {
const first = (server.requests[0] as { messages: Array<{ content: unknown }> }).messages[0]?.content
const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content
expect(server.requests).toHaveLength(2)
expect(JSON.stringify(first).match(/"type":"file"/g)).toHaveLength(2)
expect(JSON.stringify(first).match(/"type":"image"/g)).toHaveLength(2)
expect(JSON.stringify(second)).toContain('[image omitted to fit request image limits')
expect(JSON.stringify(second)).toContain(MODEL_IMAGE_PATH)
expect(JSON.stringify(second).match(/"type":"file"/g)).toHaveLength(1)
expect(JSON.stringify(second).match(/"type":"image"/g)).toHaveLength(1)
})
it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => {
const dir = await home()
@@ -309,7 +309,29 @@ describe('request-level dynamic configuration', () => {
// regression this pins — not its key either.
expect(rejected.requests).toHaveLength(0)
expect(good.requests).toHaveLength(1)
expect(good.headers[0]?.authorization).toBe('Bearer good-key')
expect(good.headers[0]?.['x-api-key']).toBe('good-key')
})
it.each(['messages', 'chat-completions'])('rejects a persisted protocol=%s section and recovers after removing the field', async (protocol) => {
const dir = await home()
const composition = await mockServer([{ kind: 'sse', events: textEvents }])
const repaired = await mockServer([{ kind: 'sse', events: textEvents }])
vi.stubEnv('DEEPSEEK_API_KEY', 'composition-key')
vi.stubEnv('REPAIRED_DEEPSEEK_KEY', 'repaired-key')
const section = { baseURL: repaired.url, apiKeyEnv: 'REPAIRED_DEEPSEEK_KEY' }
await writeFile(join(dir, 'settings.yaml'), JSON.stringify({ [NS]: { ...section, protocol } }))
const { ctx } = await boot(dir, { baseURL: composition.url })
await prompt(ctx)
expect(composition.requests).toHaveLength(1)
expect(composition.headers[0]?.['x-api-key']).toBe('composition-key')
expect(repaired.requests).toHaveLength(0)
await ctx.settings.replace(NS, section)
await prompt(ctx)
expect(repaired.requests).toHaveLength(1)
expect(repaired.headers[0]?.['x-api-key']).toBe('repaired-key')
expect(ctx.settings.get(NS)).not.toHaveProperty('protocol')
})
it('falls back to the composition entry when settings detach', async () => {
@@ -327,6 +349,6 @@ describe('request-level dynamic configuration', () => {
await settingsFiber.dispose()
await prompt(ctx)
expect(serverA.requests).toHaveLength(1)
expect(serverA.headers[0]?.authorization).toBe('Bearer steady-key')
expect(serverA.headers[0]?.['x-api-key']).toBe('steady-key')
})
})
@@ -51,19 +51,19 @@ afterAll(() => {
rmSync(home, { recursive: true, force: true })
})
/** Drive the shipping adapter's chat-completions request at an unresolvable endpoint. */
/** Drive the shipping adapter's Messages request at an unresolvable endpoint. */
async function streamOnce(): Promise<void> {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
await ctx.plugin(DeepSeekLlmApiExtensionRegistry)
await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://deepseek-probe.invalid/v1', models: [{ id: 'm' }] })
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://deepseek-probe.invalid/v1', models: [{ id: 'm' }] })
for await (const _chunk of ctx.llm.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) {
// The endpoint never answers; the proxy record is the assertion.
}
}
describe('llm-deepseek egress', () => {
it('sends the chat-completions request through the proxy', async () => {
it('sends the Messages request through the proxy', async () => {
const observed = await observe(streamOnce)
expect(observed.join('|')).toContain('deepseek-probe.invalid')
})
@@ -8,7 +8,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm'
import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
import type { DeepSeekLlmApiExtensionRequest } from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as DeepSeek from '../../src/index.ts'
import * as DeepSeek from '../src/index.ts'
import { assemble, options, sse, textEvents } from './helpers.ts'
declare module '@deepseek-ai/dsh-deepseek-llm-api-extensions' {
@@ -4,9 +4,9 @@ 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_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'
import { DeepSeekFileStore, MAX_IMAGE_BYTES } from '../src/file-store.ts'
import { DeepSeekFileId } from '../src/file-id.ts'
import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts'
const REF: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
@@ -27,7 +27,7 @@ const VERSION: RequestImageAttachment = {
space: 'srgb',
hasAlpha: true,
}
const CONNECTION = { protocol: 'chat-completions' as const, baseURL: 'https://api.deepseek.com', apiKey: 'key' }
const CONNECTION = { baseURL: 'https://api.deepseek.com', apiKey: 'key' }
const POLICY = { expiresAfterSeconds: 604_800, refreshMarginSeconds: 3_600, quotaCleanupBatch: 100 }
const NOW = 1_700_000_000_000
@@ -50,17 +50,16 @@ function uploadFetch(now: () => number = () => NOW) {
const createdAt = now() / 1_000
return new Response(JSON.stringify({
id: `file-api-${uploads}`,
object: 'file',
bytes: 3,
created_at: createdAt,
type: 'file',
size_bytes: 3,
created_at: new Date(createdAt * 1_000).toISOString(),
filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`,
purpose: 'user_data',
expires_at: createdAt + POLICY.expiresAfterSeconds,
mime_type: 'image/png',
}), { status: 200 })
}
if (init?.method === 'DELETE') {
const id = requestUrl(_url).split('/').at(-1)
return new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 })
return new Response(JSON.stringify({ id, type: 'file_deleted' }), { status: 200 })
}
throw new Error('unexpected Files API request')
}) as typeof fetch
@@ -68,42 +67,34 @@ function uploadFetch(now: () => number = () => NOW) {
}
describe('DeepSeekFileStore', () => {
it('separates native Files reuse, invalidation, and expiry from the chat namespace', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-native-file-store-'))
it('reuses equivalent API roots and isolates other endpoints across invalidation and expiry', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-scope-'))
roots.push(dir)
let now = NOW
let uploads = 0
const fetchImpl: typeof fetch = async (input, init) => {
if (init?.method !== 'POST') throw new Error('only uploads expected')
uploads++
const common = { id: `file-${uploads}`, filename: 'dsh-image.png' }
return new Response(JSON.stringify(requestUrl(input).includes('/v1/files')
? { ...common, type: 'file', size_bytes: VERSION.bytes, created_at: new Date(now).toISOString(), mime_type: VERSION.mediaType }
: { ...common, object: 'file', bytes: VERSION.bytes, created_at: now / 1_000, expires_at: now / 1_000 + POLICY.expiresAfterSeconds, purpose: 'user_data' }))
}
const remote = uploadFetch(() => now)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
const store = new DeepSeekFileStore({ index, fetch: fetchImpl, now: () => now })
const native = { ...CONNECTION, protocol: 'messages' as const }
const chat = await store.ensureUploaded(VERSION, CONNECTION, POLICY)
const first = await store.ensureUploaded(VERSION, native, POLICY)
const store = new DeepSeekFileStore({ index, fetch: remote.fetchImpl, now: () => now })
const alternate = { ...CONNECTION, baseURL: 'https://other.example' }
const other = await store.ensureUploaded(VERSION, alternate, POLICY)
const first = await store.ensureUploaded(VERSION, CONNECTION, 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)
expect((await reopened.ensureUploaded(VERSION, native, POLICY)).record).toEqual(first.record)
expect(uploads).toBe(2)
await reopened.invalidate(VERSION, first.record.fileId, native)
const replacement = await reopened.ensureUploaded(VERSION, native, POLICY)
expect(first.record.scope).not.toBe(other.record.scope)
expect((await store.ensureUploaded(VERSION, { ...CONNECTION, baseURL: `${CONNECTION.baseURL}/v1/` }, POLICY)).record).toEqual(first.record)
const reopened = new DeepSeekFileStore({ index, fetch: remote.fetchImpl, now: () => now })
expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(first.record)
await reopened.invalidate(VERSION, other.record.fileId, CONNECTION)
expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(first.record)
expect(remote.uploads()).toBe(2)
await reopened.invalidate(VERSION, first.record.fileId, CONNECTION)
const replacement = await reopened.ensureUploaded(VERSION, CONNECTION, 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)
expect((await reopened.ensureUploaded(VERSION, alternate, POLICY)).record).toEqual(other.record)
expect(remote.uploads()).toBe(3)
now = replacement.record.expiresAt - POLICY.refreshMarginSeconds * 1_000
const refreshed = await reopened.ensureUploaded(VERSION, native, POLICY)
const refreshed = await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)
expect(refreshed.record.fileId).not.toBe(replacement.record.fileId)
expect(refreshed.record.expiresAt).toBe(now + POLICY.expiresAfterSeconds * 1_000)
expect(uploads).toBe(4)
expect(remote.uploads()).toBe(4)
})
it('reclaims the oldest owned native file across descending pages before retrying an upload', async () => {
@@ -135,7 +126,7 @@ describe('DeepSeekFileStore', () => {
: { data: [file('oldest-owned', 3_000), file('foreign', 4_000, 'user-file.png')], has_more: false }))
}
const store = new DeepSeekFileStore({ index: new DeepSeekUploadIndex(join(dir, 'index.json')), fetch: fetchImpl, now: () => NOW })
await expect(store.ensureUploaded(VERSION, { ...CONNECTION, protocol: 'messages' }, { ...POLICY, quotaCleanupBatch: 1 })).resolves.toMatchObject({ record: { fileId: 'file-uploaded' } })
await expect(store.ensureUploaded(VERSION, CONNECTION, { ...POLICY, quotaCleanupBatch: 1 })).resolves.toMatchObject({ record: { fileId: 'file-uploaded' } })
expect(cursors).toEqual([null, 'middle'])
expect(deleted).toEqual(['oldest-owned'])
expect(uploads).toBe(2)
@@ -192,12 +183,11 @@ describe('DeepSeekFileStore', () => {
expect(uploadSignal?.aborted).toBe(false)
complete?.(new Response(JSON.stringify({
id: 'file-api-shared',
object: 'file',
bytes: 3,
created_at: NOW / 1_000,
type: 'file',
size_bytes: 3,
created_at: new Date(NOW).toISOString(),
filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`,
purpose: 'user_data',
expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds,
mime_type: 'image/png',
}), { status: 200 }))
await expect(completed).resolves.toMatchObject({ record: { fileId: 'file-api-shared' } })
})
@@ -273,9 +263,8 @@ describe('DeepSeekFileStore', () => {
})
}
return Promise.resolve(new Response(JSON.stringify({
id: 'file-api-retry', object: 'file', bytes: 3, created_at: NOW / 1_000,
filename: 'dsh-retry.png', purpose: 'user_data',
expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds,
id: 'file-api-retry', type: 'file', size_bytes: 3, created_at: new Date(NOW).toISOString(),
filename: 'dsh-retry.png', mime_type: 'image/png',
}), { status: 200 }))
}) as typeof fetch
const store = new DeepSeekFileStore({
@@ -295,11 +284,11 @@ describe('DeepSeekFileStore', () => {
await expect(retried).resolves.toMatchObject({ record: { fileId: 'file-api-retry' } })
})
it.each(['chat-completions', 'messages'] as const)('rejects a request version above the %s per-image limit before transport', async (protocol) => {
it('rejects a request version above the per-image limit before transport', async () => {
const fetchImpl = vi.fn() as typeof fetch
const store = new DeepSeekFileStore({ now: () => NOW, fetch: fetchImpl })
const oversized = { ...VERSION, bytes: MAX_IMAGE_BYTES + 1 }
await expect(store.ensureUploaded(oversized, { ...CONNECTION, protocol }, POLICY))
await expect(store.ensureUploaded(oversized, CONNECTION, POLICY))
.rejects.toMatchObject({ code: 'INVALID_REQUEST', message: 'DeepSeek image exceeds the 32 MiB per-image limit.' })
expect(fetchImpl).not.toHaveBeenCalled()
})
@@ -329,9 +318,8 @@ describe('DeepSeekFileStore', () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({
id: 'file-api-wrong-size', object: 'file', bytes: 2, created_at: NOW / 1_000,
filename: 'dsh-wrong.png', purpose: 'user_data',
expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds,
id: 'file-api-wrong-size', type: 'file', size_bytes: 2, created_at: new Date(NOW).toISOString(),
filename: 'dsh-wrong.png', mime_type: 'image/png',
}), { status: 200 }))) as typeof fetch
const store = new DeepSeekFileStore({
index: new DeepSeekUploadIndex(join(dir, 'index.json')),
@@ -377,7 +365,7 @@ describe('DeepSeekFileStore', () => {
})
})
it('reuses local expires_at above the refresh margin and uploads again at the margin', async () => {
it('reuses the local expiry above the refresh margin and uploads again at the margin', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
@@ -418,7 +406,7 @@ describe('DeepSeekFileStore', () => {
vi.spyOn(index, 'commit').mockResolvedValue({
accepted: false,
record: {
scope: deepSeekFileScope(CONNECTION.baseURL, CONNECTION.apiKey),
scope: deepSeekFileScope(`${CONNECTION.baseURL}/v1`, CONNECTION.apiKey),
attachmentId: VERSION.attachment.attachmentId,
variantId: VERSION.variantId,
fileId: DeepSeekFileId('file-api-winner'),
@@ -452,22 +440,20 @@ describe('DeepSeekFileStore', () => {
error: { message: 'stored file quota exceeded', code: 'file_quota' },
}), { status: 400 }))
return Promise.resolve(new Response(JSON.stringify({
id: 'file-api-recovered', object: 'file', bytes: 3, created_at: NOW / 1_000,
filename: 'dsh-recovered.png', purpose: 'user_data',
expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds,
id: 'file-api-recovered', type: 'file', size_bytes: 3, created_at: new Date(NOW).toISOString(),
filename: 'dsh-recovered.png', mime_type: 'image/png',
}), { status: 200 }))
}
if (init?.method === 'DELETE') {
return Promise.resolve(new Response(JSON.stringify({
id: 'file-api-old', object: 'file', deleted: true,
id: 'file-api-old', type: 'file_deleted',
}), { status: 200 }))
}
expect(new URL(requestUrl(input)).pathname).toBe('/files')
expect(new URL(requestUrl(input)).pathname).toBe('/v1/files')
return Promise.resolve(new Response(JSON.stringify({
object: 'list',
data: [{
id: 'file-api-old', object: 'file', bytes: 3, created_at: NOW / 1_000,
filename: 'dsh-old.png', purpose: 'user_data',
id: 'file-api-old', type: 'file', size_bytes: 3, created_at: new Date(NOW).toISOString(),
filename: 'dsh-old.png', mime_type: 'image/png',
}],
first_id: 'file-api-old', last_id: 'file-api-old', has_more: false,
}), { status: 200 }))
@@ -492,10 +478,9 @@ describe('DeepSeekFileStore', () => {
error: { message: 'file count quota exceeded', code: 'file_quota' },
}), { status: 400 }))
return Promise.resolve(new Response(JSON.stringify({
object: 'list',
data: [{
id: 'file-api-foreign', object: 'file', bytes: 3, created_at: NOW / 1_000,
filename: 'foreign.png', purpose: 'user_data',
id: 'file-api-foreign', type: 'file', size_bytes: 3, created_at: new Date(NOW).toISOString(),
filename: 'foreign.png', mime_type: 'image/png',
}],
has_more: false,
}), { status: 200 }))
@@ -518,20 +503,19 @@ describe('DeepSeekFileStore', () => {
if (init?.method === 'DELETE') {
const id = target.pathname.split('/').at(-1) ?? ''
deleted.add(id)
return new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 })
return new Response(JSON.stringify({ id, type: 'file_deleted' }), { status: 200 })
}
const after = target.searchParams.get('after')
const after = target.searchParams.get('after_id')
if (after !== null && deleted.has(after)) throw new Error('deleted cursor cannot be reused')
const id = after === null ? 'file-api-oldest' : 'file-api-next'
return new Response(JSON.stringify({
object: 'list',
data: [{
id,
object: 'file',
bytes: 3,
created_at: NOW / 1_000,
type: 'file',
size_bytes: 3,
created_at: new Date(NOW).toISOString(),
filename: `dsh-${id}.png`,
purpose: 'user_data',
mime_type: 'image/png',
}],
first_id: id,
last_id: id,
@@ -556,12 +540,12 @@ describe('DeepSeekFileStore', () => {
const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => {
if (init?.method === 'DELETE') {
const id = requestUrl(input).split('/').at(-1)
return Promise.resolve(new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 }))
return Promise.resolve(new Response(JSON.stringify({ id, type: 'file_deleted' }), { status: 200 }))
}
page += 1
const lastId = mode === 'missing' ? undefined : 'file-api-same'
return Promise.resolve(new Response(JSON.stringify({
object: 'list', data: [], has_more: true,
data: [], has_more: true,
...lastId === undefined ? {} : { last_id: lastId },
}), { status: 200 }))
}) as typeof fetch
@@ -2,13 +2,13 @@ import { once } from 'node:events'
import { createServer } from 'node:http'
import { describe, expect, it, vi } from 'vitest'
import { LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import { DeepSeekFileId } from '../src/common/file-id.ts'
import { DeepSeekFileId } from '../src/file-id.ts'
import {
DeepSeekFilesClient,
DeepSeekFilesError,
isFilesQuotaError,
MAX_FILE_UPLOAD_BYTES,
} from '../src/common/files-api.ts'
} from '../src/files-api.ts'
function requestUrl(input: string | URL | Request): string {
if (typeof input === 'string') return input
@@ -17,28 +17,19 @@ function requestUrl(input: string | URL | Request): string {
function file(overrides: Record<string, unknown> = {}) {
return {
id: 'file-api-one',
object: 'file',
bytes: 3,
created_at: 1_700_000_000,
filename: 'image.png',
purpose: 'user_data',
expires_at: 1_700_604_800,
id: 'file-api-one', type: 'file', size_bytes: 3,
created_at: '2023-11-14T22:13:20.000Z', filename: 'image.png', mime_type: 'image/png',
...overrides,
}
}
describe('DeepSeekFilesClient', () => {
function messagesFile(overrides: Record<string, unknown> = {}) {
return { id: 'file-api-one', type: 'file', size_bytes: 3, created_at: '2026-09-12T13:02:46.677853363+00:00', filename: 'image.png', mime_type: 'image/png', ...overrides }
}
describe.each(['messages', 'chat-completions'] as const)('%s successful response JSON', (protocol) => {
describe('successful response JSON', () => {
it.each((['upload', 'list', 'retrieve', 'delete'] as const).flatMap(operation =>
['', '{"id":'].map(body => ({ operation, body })),
))('rejects malformed JSON with operation and HTTP status: %j', async ({ operation, body }) => {
const fetchImpl = vi.fn<typeof fetch>(async () => new Response(body, { status: 200 }))
const client = new DeepSeekFilesClient({ protocol, baseURL: 'https://files.example', apiKey: 'test-key', fetch: fetchImpl })
const client = new DeepSeekFilesClient({ baseURL: 'https://files.example', apiKey: 'test-key', fetch: fetchImpl })
const operations = {
upload: () => client.upload({ data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600 }),
list: () => client.list(),
@@ -59,7 +50,7 @@ describe('DeepSeekFilesClient', () => {
it.each([new TypeError('body transport failed'), new DOMException('body aborted', 'AbortError')])(
'preserves a non-JSON body-read failure: %s', async (failure) => {
const client = new DeepSeekFilesClient({ protocol, baseURL: 'https://files.example', apiKey: 'test-key',
const client = new DeepSeekFilesClient({ baseURL: 'https://files.example', apiKey: 'test-key',
fetch: async () => new Response(new ReadableStream({ start(controller) { controller.error(failure) } })),
})
await expect(client.retrieve(DeepSeekFileId('file-api-one'))).rejects.toBe(failure)
@@ -79,20 +70,20 @@ describe('DeepSeekFilesClient', () => {
expect(form.get('purpose')).toBeNull()
expect(form.get('expires_after[anchor]')).toBe('created_at')
expect(form.get('expires_after[seconds]')).toBe('3600')
return new Response(JSON.stringify(messagesFile()))
return new Response(JSON.stringify(file()))
}) as typeof fetch
const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example/custom/route///', apiKey: 'key', fetch: fetchImpl })
const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example/custom/route///', apiKey: 'key', fetch: fetchImpl })
const uploaded = await client.upload({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600 })
const createdAt = Math.floor(Date.parse('2026-09-12T13:02:46.677Z') / 1_000)
expect(uploaded).toEqual({ id: 'file-api-one', bytes: 3, createdAt, filename: 'image.png', purpose: 'user_data', expiresAt: createdAt + 3_600 })
const createdAt = Math.floor(Date.parse('2023-11-14T22:13:20.000Z') / 1_000)
expect(uploaded).toEqual({ id: 'file-api-one', bytes: 3, createdAt, filename: 'image.png', 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 })
const fetchImpl = vi.fn<typeof fetch>(async () => new Response(JSON.stringify(file())))
const client = new DeepSeekFilesClient({ baseURL, apiKey: 'key', fetch: fetchImpl })
await client.upload({
data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600,
@@ -102,42 +93,42 @@ describe('DeepSeekFilesClient', () => {
expect(requestUrl(fetchImpl.mock.calls[0]![0])).toBe(expected)
})
it('maps Messages list cursors, file metadata and deletion without OpenAI-only fields', async () => {
it('maps Messages list cursors, file metadata and deletion with native metadata', async () => {
const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(requestUrl(input))
if (url.search) {
expect(Object.fromEntries(url.searchParams)).toEqual({ after_id: 'file-before', limit: '1000' })
return new Response(JSON.stringify({ data: [messagesFile()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false }))
return new Response(JSON.stringify({ data: [file()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false }))
}
expect(url.pathname).toBe('/anthropic/v1/files/file-api-one')
return new Response(JSON.stringify(init?.method === 'DELETE' ? { id: 'file-api-one', type: 'file_deleted' } : messagesFile()))
return new Response(JSON.stringify(init?.method === 'DELETE' ? { id: 'file-api-one', type: 'file_deleted' } : file()))
}) as typeof fetch
const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://api.deepseek.com/anthropic', apiKey: 'key', fetch: fetchImpl })
await expect(client.list({ after: DeepSeekFileId('file-before'), limit: 1_000, order: 'asc' })).resolves.toMatchObject({ data: [{ bytes: 3 }], hasMore: false })
const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com/anthropic', apiKey: 'key', fetch: fetchImpl })
await expect(client.list({ after: DeepSeekFileId('file-before'), limit: 1_000 })).resolves.toMatchObject({ data: [{ bytes: 3 }], hasMore: false })
await expect(client.retrieve(DeepSeekFileId('file-api-one'))).resolves.toMatchObject({ id: 'file-api-one', bytes: 3 })
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',
const client = new DeepSeekFilesClient({ 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',
const client = new DeepSeekFilesClient({ 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 }) => {
it('refuses a redirected Files request before contacting another origin', async ({ 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()))
response.end(JSON.stringify(file()))
})
const source = createServer((_request, response) => {
response.writeHead(307, { location: `${origins[0]}/file-api-one` })
@@ -159,37 +150,37 @@ describe('DeepSeekFilesClient', () => {
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 client = new DeepSeekFilesClient({ 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))),
const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key',
fetch: async () => new Response(JSON.stringify(value === null || Array.isArray(value) ? value : file(value))),
})
await expect(client.retrieve(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' })
})
it('rejects a Messages deletion that returns a different file identity', async () => {
const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example', apiKey: 'key',
const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key',
fetch: async () => new Response(JSON.stringify({ id: 'wrong', type: 'file_deleted' })),
})
await expect(client.delete(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' })
})
it('uploads multipart bytes with the required purpose and explicit expiry', async () => {
it('uploads multipart bytes with attribution and explicit expiry', async () => {
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
expect(requestUrl(url)).toBe('https://api.deepseek.com/files')
expect(requestUrl(url)).toBe('https://api.deepseek.com/v1/files')
expect(init?.method).toBe('POST')
const headers = new Headers(init?.headers)
expect(headers.get('authorization')).toBe('Bearer key')
expect(headers.get('x-api-key')).toBe('key')
expect(headers.get('user-agent')).toBe(userAgent())
const form = init?.body
expect(form).toBeInstanceOf(FormData)
if (!(form instanceof FormData)) throw new Error('expected multipart body')
expect(form.get('purpose')).toBe('user_data')
expect(form.get('purpose')).toBeNull()
expect(form.get('expires_after[anchor]')).toBe('created_at')
expect(form.get('expires_after[seconds]')).toBe('604800')
const blob = form.get('file')
@@ -197,7 +188,7 @@ describe('DeepSeekFilesClient', () => {
expect((blob as Blob).size).toBe(3)
return new Response(JSON.stringify(file()), { status: 200 })
}) as typeof fetch
const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com/', apiKey: 'key', fetch: fetchImpl })
const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com/', apiKey: 'key', fetch: fetchImpl })
await expect(client.upload({
data: Uint8Array.of(1, 2, 3),
@@ -209,7 +200,6 @@ describe('DeepSeekFilesClient', () => {
bytes: 3,
createdAt: 1_700_000_000,
filename: 'image.png',
purpose: 'user_data',
expiresAt: 1_700_604_800,
})
})
@@ -219,40 +209,28 @@ describe('DeepSeekFilesClient', () => {
const target = requestUrl(url)
if (target.includes('?')) {
return new Response(JSON.stringify({
object: 'list', data: [file()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false,
data: [file()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false,
}), { status: 200 })
}
if (init?.method === 'DELETE') {
return new Response(JSON.stringify({ id: 'file-api-one', object: 'file', deleted: true }), { status: 200 })
return new Response(JSON.stringify({ id: 'file-api-one', type: 'file_deleted' }), { status: 200 })
}
return new Response(JSON.stringify(file()), { status: 200 })
}) as typeof fetch
const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl })
const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl })
await expect(client.list({ after: DeepSeekFileId('file-api-before'), limit: 20, order: 'desc' })).resolves.toMatchObject({
await expect(client.list({ after: DeepSeekFileId('file-api-before'), limit: 20 })).resolves.toMatchObject({
data: [{ id: 'file-api-one' }], firstId: 'file-api-one', lastId: 'file-api-one', hasMore: false,
})
await expect(client.retrieve(DeepSeekFileId('file-api-one'))).resolves.toMatchObject({ id: 'file-api-one' })
await expect(client.delete(DeepSeekFileId('file-api-one'))).resolves.toBeUndefined()
})
it('refuses an upload response that omits the requested expiry', async () => {
const fetchImpl = vi.fn(() => Promise.resolve(new Response(
JSON.stringify(file({ expires_at: undefined })),
{ status: 200 },
))) as typeof fetch
const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl })
await expect(client.upload({
data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600,
})).rejects.toMatchObject({ code: 'INVALID_RESPONSE' })
})
it('retains quota error detail for the one cleanup retry policy', async () => {
const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({
error: { message: 'user storage quota exceeded', type: 'invalid_request_error', code: 'file_quota' },
}), { status: 400 }))) as typeof fetch
const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl })
const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl })
const error = await client.upload({
data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600,
@@ -269,7 +247,6 @@ describe('DeepSeekFilesClient', () => {
[400, 'FILES_API'],
] as const)('classifies HTTP %i Files failures as %s', async (status, code) => {
const client = new DeepSeekFilesClient({
protocol: 'chat-completions',
baseURL: 'https://api.deepseek.com',
apiKey: 'key',
fetch: vi.fn(() => Promise.resolve(new Response('not-json', { status }))),
@@ -290,7 +267,6 @@ describe('DeepSeekFilesClient', () => {
{ error: { message: 1, type: 2, code: 3 } },
])('falls back to the HTTP status for an unstructured provider error %#', async (body) => {
const client = new DeepSeekFilesClient({
protocol: 'chat-completions',
baseURL: 'https://api.deepseek.com',
apiKey: 'key',
fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 400 }))),
@@ -303,7 +279,6 @@ describe('DeepSeekFilesClient', () => {
it('wraps transport failures but preserves an aborted request reason', async () => {
const transport = new Error('socket closed')
const client = new DeepSeekFilesClient({
protocol: 'chat-completions',
baseURL: 'https://api.deepseek.com',
apiKey: 'key',
fetch: vi.fn(() => Promise.reject(transport)),
@@ -324,19 +299,15 @@ describe('DeepSeekFilesClient', () => {
[],
file({ id: 1 }),
file({ id: '' }),
file({ object: 'wrong' }),
file({ bytes: 1.5 }),
file({ bytes: -1 }),
file({ type: 'wrong' }),
file({ size_bytes: 1.5 }),
file({ size_bytes: -1 }),
file({ created_at: 1.5 }),
file({ created_at: -1 }),
file({ filename: 1 }),
file({ filename: '' }),
file({ purpose: 'assistants' }),
file({ expires_at: 1.5 }),
file({ expires_at: -1 }),
])('rejects an invalid file object %#', async (body) => {
const client = new DeepSeekFilesClient({
protocol: 'chat-completions',
baseURL: 'https://api.deepseek.com',
apiKey: 'key',
fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))),
@@ -350,7 +321,7 @@ describe('DeepSeekFilesClient', () => {
3_600.5,
])('refuses invalid file expiry %s before transport', async (expiresAfterSeconds) => {
const fetchImpl = vi.fn() as typeof fetch
const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl })
const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl })
await expect(client.upload({
data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds,
})).rejects.toMatchObject({ code: 'INVALID_REQUEST' })
@@ -359,7 +330,7 @@ describe('DeepSeekFilesClient', () => {
it('refuses a file larger than the upload limit before transport', async () => {
const fetchImpl = vi.fn() as typeof fetch
const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl })
const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl })
const data = { byteLength: MAX_FILE_UPLOAD_BYTES + 1 } as Uint8Array
await expect(client.upload({
data, mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600,
@@ -371,14 +342,12 @@ describe('DeepSeekFilesClient', () => {
null,
[],
{},
{ object: 'wrong', data: [], has_more: false },
{ object: 'list', data: null, has_more: false },
{ object: 'list', data: [], has_more: 0 },
{ object: 'list', data: [], has_more: false, first_id: 1 },
{ object: 'list', data: [], has_more: false, last_id: 1 },
{ data: null, has_more: false },
{ data: [], has_more: 0 },
{ data: [], has_more: false, first_id: 1 },
{ data: [], has_more: false, last_id: 1 },
])('rejects an invalid list response %#', async (body) => {
const client = new DeepSeekFilesClient({
protocol: 'chat-completions',
baseURL: 'https://api.deepseek.com',
apiKey: 'key',
fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))),
@@ -388,11 +357,11 @@ describe('DeepSeekFilesClient', () => {
it('accepts a list without cursors and uses the global fetch default', async () => {
const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({
object: 'list', data: [], has_more: false,
data: [], has_more: false,
}), { status: 200 })))
vi.stubGlobal('fetch', fetchImpl)
try {
const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com///', apiKey: 'key' })
const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com///', apiKey: 'key' })
await expect(client.list()).resolves.toEqual({ data: [], hasMore: false })
} finally {
vi.unstubAllGlobals()
@@ -403,12 +372,10 @@ describe('DeepSeekFilesClient', () => {
null,
[],
{},
{ id: 'wrong', object: 'file', deleted: true },
{ id: 'file-api-one', object: 'wrong', deleted: true },
{ id: 'file-api-one', object: 'file', deleted: false },
{ id: 'wrong', type: 'file_deleted' },
{ id: 'file-api-one', type: 'wrong' },
])('rejects an invalid delete response %#', async (body) => {
const client = new DeepSeekFilesClient({
protocol: 'chat-completions',
baseURL: 'https://api.deepseek.com',
apiKey: 'key',
fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))),
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more