From 99e22ebbeb1e24139abdd01812651b4cfb26785f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:55:27 +0800 Subject: [PATCH 1/7] refactor(llm): make the official DeepSeek adapter Messages-only --- .../2026-09-15-messages-v1-base-url.i18n.yaml | 4 +- .../2026-09-15-messages-v1-base-url.md | 2 +- .../2026-09-15-messages-v1-base-url.zh.md | 2 +- ...6-messages-historical-tool-input.i18n.yaml | 4 +- ...26-09-16-messages-historical-tool-input.md | 2 +- ...09-16-messages-historical-tool-input.zh.md | 2 +- ...ages-input-history-compatibility.i18n.yaml | 4 +- ...18-messages-input-history-compatibility.md | 2 +- ...messages-input-history-compatibility.zh.md | 2 +- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 2 +- ...08-20-unified-image-request-pipeline.zh.md | 2 +- ...-09-07-deepseek-messages-adapter.i18n.yaml | 4 +- .../2026-09-07-deepseek-messages-adapter.md | 14 +- ...2026-09-07-deepseek-messages-adapter.zh.md | 14 +- ...026-09-19-deepseek-messages-only.i18n.yaml | 6 + .../2026-09-19-deepseek-messages-only.md | 25 + .../2026-09-19-deepseek-messages-only.zh.md | 25 + apps/cli/tests/built-bin.e2e.ts | 9 +- .../tests/fixtures/image-offload.cordis.yml | 1 - .../acp/tests/image-offload.expected.e2e.ts | 165 +- .../headless/tests/headless.expected.e2e.ts | 12 +- apps/web/tests/deepseek-messages-chat.e2e.ts | 2 +- .../tests/deepseek-messages-settings.e2e.ts | 10 +- apps/web/tests/scaffold.ts | 27 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 5 - docs/config-catalog.zh.md | 5 - ...deepseek-llm-api-wire-extensions.i18n.yaml | 4 +- docs/deepseek-llm-api-wire-extensions.md | 2 +- docs/deepseek-llm-api-wire-extensions.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 2 +- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 4 +- packages/bundle/web-app/README.zh.md | 4 +- .../src/client/ProviderEditor.tsx | 2 +- .../ui-settings-models/src/client/locales.ts | 6 +- .../tests/components.client.spec.tsx | 8 +- .../tests/image-offload.spec.ts | 17 +- .../tool-agent-team/tests/tool-team.spec.ts | 33 +- packages/llm/README.i18n.yaml | 4 +- packages/llm/README.md | 2 +- packages/llm/README.zh.md | 2 +- .../README.i18n.yaml | 4 +- .../llm/deepseek-llm-api-extensions/README.md | 2 +- .../deepseek-llm-api-extensions/README.zh.md | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 67 +- packages/llm/llm-deepseek/README.zh.md | 69 +- packages/llm/llm-deepseek/package.json | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 168 +- packages/llm/llm-deepseek/src/config.ts | 33 +- .../llm-deepseek/src/{common => }/defaults.ts | 0 .../llm-deepseek/src/{common => }/file-id.ts | 0 .../src/{common => }/file-store.ts | 20 +- .../src/{common => }/files-api.ts | 117 +- .../src/{common => }/image-tokens.ts | 0 .../src/{protocols/messages => }/images.ts | 10 +- packages/llm/llm-deepseek/src/index.ts | 33 +- .../src/{common => }/messages-api.ts | 0 .../src/{common => }/model-info.ts | 0 .../llm-deepseek/src/{common => }/models.ts | 2 +- .../src/protocols/chat-completions/adapter.ts | 378 --- .../protocols/chat-completions/serialize.ts | 444 --- .../src/protocols/chat-completions/sse.ts | 40 - .../protocols/chat-completions/translate.ts | 210 -- .../src/protocols/chat-completions/types.ts | 183 -- .../src/protocols/messages/adapter.ts | 152 - .../src/{protocols/messages => }/replay.ts | 4 +- .../src/{common => }/request-extensions.ts | 6 +- .../src/{common => }/request-files.ts | 0 .../src/{common => }/request-pricing.ts | 0 .../src/{protocols/messages => }/serialize.ts | 6 +- .../src/{protocols/messages => }/sse.ts | 0 .../src/{protocols/messages => }/translate.ts | 0 .../src/{protocols/messages => }/transport.ts | 0 .../llm-deepseek/src/{common => }/types.ts | 11 +- .../src/{common => }/upload-index.ts | 0 .../messages/types.ts => wire-types.ts} | 4 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 672 ++-- .../llm/llm-deepseek/tests/adapter.spec.ts | 2750 +++-------------- .../llm-deepseek/tests/dynamic-config.spec.ts | 16 +- .../llm/llm-deepseek/tests/egress.spec.ts | 6 +- .../expected/degraded-replay.json | 0 .../tests/{messages => }/extensions.spec.ts | 2 +- .../llm/llm-deepseek/tests/file-store.spec.ts | 132 +- .../llm/llm-deepseek/tests/files-api.spec.ts | 131 +- .../tests/{messages => }/files.spec.ts | 35 +- .../tests/{messages => }/fixtures/cordis.yml | 0 .../tests/{messages => }/fixtures/history.ts | 4 +- .../{messages => }/fixtures/input-history.ts | 4 +- .../tests/{messages => }/helpers.ts | 41 +- .../llm-deepseek/tests/image-tokens.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 45 +- .../tests/messages/adapter.e2e.ts | 281 -- .../tests/messages/adapter.spec.ts | 450 --- .../tests/messages/fixtures/red.png | Bin 69 -> 0 bytes .../tests/messages/serialize.spec.ts | 454 --- .../llm/llm-deepseek/tests/mock-server.ts | 45 +- .../llm-deepseek/tests/prepared-call.spec.ts | 58 + .../llm/llm-deepseek/tests/protocol.spec.ts | 92 - .../tests/request-pricing.spec.ts | 2 +- .../llm/llm-deepseek/tests/runtime.e2e.ts | 462 +++ .../llm/llm-deepseek/tests/runtime.spec.ts | 2340 ++++++++++++++ .../llm/llm-deepseek/tests/serialize.spec.ts | 1222 +++----- packages/llm/llm-deepseek/tests/sse.spec.ts | 68 - .../tests/{messages => }/stream.spec.ts | 6 +- .../llm/llm-deepseek/tests/translate.spec.ts | 446 --- .../llm-deepseek/tests/upload-index.spec.ts | 4 +- .../tests/transport-recovery.spec.ts | 5 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- .../tests/feedback-composition.spec.ts | 8 +- .../continuation-messages-adapter.spec.ts | 2 +- .../llm-mock-server/README.i18n.yaml | 4 +- .../test-support/llm-mock-server/README.md | 12 +- .../test-support/llm-mock-server/README.zh.md | 12 +- .../test-support/llm-mock-server/package.json | 2 +- .../test-support/llm-mock-server/src/index.ts | 109 +- .../llm-mock-server/tests/server.spec.ts | 98 +- python/sdk/tests/manual_sdk_agent_smoke.py | 21 +- scripts/no-unknown-casts.baseline.json | 42 - .../cordis.snapshot.yml | 2 +- .../cordis.yml | 2 +- .../cordis.snapshot.yml | 2 +- .../cordis.yml | 2 +- .../office-skills-no-renderer/cordis.yml | 1 - 128 files changed, 4786 insertions(+), 7743 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md create mode 100644 .agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md rename packages/llm/llm-deepseek/src/{common => }/defaults.ts (100%) rename packages/llm/llm-deepseek/src/{common => }/file-id.ts (100%) rename packages/llm/llm-deepseek/src/{common => }/file-store.ts (93%) rename packages/llm/llm-deepseek/src/{common => }/files-api.ts (63%) rename packages/llm/llm-deepseek/src/{common => }/image-tokens.ts (100%) rename packages/llm/llm-deepseek/src/{protocols/messages => }/images.ts (93%) rename packages/llm/llm-deepseek/src/{common => }/messages-api.ts (100%) rename packages/llm/llm-deepseek/src/{common => }/model-info.ts (100%) rename packages/llm/llm-deepseek/src/{common => }/models.ts (92%) delete mode 100644 packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts delete mode 100644 packages/llm/llm-deepseek/src/protocols/chat-completions/serialize.ts delete mode 100644 packages/llm/llm-deepseek/src/protocols/chat-completions/sse.ts delete mode 100644 packages/llm/llm-deepseek/src/protocols/chat-completions/translate.ts delete mode 100644 packages/llm/llm-deepseek/src/protocols/chat-completions/types.ts delete mode 100644 packages/llm/llm-deepseek/src/protocols/messages/adapter.ts rename packages/llm/llm-deepseek/src/{protocols/messages => }/replay.ts (93%) rename packages/llm/llm-deepseek/src/{common => }/request-extensions.ts (87%) rename packages/llm/llm-deepseek/src/{common => }/request-files.ts (100%) rename packages/llm/llm-deepseek/src/{common => }/request-pricing.ts (100%) rename packages/llm/llm-deepseek/src/{protocols/messages => }/serialize.ts (98%) rename packages/llm/llm-deepseek/src/{protocols/messages => }/sse.ts (100%) rename packages/llm/llm-deepseek/src/{protocols/messages => }/translate.ts (100%) rename packages/llm/llm-deepseek/src/{protocols/messages => }/transport.ts (100%) rename packages/llm/llm-deepseek/src/{common => }/types.ts (92%) rename packages/llm/llm-deepseek/src/{common => }/upload-index.ts (100%) rename packages/llm/llm-deepseek/src/{protocols/messages/types.ts => wire-types.ts} (95%) rename packages/llm/llm-deepseek/tests/{messages => }/expected/degraded-replay.json (100%) rename packages/llm/llm-deepseek/tests/{messages => }/extensions.spec.ts (99%) rename packages/llm/llm-deepseek/tests/{messages => }/files.spec.ts (91%) rename packages/llm/llm-deepseek/tests/{messages => }/fixtures/cordis.yml (100%) rename packages/llm/llm-deepseek/tests/{messages => }/fixtures/history.ts (77%) rename packages/llm/llm-deepseek/tests/{messages => }/fixtures/input-history.ts (93%) rename packages/llm/llm-deepseek/tests/{messages => }/helpers.ts (63%) delete mode 100644 packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts delete mode 100644 packages/llm/llm-deepseek/tests/messages/adapter.spec.ts delete mode 100644 packages/llm/llm-deepseek/tests/messages/fixtures/red.png delete mode 100644 packages/llm/llm-deepseek/tests/messages/serialize.spec.ts create mode 100644 packages/llm/llm-deepseek/tests/prepared-call.spec.ts delete mode 100644 packages/llm/llm-deepseek/tests/protocol.spec.ts create mode 100644 packages/llm/llm-deepseek/tests/runtime.e2e.ts create mode 100644 packages/llm/llm-deepseek/tests/runtime.spec.ts delete mode 100644 packages/llm/llm-deepseek/tests/sse.spec.ts rename packages/llm/llm-deepseek/tests/{messages => }/stream.spec.ts (97%) delete mode 100644 packages/llm/llm-deepseek/tests/translate.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml index e63305ebf1..106b30af30 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-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 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md index 97f7294c28..d9014756ea 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md index ccb8c5930d..8f5803e4bd 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md @@ -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` 命名空间。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml index 6bb2fcf803..e6974943f5 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-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 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md index 201b0f21ad..85e4047bb5 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md index bcc6379824..5a8c31dbee 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md @@ -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 事件、持久化类型或协议配置。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml index 0898823b35..259613bc28 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-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 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md index 1775276dee..dd974fb1ae 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md index 50367e33c2..6e850328db 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md @@ -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)中的输入拒绝规则。通知构建仍为每种父级提供方投影子级的非空文本。子级的规范输出以及普通助手推理和工具调用仍可供现有消费者使用。省略依据是输入角色和块类型,而不是通知来源或创建日期,因此也适用于新提供的用户和工具结果内容。 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index df9b8b6339..4f9e640fe2 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: 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 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index 7431a474b5..b79eeb7d90 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index 247a57b801..4442347152 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -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 天有效期限制。 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index d726d38ae1..59be879b5b 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: b79c2526461843a9534d6a997029281a584e0bed -2026-09-07-deepseek-messages-adapter.zh.md: 49a967828e22803296ae3d4c8eef943b9ec8e3c5 +2026-09-07-deepseek-messages-adapter.md: e8c84066e2ede5bdaf9e490b4ac0c9562cc8a557 +2026-09-07-deepseek-messages-adapter.zh.md: bde7fea769fbc5b4c2e622067b89ff8f29e91fb4 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index b79c252646..e8c84066e2 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -10,25 +10,25 @@ Deployments expose DeepSeek through Anthropic Messages gateways as well as chat- ## 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 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. ## 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. diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index 49a967828e..bde7fea769 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -10,25 +10,25 @@ Status: implemented ## 决策 -[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 代理兼容。 diff --git a/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml new file mode 100644 index 0000000000..07287b00f8 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md new file mode 100644 index 0000000000..c10ecae950 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md @@ -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. diff --git a/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md new file mode 100644 index 0000000000..3cfadac6eb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md @@ -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 回放元数据读取。 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 1cd1c1636c..874e216499 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -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() diff --git a/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml b/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml index e369eb59f3..aec25ed875 100644 --- a/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml +++ b/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml @@ -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 diff --git a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts index 16da9a81d7..59195c7669 100644 --- a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts @@ -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[] = [] 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) 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> }> - 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> - 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: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n' - + `\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> }> + 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> + 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(resolve => server.close(() => { resolve() })) diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 4724981169..93e6e27ad4 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -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 { +async function deepseekDefaultsServer( + options: { waitForTitleRequest?: boolean; piAiCompatibility?: true } = {}, +): Promise { 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', diff --git a/apps/web/tests/deepseek-messages-chat.e2e.ts b/apps/web/tests/deepseek-messages-chat.e2e.ts index 36b37a70ab..d6bbf88260 100644 --- a/apps/web/tests/deepseek-messages-chat.e2e.ts +++ b/apps/web/tests/deepseek-messages-chat.e2e.ts @@ -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) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 32fd2a613e..3018cc432e 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -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 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' }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index e7249a0b67..544c717a60 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -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 { @@ -571,13 +567,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ + 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 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) { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ecb1a48a7b..ca589a4782 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 92b24a0948883b2974c5cb8ab650bd3db3b50326 -config-catalog.zh.md: 4ba0378810f3db4797c0d41fa5230f7069438b3f +config-catalog.md: ed3f3f691a0082a07926e6a2108cf7d929464a55 +config-catalog.zh.md: 00968de695ebde8599be7ca23e246fa6e00e4578 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 92b24a0948..ed3f3f691a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4ba0378810..00968de695 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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. */ diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml index 7329ae0f17..547583134d 100644 --- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml +++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/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 diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md index f45508f375..47ecc9db54 100644 --- a/docs/deepseek-llm-api-wire-extensions.md +++ b/docs/deepseek-llm-api-wire-extensions.md @@ -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. diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md index ff72d62a9c..d79f97fc3a 100644 --- a/docs/deepseek-llm-api-wire-extensions.zh.md +++ b/docs/deepseek-llm-api-wire-extensions.zh.md @@ -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,也不会改变模型可见前缀。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 1aaf9da6b4..079942f1d1 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -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 diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 705ff4a9a4..ad5bbccf32 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: 00f523098ada6646a2cf22ce57ca5fef6c6d9afb -README.zh.md: 2a3ea0d7d480ab2a29aa1d4adcb01836a9b70007 +README.md: 8fea1287610a0a71f2a351a1b8e00af87b20ba44 +README.zh.md: 603108991cd74ba379b3b9d8de9a2ff11bf7e101 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 00f523098a..8fea128761 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -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 diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 2a3ea0d7d4..603108991c 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -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 地址与凭据引用。 ### 配置 diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index bc89f43fc8..0f21aaff9a 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -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')} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 2b0f92d8df..64cb2c73f8 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -35,8 +35,7 @@ export const en = { customized: 'Customized settings', baseUrl: 'Base URL', baseUrlDefault: 'Provider default', - deepSeekChatBaseUrl: 'https://api.deepseek.com', - deepSeekMessagesBaseUrl: 'https://api.deepseek.com/anthropic', + deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', deepSeekEndpointHint: 'Use an endpoint compatible with the configured connection.', models: 'Models', modelsInherited: 'Using the adapter defaults', @@ -147,8 +146,7 @@ export const zh: { [Key in keyof typeof en]: string } = { customized: '自定义设置', baseUrl: 'API 地址', baseUrlDefault: '提供方默认', - deepSeekChatBaseUrl: 'https://api.deepseek.com', - deepSeekMessagesBaseUrl: 'https://api.deepseek.com/anthropic', + deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', deepSeekEndpointHint: '请填写与当前连接配置兼容的 API 地址。', models: '模型目录', modelsInherited: '正在使用适配器默认模型', diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index e9cec03e60..14236346c1 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -618,7 +618,7 @@ describe('ModelsSection', () => { const baseURL = screen.getByLabelText(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(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: '' } }) diff --git a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts index 2123638197..001b307a65 100644 --- a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts +++ b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts @@ -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 { 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 diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index 2c1f188bc2..198e39f35f 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -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: '\nYou are teammate "reviewer".\n\n\nReview the work', - }) + expect(childRequest.messages.at(-1)?.content.slice(0, 2)).toEqual([ + { type: 'text', text: '\nYou are teammate "reviewer".\n\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 = '\nYou are teammate "reviewer".\n\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('\nYou are teammate "reviewer".\n\n\nReview the work') + expect(serializeRequest(adapter.requests[0]!).messages.at(-1)?.content).toEqual([ + { type: 'text', text: '\nYou are teammate "reviewer".\n\n\n' }, + { type: 'text', text: 'Review the work' }, + ]) }) it('returns actionable no-progress output and renders structured wait cancellation', async () => { diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml index f98e218e74..8b480a856c 100644 --- a/packages/llm/README.i18n.yaml +++ b/packages/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/README.md -README.md: 9f7e8f444345bb60677a57679ad559f1e06be83f -README.zh.md: fbb27ed1567fa3319a7fc1bdf61b6436113e0aba +README.md: cbd9e386f0f219eea4f41470bd5da076db00ebe0 +README.zh.md: a3a31dfddd38a07ac95aae037f157ac12763974e diff --git a/packages/llm/README.md b/packages/llm/README.md index 9f7e8f4443..cbd9e386f0 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -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` | diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md index fbb27ed156..a3a31dfddd 100644 --- a/packages/llm/README.zh.md +++ b/packages/llm/README.zh.md @@ -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` | diff --git a/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml b/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml index caa80e03ad..44a416eea5 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml +++ b/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/deepseek-llm-api-extensions/README.md -README.md: 45a4182d77eb293b55d39d043bf8a1e1075873cf -README.zh.md: 0131791ca5837d3143c17640bf10cb1f4d215555 +README.md: 1505c644a32a8c90c38720ac6229eb3b654693a1 +README.zh.md: d27eca175c19bb914045958d1d748f427a4971b7 diff --git a/packages/llm/deepseek-llm-api-extensions/README.md b/packages/llm/deepseek-llm-api-extensions/README.md index 45a4182d77..1505c644a3 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.md +++ b/packages/llm/deepseek-llm-api-extensions/README.md @@ -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. diff --git a/packages/llm/deepseek-llm-api-extensions/README.zh.md b/packages/llm/deepseek-llm-api-extensions/README.zh.md index 0131791ca5..d27eca175c 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.zh.md +++ b/packages/llm/deepseek-llm-api-extensions/README.zh.md @@ -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` 都不消费该注册表。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index bcc2683e92..b77dcd559e 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 5bad4d36296c350ef2bc88ad2ff58fec33b93805 -README.zh.md: 1c1c5a8255c3cd894e0aeecfe49079a78fd03eeb +README.md: 696db921f266ea0a5187aa9b98cf316343b6c74c +README.zh.md: 8e152a93de50dea7fc62cd3638957e6b1a879d23 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5bad4d3629..696db921f2 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -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. - -### Choose a protocol + +### 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,7 +100,7 @@ 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 @@ -133,17 +124,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 +158,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 +184,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`. @@ -213,23 +193,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. +- The [Messages system-update e2e checks](tests/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`. ### Dev Note -
-Working context for maintainers — click to expand - -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. - -
+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. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 1c1c5a8255..8e152a93de 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -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` 可以提早压缩。 - -### 选择协议 + +### 端点与协议格式 -通过补丁为已有插件显式选择 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,7 +100,7 @@ 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` 清单;两者都留在模型输入之外。 ### 失败与恢复 @@ -131,19 +122,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 +158,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 +184,6 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 ## 已知限制与延期工作 -- Responses 协议尚未实现,配置值 `responses` 会被拒绝。 @@ -213,23 +193,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` 失败。 +- [Messages system 更新 e2e](tests/adapter.e2e.ts) 要求通过 `DEEPSEEK_IN_HISTORY_MODEL` 指定支持该能力的模型,例如 `deepseek-flash`,并使用 `high` 思考强度。该变量未设置或为空时跳过;普通 `off` 文本检查仍在有凭据时运行。关闭思考时已知的指令遵循不稳定,使这些 system 更新检查不适合使用 `off`。 ### 开发备注 -
-维护者的工作上下文——点击展开 - -本开发备注是不具权威性的工作上下文:尚未决定的探索方向与维护者备注。已交付的行为与既定理由以上文、包代码和相关 Agent Note 为准。 - -- OpenRouter 专属应用归因标头延期到未来显式 OpenRouter 适配器或模式;OpenAI 兼容网关请求只携带共享归因基线。 -- `off` 推理强度绝不会以 `reasoning_effort: 'off'` 过线;它序列化为 `thinking: { type: 'disabled' }` 并省略该字段,从而对拒绝未知强度取值的网关保持协议拼写有效。 - -
+无。 **运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index e3f69ca464..92a8643857 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -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" diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 8e46799185..4b0f3704b9 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -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 { + const connection = this.dependencies.options() + return Promise.resolve({ model: modelInfo(connection, provider, model), stream: options => this.generate(options, connection) }) + } + stream(options: GenerateOptions): AsyncIterable { + return this.generate(options, this.dependencies.options()) + } + + private async * generate(options: GenerateOptions, connection: Connection): AsyncGenerator { + 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 { - return this.implementation().prepareCall(provider, model, signal) - } - stream(options: GenerateOptions): AsyncIterable { - return this.implementation().stream(options) + private async * request( + options: GenerateOptions, connection: Connection, signal: AbortSignal, activity: () => void, + ): AsyncGenerator { + 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> | 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>, { + 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 + } } } diff --git a/packages/llm/llm-deepseek/src/config.ts b/packages/llm/llm-deepseek/src/config.ts index 3bf9f5a6d2..edfbc252b4 100644 --- a/packages/llm/llm-deepseek/src/config.ts +++ b/packages/llm/llm-deepseek/src/config.ts @@ -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 = z.object({ }) export const Config: z = 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 = 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: { diff --git a/packages/llm/llm-deepseek/src/common/defaults.ts b/packages/llm/llm-deepseek/src/defaults.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/defaults.ts rename to packages/llm/llm-deepseek/src/defaults.ts diff --git a/packages/llm/llm-deepseek/src/common/file-id.ts b/packages/llm/llm-deepseek/src/file-id.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/file-id.ts rename to packages/llm/llm-deepseek/src/file-id.ts diff --git a/packages/llm/llm-deepseek/src/common/file-store.ts b/packages/llm/llm-deepseek/src/file-store.ts similarity index 93% rename from packages/llm/llm-deepseek/src/common/file-store.ts rename to packages/llm/llm-deepseek/src/file-store.ts index 5fcab0cbe1..0963d2eeab 100644 --- a/packages/llm/llm-deepseek/src/common/file-store.ts +++ b/packages/llm/llm-deepseek/src/file-store.ts @@ -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 } diff --git a/packages/llm/llm-deepseek/src/common/files-api.ts b/packages/llm/llm-deepseek/src/files-api.ts similarity index 63% rename from packages/llm/llm-deepseek/src/common/files-api.ts rename to packages/llm/llm-deepseek/src/files-api.ts index 8c05165bea..abf9f12191 100644 --- a/packages/llm/llm-deepseek/src/common/files-api.ts +++ b/packages/llm/llm-deepseek/src/files-api.ts @@ -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 + 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 - 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 { 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 { - 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 { - 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 { - 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') } } diff --git a/packages/llm/llm-deepseek/src/common/image-tokens.ts b/packages/llm/llm-deepseek/src/image-tokens.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/image-tokens.ts rename to packages/llm/llm-deepseek/src/image-tokens.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/images.ts b/packages/llm/llm-deepseek/src/images.ts similarity index 93% rename from packages/llm/llm-deepseek/src/protocols/messages/images.ts rename to packages/llm/llm-deepseek/src/images.ts index f2f32f003e..9d72f4c5dc 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/images.ts +++ b/packages/llm/llm-deepseek/src/images.ts @@ -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 { diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index d38246b971..579e977a9c 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -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,18 @@ 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 type * from './wire-types.ts' export const name = 'llm-deepseek' export const inject = ['llm'] diff --git a/packages/llm/llm-deepseek/src/common/messages-api.ts b/packages/llm/llm-deepseek/src/messages-api.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/messages-api.ts rename to packages/llm/llm-deepseek/src/messages-api.ts diff --git a/packages/llm/llm-deepseek/src/common/model-info.ts b/packages/llm/llm-deepseek/src/model-info.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/model-info.ts rename to packages/llm/llm-deepseek/src/model-info.ts diff --git a/packages/llm/llm-deepseek/src/common/models.ts b/packages/llm/llm-deepseek/src/models.ts similarity index 92% rename from packages/llm/llm-deepseek/src/common/models.ts rename to packages/llm/llm-deepseek/src/models.ts index 8f614db47b..6bad9113ce 100644 --- a/packages/llm/llm-deepseek/src/common/models.ts +++ b/packages/llm/llm-deepseek/src/models.ts @@ -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' diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts deleted file mode 100644 index 77d2bf6ca5..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts +++ /dev/null @@ -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, -): 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> { - const refs = new Map() - 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 | 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 { - // 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 { - return Promise.resolve(this.config.options().models.map(model => catalogModelInfo(provider, model))) - } - - override resolveModel( - provider: string, - model: string, - _signal?: AbortSignal, - ): Promise { - return Promise.resolve(modelInfo(this.config.options(), provider, model)) - } - - override prepareCall(provider: string, model: string, _signal?: AbortSignal): Promise { - const connection = this.config.options() - return Promise.resolve({ - model: modelInfo(connection, provider, model), - stream: options => this.streamWithConnection(options, connection), - }) - } - - stream(options: GenerateOptions): AsyncIterable { - return this.streamWithConnection(options, this.config.options()) - } - - private async * streamWithConnection( - options: GenerateOptions, - connection: DeepSeekConnectionOptions, - ): AsyncIterable { - // 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 { - 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() - : 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>, { - 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 - } - } -} diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/serialize.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/serialize.ts deleted file mode 100644 index 803e4c6877..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/serialize.ts +++ /dev/null @@ -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, - location: ImageWireLocation, - ) => Promise - } - | { 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 - /** 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): '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, - 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 { - 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 { - 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 { - 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) -} diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/sse.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/sse.ts deleted file mode 100644 index 9126a18940..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/sse.ts +++ /dev/null @@ -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, - onComment?: (comment: string) => void, -): AsyncGenerator { - 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') -} diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/translate.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/translate.ts deleted file mode 100644 index 8dd939e547..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/translate.ts +++ /dev/null @@ -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(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): AsyncGenerator { - let nextIndex = 0 - let textBlock: OpenBlock | undefined - let reasoningBlock: OpenBlock | undefined - const toolBlocks = new Map() - 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(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') -} diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/types.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/types.ts deleted file mode 100644 index 38e73ed39e..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/types.ts +++ /dev/null @@ -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 - } -} - -/** 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 } -} diff --git a/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts b/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts deleted file mode 100644 index e5304b8380..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts +++ /dev/null @@ -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 - /** 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 { - const connection = this.dependencies.connection() - return Promise.resolve({ model: modelInfo(connection, provider, model), stream: options => this.generate(options, connection) }) - } - stream(options: GenerateOptions): AsyncIterable { - return this.generate(options, this.dependencies.connection()) - } - - private async * generate(options: GenerateOptions, connection: Connection): AsyncGenerator { - 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 { - 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> | 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>, { - 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 - } - } -} diff --git a/packages/llm/llm-deepseek/src/protocols/messages/replay.ts b/packages/llm/llm-deepseek/src/replay.ts similarity index 93% rename from packages/llm/llm-deepseek/src/protocols/messages/replay.ts rename to packages/llm/llm-deepseek/src/replay.ts index d6ff95804b..f285ebaeae 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/replay.ts +++ b/packages/llm/llm-deepseek/src/replay.ts @@ -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 } diff --git a/packages/llm/llm-deepseek/src/common/request-extensions.ts b/packages/llm/llm-deepseek/src/request-extensions.ts similarity index 87% rename from packages/llm/llm-deepseek/src/common/request-extensions.ts rename to packages/llm/llm-deepseek/src/request-extensions.ts index 906e778382..e1cb10febb 100644 --- a/packages/llm/llm-deepseek/src/common/request-extensions.ts +++ b/packages/llm/llm-deepseek/src/request-extensions.ts @@ -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. diff --git a/packages/llm/llm-deepseek/src/common/request-files.ts b/packages/llm/llm-deepseek/src/request-files.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/request-files.ts rename to packages/llm/llm-deepseek/src/request-files.ts diff --git a/packages/llm/llm-deepseek/src/common/request-pricing.ts b/packages/llm/llm-deepseek/src/request-pricing.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/request-pricing.ts rename to packages/llm/llm-deepseek/src/request-pricing.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts similarity index 98% rename from packages/llm/llm-deepseek/src/protocols/messages/serialize.ts rename to packages/llm/llm-deepseek/src/serialize.ts index 78448e6e51..a78767df7d 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -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') diff --git a/packages/llm/llm-deepseek/src/protocols/messages/sse.ts b/packages/llm/llm-deepseek/src/sse.ts similarity index 100% rename from packages/llm/llm-deepseek/src/protocols/messages/sse.ts rename to packages/llm/llm-deepseek/src/sse.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/translate.ts b/packages/llm/llm-deepseek/src/translate.ts similarity index 100% rename from packages/llm/llm-deepseek/src/protocols/messages/translate.ts rename to packages/llm/llm-deepseek/src/translate.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/transport.ts b/packages/llm/llm-deepseek/src/transport.ts similarity index 100% rename from packages/llm/llm-deepseek/src/protocols/messages/transport.ts rename to packages/llm/llm-deepseek/src/transport.ts diff --git a/packages/llm/llm-deepseek/src/common/types.ts b/packages/llm/llm-deepseek/src/types.ts similarity index 92% rename from packages/llm/llm-deepseek/src/common/types.ts rename to packages/llm/llm-deepseek/src/types.ts index d1c4932317..180227f484 100644 --- a/packages/llm/llm-deepseek/src/common/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -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. diff --git a/packages/llm/llm-deepseek/src/common/upload-index.ts b/packages/llm/llm-deepseek/src/upload-index.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/upload-index.ts rename to packages/llm/llm-deepseek/src/upload-index.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/types.ts b/packages/llm/llm-deepseek/src/wire-types.ts similarity index 95% rename from packages/llm/llm-deepseek/src/protocols/messages/types.ts rename to packages/llm/llm-deepseek/src/wire-types.ts index 582ffc7545..c72f0c9d47 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/types.ts +++ b/packages/llm/llm-deepseek/src/wire-types.ts @@ -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 diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 2a68e915cc..f8971f33e7 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,466 +1,280 @@ -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 { 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 { - return Promise.resolve() - } - - saveImage(_input: SaveImageAttachment): Promise { - return Promise.resolve(this.ref) - } - - readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { - return Promise.resolve({ ref, data: TEST_PNG }) - } - - override readImageRequest( - _ref: ImageAttachmentRef, - _target: ImageRequestTarget, - _signal?: AbortSignal, - ): Promise { - return Promise.resolve(this.version) - } -} - -beforeEach(async () => { - identityHome = await mkdtemp(join(tmpdir(), 'dsh-e2e-user-id-')) - vi.stubEnv('DSH_HOME', identityHome) +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!() + vi.unstubAllEnvs() + vi.unstubAllGlobals() }) - -async function harness(model: string, config: Partial = {}) { +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>() + 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, purpose: 'user_data' }) + expect(retrieved.expiresAt).toBeUndefined() + let page = await files.list({ limit: 1_000 }) + const cursors = new Set() + 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 packageIdentity = JSON.parse(await readFile(new URL('../../../plugin-package-inventory-deepseek/package.json', import.meta.url), '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 + 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) }) }) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ea6ba2bf8f..f12fe10e87 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,2358 +1,448 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' +/** HTTP lifecycle, routing and optional Cordis services under real composition. */ +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from '@deepseek-ai/cordis' -import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -import LlmRuntime, { ToolCallId, createUserMessage, - CONTEXT_WINDOW_EXCEEDED_CODE, - createToolResultMessage, - LlmError, - ProviderRequestId, - QUOTA_EXCEEDED_CODE, - ReasoningEffortId, - userAgent, -} from '@deepseek-ai/dsh-llm' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { SessionId } from '@deepseek-ai/dsh-session' -import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' -import type { ContextFormed } from '@deepseek-ai/dsh-llm' -import { httpErrorCode } from '../src/protocols/chat-completions/adapter.ts' -import { resolveRequestImageTarget } from '../src/common/request-pricing.ts' -import { assemble } from './assemble.ts' -import { closeMockServers, mockServer, textEvents } from './mock-server.ts' -import type { Behavior } from './mock-server.ts' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context, LoggerLevel, Service } from '@deepseek-ai/cordis' +import LocalAttachments from '@deepseek-ai/dsh-attachment-local' +import AgentRegistry, { installModelSelection } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import LlmRuntime, { createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { Message } from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import LocalCredentials from '@deepseek-ai/dsh-credentials-local' +import FileSettings from '@deepseek-ai/dsh-settings-file' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { DeepSeekAdapter } from '../src/adapter.ts' +import { object } from '../src/replay.ts' +import { DeepSeekFileStore } from '../src/file-store.ts' +import * as Messages from '../src/index.ts' +import { adapter, assemble, chunks, MODEL, options, prepareExtensions, server, sse, textEvents, user, sourceModuleLoader } from './helpers.ts' + +const cleanup: (() => Promise)[] = [] +afterEach(async () => { + while (cleanup.length) await cleanup.pop()!() + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) +async function endpoint(...args: Parameters) { + const instance = await server(...args) + cleanup.push(() => instance.close()) + return instance +} +async function context() { + const home = await mkdtemp(join(tmpdir(), 'dsh-messages-test-')) + cleanup.push(() => rm(home, { recursive: true, force: true })) + vi.stubEnv('DSH_HOME', home) + const ctx = new Context() + cleanup.push(() => ctx.fiber.dispose()) + return { ctx, home } +} + +async function send(agent: Agent, text: string) { + agent.followup(user(text)) + await agent.whenIdle() + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) +} declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { - 'test': { kind: 'test' } & ContextFormed + 'saved-notice': { kind: 'saved-notice' } } } -const TEST_USER_ID = '00000000-0000-4000-8000-000000000001' as AnonymousUserId -let testHome: string +describe('direct Messages HTTP', () => { + it('continues through Messages with assistant blocks in saved user history', async () => { + const http = await endpoint() + const notice = createUserMessage({ source: { kind: 'saved-notice' }, content: [ + { type: 'text', text: 'Background subagent finished.' }, + { type: 'reasoning', text: 'child reasoning' }, + { type: 'tool-call', id: ToolCallId('child-call'), name: 'read', arguments: '{}' }, + { type: 'text', text: 'Its closing message: answer.' }, + ] }) + const empty = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'reasoning', text: 'no closing text' }] }) + const history = [notice, empty] + const saved = JSON.stringify(history) + const llm = adapter({ baseURL: http.url }) + const first = await assemble(llm.stream(options({ messages: history }))) + const second = await assemble(llm.stream(options({ messages: [...history, first.message, user('continue')] }))) -beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), 'dsh-llm-deepseek-')) - vi.stubEnv('DSH_HOME', testHome) -}) - -afterEach(async () => { - await closeMockServers() - vi.unstubAllEnvs() - vi.useRealTimers() - rmSync(testHome, { recursive: true, force: true }) -}) - -async function harness(baseURL: string, config: object = {}) { - // Configuration carries only the reference; the key comes from the - // environment, which is the whole credential plane without a mounted seam. - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(DeepSeekLlmApiExtensionRegistry) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL, ...config }) - return ctx -} - -/** Direct adapter over the plugin's real resolve step, with a static key. */ -function noExtensions(): Promise { - return Promise.resolve({ fields: {}, accept: () => Promise.resolve() }) -} - -/** Direct adapter over the plugin's real resolve step, with a static key. */ -function adapterOf( - config: Partial & { apiKey?: string } = {}, - attachments?: AttachmentStore, - files?: LlmDeepSeek.DeepSeekFileStore, -): DeepSeekAdapter { - const { apiKey, ...rest } = config - return new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', ...rest }), - resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), - resolveUserId: () => TEST_USER_ID, - resolveAttachments: () => attachments, - ...files === undefined ? {} : { resolveFiles: () => files }, - prepareExtensions: noExtensions, - }) -} - -async function drain(stream: AsyncIterable): Promise { - for await (const _chunk of stream) { /* drain */ } -} - -const imageRef: ImageAttachmentRef = { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png', - bytes: 3, - width: 1, - height: 1, -} - -function requestImage(ref = imageRef): RequestImageAttachment { - return { - variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - attachment: ref, - data: Uint8Array.of(1, 2, 3), - mediaType: 'image/png', - bytes: 3, - width: 1, - height: 1, - depth: 'uchar', - space: 'srgb', - hasAlpha: true, - } -} - -function attachmentStoreOf( - project: (ref: ImageAttachmentRef, policy: unknown, signal?: AbortSignal) => Promise, -): { - store: AttachmentStore - readImageRequest: ReturnType> -} { - const readImageRequest = vi.fn(project) - return { - store: { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore, - readImageRequest, - } -} - -function fileStoreOf( - implementation: (...args: Parameters) => ReturnType, -) { - const ensureUploaded = vi.fn(implementation) - const invalidate = vi.fn(() => Promise.resolve()) - return { - store: { ensureUploaded, invalidate } as unknown as LlmDeepSeek.DeepSeekFileStore, - ensureUploaded, - invalidate, - } -} - -function fileReference(fileId: string): Awaited> { - return { - record: { fileId: LlmDeepSeek.DeepSeekFileId(fileId) }, - uploaded: true, - } as Awaited> -} - -function successfulSseResponse(): Response { - return new Response(textEvents.map(event => `data: ${event}\n\n`).join(''), { - status: 200, - headers: { 'content-type': 'text/event-stream' }, - }) -} - -describe('request image target', () => { - it.each([ - [ - { id: 'default' }, - { width: 1302, height: 1302, maxBytes: 2 * 1024 * 1024 }, - ], - [ - { id: 'low', imagePixelBudget: 'low' as const }, - { width: 512, height: 512, maxBytes: 2 * 1024 * 1024 }, - ], - [ - { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 }, - { width: 565, height: 565, maxBytes: 512_000 }, - ], - ])('resolves route-owned defaults and overrides for %s on a 4096x4096 source', (model, expected) => { - expect(resolveRequestImageTarget(model, { width: 4096, height: 4096 })).toEqual(expected) - }) - - it('caps every request image at the provider per-side limit', () => { - expect(resolveRequestImageTarget({ id: 'default' }, { width: 8192, height: 78 })) - .toEqual({ width: 4096, height: 39, maxBytes: 2 * 1024 * 1024 }) - expect(resolveRequestImageTarget({ id: 'custom', imagePixelBudget: 640_000 }, { width: 10_000, height: 100 })) - .toEqual({ width: 4096, height: 41, maxBytes: 2 * 1024 * 1024 }) - }) - - it('answers image request pricing from the current connection snapshot', () => { - const adapter = adapterOf({ - models: [{ id: 'vision', inputModalities: ['text', 'image'] }], - }) - const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) - expect(priced).toHaveLength(1) - expect(priced?.[0]!.visualTokens).toBeGreaterThan(0) - const textOnly = adapter.imageRequestPricing('deepseek-official', 'unlisted')?.priceImages([{ type: 'image', attachment: imageRef }]) - expect(textOnly?.[0]!.visualTokens).toBe(0) - }) - - it('prices descriptor text through the serializer\'s access resolution', () => { - const attachments = {} as AttachmentStore - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', models: [{ id: 'vision', inputModalities: ['text', 'image'] }] }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - resolveAttachments: () => attachments, - resolveImageAccess: (store, ref) => (store === attachments && ref === imageRef - ? { readonlyPath: '/world/img.png' } - : undefined), - prepareExtensions: noExtensions, - }) - const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) - expect(priced?.[0]?.text).toContain('/world/img.png') - }) -}) - -describe('DeepSeekAdapter against a mock server', () => { - it('merges prepared extension fields and accepts them once after HTTP 2xx', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const accept = vi.fn() - const prepareExtensions = vi.fn(async () => ({ - fields: { dsh_test: { version: 1 } }, - accept: async () => { accept() }, - })) - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: prepareExtensions as never, - }) - - await drain(adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [], sessionId: SessionId('s') })) - expect(server.requests[0]).toMatchObject({ dsh_test: { version: 1 } }) - expect(prepareExtensions).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 's' })) - expect(accept).toHaveBeenCalledOnce() - }) - - it('fails before fetch on extension preparation or base-field collision', async () => { - const server = await mockServer([]) - const base = { - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - } - const failed = new DeepSeekAdapter({ - ...base, - prepareExtensions: () => Promise.reject(new Error('metadata unavailable')), - }) - await expect(drain(failed.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) - .rejects.toMatchObject({ code: 'REQUEST_EXTENSION' }) - - const collision = new DeepSeekAdapter({ - ...base, - prepareExtensions: (() => Promise.resolve({ fields: { model: 'replacement' }, accept: () => Promise.resolve() })) as never, - }) - await expect(drain(collision.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) - .rejects.toMatchObject({ code: 'REQUEST_EXTENSION' }) - expect(server.requests).toHaveLength(0) - }) - - it('passes cancellation into extension preparation and aborts before fetch', async () => { - const server = await mockServer([]) - const controller = new AbortController() - const started = Promise.withResolvers() - let signalSeen: AbortSignal | undefined - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: ((request: { signal: AbortSignal }) => { - signalSeen = request.signal - started.resolve(undefined) - if (request.signal === undefined) return new Promise(() => {}) - return new Promise((_resolve, reject) => { - request.signal.addEventListener('abort', () => { - const reason: unknown = request.signal.reason - reject(reason instanceof Error ? reason : new Error('extension preparation aborted', { cause: reason })) - }, { once: true }) - }) - }) as never, - }) - - const pending = drain(adapter.stream({ - provider: 'deepseek-official', - model: 'm', - messages: [], - signal: controller.signal, - })) - await started.promise - expect(signalSeen).toBeInstanceOf(AbortSignal) - controller.abort() - await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) - expect(server.requests).toHaveLength(0) - }) - - it('passes cancellation through an outstanding fetch', async () => { - const controller = new AbortController() - const started = Promise.withResolvers() - const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { - const signal = init?.signal - return new Promise((_resolve, reject) => { - started.resolve(undefined) - signal?.addEventListener('abort', () => { - const reason: unknown = signal.reason - reject(reason instanceof Error ? reason : new Error('fetch aborted', { cause: reason })) - }, { once: true }) - }) - }) - try { - const adapter = adapterOf({ baseURL: 'https://provider.invalid' }) - const pending = drain(adapter.stream({ - provider: 'deepseek-official', - model: 'm', - messages: [], - signal: controller.signal, - })) - await started.promise - controller.abort() - await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) - } finally { - fetch.mockRestore() - } - }) - - it('does not accept extensions on non-2xx and does accept before a later stream failure', async () => { - const server = await mockServer([ - { kind: 'http-error', status: 500, body: '{}' }, - { kind: 'close-early', events: ['{"choices":[{"delta":{"content":"partial"}}]}'] }, + expect(first.assembler.finish.kind).toBe('stop') + expect(second.assembler.finish.kind).toBe('stop') + expect(http.requests).toHaveLength(2) + const wireNotice = { role: 'user', content: [ + { type: 'text', text: 'Background subagent finished.' }, + { type: 'text', text: 'Its closing message: answer.' }, + ] } + expect(http.requests[0]?.body.messages).toEqual([wireNotice]) + expect(http.requests[1]?.body.messages).toEqual([ + wireNotice, + { role: 'assistant', content: [{ type: 'text', text: 'Hello 世界' }] }, + { role: 'user', content: [{ type: 'text', text: 'continue' }] }, ]) - const accept = vi.fn() - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: () => Promise.resolve({ fields: { dsh_test: 1 }, accept: async () => { accept() } }) as never, - }) - const request = { provider: 'deepseek-official', model: 'm', messages: [] } + expect(JSON.stringify(history)).toBe(saved) + }) - await expect(drain(adapter.stream(request))).rejects.toMatchObject({ code: 'SERVER' }) + it('continues without a diagnostic callback when replay metadata is unusable', async () => { + const http = await endpoint() + const message = createAssistantMessage({ content: [{ type: 'text', text: 'Remember 731.' }], source: { + provider: 'deepseek-official', model: MODEL, replayState: { response: {}, blocks: [] }, + } }) + const response = await assemble(adapter({ baseURL: http.url }).stream(options({ messages: [user(), message, user()] }))) + expect(response.assembler.finish.kind).toBe('stop') + expect(http.requests[0]?.body.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'Remember 731.' }] }, + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + ]) + }) + + it('uses the Messages endpoint, authentication, attribution and final usage', async () => { + const http = await endpoint() + const llm = adapter({ baseURL: http.url }) + const response = await assemble(llm.stream(options({ model: 'deepseek-flash', sessionId: SessionId('session-test'), purpose: 'compaction' })), 'deepseek-flash') + expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) + expect(response.message.source).toMatchObject({ + model: 'deepseek-flash', replayState: { response: { model: 'deepseek-flash' } }, + }) + expect(http.requests[0]).toMatchObject({ path: '/anthropic/v1/messages', headers: { + 'x-api-key': 'test-key', 'anthropic-version': '2023-06-01', + 'user-agent': expect.stringContaining('deepseek-harness/') as string, 'x-deepseek-harness-user-id': 'test-user', + 'x-deepseek-harness-session-id': 'session-test', 'x-deepseek-harness-compact': '1', + }, body: { thinking: { type: 'enabled' }, output_config: { effort: 'high' } } }) + expect(llm.providerInfo('deepseek-official')).toEqual({ id: 'deepseek-official', name: 'DeepSeek' }) + expect((await llm.listModels('deepseek-official')).map(model => model.id)).toEqual([ + 'deepseek-flash', 'deepseek-v4-pro', + ]) + expect(await llm.resolveModel('deepseek-official', 'deepseek-flash')).toMatchObject({ + name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', + }) + expect(await llm.resolveModel('deepseek-official', MODEL)).toMatchObject({ id: MODEL }) + expect(llm.imageRequestPricing('deepseek-official', MODEL)).toBeDefined() + }) + + it.each([ + ['https://provider.example', 'https://provider.example/v1/messages'], + ['https://provider.example/v1/', 'https://provider.example/v1/messages'], + ['https://provider.example/v1beta', 'https://provider.example/v1beta/v1/messages'], + ['https://provider.example/v2', 'https://provider.example/v2/v1/messages'], + ['https://provider.example/anthropic', 'https://provider.example/anthropic/v1/messages'], + ['https://v1.provider.example', 'https://v1.provider.example/v1/messages'], + ])('resolves the Messages endpoint from %s', async (baseURL, expected) => { + const fetchImpl = vi.fn(async () => new Response(sse(textEvents), { + headers: { 'content-type': 'text/event-stream' }, + })) + vi.stubGlobal('fetch', fetchImpl) + + await chunks(adapter({ baseURL }).stream(options())) + + expect(fetchImpl.mock.calls[0]?.[0]).toBe(expected) + }) + + it.each([true, false])('maps non-2xx responses (JSON=%s)', async (json) => { + const http = await endpoint((response) => { response.statusCode = 429; response.setHeader('retry-after', '3'); response.end(json ? JSON.stringify({ error: { type: 'rate_limit_error', message: 'slow down' } }) : 'busy') }) + await expect(chunks(adapter({ baseURL: http.url }).stream(options()))).rejects.toMatchObject({ code: 'RATE_LIMIT', failure: { status: 429, providerRetryAfterMs: 3000 } }) + }) + + it('refuses redirects before credentials reach another origin or request extensions are accepted', async () => { + const destination = await endpoint() + const source = await endpoint((response) => { + response.writeHead(307, { location: `${destination.url}/v1/messages` }) + response.end() + }) + const accept = vi.fn(async () => {}) + const prepare = vi.fn(async () => ({ fields: {}, accept })) + const files = new DeepSeekFileStore() + const llm = new DeepSeekAdapter({ + options: () => Messages.resolveAdapterOptions({ baseURL: source.url }), + resolveApiKey: () => Promise.resolve('test-key'), resolveUserId: () => 'test-user' as AnonymousUserId, + resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, + prepareExtensions: prepare, + }) + const error = await chunks(llm.stream(options())).catch((cause: unknown) => cause) + expect(source.requests).toHaveLength(1) + expect(source.requests[0]?.headers['x-api-key']).toBe('test-key') + expect(destination.requests).toEqual([]) + expect(error).toMatchObject({ code: 'TRANSPORT' }) + expect(prepare).toHaveBeenCalledOnce() expect(accept).not.toHaveBeenCalled() - await expect(drain(adapter.stream(request))).rejects.toBeDefined() - expect(accept).toHaveBeenCalledOnce() }) - it('reports a post-2xx extension acceptance failure without relabelling it as transport', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const failure = new Error('watermark append failed') - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: () => Promise.resolve({ - fields: { dsh_test: 1 }, - accept: () => Promise.reject(failure), - }) as never, + it('freezes endpoint and defaults for a prepared call while the next call sees new settings', async () => { + const first = await endpoint(), second = await endpoint() + let config = Messages.resolveAdapterOptions({ baseURL: first.url, maxTokens: 10, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) + const files = new DeepSeekFileStore() + const llm = new DeepSeekAdapter({ options: () => config, resolveApiKey: snapshot => Promise.resolve(snapshot.maxTokens === 10 ? 'first' : 'second'), resolveUserId: () => 'user' as AnonymousUserId, resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, prepareExtensions }) + const prepared = await llm.prepareCall('deepseek-official', MODEL) + config = Messages.resolveAdapterOptions({ baseURL: second.url, maxTokens: 20 }) + expect(prepared.model.systemPromptUpdate).toBe('in-history') + expect((await llm.resolveModel('deepseek-official', MODEL)).systemPromptUpdate).toBeUndefined() + await chunks(prepared.stream(options())) + await chunks(llm.stream(options())) + expect(first.requests[0]).toMatchObject({ headers: { 'x-api-key': 'first' }, body: { max_tokens: 10 } }) + expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'second' }, body: { max_tokens: 20 } }) + }) + + it('aborts an open provider response when its consumer stops', async () => { + let closed!: () => void + const stopped = new Promise((resolve) => { closed = resolve }) + const http = await endpoint((response) => { + response.once('close', closed) + response.write(sse(textEvents.slice(0, 3))) }) - - await expect(drain(adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) - .rejects.toMatchObject({ code: 'REQUEST_EXTENSION', cause: failure }) - expect(server.requests).toHaveLength(1) + const stream = adapter({ baseURL: http.url }).stream(options())[Symbol.asyncIterator]() + expect((await stream.next()).value).toMatchObject({ type: 'block-start' }) + await stream.return!() + await stopped }) - it('streams a text generation end to end through the assembler', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url) - - const result = await assemble(ctx, { - model: 'deepseek-v4-pro', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) - expect(result.finish).toEqual({ kind: 'stop' }) - expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1, totalTokens: 4 }) - - // The wire request carried the auth header contents we configured. - expect(server.requests[0]).toMatchObject({ - model: 'deepseek-v4-pro', - max_tokens: 256_000, - reasoning_effort: 'high', - stream: true, - stream_options: { include_usage: true }, - }) - // App attribution and DeepSeek request identity are independent wire facts. - expect(server.headers[0]?.['user-agent']).toBe(userAgent()) - expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) - expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-session-id') - expect(server.headers[0]).not.toHaveProperty('http-referer') - expect(server.headers[0]).not.toHaveProperty('x-openrouter-title') - expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories') - expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact') + it('distinguishes caller cancellation from idle timeout and transport failure', async () => { + const http = await endpoint((response) =>{ response.flushHeaders() }) + await expect(chunks(adapter({ baseURL: http.url, streamIdleTimeoutMs: 30 }).stream(options()))).rejects.toMatchObject({ code: 'TIMEOUT' }) + const controller = new AbortController(); controller.abort() + await expect(chunks(adapter({ baseURL: http.url }).stream(options({ signal: controller.signal })))).rejects.toMatchObject({ code: 'ABORTED' }) + vi.stubGlobal('fetch', async () => { throw new TypeError('network down') }) + await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'TRANSPORT' }) }) - it('uploads a durable image once and sends only its Files API id to the vision model', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const signalSeen: (AbortSignal | undefined)[] = [] - const policies: unknown[] = [] - const attachmentMocks = attachmentStoreOf((ref, policy, signal) => { - signalSeen.push(signal) - policies.push(policy) - return Promise.resolve(requestImage(ref)) - }) - const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-flash', - messages: [createUserMessage({ - content: [ - { type: 'text', text: 'describe ' }, - { type: 'image', attachment: imageRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(server.requests[0]).toMatchObject({ - model: 'deepseek-flash', - messages: [{ - role: 'user', - content: [ - { type: 'text', text: 'describe ' }, - { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request preview 1x1px.`) as string }, - { type: 'file', file_id: 'file-api-1' }, - ], - }], - }) - expect(server.fileRequests).toEqual([{ - method: 'POST', - path: '/files', - filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, - bytes: 3, - }]) - expect(signalSeen[0]).toBeInstanceOf(AbortSignal) - expect(policies).toEqual([{ width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }]) - }) - - it('keeps images inside plugin content opaque through adapter.stream', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) - const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) - await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-pro', messages: [ - createUserMessage({ source: { kind: 'user' }, content: [ - { type: 'plugin:message', - message: { role: 'tool', toolCallId: 'foreign', content: [{ type: 'image', attachment: { ...imageRef } }] }, - } as never, - { type: 'text', text: 'context' }, - ] }), - ] })) - expect(attachmentMocks.readImageRequest).not.toHaveBeenCalled() - expect(server.fileRequests).toHaveLength(0) - expect(server.requests).toHaveLength(1) - expect(server.requests[0]).toMatchObject({ messages: [{ role: 'user', content: 'context' }] }) - }) - - it('falls back to one all-base64 request when Files API resolution fails', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } - const attachments = attachmentStoreOf(ref => Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), - })).store - const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - const body = server.requests[0] as { messages: Array<{ content: unknown }> } - expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image_url"/g)).toHaveLength(2) - expect(JSON.stringify(body)).not.toContain('file_id') - expect(files.ensureUploaded).toHaveBeenCalledTimes(1) - }) - - it('fails a base64 fallback whose retained images exceed the inline bound with the count to offload', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) - const adapter = adapterOf({ - baseURL: server.url, - maxInlineRequestImageBytes: 80, - inlineImageOffloadByteQuantum: 40, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: imageRef })), - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', message: expect.stringContaining('11 more') as string }) - expect(server.requests).toHaveLength(0) - }) - it('discards partially resolved file ids and falls back with every retained image inline', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } - const attachments = attachmentStoreOf(ref => Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), - })).store - const files = fileStoreOf(() => Promise.reject(new Error('unused'))) - files.ensureUploaded - .mockResolvedValueOnce(fileReference('file-api-partial')) - .mockRejectedValueOnce(new LlmError('Files unavailable', 'TRANSPORT')) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - const body = server.requests[0] as { messages: Array<{ content: unknown }> } - expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image_url"/g)).toHaveLength(2) - expect(JSON.stringify(body)).not.toContain('file-api-partial') - }) - - it('falls back after the configured Files API deadline without aborting chat', async () => { - vi.useFakeTimers() - const started = Promise.withResolvers() - const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { - started.resolve(undefined) - signal?.addEventListener('abort', () => { - const reason: unknown = signal.reason - reject(reason instanceof Error ? reason : new Error('files operation aborted')) - }, { once: true }) - })) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulSseResponse()) - const adapter = adapterOf({ - baseURL: 'https://deepseek.invalid', - filesApiTimeoutMs: 50, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - const pending = drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - await started.promise - await vi.advanceTimersByTimeAsync(50) - await pending - - expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(fetchSpy.mock.calls[0]?.[1]?.body).toEqual(expect.stringContaining('image_url')) - fetchSpy.mockRestore() - }) - - it('does not turn caller cancellation during file resolution into base64 fallback', async () => { - const started = Promise.withResolvers() - const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { - started.resolve(undefined) - signal?.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true }) - })) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const fetchSpy = vi.spyOn(globalThis, 'fetch') - const controller = new AbortController() - const adapter = adapterOf({ - baseURL: 'https://deepseek.invalid', - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - const pending = drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - signal: controller.signal, - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - await started.promise - controller.abort() - - await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) - expect(fetchSpy).not.toHaveBeenCalled() - fetchSpy.mockRestore() - }) - - it('does not retry a generic chat failure through base64 fallback', async () => { - const server = await mockServer([{ - kind: 'http-error', - status: 503, - body: JSON.stringify({ error: { message: 'chat unavailable' } }), - }]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const files = fileStoreOf(() => Promise.resolve(fileReference('file-api-ready'))) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'SERVER', message: 'chat unavailable' }) - - expect(server.requests).toHaveLength(1) - expect(JSON.stringify(server.requests[0])).toContain('file-api-ready') - expect(JSON.stringify(server.requests[0])).not.toContain('image_url') - }) - - it('does not prepare a surface-offloaded image and sends its placeholder instead', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const old = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), bytes: 3 } - const recent = { ...imageRef, attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), bytes: 3 } - const attachmentMocks = attachmentStoreOf((ref) => { - if (ref.attachmentId === old.attachmentId) throw new Error('old image must not be read') - return Promise.resolve(requestImage(ref)) - }) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachmentMocks.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: old, offloaded: true }, - { type: 'image', attachment: recent }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith( - recent, - { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, - expect.any(AbortSignal), - ) - const body = server.requests[0] as { messages: unknown[] } - expect(body.messages[0]).toMatchObject({ - role: 'user', - content: [ - { type: 'text', text: expect.stringContaining(`image omitted to fit request image limits; ${old.attachmentId}`) as string }, - { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, - { type: 'file', file_id: 'file-api-1' }, - ], - }) - }) - - it('projects nested tool-result images with route-owned request budgets', async () => { - const server = await mockServer([ - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - ]) - const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) - const adapter = adapterOf({ - baseURL: server.url, - models: [ - { - id: 'vision-low', - inputModalities: ['text', 'image'], - imagePixelBudget: 'low', - imageMaxBytes: 512_000, - }, - { - id: 'vision-custom', - inputModalities: ['text', 'image'], - imagePixelBudget: 320_000, - }, - ], - }, attachmentMocks.store) - const nested = createToolResultMessage({ - callId: ToolCallId('image-result'), - content: [{ type: 'image', attachment: imageRef }], - isError: false, - }) - - await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-low', messages: [nested] })) - await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-custom', messages: [nested] })) - - expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( - 1, - imageRef, - { width: 1, height: 1, maxBytes: 512_000 }, - expect.any(AbortSignal), - ) - expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( - 2, - imageRef, - { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, - expect.any(AbortSignal), - ) - }) - - it('reuses the exact request version between agent and compaction calls', async () => { - const server = await mockServer([ - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - const messages = [createUserMessage({ - content: [{ type: 'image' as const, attachment: imageRef }], - source: { kind: 'test' as const }, - })] - - await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-flash-vision-exp', messages })) - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages, - purpose: 'compaction', - })) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(1) - expect(server.requests).toMatchObject([ - { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-1' }] }] }, - { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-1' }] }] }, - ]) - expect(server.headers[1]?.['x-deepseek-harness-compact']).toBe('1') - }) - - it('explains a provider rejection of a normalized image and retains the raw response as cause', async () => { - const raw = JSON.stringify({ error: { message: 'unsupported image payload for file-api-1' } }) - const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - let failure: unknown - try { - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - } catch (error: unknown) { - failure = error - } - expect(failure).toMatchObject({ - code: 'INVALID_REQUEST', - message: expect.stringContaining( - `normalized image "${imageRef.attachmentId}" at message 1, image 1`, - ) as string, - cause: { message: raw }, - }) - expect((failure as Error).message).toContain('image/png, 8-bit sRGBA, 1x1') - expect((failure as Error).message).toContain('unsupported image payload for file-api-1') - expect((failure as Error).message).not.toBe(raw) - }) - - it('identifies the sole image when a normalized rejection omits its file id', async () => { - const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) - const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ - message: expect.stringContaining(`normalized image "${imageRef.attachmentId}"`) as string, - }) - }) - - it('lists every candidate when a normalized multi-image rejection names no file id', async () => { - const secondRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), - } - const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) - const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) - const attachments = attachmentStoreOf((ref) => { - const first = ref.attachmentId === imageRef.attachmentId - return Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(first ? 'b' : 'd').repeat(64)}`), - attachment: first ? { ...ref, name: 'diagram.png' } : ref, - hasAlpha: false, - }) - }).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ - code: 'INVALID_REQUEST', - message: expect.stringContaining('Candidate images: "diagram.png"') as string, - cause: { message: raw }, - }) - }) - - it.each([ - 'file-api-1 expired', - 'file_id file-api-10 invalid; file_id file-api-1 expired', - 'file_id file-api-1 expired', - 'file_not_found', - 'file_id file-api-1 deleted', - 'invalid file_id file-api-1', - ])('reuploads once when chat rejects a Files API reference as %s', async (providerMessage) => { - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ error: { message: providerMessage } }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachmentMocks.store) - const options = { - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image' as const, attachment: imageRef }], - source: { kind: 'test' as const }, - })], - } - - await drain(adapter.stream(options)) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) - expect(server.requests).toMatchObject([ - { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-1' }] }] }, - { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-2' }] }] }, - ]) - expect(attachmentMocks.readImageRequest).toHaveBeenCalledTimes(1) - }) - - it('uses inline fallback when stale-id recovery cannot resolve a replacement file', async () => { - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ error: { message: 'file_id file-api-stale expired' } }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const files = fileStoreOf(() => Promise.reject(new Error('unused'))) - files.ensureUploaded - .mockResolvedValueOnce(fileReference('file-api-stale')) - .mockRejectedValueOnce(new LlmError('Files unavailable', 'SERVER')) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(files.invalidate).toHaveBeenCalledTimes(1) - expect(server.requests).toHaveLength(2) - expect(JSON.stringify(server.requests[0])).toContain('file-api-stale') - expect(JSON.stringify(server.requests[1])).toContain('image_url') - }) - - it('invalidates only the identified mapping when a multi-image request names one stale file id', async () => { - const secondRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), - } - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ error: { message: 'file_id file-api-2 expired' } }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), - })).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(3) - const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> - expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([{ type: 'file', file_id: 'file-api-1' }, { type: 'file', file_id: 'file-api-2' }]) - expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([{ type: 'file', file_id: 'file-api-1' }, { type: 'file', file_id: 'file-api-3' }]) - }) - - it('invalidates every listed missing file id and preserves unlisted mappings', async () => { - const secondRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), - } - const thirdRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), - } - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ - error: { - message: 'path.to.object[index]: the following file_ids do not exist or are not created under your account: ' - + 'file-api-1, file-api-3, file-api-unknown', - }, - }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf((ref) => { - let digest = 'f' - if (ref.attachmentId === imageRef.attachmentId) digest = 'b' - else if (ref.attachmentId === secondRef.attachmentId) digest = 'd' - return Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${digest.repeat(64)}`), - }) - }).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - { type: 'image', attachment: thirdRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(5) - const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> - expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([ - { type: 'file', file_id: 'file-api-1' }, - { type: 'file', file_id: 'file-api-2' }, - { type: 'file', file_id: 'file-api-3' }, - ]) - expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([ - { type: 'file', file_id: 'file-api-4' }, - { type: 'file', file_id: 'file-api-2' }, - { type: 'file', file_id: 'file-api-5' }, - ]) - }) - - it('invalidates every used mapping when a stale-file response does not identify one file id', async () => { - const secondRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), - } - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ error: { message: 'file reference expired' } }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), - })).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(4) - const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> - expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([{ type: 'file', file_id: 'file-api-3' }, { type: 'file', file_id: 'file-api-4' }]) - }) - - it('returns the second stale-file rejection without a third chat attempt', async () => { - const stale = JSON.stringify({ error: { message: 'file_id file-api-1 expired' } }) - const server = await mockServer([ - { kind: 'http-error', status: 400, body: stale }, - { kind: 'http-error', status: 400, body: stale }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'INVALID_REQUEST', message: 'file_id file-api-1 expired' }) - expect(server.requests).toHaveLength(2) - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) - }) - - it.each(['deepseek-v4-flash', 'unlisted-pass-through'])( - 'rejects image input for text-only model %s before credentials, attachments, or fetch', - async (model) => { - const server = await mockServer([]) - const resolveApiKey = vi.fn(() => Promise.resolve('k')) - const resolveAttachments = vi.fn(() => ({}) as AttachmentStore) - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey, - resolveUserId: () => TEST_USER_ID, - resolveAttachments, - prepareExtensions: noExtensions, - }) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model, - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveApiKey).not.toHaveBeenCalled() - expect(resolveAttachments).not.toHaveBeenCalled() - expect(server.requests).toHaveLength(0) - }, - ) - - it('rejects vision input without an attachment provider before credentials or fetch', async () => { - const server = await mockServer([]) - const resolveApiKey = vi.fn(() => Promise.resolve('k')) - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ - protocol: 'chat-completions', - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }), - resolveApiKey, - resolveUserId: () => TEST_USER_ID, - prepareExtensions: noExtensions, - }) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveApiKey).not.toHaveBeenCalled() - expect(server.requests).toHaveLength(0) - }) - - it('streams raw chunks through ctx.llm.stream', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }]) - const ctx = await harness(server.url) - - const kinds: string[] = [] - for await (const chunk of ctx.llm.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) { - kinds.push(chunk.type) - } - expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) - }) - - it('forwards the harness user and session ids for host-side trajectory routing', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url) - - await assemble(ctx, { - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - sessionId: SessionId('child-session'), - }) - - expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session') - expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) - }) - - it('marks the auxiliary compaction call on the wire', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url) - - await assemble(ctx, { - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - purpose: 'compaction', - }) - - expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1') - }) - - it('switches dynamically from the configured low default through off to max', async () => { - const server = await mockServer([ - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - ]) - const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'low' }) - - await assemble(ctx,{ - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - await assemble(ctx,{ - model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId('off'), - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi again' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - await assemble(ctx,{ - model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId('max'), - messages: [createUserMessage({ - content: [{ type: 'text', text: 'one more time' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - expect(server.requests[0]).toMatchObject({ - thinking: { type: 'enabled' }, - reasoning_effort: 'low', - }) - expect(server.requests[1]).toMatchObject({ - thinking: { type: 'disabled' }, - }) - expect(server.requests[1]).not.toHaveProperty('reasoning_effort') - expect(server.requests[2]).toMatchObject({ - thinking: { type: 'enabled' }, - reasoning_effort: 'max', - }) - }) - - it('uses the configured maxTokens default and preserves an explicit request cap', async () => { - const server = await mockServer([ - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - ]) - const ctx = await harness(server.url, { maxTokens: 32_000 }) - - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], maxTokens: 8_192 }) - - expect(server.requests[0]).toMatchObject({ max_tokens: 32_000 }) - expect(server.requests[1]).toMatchObject({ max_tokens: 8_192 }) - }) - - it('publishes only off and omits the wire effort when thinking is disabled', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url, { thinking: 'disabled' }) - - await assemble(ctx,{ - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - expect(server.requests[0]).toMatchObject({ - thinking: { type: 'disabled' }, - }) - expect(server.requests[0]).not.toHaveProperty('reasoning_effort') - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash')) - .resolves.toMatchObject({ - reasoning: { - efforts: [{ - id: ReasoningEffortId('off'), - name: 'Off', - description: 'Use for simple tasks that do not need reasoning.', - }], - defaultEffort: ReasoningEffortId('off'), - }, - }) - }) - - it('reports a per-request effort failure before I/O when thinking is disabled', async () => { - const server = await mockServer([]) - const ctx = await harness(server.url, { thinking: 'disabled' }) - - const result = await assemble(ctx, { - model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId('high'), - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - expect(result.finish).toMatchObject({ - kind: 'error', - failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, - }) - expect(server.requests).toHaveLength(0) - }) - - it.each(['high', 'max'])( - 'rejects direct adapter effort %s before I/O when thinking is disabled', - async (effort) => { - const server = await mockServer([]) - const adapter = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' }) - - const stream = adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId(effort), - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - await expect(async () => { - for await (const _chunk of stream) { /* drain */ } - }).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) - expect(server.requests).toHaveLength(0) - }, - ) - - it.each([ - [401, 'AUTH'], - [403, 'AUTH'], - [429, 'RATE_LIMIT'], - [400, 'INVALID_REQUEST'], - [500, 'SERVER'], - [503, 'SERVER'], - ])('maps HTTP %d to failure code %s with the body message', async (status, code) => { - const behavior: Behavior = { - kind: 'http-error', - status, - body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), - } - const server = await mockServer([behavior]) - const ctx = await harness(server.url) - const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toEqual({ - kind: 'error', - failure: { message: `failed with ${status}`, code, status }, - }) - }) - - it('uses the HTTP status as the cause when an error response has no body', async () => { - const server = await mockServer([{ kind: 'http-error', status: 500, body: '' }]) - const adapter = adapterOf({ baseURL: server.url }) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - messages: [], - }))).rejects.toMatchObject({ - code: 'SERVER', - cause: { message: 'DeepSeek HTTP 500' }, - }) - }) - - it('classifies an HTTP context-window failure with the canonical code', async () => { - const server = await mockServer([{ - kind: 'http-error', - status: 400, - body: JSON.stringify({ - error: { - message: 'This model maximum context length is 128000 tokens; your input exceeds that limit.', - type: 'invalid_request_error', - code: 'context_length_exceeded', - }, - }), - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toMatchObject({ - kind: 'error', - failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE }, - }) - }) - - it('retains status, Retry-After seconds, and provider request id as structured facts', async () => { - const server = await mockServer([{ - kind: 'http-error', - status: 429, - body: JSON.stringify({ error: { message: 'slow down' } }), - headers: { 'retry-after': '2', 'x-request-id': 'req-429' }, - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toEqual({ - kind: 'error', - failure: { - message: 'slow down', - code: 'RATE_LIMIT', - status: 429, - providerRetryAfterMs: 2_000, - requestId: ProviderRequestId('req-429'), - }, - }) - }) - - it('parses a future Retry-After HTTP date and the DeepSeek request-id fallback', async () => { - const now = 1_800_000_000_000 - const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) - try { - const server = await mockServer([{ - kind: 'http-error', - status: 503, - body: JSON.stringify({ error: { message: 'come back later' } }), - headers: { - 'retry-after': new Date(now + 3_000).toUTCString(), - 'x-deepseek-request-id': 'deepseek-503', - }, - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toEqual({ - kind: 'error', - failure: { - message: 'come back later', - code: 'SERVER', - status: 503, - providerRetryAfterMs: 3_000, - requestId: ProviderRequestId('deepseek-503'), - }, - }) - } finally { - dateNow.mockRestore() - } - }) - - it('omits zero, non-finite, invalid, and past Retry-After values', async () => { - const values = [ - '0', - '9'.repeat(400), - 'not-a-date', - new Date(0).toUTCString(), - ] - for (const value of values) { - const server = await mockServer([{ - kind: 'http-error', - status: 429, - body: JSON.stringify({ error: { message: 'retry later' } }), - headers: { 'retry-after': value }, - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toEqual({ - kind: 'error', - failure: { message: 'retry later', code: 'RATE_LIMIT', status: 429 }, - }) - } - }) - - it('classifies only context-capacity HTTP 400 details as context overflow', () => { - expect(httpErrorCode(400, { message: 'request too large for model context' })) - .toBe(CONTEXT_WINDOW_EXCEEDED_CODE) - expect(httpErrorCode(400, { message: 'invalid input: temperature exceeds maximum allowed value' })) - .toBe('INVALID_REQUEST') - expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('INVALID_REQUEST') - }) - - it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => { - expect(httpErrorCode(429, { code: 'insufficient_quota', message: 'account credits exhausted' })) - .toBe(QUOTA_EXCEEDED_CODE) - expect(httpErrorCode(429, { message: 'request rate limit exceeded' })).toBe('RATE_LIMIT') - }) - - it('keeps the status-line message for JSON error bodies without a message', async () => { - const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }]) - const ctx = await harness(server.url) - const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish.kind).toBe('error') - if (result.finish.kind !== 'error') throw new Error('expected an error finish') - expect(result.finish.failure.code).toBe('SERVER') - expect(result.finish.failure.message).toMatch(/HTTP 500/) - }) - - it('keeps the status-line message for non-JSON error bodies', async () => { - const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }]) - const ctx = await harness(server.url) - const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish.kind).toBe('error') - if (result.finish.kind !== 'error') throw new Error('expected an error finish') - expect(result.finish.failure.code).toBe('SERVER') - expect(result.finish.failure.message).toMatch(/HTTP 502/) - }) - - it('maps unusual statuses to HTTP_', () => { - expect(httpErrorCode(418)).toBe('HTTP_418') - }) - - it('reports a transport failure with the endpoint in the message', async () => { - // Port 1 is reserved/unbound, so the service normalizes the fetch failure. - const ctx = await harness('http://127.0.0.1:1') - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toMatchObject({ - kind: 'error', - failure: { - code: 'TRANSPORT', - message: 'DeepSeek API request to http://127.0.0.1:1 failed', - }, - }) - }) - - it('classifies an aborted request as an aborted finish', async () => { - const controller = new AbortController() - controller.abort() - const ctx = await harness('http://127.0.0.1:1') - const result = await assemble(ctx, { - model: 'deepseek-v4-flash', - messages: [], - signal: controller.signal, - }) - expect(result.finish).toMatchObject({ kind: 'aborted', failure: { code: 'ABORTED' } }) - }) - - it('throws EMPTY_RESPONSE when the response has no body', async () => { - const adapter = adapterOf({ baseURL: 'http://127.0.0.1:1' }) - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(null, { status: 200 }), - ) - try { - const iterate = async (): Promise => { - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - } - await expect(iterate()).rejects.toThrow(/no response body/) - } finally { - fetchSpy.mockRestore() - } - }) - - it('classifies an abrupt body close as TRANSPORT', async () => { - const server = await mockServer([{ - kind: 'close-early', - events: ['{"choices":[{"delta":{"content":"par"}}]}'], - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish.kind).toBe('error') - if (result.finish.kind !== 'error') throw new Error('expected an error finish') - expect(result.finish.failure.code).toBe('TRANSPORT') - expect(result.finish.failure.message).toMatch(/^DeepSeek API stream from .* failed$/) - }) - - it('aborts mid-stream via the request signal', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 50 }]) - const ctx = await harness(server.url) - const controller = new AbortController() - - const pending = (async () => { - const chunks = [] - for await (const chunk of ctx.llm.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - messages: [], - signal: controller.signal, - })) { - chunks.push(chunk) - } - return chunks - })() - - setTimeout(() => { controller.abort() }, 30) - const chunks = await pending - expect(chunks).toHaveLength(1) - expect(chunks[0]?.type).toBe('finish') - if (chunks[0]?.type !== 'finish') throw new Error('expected a finish chunk') - expect(chunks[0].reason.kind).toBe('aborted') - if (chunks[0].reason.kind !== 'aborted') throw new Error('expected an aborted finish') - expect(chunks[0].reason.failure.code).toBe('ABORTED') - }) - - it('maps connection failures to TRANSPORT without losing the cause', async () => { - const cause = new TypeError('connection refused') - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause) - const adapter = adapterOf({ baseURL: 'https://example.invalid' }) - try { - const drain = async (): Promise => { - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - } - await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause }) - } finally { - fetchSpy.mockRestore() - } - }) - - it('renders a non-Error transport rejection without losing its cause', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { - const failed = Promise.withResolvers() - failed.reject('offline') - return failed.promise - }) - const adapter = adapterOf({ baseURL: 'https://example.invalid' }) - try { - const drain = async (): Promise => { - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - } - await expect(drain()).rejects.toMatchObject({ - message: 'DeepSeek API request to https://example.invalid failed', - code: 'TRANSPORT', - cause: 'offline', - }) - } finally { - fetchSpy.mockRestore() - } - }) - - it('aborts the underlying body when the stream stays idle past its watchdog', async () => { - vi.useFakeTimers() - let stopped = false - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { - const signal = init?.signal - const body = new ReadableStream({ - start(controller) { - signal?.addEventListener('abort', () => { - stopped = true - controller.error(signal.reason) - }, { once: true }) - }, - }) - return Promise.resolve(new Response(body, { status: 200 })) - }) - const adapter = adapterOf({ - baseURL: 'https://example.invalid', - streamIdleTimeoutMs: 100, - }) - try { - const drain = (async () => { - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - })() - const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' }) - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(100) - await rejected - expect(stopped).toBe(true) - } finally { - fetchSpy.mockRestore() - } - }) - - it('keeps an idle provider read alive through SSE comments', async () => { - vi.useFakeTimers() - const encoder = new TextEncoder() - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { - const body = new ReadableStream({ - start(controller) { - setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 75) - setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 150) - setTimeout(() => { - controller.enqueue(encoder.encode(textEvents.map(event => `data: ${event}\n\n`).join(''))) - controller.close() - }, 225) - }, - }) - return Promise.resolve(new Response(body, { status: 200 })) - }) - const adapter = adapterOf({ - baseURL: 'https://example.invalid', - streamIdleTimeoutMs: 100, - }) - try { - const chunks: string[] = [] - const drain = (async () => { - for await (const chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { - chunks.push(chunk.type) - } - })() - await vi.advanceTimersByTimeAsync(75) - await vi.advanceTimersByTimeAsync(75) - await vi.advanceTimersByTimeAsync(75) - await expect(drain).resolves.toBeUndefined() - expect(chunks).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) - } finally { - fetchSpy.mockRestore() - } + it('rejects a successful response with no readable body', async () => { + vi.stubGlobal('fetch', async () => new Response(null, { status: 200 })) + await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'EMPTY_RESPONSE' }) }) }) -describe('plugin registration and config', () => { - it('defaults to Messages and resolves the selected protocol endpoint without rewriting overrides', () => { - expect(resolveAdapterOptions({})).toMatchObject({ protocol: 'messages', baseURL: 'https://api.deepseek.com/anthropic' }) - expect(resolveAdapterOptions({ protocol: 'chat-completions' })).toMatchObject({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com' }) - expect(resolveAdapterOptions({ protocol: 'messages' })).toMatchObject({ protocol: 'messages', baseURL: 'https://api.deepseek.com/anthropic' }) - for (const baseURL of ['https://gateway.example/custom/v1', 'https://gateway.example/v1/messages']) { - expect(resolveAdapterOptions({ protocol: 'messages', baseURL }).baseURL).toBe(baseURL) - } - expect(() => resolveAdapterOptions({ protocol: 'responses' } as unknown as LlmDeepSeek.Config)).toThrow(/protocol/) - }) - - it('keeps wire helpers off the package root', () => { - for (const helper of [ - 'httpErrorCode', - 'serializeMessages', - 'serializeRequest', - 'DONE', - 'parseSse', - 'mapFinishReason', - 'mapUsage', - 'translate', - ]) expect(LlmDeepSeek).not.toHaveProperty(helper) - }) - - it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => { - const server = await mockServer([]) - const ctx = new Context() +describe('Cordis provider composition', () => { + it('resolves an attachment service loaded after the adapter and maps its read-only path', async () => { + const http = await endpoint() + const { ctx, home } = await context() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') await ctx.plugin(LlmRuntime) - const fiber = await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: server.url, + await ctx.plugin(Messages, { baseURL: http.url }) + const model = 'deepseek-flash' + const price = () => ctx.llm.imageRequestPricing('deepseek-official', model)! + const dummy = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), width: 1, height: 1, bytes: 3, mediaType: 'image/png' as const } + expect(price().priceImages([{ type: 'image', attachment: dummy }])[0]?.text).toBeDefined() + await ctx.plugin(LocalAttachments, { dshHome: home }) + const attachment = await ctx.attachments.saveImage({ data: await readFile(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) + expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).not.toContain('/mounted/image.png') + class MappedFiles extends Service { + constructor(context: Context) { super(context, 'fs') } + processPathFromHostPath(_path: string) { return '/mounted/image.png' } + } + await ctx.plugin(MappedFiles) + const message = user() + await chunks(ctx.llm.stream(options({ model, messages: [{ ...message, content: [...message.content, { type: 'image', attachment }] }] }))) + expect(JSON.stringify(http.requests[0]?.body)).toContain('/mounted/image.png') + expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).toContain('/mounted/image.png') + }) + + async function boot(...args: Parameters) { + const http = await endpoint(...args) + const { ctx, home } = await context() + vi.stubEnv('DEEPSEEK_API_KEY', '') + await writeFile(join(home, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: stored-key\n', { mode: 0o600 }) + await writeFile(join(home, 'settings.yaml'), '{}\n') + const template = await readFile(new URL('fixtures/cordis.yml', import.meta.url), 'utf8') + await writeFile(join(home, 'cordis.yml'), template.replaceAll('{{endpoint}}', JSON.stringify(http.url)).replaceAll('{{settings}}', JSON.stringify(join(home, 'settings.yaml'))).replaceAll('{{credentials}}', JSON.stringify(join(home, '.credentials.yaml')))) + ctx.baseUrl = pathToFileURL(home).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-llm-deepseek', Messages], + ['@deepseek-ai/dsh-credentials-local', LocalCredentials], ['@deepseek-ai/dsh-settings-file', FileSettings], + ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], + ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ]) + // The importer supplies source modules while Loader still owns configuration and effects. + for (const name of modules.keys()) { + const directory = join(home, 'node_modules', name) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'package.json'), JSON.stringify({ name, version: '0.1.3-alpha.1', type: 'module' })) + } + ctx.loader.internal = sourceModuleLoader(async (name) => { + if (!modules.has(name)) throw new Error(`unexpected module ${name}`) + return modules.get(name) }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - expect(ctx.llm.listConfigurableProviders()).toEqual([{ - provider: 'deepseek-official', - displayName: 'DeepSeek', - settingsNs: 'llm-deepseek', - settingsPath: [], - }]) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(home, 'cordis.yml')).href } }) + await ctx.loader.await() + return { ctx, http } + } + + it.each([ + { model: MODEL, inHistory: false }, + { model: MODEL, inHistory: true }, + { model: 'deepseek-flash', inHistory: true }, + ])('updates, clears and restores prompts across continued and resumed sessions, model=$model in-history=$inHistory', async ({ model, inHistory }) => { + const { ctx, http } = await boot() + if (inHistory && model === MODEL) await ctx.settings.update(Messages.name, { models: [{ id: model, systemPromptUpdate: 'in-history' }] }) + let prompt = 'first prompt' + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ + ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], + })) + const agentOptions = { provider: 'deepseek-official', model } + const agent = await ctx.agentLoop.create(SessionId('prompt-update'), agentOptions) + await send(agent, 'first') + prompt = 'second prompt' + await send(agent, 'second') + const count = agent.session.snapshotEvents().filter(event => event.type === 'system/message').length + await send(agent, 'unchanged') + expect(agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(count) + prompt = '' + await send(agent, 'clear') + const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('prompt-resume'), agentOptions, + seed: [...agent.session.snapshotEvents()] }) + await send(resumed, 'resume cleared') + prompt = 'restored prompt' + await send(resumed, 'restore') + expect(http.requests.map(request => request.body.system)).toEqual(inHistory + ? ['first prompt', 'first prompt', 'first prompt', undefined, undefined, undefined] + : ['first prompt', 'second prompt', 'second prompt', undefined, undefined, 'restored prompt']) + for (const [index, request] of http.requests.entries()) { + const messages = request.body.messages as { role: string; content: unknown[] }[] + expect(messages.filter(message => message.role === 'assistant')).toHaveLength(index) + expect(messages.filter(message => message.role === 'system').map(message => message.content)).toEqual( + !inHistory ? [] : index === 1 || index === 2 ? [[{ type: 'text', text: 'second prompt' }]] + : index === 5 ? [[{ type: 'text', text: 'restored prompt' }]] : [], + ) + expect(JSON.stringify(messages.filter(message => message.role !== 'system'))).not.toMatch(/first prompt|second prompt|restored prompt/) + } + expect(resumed.session.requestContext()?.systemPromptUpdate).toBe(inHistory ? 'in-history' : undefined) + }) + + it.each([false, true])('continues and resumes sessions after a model capability change, in-history=%s', async (inHistory) => { + const { ctx, http } = await boot() + await ctx.settings.update('llm-deepseek', { baseURL: http.url, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) + let prompt = 'old prompt' + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ + ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], + })) + const selection: ModelSelectionRef = { current: { provider: 'deepseek-official', model: MODEL }, assembled: undefined } + const agent = await ctx.agentLoop.create(SessionId('capability-switch'), selection.current) + installModelSelection(agent.ctx, selection) + await send(agent, 'first') + prompt = 'current prompt' + await send(agent, 'second') + expect((http.requests[1]?.body.messages as { role: string }[]).filter(message => message.role === 'system')).toHaveLength(1) + const seed = [...agent.session.snapshotEvents()] + const saved = JSON.stringify(seed) + await ctx.settings.update(Messages.name, { models: [{ id: MODEL, ...inHistory ? { systemPromptUpdate: 'in-history' } : {} }] }) + selection.current = { provider: 'deepseek-official', model: MODEL } + await send(agent, 'switch') + const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('switch-resume'), agentOptions: selection.current, seed }) + await send(resumed, 'resume') + for (const request of http.requests.slice(2)) { + expect(request.path).toBe('/anthropic/v1/messages') + expect(request.body.system).toBe(inHistory ? 'old prompt' : 'current prompt') + const messages = request.body.messages as { role: string }[] + expect(messages.filter(message => message.role === 'assistant')).toHaveLength(2) + expect(messages.filter(message => message.role === 'system')).toHaveLength(inHistory ? 1 : 0) + expect(JSON.stringify(messages.filter(message => message.role !== 'system'))).not.toMatch(/old prompt|current prompt/) + } + expect(JSON.stringify(seed)).toBe(saved) + expect(agent.session.deriveMessages().filter(message => message.role === 'system')).toHaveLength(inHistory ? 2 : 1) + expect(resumed.session.deriveMessages().filter(message => message.role === 'system')).toHaveLength(inHistory ? 2 : 1) + }) + + it('maps multiple system snapshots on direct compaction calls to the latest prompt', async () => { + const { ctx, http } = await boot() + const history = [createSystemMessage('old'), user(), + createAssistantMessage({ content: [{ type: 'text', text: 'OK' }], source: { provider: 'deepseek-official', model: MODEL } }), + createSystemMessage('current'), user('summarize')] + const saved = JSON.stringify(history) + const response = await assemble(ctx.llm.stream(options({ messages: history, purpose: 'compaction' }))) + expect(response.assembler.finish.kind).toBe('stop') + expect(http.requests[0]?.body.system).toBe('current') + expect((http.requests[0]?.body.messages as { role: string }[]).map(message => message.role)).toEqual(['user', 'assistant', 'user']) + expect(JSON.stringify(history)).toBe(saved) + }) + + it('continues a recorded tool turn with a warning when its native replay version is unknown', async () => { + const { ctx, http } = await boot() + const warnings: unknown[][] = [] + ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } }) + const fixture = await readFile(new URL('../../../../snapshots/session/deepseek-messages-degraded-replay/session.v2.jsonl', import.meta.url), 'utf8') + const records = fixture.trim().split('\n').map(line => JSON.parse(line) as { type: string; data: { message?: Message } }) + const assistant = records.find(record => record.type === 'assistant/message')!.data.message! + if (assistant.source.kind === 'model') assistant.source.provider = 'deepseek-official' + // The released v2 row still wraps its tool result inside a user message. + const released = object(records.find(record => record.type === 'tool/result')!.data.message) + if (!Array.isArray(released.content)) throw new Error('fixture lacks released tool content') + const releasedBlock = object(released.content[0]) + if (typeof releasedBlock.toolCallId !== 'string' || !Array.isArray(releasedBlock.content)) { + throw new Error('fixture lacks released tool result') + } + const content = releasedBlock.content.map((value) => { + const block = object(value) + if (block.type !== 'text' || typeof block.text !== 'string') throw new Error('fixture tool result must contain text') + return { type: 'text' as const, text: block.text } + }) + const result = createToolResultMessage({ + callId: ToolCallId(releasedBlock.toolCallId), content, isError: releasedBlock.isError === true, + }) + const saved = JSON.stringify([assistant, result]) + const response = await assemble(ctx.llm.stream(options({ messages: [user(), assistant, result] }))) + expect(response.assembler.finish.kind).toBe('stop') + expect(warnings).toEqual([[`llm-deepseek: unusable Messages replay state on assistant history for route "deepseek-official/${MODEL}"; sending provider-neutral content (DeepSeek Messages replay: unsupported kind or version)`]]) + expect(http.requests).toHaveLength(1) + expect(http.requests[0]?.body.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [ + { type: 'thinking', thinking: 'The user wants me to run a simple bash command and then reply with "DONE".' }, + { type: 'tool_use', id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', name: 'bash', input: { command: 'echo TERMINAL_OK', description: 'Echo TERMINAL_OK to verify terminal access' } }, + ] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', content: [{ type: 'text', text: 'TERMINAL_OK\n' }], is_error: false }] }, + ]) + expect(JSON.stringify([assistant, result])).toBe(saved) + }) + + it('loads one provider from YAML, rotates settings and credentials, then removes disposed registrations', async () => { + const { ctx, http } = await boot() + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek-official']) + expect((await assemble(ctx.llm.stream(options()))).assembler.finish.kind).toBe('stop') + expect(http.requests[0]?.headers['x-api-key']).toBe('stored-key') + const second = await endpoint() + await ctx.settings.update(Messages.name, { baseURL: second.url, maxTokens: 51, retryPolicy: { mode: 'always' } }) + await ctx.credentials.set(credentialRef('DEEPSEEK_API_KEY'), 'rotated') + await chunks(ctx.llm.stream(options())) + expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'rotated' }, body: { max_tokens: 51 } }) + await ctx.settings.update(Messages.name, { models: [{ id: 'duplicate' }, { id: 'duplicate' }], baseURL: http.url }) + await chunks(ctx.llm.stream(options())) + expect(second.requests).toHaveLength(2) + expect(http.requests).toHaveLength(1) + await ctx.settings.update(Messages.name, { models: [{ id: MODEL }], baseURL: http.url }) + await chunks(ctx.llm.stream(options())) + expect(http.requests).toHaveLength(2) + const llm = ctx.llm + await ctx.fiber.dispose() + expect(llm.listProviders()).toEqual([]) + expect(llm.listConfigurableProviders()).toEqual([]) + }) + + it('uses environment credentials and reports missing or malformed keys without network access', async () => { + const http = await endpoint() + const { ctx } = await context() + vi.stubEnv('DEEPSEEK_BASE_URL', http.url) + vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') + await ctx.plugin(LlmRuntime) + const fiber = ctx.plugin(Messages) + await fiber + await chunks(ctx.llm.stream(options())) + expect(http.requests[0]?.headers['x-api-key']).toBe('env-key') + vi.stubEnv('DEEPSEEK_API_KEY', '') + expect((await assemble(ctx.llm.stream(options()))).assembler.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + vi.stubEnv('DEEPSEEK_API_KEY', 'bad\nkey') + expect((await assemble(ctx.llm.stream(options()))).assembler.finish).toMatchObject({ kind: 'error', failure: { code: 'INVALID_CREDENTIAL' } }) await fiber.dispose() expect(ctx.llm.listProviders()).toEqual([]) - expect(ctx.llm.listConfigurableProviders()).toEqual([]) - }) - - it('registers retryPolicy from the provider config', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - retryPolicy: { - mode: 'always', - backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, - }, - }) - - expect(ctx.llm.providerRetryPolicy('deepseek-official')).toEqual({ - mode: 'always', - initialDelayMs: 25, - maxDelayMs: 100, - jitterRatio: 0.2, - }) - }) - - it('owns the deepseek provider and advertises the default models', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, - { - provider: 'deepseek-official', - id: 'deepseek-v4-pro', - name: 'DeepSeek-V4-Pro', - description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', - inputModalities: ['text'], - }, - ]) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')) - .resolves.toMatchObject({ - provider: 'deepseek-official', - id: 'deepseek-flash', - name: 'DeepSeek-V41-Flash', - inputModalities: ['text', 'image'], - systemPromptUpdate: 'in-history', - context: { contextWindow: 1_000_000 }, - defaultMaxTokens: 256_000, - reasoning: { - efforts: [ - { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, - { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, - { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, - { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, - ], - defaultEffort: ReasoningEffortId('high'), - }, - }) - }) - - it('keeps deepseek-v4-pro available with its V4 capabilities', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - const info = await ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-pro') - expect(info).toMatchObject({ - id: 'deepseek-v4-pro', - inputModalities: ['text'], - context: { contextWindow: 1_000_000 }, - defaultMaxTokens: 256_000, - }) - expect(info?.systemPromptUpdate).toBeUndefined() - }) - - it.each(['off', 'low', 'max'] as const)('uses the configured %s reasoning default', async (effort) => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - reasoningEffort: effort, - }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) - .resolves.toMatchObject({ - reasoning: { - efforts: [ - { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, - { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, - { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, - { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, - ], - defaultEffort: ReasoningEffortId(effort), - }, - }) - }) - - it('accepts off as the default when thinking is deployment-disabled', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - thinking: 'disabled', - reasoningEffort: 'off', - }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) - .resolves.toMatchObject({ - reasoning: { - efforts: [{ - id: ReasoningEffortId('off'), - name: 'Off', - description: 'Use for simple tasks that do not need reasoning.', - }], - defaultEffort: ReasoningEffortId('off'), - }, - }) - }) - - it.each(['low', 'high', 'max'] as const)( - 'rejects configured reasoning effort %s when thinking is disabled', - async (reasoningEffort) => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - thinking: 'disabled', - reasoningEffort, - })).rejects.toThrow(/only reasoningEffort "off"/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it.each(['low', 'high', 'max'] as const)( - 'rejects disabled-thinking effort %s at the resolver boundary', - (reasoningEffort) => { - expect(() => resolveAdapterOptions({ thinking: 'disabled', reasoningEffort })) - .toThrow(/only reasoningEffort "off"/) - }, - ) - - it('accepts disabled thinking with off at the resolver boundary', async () => { - const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' }) - await expect(adapter.resolveModel('deepseek-official', 'pass-through')).resolves.toMatchObject({ - reasoning: { - efforts: [{ - id: ReasoningEffortId('off'), - name: 'Off', - description: 'Use for simple tasks that do not need reasoning.', - }], - defaultEffort: ReasoningEffortId('off'), - }, - }) - }) - - it('uses the default model catalog when apply is called directly', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, - { - provider: 'deepseek-official', - id: 'deepseek-v4-pro', - name: 'DeepSeek-V4-Pro', - description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', - inputModalities: ['text'], - }, - ]) - }) - - it('defaults an adapter-supplied catalog entry to text input', async () => { - const connection = resolveAdapterOptions({ models: [] }) - const adapter = new DeepSeekAdapter({ - options: () => ({ ...connection, models: [{ id: 'adapter-model' }] }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: noExtensions, - }) - await expect(adapter.listModels('deepseek-official')).resolves.toEqual([{ - provider: 'deepseek-official', - id: 'adapter-model', - name: 'adapter-model', - inputModalities: ['text'], - }]) - }) - - it('advertises configured models without restricting arbitrary request ids', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - models: [ - { id: 'private-fast', contextWindow: 32_000 }, - { - id: 'private-reasoner', - name: 'Private Reasoner', - description: 'Higher reasoning budget', - contextWindow: 64_000, - inputModalities: ['text', 'image'], - }, - ], - }) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, - { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text', 'image'] }, - ]) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast')) - .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-reasoner')) - .resolves.toMatchObject({ - name: 'Private Reasoner', - description: 'Higher reasoning budget', - inputModalities: ['text', 'image'], - }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'arbitrary-unlisted')) - .resolves.toMatchObject({ - context: { contextWindow: 1_000_000 }, - defaultMaxTokens: 256_000, - }) - }) - - it('uses exact model capacity before the adapter-wide default', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - defaultContextWindow: 256_000, - models: [ - { id: 'inherits-default' }, - { id: 'exact-override', contextWindow: 64_000 }, - ], - }) - - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'inherits-default')) - .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'exact-override')) - .resolves.toMatchObject({ context: { contextWindow: 64_000 } }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) - .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) - }) - - it('allows an explicit empty model catalog', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - models: [], - }) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([]) - }) - - const invalidModels: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [ - [[{ id: '' }], /ids must be non-empty/], - [[{ id: 'm', name: '' }], /empty name/], - [[{ id: 'm', contextWindow: 0 }], /contextWindow/], - [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/], - [[{ id: 'm', inputModalities: [] }], /inputModalities/], - [[{ id: 'm', inputModalities: ['text', 'text'] }], /inputModalities must not contain duplicates/], - [[{ - id: 'm', - inputModalities: ['audio'] as unknown as NonNullable, - }], /expected "text" \| "image"/], - [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/], - ] - - it.each(invalidModels)('rejects invalid advisory model config', async (models, message) => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - models: [...models], - })).rejects.toThrow(message) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - const invalidProgrammaticModalities: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [ - [[{ id: 'm', inputModalities: [] }], /inputModalities must not be empty/], - [[{ - id: 'm', - inputModalities: ['audio'] as unknown as NonNullable, - }], /inputModalities must contain only "text" and "image"/], - ] - - it.each(invalidProgrammaticModalities)('rejects programmatic modality config that bypasses the schema', (models, message) => { - expect(() => resolveAdapterOptions({ models: [...models] })).toThrow(message) - }) - - it('rejects the removed imageDetail model setting through schema and direct construction', async () => { - const legacyModel = { id: 'vision', inputModalities: ['image'], imageDetail: 'low' } as unknown as - LlmDeepSeek.DeepSeekCatalogModel - expect(() => resolveAdapterOptions({ models: [legacyModel] })).toThrow(/imageDetail is no longer supported/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - models: [legacyModel], - })).rejects.toThrow(/imageDetail is no longer supported/) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { - expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) - .toThrow(/maxTokens must be a positive integer/) - }) - - it('surfaces a catalog model\'s in-history system prompt update mode and rejects any other mode', async () => { - const adapter = adapterOf({ models: [ - { id: 'capable', systemPromptUpdate: 'in-history' }, - { id: 'plain' }, - ] }) - await expect(adapter.resolveModel('deepseek-official', 'capable')) - .resolves.toMatchObject({ systemPromptUpdate: 'in-history' }) - await expect(adapter.resolveModel('deepseek-official', 'plain')) - .resolves.not.toHaveProperty('systemPromptUpdate') - await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) - .resolves.not.toHaveProperty('systemPromptUpdate') - expect(() => resolveAdapterOptions({ - models: [{ id: 'bogus', systemPromptUpdate: 'leading' as unknown as 'in-history' }], - })).toThrow(/systemPromptUpdate must be "in-history" when present/) - }) - - it('rejects image request limits on a text-only catalog model', () => { - expect(() => resolveAdapterOptions({ - models: [{ id: 'text-only', inputModalities: ['text'], imagePixelBudget: 1 }], - })).toThrow(/text-only catalog model .* cannot declare image request limits/) - }) - - it.each([ - ['imagePixelBudget', 0, /imagePixelBudget must be "low" or a positive safe integer/], - ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be "low" or a positive safe integer/], - ['imagePixelBudget', 'auto', /imagePixelBudget must be "low" or a positive safe integer/], - ['imageMaxBytes', 0, /imageMaxBytes must be a positive safe integer/], - ['imageMaxBytes', 1.5, /imageMaxBytes must be a positive safe integer/], - ] as const)('rejects per-model %s=%s', (field, value, message) => { - expect(() => resolveAdapterOptions({ - models: [{ id: 'vision', inputModalities: ['image'], [field]: value }], - })).toThrow(message) - }) - - it('prefers a model\'s own output cap over the profile default', async () => { - // The profile default stays what an unlisted or uncapped model resolves - // to, so adding a per-model cap changes one model rather than the route. - const adapter = adapterOf({ maxTokens: 4096, models: [ - { id: 'capped', maxTokens: 512 }, - { id: 'uncapped' }, - ] }) - await expect(adapter.resolveModel('deepseek-official', 'capped')) - .resolves.toMatchObject({ defaultMaxTokens: 512 }) - await expect(adapter.resolveModel('deepseek-official', 'uncapped')) - .resolves.toMatchObject({ defaultMaxTokens: 4096 }) - await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) - .resolves.toMatchObject({ defaultMaxTokens: 4096 }) - }) - - it('rejects invalid context capacity when apply is called directly', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - expect(() => { - LlmDeepSeek.apply(ctx, { - baseURL: 'http://127.0.0.1:1', - models: [{ id: 'invalid-context', contextWindow: 0 }], - }) - }).toThrow(/contextWindow must be a positive integer/) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - it.each([0, 1.5])( - 'rejects invalid adapter-wide default context capacity %s', - async (defaultContextWindow) => { - expect(() => resolveAdapterOptions({ defaultContextWindow })) - .toThrow(/defaultContextWindow must be a positive integer/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - defaultContextWindow, - })).rejects.toThrow(/defaultContextWindow/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( - 'rejects invalid adapter-wide maxTokens %s', - async (maxTokens) => { - expect(() => resolveAdapterOptions({ maxTokens })) - .toThrow(/maxTokens must be a positive safe integer/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - maxTokens, - })).rejects.toThrow(/maxTokens/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it('rejects offload quanta larger than their request bounds', () => { - expect(() => resolveAdapterOptions({ - maxRequestFilesBytes: 10, - imageOffloadByteQuantum: 11, - })).toThrow(/imageOffloadByteQuantum must not exceed maxRequestFilesBytes/) - expect(() => resolveAdapterOptions({ - maxInlineRequestImageBytes: 10, - inlineImageOffloadByteQuantum: 11, - })).toThrow(/inlineImageOffloadByteQuantum must not exceed maxInlineRequestImageBytes/) - expect(() => resolveAdapterOptions({ - maxImagesPerRequest: 10, - imageOffloadCountQuantum: 11, - })).toThrow(/imageOffloadCountQuantum must not exceed maxImagesPerRequest/) - }) - - it.each([ - ['maxImagesPerRequest', 0, /maxImagesPerRequest must be a positive safe integer/], - ['maxImagesPerRequest', 1.5, /maxImagesPerRequest must be a positive safe integer/], - ['imageOffloadByteQuantum', 0, /imageOffloadByteQuantum must be a positive safe integer/], - ['imageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /imageOffloadByteQuantum must be a positive safe integer/], - ['inlineImageOffloadByteQuantum', 0, /inlineImageOffloadByteQuantum must be a positive safe integer/], - ['inlineImageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /inlineImageOffloadByteQuantum must be a positive safe integer/], - ['imageOffloadCountQuantum', 0, /imageOffloadCountQuantum must be a positive safe integer/], - ['imageOffloadCountQuantum', 1.5, /imageOffloadCountQuantum must be a positive safe integer/], - ['fileExpiresAfterSeconds', 3_599, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], - ['fileExpiresAfterSeconds', 2_592_001, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], - ['fileRefreshMarginSeconds', -1, /fileRefreshMarginSeconds must be a non-negative integer/], - ['fileRefreshMarginSeconds', 604_800, /fileRefreshMarginSeconds must be a non-negative integer/], - ['fileQuotaCleanupBatch', 0, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], - ['fileQuotaCleanupBatch', 1_001, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], - ] as const)('rejects %s=%s', (field, value, message) => { - expect(() => resolveAdapterOptions({ [field]: value })).toThrow(message) - }) - - it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( - 'rejects invalid request file bound %s', - async (maxRequestFilesBytes) => { - expect(() => resolveAdapterOptions({ maxRequestFilesBytes })) - .toThrow(/maxRequestFilesBytes must be a positive safe integer/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - maxRequestFilesBytes, - })).rejects.toThrow(/maxRequestFilesBytes/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( - 'rejects invalid inline request image bound %s', - async (maxInlineRequestImageBytes) => { - expect(() => resolveAdapterOptions({ maxInlineRequestImageBytes })) - .toThrow(/maxInlineRequestImageBytes must be a positive safe integer/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - maxInlineRequestImageBytes, - })).rejects.toThrow(/maxInlineRequestImageBytes/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') - vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - }) - - it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', '') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - // First-boot onboarding: the route registers so models stay discoverable; - // only the request itself needs a key. - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) - const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) - expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) - // The guidance leads with the managed credential store. - const second = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) - expect(second.finish.kind).toBe('error') - if (second.finish.kind !== 'error') throw new Error('expected an error finish') - // The guidance names both places a credential can come from, and nothing - // else: configuration carries the reference, never a literal key. - expect(second.finish.failure.message) - .toMatch(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s) - }) - - it('reads the ambient variable when no credentials seam is mounted', async () => { - // The plain cordis.yml composition: no credential provider, the key in - // the launching environment. - vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: server.url }) - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') - }) - - it('treats an empty ambient variable as no key when no credentials seam is mounted', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', '') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) - }) - - it('prefers explicit config over env for key and base URL', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') - vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1') - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url) // harness passes explicit config - await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(server.requests).toHaveLength(1) // hit the explicit URL, not env - }) - - it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - vi.stubEnv('DEEPSEEK_BASE_URL', server.url) - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(server.requests).toHaveLength(1) - }) - - - it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => { - const trusted = createLaunchEnvironmentSnapshot([ - { source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } }, - ]) - expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example') - // The product trusts the project it is launched in, so a checkout can - // point its own agent at the gateway that checkout is meant to use. - const project = createLaunchEnvironmentSnapshot([ - { source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } }, - ]) - expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example') - // An explicitly configured endpoint outranks every environment layer, so a - // stale shell value cannot rewrite a deployment's own gateway. - const shell = createLaunchEnvironmentSnapshot([ - { source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } }, - ]) - expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal') - }) - it('uses the public Chat base URL when selected without an endpoint override', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'k') - vi.stubEnv('DEEPSEEK_BASE_URL', undefined) - const ctx = new Context() - await ctx.plugin(LlmRuntime) - // Registration succeeds; no call is made (would hit api.deepseek.com). - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - }) - - it('adapter is constructible directly for embedding over the shared resolver', async () => { - const adapter = adapterOf() - expect(adapter).toBeInstanceOf(DeepSeekAdapter) - // Direct embedding shares the plugin's one resolve step, so it advertises - // the same default catalog instead of a divergent empty one. - await expect(adapter.listModels('deepseek-official')).resolves.toHaveLength(2) - }) - - it('resolves connection facts and the credential exactly once per stream call', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const options = vi.fn(() => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url })) - const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) - const resolveUserId = vi.fn(() => TEST_USER_ID) - const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId, prepareExtensions: noExtensions }) - - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - - expect(options).toHaveBeenCalledTimes(1) - expect(resolveApiKey).toHaveBeenCalledTimes(1) - expect(resolveUserId).toHaveBeenCalledTimes(1) - expect(server.headers[0]?.authorization).toBe('Bearer per-request-key') - }) - - it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => { - expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: Number.POSITIVE_INFINITY })) - .toThrow(/streamIdleTimeoutMs.*positive finite/) - expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) - .toThrow(/streamIdleTimeoutMs.*no greater/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - streamIdleTimeoutMs: 0, - })).rejects.toThrow(/streamIdleTimeoutMs/) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, - })).rejects.toThrow(/streamIdleTimeoutMs/) - }) - - it('validates Files API timeout bounds independently of the stream idle deadline', async () => { - expect(() => resolveAdapterOptions({ filesApiTimeoutMs: Number.POSITIVE_INFINITY })) - .toThrow(/filesApiTimeoutMs.*positive finite/) - expect(() => resolveAdapterOptions({ filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) - .toThrow(/filesApiTimeoutMs.*no greater/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - filesApiTimeoutMs: 0, - })).rejects.toThrow(/filesApiTimeoutMs/) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1, - })).rejects.toThrow(/filesApiTimeoutMs/) - expect(resolveAdapterOptions({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 })) - .toMatchObject({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 }) - }) - - it('rejects invalid nested retryPolicy before registering the provider', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - retryPolicy: { mode: 'normal', maxRetries: -1 }, - })).rejects.toThrow(/retryPolicy/) - expect(ctx.llm.listProviders()).toEqual([]) }) }) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 7288bb9bfb..a1f55b5113 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -131,7 +131,7 @@ async function boot(dir: string, config: object): Promise { 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,7 @@ 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('falls back to the composition entry when settings detach', async () => { @@ -327,6 +327,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') }) }) diff --git a/packages/llm/llm-deepseek/tests/egress.spec.ts b/packages/llm/llm-deepseek/tests/egress.spec.ts index 61629ef425..3a74a50cb8 100644 --- a/packages/llm/llm-deepseek/tests/egress.spec.ts +++ b/packages/llm/llm-deepseek/tests/egress.spec.ts @@ -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 { 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') }) diff --git a/packages/llm/llm-deepseek/tests/messages/expected/degraded-replay.json b/packages/llm/llm-deepseek/tests/expected/degraded-replay.json similarity index 100% rename from packages/llm/llm-deepseek/tests/messages/expected/degraded-replay.json rename to packages/llm/llm-deepseek/tests/expected/degraded-replay.json diff --git a/packages/llm/llm-deepseek/tests/messages/extensions.spec.ts b/packages/llm/llm-deepseek/tests/extensions.spec.ts similarity index 99% rename from packages/llm/llm-deepseek/tests/messages/extensions.spec.ts rename to packages/llm/llm-deepseek/tests/extensions.spec.ts index 29c2ce10c0..66256ce5df 100644 --- a/packages/llm/llm-deepseek/tests/messages/extensions.spec.ts +++ b/packages/llm/llm-deepseek/tests/extensions.spec.ts @@ -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' { diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts index 10af67203a..a671607788 100644 --- a/packages/llm/llm-deepseek/tests/file-store.spec.ts +++ b/packages/llm/llm-deepseek/tests/file-store.spec.ts @@ -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 diff --git a/packages/llm/llm-deepseek/tests/files-api.spec.ts b/packages/llm/llm-deepseek/tests/files-api.spec.ts index a23664c85c..daed0ebedf 100644 --- a/packages/llm/llm-deepseek/tests/files-api.spec.ts +++ b/packages/llm/llm-deepseek/tests/files-api.spec.ts @@ -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 = {}) { 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 = {}) { - 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(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(async () => new Response(JSON.stringify(messagesFile()))) - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL, apiKey: 'key', fetch: fetchImpl }) + const fetchImpl = vi.fn(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 }))), diff --git a/packages/llm/llm-deepseek/tests/messages/files.spec.ts b/packages/llm/llm-deepseek/tests/files.spec.ts similarity index 91% rename from packages/llm/llm-deepseek/tests/messages/files.spec.ts rename to packages/llm/llm-deepseek/tests/files.spec.ts index e4f1a9060e..aee62c111b 100644 --- a/packages/llm/llm-deepseek/tests/messages/files.spec.ts +++ b/packages/llm/llm-deepseek/tests/files.spec.ts @@ -1,16 +1,17 @@ /** Messages file-reference admission, bounded recovery and request-wide inline fallback. */ +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { afterEach, describe, expect, it, vi } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { createAssistantMessage, createToolResultMessage, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' -import { DeepSeekFileId } from '../../src/common/file-id.ts' -import type { DeepSeekFileStore } from '../../src/common/file-store.ts' -import { resolveAdapterOptions } from '../../src/config.ts' -import type { Config } from '../../src/config.ts' -import { DeepSeekMessagesAdapter } from '../../src/protocols/messages/adapter.ts' -import { prepareImages } from '../../src/protocols/messages/images.ts' -import { providerErrorDetail } from '../../src/protocols/messages/transport.ts' -import { chunks, options, prepareExtensions, sse, textEvents, user } from './helpers.ts' +import { DeepSeekFileId } from '../src/file-id.ts' +import { DeepSeekFileStore } from '../src/file-store.ts' +import { resolveAdapterOptions } from '../src/config.ts' +import type { Config } from '../src/config.ts' +import { DeepSeekAdapter } from '../src/adapter.ts' +import { prepareImages } from '../src/images.ts' +import { providerErrorDetail } from '../src/transport.ts' +import { chunks, options, prepareExtensions, sse, textEvents, user, requestImageStore } from './helpers.ts' const model = 'deepseek-flash' const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), width: 1, height: 1, mediaType: 'image/png', bytes: 3 } @@ -31,15 +32,15 @@ function harness(config: Config = {}) { record: { fileId: DeepSeekFileId(image.attachment.attachmentId === ref.attachmentId ? 'file-a' : 'file-b') }, uploaded: false, } as Awaited>)) const invalidate = vi.fn(async () => {}) - const files = { ensureUploaded, invalidate } as unknown as DeepSeekFileStore + const files = Object.assign(new DeepSeekFileStore(), { ensureUploaded, invalidate }) const readImageRequest = vi.fn(async (attachment: ImageAttachmentRef) => version(attachment)) // The codec and remote upload are the expensive boundaries; request projection and recovery stay real. - const attachments = { readImageRequest } as unknown as AttachmentStore + const attachments = requestImageStore(readImageRequest) const prepare = vi.fn(prepareExtensions) - const adapter = new DeepSeekMessagesAdapter({ - connection: () => resolveAdapterOptions(Object.assign({ baseURL: 'https://gateway.example/custom' }, config)), - apiKey: async () => 'test-key', userId: () => 'test-user', attachments: () => attachments, - imageAccess: () => ({ readonlyPath: '/workspace/image.png' }), files: () => files, prepareExtensions: prepare, + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions(Object.assign({ baseURL: 'https://gateway.example/custom' }, config)), + resolveApiKey: async () => 'test-key', resolveUserId: () => 'test-user' as AnonymousUserId, resolveAttachments: () => attachments, + resolveImageAccess: () => ({ readonlyPath: '/workspace/image.png' }), resolveFiles: () => files, prepareExtensions: prepare, }) return { adapter, ensureUploaded, invalidate, readImageRequest, prepare } } @@ -55,7 +56,7 @@ describe('Messages Files requests', () => { createToolResultMessage({ callId, isError: false, content: [{ type: 'image', attachment: ref }, { type: 'image', attachment: ref }] })] await chunks(h.adapter.stream(options({ model, messages }))) expect(h.readImageRequest).toHaveBeenCalledTimes(1) - expect(h.ensureUploaded).toHaveBeenCalledWith(expect.anything(), { baseURL: 'https://gateway.example/custom', apiKey: 'test-key', protocol: 'messages' }, expect.anything(), expect.any(AbortSignal)) + expect(h.ensureUploaded).toHaveBeenCalledWith(expect.anything(), { baseURL: 'https://gateway.example/custom', apiKey: 'test-key' }, expect.anything(), expect.any(AbortSignal)) const [url, init] = fetchImpl.mock.calls[0]! expect(url).toBe('https://gateway.example/custom/v1/messages') expect(new Headers(init?.headers).get('anthropic-beta')).toBe('files-api-2025-04-14') @@ -72,7 +73,7 @@ describe('Messages Files requests', () => { vi.stubGlobal('fetch', fetchImpl) const h = harness() await chunks(h.adapter.stream(request([ref, second]))) - expect(h.invalidate).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ attachment: ref }), 'file-a', expect.objectContaining({ protocol: 'messages' })) + expect(h.invalidate).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ attachment: ref }), 'file-a', expect.objectContaining({})) expect(fetchImpl).toHaveBeenCalledTimes(2) expect(h.prepare).toHaveBeenCalledTimes(2) expect(h.ensureUploaded).toHaveBeenCalledTimes(4) diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/cordis.yml b/packages/llm/llm-deepseek/tests/fixtures/cordis.yml similarity index 100% rename from packages/llm/llm-deepseek/tests/messages/fixtures/cordis.yml rename to packages/llm/llm-deepseek/tests/fixtures/cordis.yml diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/history.ts b/packages/llm/llm-deepseek/tests/fixtures/history.ts similarity index 77% rename from packages/llm/llm-deepseek/tests/messages/fixtures/history.ts rename to packages/llm/llm-deepseek/tests/fixtures/history.ts index 4c5b78c6d1..0a02332dfc 100644 --- a/packages/llm/llm-deepseek/tests/messages/fixtures/history.ts +++ b/packages/llm/llm-deepseek/tests/fixtures/history.ts @@ -1,7 +1,7 @@ /** Exercise Messages request conversion with recorded responses from another protocol. */ import type { Context } from '@deepseek-ai/cordis' -import { resolveAdapterOptions } from '../../../src/config.ts' -import { serialize } from '../../../src/protocols/messages/serialize.ts' +import { resolveAdapterOptions } from '../../src/config.ts' +import { serialize } from '../../src/serialize.ts' export const name = 'messages-history-snapshot' export const inject = ['llm'] diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts b/packages/llm/llm-deepseek/tests/fixtures/input-history.ts similarity index 93% rename from packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts rename to packages/llm/llm-deepseek/tests/fixtures/input-history.ts index ce98439370..33f45f6291 100644 --- a/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts +++ b/packages/llm/llm-deepseek/tests/fixtures/input-history.ts @@ -3,8 +3,8 @@ import assert from 'node:assert/strict' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' -import { resolveAdapterOptions } from '../../../src/config.ts' -import { serialize } from '../../../src/protocols/messages/serialize.ts' +import { resolveAdapterOptions } from '../../src/config.ts' +import { serialize } from '../../src/serialize.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { diff --git a/packages/llm/llm-deepseek/tests/messages/helpers.ts b/packages/llm/llm-deepseek/tests/helpers.ts similarity index 63% rename from packages/llm/llm-deepseek/tests/messages/helpers.ts rename to packages/llm/llm-deepseek/tests/helpers.ts index 1aff3c74f6..d5f8e780d7 100644 --- a/packages/llm/llm-deepseek/tests/messages/helpers.ts +++ b/packages/llm/llm-deepseek/tests/helpers.ts @@ -1,14 +1,18 @@ /** Deterministic Messages fixtures and loopback transport with explicit teardown. */ +import { Context } from '@deepseek-ai/cordis' +import AttachmentStore from '@deepseek-ai/dsh-attachment' +import type { ModuleLoaderV2 } from '@deepseek-ai/cordis-plugin-loader' +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { createServer } from 'node:http' import type { IncomingHttpHeaders, ServerResponse } from 'node:http' import { once } from 'node:events' -import { object } from '../../src/protocols/messages/replay.ts' +import { object } from '../src/replay.ts' import { BlockAssembler, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { resolveAdapterOptions } from '../../src/index.ts' -import { DeepSeekMessagesAdapter } from '../../src/protocols/messages/adapter.ts' -import type { Config } from '../../src/config.ts' -import { DeepSeekFileStore } from '../../src/common/file-store.ts' +import { resolveAdapterOptions } from '../src/index.ts' +import { DeepSeekAdapter } from '../src/adapter.ts' +import type { Config } from '../src/config.ts' +import { DeepSeekFileStore } from '../src/file-store.ts' export const prepareExtensions = async () => ({ fields: {}, accept: async () => {} }) @@ -40,7 +44,7 @@ export async function assemble(stream: AsyncIterable, model = MODEL } export function adapter(config: Config = {}) { const files = new DeepSeekFileStore() - return new DeepSeekMessagesAdapter({ connection: () => resolveAdapterOptions(config), apiKey: () => Promise.resolve('test-key'), userId: () => 'test-user', attachments: () => undefined, imageAccess: () => undefined, files: () => files, prepareExtensions }) + return new DeepSeekAdapter({ options: () => resolveAdapterOptions(config), resolveApiKey: () => Promise.resolve('test-key'), resolveUserId: () => 'test-user' as AnonymousUserId, resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, prepareExtensions }) } export async function server(reply: (response: ServerResponse, count: number) => void = response => response.end(sse(textEvents))) { const requests: { path: string; headers: IncomingHttpHeaders; body: Record }[] = [] @@ -70,3 +74,28 @@ export async function server(reply: (response: ServerResponse, count: number) => }, } } + +/** Supply only the image projection operation; any unexpected storage work fails. */ +export function requestImageStore(readImageRequest: AttachmentStore['readImageRequest']): AttachmentStore { + class ProjectedAttachments extends AttachmentStore { + get imageLimits(): never { throw new Error('unexpected image policy read') } + validateImage(): never { throw new Error('unexpected image validation') } + saveImage(): never { throw new Error('unexpected image save') } + readImage(): never { throw new Error('unexpected original image read') } + override readImageRequest = readImageRequest + } + return new ProjectedAttachments(new Context()) +} + +/** Import mapped source modules without claiming support for Node's HMR internals. */ +export function sourceModuleLoader(importModule: (specifier: string) => Promise): ModuleLoaderV2 { + return { + version: 'v2', + import: importModule, + get loadCache(): never { throw new Error('unexpected module cache access') }, + register(): never { throw new Error('unexpected module hook registration') }, + getOrCreateModuleJob(): never { throw new Error('unexpected module job creation') }, + resolveSync(): never { throw new Error('unexpected synchronous module resolution') }, + load(): never { throw new Error('unexpected module load') }, + } +} diff --git a/packages/llm/llm-deepseek/tests/image-tokens.spec.ts b/packages/llm/llm-deepseek/tests/image-tokens.spec.ts index 5825de033e..0796e3d113 100644 --- a/packages/llm/llm-deepseek/tests/image-tokens.spec.ts +++ b/packages/llm/llm-deepseek/tests/image-tokens.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { deepSeekImageTokens, deepSeekRequestImageDimensions } from '../src/common/image-tokens.ts' +import { deepSeekImageTokens, deepSeekRequestImageDimensions } from '../src/image-tokens.ts' describe('DeepSeek image tokens', () => { // Reference values from the provider's published image token calculator diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 146fd111d4..0626186629 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -29,14 +29,13 @@ import * as DeepSeekPluginPackageInventory from '@deepseek-ai/dsh-plugin-package import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' -import { server as messagesServer } from './messages/helpers.ts' +import { sourceModuleLoader } from './helpers.ts' const NS = 'llm-deepseek' const KEY_REF = credentialRef('DEEPSEEK_API_KEY') let root: string | undefined let context: Context | undefined -const closeMessagesServers: (() => Promise)[] = [] afterEach(async () => { await context?.fiber.dispose() @@ -44,12 +43,11 @@ afterEach(async () => { if (root !== undefined) await rm(root, { recursive: true, force: true }) root = undefined await closeMockServers() - while (closeMessagesServers.length) await closeMessagesServers.pop()!() vi.unstubAllEnvs() }) async function loadComposition( - options: { withDynamic: boolean; baseURL: string; reuseRoot?: string; enableSessionLog?: boolean; protocol?: 'chat-completions' | 'messages' }, + options: { withDynamic: boolean; baseURL: string; reuseRoot?: string; enableSessionLog?: boolean }, ): Promise<{ ctx: Context; settingsPath: string; credentialsPath: string }> { // A reused root is the restart case: the same harness home, its documents // exactly as the previous process left them. @@ -97,7 +95,6 @@ async function loadComposition( '- id: llm-deepseek', " name: '@deepseek-ai/dsh-llm-deepseek'", ' config:', - ` protocol: ${options.protocol ?? 'chat-completions'}`, ` baseURL: ${JSON.stringify(options.baseURL)}`, '', ].join('\n')) @@ -129,13 +126,10 @@ async function loadComposition( type: 'module', })}\n`) })) - ctx.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) - return modules.get(specifier) - }, - } as unknown as NonNullable + ctx.loader.internal = sourceModuleLoader(async (specifier) => { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href }, @@ -144,18 +138,12 @@ async function loadComposition( return { ctx, settingsPath, credentialsPath } } -async function extensionServer(protocol: 'chat-completions' | 'messages') { - if (protocol === 'chat-completions') return mockServer([{ kind: 'sse', events: textEvents }]) - const server = await messagesServer() - closeMessagesServers.push(() => server.close()) - return { url: server.url, get requests() { return server.requests.map(request => request.body) } } -} describe('llm-deepseek real dynamic composition', () => { - it.each(['chat-completions', 'messages'] as const)('keeps package inventory on when the %s Loader composition disables session upload', async (protocol) => { + it('keeps package inventory on when the Loader composition disables session upload', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key') - const server = await extensionServer(protocol) - const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url, protocol, enableSessionLog: false }) + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url, enableSessionLog: false }) const session = ctx.sessions.create(SessionId('extension-composition')) session.append('turn/start', { turn: 1 }) @@ -171,13 +159,12 @@ describe('llm-deepseek real dynamic composition', () => { expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(-1) }) - it.each(['chat-completions', 'messages'] as const)('sends the canonical session suffix by default through %s Loader composition', async (protocol) => { + it('sends the canonical session suffix by default through Loader composition', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key') - const server = await extensionServer(protocol) + const server = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url, - protocol, }) const session = ctx.sessions.create(SessionId('extension-composition-enabled')) session.append('turn/start', { turn: 1 }) @@ -210,7 +197,7 @@ describe('llm-deepseek real dynamic composition', () => { expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS]) await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key') + expect(serverA.headers[0]?.['x-api-key']).toBe('boot-key') expect(serverA.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) // External edits, exactly as a user or the web UI would leave them on disk. @@ -225,7 +212,7 @@ describe('llm-deepseek real dynamic composition', () => { await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(serverA.requests).toHaveLength(1) - expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key') + expect(serverB.headers[0]?.['x-api-key']).toBe('rotated-key') }) it('keeps a stored key writable and rotatable across a real restart', async () => { @@ -240,7 +227,7 @@ describe('llm-deepseek real dynamic composition', () => { expect(await boot.ctx.get('credentials')!.describe(KEY_REF)) .toEqual({ configured: true, source: 'file', writable: true }) await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui') + expect(first.headers[0]?.['x-api-key']).toBe('stored-by-ui') await boot.ctx.fiber.dispose() context = undefined @@ -254,7 +241,7 @@ describe('llm-deepseek real dynamic composition', () => { // Rotation still works after the restart, and the next request uses it. await credentials.set(KEY_REF, 'rotated-after-restart') await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart') + expect(second.headers[0]?.['x-api-key']).toBe('rotated-after-restart') }) it('boots the same adapter on entry config alone, resolving the reference from the environment', async () => { @@ -267,6 +254,6 @@ describe('llm-deepseek real dynamic composition', () => { expect(ctx.get('settings')).toBeUndefined() expect(ctx.get('credentials')).toBeUndefined() await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(server.headers[0]?.authorization).toBe('Bearer entry-key') + expect(server.headers[0]?.['x-api-key']).toBe('entry-key') }) }) diff --git a/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts deleted file mode 100644 index aeb6ebe3e8..0000000000 --- a/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * 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 { 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 LocalAttachments from '@deepseek-ai/dsh-attachment-local' -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 Messages from '../../src/index.ts' -import { DeepSeekFilesClient } from '../../src/common/files-api.ts' -import { MESSAGES_FILES_BETA } from '../../src/common/messages-api.ts' -import { assemble, options, user } from './helpers.ts' - -const IN_HISTORY_MODEL = process.env.DEEPSEEK_IN_HISTORY_MODEL -const cleanups: (() => Promise)[] = [] -afterEach(async () => { - while (cleanups.length) await cleanups.pop()!() - vi.unstubAllEnvs() - vi.unstubAllGlobals() -}) -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() - cleanups.push(() => ctx.fiber.dispose()) - await ctx.plugin(LlmRuntime) - await ctx.plugin(Messages, { - baseURL: Messages.MESSAGES_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'] } } - -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) - } - 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('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.MESSAGES_BASE_URL, protocol: 'messages', apiKey: process.env.DEEPSEEK_API_KEY as string, fetch: fetchImpl }) - const ownedFiles = new Set>() - 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.MESSAGES_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.MESSAGES_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') - } - expect(uploads).toHaveLength(1) - expect(bodies).toHaveLength(2) - expect(bodies.every(body => body.includes(`"file_id":"${uploads[0]}"`) && !body.includes('"type":"base64"'))).toBe(true) - const fileId = Messages.DeepSeekFileId(uploads[0]!) - const retrieved = await files.retrieve(fileId) - expect(retrieved).toMatchObject({ id: fileId, bytes: attachment.bytes, purpose: 'user_data' }) - expect(retrieved.expiresAt).toBeUndefined() - let page = await files.list({ limit: 1_000 }) - const cursors = new Set() - 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.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(SessionStore) - await ctx.plugin(DeepSeekLlmApiExtensionRegistry) - 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 = { - version: 'v2', - async import(specifier: string) { - if (specifier !== '@deepseek-ai/dsh-plugin-package-inventory-deepseek') throw new Error(`unexpected Loader import: ${specifier}`) - return PluginPackageInventoryDeepSeek - }, - } as unknown as NonNullable - await ctx.loader.create({ name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' }) - await ctx.loader.await() - const packageIdentity = JSON.parse(await readFile(new URL('../../../plugin-package-inventory-deepseek/package.json', import.meta.url), 'utf8')) as { name: string; version: string } - const session = ctx.sessions.create(SessionId(`real-messages-extensions-${randomUUID()}`)) - session.append('turn/start', { turn: 1 }) - 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.MESSAGES_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 - 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 - }) - 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) - } - 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(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) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts b/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts deleted file mode 100644 index 6a9fa25af6..0000000000 --- a/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts +++ /dev/null @@ -1,450 +0,0 @@ -/** HTTP lifecycle, routing and optional Cordis services under real composition. */ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context, LoggerLevel, Service } from '@deepseek-ai/cordis' -import LocalAttachments from '@deepseek-ai/dsh-attachment-local' -import AgentRegistry, { installModelSelection } from '@deepseek-ai/dsh-agent' -import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import LlmRuntime, { createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' -import { credentialRef } from '@deepseek-ai/dsh-credentials' -import LocalCredentials from '@deepseek-ai/dsh-credentials-local' -import FileSettings from '@deepseek-ai/dsh-settings-file' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import { DeepSeekMessagesAdapter } from '../../src/protocols/messages/adapter.ts' -import { DeepSeekFileStore } from '../../src/common/file-store.ts' -import * as Messages from '../../src/index.ts' -import { adapter, assemble, chunks, MODEL, options, prepareExtensions, server, sse, textEvents, user } from './helpers.ts' - -const cleanup: (() => Promise)[] = [] -afterEach(async () => { - while (cleanup.length) await cleanup.pop()!() - vi.unstubAllEnvs() - vi.unstubAllGlobals() -}) -async function endpoint(...args: Parameters) { - const instance = await server(...args) - cleanup.push(() => instance.close()) - return instance -} -async function context() { - const home = await mkdtemp(join(tmpdir(), 'dsh-messages-test-')) - cleanup.push(() => rm(home, { recursive: true, force: true })) - vi.stubEnv('DSH_HOME', home) - const ctx = new Context() - cleanup.push(() => ctx.fiber.dispose()) - return { ctx, home } -} - -async function send(agent: Agent, text: string) { - agent.followup(user(text)) - await agent.whenIdle() - expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) -} - -declare module '@deepseek-ai/dsh-llm' { - interface MessageSourceMap { - 'saved-notice': { kind: 'saved-notice' } - } -} - -describe('direct Messages HTTP', () => { - it('continues through Messages with assistant blocks in saved user history', async () => { - const http = await endpoint() - const notice = createUserMessage({ source: { kind: 'saved-notice' }, content: [ - { type: 'text', text: 'Background subagent finished.' }, - { type: 'reasoning', text: 'child reasoning' }, - { type: 'tool-call', id: ToolCallId('child-call'), name: 'read', arguments: '{}' }, - { type: 'text', text: 'Its closing message: answer.' }, - ] }) - const empty = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'reasoning', text: 'no closing text' }] }) - const history = [notice, empty] - const saved = JSON.stringify(history) - const llm = adapter({ baseURL: http.url }) - const first = await assemble(llm.stream(options({ messages: history }))) - const second = await assemble(llm.stream(options({ messages: [...history, first.message, user('continue')] }))) - - expect(first.assembler.finish.kind).toBe('stop') - expect(second.assembler.finish.kind).toBe('stop') - expect(http.requests).toHaveLength(2) - const wireNotice = { role: 'user', content: [ - { type: 'text', text: 'Background subagent finished.' }, - { type: 'text', text: 'Its closing message: answer.' }, - ] } - expect(http.requests[0]?.body.messages).toEqual([wireNotice]) - expect(http.requests[1]?.body.messages).toEqual([ - wireNotice, - { role: 'assistant', content: [{ type: 'text', text: 'Hello 世界' }] }, - { role: 'user', content: [{ type: 'text', text: 'continue' }] }, - ]) - expect(JSON.stringify(history)).toBe(saved) - }) - - it('continues without a diagnostic callback when replay metadata is unusable', async () => { - const http = await endpoint() - const message = createAssistantMessage({ content: [{ type: 'text', text: 'Remember 731.' }], source: { - provider: 'deepseek-official', model: MODEL, replayState: { response: {}, blocks: [] }, - } }) - const response = await assemble(adapter({ baseURL: http.url }).stream(options({ messages: [user(), message, user()] }))) - expect(response.assembler.finish.kind).toBe('stop') - expect(http.requests[0]?.body.messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'Remember 731.' }] }, - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - ]) - }) - - it('uses the Messages endpoint, authentication, attribution and final usage', async () => { - const http = await endpoint() - const llm = adapter({ baseURL: http.url }) - const response = await assemble(llm.stream(options({ model: 'deepseek-flash', sessionId: SessionId('session-test'), purpose: 'compaction' })), 'deepseek-flash') - expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) - expect(response.message.source).toMatchObject({ - model: 'deepseek-flash', replayState: { response: { model: 'deepseek-flash' } }, - }) - expect(http.requests[0]).toMatchObject({ path: '/anthropic/v1/messages', headers: { - 'x-api-key': 'test-key', 'anthropic-version': '2023-06-01', - 'user-agent': expect.stringContaining('deepseek-harness/') as string, 'x-deepseek-harness-user-id': 'test-user', - 'x-deepseek-harness-session-id': 'session-test', 'x-deepseek-harness-compact': '1', - }, body: { thinking: { type: 'enabled' }, output_config: { effort: 'high' } } }) - expect(llm.providerInfo('deepseek-official')).toEqual({ id: 'deepseek-official', name: 'DeepSeek' }) - expect((await llm.listModels('deepseek-official')).map(model => model.id)).toEqual([ - 'deepseek-flash', 'deepseek-v4-pro', - ]) - expect(await llm.resolveModel('deepseek-official', 'deepseek-flash')).toMatchObject({ - name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', - }) - expect(await llm.resolveModel('deepseek-official', MODEL)).toMatchObject({ id: MODEL }) - expect(llm.imageRequestPricing('deepseek-official', MODEL)).toBeDefined() - }) - - it.each([ - ['https://provider.example', 'https://provider.example/v1/messages'], - ['https://provider.example/v1/', 'https://provider.example/v1/messages'], - ['https://provider.example/v1beta', 'https://provider.example/v1beta/v1/messages'], - ['https://provider.example/v2', 'https://provider.example/v2/v1/messages'], - ['https://provider.example/anthropic', 'https://provider.example/anthropic/v1/messages'], - ['https://v1.provider.example', 'https://v1.provider.example/v1/messages'], - ])('resolves the Messages endpoint from %s', async (baseURL, expected) => { - const fetchImpl = vi.fn(async () => new Response(sse(textEvents), { - headers: { 'content-type': 'text/event-stream' }, - })) - vi.stubGlobal('fetch', fetchImpl) - - await chunks(adapter({ baseURL }).stream(options())) - - expect(fetchImpl.mock.calls[0]?.[0]).toBe(expected) - }) - - it.each([true, false])('maps non-2xx responses (JSON=%s)', async (json) => { - const http = await endpoint((response) => { response.statusCode = 429; response.setHeader('retry-after', '3'); response.end(json ? JSON.stringify({ error: { type: 'rate_limit_error', message: 'slow down' } }) : 'busy') }) - await expect(chunks(adapter({ baseURL: http.url }).stream(options()))).rejects.toMatchObject({ code: 'RATE_LIMIT', failure: { status: 429, providerRetryAfterMs: 3000 } }) - }) - - it('refuses redirects before credentials reach another origin or request extensions are accepted', async () => { - const destination = await endpoint() - const source = await endpoint((response) => { - response.writeHead(307, { location: `${destination.url}/v1/messages` }) - response.end() - }) - const accept = vi.fn(async () => {}) - const prepare = vi.fn(async () => ({ fields: {}, accept })) - const files = new DeepSeekFileStore() - const llm = new DeepSeekMessagesAdapter({ - connection: () => Messages.resolveAdapterOptions({ baseURL: source.url }), - apiKey: () => Promise.resolve('test-key'), userId: () => 'test-user', - attachments: () => undefined, imageAccess: () => undefined, files: () => files, - prepareExtensions: prepare, - }) - const error = await chunks(llm.stream(options())).catch((cause: unknown) => cause) - expect(source.requests).toHaveLength(1) - expect(source.requests[0]?.headers['x-api-key']).toBe('test-key') - expect(destination.requests).toEqual([]) - expect(error).toMatchObject({ code: 'TRANSPORT' }) - expect(prepare).toHaveBeenCalledOnce() - expect(accept).not.toHaveBeenCalled() - }) - - it('freezes endpoint and defaults for a prepared call while the next call sees new settings', async () => { - const first = await endpoint(), second = await endpoint() - let config = Messages.resolveAdapterOptions({ baseURL: first.url, maxTokens: 10, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) - const files = new DeepSeekFileStore() - const llm = new DeepSeekMessagesAdapter({ connection: () => config, apiKey: snapshot => Promise.resolve(snapshot.maxTokens === 10 ? 'first' : 'second'), userId: () => 'user', attachments: () => undefined, imageAccess: () => undefined, files: () => files, prepareExtensions }) - const prepared = await llm.prepareCall('deepseek-official', MODEL) - config = Messages.resolveAdapterOptions({ baseURL: second.url, maxTokens: 20 }) - expect(prepared.model.systemPromptUpdate).toBe('in-history') - expect((await llm.resolveModel('deepseek-official', MODEL)).systemPromptUpdate).toBeUndefined() - await chunks(prepared.stream(options())) - await chunks(llm.stream(options())) - expect(first.requests[0]).toMatchObject({ headers: { 'x-api-key': 'first' }, body: { max_tokens: 10 } }) - expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'second' }, body: { max_tokens: 20 } }) - }) - - it('aborts an open provider response when its consumer stops', async () => { - let closed!: () => void - const stopped = new Promise((resolve) => { closed = resolve }) - const http = await endpoint((response) => { - response.once('close', closed) - response.write(sse(textEvents.slice(0, 3))) - }) - const stream = adapter({ baseURL: http.url }).stream(options())[Symbol.asyncIterator]() - expect((await stream.next()).value).toMatchObject({ type: 'block-start' }) - await stream.return!() - await stopped - }) - - it('distinguishes caller cancellation from idle timeout and transport failure', async () => { - const http = await endpoint((response) =>{ response.flushHeaders() }) - await expect(chunks(adapter({ baseURL: http.url, streamIdleTimeoutMs: 30 }).stream(options()))).rejects.toMatchObject({ code: 'TIMEOUT' }) - const controller = new AbortController(); controller.abort() - await expect(chunks(adapter({ baseURL: http.url }).stream(options({ signal: controller.signal })))).rejects.toMatchObject({ code: 'ABORTED' }) - vi.stubGlobal('fetch', async () => { throw new TypeError('network down') }) - await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'TRANSPORT' }) - }) - - it('rejects a successful response with no readable body', async () => { - vi.stubGlobal('fetch', async () => new Response(null, { status: 200 })) - await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'EMPTY_RESPONSE' }) - }) -}) - -describe('Cordis provider composition', () => { - it('resolves an attachment service loaded after the adapter and maps its read-only path', async () => { - const http = await endpoint() - const { ctx, home } = await context() - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - await ctx.plugin(LlmRuntime) - await ctx.plugin(Messages, { baseURL: http.url }) - const model = 'deepseek-flash' - const price = () => ctx.llm.imageRequestPricing('deepseek-official', model)! - const dummy = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), width: 1, height: 1, bytes: 3, mediaType: 'image/png' as const } - expect(price().priceImages([{ type: 'image', attachment: dummy }])[0]?.text).toBeDefined() - await ctx.plugin(LocalAttachments, { dshHome: home }) - const attachment = await ctx.attachments.saveImage({ data: await readFile(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) - expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).not.toContain('/mounted/image.png') - class MappedFiles extends Service { - constructor(context: Context) { super(context, 'fs') } - processPathFromHostPath(_path: string) { return '/mounted/image.png' } - } - await ctx.plugin(MappedFiles) - const message = user() - await chunks(ctx.llm.stream(options({ model, messages: [{ ...message, content: [...message.content, { type: 'image', attachment }] }] }))) - expect(JSON.stringify(http.requests[0]?.body)).toContain('/mounted/image.png') - expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).toContain('/mounted/image.png') - }) - - async function boot(...args: Parameters) { - const http = await endpoint(...args) - const { ctx, home } = await context() - vi.stubEnv('DEEPSEEK_API_KEY', '') - await writeFile(join(home, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: stored-key\n', { mode: 0o600 }) - await writeFile(join(home, 'settings.yaml'), '{}\n') - const template = await readFile(new URL('fixtures/cordis.yml', import.meta.url), 'utf8') - await writeFile(join(home, 'cordis.yml'), template.replaceAll('{{endpoint}}', JSON.stringify(http.url)).replaceAll('{{settings}}', JSON.stringify(join(home, 'settings.yaml'))).replaceAll('{{credentials}}', JSON.stringify(join(home, '.credentials.yaml')))) - ctx.baseUrl = pathToFileURL(home).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - const modules = new Map([ - ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-llm-deepseek', Messages], - ['@deepseek-ai/dsh-credentials-local', LocalCredentials], ['@deepseek-ai/dsh-settings-file', FileSettings], - ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], - ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], - ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], - ]) - // The importer supplies source modules while Loader still owns configuration and effects. - for (const name of modules.keys()) { - const directory = join(home, 'node_modules', name) - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'package.json'), JSON.stringify({ name, version: '0.1.3-alpha.1', type: 'module' })) - } - ctx.loader.internal = { version: 'v2', async import(name: string) { - if (!modules.has(name)) throw new Error(`unexpected module ${name}`) - return modules.get(name) - } } as unknown as NonNullable - await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(home, 'cordis.yml')).href } }) - await ctx.loader.await() - return { ctx, http } - } - - it.each([ - { model: MODEL, inHistory: false }, - { model: MODEL, inHistory: true }, - { model: 'deepseek-flash', inHistory: true }, - ])('updates, clears and restores prompts across continued and resumed sessions, model=$model in-history=$inHistory', async ({ model, inHistory }) => { - const { ctx, http } = await boot() - if (inHistory && model === MODEL) await ctx.settings.update(Messages.name, { models: [{ id: model, systemPromptUpdate: 'in-history' }] }) - let prompt = 'first prompt' - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ - ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], - })) - const agentOptions = { provider: 'deepseek-official', model } - const agent = await ctx.agentLoop.create(SessionId('prompt-update'), agentOptions) - await send(agent, 'first') - prompt = 'second prompt' - await send(agent, 'second') - const count = agent.session.snapshotEvents().filter(event => event.type === 'system/message').length - await send(agent, 'unchanged') - expect(agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(count) - prompt = '' - await send(agent, 'clear') - const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('prompt-resume'), agentOptions, - seed: [...agent.session.snapshotEvents()] }) - await send(resumed, 'resume cleared') - prompt = 'restored prompt' - await send(resumed, 'restore') - expect(http.requests.map(request => request.body.system)).toEqual(inHistory - ? ['first prompt', 'first prompt', 'first prompt', undefined, undefined, undefined] - : ['first prompt', 'second prompt', 'second prompt', undefined, undefined, 'restored prompt']) - for (const [index, request] of http.requests.entries()) { - const messages = request.body.messages as { role: string; content: unknown[] }[] - expect(messages.filter(message => message.role === 'assistant')).toHaveLength(index) - expect(messages.filter(message => message.role === 'system').map(message => message.content)).toEqual( - !inHistory ? [] : index === 1 || index === 2 ? [[{ type: 'text', text: 'second prompt' }]] - : index === 5 ? [[{ type: 'text', text: 'restored prompt' }]] : [], - ) - expect(JSON.stringify(messages.filter(message => message.role !== 'system'))).not.toMatch(/first prompt|second prompt|restored prompt/) - } - expect(resumed.session.requestContext()?.systemPromptUpdate).toBe(inHistory ? 'in-history' : undefined) - }) - - it.each([false, true])('continues and resumes Chat Completions sessions through Messages, in-history=%s', async (inHistory) => { - let messagesProtocol = false - const { ctx, http } = await boot(response => response.end(messagesProtocol ? sse(textEvents) : [ - 'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n', - 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n', - 'data: [DONE]\n\n', - ].join(''))) - await ctx.settings.update('llm-deepseek', { protocol: 'chat-completions', baseURL: http.url, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) - let prompt = 'old prompt' - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ - ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], - })) - const selection: ModelSelectionRef = { current: { provider: 'deepseek-official', model: MODEL }, assembled: undefined } - const agent = await ctx.agentLoop.create(SessionId('protocol-switch'), selection.current) - installModelSelection(agent.ctx, selection) - await send(agent, 'first') - prompt = 'current prompt' - await send(agent, 'second') - expect((http.requests[1]?.body.messages as { role: string }[]).filter(message => message.role === 'system')).toHaveLength(2) - const seed = [...agent.session.snapshotEvents()] - const saved = JSON.stringify(seed) - messagesProtocol = true - await ctx.settings.update(Messages.name, { protocol: 'messages', models: [{ id: MODEL, ...inHistory ? { systemPromptUpdate: 'in-history' } : {} }] }) - selection.current = { provider: 'deepseek-official', model: MODEL } - await send(agent, 'switch') - const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('switch-resume'), agentOptions: selection.current, seed }) - await send(resumed, 'resume') - for (const request of http.requests.slice(2)) { - expect(request.path).toBe('/anthropic/v1/messages') - expect(request.body.system).toBe(inHistory ? 'old prompt' : 'current prompt') - const messages = request.body.messages as { role: string }[] - expect(messages.filter(message => message.role === 'assistant')).toHaveLength(2) - expect(messages.filter(message => message.role === 'system')).toHaveLength(inHistory ? 1 : 0) - expect(JSON.stringify(messages.filter(message => message.role !== 'system'))).not.toMatch(/old prompt|current prompt/) - } - expect(JSON.stringify(seed)).toBe(saved) - expect(agent.session.deriveMessages().filter(message => message.role === 'system')).toHaveLength(inHistory ? 2 : 1) - expect(resumed.session.deriveMessages().filter(message => message.role === 'system')).toHaveLength(inHistory ? 2 : 1) - }) - - it('maps multiple system snapshots on direct compaction calls to the latest prompt', async () => { - const { ctx, http } = await boot() - const history = [createSystemMessage('old'), user(), - createAssistantMessage({ content: [{ type: 'text', text: 'OK' }], source: { provider: 'deepseek-official', model: MODEL } }), - createSystemMessage('current'), user('summarize')] - const saved = JSON.stringify(history) - const response = await assemble(ctx.llm.stream(options({ messages: history, purpose: 'compaction' }))) - expect(response.assembler.finish.kind).toBe('stop') - expect(http.requests[0]?.body.system).toBe('current') - expect((http.requests[0]?.body.messages as { role: string }[]).map(message => message.role)).toEqual(['user', 'assistant', 'user']) - expect(JSON.stringify(history)).toBe(saved) - }) - - it('continues a recorded tool turn with a warning when its native replay version is unknown', async () => { - const { ctx, http } = await boot() - const warnings: unknown[][] = [] - ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } }) - const fixture = await readFile(new URL('../../../../../snapshots/session/deepseek-messages-degraded-replay/session.v2.jsonl', import.meta.url), 'utf8') - const records = fixture.trim().split('\n').map(line => JSON.parse(line) as { type: string; data: { message?: Message } }) - const assistant = records.find(record => record.type === 'assistant/message')!.data.message! - if (assistant.source.kind === 'model') assistant.source.provider = 'deepseek-official' - // The released v2 row still wraps its tool result inside a user message. - const released = records.find(record => record.type === 'tool/result')!.data.message! as unknown as { - readonly content: readonly { - readonly type: string - readonly toolCallId: ToolCallId - readonly content: readonly ContentBlock[] - readonly isError?: boolean - }[] - } - const releasedBlock = released.content[0]! - const result = createToolResultMessage({ - callId: releasedBlock.toolCallId, content: [...releasedBlock.content], isError: releasedBlock.isError === true, - }) - const saved = JSON.stringify([assistant, result]) - const response = await assemble(ctx.llm.stream(options({ messages: [user(), assistant, result] }))) - expect(response.assembler.finish.kind).toBe('stop') - expect(warnings).toEqual([[`llm-deepseek: unusable Messages replay state on assistant history for route "deepseek-official/${MODEL}"; sending provider-neutral content (DeepSeek Messages replay: unsupported kind or version)`]]) - expect(http.requests).toHaveLength(1) - expect(http.requests[0]?.body.messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [ - { type: 'thinking', thinking: 'The user wants me to run a simple bash command and then reply with "DONE".' }, - { type: 'tool_use', id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', name: 'bash', input: { command: 'echo TERMINAL_OK', description: 'Echo TERMINAL_OK to verify terminal access' } }, - ] }, - { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', content: [{ type: 'text', text: 'TERMINAL_OK\n' }], is_error: false }] }, - ]) - expect(JSON.stringify([assistant, result])).toBe(saved) - }) - - it('loads one provider from YAML, rotates settings and credentials, then removes disposed registrations', async () => { - const { ctx, http } = await boot() - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek-official']) - expect((await assemble(ctx.llm.stream(options()))).assembler.finish.kind).toBe('stop') - expect(http.requests[0]?.headers['x-api-key']).toBe('stored-key') - const second = await endpoint() - await ctx.settings.update(Messages.name, { baseURL: second.url, maxTokens: 51, retryPolicy: { mode: 'always' } }) - await ctx.credentials.set(credentialRef('DEEPSEEK_API_KEY'), 'rotated') - await chunks(ctx.llm.stream(options())) - expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'rotated' }, body: { max_tokens: 51 } }) - await ctx.settings.update(Messages.name, { models: [{ id: 'duplicate' }, { id: 'duplicate' }], baseURL: http.url }) - await chunks(ctx.llm.stream(options())) - expect(second.requests).toHaveLength(2) - expect(http.requests).toHaveLength(1) - await ctx.settings.update(Messages.name, { models: [{ id: MODEL }], baseURL: http.url }) - await chunks(ctx.llm.stream(options())) - expect(http.requests).toHaveLength(2) - const llm = ctx.llm - await ctx.fiber.dispose() - expect(llm.listProviders()).toEqual([]) - expect(llm.listConfigurableProviders()).toEqual([]) - }) - - it('uses environment credentials and reports missing or malformed keys without network access', async () => { - const http = await endpoint() - const { ctx } = await context() - vi.stubEnv('DEEPSEEK_BASE_URL', http.url) - vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') - await ctx.plugin(LlmRuntime) - const fiber = ctx.plugin(Messages) - await fiber - await chunks(ctx.llm.stream(options())) - expect(http.requests[0]?.headers['x-api-key']).toBe('env-key') - vi.stubEnv('DEEPSEEK_API_KEY', '') - expect((await assemble(ctx.llm.stream(options()))).assembler.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) - vi.stubEnv('DEEPSEEK_API_KEY', 'bad\nkey') - expect((await assemble(ctx.llm.stream(options()))).assembler.finish).toMatchObject({ kind: 'error', failure: { code: 'INVALID_CREDENTIAL' } }) - await fiber.dispose() - expect(ctx.llm.listProviders()).toEqual([]) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/red.png b/packages/llm/llm-deepseek/tests/messages/fixtures/red.png deleted file mode 100644 index 62a5f8f47fec02344e5bf9061888262f677cf5d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; diff --git a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts b/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts deleted file mode 100644 index dbb1424056..0000000000 --- a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts +++ /dev/null @@ -1,454 +0,0 @@ -/** Request conversion and durable replay validation. */ -import { describe, expect, it, vi } from 'vitest' -import { createDeveloperMessage, createUserMessage, createAssistantMessage, createMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, ImageBlock, Message, RequestMessage, RequestUserInput } from '@deepseek-ai/dsh-llm' -import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { resolveAdapterOptions } from '../../src/config.ts' -import { modelInfo } from '../../src/common/model-info.ts' -import type { Config } from '../../src/config.ts' -import { imagePricing, inlineImages, prepareImages } from '../../src/protocols/messages/images.ts' -import { readReplay, replayState } from '../../src/protocols/messages/replay.ts' -import { serialize } from '../../src/protocols/messages/serialize.ts' -import { MODEL, options, user } from './helpers.ts' - -const connection = resolveAdapterOptions({}) -const call = (id = 'a'): ContentBlock => ({ type: 'tool-call', id: ToolCallId(id), name: 'read', arguments: '{"path":"a"}' }) -const assistant = (content: ContentBlock[]) => createAssistantMessage({ content, source: { provider: 'deepseek-official', model: MODEL } }) -const result = (id = 'a', content: ContentBlock[] = [{ type: 'text', text: 'result' }]) => createToolResultMessage({ callId: ToolCallId(id), content, isError: false }) -const body = (messages: RequestMessage[] = [user()], overrides: Partial = {}) => serialize( - options({ messages, ...overrides }), connection, messages, new Map(), () => undefined, -) -const capable = resolveAdapterOptions({ models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) -const nativeBody = (messages: Message[]) => serialize(options({ messages }), capable, messages, new Map(), () => undefined) - -describe('Messages request conversion', () => { - it('rejects unknown plugin content without interpreting its payload', () => { - expect(() => body([createUserMessage({ source: { kind: 'user' }, content: [ - { type: 'plugin:text', text: 'opaque' } as never, - ] })])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - }) - it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => { - for (const type of ['tool-addition', 'tool-removal'] as const) { - const message = { id: 'invalid', role, source: { kind: 'test' }, content: [{ type, toolName: 'search' }] } as unknown as Message - expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - } - }) - - it('rejects deferred tool definitions until provider loading is implemented', () => { - expect(() => body([], { tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] })) - .toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - }) - - it('preserves the exact request with request-only text after durable tool results', () => { - const prefix = [user(), assistant([call()]), result()] - const input: RequestUserInput = { role: 'user', content: [{ type: 'text', text: 'review or summarize this input' }] } - const durable = createUserMessage({ content: input.content, source: { kind: 'user' } }) - expect(body([...prefix, input], { system: 'policy' })).toEqual(body([...prefix, durable], { system: 'policy' })) - }) - - it('preserves request-only image content through image preparation and wire conversion', async () => { - const attachment: ImageAttachmentRef = { - attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), mediaType: 'image/png', bytes: 3, width: 1, height: 1, - } - const version: RequestImageAttachment = { - variantId: ImageVariantId(`sha256:${'f'.repeat(64)}`), attachment, data: Uint8Array.of(1, 2, 3), - mediaType: 'image/png', bytes: 3, width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: true, - } - const input: RequestUserInput = { role: 'user', content: [ - { type: 'text', text: 'before' }, { type: 'image', attachment }, { type: 'text', text: 'after' }, - ] } - const store = { readImageRequest: async () => version } as unknown as AttachmentStore - const vision = resolveAdapterOptions({ models: [{ id: MODEL, inputModalities: ['text', 'image'] }] }) - const durable = createUserMessage({ content: input.content, source: { kind: 'user' } }) - const actual = await prepareImages([input], vision, MODEL, store, () => undefined, new AbortController().signal) - const expected = await prepareImages([durable], vision, MODEL, store, () => undefined, new AbortController().signal) - expect(serialize(options({ messages: [input] }), vision, actual.messages, actual.versions, () => undefined)) - .toEqual(serialize(options({ messages: [durable] }), vision, expected.messages, expected.versions, () => undefined)) - expect(actual.messages[0]).toBe(input) - expect(input).not.toHaveProperty('id') - expect(input).not.toHaveProperty('source') - }) - - it('rejects developer history while provider serialization is unsupported', () => { - const message = createDeveloperMessage({ content: [{ type: 'tool-addition', toolName: 'search' }], source: { kind: 'tool-registry' } }) - expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - }) - - it('keeps the original top-level prompt and cached prefix while appending native system updates', () => { - const head = createSystemMessage('original') - const first = [head, user('first')] - const update = createSystemMessage('updated') - const second = [...first, assistant([{ type: 'text', text: 'one' }]), update, user('second')] - const saved = JSON.stringify(second) - const before = nativeBody(first) - const after = nativeBody(second) - expect(after.system).toBe(before.system) - expect(after.messages.slice(0, before.messages.length)).toEqual(before.messages) - expect(after.messages.slice(-2)).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'second' }] }, - { role: 'system', content: [{ type: 'text', text: 'updated' }] }, - ]) - const third = nativeBody([...second, assistant([{ type: 'text', text: 'two' }]), user('third')]) - expect(third.messages.slice(0, after.messages.length)).toEqual(after.messages) - expect(JSON.stringify(second)).toBe(saved) - }) - - it('places system updates after all parallel tool results and before the next assistant', () => { - const history = [createSystemMessage('original'), user(), assistant([call(), call('b')]), - createSystemMessage('first update'), result(), createSystemMessage('second update'), result('b'), - user('more input'), assistant([{ type: 'text', text: 'done' }])] - const request = nativeBody(history) - expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'system', 'assistant']) - expect(request.messages[2]?.content.map(block => block.type)).toEqual(['tool_result', 'tool_result', 'text']) - expect(request.messages.slice(3, 5).map(message => message.content)).toEqual([ - [{ type: 'text', text: 'first update' }], [{ type: 'text', text: 'second update' }], - ]) - }) - - it('accepts native trailing updates without a top-level prompt and rejects unrepresentable positions', () => { - const update = createSystemMessage('update') - expect(nativeBody([user(), update])).toMatchObject({ messages: [ - { role: 'user' }, { role: 'system', content: [{ type: 'text', text: 'update' }] }, - ] }) - expect(nativeBody([user(), update]).system).toBeUndefined() - expect(() => nativeBody([user(), assistant([{ type: 'text', text: 'done' }]), update])).toThrow(/preceding user/) - expect(() => nativeBody([user(), createSystemMessage('')])).toThrow(/empty in-history/) - expect(() => nativeBody([user(), assistant([call(), call('b')]), update, result()])).toThrow(/immediate results/) - }) - - it.each([[], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')]] satisfies ContentBlock[][])( - 'rejects native system updates when their user input is omitted %#', (...content) => { - const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) - const history = [user(), assistant([{ type: 'text', text: 'answer' }]), createSystemMessage('updated'), empty] - const saved = JSON.stringify(history) - for (const messages of [history, [...history, assistant([{ type: 'text', text: 'next answer' }])]]) { - expect(() => nativeBody(messages)).toThrow(expect.objectContaining({ - code: 'UNSUPPORTED_CONTENT', - message: 'DeepSeek Messages cannot represent system update without a preceding user or tool-result turn', - })) - } - expect(JSON.stringify(history)).toBe(saved) - }, - ) - - it('keeps native system updates after retained text or tool results beside omitted user input', () => { - const reasoning: ContentBlock = { type: 'reasoning', text: 'child reasoning' } - const empty = createMessage({ role: 'user', source: { kind: 'user' }, content: [reasoning] }) - const update = createSystemMessage('updated') - for (const [previous, retained, expected] of [ - [assistant([{ type: 'text', text: 'answer' }]), user('continue'), [{ type: 'text', text: 'continue' }]], - [assistant([call()]), result('a', [reasoning]), [{ type: 'tool_result', tool_use_id: 'a', content: [], is_error: false }]], - ] as const) { - const history = [user(), previous, update, empty, retained, assistant([{ type: 'text', text: 'done' }])] - const saved = JSON.stringify(history) - const request = nativeBody(history) - expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'assistant']) - expect(request.messages[2]?.content).toEqual(expected) - expect(request.messages[3]?.content).toEqual([{ type: 'text', text: 'updated' }]) - expect(JSON.stringify(history)).toBe(saved) - } - }) - - it('groups parallel results before ordinary text and keeps tool failure content', () => { - const messages = [user(), assistant([call(), call('b')]), user('follow-up'), result(), createToolResultMessage({ callId: ToolCallId('b'), content: [{ type: 'text', text: 'permission denied' }], isError: true })] - expect(body(messages).messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: ['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })) }, - { role: 'user', content: [ - { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'result' }], is_error: false }, - { type: 'tool_result', tool_use_id: 'b', content: [{ type: 'text', text: 'permission denied' }], is_error: true }, - { type: 'text', text: 'follow-up' }, - ] }, - ]) - expect(messages[2]?.content).toEqual([{ type: 'text', text: 'follow-up' }]) - }) - - it('preserves empty results without inventing model-visible output', () => { - const response = body([user(), assistant([call()]), result('a', [])]) - expect(response.messages[2]?.content[0]).toMatchObject({ content: [] }) - const { isError: _isError, ...minimal } = createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: '' }], isError: false }) - expect(body([assistant([call()]), minimal]).messages[1]?.content[0]).toEqual({ type: 'tool_result', tool_use_id: 'a', content: [] }) - }) - - it('omits assistant-only blocks from user input while preserving text and durable content', () => { - const history = [createMessage({ role: 'user', source: { kind: 'user' }, content: [ - { type: 'text', text: 'Background subagent finished.\n' }, - { type: 'reasoning', text: 'child reasoning' }, - call('child-call'), - { type: 'text', text: ' child answer ' }, - ] })] - const saved = JSON.stringify(history) - - expect(body(history).messages).toEqual([{ role: 'user', content: [ - { type: 'text', text: 'Background subagent finished.\n' }, - { type: 'text', text: ' child answer ' }, - ] }]) - expect(JSON.stringify(history)).toBe(saved) - }) - - it('omits assistant-only blocks inside tool results while retaining calls, errors and empty results', () => { - const reasoning: ContentBlock = { type: 'reasoning', text: 'tool reasoning' } - const history = [assistant([reasoning, call(), call('b')]), - createToolResultMessage({ callId: ToolCallId('a'), content: [reasoning, call('nested-call'), { type: 'text', text: ' result\n' }], isError: true }), - result('b', [reasoning, call('another-nested-call')]), - ] - const saved = JSON.stringify(history) - - expect(body(history).messages).toEqual([ - { role: 'assistant', content: [ - { type: 'thinking', thinking: 'tool reasoning' }, - ...['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })), - ] }, - { role: 'user', content: [ - { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: ' result\n' }], is_error: true }, - { type: 'tool_result', tool_use_id: 'b', content: [], is_error: false }, - ] }, - ]) - expect(JSON.stringify(history)).toBe(saved) - }) - - it.each([ - [], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')], - ] satisfies ContentBlock[][])('omits empty user input after conversion %#', (...content) => { - const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) - expect(body([empty, user(), assistant([{ type: 'text', text: 'answer' }]), empty]).messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'answer' }] }, - ]) - }) - - it('collects leading system text and maps tools, stop sequences and explicit output cap', () => { - const system = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'text', text: 'instructions' }] }) - expect(body([system, user()], { system: 'top', maxTokens: 123, stop: ['END'], tools: [{ name: 'read', description: 'Read a file', parameters: { type: 'object' } }] })).toMatchObject({ - system: 'top\n\ninstructions', max_tokens: 123, stop_sequences: ['END'], tools: [{ name: 'read', description: 'Read a file', input_schema: { type: 'object' } }], - }) - expect(body([user(), system]).system).toBe('instructions') - }) - - it('uses the latest complete system snapshot without changing tool history or durable messages', () => { - const conversation = [user(), assistant([call()]), result(), assistant([{ type: 'text', text: 'done' }]), user('continue')] - const history = [createSystemMessage('obsolete'), ...conversation.slice(0, 2), - createSystemMessage('intermediate'), ...conversation.slice(2, 4), - createSystemMessage('current'), conversation[4]!] - const saved = JSON.stringify(history) - expect(body(history)).toEqual({ ...body(conversation), system: 'current' }) - expect(body(history, { system: 'one-shot prefix' }).system).toBe('one-shot prefix\n\ncurrent') - expect(JSON.stringify(history)).toBe(saved) - }) - - it('replaces adjacent system snapshots and joins blocks only within the current snapshot', () => { - const latest = createMessage({ role: 'system', source: { kind: 'system-prompt' }, - content: [{ type: 'text', text: 'part one' }, { type: 'text', text: ' and part two' }] }) - expect(body([createSystemMessage('old'), latest, user()]).system).toBe('part one and part two') - }) - - it.each([[], [{ type: 'text' as const, text: '' }]].map(content => ({ content })))('clears earlier prompt snapshots with empty content %#', ({ content }) => { - const cleared = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content }) - const history = [createSystemMessage('old'), user(), cleared] - expect(body(history).system).toBeUndefined() - expect(body(history, { system: 'one-shot prefix' }).system).toBe('one-shot prefix') - expect(body(history, { system: '' }).system).toBeUndefined() - }) - - it('rejects non-text system content even when a later snapshot supersedes it', () => { - const invalid = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'reasoning', text: 'bad' }] }) - expect(() => body([invalid, user(), createSystemMessage('current')])).toThrow(/non-text system/) - }) - - it.each(['off', 'low', 'high', 'max'])('maps reasoning effort %s', (effort) => { - const request = body([user()], { reasoningEffort: ReasoningEffortId(effort) }) - expect(request.thinking.type).toBe(effort === 'off' ? 'disabled' : 'enabled') - expect(request.output_config).toEqual(effort === 'off' ? undefined : { effort }) - }) - - it('disables thinking for titles, passes temperature with thinking and refuses unsupported effort', () => { - expect(body([user()], { purpose: 'session-title', temperature: 0 })).toMatchObject({ thinking: { type: 'disabled' }, temperature: 0 }) - expect(body([user()], { temperature: 0 })).toMatchObject({ thinking: { type: 'enabled' }, temperature: 0 }) - expect(() => body([user()], { reasoningEffort: ReasoningEffortId('medium') })).toThrow(/effort/) - const disabled = resolveAdapterOptions({ thinking: 'disabled' }) - expect(serialize(options(), disabled, [user()], new Map(), () => undefined).thinking).toEqual({ type: 'disabled' }) - expect(() => serialize(options({ reasoningEffort: ReasoningEffortId('high') }), disabled, [user()], new Map(), () => undefined)).toThrow(/effort/) - const capped = resolveAdapterOptions({ models: [{ id: MODEL, maxTokens: 321 }] }) - expect(serialize(options(), capped, [user()], new Map(), () => undefined).max_tokens).toBe(321) - }) - - it.each([ - [result()], [assistant([call()])], [assistant([call()]), user()], - [assistant([call(), call()]), result()], - [assistant([call()]), result(), result()], - ])('rejects unmatched or duplicated tool history %#', (...messages) => { - expect(() => body(messages)).toThrow(/tool/) - }) - - it.each(['{', '', '[]', 'null', '42', 'true', '"text"', '{"description":"最快,但"某个说法"没有证据。"}'])('uses empty input for malformed or non-object historical tool arguments %s', (arguments_) => { - const message = assistant([{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: arguments_ }]) - const history = [user(), message, createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: 'Invalid arguments' }], isError: true }), user('Continue')] - const saved = JSON.stringify(history) - const restored = JSON.parse(saved) as Message[] - expect(body(restored).messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [{ type: 'tool_use', id: 'a', name: 'read', input: {} }] }, - { role: 'user', content: [ - { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'Invalid arguments' }], is_error: true }, - { type: 'text', text: 'Continue' }, - ] }, - ]) - expect(JSON.stringify(restored)).toBe(saved) - }) - - it('preserves own signed thinking, omits absent signatures and validates durable metadata', () => { - const content: ContentBlock[] = [{ type: 'reasoning', text: '' }, { type: 'text', text: 'answer' }] - const source = { provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: 'signed' }, { type: 'text' }]) } - const message = createAssistantMessage({ content, source }) - expect(body([user(), message, user()]).messages[1]?.content).toEqual([{ type: 'thinking', thinking: '', signature: 'signed' }, { type: 'text', text: 'answer' }]) - expect(body([assistant([{ type: 'reasoning', text: 'foreign thought' }])]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'foreign thought' }]) - expect(readReplay(message, 'different-model')).toBeUndefined() - expect(readReplay(user(), MODEL)).toBeUndefined() - }) - - it.each([ - null, - [], - { response: null, blocks: [] }, - { response: { kind: 'other', version: 1 }, blocks: [] }, - { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] }, - { response: { kind: 'deepseek-messages', version: 1, model: 'wrong' }, blocks: [] }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [] }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: null }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [null] }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'tool-call' }] }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'reasoning', signature: 3 }] }, - ].map(state => ({ state })))('degrades unusable replay state with a diagnostic %#', ({ state }) => { - const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { provider: 'deepseek-official', model: MODEL, replayState: state } }) - const onDegrade = vi.fn() - expect(readReplay(message, MODEL, onDegrade)).toBeUndefined() - expect(onDegrade).toHaveBeenCalledExactlyOnceWith(expect.any(String)) - expect(body([message]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'think' }]) - }) - - it.each([MODEL, 'different-model'])('keeps durable content when replay degrades for %s', async (model) => { - const message = createAssistantMessage({ - content: [{ type: 'reasoning', text: 'Read the file.' }, { type: 'text', text: 'Checking a.' }, call()], - source: { provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [ - { type: 'reasoning', signature: 'do-not-send' }, { type: 'text', signature: 'invalid-for-text' }, { type: 'tool-call' }, - ]) }, - }) - const saved = JSON.stringify(message) - const restored = JSON.parse(saved) as Message - const messages = [user(), restored, result()] - const onDegrade = vi.fn() - const request = serialize(options({ model }), connection, messages, new Map(), () => undefined, onDegrade) - expect(onDegrade).toHaveBeenCalledExactlyOnceWith('DeepSeek Messages replay: invalid signature') - await expect(JSON.stringify(request.messages, null, 2) + '\n').toMatchFileSnapshot('expected/degraded-replay.json') - expect(JSON.stringify(restored)).toBe(saved) - }) - - it('keeps valid cross-model and foreign history quiet and propagates diagnostic failures', () => { - const onDegrade = vi.fn() - const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { - provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: '' }]), - } }) - expect(readReplay(message, MODEL, onDegrade)).toEqual([{ type: 'reasoning', signature: '' }]) - expect(readReplay(message, 'different-model', onDegrade)).toBeUndefined() - expect(readReplay(assistant([{ type: 'text', text: 'foreign' }]), MODEL, onDegrade)).toBeUndefined() - expect(onDegrade).not.toHaveBeenCalled() - const damaged = { ...message, source: { ...message.source, replayState: { response: {}, blocks: [] } } } - const failure = new Error('diagnostic failed') - expect(() => readReplay(damaged, MODEL, () => { throw failure })).toThrow(failure) - }) - - it.each([1, 2])('uses empty historical tool input with replay version %s', (version) => { - const message = createAssistantMessage({ content: [{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: '{' }], source: { - provider: 'deepseek-official', model: MODEL, replayState: { response: { kind: 'deepseek-messages', version, model: MODEL }, blocks: [{ type: 'tool-call' }] }, - } }) - const saved = JSON.stringify(message) - const onDegrade = vi.fn() - const request = serialize(options(), connection, [message, result()], new Map(), () => undefined, onDegrade) - expect(request.messages[0]?.content).toEqual([{ type: 'tool_use', id: 'a', name: 'read', input: {} }]) - expect(onDegrade).toHaveBeenCalledTimes(version === 1 ? 0 : 1) - expect(JSON.stringify(message)).toBe(saved) - }) -}) - -describe('validated configuration', () => { - it('advertises exact model metadata and allows unlisted text models', () => { - expect(modelInfo(connection, 'deepseek-official', MODEL)).toMatchObject({ context: { contextWindow: 1_000_000 }, defaultMaxTokens: 256_000, reasoning: { defaultEffort: 'high' } }) - expect(modelInfo(connection, 'deepseek-official', 'custom').inputModalities).toEqual(['text']) - expect(modelInfo(connection, 'deepseek-official', MODEL).systemPromptUpdate).toBeUndefined() - expect(modelInfo(connection, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined() - expect(modelInfo(capable, 'deepseek-official', MODEL).systemPromptUpdate).toBe('in-history') - expect(modelInfo(capable, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined() - expect(modelInfo(resolveAdapterOptions({ thinking: 'disabled' }), 'deepseek-official', MODEL).reasoning?.efforts).toMatchObject([{ id: 'off', name: 'Off' }]) - expect(resolveAdapterOptions({ baseURL: 'https://example.com/anthropic///' }).baseURL).toBe('https://example.com/anthropic///') - }) - it.each([ - { thinking: 'disabled', reasoningEffort: 'high' }, { models: [{ id: '' }] }, - { models: [{ id: 'a' }, { id: 'a' }] }, { models: [{ id: 'a', name: '' }] }, - { maxInlineRequestImageBytes: 1 }, { maxImagesPerRequest: 1 }, - { baseURL: 'ftp://example.com' }, { baseURL: 'https://user:pass@example.com' }, - { baseURL: 'https://example.com/?key=x' }, { baseURL: 'https://example.com/#x' }, - { maxTokens: 0 }, { streamIdleTimeoutMs: 0 }, - { models: [{ id: MODEL, systemPromptUpdate: 'unsupported' }] }, - ])('rejects invalid composition input %#', (value) => { - expect(() => resolveAdapterOptions(value as Config)).toThrow() - }) -}) - -describe('Messages images', () => { - const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), mediaType: 'image/png', width: 1, height: 1, bytes: 3 } - const image: ImageBlock = { type: 'image', attachment: ref } - const version: RequestImageAttachment = { attachment: ref, variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), mediaType: 'image/png', bytes: 3, data: Uint8Array.of(1, 2, 3), width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: false } - const access = () => ({ readonlyPath: '/workspace/image.png' }) - const model = 'deepseek-flash' - // Only the read operation is consumed by image preparation; the transport is mocked, not durable content. - const attachments = { readImageRequest: async () => version } as unknown as AttachmentStore - const signal = new AbortController().signal - it('keeps image bytes inside tool results and deduplicates normalization', async () => { - const history = [assistant([call()]), result('a', [image, image])] - const prepared = await prepareImages(history, connection, model, attachments, access, signal) - expect(prepared.versions.size).toBe(1) - const request = serialize(options({ model }), connection, prepared.messages, prepared.versions, access) - expect(request.messages[1]?.content[0]).toMatchObject({ type: 'tool_result', content: [ - { type: 'text', text: expect.stringContaining('/workspace/image.png') as string }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AQID' } }, - { type: 'text' }, { type: 'image' }, - ] }) - expect(imagePricing(connection, model, access).priceImages([image])[0]?.visualTokens).toBeGreaterThan(0) - expect(imagePricing(connection, MODEL, access).priceImages([image])[0]?.visualTokens).toBe(0) - }) - it('requires logged offload at exact encoded bytes and preserves durable references', async () => { - const config = resolveAdapterOptions({ - maxInlineRequestImageBytes: 4, inlineImageOffloadByteQuantum: 1, maxImagesPerRequest: 2, imageOffloadCountQuantum: 1, - }) - const history = [result('a', [image, image])] - const prepared = await prepareImages(history, config, model, attachments, access, signal) - expect(prepared.messages[0]?.content).toMatchObject([image, image]) - expect(() => inlineImages(prepared.messages, prepared.versions, config)).toThrow(expect.objectContaining({ - failure: expect.objectContaining({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) as unknown, - })) - const offloaded: ImageBlock = { ...image, offloaded: true } - const retry = await prepareImages([result('a', [offloaded, image])], config, model, attachments, access, signal) - expect(inlineImages(retry.messages, retry.versions, config)[0]?.content).toMatchObject([{ type: 'text' }, { type: 'image' }]) - expect(history[0]?.content).toMatchObject([image, image]) - expect(imagePricing(config, model, access).priceImages([image, image]).map(entry => entry.visualTokens)) - .toEqual([expect.any(Number), expect.any(Number)]) - const large = { readImageRequest: async () => ({ ...version, bytes: 30, data: new Uint8Array(30) }) } as unknown as AttachmentStore - const exact = await prepareImages([result('a', [image])], config, model, large, access, signal) - expect(() => inlineImages(exact.messages, exact.versions, config)).toThrow(expect.objectContaining({ - failure: expect.objectContaining({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) as unknown, - })) - }) - it('rejects unsupported roles and unavailable image capabilities before HTTP', async () => { - const history = [result('a', [image])] - await expect(prepareImages(history, connection, MODEL, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - await expect(prepareImages(history, connection, model, undefined, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - await expect(prepareImages([assistant([image])], connection, model, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(() => body([result('a', [image])])).toThrow(/image/) - expect(() => body([assistant([image])])).toThrow(/assistant/) - // @ts-expect-error -- malformed provider input can carry a retired nested result block. - expect(() => body([result('a', [{ type: 'tool-result', toolCallId: ToolCallId('nested'), content: [] }])])) - .toThrow(/user\/tool-result content tool-result/) - expect(() => serialize(options({ model }), connection, [result('a', [image])], new Map([[ref.attachmentId, version]]), access, undefined, new Map())) - .toThrow(/request file id is missing/) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/mock-server.ts b/packages/llm/llm-deepseek/tests/mock-server.ts index 819a945e93..45402bdc1b 100644 --- a/packages/llm/llm-deepseek/tests/mock-server.ts +++ b/packages/llm/llm-deepseek/tests/mock-server.ts @@ -13,7 +13,7 @@ export interface MockServer { requests: unknown[] /** Header bags of received requests, in order (parallel to `requests`). */ headers: IncomingMessage['headers'][] - /** Parsed Files API operations, excluded from chat request ordering. */ + /** Parsed Files API operations, excluded from model request ordering. */ fileRequests: Array<{ method: string; path: string; filename?: string; bytes?: number }> script: Behavior[] close(): Promise @@ -28,18 +28,20 @@ export async function closeMockServers(): Promise { /** A minimal complete text generation, reused by request-shape assertions. */ export const textEvents = [ - '{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', - '{"choices":[{"delta":{"content":"hello"}}]}', - '{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - '[DONE]', -] + { type: 'message_start', message: { id: 'msg_1', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hello' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, +].map(event => JSON.stringify(event)) -/** Local chat-completions stand-in: replays scripted behaviors per request. */ +/** Local Messages stand-in: replays scripted behaviors per request. */ export async function mockServer(script: Behavior[]): Promise { const requests: unknown[] = [] const headers: IncomingMessage['headers'][] = [] const fileRequests: MockServer['fileRequests'] = [] - const files = new Map() + const files = new Map() let nextFile = 1 const server = createServer((request: IncomingMessage, response: ServerResponse) => { const chunks: Buffer[] = [] @@ -48,12 +50,12 @@ export async function mockServer(script: Behavior[]): Promise { void (async () => { const url = new URL(request.url ?? '/', 'http://localhost') const body = Buffer.concat(chunks) - if (url.pathname === '/files' && request.method === 'POST') { + if (url.pathname === '/v1/files' && request.method === 'POST') { const headers = new Headers() for (const [name, value] of Object.entries(request.headers)) { if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value) } - const form = await new Request('http://localhost/files', { + const form = await new Request('http://localhost/v1/files', { method: 'POST', headers, body, @@ -62,28 +64,25 @@ export async function mockServer(script: Behavior[]): Promise { if (!(blob instanceof Blob)) throw new Error('mock upload omitted file') const name = 'name' in blob && typeof blob.name === 'string' ? blob.name : 'uploaded_file' const id = `file-api-${nextFile}` - const createdAt = Math.floor(Date.now() / 1_000) + const createdAt = new Date().toISOString() nextFile += 1 - const expiresSeconds = Number(form.get('expires_after[seconds]')) const file = { id, - object: 'file' as const, - bytes: blob.size, + type: 'file' as const, + size_bytes: blob.size, created_at: createdAt, filename: name, - purpose: 'user_data' as const, - expires_at: createdAt + expiresSeconds, + mime_type: blob.type, } files.set(id, file) fileRequests.push({ method: 'POST', path: url.pathname, filename: name, bytes: blob.size }) response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(file)) return } - if (url.pathname === '/files' && request.method === 'GET') { + if (url.pathname === '/v1/files' && request.method === 'GET') { fileRequests.push({ method: 'GET', path: `${url.pathname}${url.search}` }) const data = [...files.values()] response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ - object: 'list', data, first_id: data[0]?.id, last_id: data.at(-1)?.id, @@ -91,17 +90,17 @@ export async function mockServer(script: Behavior[]): Promise { })) return } - if (url.pathname.startsWith('/files/') && request.method === 'DELETE') { - const id = decodeURIComponent(url.pathname.slice('/files/'.length)) + if (url.pathname.startsWith('/v1/files/') && request.method === 'DELETE') { + const id = decodeURIComponent(url.pathname.slice('/v1/files/'.length)) files.delete(id) fileRequests.push({ method: 'DELETE', path: url.pathname }) response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ - id, object: 'file', deleted: true, + id, type: 'file_deleted', })) return } - if (url.pathname.startsWith('/files/') && request.method === 'GET') { - const id = decodeURIComponent(url.pathname.slice('/files/'.length)) + if (url.pathname.startsWith('/v1/files/') && request.method === 'GET') { + const id = decodeURIComponent(url.pathname.slice('/v1/files/'.length)) fileRequests.push({ method: 'GET', path: url.pathname }) const file = files.get(id) if (file === undefined) { diff --git a/packages/llm/llm-deepseek/tests/prepared-call.spec.ts b/packages/llm/llm-deepseek/tests/prepared-call.spec.ts new file mode 100644 index 0000000000..68e38db8ee --- /dev/null +++ b/packages/llm/llm-deepseek/tests/prepared-call.spec.ts @@ -0,0 +1,58 @@ +/** Prepared calls retain their endpoint and credential generation. */ +import { afterEach, expect, it } from 'vitest' +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import { Config, DeepSeekAdapter, resolveAdapterOptions } from '../src/index.ts' +import type { DeepSeekConnectionOptions } from '../src/index.ts' +import { assemble, chunks, MODEL, options, server } from './helpers.ts' + +const close: (() => Promise)[] = [] +afterEach(async () => { + while (close.length) await close.pop()!() +}) +async function endpoint(...args: Parameters) { + const instance = await server(...args) + close.push(() => instance.close()) + return instance +} +function adapter(connection: () => DeepSeekConnectionOptions) { + return new DeepSeekAdapter({ + options: connection, + resolveApiKey: snapshot => Promise.resolve(`key-for-${snapshot.apiKeyEnv}`), + resolveUserId: () => '00000000-0000-4000-8000-000000000001' as AnonymousUserId, + prepareExtensions: () => Promise.resolve({ fields: {}, accept: () => Promise.resolve() }), + }) +} + +it.each([false, true])('uses Messages, schema=%s', async (schema) => { + const http = await endpoint() + const raw = { baseURL: http.url } + const connection = resolveAdapterOptions(schema ? Config(raw) : raw) + const response = await assemble(adapter(() => connection).stream(options())) + + expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) + expect(http.requests).toHaveLength(1) + expect(http.requests[0]).toMatchObject({ + path: '/anthropic/v1/messages', + headers: { 'x-api-key': 'key-for-DEEPSEEK_API_KEY', 'anthropic-version': '2023-06-01' }, + body: { model: MODEL, messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }] }, + }) +}) + +it('keeps the prepared credential reference and endpoint after configuration changes', async () => { + const first = await endpoint(), second = await endpoint() + let connection = resolveAdapterOptions({ baseURL: first.url, apiKeyEnv: 'FIRST_KEY', maxTokens: 12 }) + const llm = adapter(() => connection) + const prepared = await llm.prepareCall('deepseek-official', MODEL) + connection = resolveAdapterOptions({ baseURL: second.url, apiKeyEnv: 'SECOND_KEY', maxTokens: 24 }) + await chunks(prepared.stream(options())) + await chunks(prepared.stream(options())) + expect(prepared.model.defaultMaxTokens).toBe(12) + expect((await llm.resolveModel('deepseek-official', MODEL)).defaultMaxTokens).toBe(24) + await chunks(llm.stream(options())) + expect(first.requests).toHaveLength(2) + for (const request of first.requests) expect(request).toMatchObject({ + path: '/anthropic/v1/messages', headers: { 'x-api-key': 'key-for-FIRST_KEY' }, body: { max_tokens: 12 }, + }) + expect(second.requests).toHaveLength(1) + expect(second.requests[0]).toMatchObject({ path: '/anthropic/v1/messages', headers: { 'x-api-key': 'key-for-SECOND_KEY' } }) +}) diff --git a/packages/llm/llm-deepseek/tests/protocol.spec.ts b/packages/llm/llm-deepseek/tests/protocol.spec.ts deleted file mode 100644 index 788df7982f..0000000000 --- a/packages/llm/llm-deepseek/tests/protocol.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** Same-provider protocol changes retain prepared requests and durable conversation content. */ -import { afterEach, expect, it } from 'vitest' -import type { Message } from '@deepseek-ai/dsh-llm' -import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { Config, DeepSeekAdapter, resolveAdapterOptions } from '../src/index.ts' -import type { DeepSeekConnectionOptions } from '../src/index.ts' -import { assemble, chunks, end, MODEL, options, server, sse, start, textEvents, user } from './messages/helpers.ts' - -const close: (() => Promise)[] = [] -afterEach(async () => { - while (close.length) await close.pop()!() -}) -async function endpoint(...args: Parameters) { - const instance = await server(...args) - close.push(() => instance.close()) - return instance -} -const chat = 'data: {"choices":[{"delta":{"content":"Chat answer"}}]}\n\n' - + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n' -function adapter(connection: () => DeepSeekConnectionOptions) { - return new DeepSeekAdapter({ - options: connection, - resolveApiKey: snapshot => Promise.resolve(`key-for-${snapshot.apiKeyEnv}`), - resolveUserId: () => '00000000-0000-4000-8000-000000000001' as AnonymousUserId, - prepareExtensions: () => Promise.resolve({ fields: {}, accept: () => Promise.resolve() }), - }) -} - -it.each([false, true])('uses Messages when protocol is omitted, schema=%s', async (schema) => { - const http = await endpoint() - const raw = { baseURL: http.url } - const connection = resolveAdapterOptions(schema ? Config(raw) : raw) - const response = await assemble(adapter(() => connection).stream(options())) - - expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) - expect(http.requests).toHaveLength(1) - expect(http.requests[0]).toMatchObject({ - path: '/anthropic/v1/messages', - headers: { 'x-api-key': 'key-for-DEEPSEEK_API_KEY', 'anthropic-version': '2023-06-01' }, - body: { model: MODEL, messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }] }, - }) -}) - -it('keeps the prepared Messages protocol, credential reference and endpoint after switching to Chat', async () => { - const first = await endpoint(), second = await endpoint(response => response.end(chat)) - let connection = resolveAdapterOptions({ protocol: 'messages', baseURL: first.url, apiKeyEnv: 'MESSAGES_KEY', maxTokens: 12 }) - const llm = adapter(() => connection) - const prepared = await llm.prepareCall('deepseek-official', MODEL) - connection = resolveAdapterOptions({ protocol: 'chat-completions', baseURL: second.url, apiKeyEnv: 'CHAT_KEY', maxTokens: 24 }) - await chunks(prepared.stream(options())) - await chunks(prepared.stream(options())) - expect(prepared.model.defaultMaxTokens).toBe(12) - expect((await llm.resolveModel('deepseek-official', MODEL)).defaultMaxTokens).toBe(24) - await chunks(llm.stream(options())) - expect(first.requests).toHaveLength(2) - for (const request of first.requests) expect(request).toMatchObject({ - path: '/anthropic/v1/messages', headers: { 'x-api-key': 'key-for-MESSAGES_KEY' }, body: { max_tokens: 12 }, - }) - expect(second.requests).toHaveLength(1) - expect(second.requests[0]).toMatchObject({ path: '/anthropic/chat/completions', headers: { authorization: 'Bearer key-for-CHAT_KEY' } }) -}) - -it('continues Messages → Chat → Messages with the same provider and without leaking native signatures to Chat', async () => { - const signed = [start, - { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: 'Reasoning', signature: 'native-signature' } }, - { type: 'content_block_stop', index: 0 }, - { type: 'content_block_start', index: 1, content_block: { type: 'text', text: 'Messages answer' } }, - { type: 'content_block_stop', index: 1 }, ...end(), - ] - const http = await endpoint((response, count) => response.end(count === 1 ? sse(signed) : count === 2 ? chat : sse(textEvents))) - let connection = resolveAdapterOptions({ protocol: 'messages', baseURL: http.url }) - const llm = adapter(() => connection) - const history: Message[] = [user()] - const first = await assemble(llm.stream(options({ messages: history }))) - history.push(first.message, user('continue with Chat')) - const saved = JSON.stringify(history) - connection = resolveAdapterOptions({ protocol: 'chat-completions', baseURL: http.url }) - const second = await assemble(llm.stream(options({ messages: history }))) - expect(JSON.stringify(http.requests[1]?.body)).not.toContain('signature') - expect(http.requests[1]?.body.messages).toContainEqual({ role: 'assistant', content: 'Messages answer', reasoning_content: 'Reasoning' }) - expect(JSON.stringify(history)).toBe(saved) - expect(second.message.source.replayState).toBeUndefined() - history.push(second.message, user('continue with Messages')) - connection = resolveAdapterOptions({ protocol: 'messages', baseURL: http.url }) - const third = await assemble(llm.stream(options({ messages: history }))) - expect(third.assembler.finish.kind).toBe('stop') - const messages = http.requests[2]?.body.messages as { role: string; content: unknown[] }[] - expect(messages.filter(message => message.role === 'assistant')).toEqual([ - { role: 'assistant', content: [{ type: 'thinking', thinking: 'Reasoning', signature: 'native-signature' }, { type: 'text', text: 'Messages answer' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'Chat answer' }] }, - ]) -}) diff --git a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts index bf2216d6c8..cb4b31d3c6 100644 --- a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts +++ b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts @@ -3,7 +3,7 @@ import { offloadedImageText, requestImageHandleText, textOnlyImageText } from '@ import type { ImageBlock } from '@deepseek-ai/dsh-llm' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { deepSeekImageRequestPricing } from '../src/common/request-pricing.ts' +import { deepSeekImageRequestPricing } from '../src/request-pricing.ts' import { resolveAdapterOptions } from '../src/index.ts' import type { Config } from '../src/index.ts' diff --git a/packages/llm/llm-deepseek/tests/runtime.e2e.ts b/packages/llm/llm-deepseek/tests/runtime.e2e.ts new file mode 100644 index 0000000000..62c727a719 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/runtime.e2e.ts @@ -0,0 +1,462 @@ +import { readFileSync } from 'node:fs' +import { mkdtemp, rm, writeFile } 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 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 * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' +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 { WireRequest } from '../src/wire-types.ts' +import { assemble, type AssembledResult } from './assemble.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 { + return Promise.resolve() + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve(this.ref) + } + + readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { + return Promise.resolve({ ref, data: TEST_PNG }) + } + + override readImageRequest( + _ref: ImageAttachmentRef, + _target: ImageRequestTarget, + _signal?: AbortSignal, + ): Promise { + return Promise.resolve(this.version) + } +} + +beforeEach(async () => { + identityHome = await mkdtemp(join(tmpdir(), 'dsh-e2e-user-id-')) + vi.stubEnv('DSH_HOME', identityHome) +}) + +async function harness(model: string, config: Partial = {}) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + await ctx.plugin(E2eAttachmentStore) + await ctx.plugin(LlmDeepSeek, { + baseURL: LlmDeepSeek.PUBLIC_BASE_URL, + ...model === VISION ? { models: [{ id: VISION, inputModalities: ['text', 'image'] }] } : {}, + ...config, + }) + return ctx +} + +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, { 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') + 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') + }) + + 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) + } + return response + } + vi.stubGlobal('fetch', observedFetch) + const files = new LlmDeepSeek.DeepSeekFilesClient({ 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) + } + }) + + it('accepts the session-log and plugin-package request extension fields', async () => { + const ctx = new Context() + contexts.push(ctx) + 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, { baseURL: LlmDeepSeek.PUBLIC_BASE_URL, thinking: 'disabled' }) + const session = ctx.sessions.create(SessionId('real-extension-fields')) + 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, + }) + 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, { 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: WireRequest[] = [] + 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) + if (wireRequests.length === 1) { + expect(body.system).toBe(initial) + expect(body.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'ping' }] }]) + } else if (wireRequests.length === 2) { + expect(body).toEqual(wireRequests[0]) + } else if (wireRequests.length === 3) { + expect(body.system).toBe(initial) + expect(body.messages[0]).toEqual(wireRequests[0]!.messages[0]) + expect(body.messages.at(-1)).toEqual({ role: 'system', content: [{ type: 'text', text: latest }] }) + } else if (wireRequests.length === 4) { + expect(body.system).toBe(latest) + expect(body.messages).toEqual(wireRequests[2]!.messages.filter(message => message.role !== 'system')) + } + } + return nativeFetch(input, init) + } + 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(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')) + }) +}) diff --git a/packages/llm/llm-deepseek/tests/runtime.spec.ts b/packages/llm/llm-deepseek/tests/runtime.spec.ts new file mode 100644 index 0000000000..55ab3acb4f --- /dev/null +++ b/packages/llm/llm-deepseek/tests/runtime.spec.ts @@ -0,0 +1,2340 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import LlmRuntime, { ToolCallId, createUserMessage, + CONTEXT_WINDOW_EXCEEDED_CODE, + createToolResultMessage, + createAssistantMessage, + LlmError, + ProviderRequestId, + QUOTA_EXCEEDED_CODE, + ReasoningEffortId, + userAgent, +} from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import { SessionId } from '@deepseek-ai/dsh-session' +import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import type { ContextFormed } from '@deepseek-ai/dsh-llm' +import { providerError } from '../src/transport.ts' +import { resolveRequestImageTarget } from '../src/request-pricing.ts' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' +import type { Behavior } from './mock-server.ts' +import { requestImageStore } from './helpers.ts' + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'test': { kind: 'test' } & ContextFormed + } +} + +const TEST_USER_ID = '00000000-0000-4000-8000-000000000001' as AnonymousUserId +let testHome: string + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), 'dsh-llm-deepseek-')) + vi.stubEnv('DSH_HOME', testHome) +}) + +afterEach(async () => { + await closeMockServers() + vi.unstubAllEnvs() + vi.useRealTimers() + rmSync(testHome, { recursive: true, force: true }) +}) + +async function harness(baseURL: string, config: object = {}) { + // Configuration carries only the reference; the key comes from the + // environment, which is the whole credential plane without a mounted seam. + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeekLlmApiExtensionRegistry) + await ctx.plugin(LlmDeepSeek, { baseURL, ...config }) + return ctx +} + +/** Direct adapter over the plugin's real resolve step, with a static key. */ +function noExtensions(): Promise { + return Promise.resolve({ fields: {}, accept: () => Promise.resolve() }) +} + +/** Direct adapter over the plugin's real resolve step, with a static key. */ +function adapterOf( + config: Partial & { apiKey?: string } = {}, + attachments?: AttachmentStore, + files?: LlmDeepSeek.DeepSeekFileStore, +): DeepSeekAdapter { + const { apiKey, ...rest } = config + return new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ ...rest }), + resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), + resolveUserId: () => TEST_USER_ID, + resolveAttachments: () => attachments, + ...files === undefined ? {} : { resolveFiles: () => files }, + prepareExtensions: noExtensions, + }) +} + +async function drain(stream: AsyncIterable): Promise { + for await (const _chunk of stream) { /* drain */ } +} + +const imageRef: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, +} + +function requestImage(ref = imageRef): RequestImageAttachment { + return { + variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), + attachment: ref, + data: Uint8Array.of(1, 2, 3), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, + depth: 'uchar', + space: 'srgb', + hasAlpha: true, + } +} + +function attachmentStoreOf( + project: (ref: ImageAttachmentRef, policy: unknown, signal?: AbortSignal) => Promise, +): { + store: AttachmentStore + readImageRequest: ReturnType> +} { + const readImageRequest = vi.fn(project) + return { + store: requestImageStore(readImageRequest), + readImageRequest, + } +} + +function fileStoreOf( + implementation: (...args: Parameters) => ReturnType, +) { + const ensureUploaded = vi.fn(implementation) + const invalidate = vi.fn(() => Promise.resolve()) + return { + store: Object.assign(new LlmDeepSeek.DeepSeekFileStore(), { ensureUploaded, invalidate }), + ensureUploaded, + invalidate, + } +} + +function fileReference(fileId: string): Awaited> { + return { + record: { fileId: LlmDeepSeek.DeepSeekFileId(fileId) }, + uploaded: true, + } as Awaited> +} + +function successfulSseResponse(): Response { + return new Response(textEvents.map(event => `data: ${event}\n\n`).join(''), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) +} + +describe('request image target', () => { + it.each([ + [ + { id: 'default' }, + { width: 1302, height: 1302, maxBytes: 2 * 1024 * 1024 }, + ], + [ + { id: 'low', imagePixelBudget: 'low' as const }, + { width: 512, height: 512, maxBytes: 2 * 1024 * 1024 }, + ], + [ + { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 }, + { width: 565, height: 565, maxBytes: 512_000 }, + ], + ])('resolves route-owned defaults and overrides for %s on a 4096x4096 source', (model, expected) => { + expect(resolveRequestImageTarget(model, { width: 4096, height: 4096 })).toEqual(expected) + }) + + it('caps every request image at the provider per-side limit', () => { + expect(resolveRequestImageTarget({ id: 'default' }, { width: 8192, height: 78 })) + .toEqual({ width: 4096, height: 39, maxBytes: 2 * 1024 * 1024 }) + expect(resolveRequestImageTarget({ id: 'custom', imagePixelBudget: 640_000 }, { width: 10_000, height: 100 })) + .toEqual({ width: 4096, height: 41, maxBytes: 2 * 1024 * 1024 }) + }) + + it('answers image request pricing from the current connection snapshot', () => { + const adapter = adapterOf({ + models: [{ id: 'vision', inputModalities: ['text', 'image'] }], + }) + const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) + expect(priced).toHaveLength(1) + expect(priced?.[0]!.visualTokens).toBeGreaterThan(0) + const textOnly = adapter.imageRequestPricing('deepseek-official', 'unlisted')?.priceImages([{ type: 'image', attachment: imageRef }]) + expect(textOnly?.[0]!.visualTokens).toBe(0) + }) + + it('prices descriptor text through the serializer\'s access resolution', () => { + const attachments = {} as AttachmentStore + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ models: [{ id: 'vision', inputModalities: ['text', 'image'] }] }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + resolveAttachments: () => attachments, + resolveImageAccess: (store, ref) => (store === attachments && ref === imageRef + ? { readonlyPath: '/world/img.png' } + : undefined), + prepareExtensions: noExtensions, + }) + const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) + expect(priced?.[0]?.text).toContain('/world/img.png') + }) +}) + +describe('DeepSeekAdapter against a mock server', () => { + it('merges prepared extension fields and accepts them once after HTTP 2xx', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const accept = vi.fn() + const prepareExtensions = vi.fn(async () => ({ + fields: { dsh_test: { version: 1 } }, + accept: async () => { accept() }, + })) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: prepareExtensions as never, + }) + + await drain(adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [], sessionId: SessionId('s') })) + expect(server.requests[0]).toMatchObject({ dsh_test: { version: 1 } }) + expect(prepareExtensions).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 's' })) + expect(accept).toHaveBeenCalledOnce() + }) + + it('fails before fetch on extension preparation or base-field collision', async () => { + const server = await mockServer([]) + const base = { + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + } + const failed = new DeepSeekAdapter({ + ...base, + prepareExtensions: () => Promise.reject(new Error('metadata unavailable')), + }) + await expect(drain(failed.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) + .rejects.toMatchObject({ code: 'REQUEST_EXTENSION' }) + + const collision = new DeepSeekAdapter({ + ...base, + prepareExtensions: (() => Promise.resolve({ fields: { model: 'replacement' }, accept: () => Promise.resolve() })) as never, + }) + await expect(drain(collision.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) + .rejects.toMatchObject({ code: 'REQUEST_EXTENSION' }) + expect(server.requests).toHaveLength(0) + }) + + it('passes cancellation into extension preparation and aborts before fetch', async () => { + const server = await mockServer([]) + const controller = new AbortController() + const started = Promise.withResolvers() + let signalSeen: AbortSignal | undefined + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: ((request: { signal: AbortSignal }) => { + signalSeen = request.signal + started.resolve(undefined) + if (request.signal === undefined) return new Promise(() => {}) + return new Promise((_resolve, reject) => { + request.signal.addEventListener('abort', () => { + const reason: unknown = request.signal.reason + reject(reason instanceof Error ? reason : new Error('extension preparation aborted', { cause: reason })) + }, { once: true }) + }) + }) as never, + }) + + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'm', + messages: [], + signal: controller.signal, + })) + await started.promise + expect(signalSeen).toBeInstanceOf(AbortSignal) + controller.abort() + await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + expect(server.requests).toHaveLength(0) + }) + + it('passes cancellation through an outstanding fetch', async () => { + const controller = new AbortController() + const started = Promise.withResolvers() + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + const signal = init?.signal + return new Promise((_resolve, reject) => { + started.resolve(undefined) + signal?.addEventListener('abort', () => { + const reason: unknown = signal.reason + reject(reason instanceof Error ? reason : new Error('fetch aborted', { cause: reason })) + }, { once: true }) + }) + }) + try { + const adapter = adapterOf({ baseURL: 'https://provider.invalid' }) + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'm', + messages: [], + signal: controller.signal, + })) + await started.promise + controller.abort() + await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + } finally { + fetch.mockRestore() + } + }) + + it('does not accept extensions on non-2xx and does accept before a later stream failure', async () => { + const server = await mockServer([ + { kind: 'http-error', status: 500, body: '{}' }, + { kind: 'close-early', events: textEvents.slice(0, 3) }, + ]) + const accept = vi.fn() + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: () => Promise.resolve({ fields: { dsh_test: 1 }, accept: async () => { accept() } }) as never, + }) + const request = { provider: 'deepseek-official', model: 'm', messages: [] } + + await expect(drain(adapter.stream(request))).rejects.toMatchObject({ code: 'SERVER' }) + expect(accept).not.toHaveBeenCalled() + await expect(drain(adapter.stream(request))).rejects.toBeDefined() + expect(accept).toHaveBeenCalledOnce() + }) + + it('reports a post-2xx extension acceptance failure without relabelling it as transport', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const failure = new Error('watermark append failed') + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: () => Promise.resolve({ + fields: { dsh_test: 1 }, + accept: () => Promise.reject(failure), + }) as never, + }) + + await expect(drain(adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) + .rejects.toMatchObject({ code: 'REQUEST_EXTENSION', cause: failure }) + expect(server.requests).toHaveLength(1) + }) + + it('streams a text generation end to end through the assembler', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) + + const result = await assemble(ctx, { + model: 'deepseek-v4-pro', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(result.finish).toEqual({ kind: 'stop' }) + expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1, totalTokens: 4 }) + + // The wire request carried the auth header contents we configured. + expect(server.requests[0]).toMatchObject({ + model: 'deepseek-v4-pro', + max_tokens: 256_000, + output_config: { effort: 'high' }, + stream: true, + }) + // App attribution and DeepSeek request identity are independent wire facts. + expect(server.headers[0]?.['user-agent']).toBe(userAgent()) + expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) + expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-session-id') + expect(server.headers[0]).not.toHaveProperty('http-referer') + expect(server.headers[0]).not.toHaveProperty('x-openrouter-title') + expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories') + expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact') + }) + + it('uploads a durable image once and sends only its Files API id to the vision model', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const signalSeen: (AbortSignal | undefined)[] = [] + const policies: unknown[] = [] + const attachmentMocks = attachmentStoreOf((ref, policy, signal) => { + signalSeen.push(signal) + policies.push(policy) + return Promise.resolve(requestImage(ref)) + }) + const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-flash', + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'describe ' }, + { type: 'image', attachment: imageRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(server.requests[0]).toMatchObject({ + model: 'deepseek-flash', + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'describe ' }, + { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request preview 1x1px.`) as string }, + { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, + ], + }], + }) + expect(server.fileRequests).toEqual([{ + method: 'POST', + path: '/v1/files', + filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, + bytes: 3, + }]) + expect(signalSeen[0]).toBeInstanceOf(AbortSignal) + expect(policies).toEqual([{ width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }]) + }) + + it('rejects unknown plugin content without preparing nested images', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) + await expect(drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-pro', messages: [ + createUserMessage({ source: { kind: 'user' }, content: [ + { type: 'plugin:message', + message: { role: 'tool', toolCallId: 'foreign', content: [{ type: 'image', attachment: { ...imageRef } }] }, + } as never, + { type: 'text', text: 'context' }, + ] }), + ] }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(attachmentMocks.readImageRequest).not.toHaveBeenCalled() + expect(server.fileRequests).toHaveLength(0) + expect(server.requests).toHaveLength(0) + }) + + it('falls back to one all-base64 request when Files API resolution fails', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + const body = server.requests[0] as { messages: Array<{ content: unknown }> } + expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image"/g)).toHaveLength(2) + expect(JSON.stringify(body)).not.toContain('file_id') + expect(files.ensureUploaded).toHaveBeenCalledTimes(1) + }) + + it('fails a base64 fallback whose retained images exceed the inline bound with the count to offload', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) + const adapter = adapterOf({ + baseURL: server.url, + maxInlineRequestImageBytes: 80, + inlineImageOffloadByteQuantum: 40, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: imageRef })), + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', message: expect.stringContaining('11 more') as string }) + expect(server.requests).toHaveLength(0) + }) + it('discards partially resolved file ids and falls back with every retained image inline', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const files = fileStoreOf(() => Promise.reject(new Error('unused'))) + files.ensureUploaded + .mockResolvedValueOnce(fileReference('file-api-partial')) + .mockRejectedValueOnce(new LlmError('Files unavailable', 'TRANSPORT')) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + const body = server.requests[0] as { messages: Array<{ content: unknown }> } + expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image"/g)).toHaveLength(2) + expect(JSON.stringify(body)).not.toContain('file-api-partial') + }) + + it('falls back after the configured Files API deadline without aborting the model request', async () => { + vi.useFakeTimers() + const started = Promise.withResolvers() + const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { + started.resolve(undefined) + signal?.addEventListener('abort', () => { + const reason: unknown = signal.reason + reject(reason instanceof Error ? reason : new Error('files operation aborted')) + }, { once: true }) + })) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulSseResponse()) + const adapter = adapterOf({ + baseURL: 'https://deepseek.invalid', + filesApiTimeoutMs: 50, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + await started.promise + await vi.advanceTimersByTimeAsync(50) + await pending + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy.mock.calls[0]?.[1]?.body).toEqual(expect.stringContaining('base64')) + fetchSpy.mockRestore() + }) + + it('does not turn caller cancellation during file resolution into base64 fallback', async () => { + const started = Promise.withResolvers() + const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { + started.resolve(undefined) + signal?.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true }) + })) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const fetchSpy = vi.spyOn(globalThis, 'fetch') + const controller = new AbortController() + const adapter = adapterOf({ + baseURL: 'https://deepseek.invalid', + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + signal: controller.signal, + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + await started.promise + controller.abort() + + await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + expect(fetchSpy).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) + + it('does not retry a generic Messages failure through base64 fallback', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 503, + body: JSON.stringify({ error: { message: 'chat unavailable' } }), + }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const files = fileStoreOf(() => Promise.resolve(fileReference('file-api-ready'))) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'SERVER', message: 'chat unavailable' }) + + expect(server.requests).toHaveLength(1) + expect(JSON.stringify(server.requests[0])).toContain('file-api-ready') + expect(JSON.stringify(server.requests[0])).not.toContain('base64') + }) + + it('does not prepare a surface-offloaded image and sends its placeholder instead', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const old = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), bytes: 3 } + const recent = { ...imageRef, attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), bytes: 3 } + const attachmentMocks = attachmentStoreOf((ref) => { + if (ref.attachmentId === old.attachmentId) throw new Error('old image must not be read') + return Promise.resolve(requestImage(ref)) + }) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachmentMocks.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: old, offloaded: true }, + { type: 'image', attachment: recent }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith( + recent, + { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, + expect.any(AbortSignal), + ) + const body = server.requests[0] as { messages: unknown[] } + expect(body.messages[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'text', text: expect.stringContaining(`image omitted to fit request image limits; ${old.attachmentId}`) as string }, + { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, + { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, + ], + }) + }) + + it('projects nested tool-result images with route-owned request budgets', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const adapter = adapterOf({ + baseURL: server.url, + models: [ + { + id: 'vision-low', + inputModalities: ['text', 'image'], + imagePixelBudget: 'low', + imageMaxBytes: 512_000, + }, + { + id: 'vision-custom', + inputModalities: ['text', 'image'], + imagePixelBudget: 320_000, + }, + ], + }, attachmentMocks.store) + const nested = createToolResultMessage({ + callId: ToolCallId('image-result'), + content: [{ type: 'image', attachment: imageRef }], + isError: false, + }) + + const call = createAssistantMessage({ + source: { provider: 'deepseek-official', model: 'vision-low' }, + content: [{ type: 'tool-call', id: nested.toolCallId, name: 'read_image', arguments: '{}' }], + }) + await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-low', messages: [call, nested] })) + await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-custom', messages: [call, nested] })) + + expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( + 1, + imageRef, + { width: 1, height: 1, maxBytes: 512_000 }, + expect.any(AbortSignal), + ) + expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( + 2, + imageRef, + { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, + expect.any(AbortSignal), + ) + }) + + it('reuses the exact request version between agent and compaction calls', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + const messages = [createUserMessage({ + content: [{ type: 'image' as const, attachment: imageRef }], + source: { kind: 'test' as const }, + })] + + await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-flash-vision-exp', messages })) + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages, + purpose: 'compaction', + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(1) + expect(server.requests).toMatchObject([ + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }] }] }, + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }] }] }, + ]) + expect(server.headers[1]?.['x-deepseek-harness-compact']).toBe('1') + }) + + it('explains a provider rejection of a normalized image and retains the raw response as cause', async () => { + const raw = JSON.stringify({ error: { message: 'unsupported image payload for file-api-1' } }) + const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + let failure: unknown + try { + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + } catch (error: unknown) { + failure = error + } + expect(failure).toMatchObject({ + code: 'INVALID_REQUEST', + message: expect.stringContaining( + `normalized image "${imageRef.attachmentId}" at message 1, image 1`, + ) as string, + cause: { message: raw }, + }) + expect((failure as Error).message).toContain('image/png, 8-bit sRGBA, 1x1') + expect((failure as Error).message).toContain('unsupported image payload for file-api-1') + expect((failure as Error).message).not.toBe(raw) + }) + + it('identifies the sole image when a normalized rejection omits its file id', async () => { + const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) + const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ + message: expect.stringContaining(`normalized image "${imageRef.attachmentId}"`) as string, + }) + }) + + it('lists every candidate when a normalized multi-image rejection names no file id', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) + const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) + const attachments = attachmentStoreOf((ref) => { + const first = ref.attachmentId === imageRef.attachmentId + return Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(first ? 'b' : 'd').repeat(64)}`), + attachment: first ? { ...ref, name: 'diagram.png' } : ref, + hasAlpha: false, + }) + }).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + message: expect.stringContaining('Candidate images: "diagram.png"') as string, + cause: { message: raw }, + }) + }) + + it.each([ + 'file-api-1 expired', + 'file_id file-api-10 invalid; file_id file-api-1 expired', + 'file_id file-api-1 expired', + 'file_not_found', + 'file_id file-api-1 deleted', + 'invalid file_id file-api-1', + ])('reuploads once when Messages rejects a Files API reference as %s', async (providerMessage) => { + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: providerMessage } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachmentMocks.store) + const options = { + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image' as const, attachment: imageRef }], + source: { kind: 'test' as const }, + })], + } + + await drain(adapter.stream(options)) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) + expect(server.requests).toMatchObject([ + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }] }] }, + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { type: 'image', source: { type: 'file', file_id: 'file-api-2' } }] }] }, + ]) + expect(attachmentMocks.readImageRequest).toHaveBeenCalledTimes(1) + }) + + it('uses inline fallback when stale-id recovery cannot resolve a replacement file', async () => { + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: 'file_id file-api-stale expired' } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const files = fileStoreOf(() => Promise.reject(new Error('unused'))) + files.ensureUploaded + .mockResolvedValueOnce(fileReference('file-api-stale')) + .mockRejectedValueOnce(new LlmError('Files unavailable', 'SERVER')) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(files.invalidate).toHaveBeenCalledTimes(1) + expect(server.requests).toHaveLength(2) + expect(JSON.stringify(server.requests[0])).toContain('file-api-stale') + expect(JSON.stringify(server.requests[1])).toContain('base64') + }) + + it('invalidates only the identified mapping when a multi-image request names one stale file id', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: 'file_id file-api-2 expired' } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(3) + const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> + expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([{ type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, { type: 'image', source: { type: 'file', file_id: 'file-api-2' } }]) + expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([{ type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, { type: 'image', source: { type: 'file', file_id: 'file-api-3' } }]) + }) + + it('invalidates every listed missing file id and preserves unlisted mappings', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const thirdRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), + } + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ + error: { + message: 'path.to.object[index]: the following file_ids do not exist or are not created under your account: ' + + 'file-api-1, file-api-3, file-api-unknown', + }, + }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf((ref) => { + let digest = 'f' + if (ref.attachmentId === imageRef.attachmentId) digest = 'b' + else if (ref.attachmentId === secondRef.attachmentId) digest = 'd' + return Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${digest.repeat(64)}`), + }) + }).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + { type: 'image', attachment: thirdRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(5) + const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> + expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([ + { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, + { type: 'image', source: { type: 'file', file_id: 'file-api-2' } }, + { type: 'image', source: { type: 'file', file_id: 'file-api-3' } }, + ]) + expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([ + { type: 'image', source: { type: 'file', file_id: 'file-api-4' } }, + { type: 'image', source: { type: 'file', file_id: 'file-api-2' } }, + { type: 'image', source: { type: 'file', file_id: 'file-api-5' } }, + ]) + }) + + it('invalidates every used mapping when a stale-file response does not identify one file id', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: 'file reference expired' } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(4) + const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> + expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([{ type: 'image', source: { type: 'file', file_id: 'file-api-3' } }, { type: 'image', source: { type: 'file', file_id: 'file-api-4' } }]) + }) + + it('returns the second stale-file rejection without a third model attempt', async () => { + const stale = JSON.stringify({ error: { message: 'file_id file-api-1 expired' } }) + const server = await mockServer([ + { kind: 'http-error', status: 400, body: stale }, + { kind: 'http-error', status: 400, body: stale }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'INVALID_REQUEST', message: 'file_id file-api-1 expired' }) + expect(server.requests).toHaveLength(2) + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) + }) + + it.each(['deepseek-v4-flash', 'unlisted-pass-through'])( + 'rejects image input for text-only model %s before credentials, image reads, or fetch', + async (model) => { + const server = await mockServer([]) + const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const resolveAttachments = vi.fn(() => attachments.store) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey, + resolveUserId: () => TEST_USER_ID, + resolveAttachments, + prepareExtensions: noExtensions, + }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model, + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(attachments.readImageRequest).not.toHaveBeenCalled() + expect(server.requests).toHaveLength(0) + }, + ) + + it('rejects vision input without an attachment provider before credentials or fetch', async () => { + const server = await mockServer([]) + const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }), + resolveApiKey, + resolveUserId: () => TEST_USER_ID, + prepareExtensions: noExtensions, + }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(server.requests).toHaveLength(0) + }) + + it('streams raw chunks through ctx.llm.stream', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }]) + const ctx = await harness(server.url) + + const kinds: string[] = [] + for await (const chunk of ctx.llm.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) { + kinds.push(chunk.type) + } + expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) + }) + + it('forwards the harness user and session ids for host-side trajectory routing', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) + + await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + sessionId: SessionId('child-session'), + }) + + expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session') + expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) + }) + + it('marks the auxiliary compaction call on the wire', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) + + await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + purpose: 'compaction', + }) + + expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1') + }) + + it('switches dynamically from the configured low default through off to max', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'low' }) + + await assemble(ctx,{ + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + await assemble(ctx,{ + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('off'), + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi again' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + await assemble(ctx,{ + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('max'), + messages: [createUserMessage({ + content: [{ type: 'text', text: 'one more time' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + expect(server.requests[0]).toMatchObject({ + thinking: { type: 'enabled' }, + output_config: { effort: 'low' }, + }) + expect(server.requests[1]).toMatchObject({ + thinking: { type: 'disabled' }, + }) + expect(server.requests[1]).not.toHaveProperty('output_config') + expect(server.requests[2]).toMatchObject({ + thinking: { type: 'enabled' }, + output_config: { effort: 'max' }, + }) + }) + + it('uses the configured maxTokens default and preserves an explicit request cap', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const ctx = await harness(server.url, { maxTokens: 32_000 }) + + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], maxTokens: 8_192 }) + + expect(server.requests[0]).toMatchObject({ max_tokens: 32_000 }) + expect(server.requests[1]).toMatchObject({ max_tokens: 8_192 }) + }) + + it('publishes only off and omits the wire effort when thinking is disabled', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url, { thinking: 'disabled' }) + + await assemble(ctx,{ + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + expect(server.requests[0]).toMatchObject({ + thinking: { type: 'disabled' }, + }) + expect(server.requests[0]).not.toHaveProperty('output_config') + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash')) + .resolves.toMatchObject({ + reasoning: { + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], + defaultEffort: ReasoningEffortId('off'), + }, + }) + }) + + it('reports a per-request effort failure before I/O when thinking is disabled', async () => { + const server = await mockServer([]) + const ctx = await harness(server.url, { thinking: 'disabled' }) + + const result = await assemble(ctx, { + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('high'), + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, + }) + expect(server.requests).toHaveLength(0) + }) + + it.each(['high', 'max'])( + 'rejects direct adapter effort %s before I/O when thinking is disabled', + async (effort) => { + const server = await mockServer([]) + const adapter = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' }) + + const stream = adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId(effort), + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + await expect(async () => { + for await (const _chunk of stream) { /* drain */ } + }).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + expect(server.requests).toHaveLength(0) + }, + ) + + it.each([ + [401, 'AUTH'], + [403, 'AUTH'], + [429, 'RATE_LIMIT'], + [400, 'INVALID_REQUEST'], + [500, 'SERVER'], + [503, 'SERVER'], + ])('maps HTTP %d to failure code %s with the body message', async (status, code) => { + const behavior: Behavior = { + kind: 'http-error', + status, + body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), + } + const server = await mockServer([behavior]) + const ctx = await harness(server.url) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { message: `failed with ${status}`, code, status }, + }) + }) + + it('retains the HTTP status when an error response has no body', async () => { + const server = await mockServer([{ kind: 'http-error', status: 500, body: '' }]) + const adapter = adapterOf({ baseURL: server.url }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + messages: [], + }))).rejects.toMatchObject({ + failure: { code: 'SERVER', status: 500 }, + message: 'DeepSeek Messages request failed (500)', + }) + }) + + it('classifies an HTTP context-window failure with the canonical code', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 400, + body: JSON.stringify({ + error: { + message: 'This model maximum context length is 128000 tokens; your input exceeds that limit.', + type: 'invalid_request_error', + code: 'context_length_exceeded', + }, + }), + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE }, + }) + }) + + it('retains status, Retry-After seconds, and provider request id as structured facts', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 429, + body: JSON.stringify({ error: { message: 'slow down' } }), + headers: { 'retry-after': '2', 'x-request-id': 'req-429' }, + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { + message: 'slow down', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 2_000, + requestId: ProviderRequestId('req-429'), + }, + }) + }) + + it('parses a future Retry-After HTTP date and the DeepSeek request-id fallback', async () => { + const now = 1_800_000_000_000 + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) + try { + const server = await mockServer([{ + kind: 'http-error', + status: 503, + body: JSON.stringify({ error: { message: 'come back later' } }), + headers: { + 'retry-after': new Date(now + 3_000).toUTCString(), + 'x-deepseek-request-id': 'deepseek-503', + }, + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { + message: 'come back later', + code: 'SERVER', + status: 503, + providerRetryAfterMs: 3_000, + requestId: ProviderRequestId('deepseek-503'), + }, + }) + } finally { + dateNow.mockRestore() + } + }) + + it('omits zero, non-finite, invalid, and past Retry-After values', async () => { + const values = [ + '0', + '9'.repeat(400), + 'not-a-date', + new Date(0).toUTCString(), + ] + for (const value of values) { + const server = await mockServer([{ + kind: 'http-error', + status: 429, + body: JSON.stringify({ error: { message: 'retry later' } }), + headers: { 'retry-after': value }, + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { message: 'retry later', code: 'RATE_LIMIT', status: 429 }, + }) + } + }) + + it('recognizes context-capacity details independently of the HTTP status', () => { + expect(providerError({ error: { message: 'request too large for model context' } }, 400).code) + .toBe(CONTEXT_WINDOW_EXCEEDED_CODE) + expect(providerError({ error: { message: 'invalid input: temperature exceeds maximum allowed value' } }, 400).code) + .toBe('INVALID_REQUEST') + expect(providerError({ error: { code: 'context_length_exceeded' } }, 413).code).toBe('CONTEXT_WINDOW_EXCEEDED') + }) + + it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => { + expect(providerError({ error: { code: 'insufficient_quota', message: 'account credits exhausted' } }, 429).code) + .toBe(QUOTA_EXCEEDED_CODE) + expect(providerError({ error: { message: 'request rate limit exceeded' } }, 429).code).toBe('RATE_LIMIT') + }) + + it('includes the status for JSON error bodies without a message', async () => { + const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }]) + const ctx = await harness(server.url) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('SERVER') + expect(result.finish.failure.message).toBe('DeepSeek Messages request failed (500)') + }) + + it('includes the status for non-JSON error bodies', async () => { + const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }]) + const ctx = await harness(server.url) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('SERVER') + expect(result.finish.failure.message).toBe('DeepSeek Messages request failed (502)') + }) + + it('maps unusual statuses to HTTP_', () => { + expect(providerError({}, 418).code).toBe('HTTP_418') + }) + + it('reports a failed endpoint connection as a transport failure', async () => { + // Port 1 is reserved/unbound, so the service normalizes the fetch failure. + const ctx = await harness('http://127.0.0.1:1') + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { + code: 'TRANSPORT', + message: 'DeepSeek Messages transport failed', + }, + }) + }) + + it('classifies an aborted request as an aborted finish', async () => { + const controller = new AbortController() + controller.abort() + const ctx = await harness('http://127.0.0.1:1') + const result = await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [], + signal: controller.signal, + }) + expect(result.finish).toMatchObject({ kind: 'aborted', failure: { code: 'ABORTED' } }) + }) + + it('throws EMPTY_RESPONSE when the response has no body', async () => { + const adapter = adapterOf({ baseURL: 'http://127.0.0.1:1' }) + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 200 }), + ) + try { + const iterate = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + } + await expect(iterate()).rejects.toThrow(/no response body/) + } finally { + fetchSpy.mockRestore() + } + }) + + it('classifies an abrupt body close as TRANSPORT', async () => { + const server = await mockServer([{ + kind: 'close-early', + events: textEvents.slice(0, 3), + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('TRANSPORT') + expect(result.finish.failure.message).toBe('DeepSeek Messages transport failed') + }) + + it('aborts mid-stream via the request signal', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 50 }]) + const ctx = await harness(server.url) + const controller = new AbortController() + + const pending = (async () => { + const chunks = [] + for await (const chunk of ctx.llm.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + messages: [], + signal: controller.signal, + })) { + chunks.push(chunk) + } + return chunks + })() + + setTimeout(() => { controller.abort() }, 30) + const chunks = await pending + expect(chunks).toHaveLength(1) + expect(chunks[0]?.type).toBe('finish') + if (chunks[0]?.type !== 'finish') throw new Error('expected a finish chunk') + expect(chunks[0].reason.kind).toBe('aborted') + if (chunks[0].reason.kind !== 'aborted') throw new Error('expected an aborted finish') + expect(chunks[0].reason.failure.code).toBe('ABORTED') + }) + + it('maps connection failures to TRANSPORT without losing the cause', async () => { + const cause = new TypeError('connection refused') + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause) + const adapter = adapterOf({ baseURL: 'https://example.invalid' }) + try { + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + } + await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause }) + } finally { + fetchSpy.mockRestore() + } + }) + + it('renders a non-Error transport rejection without losing its cause', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + const failed = Promise.withResolvers() + failed.reject('offline') + return failed.promise + }) + const adapter = adapterOf({ baseURL: 'https://example.invalid' }) + try { + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + } + await expect(drain()).rejects.toMatchObject({ + message: 'DeepSeek Messages transport failed', + code: 'TRANSPORT', + cause: 'offline', + }) + } finally { + fetchSpy.mockRestore() + } + }) + + it('aborts the underlying body when the stream stays idle past its watchdog', async () => { + vi.useFakeTimers() + let stopped = false + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + const signal = init?.signal + const body = new ReadableStream({ + start(controller) { + signal?.addEventListener('abort', () => { + stopped = true + controller.error(signal.reason) + }, { once: true }) + }, + }) + return Promise.resolve(new Response(body, { status: 200 })) + }) + const adapter = adapterOf({ + baseURL: 'https://example.invalid', + streamIdleTimeoutMs: 100, + }) + try { + const drain = (async () => { + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + })() + const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' }) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(100) + await rejected + expect(stopped).toBe(true) + } finally { + fetchSpy.mockRestore() + } + }) + + it('keeps an idle provider read alive through SSE comments', async () => { + vi.useFakeTimers() + const encoder = new TextEncoder() + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + const body = new ReadableStream({ + start(controller) { + setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 75) + setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 150) + setTimeout(() => { + controller.enqueue(encoder.encode(textEvents.map(event => `data: ${event}\n\n`).join(''))) + controller.close() + }, 225) + }, + }) + return Promise.resolve(new Response(body, { status: 200 })) + }) + const adapter = adapterOf({ + baseURL: 'https://example.invalid', + streamIdleTimeoutMs: 100, + }) + try { + const chunks: string[] = [] + const drain = (async () => { + for await (const chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { + chunks.push(chunk.type) + } + })() + await vi.advanceTimersByTimeAsync(75) + await vi.advanceTimersByTimeAsync(75) + await vi.advanceTimersByTimeAsync(75) + await expect(drain).resolves.toBeUndefined() + expect(chunks).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) + } finally { + fetchSpy.mockRestore() + } + }) +}) + +describe('plugin registration and config', () => { + it('resolves the Messages endpoint without rewriting overrides', () => { + expect(resolveAdapterOptions({}).baseURL).toBe('https://api.deepseek.com/anthropic') + for (const baseURL of ['https://example.com', 'https://example.com/custom']) { + expect(resolveAdapterOptions({ baseURL }).baseURL).toBe(baseURL) + } + const retired = { protocol: 'messages', baseURL: 'https://gateway.example' } + expect(() => resolveAdapterOptions(retired)).toThrow(/protocol/) + }) + + it('keeps wire helpers off the package root', () => { + for (const helper of [ + 'httpErrorCode', + 'serializeMessages', + 'serializeRequest', + 'DONE', + 'parseSse', + 'mapFinishReason', + 'mapUsage', + 'translate', + ]) expect(LlmDeepSeek).not.toHaveProperty(helper) + }) + + it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => { + const server = await mockServer([]) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + const fiber = await ctx.plugin(LlmDeepSeek, { + baseURL: server.url, + }) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listConfigurableProviders()).toEqual([{ + provider: 'deepseek-official', + displayName: 'DeepSeek', + settingsNs: 'llm-deepseek', + settingsPath: [], + }]) + await fiber.dispose() + expect(ctx.llm.listProviders()).toEqual([]) + expect(ctx.llm.listConfigurableProviders()).toEqual([]) + }) + + it('registers retryPolicy from the provider config', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, + }) + + expect(ctx.llm.providerRetryPolicy('deepseek-official')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + }) + + it('owns the deepseek provider and advertises the default models', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ + { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + inputModalities: ['text'], + }, + ]) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')) + .resolves.toMatchObject({ + provider: 'deepseek-official', + id: 'deepseek-flash', + name: 'DeepSeek-V41-Flash', + inputModalities: ['text', 'image'], + systemPromptUpdate: 'in-history', + context: { contextWindow: 1_000_000 }, + defaultMaxTokens: 256_000, + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, + { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, + { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, + { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }) + }) + + it('keeps deepseek-v4-pro available with its V4 capabilities', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + const info = await ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-pro') + expect(info).toMatchObject({ + id: 'deepseek-v4-pro', + inputModalities: ['text'], + context: { contextWindow: 1_000_000 }, + defaultMaxTokens: 256_000, + }) + expect(info?.systemPromptUpdate).toBeUndefined() + }) + + it.each(['off', 'low', 'max'] as const)('uses the configured %s reasoning default', async (effort) => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + reasoningEffort: effort, + }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) + .resolves.toMatchObject({ + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, + { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, + { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, + { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, + ], + defaultEffort: ReasoningEffortId(effort), + }, + }) + }) + + it('accepts off as the default when thinking is deployment-disabled', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + thinking: 'disabled', + reasoningEffort: 'off', + }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) + .resolves.toMatchObject({ + reasoning: { + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], + defaultEffort: ReasoningEffortId('off'), + }, + }) + }) + + it.each(['low', 'high', 'max'] as const)( + 'rejects configured reasoning effort %s when thinking is disabled', + async (reasoningEffort) => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + thinking: 'disabled', + reasoningEffort, + })).rejects.toThrow(/only reasoningEffort "off"/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it.each(['low', 'high', 'max'] as const)( + 'rejects disabled-thinking effort %s at the resolver boundary', + (reasoningEffort) => { + expect(() => resolveAdapterOptions({ thinking: 'disabled', reasoningEffort })) + .toThrow(/only reasoningEffort "off"/) + }, + ) + + it('accepts disabled thinking with off at the resolver boundary', async () => { + const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' }) + await expect(adapter.resolveModel('deepseek-official', 'pass-through')).resolves.toMatchObject({ + reasoning: { + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], + defaultEffort: ReasoningEffortId('off'), + }, + }) + }) + + it('uses the default model catalog when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ + { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + inputModalities: ['text'], + }, + ]) + }) + + it('defaults an adapter-supplied catalog entry to text input', async () => { + const connection = resolveAdapterOptions({ models: [] }) + const adapter = new DeepSeekAdapter({ + options: () => ({ ...connection, models: [{ id: 'adapter-model' }] }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: noExtensions, + }) + await expect(adapter.listModels('deepseek-official')).resolves.toEqual([{ + provider: 'deepseek-official', + id: 'adapter-model', + name: 'adapter-model', + inputModalities: ['text'], + }]) + }) + + it('advertises configured models without restricting arbitrary request ids', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + models: [ + { id: 'private-fast', contextWindow: 32_000 }, + { + id: 'private-reasoner', + name: 'Private Reasoner', + description: 'Higher reasoning budget', + contextWindow: 64_000, + inputModalities: ['text', 'image'], + }, + ], + }) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ + { provider: 'deepseek-official', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, + { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text', 'image'] }, + ]) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast')) + .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-reasoner')) + .resolves.toMatchObject({ + name: 'Private Reasoner', + description: 'Higher reasoning budget', + inputModalities: ['text', 'image'], + }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'arbitrary-unlisted')) + .resolves.toMatchObject({ + context: { contextWindow: 1_000_000 }, + defaultMaxTokens: 256_000, + }) + }) + + it('uses exact model capacity before the adapter-wide default', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + defaultContextWindow: 256_000, + models: [ + { id: 'inherits-default' }, + { id: 'exact-override', contextWindow: 64_000 }, + ], + }) + + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'inherits-default')) + .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'exact-override')) + .resolves.toMatchObject({ context: { contextWindow: 64_000 } }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) + .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) + }) + + it('allows an explicit empty model catalog', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + models: [], + }) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([]) + }) + + const invalidModels: Array<[object[], RegExp]> = [ + [[{ id: '' }], /ids must be non-empty/], + [[{ id: 'm', name: '' }], /empty name/], + [[{ id: 'm', contextWindow: 0 }], /contextWindow/], + [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/], + [[{ id: 'm', inputModalities: [] }], /inputModalities/], + [[{ id: 'm', inputModalities: ['text', 'text'] }], /inputModalities must not contain duplicates/], + [[{ + id: 'm', + inputModalities: ['audio'], + }], /expected "text" \| "image"/], + [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/], + ] + + it.each(invalidModels)('rejects invalid advisory model config', async (models, message) => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(Reflect.apply(ctx.plugin.bind(ctx), undefined, [LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', models: [...models], + }])).rejects.toThrow(message) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + const invalidProgrammaticModalities: Array<[object[], RegExp]> = [ + [[{ id: 'm', inputModalities: [] }], /inputModalities must not be empty/], + [[{ + id: 'm', + inputModalities: ['audio'], + }], /inputModalities must contain only "text" and "image"/], + ] + + it.each(invalidProgrammaticModalities)('rejects programmatic modality config that bypasses the schema', (models, message) => { + expect(() => { Reflect.apply(resolveAdapterOptions, undefined, [{ models: [...models] }]) }).toThrow(message) + }) + + it('rejects the removed imageDetail model setting through schema and direct construction', async () => { + const legacyModel = { id: 'vision', inputModalities: ['image' as const], imageDetail: 'low' } + expect(() => resolveAdapterOptions({ models: [legacyModel] })).toThrow(/imageDetail is no longer supported/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + models: [legacyModel], + })).rejects.toThrow(/imageDetail is no longer supported/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { + expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) + .toThrow(/maxTokens must be a positive integer/) + }) + + it('surfaces a catalog model\'s in-history system prompt update mode and rejects any other mode', async () => { + const adapter = adapterOf({ models: [ + { id: 'capable', systemPromptUpdate: 'in-history' }, + { id: 'plain' }, + ] }) + await expect(adapter.resolveModel('deepseek-official', 'capable')) + .resolves.toMatchObject({ systemPromptUpdate: 'in-history' }) + await expect(adapter.resolveModel('deepseek-official', 'plain')) + .resolves.not.toHaveProperty('systemPromptUpdate') + await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) + .resolves.not.toHaveProperty('systemPromptUpdate') + expect(() => { Reflect.apply(resolveAdapterOptions, undefined, [{ + models: [{ id: 'bogus', systemPromptUpdate: 'leading' }], + }]) }).toThrow(/systemPromptUpdate must be "in-history" when present/) + }) + + it('rejects image request limits on a text-only catalog model', () => { + expect(() => resolveAdapterOptions({ + models: [{ id: 'text-only', inputModalities: ['text'], imagePixelBudget: 1 }], + })).toThrow(/text-only catalog model .* cannot declare image request limits/) + }) + + it.each([ + ['imagePixelBudget', 0, /imagePixelBudget must be "low" or a positive safe integer/], + ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be "low" or a positive safe integer/], + ['imagePixelBudget', 'auto', /imagePixelBudget must be "low" or a positive safe integer/], + ['imageMaxBytes', 0, /imageMaxBytes must be a positive safe integer/], + ['imageMaxBytes', 1.5, /imageMaxBytes must be a positive safe integer/], + ] as const)('rejects per-model %s=%s', (field, value, message) => { + expect(() => resolveAdapterOptions({ + models: [{ id: 'vision', inputModalities: ['image'], [field]: value }], + })).toThrow(message) + }) + + it('prefers a model\'s own output cap over the profile default', async () => { + // The profile default stays what an unlisted or uncapped model resolves + // to, so adding a per-model cap changes one model rather than the route. + const adapter = adapterOf({ maxTokens: 4096, models: [ + { id: 'capped', maxTokens: 512 }, + { id: 'uncapped' }, + ] }) + await expect(adapter.resolveModel('deepseek-official', 'capped')) + .resolves.toMatchObject({ defaultMaxTokens: 512 }) + await expect(adapter.resolveModel('deepseek-official', 'uncapped')) + .resolves.toMatchObject({ defaultMaxTokens: 4096 }) + await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) + .resolves.toMatchObject({ defaultMaxTokens: 4096 }) + }) + + it('rejects invalid context capacity when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + expect(() => { + LlmDeepSeek.apply(ctx, { + baseURL: 'http://127.0.0.1:1', + models: [{ id: 'invalid-context', contextWindow: 0 }], + }) + }).toThrow(/contextWindow must be a positive integer/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + it.each([0, 1.5])( + 'rejects invalid adapter-wide default context capacity %s', + async (defaultContextWindow) => { + expect(() => resolveAdapterOptions({ defaultContextWindow })) + .toThrow(/defaultContextWindow must be a positive integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + defaultContextWindow, + })).rejects.toThrow(/defaultContextWindow/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid adapter-wide maxTokens %s', + async (maxTokens) => { + expect(() => resolveAdapterOptions({ maxTokens })) + .toThrow(/maxTokens must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxTokens, + })).rejects.toThrow(/maxTokens/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it('rejects offload quanta larger than their request bounds', () => { + expect(() => resolveAdapterOptions({ + maxRequestFilesBytes: 10, + imageOffloadByteQuantum: 11, + })).toThrow(/imageOffloadByteQuantum must not exceed maxRequestFilesBytes/) + expect(() => resolveAdapterOptions({ + maxInlineRequestImageBytes: 10, + inlineImageOffloadByteQuantum: 11, + })).toThrow(/inlineImageOffloadByteQuantum must not exceed maxInlineRequestImageBytes/) + expect(() => resolveAdapterOptions({ + maxImagesPerRequest: 10, + imageOffloadCountQuantum: 11, + })).toThrow(/imageOffloadCountQuantum must not exceed maxImagesPerRequest/) + }) + + it.each([ + ['maxImagesPerRequest', 0, /maxImagesPerRequest must be a positive safe integer/], + ['maxImagesPerRequest', 1.5, /maxImagesPerRequest must be a positive safe integer/], + ['imageOffloadByteQuantum', 0, /imageOffloadByteQuantum must be a positive safe integer/], + ['imageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /imageOffloadByteQuantum must be a positive safe integer/], + ['inlineImageOffloadByteQuantum', 0, /inlineImageOffloadByteQuantum must be a positive safe integer/], + ['inlineImageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /inlineImageOffloadByteQuantum must be a positive safe integer/], + ['imageOffloadCountQuantum', 0, /imageOffloadCountQuantum must be a positive safe integer/], + ['imageOffloadCountQuantum', 1.5, /imageOffloadCountQuantum must be a positive safe integer/], + ['fileExpiresAfterSeconds', 3_599, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], + ['fileExpiresAfterSeconds', 2_592_001, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], + ['fileRefreshMarginSeconds', -1, /fileRefreshMarginSeconds must be a non-negative integer/], + ['fileRefreshMarginSeconds', 604_800, /fileRefreshMarginSeconds must be a non-negative integer/], + ['fileQuotaCleanupBatch', 0, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], + ['fileQuotaCleanupBatch', 1_001, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], + ] as const)('rejects %s=%s', (field, value, message) => { + expect(() => resolveAdapterOptions({ [field]: value })).toThrow(message) + }) + + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid request file bound %s', + async (maxRequestFilesBytes) => { + expect(() => resolveAdapterOptions({ maxRequestFilesBytes })) + .toThrow(/maxRequestFilesBytes must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxRequestFilesBytes, + })).rejects.toThrow(/maxRequestFilesBytes/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid inline request image bound %s', + async (maxInlineRequestImageBytes) => { + expect(() => resolveAdapterOptions({ maxInlineRequestImageBytes })) + .toThrow(/maxInlineRequestImageBytes must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxInlineRequestImageBytes, + })).rejects.toThrow(/maxInlineRequestImageBytes/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') + vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, {}) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + }) + + it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + // First-boot onboarding: the route registers so models stay discoverable; + // only the request itself needs a key. + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) + const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) + expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + // The guidance leads with the managed credential store. + const second = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) + expect(second.finish.kind).toBe('error') + if (second.finish.kind !== 'error') throw new Error('expected an error finish') + // The guidance names both places a credential can come from, and nothing + // else: configuration carries the reference, never a literal key. + expect(second.finish.failure.message) + .toMatch(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s) + }) + + it('reads the ambient variable when no credentials seam is mounted', async () => { + // The plain cordis.yml composition: no credential provider, the key in + // the launching environment. + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: server.url }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.['x-api-key']).toBe('ambient-key') + }) + + it('treats an empty ambient variable as no key when no credentials seam is mounted', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + }) + + it('prefers explicit config over env for key and base URL', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') + vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) // harness passes explicit config + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(server.requests).toHaveLength(1) // hit the explicit URL, not env + }) + + it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + vi.stubEnv('DEEPSEEK_BASE_URL', server.url) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, {}) + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(server.requests).toHaveLength(1) + }) + + + it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => { + const trusted = createLaunchEnvironmentSnapshot([ + { source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } }, + ]) + expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example') + // The product trusts the project it is launched in, so a checkout can + // point its own agent at the gateway that checkout is meant to use. + const project = createLaunchEnvironmentSnapshot([ + { source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } }, + ]) + expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example') + // An explicitly configured endpoint outranks every environment layer, so a + // stale shell value cannot rewrite a deployment's own gateway. + const shell = createLaunchEnvironmentSnapshot([ + { source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } }, + ]) + expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal') + }) + it('uses the public Messages base URL without an endpoint override', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'k') + vi.stubEnv('DEEPSEEK_BASE_URL', undefined) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + // Registration succeeds; no call is made (would hit api.deepseek.com). + await ctx.plugin(LlmDeepSeek, {}) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + }) + + it('adapter is constructible directly for embedding over the shared resolver', async () => { + const adapter = adapterOf() + expect(adapter).toBeInstanceOf(DeepSeekAdapter) + // Direct embedding shares the plugin's one resolve step, so it advertises + // the same default catalog instead of a divergent empty one. + await expect(adapter.listModels('deepseek-official')).resolves.toHaveLength(2) + }) + + it('resolves connection facts and the credential exactly once per stream call', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url })) + const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) + const resolveUserId = vi.fn(() => TEST_USER_ID) + const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId, prepareExtensions: noExtensions }) + + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + + expect(options).toHaveBeenCalledTimes(1) + expect(resolveApiKey).toHaveBeenCalledTimes(1) + expect(resolveUserId).toHaveBeenCalledTimes(1) + expect(server.headers[0]?.['x-api-key']).toBe('per-request-key') + }) + + it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => { + expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: Number.POSITIVE_INFINITY })) + .toThrow(/streamIdleTimeoutMs.*positive finite/) + expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .toThrow(/streamIdleTimeoutMs.*no greater/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: 0, + })).rejects.toThrow(/streamIdleTimeoutMs/) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow(/streamIdleTimeoutMs/) + }) + + it('validates Files API timeout bounds independently of the stream idle deadline', async () => { + expect(() => resolveAdapterOptions({ filesApiTimeoutMs: Number.POSITIVE_INFINITY })) + .toThrow(/filesApiTimeoutMs.*positive finite/) + expect(() => resolveAdapterOptions({ filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .toThrow(/filesApiTimeoutMs.*no greater/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + filesApiTimeoutMs: 0, + })).rejects.toThrow(/filesApiTimeoutMs/) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow(/filesApiTimeoutMs/) + expect(resolveAdapterOptions({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 })) + .toMatchObject({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 }) + }) + + it('rejects invalid nested retryPolicy before registering the provider', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + retryPolicy: { mode: 'normal', maxRetries: -1 }, + })).rejects.toThrow(/retryPolicy/) + expect(ctx.llm.listProviders()).toEqual([]) + }) +}) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 804536fe3b..de32f4680f 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,832 +1,458 @@ +/** Request conversion and durable replay validation. */ import { describe, expect, it, vi } from 'vitest' +import { createDeveloperMessage, createUserMessage, createAssistantMessage, createMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, ImageBlock, Message, RequestMessage, RequestUserInput } from '@deepseek-ai/dsh-llm' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { createDeveloperMessage, createToolResultMessage, createUserMessage, ToolCallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, Message, RequestUserInput } from '@deepseek-ai/dsh-llm' -import type { ContextFormed } from '@deepseek-ai/dsh-llm' -import { - serializeMessages, - serializeMessagesWithImages, - serializeRequest, - serializeRequestWithImages, -} from '../src/protocols/chat-completions/serialize.ts' -import type { ImageSerializationOptions } from '../src/protocols/chat-completions/serialize.ts' +import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import { resolveAdapterOptions } from '../src/config.ts' +import { modelInfo } from '../src/model-info.ts' +import type { Config } from '../src/config.ts' +import { imagePricing, inlineImages, prepareImages } from '../src/images.ts' +import { readReplay, replayState } from '../src/replay.ts' +import { serialize } from '../src/serialize.ts' +import { MODEL, options, user, requestImageStore } from './helpers.ts' -declare module '@deepseek-ai/dsh-llm' { - interface MessageSourceMap { - 'test': { kind: 'test' } & ContextFormed - } -} +const connection = resolveAdapterOptions({}) +const call = (id = 'a'): ContentBlock => ({ type: 'tool-call', id: ToolCallId(id), name: 'read', arguments: '{"path":"a"}' }) +const assistant = (content: ContentBlock[]) => createAssistantMessage({ content, source: { provider: 'deepseek-official', model: MODEL } }) +const result = (id = 'a', content: ContentBlock[] = [{ type: 'text', text: 'result' }]) => createToolResultMessage({ callId: ToolCallId(id), content, isError: false }) +const body = (messages: RequestMessage[] = [user()], overrides: Partial = {}) => serialize( + options({ messages, ...overrides }), connection, messages, new Map(), () => undefined, +) +const capable = resolveAdapterOptions({ models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) +const nativeBody = (messages: Message[]) => serialize(options({ messages }), capable, messages, new Map(), () => undefined) -type FileResolver = Extract['resolveFileId'] - -function request(overrides: Partial = {}): GenerateOptions { - return { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [], ...overrides } -} - -function imageRef(mediaType: ImageMediaType = 'image/png', bytes = 3): ImageAttachmentRef { - const digit = ({ - 'image/png': 'a', - 'image/jpeg': 'b', - 'image/webp': 'c', - 'image/gif': 'd', - } as const)[mediaType] - return { - attachmentId: AttachmentId(`sha256:${digit.repeat(64)}`), - mediaType, - bytes, - width: 1, - height: 1, - } -} - -function fileResolver(id = 'file-api-image') { - return vi.fn(() => Promise.resolve(id)) -} - -function requestVersion(ref: ImageAttachmentRef): RequestImageAttachment { - const hash = String(ref.attachmentId).slice('sha256:'.length) - return { - variantId: ImageVariantId(`sha256:${hash}`), - attachment: ref, - data: new Uint8Array(ref.bytes), - mediaType: ref.mediaType, - bytes: ref.bytes, - width: ref.width, - height: ref.height, - depth: 'uchar', - space: 'srgb', - hasAlpha: ref.mediaType === 'image/png', - } -} - -function imageOptions( - refs: readonly ImageAttachmentRef[], - resolveFileId: FileResolver = fileResolver(), - maxRequestImageBytes = 20 * 1024 * 1024, -): ImageSerializationOptions { - return { - representation: { kind: 'file' as const, resolveFileId }, - requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), - maxRequestImageBytes, - } -} - -function inlineImageOptions( - refs: readonly ImageAttachmentRef[], - maxRequestImageBytes = 20 * 1024 * 1024, - byteQuantum = 10 * 1024 * 1024, -): ImageSerializationOptions { - return { - representation: { kind: 'base64' }, - requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), - maxRequestImageBytes, - byteQuantum, - } -} - -it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => { - for (const type of ['tool-addition', 'tool-removal'] as const) { - const message = { id: 'invalid', role, source: { kind: 'test' }, content: [{ type, toolName: 'search' }] } as unknown as Message - expect(() => serializeMessages([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - } -}) - -it('rejects deferred tool definitions until provider loading is implemented', () => { - expect(() => serializeRequest(request({ tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] }))) - .toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) -}) - -describe('request-only user input', () => { - it('preserves the exact text request and durable tool-result prefix', () => { - const prefix = [ - createMessage({ role: 'assistant', content: [{ type: 'tool-call', id: ToolCallId('lookup'), name: 'lookup', arguments: '{}' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' } }), - createToolResultMessage({ callId: ToolCallId('lookup'), content: [{ type: 'text', text: 'result' }], isError: false }), - ] - const input: RequestUserInput = { role: 'user', content: [{ type: 'text', text: 'review or summarize this input' }] } - const durable = createUserMessage({ content: input.content, source: { kind: 'test' } }) - expect(serializeRequest(request({ messages: [...prefix, input], system: 'policy' }))) - .toEqual(serializeRequest(request({ messages: [...prefix, durable], system: 'policy' }))) +describe('Messages request conversion', () => { + it('rejects unknown plugin content without interpreting its payload', () => { + expect(() => body([createUserMessage({ source: { kind: 'user' }, content: [ + { type: 'plugin:text', text: 'opaque' } as never, + ] })])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) + it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => { + for (const type of ['tool-addition', 'tool-removal'] as const) { + const content: ContentBlock[] = [{ type, toolName: 'search' }] + const message = role === 'user' ? createUserMessage({ source: { kind: 'user' }, content }) + : role === 'system' ? createMessage({ role, source: { kind: 'system-prompt' }, content }) + : role === 'assistant' ? assistant(content) : result('invalid', content) + expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + } }) - it('preserves image ordering and bytes without manufacturing durable metadata', async () => { - const ref = imageRef() + it('rejects deferred tool definitions until provider loading is implemented', () => { + expect(() => body([], { tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] })) + .toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) + + it('preserves the exact request with request-only text after durable tool results', () => { + const prefix = [user(), assistant([call()]), result()] + const input: RequestUserInput = { role: 'user', content: [{ type: 'text', text: 'review or summarize this input' }] } + const durable = createUserMessage({ content: input.content, source: { kind: 'user' } }) + expect(body([...prefix, input], { system: 'policy' })).toEqual(body([...prefix, durable], { system: 'policy' })) + }) + + it('preserves request-only image content through image preparation and wire conversion', async () => { + const attachment: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), mediaType: 'image/png', bytes: 3, width: 1, height: 1, + } + const version: RequestImageAttachment = { + variantId: ImageVariantId(`sha256:${'f'.repeat(64)}`), attachment, data: Uint8Array.of(1, 2, 3), + mediaType: 'image/png', bytes: 3, width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: true, + } const input: RequestUserInput = { role: 'user', content: [ - { type: 'text', text: 'before' }, { type: 'image', attachment: ref }, { type: 'text', text: 'after' }, + { type: 'text', text: 'before' }, { type: 'image', attachment }, { type: 'text', text: 'after' }, ] } - const durable = createUserMessage({ content: input.content, source: { kind: 'test' } }) - const actual = await serializeRequestWithImages(request({ messages: [input] }), inlineImageOptions([ref])) - const expected = await serializeRequestWithImages(request({ messages: [durable] }), inlineImageOptions([ref])) - expect(actual).toEqual(expected) + const store = requestImageStore(async () => version) + const vision = resolveAdapterOptions({ models: [{ id: MODEL, inputModalities: ['text', 'image'] }] }) + const durable = createUserMessage({ content: input.content, source: { kind: 'user' } }) + const actual = await prepareImages([input], vision, MODEL, store, () => undefined, new AbortController().signal) + const expected = await prepareImages([durable], vision, MODEL, store, () => undefined, new AbortController().signal) + expect(serialize(options({ messages: [input] }), vision, actual.messages, actual.versions, () => undefined)) + .toEqual(serialize(options({ messages: [durable] }), vision, expected.messages, expected.versions, () => undefined)) + expect(actual.messages[0]).toBe(input) expect(input).not.toHaveProperty('id') expect(input).not.toHaveProperty('source') }) -}) -describe('serializeMessages', () => { - it('maps user text to string content', () => { - const wire = serializeMessages([ - createUserMessage({ - content: [{ type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ]) - expect(wire).toEqual([{ role: 'user', content: 'hello world' }]) + it('rejects developer history while provider serialization is unsupported', () => { + const message = createDeveloperMessage({ content: [{ type: 'tool-addition', toolName: 'search' }], source: { kind: 'tool-registry' } }) + expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) }) - it('maps system-role messages in history', () => { - const wire = serializeMessages([ - createMessage({ - role: 'system', content: [{ type: 'text', text: 'be brief' }], - source: { kind: 'system-prompt' }, - }), + it('keeps the original top-level prompt and cached prefix while appending native system updates', () => { + const head = createSystemMessage('original') + const first = [head, user('first')] + const update = createSystemMessage('updated') + const second = [...first, assistant([{ type: 'text', text: 'one' }]), update, user('second')] + const saved = JSON.stringify(second) + const before = nativeBody(first) + const after = nativeBody(second) + expect(after.system).toBe(before.system) + expect(after.messages.slice(0, before.messages.length)).toEqual(before.messages) + expect(after.messages.slice(-2)).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'second' }] }, + { role: 'system', content: [{ type: 'text', text: 'updated' }] }, ]) - expect(wire).toEqual([{ role: 'system', content: 'be brief' }]) + const third = nativeBody([...second, assistant([{ type: 'text', text: 'two' }]), user('third')]) + expect(third.messages.slice(0, after.messages.length)).toEqual(after.messages) + expect(JSON.stringify(second)).toBe(saved) }) - it('passes reasoning_content back on tool-call-free turns', () => { - const wire = serializeMessages([ - createMessage({ - role: 'assistant', - content: [ - { type: 'reasoning', text: 'thinking…' }, - { type: 'text', text: 'answer' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ]) - // A gateway that re-encodes the conversation for another vendor recovers - // the upstream thinking signature by hashing this exact text, and a turn - // that called no tool carries it nowhere else. - expect(wire).toEqual([{ role: 'assistant', content: 'answer', reasoning_content: 'thinking…' }]) - }) - - it('passes reasoning_content back on tool-call turns (official passback rule)', () => { - const wire = serializeMessages([ - createMessage({ - role: 'assistant', - content: [ - { type: 'reasoning', text: 'I should check the weather.' }, - { type: 'tool-call', id: ToolCallId('call-1'), name: 'get_weather', arguments: '{"city":"Paris"}' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ]) - expect(wire).toEqual([{ - role: 'assistant', - // "" (not null) on tool-call turns — mirrors the official samples' - // verbatim message replay; some gateways reject null. - content: '', - reasoning_content: 'I should check the weather.', - tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }], - }]) - }) - - it('serializes parallel tool calls in order', () => { - const wire = serializeMessages([ - createMessage({ - role: 'assistant', - content: [ - { type: 'tool-call', id: ToolCallId('a'), name: 'one', arguments: '{}' }, - { type: 'tool-call', id: ToolCallId('b'), name: 'two', arguments: '{}' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ]) - const assistant = wire[0] as { tool_calls: { id: string }[] } - expect(assistant.tool_calls.map(call => call.id)).toEqual(['a', 'b']) - }) - - it('turns tool messages into role:tool wire messages', () => { - const wire = serializeMessages([ - createToolResultMessage({ - callId: ToolCallId('call-1'), - content: [{ type: 'text', text: 'Sunny 22C' }], - isError: false, - }), - ]) - expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: 'Sunny 22C' }]) - }) - - it('sends a sentinel for empty tool content', () => { - const wire = serializeMessages([ - createToolResultMessage({ callId: ToolCallId('call-1'), content: [], isError: false }), - ]) - expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: '(no output)' }]) - }) - - it('keeps user text and tool messages as separate wire messages', () => { - const wire = serializeMessages([ - createUserMessage({ - content: [{ type: 'text', text: 'context note' }], - source: { kind: 'test' }, - }), - createToolResultMessage({ - callId: ToolCallId('call-1'), - content: [{ type: 'text', text: 'ok' }], - isError: false, - }), - ]) - expect(wire).toEqual([ - { role: 'user', content: 'context note' }, - { role: 'tool', tool_call_id: 'call-1', content: 'ok' }, + it('places system updates after all parallel tool results and before the next assistant', () => { + const history = [createSystemMessage('original'), user(), assistant([call(), call('b')]), + createSystemMessage('first update'), result(), createSystemMessage('second update'), result('b'), + user('more input'), assistant([{ type: 'text', text: 'done' }])] + const request = nativeBody(history) + expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'system', 'assistant']) + expect(request.messages[2]?.content.map(block => block.type)).toEqual(['tool_result', 'tool_result', 'text']) + expect(request.messages.slice(3, 5).map(message => message.content)).toEqual([ + [{ type: 'text', text: 'first update' }], [{ type: 'text', text: 'second update' }], ]) }) - it('skips plugin-added block types (merge-extensible ContentBlockMap)', () => { - const wire = serializeMessages([ - createUserMessage({ - content: [ - { type: 'chart', data: 'x' } as unknown as ContentBlock, - { type: 'text', text: 'see chart' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), + it('accepts native trailing updates without a top-level prompt and rejects unrepresentable positions', () => { + const update = createSystemMessage('update') + expect(nativeBody([user(), update])).toMatchObject({ messages: [ + { role: 'user' }, { role: 'system', content: [{ type: 'text', text: 'update' }] }, + ] }) + expect(nativeBody([user(), update]).system).toBeUndefined() + expect(() => nativeBody([user(), assistant([{ type: 'text', text: 'done' }]), update])).toThrow(/preceding user/) + expect(() => nativeBody([user(), createSystemMessage('')])).toThrow(/empty in-history/) + expect(() => nativeBody([user(), assistant([call(), call('b')]), update, result()])).toThrow(/immediate results/) + }) + + it.each([[], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')]] satisfies ContentBlock[][])( + 'rejects native system updates when their user input is omitted %#', (...content) => { + const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) + const history = [user(), assistant([{ type: 'text', text: 'answer' }]), createSystemMessage('updated'), empty] + const saved = JSON.stringify(history) + for (const messages of [history, [...history, assistant([{ type: 'text', text: 'next answer' }])]]) { + expect(() => nativeBody(messages)).toThrow(expect.objectContaining({ + code: 'UNSUPPORTED_CONTENT', + message: 'DeepSeek Messages cannot represent system update without a preceding user or tool-result turn', + })) + } + expect(JSON.stringify(history)).toBe(saved) + }, + ) + + it('keeps native system updates after retained text or tool results beside omitted user input', () => { + const reasoning: ContentBlock = { type: 'reasoning', text: 'child reasoning' } + const empty = createMessage({ role: 'user', source: { kind: 'user' }, content: [reasoning] }) + const update = createSystemMessage('updated') + for (const [previous, retained, expected] of [ + [assistant([{ type: 'text', text: 'answer' }]), user('continue'), [{ type: 'text', text: 'continue' }]], + [assistant([call()]), result('a', [reasoning]), [{ type: 'tool_result', tool_use_id: 'a', content: [], is_error: false }]], + ] as const) { + const history = [user(), previous, update, empty, retained, assistant([{ type: 'text', text: 'done' }])] + const saved = JSON.stringify(history) + const request = nativeBody(history) + expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'assistant']) + expect(request.messages[2]?.content).toEqual(expected) + expect(request.messages[3]?.content).toEqual([{ type: 'text', text: 'updated' }]) + expect(JSON.stringify(history)).toBe(saved) + } + }) + + it('groups parallel results before ordinary text and keeps tool failure content', () => { + const messages = [user(), assistant([call(), call('b')]), user('follow-up'), result(), createToolResultMessage({ callId: ToolCallId('b'), content: [{ type: 'text', text: 'permission denied' }], isError: true })] + expect(body(messages).messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: ['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })) }, + { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'result' }], is_error: false }, + { type: 'tool_result', tool_use_id: 'b', content: [{ type: 'text', text: 'permission denied' }], is_error: true }, + { type: 'text', text: 'follow-up' }, + ] }, ]) - expect(wire).toEqual([{ role: 'user', content: 'see chart' }]) + expect(messages[2]?.content).toEqual([{ type: 'text', text: 'follow-up' }]) }) - it('rejects image blocks instead of silently flattening them away', () => { - expect(() => serializeMessages([createUserMessage({ - content: [{ - type: 'image', - attachment: { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png', bytes: 68, width: 1, height: 1, - }, - }], - source: { kind: 'test' }, - })])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + it('preserves empty results without inventing model-visible output', () => { + const response = body([user(), assistant([call()]), result('a', [])]) + expect(response.messages[2]?.content[0]).toMatchObject({ content: [] }) + const { isError: _isError, ...minimal } = createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: '' }], isError: false }) + expect(body([assistant([call()]), minimal]).messages[1]?.content[0]).toEqual({ type: 'tool_result', tool_use_id: 'a', content: [] }) }) - it('emits an empty user message rather than dropping block-less messages', () => { - const wire = serializeMessages([createUserMessage({ - content: [], - source: { kind: 'test' }, - })]) - expect(wire).toEqual([{ role: 'user', content: '' }]) + it('omits assistant-only blocks from user input while preserving text and durable content', () => { + const history = [createMessage({ role: 'user', source: { kind: 'user' }, content: [ + { type: 'text', text: 'Background subagent finished.\n' }, + { type: 'reasoning', text: 'child reasoning' }, + call('child-call'), + { type: 'text', text: ' child answer ' }, + ] })] + const saved = JSON.stringify(history) + + expect(body(history).messages).toEqual([{ role: 'user', content: [ + { type: 'text', text: 'Background subagent finished.\n' }, + { type: 'text', text: ' child answer ' }, + ] }]) + expect(JSON.stringify(history)).toBe(saved) + }) + + it('omits assistant-only blocks inside tool results while retaining calls, errors and empty results', () => { + const reasoning: ContentBlock = { type: 'reasoning', text: 'tool reasoning' } + const history = [assistant([reasoning, call(), call('b')]), + createToolResultMessage({ callId: ToolCallId('a'), content: [reasoning, call('nested-call'), { type: 'text', text: ' result\n' }], isError: true }), + result('b', [reasoning, call('another-nested-call')]), + ] + const saved = JSON.stringify(history) + + expect(body(history).messages).toEqual([ + { role: 'assistant', content: [ + { type: 'thinking', thinking: 'tool reasoning' }, + ...['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })), + ] }, + { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: ' result\n' }], is_error: true }, + { type: 'tool_result', tool_use_id: 'b', content: [], is_error: false }, + ] }, + ]) + expect(JSON.stringify(history)).toBe(saved) + }) + + it.each([ + [], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')], + ] satisfies ContentBlock[][])('omits empty user input after conversion %#', (...content) => { + const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) + expect(body([empty, user(), assistant([{ type: 'text', text: 'answer' }]), empty]).messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'answer' }] }, + ]) + }) + + it('collects leading system text and maps tools, stop sequences and explicit output cap', () => { + const system = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'text', text: 'instructions' }] }) + expect(body([system, user()], { system: 'top', maxTokens: 123, stop: ['END'], tools: [{ name: 'read', description: 'Read a file', parameters: { type: 'object' } }] })).toMatchObject({ + system: 'top\n\ninstructions', max_tokens: 123, stop_sequences: ['END'], tools: [{ name: 'read', description: 'Read a file', input_schema: { type: 'object' } }], + }) + expect(body([user(), system]).system).toBe('instructions') + }) + + it('uses the latest complete system snapshot without changing tool history or durable messages', () => { + const conversation = [user(), assistant([call()]), result(), assistant([{ type: 'text', text: 'done' }]), user('continue')] + const history = [createSystemMessage('obsolete'), ...conversation.slice(0, 2), + createSystemMessage('intermediate'), ...conversation.slice(2, 4), + createSystemMessage('current'), conversation[4]!] + const saved = JSON.stringify(history) + expect(body(history)).toEqual({ ...body(conversation), system: 'current' }) + expect(body(history, { system: 'one-shot prefix' }).system).toBe('one-shot prefix\n\ncurrent') + expect(JSON.stringify(history)).toBe(saved) + }) + + it('replaces adjacent system snapshots and joins blocks only within the current snapshot', () => { + const latest = createMessage({ role: 'system', source: { kind: 'system-prompt' }, + content: [{ type: 'text', text: 'part one' }, { type: 'text', text: ' and part two' }] }) + expect(body([createSystemMessage('old'), latest, user()]).system).toBe('part one and part two') + }) + + it.each([[], [{ type: 'text' as const, text: '' }]].map(content => ({ content })))('clears earlier prompt snapshots with empty content %#', ({ content }) => { + const cleared = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content }) + const history = [createSystemMessage('old'), user(), cleared] + expect(body(history).system).toBeUndefined() + expect(body(history, { system: 'one-shot prefix' }).system).toBe('one-shot prefix') + expect(body(history, { system: '' }).system).toBeUndefined() + }) + + it('rejects non-text system content even when a later snapshot supersedes it', () => { + const invalid = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'reasoning', text: 'bad' }] }) + expect(() => body([invalid, user(), createSystemMessage('current')])).toThrow(/non-text system/) + }) + + it.each(['off', 'low', 'high', 'max'])('maps reasoning effort %s', (effort) => { + const request = body([user()], { reasoningEffort: ReasoningEffortId(effort) }) + expect(request.thinking.type).toBe(effort === 'off' ? 'disabled' : 'enabled') + expect(request.output_config).toEqual(effort === 'off' ? undefined : { effort }) + }) + + it('disables thinking for titles, passes temperature with thinking and refuses unsupported effort', () => { + expect(body([user()], { purpose: 'session-title', temperature: 0 })).toMatchObject({ thinking: { type: 'disabled' }, temperature: 0 }) + expect(body([user()], { temperature: 0 })).toMatchObject({ thinking: { type: 'enabled' }, temperature: 0 }) + expect(() => body([user()], { reasoningEffort: ReasoningEffortId('medium') })).toThrow(/effort/) + const disabled = resolveAdapterOptions({ thinking: 'disabled' }) + expect(serialize(options(), disabled, [user()], new Map(), () => undefined).thinking).toEqual({ type: 'disabled' }) + expect(() => serialize(options({ reasoningEffort: ReasoningEffortId('high') }), disabled, [user()], new Map(), () => undefined)).toThrow(/effort/) + const capped = resolveAdapterOptions({ models: [{ id: MODEL, maxTokens: 321 }] }) + expect(serialize(options(), capped, [user()], new Map(), () => undefined).max_tokens).toBe(321) + }) + + it.each([ + [result()], [assistant([call()])], [assistant([call()]), user()], + [assistant([call(), call()]), result()], + [assistant([call()]), result(), result()], + ])('rejects unmatched or duplicated tool history %#', (...messages) => { + expect(() => body(messages)).toThrow(/tool/) + }) + + it.each(['{', '', '[]', 'null', '42', 'true', '"text"', '{"description":"最快,但"某个说法"没有证据。"}'])('uses empty input for malformed or non-object historical tool arguments %s', (arguments_) => { + const message = assistant([{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: arguments_ }]) + const history = [user(), message, createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: 'Invalid arguments' }], isError: true }), user('Continue')] + const saved = JSON.stringify(history) + const restored = JSON.parse(saved) as Message[] + expect(body(restored).messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'tool_use', id: 'a', name: 'read', input: {} }] }, + { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'Invalid arguments' }], is_error: true }, + { type: 'text', text: 'Continue' }, + ] }, + ]) + expect(JSON.stringify(restored)).toBe(saved) + }) + + it('preserves own signed thinking, omits absent signatures and validates durable metadata', () => { + const content: ContentBlock[] = [{ type: 'reasoning', text: '' }, { type: 'text', text: 'answer' }] + const source = { provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: 'signed' }, { type: 'text' }]) } + const message = createAssistantMessage({ content, source }) + expect(body([user(), message, user()]).messages[1]?.content).toEqual([{ type: 'thinking', thinking: '', signature: 'signed' }, { type: 'text', text: 'answer' }]) + expect(body([assistant([{ type: 'reasoning', text: 'foreign thought' }])]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'foreign thought' }]) + expect(readReplay(message, 'different-model')).toBeUndefined() + expect(readReplay(user(), MODEL)).toBeUndefined() + }) + + it.each([ + null, + [], + { response: null, blocks: [] }, + { response: { kind: 'other', version: 1 }, blocks: [] }, + { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] }, + { response: { kind: 'deepseek-messages', version: 1, model: 'wrong' }, blocks: [] }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [] }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: null }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [null] }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'tool-call' }] }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'reasoning', signature: 3 }] }, + ].map(state => ({ state })))('degrades unusable replay state with a diagnostic %#', ({ state }) => { + const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { provider: 'deepseek-official', model: MODEL, replayState: state } }) + const onDegrade = vi.fn() + expect(readReplay(message, MODEL, onDegrade)).toBeUndefined() + expect(onDegrade).toHaveBeenCalledExactlyOnceWith(expect.any(String)) + expect(body([message]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'think' }]) + }) + + it.each([MODEL, 'different-model'])('keeps durable content when replay degrades for %s', async (model) => { + const message = createAssistantMessage({ + content: [{ type: 'reasoning', text: 'Read the file.' }, { type: 'text', text: 'Checking a.' }, call()], + source: { provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [ + { type: 'reasoning', signature: 'do-not-send' }, { type: 'text', signature: 'invalid-for-text' }, { type: 'tool-call' }, + ]) }, + }) + const saved = JSON.stringify(message) + const restored = JSON.parse(saved) as Message + const messages = [user(), restored, result()] + const onDegrade = vi.fn() + const request = serialize(options({ model }), connection, messages, new Map(), () => undefined, onDegrade) + expect(onDegrade).toHaveBeenCalledExactlyOnceWith('DeepSeek Messages replay: invalid signature') + await expect(JSON.stringify(request.messages, null, 2) + '\n').toMatchFileSnapshot('expected/degraded-replay.json') + expect(JSON.stringify(restored)).toBe(saved) + }) + + it('keeps valid cross-model and foreign history quiet and propagates diagnostic failures', () => { + const onDegrade = vi.fn() + const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { + provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: '' }]), + } }) + expect(readReplay(message, MODEL, onDegrade)).toEqual([{ type: 'reasoning', signature: '' }]) + expect(readReplay(message, 'different-model', onDegrade)).toBeUndefined() + expect(readReplay(assistant([{ type: 'text', text: 'foreign' }]), MODEL, onDegrade)).toBeUndefined() + expect(onDegrade).not.toHaveBeenCalled() + const damaged = { ...message, source: { ...message.source, replayState: { response: {}, blocks: [] } } } + const failure = new Error('diagnostic failed') + expect(() => readReplay(damaged, MODEL, () => { throw failure })).toThrow(failure) + }) + + it.each([1, 2])('uses empty historical tool input with replay version %s', (version) => { + const message = createAssistantMessage({ content: [{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: '{' }], source: { + provider: 'deepseek-official', model: MODEL, replayState: { response: { kind: 'deepseek-messages', version, model: MODEL }, blocks: [{ type: 'tool-call' }] }, + } }) + const saved = JSON.stringify(message) + const onDegrade = vi.fn() + const request = serialize(options(), connection, [message, result()], new Map(), () => undefined, onDegrade) + expect(request.messages[0]?.content).toEqual([{ type: 'tool_use', id: 'a', name: 'read', input: {} }]) + expect(onDegrade).toHaveBeenCalledTimes(version === 1 ? 0 : 1) + expect(JSON.stringify(message)).toBe(saved) }) }) -describe('serializeRequest', () => { - const history: Message[] = [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'test' }, - })] +describe('validated configuration', () => { + it('advertises exact model metadata and allows unlisted text models', () => { + expect(modelInfo(connection, 'deepseek-official', MODEL)).toMatchObject({ context: { contextWindow: 1_000_000 }, defaultMaxTokens: 256_000, reasoning: { defaultEffort: 'high' } }) + expect(modelInfo(connection, 'deepseek-official', 'custom').inputModalities).toEqual(['text']) + expect(modelInfo(connection, 'deepseek-official', MODEL).systemPromptUpdate).toBeUndefined() + expect(modelInfo(connection, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined() + expect(modelInfo(capable, 'deepseek-official', MODEL).systemPromptUpdate).toBe('in-history') + expect(modelInfo(capable, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined() + expect(modelInfo(resolveAdapterOptions({ thinking: 'disabled' }), 'deepseek-official', MODEL).reasoning?.efforts).toMatchObject([{ id: 'off', name: 'Off' }]) + expect(resolveAdapterOptions({ baseURL: 'https://example.com/anthropic///' }).baseURL).toBe('https://example.com/anthropic///') + }) + it.each([ + { thinking: 'disabled', reasoningEffort: 'high' }, { models: [{ id: '' }] }, + { models: [{ id: 'a' }, { id: 'a' }] }, { models: [{ id: 'a', name: '' }] }, + { maxInlineRequestImageBytes: 1 }, { maxImagesPerRequest: 1 }, + { baseURL: 'ftp://example.com' }, { baseURL: 'https://user:pass@example.com' }, + { baseURL: 'https://example.com/?key=x' }, { baseURL: 'https://example.com/#x' }, + { maxTokens: 0 }, { streamIdleTimeoutMs: 0 }, + { models: [{ id: MODEL, systemPromptUpdate: 'unsupported' }] }, + ])('rejects invalid composition input %#', (value) => { + expect(() => resolveAdapterOptions(value as Config)).toThrow() + }) +}) - it('always streams with usage and maps the basics', () => { - const wire = serializeRequest(request({ messages: history })) - expect(wire).toEqual({ - model: 'deepseek-v4-flash', - messages: [{ role: 'user', content: 'hi' }], - stream: true, - stream_options: { include_usage: true }, +describe('Messages images', () => { + const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), mediaType: 'image/png', width: 1, height: 1, bytes: 3 } + const image: ImageBlock = { type: 'image', attachment: ref } + const version: RequestImageAttachment = { attachment: ref, variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), mediaType: 'image/png', bytes: 3, data: Uint8Array.of(1, 2, 3), width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: false } + const access = () => ({ readonlyPath: '/workspace/image.png' }) + const model = 'deepseek-flash' + // Only the read operation is consumed by image preparation; the transport is mocked, not durable content. + const attachments = requestImageStore(async () => version) + const signal = new AbortController().signal + it('keeps image bytes inside tool results and deduplicates normalization', async () => { + const history = [assistant([call()]), result('a', [image, image])] + const prepared = await prepareImages(history, connection, model, attachments, access, signal) + expect(prepared.versions.size).toBe(1) + const request = serialize(options({ model }), connection, prepared.messages, prepared.versions, access) + expect(request.messages[1]?.content[0]).toMatchObject({ type: 'tool_result', content: [ + { type: 'text', text: expect.stringContaining('/workspace/image.png') as string }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AQID' } }, + { type: 'text' }, { type: 'image' }, + ] }) + expect(imagePricing(connection, model, access).priceImages([image])[0]?.visualTokens).toBeGreaterThan(0) + expect(imagePricing(connection, MODEL, access).priceImages([image])[0]?.visualTokens).toBe(0) + }) + it('requires logged offload at exact encoded bytes and preserves durable references', async () => { + const config = resolveAdapterOptions({ + maxInlineRequestImageBytes: 4, inlineImageOffloadByteQuantum: 1, maxImagesPerRequest: 2, imageOffloadCountQuantum: 1, }) - }) - - it('prepends the system prompt', () => { - const wire = serializeRequest(request({ messages: history, system: 'be helpful' })) - expect(wire.messages[0]).toEqual({ role: 'system', content: 'be helpful' }) - expect(wire.messages[1]).toEqual({ role: 'user', content: 'hi' }) - }) - - it('serializes a leading system message byte-for-byte like the same prompt passed as options.system', async () => { - const systemMessage = createMessage({ - role: 'system', - content: [{ type: 'text', text: 'be helpful' }], - source: { kind: 'system-prompt' }, - }) - const tools = [{ name: 'f', description: 'F', parameters: { type: 'object', properties: {} } }] - const fromHistory = serializeRequest(request({ messages: [systemMessage, ...history], tools })) - const fromOption = serializeRequest(request({ messages: history, system: 'be helpful', tools })) - expect(fromHistory.messages[0]).toEqual({ role: 'system', content: 'be helpful' }) - expect(JSON.stringify(fromHistory)).toBe(JSON.stringify(fromOption)) - const images = imageOptions([]) - const imageHistory = await serializeRequestWithImages(request({ messages: [systemMessage, ...history], tools }), images) - const imageOption = await serializeRequestWithImages(request({ messages: history, system: 'be helpful', tools }), images) - expect(JSON.stringify(imageHistory)).toBe(JSON.stringify(imageOption)) - }) - - it('maps sampling params and stop sequences', () => { - const wire = serializeRequest(request({ messages: history, temperature: 0.2, maxTokens: 100, stop: ['END'] })) - expect(wire.temperature).toBe(0.2) - expect(wire.max_tokens).toBe(100) - expect(wire.stop).toEqual(['END']) - }) - - it('maps tools to the wire function shape', () => { - const wire = serializeRequest(request({ - messages: history, - tools: [ - { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } }, - { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } }, - ], + const offloadFailure: unknown = expect.objectContaining({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) + const history = [result('a', [image, image])] + const prepared = await prepareImages(history, config, model, attachments, access, signal) + expect(prepared.messages[0]?.content).toMatchObject([image, image]) + expect(() => inlineImages(prepared.messages, prepared.versions, config)).toThrow(expect.objectContaining({ + failure: offloadFailure, + })) + const offloaded: ImageBlock = { ...image, offloaded: true } + const retry = await prepareImages([result('a', [offloaded, image])], config, model, attachments, access, signal) + expect(inlineImages(retry.messages, retry.versions, config)[0]?.content).toMatchObject([{ type: 'text' }, { type: 'image' }]) + expect(history[0]?.content).toMatchObject([image, image]) + expect(imagePricing(config, model, access).priceImages([image, image]).map(entry => entry.visualTokens)) + .toEqual([expect.any(Number), expect.any(Number)]) + const large = requestImageStore(async () => ({ ...version, bytes: 30, data: new Uint8Array(30) })) + const exact = await prepareImages([result('a', [image])], config, model, large, access, signal) + expect(() => inlineImages(exact.messages, exact.versions, config)).toThrow(expect.objectContaining({ + failure: offloadFailure, })) - expect(wire.tools).toEqual([ - { type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } }, - { type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } } }, - ]) }) - - it('omits an empty tools array', () => { - const wire = serializeRequest(request({ messages: history, tools: [] })) - expect(wire.tools).toBeUndefined() - }) - - it.each(['low', 'high', 'max'] as const)('maps adapter-default thinking and request effort %s', (effort) => { - const wire = serializeRequest( - request({ messages: history, reasoningEffort: ReasoningEffortId(effort) }), - { thinking: 'enabled', reasoningEffort: 'high' }, - ) - expect(wire.thinking).toEqual({ type: 'enabled' }) - expect(wire.reasoning_effort).toBe(effort) - }) - - it('maps off to disabled thinking without a wire reasoning effort', () => { - const wire = serializeRequest( - request({ messages: history, reasoningEffort: ReasoningEffortId('off') }), - { thinking: 'enabled', reasoningEffort: 'max' }, - ) - expect(wire.thinking).toEqual({ type: 'disabled' }) - expect(wire.reasoning_effort).toBeUndefined() - }) - - it('re-enables thinking when max overrides an off default', () => { - const wire = serializeRequest( - request({ messages: history, reasoningEffort: ReasoningEffortId('max') }), - { reasoningEffort: 'off' }, - ) - expect(wire.thinking).toEqual({ type: 'enabled' }) - expect(wire.reasoning_effort).toBe('max') - }) - - it('rejects enabling thinking when the deployment is locked to disabled', () => { - expect(() => serializeRequest( - request({ messages: history, reasoningEffort: ReasoningEffortId('high') }), - { thinking: 'disabled' }, - )).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' })) - }) - - it('disables thinking for session-title requests without changing adapter defaults', () => { - const wire = serializeRequest( - request({ - messages: history, - purpose: 'session-title', - reasoningEffort: ReasoningEffortId('max'), - }), - { thinking: 'enabled', reasoningEffort: 'max' }, - ) - expect(wire.thinking).toEqual({ type: 'disabled' }) - expect(wire.reasoning_effort).toBeUndefined() - }) - - it('omits thinking fields when unset (provider default applies)', () => { - const wire = serializeRequest(request({ messages: history })) - expect(wire.thinking).toBeUndefined() - expect(wire.reasoning_effort).toBeUndefined() - }) - - it('preserves an explicit enabled default without inventing a wire effort', () => { - const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled' }) - expect(wire.thinking).toEqual({ type: 'enabled' }) - expect(wire.reasoning_effort).toBeUndefined() - }) - - it('rejects an effort outside the DeepSeek capability', () => { - expect(() => serializeRequest(request({ - messages: history, - reasoningEffort: ReasoningEffortId('medium'), - }))).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' })) - }) -}) - -describe('image serialization', () => { - it.each([ - 'image/png', - 'image/jpeg', - 'image/webp', - 'image/gif', - ] as const)('preserves ordered text and %s image parts', async (mediaType) => { - const resolveFileId = fileResolver() - const ref = imageRef(mediaType) - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'text', text: 'before' }, - { type: 'image', attachment: ref }, - { type: 'text', text: 'after' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), imageOptions([ref], resolveFileId)) - - expect(wire.messages).toEqual([{ - role: 'user', - content: [ - { type: 'text', text: 'before' }, - { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string }, - { type: 'file', file_id: 'file-api-image' }, - { type: 'text', text: 'after' }, - ], - }]) - }) - - it.each([ - ['image/png', 'data:image/png;base64,AAAA'], - ['image/jpeg', 'data:image/jpeg;base64,AAAA'], - ['image/webp', 'data:image/webp;base64,AAAA'], - ['image/gif', 'data:image/gif;base64,AAAA'], - ] as const)('serializes every retained %s request version as an inline data URL', async (mediaType, url) => { - const ref = imageRef(mediaType) - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), inlineImageOptions([ref])) - - expect(wire.messages).toEqual([{ - role: 'user', - content: [ - { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string }, - { type: 'image_url', image_url: { url } }, - ], - }]) - }) - - it('gives image-only input a stable handle and request dimensions', async () => { - const ref = imageRef() - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), imageOptions([ref])) - - expect(wire.messages).toEqual([{ - role: 'user', - content: [ - { - type: 'text', - text: `Image ${ref.attachmentId}; request preview 1x1px. It may be resized or re-encoded; source dimensions, format, and byte size may differ.`, - }, - { type: 'file', file_id: 'file-api-image' }, - ], - }]) - }) - - it('includes provider-resolved normalized access in a retained image handle', async () => { - const ref = { ...imageRef(), name: 'diagram.png', width: 2048, height: 1024 } - const images = imageOptions([ref]) - const version = images.requestImages.get(ref.attachmentId) as RequestImageAttachment - version.width = 1130 - version.height = 565 - images.resolveImageAccess = () => ({ readonlyPath: '/tmp/dsh/objects/aa/object' }) - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), images) - - expect(wire.messages[0]).toMatchObject({ - role: 'user', - content: [{ - type: 'text', - text: expect.stringContaining('Image "diagram.png"') as string, - }, { type: 'file' }], - }) - expect(JSON.stringify(wire.messages[0])).toContain('/tmp/dsh/objects/aa/object') - expect(JSON.stringify(wire.messages[0])).toContain('request preview 1130x565px') - }) - - it('rejects an image whose prepared request version is absent', async () => { - const ref = imageRef() - await expect(serializeMessagesWithImages([createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'test' }, - })], imageOptions([]))).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) - }) - - it('keeps tool content textual and groups consecutive tool-result images afterward', async () => { - const messages = [ - createToolResultMessage({ - callId: ToolCallId('first'), - content: [{ type: 'image', attachment: imageRef() }], - isError: false, - }), - createToolResultMessage({ - callId: ToolCallId('second'), - content: [ - { type: 'text', text: 'caption' }, - { type: 'image', attachment: imageRef('image/jpeg') }, - ], - isError: false, - }), - ] - - const png = imageRef() - const jpeg = imageRef('image/jpeg') - await expect(serializeMessagesWithImages(messages, imageOptions( - [png, jpeg], - vi.fn((version: RequestImageAttachment) => Promise.resolve(`file-api-${version.mediaType}`)), - ))).resolves.toEqual([ - { - role: 'tool', - tool_call_id: 'first', - content: expect.stringContaining(`Image ${png.attachmentId}`) as string, - }, - { - role: 'tool', - tool_call_id: 'second', - content: expect.stringContaining(`caption\nImage ${jpeg.attachmentId}`) as string, - }, - { - role: 'user', - content: [ - { type: 'text', text: 'Attached image(s) from tool result:' }, - { type: 'file', file_id: 'file-api-image/png' }, - { type: 'file', file_id: 'file-api-image/jpeg' }, - ], - }, - ]) - }) - - it('keeps an empty user message for ignored content beside a tool message', async () => { - const messages = [ - createUserMessage({ - content: [ - { type: 'text', text: '' }, - { type: 'chart', data: 'ignored' } as unknown as ContentBlock, - ], - source: { kind: 'test' }, - }), - createToolResultMessage({ - callId: ToolCallId('result'), - content: [{ type: 'text', text: 'ok' }], - isError: false, - }), - ] - - await expect(serializeMessagesWithImages(messages, imageOptions([], fileResolver()))).resolves.toEqual([ - { role: 'user', content: '' }, - { role: 'tool', tool_call_id: 'result', content: 'ok' }, - ]) - }) - - it('converts consecutive tool messages and preserves the empty fallback', async () => { - const messages = [ - createToolResultMessage({ - callId: ToolCallId('nested'), - content: [{ type: 'text', text: 'inside' }], - isError: false, - }), - createToolResultMessage({ callId: ToolCallId('empty'), content: [], isError: false }), - ] - - await expect(serializeMessagesWithImages(messages, imageOptions([], fileResolver()))).resolves.toEqual([ - { role: 'tool', tool_call_id: 'nested', content: 'inside' }, - { role: 'tool', tool_call_id: 'empty', content: '(no output)' }, - ]) - }) - - it('flushes tool-result images before system and assistant history', async () => { - const imageResult = (id: string) => createToolResultMessage({ - callId: ToolCallId(id), - content: [{ type: 'image', attachment: imageRef() }], - isError: false, - }) - const messages = [ - imageResult('before-system'), - createMessage({ - role: 'system', - content: [{ type: 'text', text: 'system history' }], - source: { kind: 'system-prompt' }, - }), - imageResult('before-assistant'), - createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'assistant history' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ] - - const wire = await serializeMessagesWithImages(messages, imageOptions([imageRef()], fileResolver())) - expect(wire).toEqual([ - { - role: 'tool', - tool_call_id: 'before-system', - content: expect.stringContaining('request preview 1x1px') as string, - }, - expect.objectContaining({ role: 'user' }), - { role: 'system', content: 'system history' }, - { - role: 'tool', - tool_call_id: 'before-assistant', - content: expect.stringContaining('request preview 1x1px') as string, - }, - expect.objectContaining({ role: 'user' }), - { role: 'assistant', content: 'assistant history' }, - ]) - }) - - it('projects surface-offloaded occurrences to placeholders without resolving them', async () => { - const resolveFileId = fileResolver() - const png = imageRef('image/png', 3) - const jpeg = imageRef('image/jpeg', 3) - const images = imageOptions([png, jpeg], resolveFileId, 4) - images.resolveImageAccess = ref => ref.mediaType === 'image/png' - ? { readonlyPath: '/tmp/dsh/objects/png' } - : undefined - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: png, offloaded: true }, - { type: 'image', attachment: jpeg }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), images) - - expect(wire.messages[0]).toMatchObject({ - role: 'user', - content: [ - { - type: 'text', - text: expect.stringContaining(`image omitted to fit request image limits; ${png.attachmentId}. Normalized copy (read-only; may be resized or re-encoded): "/tmp/dsh/objects/png"`) as string, - }, - { type: 'text', text: expect.stringContaining(`Image ${jpeg.attachmentId}`) as string }, - { type: 'file', file_id: 'file-api-image' }, - ], - }) - expect(resolveFileId).toHaveBeenCalledTimes(1) - expect(resolveFileId.mock.calls[0]?.[0]).toMatchObject({ attachment: { mediaType: 'image/jpeg' } }) - }) - it('rejects retained inline images beyond the bound with the quantized count to offload', async () => { - const ref = imageRef('image/png', 3) - // 21 retained 3-byte images encode to 84 base64 bytes against an 80-byte bound with a - // 40-byte quantum: the removal crosses the quantum at the eleventh oldest occurrence. - await expect(serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: ref })), - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), inlineImageOptions([ref], 80, 40))).rejects.toMatchObject({ - code: 'IMAGE_OFFLOAD_REQUIRED', - failure: { offloadImages: 11 }, - }) - }) - - it('uses the configured image-count quantum before resolving files', async () => { - const ref = imageRef() - const resolveFileId = fileResolver() - const messages = [createUserMessage({ - content: Array.from({ length: 3 }, () => ({ type: 'image' as const, attachment: ref })), - source: { kind: 'test' }, - })] - await expect(serializeRequestWithImages(request({ messages }), { - ...imageOptions([ref], resolveFileId), maxImagesPerRequest: 2, countQuantum: 2, - })).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', failure: { offloadImages: 2 } }) - expect(resolveFileId).not.toHaveBeenCalled() - }) - - it('counts only retained occurrences against the bound', async () => { - const ref = imageRef('image/png', 3) - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - ...Array.from({ length: 11 }, () => ({ type: 'image' as const, attachment: ref, offloaded: true as const })), - ...Array.from({ length: 10 }, () => ({ type: 'image' as const, attachment: ref })), - ], - source: { kind: 'test' }, - })], - }), inlineImageOptions([ref], 80, 40)) - const content = wire.messages[0]?.content - expect(JSON.stringify(content).match(/image omitted to fit request image limits/g)).toHaveLength(11) - expect(JSON.stringify(content).match(/"type":"image_url"/g)).toHaveLength(10) - }) - it('rejects an unprepared image while computing exact request bytes', async () => { - const ref = imageRef() - await expect(serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), imageOptions([]))).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) - }) - - it.each(['system', 'assistant'] as const)('rejects an image in %s history before reading attachments', async (role) => { - const resolveFileId = vi.fn() - const content: ContentBlock[] = [{ type: 'image', attachment: imageRef() }] - const message = role === 'system' - ? createMessage({ role, content, source: { kind: 'system-prompt' } }) - : createMessage({ role, content, source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) - await expect(serializeMessagesWithImages([message], imageOptions([imageRef()], resolveFileId))) - .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveFileId).not.toHaveBeenCalled() - }) - - it('rejects developer history on text and image paths before reading attachments', async () => { - const resolveFileId = fileResolver() - const message = createDeveloperMessage({ - content: [{ type: 'tool-addition', toolName: 'search' }], - source: { kind: 'test' }, - }) - const failure = { code: 'UNSUPPORTED_CONTENT', message: 'Developer messages are not supported yet' } - expect(() => serializeMessages([message])).toThrow(expect.objectContaining(failure)) - await expect(serializeMessagesWithImages([message], imageOptions([], resolveFileId))) - .rejects.toMatchObject(failure) - await expect(serializeRequestWithImages(request({ messages: [message] }), imageOptions([], resolveFileId))) - .rejects.toMatchObject(failure) - expect(resolveFileId).not.toHaveBeenCalled() - }) - - it('rejects unsupported image history before request offloading can replace it', async () => { - const resolveFileId = vi.fn() - await expect(serializeRequestWithImages(request({ - messages: [createMessage({ - role: 'system', - content: [{ type: 'image', attachment: imageRef('image/png', 300) }], - source: { kind: 'system-prompt' }, - })], - }), imageOptions([imageRef('image/png', 300)], resolveFileId, 1))) - .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveFileId).not.toHaveBeenCalled() - }) - - it('prepends the request system prompt on the image path', async () => { - const ref = imageRef() - const wire = await serializeRequestWithImages(request({ - system: 'system prompt', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), imageOptions([ref])) - expect(wire.messages[0]).toEqual({ role: 'system', content: 'system prompt' }) - }) - - it('preserves stable file-resolution failure codes', async () => { - const failure = new Error('Stored attachment bytes are corrupt.') as Error & { code: string } - failure.code = 'ATTACHMENT_CORRUPT' - const resolveFileId = vi.fn(() => Promise.reject(failure)) - await expect(serializeMessagesWithImages([createUserMessage({ - content: [{ type: 'image', attachment: imageRef() }], - source: { kind: 'test' }, - })], imageOptions([imageRef()], resolveFileId))) - .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) - }) - - it('preserves non-attachment resolver failures', async () => { - const failure = new Error('resolver failed') - const resolveFileId = vi.fn(() => Promise.reject(failure)) - await expect(serializeMessagesWithImages([createUserMessage({ - content: [{ type: 'image', attachment: imageRef() }], - source: { kind: 'test' }, - })], imageOptions([imageRef()], resolveFileId))).rejects.toBe(failure) - }) -}) - -describe('review fixes: assistant content shapes', () => { - it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => { - // Aborted/empty assistant turns: no text, no calls → "". The earlier - // null shape was live-falsified: the API 400s a null-content assistant - // message without tool_calls ("content or tool_calls must be set"). - const wire = serializeMessages([createMessage({ - role: 'assistant', content: [], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })]) - expect(wire).toEqual([{ role: 'assistant', content: '' }]) - }) - - it('serializes a reasoning-ONLY assistant message as "" content beside its reasoning', () => { - // The model can answer entirely in the reasoning channel (a v4-flash - // greeting did, live). Content must still be SET — a null here poisoned - // the session log and bricked every later turn of that session. - const wire = serializeMessages([createMessage({ - role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })]) - expect(wire).toEqual([{ - role: 'assistant', content: '', reasoning_content: '你好!有什么我可以帮你的吗?', - }]) - }) - - it('serializes tool-call turns with empty string content, not null', () => { - const wire = serializeMessages([createMessage({ - role: 'assistant', - content: [{ type: 'tool-call', id: ToolCallId('c'), name: 'f', arguments: '{}' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })]) - expect(wire[0]).toMatchObject({ content: '' }) + it('rejects unsupported roles and unavailable image capabilities before HTTP', async () => { + const history = [result('a', [image])] + await expect(prepareImages(history, connection, MODEL, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + await expect(prepareImages(history, connection, model, undefined, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + await expect(prepareImages([assistant([image])], connection, model, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(() => body([result('a', [image])])).toThrow(/image/) + expect(() => body([assistant([image])])).toThrow(/assistant/) + // @ts-expect-error -- malformed provider input can carry a retired nested result block. + expect(() => body([result('a', [{ type: 'tool-result', toolCallId: ToolCallId('nested'), content: [] }])])) + .toThrow(/user\/tool-result content tool-result/) + expect(() => serialize(options({ model }), connection, [result('a', [image])], new Map([[ref.attachmentId, version]]), access, undefined, new Map())) + .toThrow(/request file id is missing/) }) }) diff --git a/packages/llm/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts deleted file mode 100644 index bdf1508c60..0000000000 --- a/packages/llm/llm-deepseek/tests/sse.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { LlmError } from '@deepseek-ai/dsh-llm' -import { DONE, parseSse } from '../src/protocols/chat-completions/sse.ts' - -/** - * DeepSeek protocol contract only: the [DONE] sentinel and STREAM_CLOSED on - * EOF without it. SSE framing (chunk splits, CRLF, multi-data joins, comments) - * is eventsource-parser's contract, not re-proven here. - */ - -/** Build an SSE byte stream from string fragments (fragments = network reads). */ -function bytes(...fragments: string[]): ReadableStream> { - const encoder = new TextEncoder() - return new ReadableStream({ - start(controller) { - for (const fragment of fragments) controller.enqueue(encoder.encode(fragment)) - controller.close() - }, - }) -} - -async function collect(stream: AsyncIterable): Promise { - const out: string[] = [] - for await (const item of stream) out.push(item) - return out -} - -describe('parseSse', () => { - it('yields event payloads and the DONE sentinel', async () => { - const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('reports comments out of band without yielding them', async () => { - const comments: string[] = [] - const events = await collect(parseSse( - bytes(': keep-alive\n\ndata: {"a":1}\n\ndata: [DONE]\n\n'), - (comment) => { comments.push(comment) }, - )) - expect(comments).toEqual(['keep-alive']) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('stops yielding after DONE even when more data follows', async () => { - const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n'))) - expect(events).toEqual([DONE]) - }) - - it('throws STREAM_CLOSED when the stream ends without DONE', async () => { - await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(LlmError) - await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(/without \[DONE\]/) - }) - - it('throws STREAM_CLOSED for an empty stream', async () => { - await expect(collect(parseSse(bytes()))).rejects.toThrow(/without \[DONE\]/) - }) - - it('throws STREAM_CLOSED for a mid-event close', async () => { - await expect(collect(parseSse(bytes('data: {"a"')))).rejects.toThrow(/without \[DONE\]/) - }) - - it('treats a final DONE missing its blank-line terminator as truncation', async () => { - // Spec-strict framing: an event dispatches only on its blank-line - // terminator, so an unterminated tail at EOF is STREAM_CLOSED — real - // providers always terminate events, so a missing terminator is truncation. - await expect(collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]')))).rejects.toThrow(/without \[DONE\]/) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/messages/stream.spec.ts b/packages/llm/llm-deepseek/tests/stream.spec.ts similarity index 97% rename from packages/llm/llm-deepseek/tests/messages/stream.spec.ts rename to packages/llm/llm-deepseek/tests/stream.spec.ts index 51d54beef0..aa86d48404 100644 --- a/packages/llm/llm-deepseek/tests/messages/stream.spec.ts +++ b/packages/llm/llm-deepseek/tests/stream.spec.ts @@ -1,8 +1,8 @@ /** Protocol invariants at JSON/SSE boundaries, including partial and failed responses. */ import { describe, expect, it } from 'vitest' -import { translate } from '../../src/protocols/messages/translate.ts' -import { parseSse } from '../../src/protocols/messages/sse.ts' -import { providerError } from '../../src/protocols/messages/transport.ts' +import { translate } from '../src/translate.ts' +import { parseSse } from '../src/sse.ts' +import { providerError } from '../src/transport.ts' import { assemble, chunks, end, events, MODEL, sse, start, textEvents } from './helpers.ts' describe('Messages stream', () => { diff --git a/packages/llm/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts deleted file mode 100644 index 5e19f0c94f..0000000000 --- a/packages/llm/llm-deepseek/tests/translate.spec.ts +++ /dev/null @@ -1,446 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { BlockAssembler, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm' -import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import { DONE } from '../src/protocols/chat-completions/sse.ts' -import { mapFinishReason, mapUsage, translate } from '../src/protocols/chat-completions/translate.ts' - -async function* feed(...payloads: (string | object)[]): AsyncGenerator { - for (const payload of payloads) { - yield typeof payload === 'string' ? payload : JSON.stringify(payload) - } -} - -async function collect(stream: AsyncIterable): Promise { - const out: StreamChunk[] = [] - for await (const chunk of stream) out.push(chunk) - return out -} - -/** The live first-chunk signature: role + null content + EMPTY reasoning. */ -const firstChunk = { choices: [{ delta: { role: 'assistant', content: null, reasoning_content: '' } }] } - -describe('translate: text', () => { - it('streams a text block and defers finish to DONE', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'Hel' } }] }, - { choices: [{ delta: { content: 'lo' } }] }, - { choices: [{ delta: { content: '' }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 2 } }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'block-start', index: 0, blockType: 'text' }, - { type: 'text-delta', index: 0, text: 'Hel' }, - { type: 'text-delta', index: 0, text: 'lo' }, - { type: 'block-end', index: 0, block: { type: 'text', text: 'Hello' } }, - { type: 'usage', usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }, - { type: 'finish', reason: { kind: 'stop' } }, - ]) - }) - - it('assembles into the message BlockAssembler expects', async () => { - const assembler = new BlockAssembler() - for await (const chunk of translate(feed( - firstChunk, - { choices: [{ delta: { content: 'hi' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) { - assembler.push(chunk) - } - const result = { message: assembler.message({ provider: 'mock', model: 'mock' }), finish: assembler.finish } - expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }]) - expect(result.finish).toEqual({ kind: 'stop' }) - }) -}) - -describe('translate: reasoning', () => { - it('does NOT open a reasoning block for the empty first-chunk signature', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'plain' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) - expect(chunks.some(chunk => chunk.type === 'block-start' && chunk.blockType === 'reasoning')).toBe(false) - }) - - it('streams reasoning then text as separate blocks', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: null, reasoning_content: 'think' } }] }, - { choices: [{ delta: { content: null, reasoning_content: 'ing' } }] }, - { choices: [{ delta: { content: 'answer', reasoning_content: null } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'block-start', index: 0, blockType: 'reasoning' }, - { type: 'reasoning-delta', index: 0, text: 'think' }, - { type: 'reasoning-delta', index: 0, text: 'ing' }, - { type: 'block-start', index: 1, blockType: 'text' }, - { type: 'text-delta', index: 1, text: 'answer' }, - { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'thinking' } }, - { type: 'block-end', index: 1, block: { type: 'text', text: 'answer' } }, - { type: 'finish', reason: { kind: 'stop' } }, - ]) - }) - - it('treats an entirely absent reasoning_content field as non-thinking', async () => { - const chunks = await collect(translate(feed( - { choices: [{ delta: { role: 'assistant', content: 'x' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-start')).toEqual([ - { type: 'block-start', index: 0, blockType: 'text' }, - ]) - }) -}) - -describe('translate: tool calls', () => { - it('reassembles a tool call from fragmented argument deltas (live capture shape)', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_00_x', type: 'function', function: { name: 'get_weather', arguments: '' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"city"' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: ': "Paris"}' } }] } }] }, - { choices: [{ delta: { content: '' }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 28, completion_tokens: 6 } }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '' }, - { type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '{"city"' }, - { type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: ': "Paris"}' }, - { - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: 'call_00_x', name: 'get_weather', arguments: '{"city": "Paris"}' }, - }, - { type: 'usage', usage: { inputTokens: 28, outputTokens: 6, totalTokens: 34 } }, - { type: 'finish', reason: { kind: 'tool-calls' } }, - ]) - }) - - it('disambiguates parallel tool calls by wire index', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { - choices: [{ - delta: { - tool_calls: [ - { index: 0, id: 'a', type: 'function', function: { name: 'one', arguments: '{}' } }, - { index: 1, id: 'b', type: 'function', function: { name: 'two', arguments: '' } }, - ], - }, - }], - }, - { choices: [{ delta: { tool_calls: [{ index: 1, function: { arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - const ends = chunks.filter(chunk => chunk.type === 'block-end') - expect(ends).toEqual([ - { type: 'block-end', index: 0, block: { type: 'tool-call', id: 'a', name: 'one', arguments: '{}' } }, - { type: 'block-end', index: 1, block: { type: 'tool-call', id: 'b', name: 'two', arguments: '{}' } }, - ]) - }) - - it('interleaves text and tool-call blocks with distinct indices', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'Checking.' } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f', arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - const starts = chunks.filter(chunk => chunk.type === 'block-start') - expect(starts).toEqual([ - { type: 'block-start', index: 0, blockType: 'text' }, - { type: 'block-start', index: 1, blockType: 'tool-call' }, - ]) - }) -}) - -describe('translate: finish and usage handling', () => { - it('takes usage from a trailing usage-only chunk (docs shape)', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'x' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }], usage: null }, - { choices: [], usage: { prompt_tokens: 9, completion_tokens: 1 } }, - DONE, - ))) - expect(chunks.at(-2)).toEqual({ type: 'usage', usage: { inputTokens: 9, outputTokens: 1, totalTokens: 10 } }) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } }) - }) - - it('last usage wins when both attached and trailing arrive', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } }, - { choices: [], usage: { prompt_tokens: 2, completion_tokens: 2 } }, - DONE, - ))) - const usage = chunks.find(chunk => chunk.type === 'usage') - expect(usage).toEqual({ type: 'usage', usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 } }) - }) - - it('defaults to finish stop when no finish_reason ever arrives', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'x' } }] }, - DONE, - ))) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } }) - }) - - it('omits the usage chunk when none arrived', async () => { - const chunks = await collect(translate(feed(firstChunk, DONE))) - expect(chunks.some(chunk => chunk.type === 'usage')).toBe(false) - }) - - it('handles chunks with no choices at all', async () => { - const chunks = await collect(translate(feed({}, DONE))) - expect(chunks).toEqual([{ - type: 'finish', - reason: { - kind: 'error', - failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE }, - }, - }]) - }) - - it('classifies an explicit stop with no opened blocks as EMPTY_RESPONSE, after usage', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 7, completion_tokens: 0 } }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'usage', usage: { inputTokens: 7, outputTokens: 0, totalTokens: 7 } }, - { - type: 'finish', - reason: { - kind: 'error', - failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE }, - }, - }, - ]) - }) - - it('keeps a reasoning-only stream a successful stop (any opened block counts)', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: null, reasoning_content: 'mull' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } }) - }) - - it('leaves non-stop finishes unclassified even with no opened blocks', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: {}, finish_reason: 'length' }] }, - DONE, - ))) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'max-tokens' } }) - }) -}) - -describe('translate: errors', () => { - it('throws MALFORMED_RESPONSE for invalid JSON payloads', async () => { - await expect(collect(translate(feed('{bad json')))).rejects.toThrow(LlmError) - await expect(collect(translate(feed('{bad json')))).rejects.toThrow(/malformed SSE payload/) - }) - - it('throws STREAM_CLOSED when the payload source ends without DONE', async () => { - await expect(collect(translate(feed(firstChunk)))).rejects.toThrow(/without \[DONE\]/) - }) -}) - -describe('mapFinishReason', () => { - it.each([ - ['stop', { kind: 'stop' }], - ['tool_calls', { kind: 'tool-calls' }], - ['length', { kind: 'max-tokens' }], - ])('maps %s', (wire, expected) => { - expect(mapFinishReason(wire)).toEqual(expected) - }) - - it.each(['content_filter', 'insufficient_system_resource', 'mystery_reason'])( - 'maps %s to an error kind with the wire code', - (wire) => { - expect(mapFinishReason(wire)).toEqual({ - kind: 'error', - failure: { message: `model stopped: ${wire}`, code: wire.toUpperCase() }, - }) - }, - ) -}) - -describe('mapUsage', () => { - it('maps the full live-capture shape', () => { - expect(mapUsage({ - prompt_tokens: 283, - completion_tokens: 69, - total_tokens: 352, - prompt_cache_hit_tokens: 256, - prompt_cache_miss_tokens: 27, - prompt_tokens_details: { cached_tokens: 256 }, - completion_tokens_details: { reasoning_tokens: 24 }, - })).toEqual({ - // 283 wire prompt_tokens minus the 256 cached → 27 uncached input - // (TokenUsage counts are disjoint). - inputTokens: 27, - outputTokens: 69, - totalTokens: 352, - cacheReadTokens: 256, - reasoningTokens: 24, - }) - }) - - it('falls back to prompt_cache_hit_tokens when details are absent', () => { - expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2, prompt_cache_hit_tokens: 8 })) - .toEqual({ inputTokens: 2, outputTokens: 2, totalTokens: 12, cacheReadTokens: 8 }) - }) - - it('reconstructs an exact total when the wire omits it', () => { - expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2 })) - .toEqual({ inputTokens: 10, outputTokens: 2, totalTokens: 12 }) - }) - - it.each([ - ['contradictory total', { prompt_tokens: 10, completion_tokens: 2, total_tokens: 99 }], - ['negative prompt', { prompt_tokens: -1, completion_tokens: 2 }], - ['fractional prompt', { prompt_tokens: 1.5, completion_tokens: 2 }], - ['negative completion', { prompt_tokens: 2, completion_tokens: -1 }], - ['fractional completion', { prompt_tokens: 2, completion_tokens: 1.5 }], - ['unsafe aggregate', { prompt_tokens: Number.MAX_SAFE_INTEGER, completion_tokens: 1 }], - ])('omits the exact total for %s without changing existing buckets', (_name, wire) => { - expect(mapUsage(wire)).toEqual({ - inputTokens: wire.prompt_tokens, - outputTokens: wire.completion_tokens, - }) - }) -}) - -describe('translate: defensive tool-call branches', () => { - it('handles deltas that never carry id or name (empty-string fallbacks)', async () => { - const chunks = await collect(translate(feed( - firstChunk, - // Hypothetical lenient wire: argument fragments with no id/name at all. - { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' }, - { type: 'block-end', index: 0, block: { type: 'tool-call', id: '', name: '', arguments: '{}' } }, - { type: 'finish', reason: { kind: 'tool-calls' } }, - ]) - }) - - it('handles tool_call deltas with a function object but no arguments field', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', name: 'f', argumentsDelta: '' }) - }) - - it('handles tool_call deltas with no function object at all', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c' }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' }) - }) -}) - -describe('translate: tool-call identity across deltas', () => { - it('keeps the established identity when continuation deltas re-send it empty', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_00_x', type: 'function', function: { name: 'get_weather', arguments: '' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: '', type: 'function', function: { name: '', arguments: '{"city"' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: '', type: 'function', function: { name: '', arguments: ': "Paris"}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([{ - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: 'call_00_x', name: 'get_weather', arguments: '{"city": "Paris"}' }, - }]) - }) - - it('keeps the established identity when continuation deltas re-send it null', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'Glob', arguments: '' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: null, function: { name: null, arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([{ - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: 'call_1', name: 'Glob', arguments: '{}' }, - }]) - }) - - it('re-sending the same non-empty identity does not duplicate it', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'Glob', arguments: '' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'Glob', arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([{ - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: 'call_1', name: 'Glob', arguments: '{}' }, - }]) - }) - - it('maintains each parallel call identity separately under empty continuation deltas', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { - choices: [{ - delta: { - tool_calls: [ - { index: 0, id: 'a', type: 'function', function: { name: 'one', arguments: '' } }, - { index: 1, id: 'b', type: 'function', function: { name: 'two', arguments: '' } }, - ], - }, - }], - }, - { - choices: [{ - delta: { - tool_calls: [ - { index: 1, id: '', function: { name: '', arguments: '{"b":1}' } }, - { index: 0, id: '', function: { name: '', arguments: '{"a":1}' } }, - ], - }, - }], - }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([ - { type: 'block-end', index: 0, block: { type: 'tool-call', id: 'a', name: 'one', arguments: '{"a":1}' } }, - { type: 'block-end', index: 1, block: { type: 'tool-call', id: 'b', name: 'two', arguments: '{"b":1}' } }, - ]) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/upload-index.spec.ts b/packages/llm/llm-deepseek/tests/upload-index.spec.ts index ddff23e060..989a921e69 100644 --- a/packages/llm/llm-deepseek/tests/upload-index.spec.ts +++ b/packages/llm/llm-deepseek/tests/upload-index.spec.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import { DeepSeekFileId } from '../src/common/file-id.ts' -import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/common/upload-index.ts' +import { DeepSeekFileId } from '../src/file-id.ts' +import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts' const ATTACHMENT = AttachmentId(`sha256:${'a'.repeat(64)}`) const VARIANT = ImageVariantId(`sha256:${'b'.repeat(64)}`) diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index a502f07c50..6322a88f27 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -39,7 +39,6 @@ async function harness( const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', baseURL, streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000, retryPolicy: { @@ -196,7 +195,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'error', error: { message: 'SSE stream ended without [DONE]', code: 'STREAM_CLOSED' } } }, + data: { reason: { kind: 'error', error: { message: 'DeepSeek Messages stream ended before message_stop', code: 'STREAM_CLOSED' } } }, }) }) @@ -242,7 +241,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { data: { reason: { kind: 'error', error: { code: 'TRANSPORT' } } }, }) if (end?.type === 'turn/end' && end.data.reason.kind === 'error') { - expect(end.data.reason.error.message).toContain('DeepSeek API request to') + expect(end.data.reason.error.message).toBe('DeepSeek Messages transport failed') } }) }) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 6fac0fb24a..dbc3bd15e7 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: cdda682cebc720d51692a1b0c4c8691fa84359a3 -README.zh.md: 8c679ecc6910376f7270450ae5be867d0da353fc +README.md: 04de24592379dcb0d815f3b033ca2f9f22e2ff6a +README.zh.md: bee35d4a0b62dd61a61b435dfc4eb27b62cdce69 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index cdda682ceb..04de245923 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -127,7 +127,7 @@ File detection reads current content, including tool-role result content, on eve Read these pages when the package-level contract is not enough. They move from the shared types to the concrete adapters, the retry executor, and the measurement service. - [LLM streaming subsystem](../../../docs/subsystems/llm-streaming.md) — the message and block types, compact Assistant stream records, the `StreamChunk` protocol, and the adapter contract. -- [llm-deepseek adapter](../llm-deepseek/README.md) — the direct DeepSeek chat-completions implementation. +- [llm-deepseek adapter](../llm-deepseek/README.md) — the direct DeepSeek Messages implementation. - [llm-pi-ai adapter](../llm-pi-ai/README.md) — the pi-ai-backed multi-provider implementation. - [llm-retry](../llm-retry/README.md) — the retry executor that re-runs failed model requests. - [Token meter](../token-meter/README.md) — replay-aware request and context pressure measurement. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 8c679ecc69..bee35d4a0b 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -127,7 +127,7 @@ for await (const chunk of ctx.llm.stream({ 当包级约定不够用时阅读以下页面。它们从共享类型逐步进入具体适配器、重试执行器与计量服务。 - [LLM(大语言模型)流式子系统](../../../docs/subsystems/llm-streaming.zh.md)——消息与块类型、紧凑的 Assistant 流记录、`StreamChunk` 协议与适配器约定。 -- [llm-deepseek 适配器](../llm-deepseek/README.zh.md)——DeepSeek chat-completions 直连实现。 +- [llm-deepseek 适配器](../llm-deepseek/README.zh.md)——DeepSeek Messages 直连实现。 - [llm-pi-ai 适配器](../llm-pi-ai/README.zh.md)——基于 pi-ai 的多提供方实现。 - [llm-retry](../llm-retry/README.zh.md)——重跑失败模型请求的重试执行器。 - [Token 计量](../token-meter/README.zh.md)——具备回放感知的请求与上下文压力测量。 diff --git a/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts b/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts index 7bf73bc369..2a91b33f33 100644 --- a/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts +++ b/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts @@ -53,7 +53,7 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged : name === '@deepseek-ai/dsh-message-feedback' ? { config: { maxNoteBytes: 1024 } } : name === '@deepseek-ai/dsh-llm-deepseek' - ? { config: { protocol: 'chat-completions', baseURL: server!.baseURL } } + ? { config: { baseURL: server!.baseURL } } : name === '@deepseek-ai/dsh-session-log-deepseek' ? { config: { enabled: true } } : {}, @@ -128,11 +128,11 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged expect(session.deriveMessages()).toEqual(messages) expect(await ctx.messageFeedback.list({ sessionId: session.id })).toEqual({ ok: true, value: { items: [] } }) for (const wire of server.requests) { - expect(wire.path).toBe('/chat/completions') + expect(wire.path).toBe('/v1/messages') expect(wire.body).not.toHaveProperty('dsh_feedback') expect(wire.body).toMatchObject({ model: 'deepseek-v4-flash', messages: [ - { role: 'user', content: 'Question' }, - { role: 'assistant', content: 'Answer' }, + { role: 'user', content: [{ type: 'text', text: 'Question' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'Answer' }] }, ] }) } await ctx.sessions.flush(session) diff --git a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts index 0458b7696d..380a47fbda 100644 --- a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts +++ b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts @@ -11,7 +11,7 @@ import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-dee import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' -import { end, MODEL, server, sse, start } from '../../../llm/llm-deepseek/tests/messages/helpers.ts' +import { end, MODEL, server, sse, start } from '../../../llm/llm-deepseek/tests/helpers.ts' import SubagentRuntime, { type SubagentRunEndInfo } from '../src/index.ts' import { loadStoredSession } from './persistence-helpers.ts' diff --git a/packages/test-support/llm-mock-server/README.i18n.yaml b/packages/test-support/llm-mock-server/README.i18n.yaml index 10ca40b376..22fcf568b1 100644 --- a/packages/test-support/llm-mock-server/README.i18n.yaml +++ b/packages/test-support/llm-mock-server/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/llm-mock-server/README.md -README.md: be311d9c83bcd35ad5bd0c003f6e95861b6d6976 -README.zh.md: 7a022131d9ef09d9ef89c91cae418f093f77aca1 +README.md: 2f83cf66c02728820a4f8aa6f14b1a091d092626 +README.zh.md: ad85853fb4a20c3ccdb444508da8032c18d7538a diff --git a/packages/test-support/llm-mock-server/README.md b/packages/test-support/llm-mock-server/README.md index be311d9c83..2f83cf66c0 100644 --- a/packages/test-support/llm-mock-server/README.md +++ b/packages/test-support/llm-mock-server/README.md @@ -1,5 +1,5 @@ --- -description: "Scriptable OpenAI-compatible fault server for testing LLM adapters and recovery policy without a provider key, for test authors and demos." +description: "Scriptable Messages-compatible fault server for testing LLM adapters and recovery policy without a provider key, for test authors and demos." kind: "package-library" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package gives tests and demos a scriptable OpenAI-compatible HTTP/SSE endpoint, so they can exercise model-provider failures and successes without a provider key. Each accepted `/chat/completions` request consumes the next scripted behavior, including resets, stalls, malformed chunks, rate limits, server errors, completions, and tool calls. Test authors can run it with `pnpm run mock:llm` or call `startMockLlmServer`, which returns captured requests for assertions. Seeded `random` behavior supports reproducible mixed-failure stress runs. +This package gives tests and demos a scriptable Messages-compatible HTTP/SSE endpoint, so they can exercise model-provider failures and successes without a provider key. Each accepted `/v1/messages` request consumes the next scripted behavior, including resets, stalls, malformed chunks, rate limits, server errors, completions, and tool calls. Test authors can run it with `pnpm run mock:llm` or call `startMockLlmServer`, which returns captured requests for assertions. Seeded `random` behavior supports reproducible mixed-failure stress runs. ## Table of Contents @@ -39,7 +39,7 @@ pnpm run mock:llm \ --partial-text "discard this half" ``` -Point the shipping DeepSeek adapter at the server; it appends `/chat/completions` to the configured base: +Point the shipping DeepSeek adapter at the server; it sends requests to `/messages` beneath the server's `/v1` root: ```sh DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ @@ -88,13 +88,13 @@ Omitting `--seed` generates one and prints it in the `ready` record. `--random-w ### Timing and content controls -The CLI exposes `--success-text`, `--partial-text`, `--reasoning-text`, `--chunk-size`, `--chunk-delay-ms`, `--disconnect-delay-ms`, `--retry-after-ms`, `--request-id`, `--tool-name`, and `--tool-arguments`. Millisecond delays are bounded integers within Node's timer range; `retryAfterMs` must also be positive. The library accepts the same camel-case options. An optional exact `apiKey` validates `Authorization: Bearer `; omission accepts any token. +The CLI exposes `--success-text`, `--partial-text`, `--reasoning-text`, `--chunk-size`, `--chunk-delay-ms`, `--disconnect-delay-ms`, `--retry-after-ms`, `--request-id`, `--tool-name`, and `--tool-arguments`. Millisecond delays are bounded integers within Node's timer range; `retryAfterMs` must also be positive. The library accepts the same camel-case options. An optional exact `apiKey` validates `x-api-key: `; omission accepts any token. ### What can go wrong - **The script runs out** — exhaustion returns a structured HTTP 500; set `--repeat-last` or lengthen the sequence when a run needs more requests. - **Random weights without a positive concrete behavior are rejected** — every entry must name an existing behavior and at least one must carry positive weight. -- **Invalid requests do not consume the script** — wrong methods, paths, bearer tokens, and malformed JSON get ordinary 4xx responses, so a misconfigured client can burn retries without advancing the sequence. +- **Invalid requests do not consume the script** — wrong methods, paths, API keys, and malformed JSON get ordinary 4xx responses, so a misconfigured client can burn retries without advancing the sequence. ----- @@ -108,7 +108,7 @@ This section explains the design of the server; the observable behavior is fully ### Design -The server is built on one rule: each accepted chat-completions request consumes exactly one behavior from an arrival-ordered FIFO cursor, and the server never retries or interprets harness policy. Validation happens before the cursor advances — only a `POST` whose path ends in `/chat/completions`, with a valid bearer token when one is configured and a parseable JSON body, consumes the script; everything else receives an ordinary 4xx. `random` entries resolve at request time through a seeded PRNG over the configured weights, so a run is reproducible from its printed seed. +The server is built on one rule: each accepted Messages request consumes exactly one behavior from an arrival-ordered FIFO cursor, and the server never retries or interprets harness policy. Validation happens before the cursor advances — only a `POST` whose path ends in `/v1/messages`, with a valid API key when one is configured and a parseable JSON body, consumes the script; everything else receives an ordinary 4xx. `random` entries resolve at request time through a seeded PRNG over the configured weights, so a run is reproducible from its printed seed. ### Source map diff --git a/packages/test-support/llm-mock-server/README.zh.md b/packages/test-support/llm-mock-server/README.zh.md index 7a022131d9..ad85853fb4 100644 --- a/packages/test-support/llm-mock-server/README.zh.md +++ b/packages/test-support/llm-mock-server/README.zh.md @@ -1,5 +1,5 @@ --- -description: "用于在无提供方密钥的情况下测试 LLM(大语言模型)适配器与恢复策略的可通过脚本控制的 OpenAI 兼容故障服务器,面向测试作者与演示。" +description: "用于在无提供方密钥的情况下测试 LLM(大语言模型)适配器与恢复策略的可通过脚本控制的 Messages 兼容故障服务器,面向测试作者与演示。" kind: "package-library" --- @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -本包为测试与演示提供可编脚本的 OpenAI 兼容 HTTP/SSE(Server-Sent Events)端点,使其无需提供方密钥即可检验模型提供方的失败与成功。每个已接受的 `/chat/completions` 请求依次消费下一个脚本行为,包括重置、停滞、畸形分片、限流、服务器错误、补全与工具调用。测试作者可以通过 `pnpm run mock:llm` 运行服务器,也可以调用 `startMockLlmServer`,后者会返回捕获的请求供断言使用。带种子的 `random` 行为支持可复现的混合故障压力运行。 +本包为测试与演示提供可编脚本的 Messages 兼容 HTTP/SSE(Server-Sent Events)端点,使其无需提供方密钥即可检验模型提供方的失败与成功。每个已接受的 `/v1/messages` 请求依次消费下一个脚本行为,包括重置、停滞、畸形分片、限流、服务器错误、补全与工具调用。测试作者可以通过 `pnpm run mock:llm` 运行服务器,也可以调用 `startMockLlmServer`,后者会返回捕获的请求供断言使用。带种子的 `random` 行为支持可复现的混合故障压力运行。 ## 目录 @@ -39,7 +39,7 @@ pnpm run mock:llm \ --partial-text "discard this half" ``` -将发布的 DeepSeek 适配器指向服务器;它会将 `/chat/completions` 追加到已配置 base: +将发布的 DeepSeek 适配器指向服务器;它会向服务器 `/v1` 根路径下的 `/messages` 发送请求: ```sh DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ @@ -88,13 +88,13 @@ pnpm run mock:llm \ ### 时序与内容控制 -CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-size`、`--chunk-delay-ms`、`--disconnect-delay-ms`、`--retry-after-ms`、`--request-id`、`--tool-name` 与 `--tool-arguments`。毫秒延迟是 Node 定时器范围内的有界整数;`retryAfterMs` 还必须为正数。库接受相同的 camel-case 选项。可选的 `apiKey` 会精确验证 `Authorization: Bearer `;省略时接受任何 token。 +CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-size`、`--chunk-delay-ms`、`--disconnect-delay-ms`、`--retry-after-ms`、`--request-id`、`--tool-name` 与 `--tool-arguments`。毫秒延迟是 Node 定时器范围内的有界整数;`retryAfterMs` 还必须为正数。库接受相同的 camel-case 选项。可选的 `apiKey` 会精确验证 `x-api-key: `;省略时接受任何 token。 ### 可能出什么问题 - **脚本耗尽**——耗尽时返回结构化 HTTP 500;当一次运行需要更多请求时设置 `--repeat-last` 或加长序列。 - **没有正权重具体行为的随机权重会被拒绝**——每个条目都必须命名现有行为,且至少一个条目带正权重。 -- **无效请求不消费脚本**——错误方法、路径、Bearer token 与畸形 JSON 会收到普通 4xx 响应,因此配置错误的客户端可能耗尽重试却不推进序列。 +- **无效请求不消费脚本**——错误方法、路径、API key 与畸形 JSON 会收到普通 4xx 响应,因此配置错误的客户端可能耗尽重试却不推进序列。 ----- @@ -108,7 +108,7 @@ CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-s ### 设计 -服务器建立在一个规则之上:每个已接受的 chat-completions 请求从按到达顺序排列的 FIFO 游标消费恰好一个行为,服务器从不重试或解读 harness 策略。校验先于游标推进——只有 `POST` 且路径以 `/chat/completions` 结尾、配置密钥时携带有效 Bearer token、且 JSON 正文可解析的请求才消费脚本;其余请求都收到普通 4xx。`random` 条目在请求时通过带种子的 PRNG 按配置权重解析,因此一次运行可由其打印出的种子复现。 +服务器建立在一个规则之上:每个已接受的 Messages 请求从按到达顺序排列的 FIFO 游标消费恰好一个行为,服务器从不重试或解读 harness 策略。校验先于游标推进——只有 `POST` 且路径以 `/v1/messages` 结尾、配置密钥时携带有效 API key、且 JSON 正文可解析的请求才消费脚本;其余请求都收到普通 4xx。`random` 条目在请求时通过带种子的 PRNG 按配置权重解析,因此一次运行可由其打印出的种子复现。 ### 源码地图 diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index 6bed08aa80..225c056215 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", - "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", + "description": "Scriptable Messages HTTP/SSE fault server for LLM recovery tests", "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" diff --git a/packages/test-support/llm-mock-server/src/index.ts b/packages/test-support/llm-mock-server/src/index.ts index b07a2bb09b..029548b1e8 100644 --- a/packages/test-support/llm-mock-server/src/index.ts +++ b/packages/test-support/llm-mock-server/src/index.ts @@ -1,6 +1,6 @@ /** - * Scriptable OpenAI-compatible HTTP/SSE server for transport, protocol, and - * semantic-empty LLM recovery tests. Each accepted chat-completions request + * Scriptable Messages HTTP/SSE server for transport, protocol, and + * semantic-empty LLM recovery tests. Each accepted Messages request * consumes one behavior; the server never retries or interprets harness policy. * * @module @deepseek-ai/dsh-llm-mock-server @@ -95,7 +95,7 @@ export type MockLlmServerEvent = /** Captured wire request and its final server-side outcome. */ export interface MockLlmRequestRecord { - /** One-based accepted chat-completions request number. */ + /** One-based accepted Messages request number. */ readonly attempt: number /** Script entry consumed for this request before random resolution. */ readonly scriptBehavior: MockLlmBehavior | 'script_exhausted' @@ -119,7 +119,7 @@ export interface MockLlmServerOptions { readonly host?: string /** TCP port; zero requests an OS-assigned port. */ readonly port?: number - /** Optional exact bearer token; omission accepts any authorization header. */ + /** Optional exact API key; omission accepts any x-api-key header. */ readonly apiKey?: string /** Ordered request behaviors; exhaustion fails loud unless `repeatLast` is true. */ readonly sequence: readonly MockLlmBehavior[] @@ -155,7 +155,7 @@ export interface MockLlmServerOptions { /** Running mock server and captured request state. */ export interface MockLlmServer { - /** Base URL without `/v1`; both root and `/v1` chat-completions paths are accepted. */ + /** Base URL without `/v1`; the endpoint is `/v1/messages`. */ readonly baseURL: string /** Actual bound port, including an OS-assigned value. */ readonly port: number @@ -325,7 +325,7 @@ function writeSse(record: MockLlmRequestRecord, response: ServerResponse, payloa } function writeDone(record: MockLlmRequestRecord, response: ServerResponse): void { - writeSse(record, response, '[DONE]') + writeSse(record, response, { type: 'message_stop' }) } function finishRecord( @@ -364,10 +364,22 @@ function httpError( finishRecord(options, record, 'completed') } +function startMessage(record: MockLlmRequestRecord, response: ServerResponse): void { + writeSse(record, response, { + type: 'message_start', + message: { id: 'mock-message', type: 'message', role: 'assistant', model: 'mock-model', content: [], usage: { input_tokens: 3, output_tokens: 0 } }, + }) +} + +function startText(record: MockLlmRequestRecord, response: ServerResponse, index: number): void { + writeSse(record, response, { type: 'content_block_start', index, content_block: { type: 'text', text: '' } }) +} + function terminalChunk(reason: string, outputTokens: number): unknown { return { - choices: [{ index: 0, delta: { content: '' }, finish_reason: reason }], - usage: { prompt_tokens: 3, completion_tokens: outputTokens }, + type: 'message_delta', + delta: { stop_reason: reason, stop_sequence: null }, + usage: { output_tokens: outputTokens }, } } @@ -393,9 +405,10 @@ async function streamText( response: ServerResponse, text: string, delayMs: number, + index = 0, ): Promise { for (const chunk of splitText(text, options.chunkSize)) { - writeSse(record, response, { choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }] }) + writeSse(record, response, { type: 'content_block_delta', index, delta: { type: 'text_delta', text: chunk } }) if (!await pause(delayMs, response)) return false } return true @@ -405,13 +418,16 @@ async function completeText( options: ResolvedOptions, record: MockLlmRequestRecord, response: ServerResponse, - reason: 'stop' | 'length', + reason: 'end_turn' | 'max_tokens', delayMs: number, + index = 0, ): Promise { - if (!await streamText(options, record, response, options.successText, delayMs)) { + startText(record, response, index) + if (!await streamText(options, record, response, options.successText, delayMs, index)) { finishRecord(options, record, 'client_closed') return } + writeSse(record, response, { type: 'content_block_stop', index }) writeSse(record, response, terminalChunk(reason, Array.from(options.successText).length)) writeDone(record, response) response.end() @@ -434,27 +450,11 @@ async function disconnect( function toolCallChunks(options: ResolvedOptions): readonly unknown[] { const midpoint = Math.max(1, Math.floor(options.toolArguments.length / 2)) return [ - { - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: 0, - id: 'mock-call-1', - type: 'function', - function: { name: options.toolName, arguments: options.toolArguments.slice(0, midpoint) }, - }], - }, - finish_reason: null, - }], - }, - { - choices: [{ - index: 0, - delta: { tool_calls: [{ index: 0, function: { arguments: options.toolArguments.slice(midpoint) } }] }, - finish_reason: null, - }], - }, + { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'mock-call-1', name: options.toolName, input: {} } }, + ...[options.toolArguments.slice(0, midpoint), options.toolArguments.slice(midpoint)].map(partial => ({ + type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: partial }, + })), + { type: 'content_block_stop', index: 0 }, ] } @@ -478,7 +478,8 @@ async function runBehavior( return case 'empty': openSse(response) - writeSse(record, response, terminalChunk('stop', 0)) + startMessage(record, response) + writeSse(record, response, terminalChunk('end_turn', 0)) writeDone(record, response) response.end() finishRecord(options, record, 'completed') @@ -490,18 +491,22 @@ async function runBehavior( return case 'stream_eof': openSse(response) - writeSse(record, response, { choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] }) + startMessage(record, response) response.end() finishRecord(options, record, 'completed') return case 'partial_eof': openSse(response) + startMessage(record, response) + startText(record, response, 0) await streamText(options, record, response, options.partialText, 0) response.end() finishRecord(options, record, 'completed') return case 'partial_disconnect': openSse(response) + startMessage(record, response) + startText(record, response, 0) if (!await streamText(options, record, response, options.partialText, options.chunkDelayMs)) return await disconnect(options, record, response) return @@ -518,14 +523,15 @@ async function runBehavior( return case 'malformed_event': openSse(response) - writeSse(record, response, { choices: [null] }) + writeSse(record, response, { type: 'content_block_start', index: 0, content_block: null }) writeDone(record, response) response.end() finishRecord(options, record, 'completed') return case 'wrong_content_type': openSse(response, 'application/json') - await completeText(options, record, response, 'stop', 0) + startMessage(record, response) + await completeText(options, record, response, 'end_turn', 0) return case 'rate_limit': httpError(options, record, response, 429, 'mock rate limit', 'rate_limit') @@ -558,32 +564,37 @@ async function runBehavior( return case 'success': openSse(response) - await completeText(options, record, response, 'stop', 0) + startMessage(record, response) + await completeText(options, record, response, 'end_turn', 0) return case 'reasoning_success': openSse(response) + startMessage(record, response) + writeSse(record, response, { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } }) for (const chunk of splitText(options.reasoningText, options.chunkSize)) { - writeSse(record, response, { - choices: [{ index: 0, delta: { reasoning_content: chunk }, finish_reason: null }], - }) + writeSse(record, response, { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: chunk } }) } - await completeText(options, record, response, 'stop', 0) + writeSse(record, response, { type: 'content_block_stop', index: 0 }) + await completeText(options, record, response, 'end_turn', 0, 1) return case 'tool_call_success': openSse(response) + startMessage(record, response) for (const chunk of toolCallChunks(options)) writeSse(record, response, chunk) - writeSse(record, response, terminalChunk('tool_calls', 2)) + writeSse(record, response, terminalChunk('tool_use', 2)) writeDone(record, response) response.end() finishRecord(options, record, 'completed') return case 'max_tokens': openSse(response) - await completeText(options, record, response, 'length', 0) + startMessage(record, response) + await completeText(options, record, response, 'max_tokens', 0) return case 'slow_success': openSse(response) - await completeText(options, record, response, 'stop', options.chunkDelayMs) + startMessage(record, response) + await completeText(options, record, response, 'end_turn', options.chunkDelayMs) return } } @@ -615,8 +626,8 @@ function chooseRandomBehavior( } /** - * Start a local chat-completions server that consumes one configured behavior - * per accepted request. Only a `POST` path ending in `/chat/completions` consumes the script; + * Start a local Messages server that consumes one configured behavior + * per accepted request. Only a `POST` path ending in `/v1/messages` consumes the script; * invalid routes, methods, authorization, and JSON receive ordinary 4xx * responses. Closing the handle terminates stalled connections. * @@ -652,13 +663,13 @@ export async function startMockLlmServer(options: MockLlmServerOptions): Promise response.writeHead(405, { allow: 'POST' }).end() return } - if (!path.endsWith('/chat/completions')) { + if (!path.endsWith('/v1/messages')) { response.writeHead(404).end() return } - if (resolved.apiKey !== undefined && request.headers.authorization !== `Bearer ${resolved.apiKey}`) { + if (resolved.apiKey !== undefined && request.headers['x-api-key'] !== resolved.apiKey) { response.writeHead(401, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ error: { message: 'invalid mock bearer token', code: 'invalid_api_key' } })) + response.end(JSON.stringify({ error: { message: 'invalid mock API key', code: 'invalid_api_key' } })) return } diff --git a/packages/test-support/llm-mock-server/tests/server.spec.ts b/packages/test-support/llm-mock-server/tests/server.spec.ts index a32e5cc8b2..5d7bbfaba6 100644 --- a/packages/test-support/llm-mock-server/tests/server.spec.ts +++ b/packages/test-support/llm-mock-server/tests/server.spec.ts @@ -18,24 +18,24 @@ async function start( return server } -function chat( +function messages( server: MockLlmServer, options: { path?: string; key?: string; body?: string; signal?: AbortSignal } = {}, ): Promise { - return fetch(`${server.baseURL}${options.path ?? '/v1/chat/completions'}`, { + return fetch(`${server.baseURL}${options.path ?? '/v1/messages'}`, { method: 'POST', headers: { 'content-type': 'application/json', - ...options.key === undefined ? {} : { authorization: `Bearer ${options.key}` }, + ...options.key === undefined ? {} : { 'x-api-key': options.key }, }, body: options.body ?? JSON.stringify({ model: 'mock', messages: [], stream: true }), ...options.signal === undefined ? {} : { signal: options.signal }, }) } -function rawChat(server: MockLlmServer, chunks: readonly Buffer[]): Promise { +function rawMessages(server: MockLlmServer, chunks: readonly Buffer[]): Promise { return new Promise((resolve, reject) => { - const outgoing = request(`${server.baseURL}/v1/chat/completions`, { + const outgoing = request(`${server.baseURL}/v1/messages`, { method: 'POST', headers: { 'content-type': 'application/json' }, }, (response) => { @@ -59,22 +59,22 @@ describe('mock LLM server wire behaviors', () => { onEvent: (event) => { events.push(event) }, }) - const response = await chat(server, { key: 'mock-key' }) + const response = await messages(server, { key: 'mock-key' }) const body = await response.text() expect(response.status).toBe(200) expect(response.headers.get('content-type')).toContain('text/event-stream') - expect(body).toContain('"content":"rec"') - expect(body).toContain('"content":"ove"') - expect(body).toContain('"content":"red"') - expect(body).toContain('"finish_reason":"stop"') - expect(body).toContain('data: [DONE]') + expect(body).toContain('"text":"rec"') + expect(body).toContain('"text":"ove"') + expect(body).toContain('"text":"red"') + expect(body).toContain('"stop_reason":"end_turn"') + expect(body).toContain('"type":"message_stop"') expect(server.requests).toEqual([expect.objectContaining({ attempt: 1, behavior: 'success', - path: '/v1/chat/completions', + path: '/v1/messages', body: { model: 'mock', messages: [], stream: true }, - chunksSent: 5, + chunksSent: 8, outcome: 'completed', })]) expect(events).toEqual([ @@ -83,7 +83,7 @@ describe('mock LLM server wire behaviors', () => { attempt: 1, scriptBehavior: 'success', behavior: 'success', - path: '/v1/chat/completions', + path: '/v1/messages', }, { type: 'result', @@ -91,39 +91,39 @@ describe('mock LLM server wire behaviors', () => { scriptBehavior: 'success', behavior: 'success', outcome: 'completed', - chunksSent: 5, + chunksSent: 8, }, ]) }) - it('supports root paths and intentionally ignores telemetry observer failures', async () => { + it('supports gateway prefixes and intentionally ignores telemetry observer failures', async () => { const server = await start(['empty'], { onEvent() { throw new Error('observer failed') }, }) - const response = await chat(server, { path: '/chat/completions' }) + const response = await messages(server, { path: '/anthropic/v1/messages' }) expect(response.status).toBe(200) - expect(await response.text()).toContain('data: [DONE]') - expect(server.requests[0]).toMatchObject({ path: '/chat/completions', outcome: 'completed' }) + expect(await response.text()).toContain('"type":"message_stop"') + expect(server.requests[0]).toMatchObject({ path: '/anthropic/v1/messages', outcome: 'completed' }) }) it.each([ ['empty_body', 0, ''] as const, ['stream_eof', 1, '"role":"assistant"'] as const, - ['partial_eof', 1, 'discarded partial response'] as const, + ['partial_eof', 3, 'discarded partial response'] as const, ['malformed_json', 2, 'data: {not-json'] as const, - ['malformed_event', 2, '"choices":[null]'] as const, + ['malformed_event', 2, '"content_block":null'] as const, ])('serves %s without inventing a terminal completion', async (behavior, chunks, marker) => { const server = await start([behavior], { chunkSize: 100 }) - const response = await chat(server) + const response = await messages(server) const body = await response.text() expect(response.status).toBe(200) expect(body).toContain(marker) if (behavior !== 'malformed_json' && behavior !== 'malformed_event') { - expect(body).not.toContain('[DONE]') + expect(body).not.toContain('message_stop') } expect(server.requests[0]).toMatchObject({ behavior, chunksSent: chunks, outcome: 'completed' }) }) @@ -137,7 +137,7 @@ describe('mock LLM server wire behaviors', () => { let headersReceived = false await expect((async () => { - const response = await chat(server) + const response = await messages(server) headersReceived = true await response.text() })()).rejects.toThrow() @@ -145,7 +145,7 @@ describe('mock LLM server wire behaviors', () => { expect(headersReceived).toBe(receivesHeaders) expect(server.requests[0]).toMatchObject({ behavior, - chunksSent: behavior === 'partial_disconnect' ? 1 : 0, + chunksSent: behavior === 'partial_disconnect' ? 3 : 0, outcome: 'reset', }) }) @@ -153,7 +153,7 @@ describe('mock LLM server wire behaviors', () => { it('holds a stalled stream until the client aborts and server close remains idempotent', async () => { const server = await start(['stall']) const controller = new AbortController() - const response = await chat(server, { signal: controller.signal }) + const response = await messages(server, { signal: controller.signal }) expect(response.status).toBe(200) expect(server.requests[0]).toMatchObject({ behavior: 'stall', outcome: 'stalled' }) @@ -180,7 +180,7 @@ describe('mock LLM server wire behaviors', () => { }, }) const controller = new AbortController() - const response = await chat(server, { signal: controller.signal }) + const response = await messages(server, { signal: controller.signal }) controller.abort() await expect(response.text()).rejects.toThrow() await result.promise @@ -197,7 +197,7 @@ describe('mock LLM server wire behaviors', () => { const characterOffset = encoded.indexOf(Buffer.from('你')) expect(characterOffset).toBeGreaterThanOrEqual(0) - await rawChat(server, [ + await rawMessages(server, [ encoded.subarray(0, characterOffset + 1), encoded.subarray(characterOffset + 1), ]) @@ -209,7 +209,7 @@ describe('mock LLM server wire behaviors', () => { const server = await start(['success'], { host: '::1' }) expect(server.baseURL).toMatch(/^http:\/\/\[::1\]:\d+$/) - expect((await chat(server)).status).toBe(200) + expect((await messages(server)).status).toBe(200) }) it('emits reasoning, tool calls, max-token finishes, slow chunks, and a wrong content type', async () => { @@ -231,17 +231,17 @@ describe('mock LLM server wire behaviors', () => { const bodies: string[] = [] const contentTypes: Array = [] for (let index = 0; index < 5; index += 1) { - const response = await chat(server) + const response = await messages(server) contentTypes.push(response.headers.get('content-type')) bodies.push(await response.text()) } - expect(bodies[0]).toContain('"reasoning_content":"th"') + expect(bodies[0]).toContain('"thinking":"th"') expect(bodies[1]).toContain('"name":"lookup"') - expect(bodies[1]).toContain('"arguments":"{\\"id"') - expect(bodies[1]).toContain('"finish_reason":"tool_calls"') - expect(bodies[2]).toContain('"finish_reason":"length"') - expect(bodies[3]).toContain('"finish_reason":"stop"') + expect(bodies[1]).toContain('"partial_json":"{\\"id"') + expect(bodies[1]).toContain('"stop_reason":"tool_use"') + expect(bodies[2]).toContain('"stop_reason":"max_tokens"') + expect(bodies[3]).toContain('"stop_reason":"end_turn"') expect(contentTypes[4]).toBe('application/json') expect(server.requests).toHaveLength(5) expect(server.requests.every(record => record.outcome === 'completed')).toBe(true) @@ -257,7 +257,7 @@ describe('mock LLM server wire behaviors', () => { ['quota_exceeded', 429, 'insufficient_quota'] as const, ])('serves %s as a structured HTTP error', async (behavior, status, marker) => { const server = await start([behavior], { retryAfterMs: 1_001, requestId: 'mock-request-1' }) - const response = await chat(server) + const response = await messages(server) const body = await response.text() expect(response.status).toBe(status) @@ -270,15 +270,15 @@ describe('mock LLM server wire behaviors', () => { it('fails loud on script exhaustion and can explicitly repeat the final behavior', async () => { const exhausted = await start(['success'], { successText: 'once' }) - await (await chat(exhausted)).text() - const exhaustedResponse = await chat(exhausted) + await (await messages(exhausted)).text() + const exhaustedResponse = await messages(exhausted) expect(exhaustedResponse.status).toBe(500) expect(await exhaustedResponse.text()).toContain('mock script exhausted') expect(exhausted.requests.map(record => record.behavior)).toEqual(['success', 'script_exhausted']) const repeating = await start(['empty'], { repeatLast: true }) - await (await chat(repeating)).text() - await (await chat(repeating)).text() + await (await messages(repeating)).text() + await (await messages(repeating)).text() expect(repeating.requests.map(record => record.behavior)).toEqual(['empty', 'empty']) }) @@ -295,8 +295,8 @@ describe('mock LLM server wire behaviors', () => { running.push(first, second) for (let attempt = 0; attempt < 12; attempt += 1) { - await (await chat(first)).text() - await (await chat(second)).text() + await (await messages(first)).text() + await (await messages(second)).text() } const firstChoices = first.requests.map(record => record.behavior) @@ -307,12 +307,12 @@ describe('mock LLM server wire behaviors', () => { expect(first.requests.every(record => record.scriptBehavior === 'random')).toBe(true) }) - it('rejects invalid method, route, bearer token, and JSON without consuming the script', async () => { + it('rejects invalid method, route, API key, and JSON without consuming the script', async () => { const server = await start(['success'], { apiKey: 'expected' }) - const method = await fetch(`${server.baseURL}/v1/chat/completions`) - const route = await fetch(`${server.baseURL}/v1/other`, { method: 'POST', body: '{}' }) - const auth = await chat(server, { key: 'wrong' }) - const json = await chat(server, { key: 'expected', body: '{' }) + const method = await fetch(`${server.baseURL}/v1/messages`) + const route = await fetch(`${server.baseURL}/chat/completions`, { method: 'POST', body: '{}' }) + const auth = await messages(server, { key: 'wrong' }) + const json = await messages(server, { key: 'expected', body: '{' }) expect(method.status).toBe(405) expect(method.headers.get('allow')).toBe('POST') @@ -321,9 +321,9 @@ describe('mock LLM server wire behaviors', () => { expect(json.status).toBe(400) expect(server.requests).toHaveLength(0) - const emptyRequest = await fetch(`${server.baseURL}/v1/chat/completions`, { + const emptyRequest = await fetch(`${server.baseURL}/v1/messages`, { method: 'POST', - headers: { authorization: 'Bearer expected' }, + headers: { 'x-api-key': 'expected' }, }) expect(emptyRequest.status).toBe(200) expect(server.requests[0]?.behavior).toBe('success') diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py index acadb495be..34c8f5b1f0 100644 --- a/python/sdk/tests/manual_sdk_agent_smoke.py +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -26,16 +26,22 @@ class MockCompletionHandler(BaseHTTPRequestHandler): body = self.rfile.read(length).decode("utf-8") self.requests.append({ "path": self.path, - "authorization": self.headers.get("authorization"), + "api_key": self.headers.get("x-api-key"), "body": json.loads(body), }) self.send_response(200) self.send_header("content-type", "text/event-stream") self.end_headers() - self.wfile.write(b'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n') - self.wfile.write(b'data: {"choices":[{"delta":{"content":"SDK runtime reached the configured HTTP model endpoint."}}]}\n\n') - self.wfile.write(b'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":7,"completion_tokens":9}}\n\n') - self.wfile.write(b"data: [DONE]\n\n") + events = [ + {"type": "message_start", "message": {"id": "sdk-smoke", "model": "sdk-smoke-model", "usage": {"input_tokens": 7, "output_tokens": 0}}}, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "SDK runtime reached the configured HTTP model endpoint."}}, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 9}}, + {"type": "message_stop"}, + ] + for event in events: + self.wfile.write(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode()) def log_message(self, _format: str, *_args: object) -> None: return @@ -46,7 +52,7 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: session_root = dsh_home / "sessions" runtime_entry = repo_root / "apps/cli/src/bin.ts" server = ThreadingHTTPServer(("127.0.0.1", 0), MockCompletionHandler) - thread = threading.Thread(target=server.serve_forever, name="mock-openai-compatible-server", daemon=True) + thread = threading.Thread(target=server.serve_forever, name="mock-messages-server", daemon=True) thread.start() base_url = f"http://127.0.0.1:{server.server_address[1]}" @@ -86,7 +92,8 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: assert len(MockCompletionHandler.requests) == 1 request = MockCompletionHandler.requests[0] print(json.dumps(request, ensure_ascii=False, indent=2)[:4000]) - assert request["authorization"] == "Bearer sdk-smoke-key" + assert request["path"] == "/v1/messages" + assert request["api_key"] == "sdk-smoke-key" assert request["body"]["model"] == "sdk-smoke-model" jsonl_files = sorted(session_root.rglob("*.jsonl.zstd")) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index f5ecfea159..04e9f9c404 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1513,48 +1513,6 @@ "packages/llm/deepseek-llm-api-extensions/src/index.ts": { "1f519aabb00553384bc9c1c57064a1ed6fcd757032061e1b14e0718d91dd4817": 1 }, - "packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts": { - "598014dcfe77101781e3c6a02709097e3b0151a335e909c44719fac08fdea433": 1 - }, - "packages/llm/llm-deepseek/src/protocols/messages/adapter.ts": { - "598014dcfe77101781e3c6a02709097e3b0151a335e909c44719fac08fdea433": 1 - }, - "packages/llm/llm-deepseek/src/protocols/messages/replay.ts": { - "9ebdedf03d9d83739a3d01d1c579242aceca09ea5be1be17f96eacf29e07cdea": 1 - }, - "packages/llm/llm-deepseek/tests/adapter.spec.ts": { - "34a631902f39d3a40e1baabacdccb986c3a2205cf2a6c8c1880061b87f39609d": 1, - "3f65e71f59e11feed30a6ca5bf870cad688d0bd161f6d52fe263a7aaf01bdb19": 2, - "b744a3b1ea1021e620ae97ba95bab540b731d7df9634e06fa3830ea7d9e5ebac": 1, - "cf2695bf7b16a8d315df73d326e926645d03499422d39f6b97bc569dda04137e": 1, - "d09a59b309ad22650abede4b87a64dc6afaf18c08e4313635a22f0121c0a6a92": 1, - "eea7d67f2cd0210a9a2d81bce690d485033ea7f120396b8a8b7f2016ea7ae396": 1 - }, - "packages/llm/llm-deepseek/tests/loader-composition.spec.ts": { - "59c63f734e8bba862325f776e3fcbebd78a84cfb4161f8df41c216950d9bf351": 1 - }, - "packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts": { - "0dd149237441659d6dc629eb4e30914ac604236cbe023d90693e512b7e300a49": 1 - }, - "packages/llm/llm-deepseek/tests/messages/adapter.spec.ts": { - "ae6cc7d948e6e78f72fcd1764acabc731e7498c880b7d709710f8bfeee113ef8": 1, - "f1f72ca0a6b4cc00365db8c58c0477975b87b4293f0751815e1e971a7191c34d": 1 - }, - "packages/llm/llm-deepseek/tests/messages/files.spec.ts": { - "65121d4bf51b358c794748d5895b388149f6883d4ae88f6c16b39802813c223f": 1, - "eea7d67f2cd0210a9a2d81bce690d485033ea7f120396b8a8b7f2016ea7ae396": 1 - }, - "packages/llm/llm-deepseek/tests/messages/serialize.spec.ts": { - "02285954262e9d3346a33f080c4aeb89b7f6349014fd23d64e5aeb28a52f1ced": 1, - "8c7e2bcfcb534160c7df81de8490cfe4e9891e913c7c429a3f47f603d1129da3": 2, - "a394f4cff3c3d3755ffce930afc5cbee43f92de4ae2112a2325b64bdceb74ad2": 1, - "ab74fe25dbefba4c45ffa5bab2729b4bb372df0b946711e5d463686e4a0edc02": 2 - }, - "packages/llm/llm-deepseek/tests/serialize.spec.ts": { - "12712253c43410ab721190981c8b815d102f799ce6942bee3975b035c203dfa8": 1, - "98bef9260e144cee077af05112a07d3e9626caa43f06c66ad08f9c024f5469b1": 1, - "a394f4cff3c3d3755ffce930afc5cbee43f92de4ae2112a2325b64bdceb74ad2": 1 - }, "packages/llm/llm-pi-ai/src/catalog.ts": { "5b1064d0195802d24eaa5155c66eb381da6046a8ccd0cacc3d852336475da7ef": 1 }, diff --git a/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml b/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml index 93ac7431eb..23c2d736c9 100644 --- a/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml +++ b/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml @@ -44,4 +44,4 @@ - id: deepseek-v4-flash - id: deepseek-v4-pro - id: messages-input-history-snapshot - name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts' + name: '../../../packages/llm/llm-deepseek/tests/fixtures/input-history.ts' diff --git a/snapshots/session/deepseek-messages-input-history/cordis.yml b/snapshots/session/deepseek-messages-input-history/cordis.yml index d143031868..019b755c26 100644 --- a/snapshots/session/deepseek-messages-input-history/cordis.yml +++ b/snapshots/session/deepseek-messages-input-history/cordis.yml @@ -1,3 +1,3 @@ - insert: - id: messages-input-history-snapshot - name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts' + name: '../../../packages/llm/llm-deepseek/tests/fixtures/input-history.ts' diff --git a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml index 92bac46628..0ae3966b64 100644 --- a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml +++ b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml @@ -44,4 +44,4 @@ - id: deepseek-v4-flash - id: deepseek-v4-pro - id: messages-history-snapshot - name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/history.ts' + name: '../../../packages/llm/llm-deepseek/tests/fixtures/history.ts' diff --git a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml index 55b26d1e56..6a4ad74520 100644 --- a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml +++ b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml @@ -1,3 +1,3 @@ - insert: - id: messages-history-snapshot - name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/history.ts' + name: '../../../packages/llm/llm-deepseek/tests/fixtures/history.ts' diff --git a/snapshots/session/office-skills-no-renderer/cordis.yml b/snapshots/session/office-skills-no-renderer/cordis.yml index 4c963015fe..c4d3987921 100644 --- a/snapshots/session/office-skills-no-renderer/cordis.yml +++ b/snapshots/session/office-skills-no-renderer/cordis.yml @@ -6,7 +6,6 @@ - id: llm-deepseek config: - protocol: chat-completions thinking: enabled reasoningEffort: high maxTokens: 8192 From 2b29f31dc66f8fa31b83d4c65c3893e943a035d0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:41:42 +0800 Subject: [PATCH 2/7] test(llm): assert native Messages file metadata --- packages/llm/llm-deepseek/tests/adapter.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index f8971f33e7..6aafb2b04d 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -111,7 +111,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () expect(bodies.every(body => body.includes(`"file_id":"${uploads[0]}"`) && !body.includes('"type":"base64"'))).toBe(true) const fileId = Messages.DeepSeekFileId(uploads[0]!) const retrieved = await files.retrieve(fileId) - expect(retrieved).toMatchObject({ id: fileId, bytes: attachment.bytes, purpose: 'user_data' }) + expect(retrieved).toMatchObject({ id: fileId, bytes: attachment.bytes }) expect(retrieved.expiresAt).toBeUndefined() let page = await files.list({ limit: 1_000 }) const cursors = new Set() From 385a540d7e87f724342719df942e598f67126d74 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:22:28 +0800 Subject: [PATCH 3/7] Fix Messages-only consumer validation and document recovery --- ...-09-07-deepseek-messages-adapter.i18n.yaml | 4 ++-- .../2026-09-07-deepseek-messages-adapter.md | 4 ++-- ...2026-09-07-deepseek-messages-adapter.zh.md | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 4 +++- packages/llm/llm-deepseek/README.zh.md | 4 +++- packages/llm/llm-deepseek/src/index.ts | 1 - .../llm-deepseek/tests/dynamic-config.spec.ts | 22 +++++++++++++++++++ .../llm/llm-deepseek/tests/runtime.e2e.ts | 2 +- packages/llm/llm-pi-ai/tests/adapter.e2e.ts | 1 - .../test-support/llm-mock-server/src/index.ts | 2 +- 11 files changed, 37 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index 59be879b5b..13e41b76da 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: e8c84066e2ede5bdaf9e490b4ac0c9562cc8a557 -2026-09-07-deepseek-messages-adapter.zh.md: bde7fea769fbc5b4c2e622067b89ff8f29e91fb4 +2026-09-07-deepseek-messages-adapter.md: 29f712e57c8048ddb0c05b5f56fd4793c52bec5f +2026-09-07-deepseek-messages-adapter.zh.md: 3cf60fc2101fb835423b74a6532e5edcc5bee911 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index e8c84066e2..29f712e57c 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -6,7 +6,7 @@ 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 @@ -24,7 +24,7 @@ System updates use the existing [route capability](2026-09-02-in-history-system- 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. -The adapter uses 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 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index bde7fea769..3cf60fc210 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -部署可以通过 Anthropic Messages 网关或 chat-completions 调用 DeepSeek。Messages 对思考、签名、工具调用、工具结果和累计用量的表示不同。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。 +Messages 以原生协议字段表示思考、签名、工具调用、工具结果和累计用量。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。 ## 决策 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index b77dcd559e..ac07d1a93c 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 696db921f266ea0a5187aa9b98cf316343b6c74c -README.zh.md: 8e152a93de50dea7fc62cd3638957e6b1a879d23 +README.md: e03b1024bf83fa39603c3c2413c3270df70b7bda +README.zh.md: 9f14e175d95ff0cac3e77c972a9bbc940c9d9e9f diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 696db921f2..e03b1024bf 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -104,6 +104,8 @@ When `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registe ### 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 `/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_` 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. @@ -196,7 +198,7 @@ These limits define where the adapter stops and future work begins. They are cur - **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 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. -- The [Messages system-update e2e checks](tests/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`. +- 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`. ### Dev Note diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 8e152a93de..9f14e175d9 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -104,6 +104,8 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 ### 失败与恢复 +配置仅接受 Messages,不提供 `protocol` 字段。若解析报告 `protocol is not configurable`,请从 `/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_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。任何位置都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。 @@ -196,7 +198,7 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 - **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` 失败。 -- [Messages system 更新 e2e](tests/adapter.e2e.ts) 要求通过 `DEEPSEEK_IN_HISTORY_MODEL` 指定支持该能力的模型,例如 `deepseek-flash`,并使用 `high` 思考强度。该变量未设置或为空时跳过;普通 `off` 文本检查仍在有凭据时运行。关闭思考时已知的指令遵循不稳定,使这些 system 更新检查不适合使用 `off`。 +- [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`。 ### 开发备注 diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 579e977a9c..cc5cfdc52f 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -47,7 +47,6 @@ 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 type * from './wire-types.ts' export const name = 'llm-deepseek' export const inject = ['llm'] diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index a1f55b5113..9872b90f89 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -312,6 +312,28 @@ describe('request-level dynamic configuration', () => { 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 () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() diff --git a/packages/llm/llm-deepseek/tests/runtime.e2e.ts b/packages/llm/llm-deepseek/tests/runtime.e2e.ts index 62c727a719..ee49eb04e9 100644 --- a/packages/llm/llm-deepseek/tests/runtime.e2e.ts +++ b/packages/llm/llm-deepseek/tests/runtime.e2e.ts @@ -34,7 +34,7 @@ import { assemble, type AssembledResult } from './assemble.ts' * 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). + * requires $DEEPSEEK_FLASH_E2E=1. */ const FLASH = 'deepseek-v4-flash' diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index de8cd86532..2787a1ab7d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -24,7 +24,6 @@ async function harness(_model: string, config: Partial = {} providers: { deepseek: { ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY }, - baseURL: LlmDeepSeek.PUBLIC_BASE_URL, ...config, }, }, diff --git a/packages/test-support/llm-mock-server/src/index.ts b/packages/test-support/llm-mock-server/src/index.ts index 029548b1e8..9ddab9a2cc 100644 --- a/packages/test-support/llm-mock-server/src/index.ts +++ b/packages/test-support/llm-mock-server/src/index.ts @@ -628,7 +628,7 @@ function chooseRandomBehavior( /** * Start a local Messages server that consumes one configured behavior * per accepted request. Only a `POST` path ending in `/v1/messages` consumes the script; - * invalid routes, methods, authorization, and JSON receive ordinary 4xx + * invalid routes, methods, API keys, and JSON receive ordinary 4xx * responses. Closing the handle terminates stalled connections. * * @param options - listener, script, response content, timing, and telemetry options. From 9ab5c8e672173be7340f2370b0e85753e3820bf3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:30:03 +0800 Subject: [PATCH 4/7] Fix Messages real-API package resolution and usage assertions --- packages/llm/llm-deepseek/tests/adapter.e2e.ts | 4 +++- packages/llm/llm-deepseek/tests/runtime.e2e.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 6aafb2b04d..bffec7e6ce 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +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' @@ -151,7 +152,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () }) await ctx.loader.create({ name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' }) await ctx.loader.await() - const packageIdentity = JSON.parse(await readFile(new URL('../../../plugin-package-inventory-deepseek/package.json', import.meta.url), 'utf8')) as { name: string; version: string } + 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 fetchImpl = globalThis.fetch diff --git a/packages/llm/llm-deepseek/tests/runtime.e2e.ts b/packages/llm/llm-deepseek/tests/runtime.e2e.ts index ee49eb04e9..e2634f8d64 100644 --- a/packages/llm/llm-deepseek/tests/runtime.e2e.ts +++ b/packages/llm/llm-deepseek/tests/runtime.e2e.ts @@ -298,9 +298,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () maxTokens: 2000, }) expect(withThinking.finish.kind).toBe('stop') - expect(withThinking.message.content.some(block => block.type === 'reasoning')).toBe(true) + expect(withThinking.message.content.some(block => block.type === 'reasoning' && block.text.trim().length > 0)).toBe(true) expect(textOf(withThinking)).toContain('9.8') - expect(withThinking.usage?.reasoningTokens).toBeGreaterThan(0) + expect(withThinking.usage?.outputTokens).toBeGreaterThan(0) }) it( From 0959c5be45e21e30dc84123b457c935c6746a646 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:35:34 +0800 Subject: [PATCH 5/7] Align provider guides with Messages-only configuration --- .../implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml | 4 ++-- .../notes/implemented/testing/2026-06-19-real-api-e2e-ci.md | 2 +- .../implemented/testing/2026-06-19-real-api-e2e-ci.zh.md | 2 +- docs/user/develop/practice/llm-adapter.i18n.yaml | 4 ++-- docs/user/develop/practice/llm-adapter.md | 2 +- docs/user/develop/practice/llm-adapter.zh.md | 2 +- packages/client/ui-settings-models/README.i18n.yaml | 4 ++-- packages/client/ui-settings-models/README.md | 2 +- packages/client/ui-settings-models/README.zh.md | 2 +- packages/web/web-search-deepseek/README.i18n.yaml | 4 ++-- packages/web/web-search-deepseek/README.md | 2 +- packages/web/web-search-deepseek/README.zh.md | 2 +- 12 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index e50f4af442..b55464949e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md index b218d77f08..bdb20b3d54 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -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 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index 52c988dad0..b95f7df62e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -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.`——不打印值或长度。 ### 范围与运行时形态 diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 452a429302..a77a7ed049 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 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 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 7e30847cdf..32e412b10a 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -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. diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index 22a3c54f45..bfa9842ce5 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -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 之上实现。 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index c3c33c6f90..a0ed3e4fbe 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: c1567814b5de39f9b8819fcc8848e36c0a6ff842 -README.zh.md: baa0f02e8274a7146f9eade36d3ad464ac944b27 +README.md: a47f9a6dfa1faa9257c9b726140838e4858d33bb +README.zh.md: 925f8e976559ef7e975fcd164406f8c42f0b0098 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index c1567814b5..a47f9a6dfa 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -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. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index baa0f02e82..925f8e9765 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -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` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index 713c6c7851..f0d546d923 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-search-deepseek/README.md -README.md: 1fa8153b55e40edc869d6d332e3c35a46de0cd1b -README.zh.md: a38c1e126a23033cd40ef468f98b35d7cc56d869 +README.md: 0617204fdddb9273f75bd9ad4028026c7ffc8060 +README.zh.md: b45b449f2d379f730a6a4f341e8359ed28aaeb0b diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 1fa8153b55..0617204fdd 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -33,7 +33,7 @@ Choose this backend when a deployment wants DeepSeek's native server-side web se ### Minimal configuration -Load the web service and the provider; the key resolves from `ctx.credentials` when that service is mounted, otherwise from the process environment. The auxiliary search call has its own endpoint setting and uses the Anthropic-compatible base `https://api.deepseek.com/anthropic/v1`, with `/messages` appended. It reads `$DEEPSEEK_SEARCH_BASE_URL`, independently of the conversation adapter’s `$DEEPSEEK_BASE_URL` and protocol. +Load the web service and the provider; the key resolves from `ctx.credentials` when that service is mounted, otherwise from the process environment. The auxiliary search call has its own endpoint setting and uses the Anthropic-compatible base `https://api.deepseek.com/anthropic/v1`, with `/messages` appended. It reads `$DEEPSEEK_SEARCH_BASE_URL`, independently of the conversation adapter’s `$DEEPSEEK_BASE_URL`. ```yaml - name: '@deepseek-ai/dsh-web' diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index a38c1e126a..b45b449f2d 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 最小配置 -加载 web 服务与本提供方;密钥在已挂载 `ctx.credentials` 服务时从其解析,否则从进程环境解析。辅助搜索调用有独立的端点设置,使用 Anthropic 兼容基址 `https://api.deepseek.com/anthropic/v1`,并追加 `/messages`。它读取 `$DEEPSEEK_SEARCH_BASE_URL`,与会话适配器的 `$DEEPSEEK_BASE_URL` 和协议相互独立。 +加载 web 服务与本提供方;密钥在已挂载 `ctx.credentials` 服务时从其解析,否则从进程环境解析。辅助搜索调用有独立的端点设置,使用 Anthropic 兼容基址 `https://api.deepseek.com/anthropic/v1`,并追加 `/messages`。它读取 `$DEEPSEEK_SEARCH_BASE_URL`,与会话适配器的 `$DEEPSEEK_BASE_URL` 相互独立。 ```yaml - name: '@deepseek-ai/dsh-web' From ddf11a4f0e68ee643bb8ef150b4dfebdd64694f3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:33:40 +0800 Subject: [PATCH 6/7] fix: name Anthropic Messages in DeepSeek endpoint guidance --- .../expected/deepseek-messages-settings/cards.expected.md | 2 +- .../onboarding-deepseek-config/default-models.expected.md | 2 +- .../expected/onboarding-deepseek-config/models.expected.md | 2 +- packages/client/ui-settings-models/src/client/locales.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 7df4c7ce8a..0fd9f06075 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -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": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index be77e20835..b11ef1f610 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -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": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 9e344cc148..a6f4185486 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -21,7 +21,7 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic - - text: 请填写与当前连接配置兼容的 API 地址。 + - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 64cb2c73f8..81f1c011c9 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -36,7 +36,7 @@ export const en = { baseUrl: 'Base URL', baseUrlDefault: 'Provider default', deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', - deepSeekEndpointHint: 'Use an endpoint compatible with the configured connection.', + deepSeekEndpointHint: 'Use an API endpoint compatible with Anthropic Messages.', models: 'Models', modelsInherited: 'Using the adapter defaults', modelsCustomized: 'Customized model catalog', @@ -147,7 +147,7 @@ export const zh: { [Key in keyof typeof en]: string } = { baseUrl: 'API 地址', baseUrlDefault: '提供方默认', deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', - deepSeekEndpointHint: '请填写与当前连接配置兼容的 API 地址。', + deepSeekEndpointHint: '请填写兼容 Anthropic Messages 协议的 API 地址。', models: '模型目录', modelsInherited: '正在使用适配器默认模型', modelsCustomized: '已自定义模型目录', From 5a53758b03f244c8bb77e10b867f6ae4f7809cfc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:45:26 +0800 Subject: [PATCH 7/7] test: preserve plugin-manager integration after stack rebase --- .../tests/components.client.spec.tsx | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 2226aa0476..5de5e11780 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -101,20 +101,31 @@ function renderTab(state: Partial = {}, config: Partial { - 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() return { store,