From ea6f61f144420ea63b6af162b45f7a3b46a13f4c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:03:23 +0800 Subject: [PATCH] feat(deepseek): upload plugin package metadata (#2916) * feat(deepseek): upload plugin package metadata * feat(deepseek): apply metadata review feedback * docs(deepseek): specify request wire extensions * docs(notes): record inventory cache benchmark * docs(site): keep DeepSeek wire spec repository-only --- ...pseek-llm-api-request-extensions.i18n.yaml | 6 + ...-21-deepseek-llm-api-request-extensions.md | 63 +++++ ...-deepseek-llm-api-request-extensions.zh.md | 63 +++++ ...-deepseek-request-user-id-header.i18n.yaml | 4 +- ...6-08-11-deepseek-request-user-id-header.md | 4 +- ...8-11-deepseek-request-user-id-header.zh.md | 4 +- apps/cli/composition.md | 6 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 7 + docs/capability-seams.zh.md | 7 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 19 +- docs/config-catalog.zh.md | 19 +- ...deepseek-llm-api-wire-extensions.i18n.yaml | 6 + docs/deepseek-llm-api-wire-extensions.md | 76 ++++++ docs/deepseek-llm-api-wire-extensions.zh.md | 76 ++++++ docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 16 +- docs/module-graph.zh.md | 16 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 33 +++ docs/subsystems/llm-streaming.zh.md | 33 +++ examples/acp-agent/composition.md | 6 + examples/acp-agent/cordis.yml | 6 + examples/headless-agent/composition.md | 6 + examples/headless-agent/cordis.yml | 6 + examples/jsonrpc-agent/cordis.yml | 6 + examples/jsonrpc-agent/minimal.cordis.yml | 6 + examples/package.json | 2 + packages/bundle/base/cordis.patch.yml | 6 + packages/bundle/base/package.json | 2 + .../extensions/tool-cordis/src/api-catalog.ts | 43 ++++ packages/llm/README.i18n.yaml | 4 +- packages/llm/README.md | 4 +- packages/llm/README.zh.md | 4 +- .../README.i18n.yaml | 6 + .../llm/deepseek-llm-api-extensions/README.md | 28 +++ .../deepseek-llm-api-extensions/README.zh.md | 28 +++ .../deepseek-llm-api-extensions/package.json | 47 ++++ .../deepseek-llm-api-extensions/src/index.ts | 132 ++++++++++ .../src/invariant.ts | 27 ++ .../deepseek-llm-api-extensions/src/types.ts | 59 +++++ .../tests/registry.spec.ts | 154 ++++++++++++ .../deepseek-llm-api-extensions/tsconfig.json | 21 ++ packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 10 +- packages/llm/llm-deepseek/README.zh.md | 10 +- packages/llm/llm-deepseek/package.json | 5 + packages/llm/llm-deepseek/src/adapter.ts | 32 ++- packages/llm/llm-deepseek/src/index.ts | 5 + .../llm/llm-deepseek/tests/adapter.e2e.ts | 23 ++ .../llm/llm-deepseek/tests/adapter.spec.ts | 159 +++++++++++- .../tests/loader-composition.spec.ts | 44 +++- packages/llm/llm-deepseek/tsconfig.json | 3 + packages/llm/llm-pi-ai/tests/adapter.spec.ts | 1 + .../README.i18n.yaml | 6 + .../README.md | 43 ++++ .../README.zh.md | 43 ++++ .../package.json | 67 +++++ .../src/index.ts | 198 +++++++++++++++ .../src/invariant.ts | 27 ++ .../src/types.ts | 19 ++ .../tests/inventory.spec.ts | 233 ++++++++++++++++++ .../tsconfig.json | 39 +++ packages/preset/agent-presets/src/mount.ts | 4 +- .../test-support/llm-replay/README.i18n.yaml | 4 +- packages/test-support/llm-replay/README.md | 4 +- packages/test-support/llm-replay/README.zh.md | 4 +- packages/test-support/llm-replay/package.json | 7 + packages/test-support/llm-replay/src/index.ts | 44 +++- .../llm-replay/tests/llm-replay.spec.ts | 93 ++++++- .../test-support/llm-replay/tsconfig.json | 3 + pnpm-lock.yaml | 76 ++++++ python/sdk-runtime/package.json | 2 + .../runtime/cordis.yml | 6 + scripts/gen-cordis-catalog.ts | 6 + scripts/gen-doc-graphs.ts | 9 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.host.json | 2 + website/docs.ts | 2 + 81 files changed, 2272 insertions(+), 44 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md create mode 100644 .agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md create mode 100644 docs/deepseek-llm-api-wire-extensions.i18n.yaml create mode 100644 docs/deepseek-llm-api-wire-extensions.md create mode 100644 docs/deepseek-llm-api-wire-extensions.zh.md create mode 100644 packages/llm/deepseek-llm-api-extensions/README.i18n.yaml create mode 100644 packages/llm/deepseek-llm-api-extensions/README.md create mode 100644 packages/llm/deepseek-llm-api-extensions/README.zh.md create mode 100644 packages/llm/deepseek-llm-api-extensions/package.json create mode 100644 packages/llm/deepseek-llm-api-extensions/src/index.ts create mode 100644 packages/llm/deepseek-llm-api-extensions/src/invariant.ts create mode 100644 packages/llm/deepseek-llm-api-extensions/src/types.ts create mode 100644 packages/llm/deepseek-llm-api-extensions/tests/registry.spec.ts create mode 100644 packages/llm/deepseek-llm-api-extensions/tsconfig.json create mode 100644 packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml create mode 100644 packages/llm/plugin-package-inventory-deepseek/README.md create mode 100644 packages/llm/plugin-package-inventory-deepseek/README.zh.md create mode 100644 packages/llm/plugin-package-inventory-deepseek/package.json create mode 100644 packages/llm/plugin-package-inventory-deepseek/src/index.ts create mode 100644 packages/llm/plugin-package-inventory-deepseek/src/invariant.ts create mode 100644 packages/llm/plugin-package-inventory-deepseek/src/types.ts create mode 100644 packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts create mode 100644 packages/llm/plugin-package-inventory-deepseek/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml new file mode 100644 index 0000000000..a12ce16819 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.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/architecture/2026-08-21-deepseek-llm-api-request-extensions.md +2026-08-21-deepseek-llm-api-request-extensions.md: d83b53adcfbf41f9addde0b7d4ac9ab1a8572d2f +2026-08-21-deepseek-llm-api-request-extensions.zh.md: 47d7ce574c189f8d71d28963910e948c86169198 diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md new file mode 100644 index 0000000000..d83b53adcf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md @@ -0,0 +1,63 @@ +# Agent Note: DeepSeek LLM API request extensions for plugin package metadata + +Status: implemented + +English | [中文](2026-08-21-deepseek-llm-api-request-extensions.zh.md) + +## Problem + +Provider-side diagnosis needs the exact active plugin package versions that produced an official DeepSeek request. The existing browser-facing plugin inventory reports configured Loader rows and lifecycle phases but owns neither package-manifest resolution nor the requesting agent's standing preset composition. + +This metadata belongs only on the official DeepSeek adapter path. Adding it to `GenerateOptions` or the provider-neutral LLM seam would expose a DeepSeek wire concept to pi-ai and every future adapter. + +The adapter also needs one plugin-owned extension point. Importing Loader, preset, and package-manifest logic directly into `llm-deepseek` would make the transport own metadata discovery and prevent independent request fields from evolving as plugins. + +## Decision + +`@deepseek-ai/dsh-deepseek-llm-api-extensions` registers `ctx.deepseekLlmApiExtensions`, an additive registry of top-level fields for `deepseek-official` request bodies. A contributor claims one declaration-merged field with `register()`. The adapter invokes `prepare()` after serializing the exact wire messages, passes the request cancellation signal, rejects preparation or base-field collision before HTTP, merges the detached fields, and calls the captured `accept()` transaction after HTTP 2xx. The registry stops awaiting preparation after cancellation even if a contributor ignores the signal. Acceptance failures remain request failures under `REQUEST_EXTENSION`; transport and non-2xx failures never accept a contribution. A composition without the registry retains the reusable base adapter. + +Shipped compositions mount the registry and the default-on plugin-package contributor. Keyless `deepseek-official` replay invokes preparation with a synthetic empty base body and the same acceptance transaction before its first recorded chunk, preserving post-2xx extension side effects rather than field bytes. The provider-neutral `llm` package and `llm-pi-ai` contain no extension type, service lookup, field merge, or acceptance call. + +## Plugin package field + +`@deepseek-ai/dsh-plugin-package-inventory-deepseek` owns the default-on `dsh_plugin_packages` field from the `llm` package family. It reads active non-group entries from the host Loader tree and, for a live requesting Agent, its standing preset tree. Node package resolution locates the owning manifest without requiring a `./package.json` export. Ordinary entries resolve from their owning tree, while a standing preset root mirrors its Loader's intentional harness-base override and nested includes retain their own bases. An anonymous nearest manifest marks a loose module; a named manifest must carry a version. Exact name/version pairs are deduplicated with deterministic ordering; simultaneously active versions remain separate. + +Disabled, pending, failed, unloading, disposed, structural, loose non-package, ordinary dependency, programmatic child-fiber, and in-memory dynamic-plugin entries are outside this package inventory. This definition reports package-backed composition facts the runtime can prove instead of inventing provenance for arbitrary callbacks. + +## Deferred inventory caching + +The implementation deliberately recalculates the active package set for every request while caching manifest identities for the process lifetime. A synthetic host-only benchmark on Node v24.16.0, macOS arm64 used unique active relative plugin packages, 20 warm-up requests, then 500 measured requests for 25 and 100 entries and 250 for 500 entries. “First request” includes uncached manifest reads; “cached-provider median” returns a prebuilt field through the same registry, so it retains `structuredClone()` and freeze costs but excludes adapter JSON serialization and network time. + +| Active entries | First request | Current warm median | Current warm p95 | Cached-provider median | +|---:|---:|---:|---:|---:| +| 25 | 1.23 ms | 0.05 ms | 0.07 ms | 0.02 ms | +| 100 | 2.23 ms | 0.14 ms | 0.24 ms | 0.04 ms | +| 500 | 10.22 ms | 0.60 ms | 0.79 ms | 0.18 ms | + +These measurements keep the cache deferred: even 500 entries stay below one millisecond at steady state, and the estimated saving is about 0.42 ms before unavoidable JSON serialization. A real profile showing material `prepare()` latency is the trigger to add the cache rather than a fixed entry-count threshold. + +The deferred design uses one monotonic inventory epoch. A global `internal/status` listener advances it whenever a Loader entry's root fiber crosses the `FiberState.ACTIVE` boundary, covering dependency activation, disablement, unload, and HMR without a time-based stale window. The contributor caches the Host snapshot by epoch, caches each standing preset `EntryTree` in a `WeakMap`, and caches the combined Host-plus-preset result by tree and epoch. Already-sorted snapshots merge and deduplicate exact `(name, version)` pairs in linear time. A calculation whose epoch changes before settlement retries instead of publishing a stale snapshot; disposed preset trees remain collectible through the `WeakMap`. + +The process-lifetime manifest-identity cache remains separate because in-process package-version replacement is not supported. + +## Verification + +Registry tests pin duplicate ownership, effect-scoped disposal, detached field values, concurrent and abortable preparation, receiver-preserving acceptance, one acceptance settlement, and failure aggregation. Package-inventory tests pin default-on and explicit-off policies, host and standing-preset discovery, conflicting Loader resolution bases, manifest resolution, lifecycle filtering, and exact name/version ordering. The direct adapter mock proves pre-HTTP preparation failure, cancellation, non-2xx non-acceptance, 2xx acceptance before a later stream failure, and field collision. Keyless replay pins post-2xx extension acceptance, real Loader composition inspects the default metadata field, one credentialed real-API request mounts the production contributor, and pi-ai tests retain their unchanged wire requests. + +## Alternatives considered + +**Add generic metadata to `GenerateOptions` or `ctx.llm`.** Rejected because the value and acceptance timing are DeepSeek wire semantics; a provider-neutral request would make every adapter understand or ignore a foreign field. + +**Hard-wire package discovery into `llm-deepseek`.** Rejected because the adapter would import Loader, preset, and package-manifest logic. The registry keeps transport responsible only for field merge and HTTP acceptance. + +**Inventory every live Cordis fiber.** Rejected because programmatic and in-memory fibers have no authoritative npm package provenance. Loader-backed host and preset entries provide exact resolvable package identity. + +**Cache one process-global list or expire it on a TTL.** Rejected because one immutable list is incorrect for Loader lifecycle and per-Session presets, while a TTL permits stale metadata between expiry boundaries. The deferred epoch design invalidates on the authoritative active-state transition instead. + +**Replace the complete field with a content hash or server-side inventory reference.** Rejected because it changes standalone request reconstruction and requires endpoint state plus a later wire version. That is a wire-byte protocol change, not a computation-cache optimization. + +## Consequences + +Official DeepSeek requests carry active package versions to their resolved `baseURL`, including configured gateways. The field is model-hidden and adds no prompt tokens or KV-cache changes. Manifest resolution, field collision, acceptance handling, or provider schema rejection fails the model request rather than silently dropping metadata. + +Direct calls without a live Agent still carry the host package inventory. The [DeepSeek request-identity decision](../feature/2026-08-11-deepseek-request-user-id-header.md) continues to own user/session headers, which remain outside the body. diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md new file mode 100644 index 0000000000..47d7ce574c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md @@ -0,0 +1,63 @@ +# Agent Note: DeepSeek LLM API 插件包元数据请求扩展 + +Status: implemented + +[English](2026-08-21-deepseek-llm-api-request-extensions.md) | 中文 + +## 问题 + +提供方侧诊断需要产生一条 DeepSeek 官方请求的确切存活插件包版本。现有面向浏览器的插件清单会报告已配置 Loader 配置项与生命周期阶段,但既不拥有包 manifest(元数据清单)解析,也不拥有请求 Agent 的 standing preset 组合。 + +该元数据只属于 DeepSeek 官方适配器路径。把它加入 `GenerateOptions` 或提供方无关的 LLM seam,会让 pi-ai 与未来每个适配器接触 DeepSeek 协议概念。 + +适配器还需要一个由插件拥有的扩展点。若 `llm-deepseek` 直接导入 Loader、preset 与包 manifest 逻辑,传输层就会拥有元数据发现,并阻止独立请求字段作为插件分别演进。 + +## 决策 + +`@deepseek-ai/dsh-deepseek-llm-api-extensions` 注册 `ctx.deepseekLlmApiExtensions`,即 `deepseek-official` 请求正文顶层字段的增量注册表。贡献方通过 `register()` 认领一个经声明合并的字段。适配器在序列化确切协议消息后调用 `prepare()`、传入请求取消信号,在 HTTP 前拒绝准备失败或基础字段冲突,合并分离字段,并在 HTTP 2xx 后调用捕获的 `accept()` 事务。即使贡献方忽略信号,注册表也会在取消后停止等待准备。接受失败仍以 `REQUEST_EXTENSION` 使请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。 + +随附组合会挂载注册表与默认开启的插件包贡献方。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。提供方无关的 `llm` 包与 `llm-pi-ai` 不包含任何扩展类型、服务查找、字段合并或接受调用。 + +## 插件包字段 + +`@deepseek-ai/dsh-plugin-package-inventory-deepseek` 从 `llm` 包家族中拥有默认开启的 `dsh_plugin_packages` 字段。它会读取宿主 Loader 树的存活非 group 配置项,并为存活请求 Agent 读取其 standing preset 树。Node 包解析会定位所属 manifest,无需导出 `./package.json`。普通配置项从其所属树解析;standing preset 根会复现 Loader 对宿主基址的显式覆写,嵌套 include 则保留自身基址。最近的匿名 manifest 会标记松散模块;具名 manifest 必须带有版本。系统以确定性顺序按确切名称/版本对去重,同时存活的不同版本仍会分开保留。 + +禁用、pending、failed、unloading、disposed、结构性、松散非包、普通依赖、编程式子 fiber 与内存动态插件配置项都不属于该包清单。这个定义会报告运行时可以证明的包支撑组合事实,而不会为任意回调发明来源。 + +## 暂缓的清单 cache + +当前实现会为每个请求重新计算存活包集合,同时在进程生命周期内 cache manifest 身份。一项仅含宿主树的合成基准测试使用 Node v24.16.0 与 macOS arm64,测试对象为各不相同的存活相对插件包;测试先预热 20 个请求,再对 25 项和 100 项场景分别测量 500 个请求,对 500 项场景测量 250 个请求。「首次请求」包含未 cache 的 manifest 读取;「已 cache 提供方中位数」通过同一注册表返回预构建字段,因此仍包含 `structuredClone()` 与冻结开销,但不包含适配器 JSON 序列化和网络时间。 + +| 存活配置项 | 首次请求 | 当前稳态中位数 | 当前稳态 p95 | 已 cache 提供方中位数 | +|---:|---:|---:|---:|---:| +| 25 | 1.23 ms | 0.05 ms | 0.07 ms | 0.02 ms | +| 100 | 2.23 ms | 0.14 ms | 0.24 ms | 0.04 ms | +| 500 | 10.22 ms | 0.60 ms | 0.79 ms | 0.18 ms | + +这些测量结果支持继续暂缓 cache:即使存在 500 个配置项,稳态耗时仍低于 1 毫秒;在不可避免的 JSON 序列化之前,预计节省约 0.42 毫秒。加入 cache 的触发条件是真实 profile 显示 `prepare()` 延迟达到实质水平,而不是固定的配置项数量阈值。 + +暂缓设计使用一个单调递增的清单 epoch。全局 `internal/status` listener 会在 Loader 配置项的根 fiber 跨越 `FiberState.ACTIVE` 边界时推进该值,从而覆盖依赖激活、禁用、卸载与 HMR,且不会产生基于时间的陈旧窗口。贡献方按 epoch cache 宿主快照,在 `WeakMap` 中 cache 每个 standing preset `EntryTree`,并按树与 epoch cache 宿主加 preset 的合并结果。系统以线性时间合并已经排序的快照,并对确切 `(name, version)` 对去重。计算完成前 epoch 发生变化时,系统会重试而非发布陈旧快照;已 dispose 的 preset 树仍可通过 `WeakMap` 被回收。 + +进程生命周期内的 manifest 身份 cache 保持独立,因为系统不支持在进程内替换包版本。 + +## 验证 + +注册表测试固定重复所有权、effect 作用域 dispose(资源释放)、分离字段值、并发且可取消的准备、保留接收者的接受操作、单次接受结算与失败聚合。插件包清单测试固定默认开启与显式关闭策略、宿主与 standing preset 发现、冲突的 Loader 解析基址、manifest 解析、生命周期过滤及确切名称/版本排序。直接适配器 mock 测试证明 HTTP 前准备失败、取消、非 2xx 不接受、2xx 在后续流失败前接受,以及字段冲突。无密钥回放固定 2xx 后扩展接受,真实 Loader 组合检查默认元数据字段,一个带凭据的真实 API 请求会挂载生产贡献方;pi-ai 测试保持其协议请求不变。 + +## 考虑过的替代方案 + +**向 `GenerateOptions` 或 `ctx.llm` 添加通用元数据。** 已否决,因为该值与接受时点属于 DeepSeek 协议语义;提供方无关请求会迫使每个适配器理解或忽略外来字段。 + +**把包发现硬编码进 `llm-deepseek`。** 已否决,因为适配器将导入 Loader、preset 与包 manifest 逻辑。注册表让传输只负责字段合并与 HTTP 接受。 + +**清点每个存活 Cordis fiber。** 已否决,因为编程式与内存 fiber 没有权威 npm 包来源。Loader 支撑的宿主与 preset 配置项能提供可精确解析的包身份。 + +**cache 一份全进程清单,或按 TTL 使其过期。** 已否决,因为单份不可变清单无法正确反映 Loader 生命周期与逐会话 preset,TTL 则允许元数据在过期边界之间保持陈旧。暂缓的 epoch 设计会根据权威存活状态转换执行失效。 + +**用内容 hash 或服务端清单引用替换完整字段。** 已否决,因为它会改变独立请求的重建方式,需要端点状态与后续协议版本。这属于请求字节协议变更,而不是计算 cache 优化。 + +## 后果 + +DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。该字段对模型不可见,不增加提示词 token,也不改变 KV Cache。manifest 解析、字段冲突、接受处理或提供方 schema 拒绝会使模型请求失败,而不会静默丢弃元数据。 + +缺少存活 Agent 的直接调用仍会携带宿主包清单。[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.zh.md)继续拥有 user/session header,且这些 header 仍位于正文之外。 diff --git a/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.i18n.yaml index f39fbe92b3..4d8cfe3580 100644 --- a/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.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-11-deepseek-request-user-id-header.md -2026-08-11-deepseek-request-user-id-header.md: 0641c1c78ee9992a77c9c3ea99c9b7377296b3a0 -2026-08-11-deepseek-request-user-id-header.zh.md: 18b84b9debbf598150a6712689c0db078cb99c2f +2026-08-11-deepseek-request-user-id-header.md: 54f59a3f69a933af4f80d629f32f5089676263f6 +2026-08-11-deepseek-request-user-id-header.zh.md: 4904a08fcf4320211fd4253403bddda062bd496e diff --git a/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.md b/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.md index 0641c1c78e..54f59a3f69 100644 --- a/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.md +++ b/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.md @@ -16,7 +16,7 @@ The user id is transport metadata, not model input. It must not enter the reques The plugin resolves the user id lazily after credentials succeed and memoizes it for that plugin instance. A missing credential therefore does not create `.anonymous-user-id`, while the first authorized provider request can create it even when `DSH_TELEMETRY_DISABLED` is set. The direct adapter constructor accepts a `resolveUserId` dependency so wire behavior remains deterministic in unit tests. -Both headers are model-hidden HTTP metadata sent to the resolved `baseURL`. They are absent from the JSON request body and do not become model-visible inputs or session events. A configured gateway receives them. SessionTelemetryBackend sharing controls only telemetry export and does not disable provider request identity. +Both headers are model-hidden HTTP metadata sent to the resolved `baseURL`. The identity values are absent from the JSON request body and do not become model-visible inputs or session events. A configured gateway receives them. Provider-specific body extensions are owned separately by the [DeepSeek LLM API extension decision](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md). SessionTelemetryBackend sharing controls only telemetry export and does not disable provider request identity. ## Verification @@ -41,4 +41,4 @@ Both headers are model-hidden HTTP metadata sent to the resolved `baseURL`. They - DeepSeek support can correlate requests across sessions by one anonymous harness-home id and within a conversation by the durable session id. - The first authorized DeepSeek request may create `$DSH_HOME/.anonymous-user-id` independently of telemetry export. - Custom DeepSeek gateways receive the stable user id and any available session id, so operators must treat the configured `baseURL` as an identity recipient. -- The request body, prompt, token count, KV-cache identity, and session log remain unchanged. +- The identity headers do not alter the request body, prompt, token count, KV-cache identity, or session log; separately registered DeepSeek body extensions retain their own contracts. diff --git a/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.zh.md b/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.zh.md index 18b84b9deb..4904a08fcf 100644 --- a/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.zh.md @@ -16,7 +16,7 @@ Status: implemented 插件在凭据解析成功后惰性获取用户 id,并在该插件实例内缓存。缺少凭据不会创建 `.anonymous-user-id`;即使设置了 `DSH_TELEMETRY_DISABLED`,首个已授权的提供方请求仍可能创建它。直连适配器构造函数接收 `resolveUserId` 依赖,使线路行为可在单元测试中保持确定性。 -两个头部都是发送到解析后 `baseURL` 的模型不可见 HTTP 元数据。它们不在 JSON 请求体中,也不会成为模型可见输入或会话事件。配置的网关会收到它们。遥测共享只控制遥测导出,不会禁用提供方请求身份。 +两个头部都是发送到解析后 `baseURL` 的模型不可见 HTTP 元数据。身份值不在 JSON 请求体中,也不会成为模型可见输入或会话事件。配置的网关会收到它们。提供方特定正文扩展由 [DeepSeek LLM API 扩展决策](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md)单独拥有。遥测共享只控制遥测导出,不会禁用提供方请求身份。 ## 验证 @@ -41,4 +41,4 @@ Status: implemented - DeepSeek 支持可以通过一个匿名 harness-home id 跨会话关联请求,并通过持久化 session id 关联同一对话。 - 首个已授权 DeepSeek 请求可独立于遥测导出创建 `$DSH_HOME/.anonymous-user-id`。 - 自定义 DeepSeek 网关会收到稳定用户 id 与可用的会话 id,因此运维方必须将配置的 `baseURL` 视为身份接收方。 -- 请求体、提示词、token 数、KV cache 身份和会话日志保持不变。 +- 身份头部不会改变请求体、提示词、token 数、KV cache 身份或会话日志;单独注册的 DeepSeek 正文扩展保留各自约定。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 4d37b9186a..c5a6bbe534 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -14,6 +14,8 @@ flowchart LR cfg --> plugin_dsh_base_hmr plugin_dsh_base_llm["llm
@deepseek-ai/dsh-llm"] cfg --> plugin_dsh_base_llm + plugin_dsh_base_deepseek_llm_api_extensions["deepseek-llm-api-extensions
@deepseek-ai/dsh-deepseek-llm-api-extensions"] + cfg --> plugin_dsh_base_deepseek_llm_api_extensions plugin_dsh_base_session["session
@deepseek-ai/dsh-session"] cfg --> plugin_dsh_base_session plugin_dsh_base_typert["typert
@deepseek-ai/dsh-typert-registry"] @@ -30,6 +32,8 @@ flowchart LR cfg --> plugin_dsh_base_user_questions plugin_dsh_base_agent["agent
@deepseek-ai/dsh-agent"] cfg --> plugin_dsh_base_agent + plugin_dsh_base_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek
@deepseek-ai/dsh-plugin-package-inventory-deepseek"] + cfg --> plugin_dsh_base_plugin_package_inventory_deepseek plugin_dsh_base_agent_default_model["agent-default-model
@deepseek-ai/dsh-agent-default-model"] cfg --> plugin_dsh_base_agent_default_model plugin_dsh_base_jobs["jobs
@deepseek-ai/dsh-jobs-local"] @@ -171,6 +175,7 @@ flowchart LR | `timer` | `@deepseek-ai/cordis-plugin-timer` | | `hmr` | `@deepseek-ai/cordis-plugin-hmr` | | `llm` | `@deepseek-ai/dsh-llm` | +| `deepseek-llm-api-extensions` | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | | `session` | `@deepseek-ai/dsh-session` | | `typert` | `@deepseek-ai/dsh-typert-registry` | | `typert-loader` | `@deepseek-ai/dsh-typert-loader` | @@ -179,6 +184,7 @@ flowchart LR | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` | | `user-questions` | `@deepseek-ai/dsh-user-questions` | | `agent` | `@deepseek-ai/dsh-agent` | +| `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index dbafe8c766..848a3bc580 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: 9e99ebbdc0e3af22f9939c690ead479d4d20b00c -capability-seams.zh.md: 611902310e3472e05eaa92985011eb852bbeee6d +capability-seams.md: 44ee46a0bc6472c62154df9e4111ed6e6649bacd +capability-seams.zh.md: 62ae7ac460ac9fc1c3df62d38a9dd64956744cec diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9e99ebbdc0..44ee46a0bc 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -18,6 +18,9 @@ flowchart LR pkg_llm_replay["llm-replay"] pkg_agent_loop["agent-loop"] pkg_compaction_basic["compaction-basic"] + pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] + svc_deepseekLlmApiExtensions["ctx.deepseekLlmApiExtensions
Official DeepSeek request extensions"] + pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"] pkg_token_meter["token-meter"] svc_tokenMeter["ctx.tokenMeter
Replay token measurement"] pkg_compaction_tool_result_pruner["compaction-tool-result-pruner"] @@ -225,6 +228,7 @@ flowchart LR pkg_cordis_host_runner --> svc_dynamicCordisRunner pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials + pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions pkg_directory_picker --> svc_directoryPicker pkg_directory_picker_browse --> svc_directoryPicker pkg_directory_picker_native --> svc_directoryPicker @@ -249,6 +253,7 @@ flowchart LR pkg_modules --> svc_clientModules pkg_permission_presets --> svc_permissionPresets pkg_plan_mode --> svc_planMode + pkg_plugin_package_inventory_deepseek --> svc_deepseekLlmApiExtensions pkg_pwsh_local --> svc_shell pkg_sandbox --> svc_sandbox pkg_sandbox_local --> svc_sandbox @@ -326,6 +331,7 @@ flowchart LR svc_credentials --> pkg_apiproxy svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai + svc_deepseekLlmApiExtensions --> pkg_llm_deepseek svc_directoryPicker --> pkg_apiproxy svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b @@ -427,6 +433,7 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | `host-runtime`, [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | +| `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 611902310e..62ae7ac460 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -20,6 +20,9 @@ flowchart LR pkg_llm_replay["llm-replay"] pkg_agent_loop["agent-loop"] pkg_compaction_basic["compaction-basic"] + pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] + svc_deepseekLlmApiExtensions["ctx.deepseekLlmApiExtensions
Official DeepSeek request extensions"] + pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"] pkg_token_meter["token-meter"] svc_tokenMeter["ctx.tokenMeter
Replay token measurement"] pkg_compaction_tool_result_pruner["compaction-tool-result-pruner"] @@ -227,6 +230,7 @@ flowchart LR pkg_cordis_host_runner --> svc_dynamicCordisRunner pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials + pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions pkg_directory_picker --> svc_directoryPicker pkg_directory_picker_browse --> svc_directoryPicker pkg_directory_picker_native --> svc_directoryPicker @@ -251,6 +255,7 @@ flowchart LR pkg_modules --> svc_clientModules pkg_permission_presets --> svc_permissionPresets pkg_plan_mode --> svc_planMode + pkg_plugin_package_inventory_deepseek --> svc_deepseekLlmApiExtensions pkg_pwsh_local --> svc_shell pkg_sandbox --> svc_sandbox pkg_sandbox_local --> svc_sandbox @@ -328,6 +333,7 @@ flowchart LR svc_credentials --> pkg_apiproxy svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai + svc_deepseekLlmApiExtensions --> pkg_llm_deepseek svc_directoryPicker --> pkg_apiproxy svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b @@ -429,6 +435,7 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | `host-runtime`, [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | +| `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 插件准备彼此独立的顶层字段;官方适配器会合并这些字段,并在 HTTP 接受后提交其交付状态。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 5fa1696881..ee3ec07a41 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: 9c679db07b9922d49e5ddb5f64c6726c7443e4ff -config-catalog.zh.md: b9b9b4ae8243f71fb8c60aec314709d1da325dbb +config-catalog.md: 99677918d543a2e3bf932f3466c80da56d0c1958 +config-catalog.zh.md: e3848d704d1d03fa83383b011bd22f1908ca5ffb diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9c679db07b..99677918d5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1309,7 +1309,7 @@ export interface ReplayModelConfig { Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/test-support/llm-replay/src/index.ts:809`](../packages/test-support/llm-replay/src/index.ts) +Source: [`packages/test-support/llm-replay/src/index.ts:847`](../packages/test-support/llm-replay/src/index.ts) @@ -1534,6 +1534,22 @@ export interface PlanModeConfig { Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) + + +## `@deepseek-ai/dsh-plugin-package-inventory-deepseek` + +Requires: `agents` · `deepseekLlmApiExtensions` · `loader` + +```ts config-catalog +/** Plugin-package request contribution configuration. */ +export interface Config { + /** Contribute `dsh_plugin_packages` to official DeepSeek requests. Defaults to `true`. */ + enabled?: boolean +} +``` + +Source: [`packages/llm/plugin-package-inventory-deepseek/src/index.ts:30`](../packages/llm/plugin-package-inventory-deepseek/src/index.ts) + ## `@deepseek-ai/dsh-pwsh-local` @@ -3267,6 +3283,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) - `@deepseek-ai/dsh-commands` ([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts)) - `@deepseek-ai/dsh-cordis-client-runner` ([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts)) +- `@deepseek-ai/dsh-deepseek-llm-api-extensions` ([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts)) - `@deepseek-ai/dsh-fs-e2b` — requires `e2b` ([`packages/e2b/fs-e2b/src/index.ts`](../packages/e2b/fs-e2b/src/index.ts)) - `@deepseek-ai/dsh-fs-observation-policy` ([`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-round-driver` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b9b9b4ae82..e3848d704d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1311,7 +1311,7 @@ export interface ReplayModelConfig { 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/test-support/llm-replay/src/index.ts:809`](../packages/test-support/llm-replay/src/index.ts) +来源:[`packages/test-support/llm-replay/src/index.ts:847`](../packages/test-support/llm-replay/src/index.ts) @@ -1536,6 +1536,22 @@ export interface PlanModeConfig { 来源:[`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) + + +## `@deepseek-ai/dsh-plugin-package-inventory-deepseek` + +需要:`agents` · `deepseekLlmApiExtensions` · `loader` + +```ts config-catalog +/** Plugin-package request contribution configuration. */ +export interface Config { + /** Contribute `dsh_plugin_packages` to official DeepSeek requests. Defaults to `true`. */ + enabled?: boolean +} +``` + +来源:[`packages/llm/plugin-package-inventory-deepseek/src/index.ts:30`](../packages/llm/plugin-package-inventory-deepseek/src/index.ts) + ## `@deepseek-ai/dsh-pwsh-local` @@ -3269,6 +3285,7 @@ export interface Config { - `@deepseek-ai/dsh-command-goal` — 需要 `commands` · `goals`([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) - `@deepseek-ai/dsh-commands`([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts)) - `@deepseek-ai/dsh-cordis-client-runner`([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts)) +- `@deepseek-ai/dsh-deepseek-llm-api-extensions`([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts)) - `@deepseek-ai/dsh-fs-e2b` — 需要 `e2b`([`packages/e2b/fs-e2b/src/index.ts`](../packages/e2b/fs-e2b/src/index.ts)) - `@deepseek-ai/dsh-fs-observation-policy`([`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-round-driver` — 需要 `agents` · `goals` · `sessions`([`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts)) diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml new file mode 100644 index 0000000000..b417423d76 --- /dev/null +++ b/docs/deepseek-llm-api-wire-extensions.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 docs/deepseek-llm-api-wire-extensions.md +deepseek-llm-api-wire-extensions.md: 7992048f3f8e23bf7a039facae0a3f36cb3c42a7 +deepseek-llm-api-wire-extensions.zh.md: 330d0e2d67d920fdca386c10ac4d4d25d19e49d3 diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md new file mode 100644 index 0000000000..7992048f3f --- /dev/null +++ b/docs/deepseek-llm-api-wire-extensions.md @@ -0,0 +1,76 @@ +# Official DeepSeek LLM API wire extensions + +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` chat-completion 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. + +## Wire namespaces and versioning + +| Location | Naming | Examples | +|---|---|---| +| HTTP field names | Lowercase kebab-case; HTTP matching remains case-insensitive | `user-agent`, `x-deepseek-harness-session-id` | +| DeepSeek request-body extension fields | Snake case with the reserved `dsh_` prefix | `dsh_plugin_packages` | + +Each body extension owns its `version` independently. A version applies only to the object that contains it; no compatibility or ordering relationship exists between versions of different fields. JSON member order is not part of the protocol. + +The [`DeepSeekLlmApiExtensionRegistry`](../packages/llm/deepseek-llm-api-extensions/README.md) reserves one provider per top-level extension name. Empty or whitespace-padded names, duplicate registrations, and collisions with the base DeepSeek request fail before HTTP dispatch. + +## Request headers + +| Header | Presence | Value | +|---|---|---| +| `user-agent` | Every provider HTTP request, including Files API operations | Application identity in `product/version (+url)` form; the default product is `deepseek-harness` | +| `x-deepseek-harness-user-id` | Every authorized chat-completion request | The stable anonymous UUID for the resolved Harness home | +| `x-deepseek-harness-session-id` | Chat-completion requests carrying a Session id | The exact request `sessionId` string | +| `x-deepseek-harness-compact` | Chat-completion requests whose purpose is `compaction` | The literal string `1` | + +Credential failure happens before anonymous-user-id resolution, so an unauthorized request neither sends these headers nor creates the identity file. A direct request without a Session omits `x-deepseek-harness-session-id`. Session-title requests have no additional purpose header; the ordinary Session-id rule still applies when one carries a `sessionId`. + +## Body-extension transaction + +The adapter serializes the complete base body, including the exact `messages`, before it asks registered providers to prepare fields. A provider receives that immutable body, the request cancellation signal, and optional `sessionId` and auxiliary-call `purpose`. Returning `undefined` omits that provider's field for the request. + +Prepared JSON values are detached from provider-owned state, merged as top-level siblings of the base fields, and serialized in the same HTTP body. Preparation or collision failure prevents the request. A composition without the registry sends the unextended base body. + +After the configured endpoint returns HTTP 2xx, the adapter runs the prepared `accept()` transaction before reading the SSE response body. Transport failures and non-2xx responses do not accept any contribution. An acceptance failure fails the model request even though the endpoint returned 2xx. Acceptance records endpoint-level HTTP success; it does not assert that an SSE stream completed or that the endpoint persisted an extension. + +## `dsh_plugin_packages` + +[`@deepseek-ai/dsh-plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek/README.md) contributes the complete active Loader-backed plugin package inventory. The field is enabled by default. + +```json +{ + "dsh_plugin_packages": { + "version": 1, + "packages": [ + { + "name": "@deepseek-ai/dsh-example", + "version": "0.1.1-rc.2" + } + ] + } +} +``` + +| Member | Type | Meaning | +|---|---|---| +| `version` | `1` | Schema version for `dsh_plugin_packages` | +| `packages` | array | Complete active set for this request | +| `packages[].name` | string | Exact non-empty npm package name from the owning manifest | +| `packages[].version` | string | Exact non-empty package version from the same manifest | + +Every request re-reads active non-group Loader entries from the host tree and, when available for the request Session, its standing agent-preset tree. Relative and absolute modules use their nearest owning manifest; bare package entries follow the Loader resolution base that activated them. A named manifest without a non-empty version fails request preparation. + +The sender deduplicates exact `(name, version)` pairs and sorts first by `name`, then by `version`, with a locale-independent text comparison. Simultaneously active versions of one package remain separate entries. Receivers must not collapse the array by package name or infer package activation from array order. + +Disabled, pending, failed, unloading, disposed, and structural Loader entries are absent. Ordinary dependencies, loose modules without a named owning package, programmatically mounted child fibers, and in-memory dynamic plugins are also absent because they have no authoritative Loader package provenance. + +An enabled inventory with no qualifying entries sends `packages: []`; disabling the contributor omits the entire `dsh_plugin_packages` field. Package identities are provider metadata and never enter model input. + +## Exposure and receiver requirements + +The request headers expose the Harness application version, one anonymous Harness-home identity, and an optional Session identity. `dsh_plugin_packages` exposes active npm package names and versions. A gateway selected through `baseURL` receives the same values as the official endpoint. + +Receivers address extension fields by name, dispatch each field by its own `version`, preserve distinct package versions, and ignore JSON member ordering. The base request remains usable without either the registry or a particular contribution; field absence means that contribution did not apply to that request. diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md new file mode 100644 index 0000000000..330d0e2d67 --- /dev/null +++ b/docs/deepseek-llm-api-wire-extensions.zh.md @@ -0,0 +1,76 @@ +# DeepSeek 官方 LLM API 协议扩展 + +[English](deepseek-llm-api-wire-extensions.md) | 中文 + +本参考文档定义 [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.zh.md) 在 `deepseek-official` 聊天补全请求中发送的全部 DeepSeek Harness 特有 HTTP 标头和附加 JSON 字段。本文不重复定义 DeepSeek 上游 API 持有的字段。提供方无关的 LLM 接口与 `llm-pi-ai` 均不实现这些扩展。 + +适配器将这些扩展发送至已解析的 `baseURL`,包括已配置的网关。扩展位于 `messages`、系统提示词和工具 schema 之外,因此不会增加模型输入 token,也不会改变模型可见前缀。 + +## 协议命名空间与版本 + +| 位置 | 命名方式 | 示例 | +|---|---|---| +| HTTP 字段名 | 小写 kebab-case;HTTP 匹配仍不区分大小写 | `user-agent`, `x-deepseek-harness-session-id` | +| DeepSeek 请求正文扩展字段 | 使用保留 `dsh_` 前缀的 snake case | `dsh_plugin_packages` | + +每个正文扩展独立持有自身的 `version`。版本仅适用于包含该字段的对象;不同字段的版本之间不存在兼容或排序关系。JSON 成员顺序不属于协议。 + +[`DeepSeekLlmApiExtensionRegistry`](../packages/llm/deepseek-llm-api-extensions/README.zh.md) 为每个顶层扩展名保留一个提供方。空名称、两端带空白的名称、重复注册以及与 DeepSeek 基础请求冲突的名称都会在 HTTP 分派前失败。 + +## 请求标头 + +| 标头 | 出现条件 | 值 | +|---|---|---| +| `user-agent` | 每个提供方 HTTP 请求,包括 Files API 操作 | 采用 `product/version (+url)` 形式的应用身份;默认产品为 `deepseek-harness` | +| `x-deepseek-harness-user-id` | 每个已授权的聊天补全请求 | 已解析 Harness home 的稳定匿名 UUID | +| `x-deepseek-harness-session-id` | 携带会话 id 的聊天补全请求 | 确切的请求 `sessionId` 字符串 | +| `x-deepseek-harness-compact` | 用途为 `compaction` 的聊天补全请求 | 字面字符串 `1` | + +凭据失败发生在解析匿名用户 id 之前,因此未授权请求既不会发送这些标头,也不会创建身份文件。没有会话的直接请求会省略 `x-deepseek-harness-session-id`。会话标题请求没有额外的用途标头;请求携带 `sessionId` 时,仍然适用普通的会话 id 规则。 + +## 正文扩展事务 + +适配器先序列化包括确切 `messages` 在内的完整基础正文,再让已注册提供方准备字段。提供方会收到该不可变正文、请求取消信号,以及可选的 `sessionId` 和辅助调用 `purpose`。提供方返回 `undefined` 时,本次请求会省略其字段。 + +系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。准备失败或冲突会阻止请求。组合未挂载注册表时,适配器发送未经扩展的基础正文。 + +已配置端点返回 HTTP 2xx 后,适配器会在读取 SSE 正文之前运行已准备的 `accept()` 事务。传输失败和非 2xx 响应不会接受任何贡献。即使端点返回 2xx,接受失败仍会使模型请求失败。接受仅记录端点级 HTTP 成功,不表示 SSE 流已完整结束,也不表示端点已持久化扩展。 + +## `dsh_plugin_packages` + +[`@deepseek-ai/dsh-plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek/README.zh.md) 贡献完整存活的 Loader-backed 插件包清单。该字段默认启用。 + +```json +{ + "dsh_plugin_packages": { + "version": 1, + "packages": [ + { + "name": "@deepseek-ai/dsh-example", + "version": "0.1.1-rc.2" + } + ] + } +} +``` + +| 成员 | 类型 | 含义 | +|---|---|---| +| `version` | `1` | `dsh_plugin_packages` 的 schema 版本 | +| `packages` | 数组 | 本次请求的完整存活集合 | +| `packages[].name` | 字符串 | 来自所属 manifest 的确切非空 npm 包名 | +| `packages[].version` | 字符串 | 来自同一 manifest 的确切非空包版本 | + +每个请求都会重新读取宿主树中的存活非分组 Loader 配置项;请求会话存在 standing agent-preset 树时,也会读取该树。相对与绝对模块使用距离自身最近的所属 manifest;裸包配置项使用激活自身的 Loader 解析基准。具名 manifest 未提供非空版本时,请求准备会失败。 + +发送方会对确切 `(name, version)` 组合去重,并使用与 locale 无关的文本比较,先按 `name`、再按 `version` 排序。同一包的多个同时存活版本会保留为独立配置项。接收方不得按包名折叠该数组,也不得根据数组顺序推断包的激活关系。 + +该清单不包含已禁用、pending、failed、unloading、disposed 和结构性 Loader 配置项。普通依赖、没有具名所属包的松散模块、以编程方式挂载的子 fiber,以及内存动态插件也不在其中,因为它们没有权威的 Loader 包来源信息。 + +清单已启用但没有符合条件的配置项时,系统发送 `packages: []`;禁用贡献插件时,系统省略整个 `dsh_plugin_packages` 字段。包身份属于提供方元数据,绝不进入模型输入。 + +## 暴露内容与接收方要求 + +请求标头会暴露 Harness 应用版本、一个匿名 Harness-home 身份和可选的会话身份。`dsh_plugin_packages` 会暴露存活 npm 包的名称与版本。通过 `baseURL` 选择的网关会收到与官方端点相同的值。 + +接收方按名称定位扩展字段,按各字段自己的 `version` 分派,保留不同的包版本,并忽略 JSON 成员顺序。即使缺少注册表或某项贡献,基础请求仍然可用;字段缺失表示该项贡献不适用于本次请求。 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 7edfa03250..7ca1a51d8e 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: fb65171fab57b9030e5783486bdc487558d6c4f4 -module-graph.zh.md: 5532393b35e08cbdc3c9caa4a055d3dc63640915 +module-graph.md: e87c9f89194ed5f59123362d01ac03432436a451 +module-graph.zh.md: 65853cb684b4b813822932fc10a54593843ea36b diff --git a/docs/module-graph.md b/docs/module-graph.md index fb65171fab..e87c9f8919 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -18,10 +18,12 @@ flowchart TD pkg_util_crypto["util-crypto"] end subgraph group_llm["packages/llm"] + pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] pkg_llm["llm"] pkg_llm_deepseek["llm-deepseek"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_retry["llm-retry"] + pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"] pkg_token_meter["token-meter"] end subgraph group_core["packages/core"] @@ -346,6 +348,7 @@ flowchart TD pkg_output_retention --> pkg_invariants pkg_timeout --> pkg_invariants pkg_util_crypto --> pkg_invariants + pkg_deepseek_llm_api_extensions --> pkg_invariants pkg_scope --> pkg_invariants pkg_cmdline --> pkg_invariants pkg_base --> pkg_invariants @@ -424,6 +427,7 @@ flowchart TD pkg_llm_deepseek --> pkg_attachment pkg_llm_deepseek --> pkg_brand pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions pkg_llm_deepseek --> pkg_home_paths pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment @@ -628,6 +632,11 @@ flowchart TD pkg_workspace --> pkg_session_persistence pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain + pkg_plugin_package_inventory_deepseek --> pkg_agent + pkg_plugin_package_inventory_deepseek --> pkg_agent_presets + pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions + pkg_plugin_package_inventory_deepseek --> pkg_invariants + pkg_plugin_package_inventory_deepseek --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -978,6 +987,7 @@ flowchart TD pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools pkg_llm_replay --> pkg_compaction + pkg_llm_replay --> pkg_deepseek_llm_api_extensions pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session @@ -1483,6 +1493,7 @@ flowchart TD | [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`util-crypto`](../packages/util/crypto) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1524,7 +1535,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1572,6 +1583,7 @@ flowchart TD | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | +| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1633,7 +1645,7 @@ flowchart TD | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 5532393b35..65853cb684 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -20,10 +20,12 @@ flowchart TD pkg_util_crypto["util-crypto"] end subgraph group_llm["packages/llm"] + pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] pkg_llm["llm"] pkg_llm_deepseek["llm-deepseek"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_retry["llm-retry"] + pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"] pkg_token_meter["token-meter"] end subgraph group_core["packages/core"] @@ -348,6 +350,7 @@ flowchart TD pkg_output_retention --> pkg_invariants pkg_timeout --> pkg_invariants pkg_util_crypto --> pkg_invariants + pkg_deepseek_llm_api_extensions --> pkg_invariants pkg_scope --> pkg_invariants pkg_cmdline --> pkg_invariants pkg_base --> pkg_invariants @@ -426,6 +429,7 @@ flowchart TD pkg_llm_deepseek --> pkg_attachment pkg_llm_deepseek --> pkg_brand pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions pkg_llm_deepseek --> pkg_home_paths pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment @@ -630,6 +634,11 @@ flowchart TD pkg_workspace --> pkg_session_persistence pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain + pkg_plugin_package_inventory_deepseek --> pkg_agent + pkg_plugin_package_inventory_deepseek --> pkg_agent_presets + pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions + pkg_plugin_package_inventory_deepseek --> pkg_invariants + pkg_plugin_package_inventory_deepseek --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -980,6 +989,7 @@ flowchart TD pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools pkg_llm_replay --> pkg_compaction + pkg_llm_replay --> pkg_deepseek_llm_api_extensions pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session @@ -1485,6 +1495,7 @@ flowchart TD | [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`util-crypto`](../packages/util/crypto) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1526,7 +1537,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1574,6 +1585,7 @@ flowchart TD | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | +| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1635,7 +1647,7 @@ flowchart TD | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index cd1adacbe3..485696c80e 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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/subsystems/llm-streaming.md -llm-streaming.md: 1b2356983be4045666f7a9d40d8d191bdb4910a2 -llm-streaming.zh.md: 0c2b64830dda74595deac7797c759be1970ccb32 +llm-streaming.md: a0d099bc43d1b6d17cd757e3c6bf9f9ac83f3d4f +llm-streaming.zh.md: 585134620e6b378418ac158af623388a0cfda53d diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 1b2356983b..a0d099bc43 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -662,6 +662,12 @@ interface LlmCallConfigAdapterDefaults { } ``` +## Official DeepSeek request extensions + +`ctx.deepseekLlmApiExtensions` is the provider-specific registry for additive top-level fields on `deepseek-official` requests. Contributor plugins use `register(field, provider)` to claim one field; the adapter calls `prepare(request)` after serializing its base body and merges the returned fields before HTTP. The prepared `accept()` transaction runs after 2xx, so a contributor can commit delivery state without treating a transport or provider rejection as acceptance. Preparation, collision, and acceptance failures use `REQUEST_EXTENSION` and fail the model request. + +The [wire reference](../deepseek-llm-api-wire-extensions.md) defines the exact request headers, extension transaction, field versions, and receiver obligations. The shipped composition registers [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.md) as the complete active Loader-backed package set. The field remains outside model messages and is absent from the pi-ai adapter path. + ## Service and provider contracts `LlmAdapter` is the provider contract: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmRuntime.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmRuntime.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. [architecture.md](../architecture.md#turn-flow) shows where `ctx.llm.stream()` and the `llm/stream` waterfall sit in one turn. @@ -761,6 +767,33 @@ declare abstract class LlmAdapter { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.deepseekLlmApiExtensions` — `DeepSeekLlmApiExtensionRegistry` + +Registry of independently owned top-level fields for official DeepSeek requests. + +```ts cordis-catalog +/** + * Register the sole provider of one top-level request field. Registration is effect-scoped. + * @param field - declaration-merged field owned by the provider. + * @param provider - request-time field preparation and optional acceptance behavior. + * @returns disposer that releases the field. + */ +register( field: K, provider: DeepSeekLlmApiExtensionProvider, ): () => Promise + +/** + * Prepare every currently registered field from one immutable base request. + * Preparation failures reject before HTTP dispatch. Field values are cloned and frozen; + * providers retain no mutable alias to the outgoing request. + * @param request - exact serialized request facts before extension fields. + * @returns detached fields and their idempotent joint acceptance transaction. + */ +async prepare(request: DeepSeekLlmApiExtensionRequest): Promise +``` + +Source: [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../../packages/llm/deepseek-llm-api-extensions/src/index.ts) + ### `ctx.llm` — `LlmRuntime` diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 0c2b64830d..585134620e 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -668,6 +668,12 @@ interface LlmCallConfigAdapterDefaults { } ``` +## DeepSeek 官方请求扩展 + +`ctx.deepseekLlmApiExtensions` 是用于向 `deepseek-official` 请求添加顶层字段的提供方特定注册表。贡献插件通过 `register(field, provider)` 认领一个字段;适配器在序列化基础正文后调用 `prepare(request)`,并在 HTTP 前合并返回字段。已准备的 `accept()` 事务会在 2xx 后运行,因此贡献方可以提交交付状态,而不会把传输失败或提供方拒绝当作接受。准备、冲突与接受失败会使用 `REQUEST_EXTENSION`,并使模型请求失败。 + +[协议参考](../deepseek-llm-api-wire-extensions.zh.md)定义确切的请求标头、扩展事务、字段版本和接收方义务。随附组合会将 [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.zh.md) 注册为完整存活 Loader 包集合。该字段仍位于模型消息之外,也不会进入 pi-ai 适配器路径。 + ## 服务与提供方约定 `LlmAdapter` 是提供方约定:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmRuntime.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和可选的部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmRuntime.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。`ctx.llm.stream()` 与 `llm/stream` waterfall 在一个轮次中的位置见 [architecture.md](../architecture.zh.md#turn-flow)。 @@ -767,6 +773,33 @@ declare abstract class LlmAdapter { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.deepseekLlmApiExtensions` — `DeepSeekLlmApiExtensionRegistry` + +Registry of independently owned top-level fields for official DeepSeek requests. + +```ts cordis-catalog +/** + * Register the sole provider of one top-level request field. Registration is effect-scoped. + * @param field - declaration-merged field owned by the provider. + * @param provider - request-time field preparation and optional acceptance behavior. + * @returns disposer that releases the field. + */ +register( field: K, provider: DeepSeekLlmApiExtensionProvider, ): () => Promise + +/** + * Prepare every currently registered field from one immutable base request. + * Preparation failures reject before HTTP dispatch. Field values are cloned and frozen; + * providers retain no mutable alias to the outgoing request. + * @param request - exact serialized request facts before extension fields. + * @returns detached fields and their idempotent joint acceptance transaction. + */ +async prepare(request: DeepSeekLlmApiExtensionRequest): Promise +``` + +Source: [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../../packages/llm/deepseek-llm-api-extensions/src/index.ts) + ### `ctx.llm` — `LlmRuntime` diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 7680d98a97..c081572b39 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -8,6 +8,10 @@ The ACP demo exposes fresh baseline-prompt agent sessions to programmatic client ```mermaid flowchart LR cfg["examples/acp-agent
cordis.yml"] + plugin_acp_deepseek_llm_api_extensions["deepseek-llm-api-extensions
@deepseek-ai/dsh-deepseek-llm-api-extensions"] + cfg --> plugin_acp_deepseek_llm_api_extensions + plugin_acp_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek
@deepseek-ai/dsh-plugin-package-inventory-deepseek"] + cfg --> plugin_acp_plugin_package_inventory_deepseek plugin_acp_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_acp_llm_deepseek plugin_acp_sandbox["sandbox
@deepseek-ai/dsh-sandbox-local"] @@ -75,6 +79,8 @@ flowchart LR | Plugin id | Package / module | | --- | --- | +| `deepseek-llm-api-extensions` | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | +| `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `sandbox` | `@deepseek-ai/dsh-sandbox-local` | | `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 46dccd44d1..1bff01b585 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -4,6 +4,12 @@ # before this config. This tree has no stdout logger or HMR because stdout # carries ACP JSON-RPC. +- id: deepseek-llm-api-extensions + name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' + +- id: plugin-package-inventory-deepseek + name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' + # The DeepSeek adapter. Shipped default: full thinking at max effort on every # request; exact-model resolution materializes request defaults before logging. - id: llm-deepseek diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index c983e62999..0492bb34d6 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -12,6 +12,10 @@ flowchart LR cfg --> plugin_headless_settings plugin_headless_credentials["credentials
@deepseek-ai/dsh-credentials-local"] cfg --> plugin_headless_credentials + plugin_headless_deepseek_llm_api_extensions["deepseek-llm-api-extensions
@deepseek-ai/dsh-deepseek-llm-api-extensions"] + cfg --> plugin_headless_deepseek_llm_api_extensions + plugin_headless_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek
@deepseek-ai/dsh-plugin-package-inventory-deepseek"] + cfg --> plugin_headless_plugin_package_inventory_deepseek plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_headless_llm_deepseek plugin_headless_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] @@ -64,6 +68,8 @@ flowchart LR | --- | --- | | `settings` | `@deepseek-ai/dsh-settings-file` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | +| `deepseek-llm-api-extensions` | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | +| `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 6dcde61110..e637fa32e3 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -15,6 +15,12 @@ - id: credentials name: '@deepseek-ai/dsh-credentials-local' +- id: deepseek-llm-api-extensions + name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' + +- id: plugin-package-inventory-deepseek + name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' + # The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed # twin (a `providers` dict keyed by route; `reasoning: high` replaces # thinking/reasoningEffort). Shipped default: full thinking at max effort on diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 2f7ea46ddf..20f03df0ca 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -6,6 +6,12 @@ config: maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)" +- id: deepseek-llm-api-extensions + name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' + +- id: plugin-package-inventory-deepseek + name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' + # The DeepSeek adapter. Shipped default: full thinking at max effort on every # request; exact-model resolution materializes request defaults before logging. # The model arrives per session over JSON-RPC, so it is not pinned here. diff --git a/examples/jsonrpc-agent/minimal.cordis.yml b/examples/jsonrpc-agent/minimal.cordis.yml index e23d52a866..5615974cf1 100644 --- a/examples/jsonrpc-agent/minimal.cordis.yml +++ b/examples/jsonrpc-agent/minimal.cordis.yml @@ -8,6 +8,12 @@ config: maxTokensAsSuccess: false +- id: deepseek-llm-api-extensions + name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' + +- id: plugin-package-inventory-deepseek + name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' + - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: diff --git a/examples/package.json b/examples/package.json index 6dcdc21e28..4c8ca9fbc9 100644 --- a/examples/package.json +++ b/examples/package.json @@ -41,6 +41,8 @@ "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:*", "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", "@deepseek-ai/dsh-loader-smoke": "workspace:*", diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index e9567d9206..1563a5defd 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -24,6 +24,9 @@ - id: llm name: '@deepseek-ai/dsh-llm' + - id: deepseek-llm-api-extensions + name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' + - id: session name: '@deepseek-ai/dsh-session' @@ -58,6 +61,9 @@ - id: agent name: '@deepseek-ai/dsh-agent' + - id: plugin-package-inventory-deepseek + name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' + # The transport-independent default for Agents created by entry points. # Settings may supply a saved selection; consumers read it at creation time. - id: agent-default-model diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 2096a64b75..b3a1e5857e 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -54,6 +54,8 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 32e9ccbda6..4c804644f1 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -677,6 +677,25 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'deepseekLlmApiExtensions', + summary: 'Registry of independently owned top-level fields for official DeepSeek requests.', + description: 'Registry of independently owned top-level fields for official DeepSeek requests.', + methods: [ + { + signature: 'register( field: K, provider: DeepSeekLlmApiExtensionProvider, ): () => Promise', + description: 'Register the sole provider of one top-level request field. Registration is effect-scoped.', + parameters: [{ name: 'field', description: 'declaration-merged field owned by the provider.' }, { name: 'provider', description: 'request-time field preparation and optional acceptance behavior.' }], + returns: 'disposer that releases the field.', + }, + { + signature: 'async prepare(request: DeepSeekLlmApiExtensionRequest): Promise', + description: 'Prepare every currently registered field from one immutable base request. Preparation failures reject before HTTP dispatch. Field values are cloned and frozen; providers retain no mutable alias to the outgoing request.', + parameters: [{ name: 'request', description: 'exact serialized request facts before extension fields.' }], + returns: 'detached fields and their idempotent joint acceptance transaction.', + }, + ], + }, { key: 'directoryPicker', summary: 'Abstract directory-picking service.', @@ -3225,6 +3244,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CredentialRef', declaration: 'export type CredentialRef = Branded<\'CredentialRef\'>;', }, + { + name: 'DeepSeekLlmApiExtensionMap', + declaration: 'export interface DeepSeekLlmApiExtensionMap {\n}', + }, + { + name: 'DeepSeekLlmApiExtensionProvider', + declaration: 'export interface DeepSeekLlmApiExtensionProvider {\n prepare(request: DeepSeekLlmApiExtensionRequest): PreparedDeepSeekLlmApiExtension | undefined | Promise | undefined>;\n}', + }, + { + name: 'DeepSeekLlmApiExtensionRequest', + declaration: 'export interface DeepSeekLlmApiExtensionRequest {\n readonly body: Readonly>;\n readonly sessionId?: string;\n readonly purpose?: \'compaction\' | \'session-title\';\n readonly signal: AbortSignal;\n}', + }, + { + name: 'DeepSeekLlmApiJson', + declaration: 'export type DeepSeekLlmApiJson = null | boolean | number | string | DeepSeekLlmApiJson[] | {\n [key: string]: DeepSeekLlmApiJson;\n};', + }, { name: 'DiffCallView', declaration: 'export interface DiffCallView {\n card: \'diff\';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n}', @@ -3817,6 +3852,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PreparedAdapterCall', declaration: 'export interface PreparedAdapterCall {\n readonly model: LlmResolvedModelInfo;\n stream(options: GenerateOptions): AsyncIterable;\n}', }, + { + name: 'PreparedDeepSeekLlmApiExtension', + declaration: 'export interface PreparedDeepSeekLlmApiExtension {\n readonly value: T;\n accept?(): void | Promise;\n}', + }, + { + name: 'PreparedDeepSeekLlmApiExtensions', + declaration: 'export interface PreparedDeepSeekLlmApiExtensions {\n readonly fields: Readonly>;\n accept(): Promise;\n}', + }, { name: 'PreparedLlmCall', declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly retryPolicy: ResolvedRetryPolicy;\n readonly context?: LlmModelContext;\n readonly inputModalities?: readonly ModelModality[];\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable;\n}', diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml index 8dbce74a9d..a404fdb6eb 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: 15f90024abf3256560d67099af79be7d4e0ee310 -README.zh.md: 31ccfd0c8d979f1dac1ec2c0d6a377c9277c3271 +README.md: edef08e594c1bb46179996342b05662af981b98a +README.zh.md: a454a1e1eb33484274005d7689e8a44ae0e062ba diff --git a/packages/llm/README.md b/packages/llm/README.md index 15f90024ab..edef08e594 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The LLM seam and its provider adapters. The `llm` package owns both the Service Definition and Consumer roles: the abstract service, content-block vocabulary, and stream-chunk assembler. Provider adapters register on `ctx.llm`. All **product** packages. +The LLM seam, provider adapters, and provider-specific request metadata plugins. The `llm` package owns both the Service Definition and Consumer roles: the abstract service, content-block vocabulary, and stream-chunk assembler. Provider adapters register on `ctx.llm`. All **product** packages. | Package | Role | ctx key | |---|---|---| @@ -11,6 +11,8 @@ The LLM seam and its provider adapters. The `llm` package owns both the Service | [`llm-retry/`](llm-retry/README.md) | Provider-scoped retry policy | listens to `agent/request-error` | | [`llm-deepseek/`](llm-deepseek/README.md) | Direct DeepSeek adapter | registers on `ctx.llm` | | [`llm-pi-ai/`](llm-pi-ai/README.md) | Multi-provider pi-ai adapter | registers on `ctx.llm` | +| [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.md) | Official DeepSeek request-field registry | `ctx.deepseekLlmApiExtensions` | +| [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.md) | Active package metadata for official DeepSeek requests | contributes `dsh_plugin_packages` | Adapters register provider routes on the seam; retry and token measurement remain separate consumers. The child READMEs own routing, metadata, replay, and provider-wire details; the [LLM architecture decisions](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) own the rationale. diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md index 31ccfd0c8d..a454a1e1eb 100644 --- a/packages/llm/README.zh.md +++ b/packages/llm/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -LLM(大语言模型)seam 及其提供方适配器。`llm` 包同时承担 Service Definition 和 Consumer 角色:抽象服务、内容块词汇和流式分片组装器。提供方适配器注册到 `ctx.llm`。这些全是**产品**包。 +LLM(大语言模型)seam、提供方适配器及提供方特定请求元数据插件。`llm` 包同时承担 Service Definition 和 Consumer 角色:抽象服务、内容块词汇和流式分片组装器。提供方适配器注册到 `ctx.llm`。这些全是**产品**包。 | 包 | 职责 | ctx key | |---|---|---| @@ -11,6 +11,8 @@ LLM(大语言模型)seam 及其提供方适配器。`llm` 包同时承担 Se | [`llm-retry/`](llm-retry/README.zh.md) | 提供方作用域的重试策略 | 监听 `agent/request-error` | | [`llm-deepseek/`](llm-deepseek/README.zh.md) | 直接 DeepSeek 适配器 | 注册到 `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 官方请求的存活包元数据 | 贡献 `dsh_plugin_packages` | 适配器在 seam 上注册提供方路由;重试与 token 测量仍是独立消费方。子 README 负责路由、元数据、回放和提供方协议细节;[LLM 架构决策](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md)说明设计原理。 diff --git a/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml b/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml new file mode 100644 index 0000000000..59300520e3 --- /dev/null +++ b/packages/llm/deepseek-llm-api-extensions/README.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 packages/llm/deepseek-llm-api-extensions/README.md +README.md: fd25e1e047316acc4b92c0ea2c5cc3fde85cbe8a +README.zh.md: 9f7d8e75bb6ff042d946a918e746f8b1871b2f08 diff --git a/packages/llm/deepseek-llm-api-extensions/README.md b/packages/llm/deepseek-llm-api-extensions/README.md new file mode 100644 index 0000000000..fd25e1e047 --- /dev/null +++ b/packages/llm/deepseek-llm-api-extensions/README.md @@ -0,0 +1,28 @@ +# @deepseek-ai/dsh-deepseek-llm-api-extensions + +English | [中文](README.zh.md) + +Provider-specific registry for additive top-level fields on official DeepSeek LLM API requests. `DeepSeekLlmApiExtensionRegistry` registers `ctx.deepseekLlmApiExtensions`; contributor plugins claim one declaration-merged field, and `dsh-llm-deepseek` prepares the current contributions after serializing its base request. + +## Service + +- `register(field, provider)` reserves one field for the calling fiber. Duplicate or malformed names fail synchronously; disposing the registration releases it for a later provider. +- `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`. + +Each provider sees the exact serialized base 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-plugin-package-inventory-deepseek` owns the initial `dsh_plugin_packages` field. The provider-neutral LLM seam and `llm-pi-ai` do not consume this registry. + +## Model Experience + +Indirectly, through `@deepseek-ai/dsh-llm-deepseek`, which sends registered fields outside the model's `messages`, system prompt, and tool schemas. + +#### KV Cache effect + +None; registry fields are model-hidden provider metadata and do not alter the serialized model-input prefix. + +## Known Limitations and Deferred Work + +- **Official DeepSeek requests only** — the registry intentionally has no provider-neutral routing or pi-ai adapter integration. +- **No field ordering contract** — JSON object member order follows registration preparation but receivers address fields by name. diff --git a/packages/llm/deepseek-llm-api-extensions/README.zh.md b/packages/llm/deepseek-llm-api-extensions/README.zh.md new file mode 100644 index 0000000000..9f7d8e75bb --- /dev/null +++ b/packages/llm/deepseek-llm-api-extensions/README.zh.md @@ -0,0 +1,28 @@ +# @deepseek-ai/dsh-deepseek-llm-api-extensions + +[English](README.md) | 中文 + +用于向 DeepSeek 官方 LLM API 请求添加顶层字段的提供方特定注册表。`DeepSeekLlmApiExtensionRegistry` 注册 `ctx.deepseekLlmApiExtensions`;贡献插件分别认领一个经声明合并的字段,`dsh-llm-deepseek` 则在序列化基础请求后准备当前贡献。 + +## 服务 + +- `register(field, provider)` 为调用 fiber 保留一个字段。重复或格式错误的名称会同步失败;dispose(资源释放)该注册后,后续提供方可以再次认领。 +- `prepare(request)` 对已注册提供方取快照,并发准备贡献,克隆并冻结返回的 JSON 值,然后返回 `{ fields, accept }`。准备失败会在 HTTP 分发前拒绝请求;请求取消后,即使某个提供方忽略信号,注册表也会停止等待。 +- `accept()` 对每个捕获的 2xx 后回调只运行一次。并发调用会等待同一次结算,所有回调都在报告失败前完成,多个失败会合并为一个 `AggregateError`。 + +每个提供方都会看到确切的已序列化基础正文、请求 `AbortSignal`,以及可选的 `sessionId` 与辅助调用 `purpose`。提供方必须在取消后迅速停止自身工作;字段不适用于当前请求时返回 `undefined`。即使 HMR(热模块替换)在 HTTP 接受前移除了注册,已准备的操作仍会保留其捕获的提供方。 + +注册表拥有字段添加与生命周期,不拥有字段语义。`@deepseek-ai/dsh-plugin-package-inventory-deepseek` 拥有首个 `dsh_plugin_packages` 字段。提供方无关的 LLM seam 与 `llm-pi-ai` 都不消费该注册表。 + +## 模型体验 + +通过 `@deepseek-ai/dsh-llm-deepseek` 间接生效;该包在模型的 `messages`、系统提示词与工具 schema 之外发送已注册字段。 + +#### KV Cache 影响 + +无;注册表字段是模型不可见的提供方元数据,不改变已序列化的模型输入前缀。 + +## 已知限制与暂缓事项 + +- **仅限 DeepSeek 官方请求**——该注册表刻意不提供提供方无关的路由,也不集成 pi-ai 适配器。 +- **不约定字段顺序**——JSON 对象成员顺序取决于注册准备顺序,但接收方按名称寻址字段。 diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json new file mode 100644 index 0000000000..b82d01328d --- /dev/null +++ b/packages/llm/deepseek-llm-api-extensions/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-deepseek-llm-api-extensions", + "description": "Additive request-field registry for the official DeepSeek LLM API adapter", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/llm/deepseek-llm-api-extensions" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/llm/deepseek-llm-api-extensions/src/index.ts b/packages/llm/deepseek-llm-api-extensions/src/index.ts new file mode 100644 index 0000000000..cf548f7bee --- /dev/null +++ b/packages/llm/deepseek-llm-api-extensions/src/index.ts @@ -0,0 +1,132 @@ +/** + * DeepSeek LLM API extension registry: plugins own independent top-level request + * fields while the official adapter performs one preparation and acceptance transaction. + * @module @deepseek-ai/dsh-deepseek-llm-api-extensions + */ + +import { Context, Service } from '@deepseek-ai/cordis' +import type { + DeepSeekLlmApiExtensionMap, + DeepSeekLlmApiExtensionProvider, + DeepSeekLlmApiExtensionRequest, + DeepSeekLlmApiJson, + PreparedDeepSeekLlmApiExtensions, +} from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + deepseekLlmApiExtensions: DeepSeekLlmApiExtensionRegistry + } +} + +interface ErasedProvider { + prepare(request: DeepSeekLlmApiExtensionRequest): + | { readonly value: DeepSeekLlmApiJson; accept?(): void | Promise } + | undefined + | Promise<{ readonly value: DeepSeekLlmApiJson; accept?(): void | Promise } | undefined> +} + +/** Recursively freeze a fresh structured clone. */ +function freezeJson(value: T): T { + if (value !== null && typeof value === 'object') { + for (const child of Array.isArray(value) ? value : Object.values(value)) freezeJson(child) + Object.freeze(value) + } + return value +} + +/** Settle every acceptance callback before reporting failures. */ +async function acceptAll(callbacks: readonly (() => void | Promise)[]): Promise { + const outcomes = await Promise.allSettled(callbacks.map(callback => Promise.resolve().then(callback))) + const failures: unknown[] = outcomes + .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') + .map(outcome => outcome.reason as unknown) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'DeepSeek LLM API extension acceptance failed') +} + +/** Stop awaiting provider work when the containing model request is cancelled. */ +async function abortable(work: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted() + const aborted = Promise.withResolvers() + const onAbort = (): void => { aborted.reject(signal.reason) } + signal.addEventListener('abort', onAbort, { once: true }) + try { + const result = await Promise.race([work, aborted.promise]) + signal.throwIfAborted() + return result + } finally { + signal.removeEventListener('abort', onAbort) + } +} + +/** Registry of independently owned top-level fields for official DeepSeek requests. */ +export class DeepSeekLlmApiExtensionRegistry extends Service { + private readonly providers = new Map() + + constructor(ctx: Context) { + super(ctx, 'deepseekLlmApiExtensions') + } + + /** + * Register the sole provider of one top-level request field. Registration is effect-scoped. + * @param field - declaration-merged field owned by the provider. + * @param provider - request-time field preparation and optional acceptance behavior. + * @returns disposer that releases the field. + */ + register( + field: K, + provider: DeepSeekLlmApiExtensionProvider, + ): () => Promise { + const fieldName = field as string + if (fieldName.length === 0 || fieldName.trim() !== fieldName) { + throw new Error('deepseek-llm-api-extensions: field must be a non-blank trimmed string') + } + const providers = this.providers + const erased = provider as ErasedProvider + const dispose = this.ctx.effect(() => { + if (providers.has(fieldName)) { + throw new Error(`deepseek-llm-api-extensions: field ${JSON.stringify(fieldName)} is already registered`) + } + providers.set(fieldName, erased) + return () => { + providers.delete(fieldName) + } + }, `deepseekLlmApiExtensions.register(${JSON.stringify(fieldName)})`) + return dispose + } + + /** + * Prepare every currently registered field from one immutable base request. + * Preparation failures reject before HTTP dispatch. Field values are cloned and frozen; + * providers retain no mutable alias to the outgoing request. + * @param request - exact serialized request facts before extension fields. + * @returns detached fields and their idempotent joint acceptance transaction. + */ + async prepare(request: DeepSeekLlmApiExtensionRequest): Promise { + request.signal.throwIfAborted() + const entries = [...this.providers.entries()] + const prepared = await abortable(Promise.all(entries.map(async ([field, provider]) => ({ + field, + result: await provider.prepare(request), + }))), request.signal) + const fields: Record = Object.create(null) as Record + const callbacks: Array<() => void | Promise> = [] + for (const { field, result } of prepared) { + if (result === undefined) continue + fields[field] = freezeJson(structuredClone(result.value)) + const accept = result.accept + if (accept !== undefined) callbacks.push(accept.bind(result)) + } + Object.freeze(fields) + let acceptance: Promise | undefined + return { + fields, + accept: () => acceptance ??= acceptAll(callbacks), + } + } +} + +export default DeepSeekLlmApiExtensionRegistry diff --git a/packages/llm/deepseek-llm-api-extensions/src/invariant.ts b/packages/llm/deepseek-llm-api-extensions/src/invariant.ts new file mode 100644 index 0000000000..ed743eed65 --- /dev/null +++ b/packages/llm/deepseek-llm-api-extensions/src/invariant.ts @@ -0,0 +1,27 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-deepseek-llm-api-extensions`. */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-deepseek-llm-api-extensions' + +/** Cordis companion plugin name. */ +export const name = 'deepseek-llm-api-extensions-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: duplicate ownership, detached output, and one acceptance + * settlement are enforced inside the registry operation that owns each decision. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/llm/deepseek-llm-api-extensions/src/types.ts b/packages/llm/deepseek-llm-api-extensions/src/types.ts new file mode 100644 index 0000000000..1e3ccc492b --- /dev/null +++ b/packages/llm/deepseek-llm-api-extensions/src/types.ts @@ -0,0 +1,59 @@ +/** Provider-specific JSON and contribution types for DeepSeek request extensions. */ + +/** Lossless JSON value accepted by the DeepSeek request body. */ +export type DeepSeekLlmApiJson = + | null + | boolean + | number + | string + | DeepSeekLlmApiJson[] + | { [key: string]: DeepSeekLlmApiJson } + +/** + * Merge-extensible table of top-level DeepSeek request extension fields. + * Contributor packages declaration-merge the field they own. + */ +export interface DeepSeekLlmApiExtensionMap {} + +/** Exact serialized request facts visible to extension providers. */ +export interface DeepSeekLlmApiExtensionRequest { + /** Base DeepSeek request body before extension fields are merged. */ + readonly body: Readonly> + /** Session identity carried by the model request, when present. */ + readonly sessionId?: string + /** Auxiliary request classification, when present. */ + readonly purpose?: 'compaction' | 'session-title' + /** Cancellation for request preparation; providers must stop promptly after abort. */ + readonly signal: AbortSignal +} + +/** One prepared field value and its optional post-2xx commit. */ +export interface PreparedDeepSeekLlmApiExtension { + /** Detached value merged under the provider's registered field. */ + readonly value: T + /** Commit state that depends on confirmed provider acceptance. */ + accept?(): void | Promise +} + +/** Provider registered under one key of {@link DeepSeekLlmApiExtensionMap}. */ +export interface DeepSeekLlmApiExtensionProvider { + /** + * Prepare one field for an exact serialized request. + * @param request - immutable base request facts. + * @returns the prepared field, or `undefined` when this request has no value for it. + */ + prepare( + request: DeepSeekLlmApiExtensionRequest, + ): PreparedDeepSeekLlmApiExtension | undefined | Promise | undefined> +} + +/** All fields prepared for one request plus their joint acceptance transaction. */ +export interface PreparedDeepSeekLlmApiExtensions { + /** Detached top-level fields to merge into the base request. */ + readonly fields: Readonly> + /** + * Commit every captured provider after HTTP 2xx. Repeated calls join the same settlement. + * @returns fulfillment after every commit succeeds. + */ + accept(): Promise +} diff --git a/packages/llm/deepseek-llm-api-extensions/tests/registry.spec.ts b/packages/llm/deepseek-llm-api-extensions/tests/registry.spec.ts new file mode 100644 index 0000000000..85cc97525c --- /dev/null +++ b/packages/llm/deepseek-llm-api-extensions/tests/registry.spec.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import DeepSeekLlmApiExtensionRegistry from '../src/index.ts' + +declare module '@deepseek-ai/dsh-deepseek-llm-api-extensions/types' { + interface DeepSeekLlmApiExtensionMap { + test_alpha: { readonly value: string } + test_beta: readonly number[] + } +} + +const contexts: Context[] = [] +const SIGNAL = new AbortController().signal + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function harness(): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(DeepSeekLlmApiExtensionRegistry) + return ctx +} + +describe('DeepSeekLlmApiExtensionRegistry', () => { + it('prepares detached fields and accepts every provider exactly once', async () => { + const ctx = await harness() + const first = vi.fn() + const second = vi.fn() + const mutable = { value: 'original' } + ctx.deepseekLlmApiExtensions.register('test_alpha', { + prepare: () => ({ + value: mutable, + accept: first, + }), + }) + ctx.deepseekLlmApiExtensions.register('test_beta', { + prepare: async request => ({ + value: [request.body.messages === undefined ? 0 : 1], + accept: async () => { second() }, + }), + }) + + const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL, sessionId: 's' }) + mutable.value = 'changed' + expect(prepared.fields).toEqual({ test_alpha: { value: 'original' }, test_beta: [1] }) + expect(Object.isFrozen(prepared.fields)).toBe(true) + expect(Object.isFrozen(prepared.fields.test_alpha)).toBe(true) + + await Promise.all([prepared.accept(), prepared.accept()]) + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + }) + + it('preserves the prepared result as an acceptance method receiver', async () => { + const ctx = await harness() + const result = { + value: { value: 'receiver' }, + accepted: 0, + accept(): void { + this.accepted += 1 + }, + } + ctx.deepseekLlmApiExtensions.register('test_alpha', { prepare: () => result }) + + const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: {}, signal: SIGNAL }) + await prepared.accept() + expect(result.accepted).toBe(1) + }) + + it('rejects duplicate fields and releases ownership with the registering fiber', async () => { + const ctx = await harness() + const owner = ctx.extend() + const dispose = owner.deepseekLlmApiExtensions.register('test_alpha', { + prepare: () => ({ value: { value: 'one' } }), + }) + expect(() => ctx.deepseekLlmApiExtensions.register('test_alpha', { + prepare: () => ({ value: { value: 'two' } }), + })).toThrow(/already registered/) + + await dispose() + ctx.deepseekLlmApiExtensions.register('test_alpha', { + prepare: () => ({ value: { value: 'replacement' } }), + }) + await expect(ctx.deepseekLlmApiExtensions.prepare({ body: {}, signal: SIGNAL })) + .resolves.toMatchObject({ fields: { test_alpha: { value: 'replacement' } } }) + }) + + it('settles every acceptance callback before reporting one or several failures', async () => { + const ctx = await harness() + const later = vi.fn() + ctx.deepseekLlmApiExtensions.register('test_alpha', { + prepare: () => ({ + value: { value: 'x' }, + accept: () => { throw new Error('alpha failed') }, + }), + }) + ctx.deepseekLlmApiExtensions.register('test_beta', { + prepare: () => ({ + value: [2], + accept: () => { later(); throw new Error('beta failed') }, + }), + }) + const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: {}, signal: SIGNAL }) + await expect(prepared.accept()).rejects.toMatchObject({ + errors: [expect.objectContaining({ message: 'alpha failed' }), expect.objectContaining({ message: 'beta failed' })], + }) + expect(later).toHaveBeenCalledOnce() + }) + + it('reports a single acceptance failure verbatim and omits an undefined contribution', async () => { + const ctx = await harness() + const failure = new Error('single failure') + ctx.deepseekLlmApiExtensions.register('test_alpha', { + prepare: () => ({ value: { value: 'x' }, accept: () => { throw failure } }), + }) + ctx.deepseekLlmApiExtensions.register('test_beta', { prepare: () => undefined }) + const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: {}, signal: SIGNAL }) + expect(prepared.fields).toEqual({ test_alpha: { value: 'x' } }) + await expect(prepared.accept()).rejects.toBe(failure) + }) + + it('rejects invalid field names and preparation failures before returning fields', async () => { + const ctx = await harness() + expect(() => ctx.deepseekLlmApiExtensions.register('' as 'test_alpha', { + prepare: () => ({ value: { value: 'x' } }), + })).toThrow(/non-blank trimmed/) + ctx.deepseekLlmApiExtensions.register('test_alpha', { + prepare: () => { throw new Error('prepare failed') }, + }) + await expect(ctx.deepseekLlmApiExtensions.prepare({ body: {}, signal: SIGNAL })).rejects.toThrow('prepare failed') + }) + + it('stops waiting for a provider that ignores request cancellation', async () => { + const ctx = await harness() + const controller = new AbortController() + const started = Promise.withResolvers() + ctx.deepseekLlmApiExtensions.register('test_alpha', { + prepare: () => { + started.resolve(undefined) + return new Promise(() => {}) + }, + }) + + const pending = ctx.deepseekLlmApiExtensions.prepare({ + body: {}, + signal: controller.signal, + }) + await started.promise + controller.abort(new Error('cancelled during extension preparation')) + await expect(pending).rejects.toBe(controller.signal.reason) + }, 500) +}) diff --git a/packages/llm/deepseek-llm-api-extensions/tsconfig.json b/packages/llm/deepseek-llm-api-extensions/tsconfig.json new file mode 100644 index 0000000000..bb46910c07 --- /dev/null +++ b/packages/llm/deepseek-llm-api-extensions/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index e434fefafc..b5fffc50a6 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: 8a62b7b587323de152ea3322ce310d48a41247cc -README.zh.md: 009732f4256c49d7ae8d702c41df532236f77c5f +README.md: f50de33dfbf7229968c9335972b47ab5a0bd1c41 +README.zh.md: 3820972a6cc1eb7a1c7b0855c718c6695b6a3c76 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 8a62b7b587..f50de33dfb 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -84,6 +84,12 @@ The one registration-captured fact is the retry policy: when its resolved value The plugin also declares its route in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`): provider `deepseek-official`, settings namespace `llm-deepseek`, empty settings path — the whole section is the profile. Configuration surfaces use that entry to offer this adapter alongside dormant pi-ai providers. +## DeepSeek request extensions + +When `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registered top-level fields after serializing the exact wire messages and before `fetch`. The same request signal reaches providers, and cancellation stops waiting even when a provider ignores it. Preparation failures and field collisions fail before HTTP with `REQUEST_EXTENSION`. After HTTP 2xx, the adapter awaits the prepared acceptance transaction before consuming the SSE body; an acceptance failure uses the same code, while transport and non-2xx failures do not accept the fields. Fields go to the resolved `baseURL`, including a configured gateway. A composition without the registry sends the base DeepSeek request unchanged. + +Shipped profiles and runnable examples mount [`@deepseek-ai/dsh-plugin-package-inventory-deepseek`](../plugin-package-inventory-deepseek/README.md) for the complete active `dsh_plugin_packages` field. Package metadata defaults on and remains model-hidden. `llm-pi-ai` neither imports nor calls this provider-specific registry. + ## App attribution Every chat and Files API request carries the shared attribution header from dsh-llm's `attributionHeaders()`, the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request whose `GenerateOptions.purpose` is `compaction` (dsh-compaction-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests. @@ -101,7 +107,7 @@ DeepSeek request identity is separate from app attribution. After credential res ## Errors -Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s and 413), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. If DeepSeek rejects a normalized image, the primary message names the attachment or display name, durable message and image position, normalized media type, 8-bit sRGB/sRGBA depth, dimensions, and provider message. With several candidates and no file id in the provider detail, it lists each possible image instead of assigning the failure to the first one. The raw response remains the error `cause`; it is never the only user-visible diagnostic. Attachment reads retain their stable attachment failure code rather than becoming transport failures. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy). +Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s and 413), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. Extension preparation, base-field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`; no extension failure is relabelled as transport. If DeepSeek rejects a normalized image, the primary message names the attachment or display name, durable message and image position, normalized media type, 8-bit sRGB/sRGBA depth, dimensions, and provider message. With several candidates and no file id in the provider detail, it lists each possible image instead of assigning the failure to the first one. The raw response remains the error `cause`; it is never the only user-visible diagnostic. Attachment reads retain their stable attachment failure code rather than becoming transport failures. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy). ## Model Experience @@ -109,7 +115,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config. The vision model normally receives retained user and tool-result images as Files API references beside stable attachment handles and request-image dimensions; a Files resolution failure sends all retained images as inline data URLs instead. An over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside stable attachment handles and request-image dimensions; a Files resolution failure sends all retained images as inline data URLs instead. An over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. #### Token effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 009732f425..3820972a6c 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -84,6 +84,12 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件还会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`)中声明自己的路由:提供方为 `deepseek-official`,settings namespace 为 `llm-deepseek`,settings path 为空——整个分节就是 profile。配置界面借助该条目,把本适配器与休眠的 pi-ai 提供方一并呈现。 +## DeepSeek 请求扩展 + +存在 `ctx.deepseekLlmApiExtensions` 时,适配器会在序列化确切协议消息后、`fetch` 前准备其中已注册的顶层字段。提供方会收到同一个请求信号;即使某个提供方忽略信号,取消也会停止等待。准备失败与字段冲突会在 HTTP 前以 `REQUEST_EXTENSION` 失败。HTTP 2xx 后,适配器会先等待已准备的接受事务,再消费 SSE 正文;接受失败使用同一 code,而传输失败与非 2xx 失败不会接受字段。字段会发往解析后的 `baseURL`,包括已配置的网关。未组合该注册表的部署会发送未改变的 DeepSeek 基础请求。 + +随附 profile 与可运行示例会挂载 [`@deepseek-ai/dsh-plugin-package-inventory-deepseek`](../plugin-package-inventory-deepseek/README.zh.md) 以提供完整存活 `dsh_plugin_packages` 字段。插件包元数据默认开启且对模型不可见。`llm-pi-ai` 既不导入也不调用该提供方特定注册表。 + ## 应用归因 每个 chat 和 Files API 请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.zh.md#app-attribution-attributionts))。在该适配器约定(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compaction-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。 @@ -101,7 +107,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 ## 错误 -非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400 和 413)、`SERVER`(5xx),其他情况为 `HTTP_`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。如果 DeepSeek 拒绝一张已规范化图片,主错误会写明附件 ID 或显示名称、持久消息和图片位置、规范化后的媒体类型、8-bit sRGB/sRGBA 位深、尺寸和提供方消息。存在多张候选图片且提供方详细信息没有 file id 时,错误会列出全部可能图片,不会把错误归给第一张。原始响应保留为错误 `cause`,不会成为唯一的用户可见诊断。附件读取会保留稳定的附件失败 code,不会变成传输失败。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。 +非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400 和 413)、`SERVER`(5xx),其他情况为 `HTTP_`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。扩展准备、基础字段冲突或 2xx 后接受失败会使用 `REQUEST_EXTENSION`;扩展失败绝不会被重新标记为传输失败。如果 DeepSeek 拒绝一张已规范化图片,主错误会写明附件 ID 或显示名称、持久消息和图片位置、规范化后的媒体类型、8-bit sRGB/sRGBA 位深、尺寸和提供方消息。存在多张候选图片且提供方详细信息没有 file id 时,错误会列出全部可能图片,不会把错误归给第一张。原始响应保留为错误 `cause`,不会成为唯一的用户可见诊断。附件读取会保留稳定的附件失败 code,不会变成传输失败。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。 ## 模型体验 @@ -109,7 +115,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄和请求图片尺寸;Files 解析失败时,所有保留图片改用内联 data URL。超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄和请求图片尺寸;Files 解析失败时,所有保留图片改用内联 data URL。超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 #### Token 影响 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 18bcb2e953..66824f37a9 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -50,15 +51,19 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 8c30333131..fe461fdb35 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -30,6 +30,11 @@ import type { import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { deadline, idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import type { + DeepSeekLlmApiExtensionRequest, + DeepSeekLlmApiJson, + PreparedDeepSeekLlmApiExtensions, +} from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import { serializeRequest, serializeRequestWithImages } from './serialize.ts' import type { ImageWireLocation, RequestDefaults } from './serialize.ts' import { DeepSeekFileStore } from './file-store.ts' @@ -124,6 +129,8 @@ export interface DeepSeekAdapterOptions { resolveAttachments?: () => AttachmentStore | undefined /** Resolve the process-wide upload reuse store. */ resolveFiles?: () => DeepSeekFileStore + /** Prepare the official API's plugin-contributed top-level fields for one exact wire request. */ + prepareExtensions: (request: DeepSeekLlmApiExtensionRequest) => Promise } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -598,7 +605,25 @@ export class DeepSeekAdapter extends LlmAdapter { continue } } - const payload = JSON.stringify(body) + let extensions: PreparedDeepSeekLlmApiExtensions + try { + extensions = await this.config.prepareExtensions({ + body: body as unknown as Readonly>, + signal, + ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, + ...options.purpose === undefined ? {} : { purpose: options.purpose }, + }) + } catch (error) { + throw new LlmError('DeepSeek request extension preparation failed', 'REQUEST_EXTENSION', { cause: error }) + } + for (const field of Object.keys(extensions.fields)) { + if (Object.hasOwn(body, field)) { + throw new LlmError(`DeepSeek request extension field ${JSON.stringify(field)} collides with the base request`, 'REQUEST_EXTENSION') + } + } + // Prepared outside the try so the TRANSPORT label below covers exactly the + // transport boundary, never a serialization failure. + const payload = JSON.stringify({ ...body, ...extensions.fields }) // TODO(http): adopt the Cordis HTTP service when shared transport configuration // outweighs its additional runtime dependencies. @@ -655,6 +680,11 @@ export class DeepSeekAdapter extends LlmAdapter { ...id === undefined ? {} : { requestId: id }, }) } + try { + await extensions.accept() + } catch (error) { + throw new LlmError('DeepSeek request extension acceptance failed', 'REQUEST_EXTENSION', { cause: error }) + } if (!response.body) { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 3af0236d29..e6faad74ce 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -438,6 +438,11 @@ export function apply(ctx: Context, config: Config): void { resolveApiKey, resolveUserId, resolveAttachments: () => ctx.get('attachments'), + prepareExtensions: (request) => { + const extensions = ctx.get('deepseekLlmApiExtensions') + return extensions?.prepare(request) + ?? Promise.resolve({ fields: {}, accept: () => Promise.resolve() }) + }, }) ctx.llm.registerConfigurableProviders([ { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] }, diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index ee3bea8435..2a29b4b7fb 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -5,6 +5,8 @@ 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, { createUserMessage, CallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' @@ -17,6 +19,8 @@ import type { StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' +import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' import { assemble, type AssembledResult } from './assemble.ts' @@ -180,6 +184,25 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () } }) + it('accepts the plugin-package request extension field', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeekLlmApiExtensionRegistry) + await ctx.plugin(PluginPackageInventoryDeepSeek) + await ctx.plugin(LlmDeepSeek, { thinking: 'disabled' }) + + 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') + }) + 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') diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 085b35063a..5a32a35664 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -17,6 +17,8 @@ import LlmRuntime, { CallId, createUserMessage, 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 { httpErrorCode, resolveRequestImagePolicy } from '../src/adapter.ts' @@ -45,10 +47,16 @@ async function harness(baseURL: string, config: object = {}) { 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 } = {}, @@ -62,6 +70,7 @@ function adapterOf( resolveUserId: () => TEST_USER_ID, resolveAttachments: () => attachments, ...files === undefined ? {} : { resolveFiles: () => files }, + prepareExtensions: noExtensions, }) } @@ -151,6 +160,151 @@ describe('request image policy', () => { }) 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: ['{"choices":[{"delta":{"content":"partial"}}]}'] }, + ]) + 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) @@ -881,6 +1035,7 @@ describe('DeepSeekAdapter against a mock server', () => { resolveApiKey, resolveUserId: () => TEST_USER_ID, resolveAttachments, + prepareExtensions: noExtensions, }) await expect(drain(adapter.stream({ @@ -907,6 +1062,7 @@ describe('DeepSeekAdapter against a mock server', () => { }), resolveApiKey, resolveUserId: () => TEST_USER_ID, + prepareExtensions: noExtensions, }) await expect(drain(adapter.stream({ @@ -1628,6 +1784,7 @@ describe('plugin registration and config', () => { 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', @@ -1998,7 +2155,7 @@ describe('plugin registration and config', () => { 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 }) + const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId, prepareExtensions: noExtensions }) for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index ec83345b61..f533d418a2 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -8,7 +8,7 @@ * behavior — the documented optional-inject fallback. */ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -17,11 +17,14 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import LlmRuntime from '@deepseek-ai/dsh-llm' +import AgentRegistry from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import * as DeepSeekPluginPackageInventory from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -59,7 +62,13 @@ async function loadComposition( const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ '- id: llm', - " name: 'test-llm-service'", + " name: '@deepseek-ai/dsh-llm'", + '- id: agents', + " name: '@deepseek-ai/dsh-agent'", + '- id: deepseek-llm-api-extensions', + " name: '@deepseek-ai/dsh-deepseek-llm-api-extensions'", + '- id: plugin-package-inventory-deepseek', + " name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek'", ...options.withDynamic ? [ '- id: settings', @@ -87,11 +96,25 @@ async function loadComposition( await ctx.plugin(Loader) ctx.loader.builtins.include = Include const modules = new Map([ - ['test-llm-service', LlmRuntime], + ['@deepseek-ai/dsh-llm', LlmRuntime], + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-deepseek-llm-api-extensions', DeepSeekLlmApiExtensionRegistry], + ['@deepseek-ai/dsh-plugin-package-inventory-deepseek', DeepSeekPluginPackageInventory], ['@deepseek-ai/dsh-settings-file', FileSettingsProvider], ['@deepseek-ai/dsh-credentials-local', LocalCredentialProvider], ['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek], ]) + // The custom importer bypasses Node resolution; mirror the package manifests + // a deployed cordis.yml has beside its declared dependencies. + await Promise.all([...modules.keys()].map(async (packageName) => { + const packageDir = join(root!, 'node_modules', ...packageName.split('/')) + await mkdir(packageDir, { recursive: true }) + await writeFile(join(packageDir, 'package.json'), `${JSON.stringify({ + name: packageName, + version: '0.1.0-rc.8', + type: 'module', + })}\n`) + })) ctx.loader.internal = { version: 'v2', async import(specifier: string) { @@ -108,6 +131,21 @@ async function loadComposition( } describe('llm-deepseek real dynamic composition', () => { + it('sends package inventory by default in the real Loader composition', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url }) + + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + const request = server.requests[0] as { dsh_plugin_packages: { version: number; packages: unknown[] } } + expect(request.dsh_plugin_packages.packages).toEqual(expect.arrayContaining([ + { name: '@deepseek-ai/dsh-deepseek-llm-api-extensions', version: '0.1.0-rc.8' }, + { name: '@deepseek-ai/dsh-llm-deepseek', version: '0.1.0-rc.8' }, + { name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek', version: '0.1.0-rc.8' }, + ])) + expect(request.dsh_plugin_packages.version).toBe(1) + }) + it('boots from cordis.yml and routes the next request after external settings and credential edits', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 0ba1a2116c..2f75c10b9e 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../credentials/credentials" }, + { + "path": "../deepseek-llm-api-extensions" + }, { "path": "../../util/launch-environment" }, diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index e2ed0233d9..b5413cfb31 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -136,6 +136,7 @@ describe('PiAiAdapter provider routing', () => { thinking: { type: 'enabled' }, reasoning_effort: 'max', }) + expect(server.requests[0]).not.toHaveProperty('dsh_plugin_packages') }) it('uses a dynamic request effort and reports unsupported efforts before network I/O', async () => { diff --git a/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml b/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml new file mode 100644 index 0000000000..6a8127b284 --- /dev/null +++ b/packages/llm/plugin-package-inventory-deepseek/README.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 packages/llm/plugin-package-inventory-deepseek/README.md +README.md: 3100c89b3abf317c6dfceb9aa5e77f8f6717d5c4 +README.zh.md: 64592eb0d4a0a92fbd110cb5fdcd4f46d8258b5d diff --git a/packages/llm/plugin-package-inventory-deepseek/README.md b/packages/llm/plugin-package-inventory-deepseek/README.md new file mode 100644 index 0000000000..3100c89b3a --- /dev/null +++ b/packages/llm/plugin-package-inventory-deepseek/README.md @@ -0,0 +1,43 @@ +# @deepseek-ai/dsh-plugin-package-inventory-deepseek + +English | [中文](README.zh.md) + +Complete active Loader-backed plugin package inventory for official DeepSeek LLM API requests. This function plugin injects the Loader, live Agent registry, and `ctx.deepseekLlmApiExtensions`, then owns the `dsh_plugin_packages` field. + +## Configuration + +| Key | Default | Meaning | +|---|---:|---| +| `enabled` | `true` | Register the `dsh_plugin_packages` contribution. Set it to `false` to omit package metadata. | + +Shipped profiles use the default, so every official DeepSeek request carries the package inventory when preparation succeeds. + +## Collection + +Every request re-reads active non-group entries from the host Loader tree. When optional `ctx.agentPresets` is present and `sessionId` resolves to a live Agent joined to a standing preset, that preset's separate Loader tree joins the same collection; deployments without the service report the host tree only. Entries are included only while their root fiber is `ACTIVE` and their effective Loader state is enabled. + +Bare package and package-subpath specifiers resolve through Node's package search paths without requiring a `./package.json` export. Each ordinary entry uses its owning Loader tree base. A standing preset's root entries use the harness base, matching the preset Loader's deliberate bare-package override; nested includes retain their own bases. Relative and absolute modules walk to their nearest manifest; a manifest without `name` marks a loose module and contributes no package identity. A named package manifest must also declare a non-empty `version`, and malformed package metadata fails request preparation. Exact name/version pairs are deduplicated and sorted with a locale-independent comparison, while simultaneously active different versions remain separate. + +The version-1 `dsh_plugin_packages` field contains only `{ name, version }` pairs. Disabled, pending, failed, disposed, unloading, structural `cordis:` rows, ordinary dependencies, loose files without an owning package identity, programmatically mounted child fibers, and in-memory dynamic plugins are excluded. + +## Model Experience + +### Package inventory metadata + +#### What the model sees + +Nothing. `dsh_plugin_packages` is provider metadata outside the model's messages, system prompt, and tool schemas. + +#### Token effect + +Zero model-input tokens; the complete inventory adds only HTTP request bytes. + +#### KV Cache effect + +None; package lifecycle changes do not alter the model-visible prefix. + +## Known Limitations and Deferred Work + +- **Loader package provenance only** — programmatic child fibers and in-memory dynamic plugins do not have authoritative npm name/version provenance and remain outside this inventory. +- **Loose modules are omitted** — a relative file without a named and versioned owning manifest is a plugin module, not a plugin package. +- **In-place package replacement requires restart** — manifest identities are cached for the process lifetime. Loader enable, disable, mount, unmount, and ordinary source HMR still refresh the active entry set, but replacing a mounted package's manifest with another version in the same process is not a supported upgrade path. diff --git a/packages/llm/plugin-package-inventory-deepseek/README.zh.md b/packages/llm/plugin-package-inventory-deepseek/README.zh.md new file mode 100644 index 0000000000..64592eb0d4 --- /dev/null +++ b/packages/llm/plugin-package-inventory-deepseek/README.zh.md @@ -0,0 +1,43 @@ +# @deepseek-ai/dsh-plugin-package-inventory-deepseek + +[English](README.md) | 中文 + +用于 DeepSeek 官方 LLM API 请求的完整存活 Loader 插件包清单。该函数插件注入 Loader、存活 Agent 注册表与 `ctx.deepseekLlmApiExtensions`,并拥有 `dsh_plugin_packages` 字段。 + +## 配置 + +| 配置键 | 默认值 | 含义 | +|---|---:|---| +| `enabled` | `true` | 注册 `dsh_plugin_packages` 贡献。将其设为 `false` 可省略包元数据。 | + +随附 profile 使用该默认值,因此只要准备成功,每个 DeepSeek 官方请求都会携带包清单。 + +## 收集 + +每次请求都会重读宿主 Loader 树中的存活非 group 配置项。存在可选 `ctx.agentPresets` 且 `sessionId` 解析到已加入 standing preset 的存活 Agent 时,该 preset 的独立 Loader 树也会加入同一次收集;未挂载该服务的部署只报告宿主树。只有根 fiber 处于 `ACTIVE` 且 Loader 有效状态为启用的配置项才会纳入。 + +裸包与包子路径 specifier 通过 Node 包搜索路径解析,无需包导出 `./package.json`。每个普通配置项使用其所属 Loader 树的基址。standing preset 的根配置项使用宿主基址,与 preset Loader 对裸包的显式覆写保持一致;嵌套 include 仍使用自身基址。相对与绝对模块会向上查找最近的 manifest(元数据清单);没有 `name` 的 manifest 只标记松散模块,不贡献包身份。具名包 manifest 还必须声明非空 `version`,格式错误的包元数据会使请求准备失败。系统使用与 locale 无关的比较按确切名称/版本对去重并排序,同时存活的不同版本仍会分开保留。 + +版本 1 的 `dsh_plugin_packages` 字段只包含 `{ name, version }` 对。系统会排除禁用、pending、failed、disposed、unloading 状态,结构性 `cordis:` 配置项,普通依赖,没有所属包身份的松散文件,以编程方式挂载的子 fiber,以及内存动态插件。 + +## 模型体验 + +### 包清单元数据 + +#### 模型看到的内容 + +无。`dsh_plugin_packages` 是位于模型消息、系统提示词与工具 schema 之外的提供方元数据。 + +#### Token 影响 + +模型输入 token 为零;完整清单只会增加 HTTP 请求字节数。 + +#### KV Cache 影响 + +无;包生命周期变化不会改变模型可见前缀。 + +## 已知限制与暂缓事项 + +- **仅含 Loader 包来源**——以编程方式创建的子 fiber 与内存动态插件没有权威 NPM 名称/版本来源,因此不在该清单内。 +- **省略松散模块**——没有具名且带版本所属 manifest 的相对文件是插件模块,不是插件包。 +- **原地替换包需要重启**——manifest 身份会在进程存活期内缓存。Loader 的启用、禁用、挂载、卸载与普通源码 HMR 仍会刷新存活配置项集合,但在同一进程中把已挂载包的 manifest 替换为另一版本并不是受支持的升级路径。 diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json new file mode 100644 index 0000000000..4b8eea1427 --- /dev/null +++ b/packages/llm/plugin-package-inventory-deepseek/package.json @@ -0,0 +1,67 @@ +{ + "name": "@deepseek-ai/dsh-plugin-package-inventory-deepseek", + "description": "Active Loader-backed plugin package inventory for official DeepSeek LLM API requests", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/llm/plugin-package-inventory-deepseek" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent-presets": { + "optional": true + } + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/llm/plugin-package-inventory-deepseek/src/index.ts b/packages/llm/plugin-package-inventory-deepseek/src/index.ts new file mode 100644 index 0000000000..40aeadfafa --- /dev/null +++ b/packages/llm/plugin-package-inventory-deepseek/src/index.ts @@ -0,0 +1,198 @@ +/** + * Active Loader-backed plugin package inventory for official DeepSeek requests. + * Host entries and the requesting agent's standing preset are resolved at request time; + * installed dependencies and plugin fibers without Loader package provenance are excluded. + * @module @deepseek-ai/dsh-plugin-package-inventory-deepseek + */ + +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, parse } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { FiberState, type Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Entry, EntryTree } from '@deepseek-ai/cordis-plugin-loader' +import type {} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-presets' +import type { DeepSeekPluginPackageIdentity, DeepSeekPluginPackageInventoryExtension } from './types.ts' +import type {} from './types.ts' + +export type * from './types.ts' + +/** Cordis plugin name. */ +export const name = 'plugin-package-inventory-deepseek' +/** Services required to locate host/requesting-agent entries and contribute the field. */ +export const inject = ['agents', 'deepseekLlmApiExtensions', 'loader'] + +/** Plugin-package request contribution configuration. */ +export interface Config { + /** Contribute `dsh_plugin_packages` to official DeepSeek requests. Defaults to `true`. */ + enabled?: boolean +} + +/** Validated plugin-package request contribution configuration. */ +export const Config: z = z.object({ + enabled: z.boolean().default(true), +}) + +interface PackageManifest { + readonly name?: unknown + readonly version?: unknown +} + +interface ActiveEntry { + readonly entry: Entry + /** Bare-package base used by the Loader path that activated this entry. */ + readonly bareBaseUrl?: string +} + +/** Parse a bare package or package-subpath specifier into its package name. */ +function barePackageName(specifier: string): string | undefined { + if (specifier.startsWith('.') || specifier.includes(':') || isAbsolute(specifier)) return undefined + const [first = '', second = ''] = specifier.split('/') + // An active Loader entry already passed module resolution, so a scoped bare name has its package segment. + return first.startsWith('@') ? `${first}/${second}` : first +} + +/** Read one manifest identity, optionally treating an absent name as a loose-module marker. */ +function identityFromManifest(path: string, allowAnonymous: boolean): DeepSeekPluginPackageIdentity | undefined { + const manifest = JSON.parse(readFileSync(path, 'utf8')) as PackageManifest + if (allowAnonymous && manifest.name === undefined) return undefined + if (typeof manifest.name !== 'string' || manifest.name.length === 0 + || typeof manifest.version !== 'string' || manifest.version.length === 0) { + throw new Error(`plugin-package-inventory-deepseek: ${path} must declare non-empty name and version`) + } + return { name: manifest.name, version: manifest.version } +} + +/** Resolve a bare package without requiring it to export `./package.json`. */ +function barePackageManifest(packageName: string, anchors: readonly string[]): string | undefined { + for (const anchor of anchors) { + const searchPaths = createRequire(anchor).resolve.paths(packageName) + /* v8 ignore next -- active non-builtin package entries always have Node package search paths */ + if (searchPaths === null) continue + for (const searchPath of searchPaths) { + const manifest = join(searchPath, packageName, 'package.json') + if (existsSync(manifest)) return manifest + } + } + return undefined +} + +/** Find the nearest owning manifest for a relative or absolute plugin module. */ +function nearestManifest(modulePath: string): string | undefined { + let current = dirname(modulePath) + const root = parse(current).root + while (true) { + const manifest = join(current, 'package.json') + if (existsSync(manifest)) return manifest + if (current === root) return undefined + current = dirname(current) + } +} + +/** Exact package identity resolver with immutable per-process manifest caching. */ +class PackageIdentityResolver { + // TODO: Invalidate manifest identities if in-process package-version replacement becomes a supported upgrade path. + private readonly cache = new Map() + + constructor(private readonly hostBaseUrl: string) {} + + /** Resolve one Loader entry's owning package, or absence for a non-package loose module. */ + resolve({ entry, bareBaseUrl }: ActiveEntry): DeepSeekPluginPackageIdentity | undefined { + /* v8 ignore next -- Loader entry trees inherit a base URL; the fallback supports direct embedders. */ + const treeBase = entry.parent.tree.ctx.baseUrl ?? this.hostBaseUrl + const anchors = [...new Set([bareBaseUrl ?? treeBase, treeBase, this.hostBaseUrl, import.meta.url])] + const key = `${anchors.join('\u0000')}\u0000${entry.options.name}` + if (this.cache.has(key)) return this.cache.get(key) + + const packageName = barePackageName(entry.options.name) + let manifest: string | undefined + if (packageName !== undefined) { + manifest = barePackageManifest(packageName, anchors) + if (manifest === undefined) { + throw new Error(`plugin-package-inventory-deepseek: cannot resolve active package ${JSON.stringify(packageName)}`) + } + } else if (!entry.options.name.startsWith('cordis:')) { + const moduleUrl = isAbsolute(entry.options.name) + ? pathToFileURL(entry.options.name) + : new URL(entry.options.name, treeBase) + if (moduleUrl.protocol === 'file:') manifest = nearestManifest(fileURLToPath(moduleUrl)) + } + const identity = manifest === undefined ? undefined : identityFromManifest(manifest, packageName === undefined) + this.cache.set(key, identity) + return identity + } +} + +/** Yield active, non-structural entries from one Loader tree. */ +function activeEntries(tree: EntryTree, rootBareBaseUrl?: string): ActiveEntry[] { + return [...tree.entries()] + .filter(entry => !entry.options.group + && !entry.disabled + && entry.fiber?.state === FiberState.ACTIVE) + .map(entry => ({ + entry, + ...entry.parent.tree === tree && rootBareBaseUrl !== undefined + ? { bareBaseUrl: rootBareBaseUrl } + : {}, + })) +} + +/** Deterministic text order independent of the host's ICU data and locale. */ +function compareWireText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +/** Collect the full active package set for one request. */ +async function collectActivePluginPackages( + ctx: Context, + resolver: PackageIdentityResolver, + hostBaseUrl: string, + sessionId?: string, +): Promise { + const entries = activeEntries(ctx.loader) + if (sessionId !== undefined && ctx.get('agentPresets') !== undefined) { + const agent = ctx.agents.get(SessionId(sessionId)) + if (agent !== undefined) { + // The optional peer is loaded only when its service is present. Its existing + // mount query keeps Loader internals off the public AgentPresets service. + const { standingMountFor } = await import('@deepseek-ai/dsh-agent-presets') + const presetTree = standingMountFor(agent.ctx)?.tree + // PresetTree deliberately resolves its root bare rows from the harness; + // nested ordinary includes retain their own tree base. + if (presetTree !== undefined) entries.push(...activeEntries(presetTree, hostBaseUrl)) + } + } + const unique = new Map() + for (const activeEntry of entries) { + const identity = resolver.resolve(activeEntry) + if (identity === undefined) continue + unique.set(`${identity.name}\u0000${identity.version}`, identity) + } + return [...unique.values()].sort((left, right) => ( + compareWireText(left.name, right.name) || compareWireText(left.version, right.version) + )) +} + +/** + * Register the complete `dsh_plugin_packages` request contribution when enabled. + * @param ctx - plugin context carrying Loader provenance and the DeepSeek request-extension registry. + * @param config - validated default-on configuration. + */ +export function apply(ctx: Context, config: Config): void { + if (config.enabled === false) return + const hostBaseUrl = ctx.baseUrl ?? import.meta.url + const resolver = new PackageIdentityResolver(hostBaseUrl) + ctx.deepseekLlmApiExtensions.register('dsh_plugin_packages', { + prepare: async (request) => { + const value: DeepSeekPluginPackageInventoryExtension = { + version: 1, + packages: await collectActivePluginPackages(ctx, resolver, hostBaseUrl, request.sessionId), + } + return { value } + }, + }) +} diff --git a/packages/llm/plugin-package-inventory-deepseek/src/invariant.ts b/packages/llm/plugin-package-inventory-deepseek/src/invariant.ts new file mode 100644 index 0000000000..0325dc3c4f --- /dev/null +++ b/packages/llm/plugin-package-inventory-deepseek/src/invariant.ts @@ -0,0 +1,27 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-plugin-package-inventory-deepseek`. */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-plugin-package-inventory-deepseek' + +/** Cordis companion plugin name. */ +export const name = 'plugin-package-inventory-deepseek-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: each request reads authoritative Loader fiber state and + * package manifests directly; the plugin retains no independently mutable inventory. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/llm/plugin-package-inventory-deepseek/src/types.ts b/packages/llm/plugin-package-inventory-deepseek/src/types.ts new file mode 100644 index 0000000000..8135a119d7 --- /dev/null +++ b/packages/llm/plugin-package-inventory-deepseek/src/types.ts @@ -0,0 +1,19 @@ +/** Wire types for the active DeepSeek plugin package inventory. */ + +/** One exact active plugin package version. */ +export interface DeepSeekPluginPackageIdentity { + readonly name: string + readonly version: string +} + +/** Versioned full package inventory carried by each official DeepSeek request. */ +export interface DeepSeekPluginPackageInventoryExtension { + readonly version: 1 + readonly packages: readonly DeepSeekPluginPackageIdentity[] +} + +declare module '@deepseek-ai/dsh-deepseek-llm-api-extensions/types' { + interface DeepSeekLlmApiExtensionMap { + dsh_plugin_packages: DeepSeekPluginPackageInventoryExtension + } +} diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts new file mode 100644 index 0000000000..aabbe7362f --- /dev/null +++ b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import { createScope } from '@deepseek-ai/dsh-scope' +import AgentPresets, { mountPreset } from '@deepseek-ai/dsh-agent-presets' +import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import * as PluginInventory from '../src/index.ts' + +const contexts: Context[] = [] +const roots: string[] = [] +const SIGNAL = new AbortController().signal + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function packagePlugin( + root: string, + dir: string, + manifest: object, + source = 'export default () => {}\n', +): Promise { + const packageDir = join(root, dir) + await mkdir(packageDir, { recursive: true }) + await writeFile(join(packageDir, 'package.json'), `${JSON.stringify({ type: 'module', ...manifest })}\n`) + await writeFile(join(packageDir, 'plugin.mjs'), source) + return `./${dir}/plugin.mjs` +} + +async function harness(enabled?: boolean): Promise<{ ctx: Context; root: string; disposeInventory: () => Promise }> { + const root = await mkdtemp(join(tmpdir(), 'dsh-plugin-packages-')) + roots.push(root) + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(join(root, 'cordis.yml')).href + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentPresets, { default: 'fixture', roots: [], includeUserRoot: false }) + await ctx.plugin(DeepSeekLlmApiExtensionRegistry) + const inventory = enabled === undefined + ? ctx.plugin(PluginInventory) + : ctx.plugin(PluginInventory, { enabled }) + await inventory + return { ctx, root, disposeInventory: () => inventory.dispose() } +} + +describe('DeepSeek plugin package inventory', () => { + it('contributes by default and can be explicitly disabled', async () => { + const defaultHarness = await harness() + const defaultFields = await defaultHarness.ctx.deepseekLlmApiExtensions.prepare({ + body: { messages: [] }, signal: SIGNAL, + }) + expect(defaultFields.fields).toHaveProperty('dsh_plugin_packages') + + const disabledHarness = await harness(false) + const disabledFields = await disabledHarness.ctx.deepseekLlmApiExtensions.prepare({ + body: { messages: [] }, signal: SIGNAL, + }) + expect(disabledFields.fields).not.toHaveProperty('dsh_plugin_packages') + }) + + it('reports active package versions once, retains parallel versions, and excludes inactive or loose entries', async () => { + const { ctx, root } = await harness() + const oneA = await packagePlugin(root, 'one-a', { name: 'one', version: '1.0.0' }) + const oneB = await packagePlugin(root, 'one-b', { name: 'one', version: '2.0.0' }) + const disabled = await packagePlugin(root, 'disabled', { name: 'disabled', version: '1.0.0' }) + await mkdir(join(root, 'loose'), { recursive: true }) + await writeFile(join(root, 'loose/plugin.mjs'), 'export default () => {}\n') + + await ctx.loader.create({ name: oneA }) + await ctx.loader.create({ name: oneA }) + await ctx.loader.create({ name: oneB }) + await ctx.loader.create({ name: disabled, disabled: true }) + await ctx.loader.create({ name: './loose/plugin.mjs' }) + + const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL }) + expect(prepared.fields.dsh_plugin_packages).toEqual({ + version: 1, + packages: [ + { name: 'one', version: '1.0.0' }, + { name: 'one', version: '2.0.0' }, + ], + }) + }) + + it('fails request preparation for an active package with malformed identity metadata', async () => { + const { ctx, root } = await harness() + const bad = await packagePlugin(root, 'bad', { name: 'bad' }) + await ctx.loader.create({ name: bad }) + await expect(ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })) + .rejects.toThrow(/must declare non-empty name and version/) + }) + + it('omits a loose ESM module whose nearest manifest only marks the module type', async () => { + const { ctx, root } = await harness() + const marker = await packagePlugin(root, 'marker-only', {}) + await ctx.loader.create({ name: marker }) + await expect(ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })) + .resolves.toMatchObject({ fields: { dsh_plugin_packages: { version: 1, packages: [] } } }) + }) + + it('uses the host inventory when a request has no matching or joined live agent', async () => { + const { ctx, root } = await harness() + const plugin = await packagePlugin(root, 'host-only', { name: 'host-only', version: '3.0.0' }) + await ctx.loader.create({ name: plugin }) + const missing = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL, sessionId: 'missing' }) + expect(missing.fields.dsh_plugin_packages?.packages).toEqual([{ name: 'host-only', version: '3.0.0' }]) + + const id = SessionId('bare-agent') + const agentScope = createScope(ctx, {}) + ctx.agents.register({ id, ctx: agentScope.ctx, session: { id } } as unknown as Agent) + const bare = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL, sessionId: id }) + expect(bare.fields.dsh_plugin_packages?.packages).toEqual([{ name: 'host-only', version: '3.0.0' }]) + }) + + it('resolves scoped and unscoped bare subpaths, absolute/file modules, and skips URL or Cordis modules', async () => { + const { ctx, root } = await harness() + await packagePlugin(root, 'node_modules/plain-package', { name: 'plain-package', version: '1.0.0' }) + await packagePlugin(root, 'node_modules/@scope/scoped-package', { name: '@scope/scoped-package', version: '2.0.0' }) + await packagePlugin(root, 'absolute-package', { name: 'absolute-package', version: '3.0.0' }) + const absolute = join(root, 'absolute-package/plugin.mjs') + const internal = ctx.loader.internal + ctx.loader.internal = { + version: 'v2', + import: async (specifier: string, ...args: unknown[]) => { + if (specifier === 'https://plugins.example/test.mjs') return { default: () => {} } + // Node ESM on Windows requires a file URL; retain the raw Loader name for package attribution. + const portableSpecifier = specifier === absolute ? pathToFileURL(specifier).href : specifier + return await (internal as never as { import(specifier: string, ...args: unknown[]): Promise }) + .import(portableSpecifier, ...args) + }, + } as unknown as NonNullable + + await ctx.loader.create({ name: 'plain-package/plugin.mjs' }) + await ctx.loader.create({ name: '@scope/scoped-package/plugin.mjs' }) + await ctx.loader.create({ name: absolute }) + await ctx.loader.create({ name: pathToFileURL(absolute).href }) + ctx.loader.builtins.noop = () => {} + await ctx.loader.create({ name: 'cordis:noop' }) + await ctx.loader.create({ name: 'https://plugins.example/test.mjs' }) + + const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL }) + expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([ + { name: '@scope/scoped-package', version: '2.0.0' }, + { name: 'absolute-package', version: '3.0.0' }, + { name: 'plain-package', version: '1.0.0' }, + ]) + }) + + it('fails when a Loader-resolved bare entry has no package manifest', async () => { + const { ctx } = await harness() + ctx.loader.internal = { + version: 'v2', + import: async () => ({ default: () => {} }), + } as unknown as NonNullable + await ctx.loader.create({ name: 'missing-package' }) + await expect(ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })) + .rejects.toThrow(/cannot resolve active package/) + }) + + it('supports a direct embedding whose context has no base URL', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + await ctx.plugin(AgentRegistry) + await ctx.plugin(DeepSeekLlmApiExtensionRegistry) + await ctx.plugin(PluginInventory) + const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL }) + expect(prepared.fields.dsh_plugin_packages).toEqual({ version: 1, packages: [] }) + }) + + it('uses each ordinary Loader tree base for conflicting bare package versions', async () => { + const { ctx, root } = await harness() + await packagePlugin(root, 'node_modules/versioned-plugin', { + name: 'versioned-plugin', version: '1.0.0', + }) + const nestedRoot = join(root, 'nested') + await packagePlugin(nestedRoot, 'node_modules/versioned-plugin', { + name: 'versioned-plugin', version: '2.0.0', + }) + const composition = join(nestedRoot, 'cordis.yml') + await writeFile(composition, '- id: nested\n name: versioned-plugin/plugin.mjs\n') + + await ctx.loader.create({ name: 'versioned-plugin/plugin.mjs' }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(composition).href } }) + + const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL }) + expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([ + { name: 'versioned-plugin', version: '1.0.0' }, + { name: 'versioned-plugin', version: '2.0.0' }, + ]) + }) + + it('mirrors the standing preset bare-package override instead of its local node_modules', async () => { + const { ctx, root } = await harness() + await packagePlugin(root, 'node_modules/preset-only', { name: 'preset-only', version: '4.0.0' }) + const presetDir = join(root, 'preset') + await mkdir(presetDir, { recursive: true }) + await packagePlugin(presetDir, 'node_modules/preset-only', { name: 'preset-only', version: '9.0.0' }) + const composition = join(presetDir, 'agent.cordis.yml') + await writeFile(composition, '- id: preset-only\n name: preset-only/plugin.mjs\n') + + const standingKey = {} + const standing = createScope(ctx, standingKey) + await mountPreset(standing.ctx, { id: 'fixture', trust: 'user', path: composition }) + const agentKey = {} + const agentScope = createScope(ctx, agentKey, { parent: standingKey }) + const id = SessionId('preset-agent') + const agent = { id, ctx: agentScope.ctx, session: { id } } as unknown as Agent + ctx.agents.register(agent) + + const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL, sessionId: id }) + expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([{ name: 'preset-only', version: '4.0.0' }]) + }) + + it('withdraws the inventory field when the contributing plugin reloads', async () => { + const { ctx, disposeInventory } = await harness() + expect((await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })).fields) + .toHaveProperty('dsh_plugin_packages') + await disposeInventory() + expect((await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })).fields) + .not.toHaveProperty('dsh_plugin_packages') + }) +}) diff --git a/packages/llm/plugin-package-inventory-deepseek/tsconfig.json b/packages/llm/plugin-package-inventory-deepseek/tsconfig.json new file mode 100644 index 0000000000..4edcfd192d --- /dev/null +++ b/packages/llm/plugin-package-inventory-deepseek/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../preset/agent-presets" + }, + { + "path": "../deepseek-llm-api-extensions" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 3aef452ba0..57a5c6f341 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -117,6 +117,8 @@ export interface PresetMount { readonly presetId: string /** The mounted subtree's fiber. */ readonly fiber: Fiber + /** Loader entry tree whose active rows form this standing composition. */ + readonly tree: EntryTree /** The standing scope key agents are parented to (undefined only in torn-down records). */ readonly key: ScopeKey | undefined } @@ -365,7 +367,7 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi + 'a preset service must sit behind an `isolate` realm or move to the host composition', ) } - mounts.add({ presetId: preset.id, fiber, key: scopeOf(agentCtx) }) + mounts.add({ presetId: preset.id, fiber, tree, key: scopeOf(agentCtx) }) } catch (error) { try { await handle.dispose() diff --git a/packages/test-support/llm-replay/README.i18n.yaml b/packages/test-support/llm-replay/README.i18n.yaml index 8346fe9607..3f61eb41f1 100644 --- a/packages/test-support/llm-replay/README.i18n.yaml +++ b/packages/test-support/llm-replay/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-replay/README.md -README.md: 0eb36841f2ec18280de0803a0d7da5324c3b331b -README.zh.md: 9cef329e158f8ce08c06ca1f9502cc60fef855fa +README.md: e12d359950e1caf6e31b9d25035c50bb83c73747 +README.zh.md: 83964fed15c6a985df8a92327748918c1616a83e diff --git a/packages/test-support/llm-replay/README.md b/packages/test-support/llm-replay/README.md index 0eb36841f2..e12d359950 100644 --- a/packages/test-support/llm-replay/README.md +++ b/packages/test-support/llm-replay/README.md @@ -12,7 +12,9 @@ The fixture is a projection of a persisted session log (`/session.json Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` and `compaction/summary` events plus the line-0 session header. -Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. +When replay serves `deepseek-official` in a composition carrying `ctx.deepseekLlmApiExtensions`, it prepares and accepts those fields after selecting a valid script entry and before yielding its first chunk. This mirrors the live adapter's post-2xx commit point, so durable acceptance watermarks and SDK event notifications remain identical between recording and replay. Replay supplies a synthetic `{ messages: [] }` base body: it proves acceptance side effects, not prepared field bytes. An extension whose acceptance action depends on the serialized provider body needs dedicated fixture support before this replay path can represent it. Other provider routes and compositions without the optional registry are unchanged. + +Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. A `throw` entry accepts DeepSeek request extensions when it has prefix chunks; a zero-chunk throw defaults to pre-2xx non-acceptance and can set `accepted: true` for a post-2xx failure without chunks. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. A scripted string may embed `{{fromRequest:}}` to fill a value no static sidecar can know — for example a randomly minted goal id the model must echo back into `update_goal`. At stream time every placeholder resolves against the live request: the corpus is every string leaf of the request messages joined by newlines, the pattern's LAST corpus match wins, and its first capture group (or the whole match without one) substitutes in place. A pattern that matches nothing, an invalid pattern, and an unterminated placeholder each fail loud. The last two braces of a consecutive `}` run terminate the placeholder, so a pattern may end with a brace quantifier (`[0-9a-f]{4}`) but cannot contain `}}` followed by further pattern content. Resolution applies to every scripted entry, including ones derived from the recorded JSONL — a recorded fixture whose text legitimately contains the literal marker must be expressed through a sidecar without it. diff --git a/packages/test-support/llm-replay/README.zh.md b/packages/test-support/llm-replay/README.zh.md index 9cef329e15..83964fed15 100644 --- a/packages/test-support/llm-replay/README.zh.md +++ b/packages/test-support/llm-replay/README.zh.md @@ -12,7 +12,9 @@ fixture 是持久化会话日志(`/session.jsonl`)的投影:它 因此,录制就是「运行一次真实 agent 并收集 `.jsonl`」,由快照 harness 完成;该插件本身不录制。fixture 的 `request/header` 内容可能被标记化为 `{{system}}`/`{{tools}}`(harness 会在一个场景中固定该内容,并清除其余场景中的内容);回放不受影响,因为派生过程只读取 `assistant/chunk` 和 `compaction/summary` 事件以及第 0 行的会话 header。 -有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401,此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。 +当回放在带有 `ctx.deepseekLlmApiExtensions` 的组合中提供 `deepseek-official` 时,它会在选中有效脚本条目后、产出第一个分片前准备并接受这些字段。这会复现实时适配器的 2xx 后提交点,使持久接受水位与 SDK 事件通知在录制和回放之间保持一致。回放会提供合成的 `{ messages: [] }` 基础正文:它证明的是接受副作用,而不是已准备字段的字节内容。如果某个扩展的接受操作依赖序列化后的提供方正文,该扩展需要专用 fixture 支持,此回放路径才能表示它。其他提供方路由以及未挂载该可选注册表的组合不受影响。 + +有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401,此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。带前缀分片的 `throw` 条目会接受 DeepSeek 请求扩展;零分片 `throw` 默认为 2xx 前不接受,无分片的 2xx 后失败可显式设置 `accepted: true`。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。 脚本字符串可以内嵌 `{{fromRequest:}}`,用来填入静态伴随文件不可能预知的值——例如模型必须原样回填到 `update_goal` 的随机生成 goal id。回放时每个占位符针对实时请求解析:语料是请求消息的所有字符串叶子按换行拼接的结果,取该模式在语料中的最后一次匹配,用其第一个捕获组(无捕获组时用整个匹配)原位替换。模式匹配不到内容、模式非法、占位符未闭合都会明确报错。连续右花括号串的最后两个花括号才是占位符结束符,因此模式可以以花括号量词收尾(如 `[0-9a-f]{4}`),但不能在 `}}` 之后还有后续模式内容。解析作用于所有脚本条目,包括从已记录 JSONL 派生的条目——若录制文本本身合法地含有该字面量标记,需改用不含标记的伴随文件表达。 diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index 7618322e57..315a3b456b 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-support/llm-replay/package.json @@ -33,13 +33,20 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-compaction": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-deepseek-llm-api-extensions": { + "optional": true + } + }, "devDependencies": { "@deepseek-ai/dsh-compaction": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/test-support/llm-replay/src/index.ts b/packages/test-support/llm-replay/src/index.ts index 3aed17e438..779963fa65 100644 --- a/packages/test-support/llm-replay/src/index.ts +++ b/packages/test-support/llm-replay/src/index.ts @@ -11,6 +11,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { delimiter as pathDelimiter } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-compaction' +import type {} from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import { decodeStorageRecord, type SessionEvent } from '@deepseek-ai/dsh-session' import type { ContentBlock, @@ -36,7 +37,7 @@ const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool */ export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } - | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string } + | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string; accepted?: boolean } | { kind: 'hang' /** Optional marker written after the prefix chunks are consumed and before the stream waits for cancellation. */ @@ -448,7 +449,11 @@ function readReplayEntry(value: unknown, file: string, location: string): Replay return { kind: 'chunks', chunks: readChunks(value['chunks'], file, location) } } case 'throw': { - if (!hasExactKeys(value, ['kind', 'chunks', 'message', 'code'])) { + const accepted = value['accepted'] + const keys = accepted === undefined + ? ['kind', 'chunks', 'message', 'code'] + : ['kind', 'chunks', 'message', 'code', 'accepted'] + if (!hasExactKeys(value, keys)) { invalidOverride(file, location, 'has invalid throw-entry fields') } if (typeof value['message'] !== 'string' || value['message'].length === 0) { @@ -457,11 +462,15 @@ function readReplayEntry(value: unknown, file: string, location: string): Replay if (typeof value['code'] !== 'string' || value['code'].length === 0) { invalidOverride(file, location, 'code must be a non-empty string') } + if (accepted !== undefined && typeof accepted !== 'boolean') { + invalidOverride(file, location, 'accepted must be a boolean') + } return { kind: 'throw', chunks: readChunks(value['chunks'], file, location), message: value['message'], code: value['code'], + ...(accepted === undefined ? {} : { accepted }), } } case 'hang': { @@ -716,6 +725,20 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined, } } +/** Whether the scripted provider call reached the live adapter's post-2xx commit point. */ +function providerAccepted(entry: ReplayEntry): boolean { + switch (entry.kind) { + case 'chunks': + case 'hang': + return true + case 'throw': + return entry.accepted ?? entry.chunks.length > 0 + /* v8 ignore next -- override parsing and derived entries close the local union before replay. */ + default: + return assertNever(entry, 'llm-replay acceptance entry') + } +} + /** * Install per-session positional replay. A newly seen live session takes the * next ordered recorded script, then advances its own cursor synchronously at @@ -775,7 +798,22 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): ReplayHand + `but its script has only ${boundState.entries.length}; re-record the scenario`, ) } - yield* replayEntry(resolveScriptedEntry(entry, options.messages), options.signal, paceMs) + const resolved = resolveScriptedEntry(entry, options.messages) + if (options.provider === 'deepseek-official' && providerAccepted(resolved)) { + const extensions = ctx.get('deepseekLlmApiExtensions') + if (extensions !== undefined) { + const signal = options.signal ?? new AbortController().signal + const prepared = await extensions.prepare({ + // Replay reproduces post-2xx side effects, not the provider wire body. + body: { messages: [] }, + signal, + ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, + ...options.purpose === undefined ? {} : { purpose: options.purpose }, + }) + await prepared.accept() + } + } + yield* replayEntry(resolved, options.signal, paceMs) })() } const providers = config.providers ?? [] diff --git a/packages/test-support/llm-replay/tests/llm-replay.spec.ts b/packages/test-support/llm-replay/tests/llm-replay.spec.ts index 16244c5ce5..bffa976dc7 100644 --- a/packages/test-support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/test-support/llm-replay/tests/llm-replay.spec.ts @@ -1,10 +1,11 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { CompactionId } from '@deepseek-ai/dsh-compaction' +import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import LlmRuntime, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type Config, @@ -22,6 +23,12 @@ import { resolveScriptedEntry, } from '../src/index.ts' +declare module '@deepseek-ai/dsh-deepseek-llm-api-extensions/types' { + interface DeepSeekLlmApiExtensionMap { + test_replay: { readonly version: 1 } + } +} + /** * Unit tests for the replay llm/stream plugin. These drive the listener through * the REAL LlmRuntime waterfall (not a hand-rolled stub) so they verify the @@ -702,6 +709,90 @@ describe('installLlmReplay (through the real LlmRuntime)', () => { expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(second) }) + it('settles official DeepSeek request extensions before replayed chunks', async () => { + writeLog(TEXT_CHUNKS, TEXT_CHUNKS, TEXT_CHUNKS) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeekLlmApiExtensionRegistry) + const accepted = vi.fn() + ctx.deepseekLlmApiExtensions.register('test_replay', { + prepare: () => ({ value: { version: 1 }, accept: accepted }), + }) + installLlmReplay(ctx, { file }) + + const sessionId = 'deepseek-replay' as NonNullable + await drain(ctx.llm.stream({ provider: 'deepseek-official', model: 'm', messages: [], sessionId })) + expect(accepted).toHaveBeenCalledOnce() + await drain(ctx.llm.stream({ + provider: 'deepseek-official', + model: 'm', + messages: [], + sessionId, + signal: new AbortController().signal, + purpose: 'compaction', + })) + expect(accepted).toHaveBeenCalledTimes(2) + await drain(ctx.llm.stream({ provider: 'another-provider', model: 'm', messages: [], sessionId })) + expect(accepted).toHaveBeenCalledTimes(2) + }) + + it('accepts anonymous official extensions and tolerates an absent optional registry', async () => { + writeLog(TEXT_CHUNKS) + const withRegistry = new Context() + await withRegistry.plugin(LlmRuntime) + await withRegistry.plugin(DeepSeekLlmApiExtensionRegistry) + const accepted = vi.fn() + withRegistry.deepseekLlmApiExtensions.register('test_replay', { + prepare: () => ({ value: { version: 1 }, accept: accepted }), + }) + installLlmReplay(withRegistry, { file }) + await drain(withRegistry.llm.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) + expect(accepted).toHaveBeenCalledOnce() + + const withoutRegistry = new Context() + await withoutRegistry.plugin(LlmRuntime) + installLlmReplay(withoutRegistry, { file }) + await expect(drain(withoutRegistry.llm.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) + .resolves.toEqual(TEXT_CHUNKS) + }) + + it('accepts only throw entries that reached the post-2xx point', async () => { + writeFileSync(file, sessionJsonl([]), 'utf8') + const overrideFile = join(dir, 'replay.override.json') + writeFileSync(overrideFile, JSON.stringify([ + { kind: 'throw', chunks: [{ type: 'block-start', index: 0, blockType: 'text' }], message: 'partial', code: 'STREAM_CLOSED' }, + { kind: 'throw', chunks: [], message: 'unauthorized', code: 'AUTH' }, + { kind: 'throw', chunks: [], message: 'empty body', code: 'EMPTY_RESPONSE', accepted: true }, + ]), 'utf8') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeekLlmApiExtensionRegistry) + const accepted = vi.fn() + ctx.deepseekLlmApiExtensions.register('test_replay', { + prepare: () => ({ value: { version: 1 }, accept: accepted }), + }) + installLlmReplay(ctx, { file, overrideFile }) + const request = { provider: 'deepseek-official', model: 'm', messages: [] } + + await expect(drain(ctx.llm.stream(request))).rejects.toThrow('partial') + expect(accepted).toHaveBeenCalledOnce() + await expect(drain(ctx.llm.stream(request))).rejects.toThrow('unauthorized') + expect(accepted).toHaveBeenCalledOnce() + await expect(drain(ctx.llm.stream(request))).rejects.toThrow('empty body') + expect(accepted).toHaveBeenCalledTimes(2) + }) + + it('rejects a non-boolean throw acceptance override', async () => { + writeFileSync(file, sessionJsonl([]), 'utf8') + const overrideFile = join(dir, 'replay.override.json') + writeFileSync(overrideFile, JSON.stringify([ + { kind: 'throw', chunks: [], message: 'bad', code: 'X', accepted: 'yes' }, + ]), 'utf8') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + expect(() => { installLlmReplay(ctx, { file, overrideFile }) }).toThrow(/accepted must be a boolean/) + }) + it('replays a sidecar throw-entry as an LlmError with its stable code, after its prefix chunks', async () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') diff --git a/packages/test-support/llm-replay/tsconfig.json b/packages/test-support/llm-replay/tsconfig.json index 6683cbc73b..5c84dff52e 100644 --- a/packages/test-support/llm-replay/tsconfig.json +++ b/packages/test-support/llm-replay/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/deepseek-llm-api-extensions" + }, { "path": "../../core/session" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1888628dbc..b73186037d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -490,6 +490,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:* version: link:../packages/credentials/credentials-local + '@deepseek-ai/dsh-deepseek-llm-api-extensions': + specifier: workspace:* + version: link:../packages/llm/deepseek-llm-api-extensions '@deepseek-ai/dsh-e2b': specifier: workspace:* version: link:../packages/e2b/e2b @@ -556,6 +559,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:* version: link:../packages/plan/plan-mode + '@deepseek-ai/dsh-plugin-package-inventory-deepseek': + specifier: workspace:* + version: link:../packages/llm/plugin-package-inventory-deepseek '@deepseek-ai/dsh-pwsh-local': specifier: workspace:* version: link:../packages/shell/pwsh-local @@ -1054,6 +1060,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-deepseek-llm-api-extensions': + specifier: workspace:^ + version: link:../../llm/deepseek-llm-api-extensions '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local @@ -1090,6 +1099,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode + '@deepseek-ai/dsh-plugin-package-inventory-deepseek': + specifier: workspace:^ + version: link:../../llm/plugin-package-inventory-deepseek '@deepseek-ai/dsh-pwsh-sandbox': specifier: workspace:^ version: link:../../shell/pwsh-sandbox @@ -5562,6 +5574,15 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/llm/deepseek-llm-api-extensions: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/llm/llm: dependencies: '@deepseek-ai/dsh-util-crypto': @@ -5599,6 +5620,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-anonymous-user-id': specifier: workspace:^ version: link:../../identity/anonymous-user-id @@ -5614,6 +5638,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-deepseek-llm-api-extensions': + specifier: workspace:^ + version: link:../deepseek-llm-api-extensions '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths @@ -5626,6 +5653,12 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm + '@deepseek-ai/dsh-plugin-package-inventory-deepseek': + specifier: workspace:^ + version: link:../plugin-package-inventory-deepseek + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -5731,6 +5764,40 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/llm/plugin-package-inventory-deepseek: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets + '@deepseek-ai/dsh-deepseek-llm-api-extensions': + specifier: workspace:^ + version: link:../deepseek-llm-api-extensions + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + packages/llm/token-meter: dependencies: '@deepseek-ai/schemastery': @@ -8228,6 +8295,9 @@ importers: '@deepseek-ai/dsh-compaction': specifier: workspace:^ version: link:../../compaction/compaction + '@deepseek-ai/dsh-deepseek-llm-api-extensions': + specifier: workspace:^ + version: link:../../llm/deepseek-llm-api-extensions '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -8897,6 +8967,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../packages/credentials/credentials + '@deepseek-ai/dsh-deepseek-llm-api-extensions': + specifier: workspace:^ + version: link:../../packages/llm/deepseek-llm-api-extensions '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../packages/fs/fs @@ -8966,6 +9039,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-plugin-package-inventory-deepseek': + specifier: workspace:^ + version: link:../../packages/llm/plugin-package-inventory-deepseek '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/shell/pwsh-local diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index abf3e11a78..32ae856c0d 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -48,6 +48,8 @@ "@deepseek-ai/dsh-sdk-jsonrpc-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index 4b9caf846a..b14746317c 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -17,6 +17,12 @@ # credential seam and, with no provider mounted here, from the launching # environment; DEEPSEEK_BASE_URL follows the same environment ladder. Neither # is inlined, so this file names no secret and no route. +- id: deepseek-llm-api-extensions + name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' + +- id: plugin-package-inventory-deepseek + name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' + - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f94c7735e1..7acacdd626 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -71,6 +71,7 @@ export const SERVICE_PAGE: Record = { authorization: 'credentials.md', credentials: 'credentials.md', directoryPicker: 'workspace.md', + deepseekLlmApiExtensions: 'llm-streaming.md', dynamicCordisRunner: 'extensions.md', e2b: 'subprocess.md', fileReferences: 'session-reference.md', @@ -240,6 +241,9 @@ export const LINK_MAP: Readonly> = { SettleReason: 'core.md', AdapterRegistrationHandle: 'llm-streaming.md', DirectoryRegistrationHandle: 'llm-streaming.md', + DeepSeekLlmApiExtensionMap: 'llm-streaming.md', + DeepSeekLlmApiExtensionProvider: 'llm-streaming.md', + DeepSeekLlmApiExtensionRequest: 'llm-streaming.md', LlmCallConfig: 'llm-streaming.md', LlmModelContext: 'llm-streaming.md', LlmModelReasoningInfo: 'llm-streaming.md', @@ -343,6 +347,7 @@ export const LINK_MAP: Readonly> = { LspQueryResult: 'lsp.md', LlmAdapter: 'llm-streaming.md', PreparedLlmCall: 'llm-streaming.md', + PreparedDeepSeekLlmApiExtensions: 'llm-streaming.md', LlmRuntime: 'llm-streaming.md', StreamChunk: 'llm-streaming.md', SkillProviderControl: 'skills.md', @@ -538,6 +543,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'AsyncIterable', 'Context', 'Error', + 'EntryTree', 'Exclude', 'Map', 'NonNullable', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5e19c20113..8bdd2aad1c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -115,6 +115,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'compaction-basic'], note: 'Adapters register provider implementations; the loop and compaction call the provider-neutral stream service.', }, + { + key: 'deepseekLlmApiExtensions', + pkg: 'deepseek-llm-api-extensions', + title: 'Official DeepSeek request extensions', + mode: 'seam', + implementations: ['plugin-package-inventory-deepseek'], + consumers: ['llm-deepseek'], + note: 'Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance.', + }, { key: 'tokenMeter', pkg: 'token-meter', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d1000ab162..366d2e1a7e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -55,6 +55,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' }, 'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' }, 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, + 'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' }, 'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, 'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 8712442c46..f61866e32c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -75,6 +75,7 @@ "@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"], "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"], "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"], + "@deepseek-ai/dsh-deepseek-llm-api-extensions/types": ["./packages/llm/deepseek-llm-api-extensions/src/types.ts"], "@deepseek-ai/dsh-llm-retry/types": ["./packages/llm/llm-retry/src/types.ts"], "@deepseek-ai/dsh-workflow/types": ["./packages/workflow/workflow/src/types.ts"], "@deepseek-ai/dsh-tool-workflow/types": ["./packages/workflow/tool-workflow/src/types.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 7b82fa0156..3587446662 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -135,6 +135,7 @@ { "path": "./packages/attachment/attachment" }, { "path": "./packages/attachment/attachment-local" }, { "path": "./packages/llm/llm" }, + { "path": "./packages/llm/deepseek-llm-api-extensions" }, { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, @@ -305,6 +306,7 @@ { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/frontend-static" }, { "path": "./packages/host/plugin-inventory" }, + { "path": "./packages/llm/plugin-package-inventory-deepseek" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/client" }, { "path": "./packages/sdk/protocol" }, diff --git a/website/docs.ts b/website/docs.ts index 15217647b4..5e6226ccf0 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -328,6 +328,8 @@ const subsystemsReference = subsystemGroups.flatMap(([rootSection, enSection, fi )) const reference = [ + // `docs/deepseek-llm-api-wire-extensions.md` is a repository-only provider protocol reference. + // Projected links intentionally resolve to its GitHub source instead of a public site route. ...pairedPages(([ ['docs/architecture.md', 'reference/index.md', '架构', 'Architecture', 0], ] as const).map(([source, route, rootLabel, enLabel, order]): PairedPage => ({