From c50b8990158cad1eb38bd20cb4d2870d4741acb8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:45:33 +0800 Subject: [PATCH] refactor(llm-deepseek): replace hand-rolled SSE parser with eventsource-parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the approved simplification Agent Note: sse.ts now pipes the response body through TextDecoderStream and EventSourceParserStream (eventsource-parser/stream) and keeps only the DeepSeek protocol shim — yield each event's data, terminate on [DONE], throw LlmError('STREAM_CLOSED') on EOF without the sentinel. The SSE spec-conformance tests are deleted; sse.spec.ts pins only the [DONE]/STREAM_CLOSED/EOF contract, including the new spec-strict verdict that an unterminated trailing event is truncation (the old parser flushed it — a robustness nicety no real provider shape needs). eventsource-parser@^3.1.0 becomes llm-deepseek's second runtime dependency (already in the lockfile transitively via the MCP SDK). Docs: the Agent Note moves proposed/ → implemented/ and is rewritten per the lifecycle contract; the rejected NIH roll-up note's inbound links follow. The twin-adapters note, dsh-llm LlmAdapter JSDoc (and its type-equiv fences), cookbook, group/package READMEs, root AGENTS.md layout line, sdk-helper comments, and the regenerated config catalog drop the "hand-rolled fetch + SSE" claim in both languages; all eight touched pairs re-recorded. --- .../2026-06-13-twin-llm-adapters.i18n.yaml | 4 +- .../2026-06-13-twin-llm-adapters.md | 6 +- .../2026-06-13-twin-llm-adapters.zh.md | 6 +- ...ntsource-parser-for-deepseek-sse.i18n.yaml | 4 +- ...-26-eventsource-parser-for-deepseek-sse.md | 28 ++++++ ...-eventsource-parser-for-deepseek-sse.zh.md | 28 ++++++ ...-26-eventsource-parser-for-deepseek-sse.md | 33 ------- ...-eventsource-parser-for-deepseek-sse.zh.md | 33 ------- ...ency-swaps-rejected-by-nih-audit.i18n.yaml | 4 +- ...-dependency-swaps-rejected-by-nih-audit.md | 2 +- ...pendency-swaps-rejected-by-nih-audit.zh.md | 2 +- AGENTS.md | 2 +- docs/config-catalog.md | 2 +- docs/cookbook/adding-an-llm-adapter.i18n.yaml | 4 +- docs/cookbook/adding-an-llm-adapter.md | 2 +- docs/cookbook/adding-an-llm-adapter.zh.md | 2 +- .../llm-streaming.i18n.yaml | 4 +- docs/core-data-structures/llm-streaming.md | 6 +- docs/core-data-structures/llm-streaming.zh.md | 6 +- packages/llm/README.i18n.yaml | 4 +- packages/llm/README.md | 2 +- packages/llm/README.zh.md | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/package.json | 1 + packages/llm/llm-deepseek/src/adapter.ts | 2 +- packages/llm/llm-deepseek/src/sse.ts | 60 +++--------- .../llm/llm-deepseek/tests/adapter.e2e.ts | 2 +- packages/llm/llm-deepseek/tests/sse.spec.ts | 96 +++++-------------- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/index.ts | 4 +- .../helper/src/features/builtin/provider.ts | 4 +- pnpm-lock.yaml | 3 + 36 files changed, 143 insertions(+), 231 deletions(-) rename .agents/notes/{proposed => implemented}/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml (60%) create mode 100644 .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md create mode 100644 .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md delete mode 100644 .agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md delete mode 100644 .agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml index f1d2fe1a90..dc88be13a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.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 -2026-06-13-twin-llm-adapters.md: 5c3308b281ce71407002e95dd6e794da2a421fa8 -2026-06-13-twin-llm-adapters.zh.md: 93b084973bccaeb802508e4e939a259c281f2608 +2026-06-13-twin-llm-adapters.md: b922891d4438553fd96a7f4f4226f378e66e8ad2 +2026-06-13-twin-llm-adapters.zh.md: d98b57a0a2e7c92453046022e8cb50aa52994c0f diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md index 5c3308b281..b922891d44 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -12,10 +12,10 @@ English | [中文](2026-06-13-twin-llm-adapters.zh.md) Ship **two** adapters against the one contract from the start, deliberately built on different internals: -- `dsh-llm-deepseek` — hand-rolled `fetch` + SSE parsing against the DeepSeek API. +- `dsh-llm-deepseek` — direct `fetch` + in-repo translation against the DeepSeek API; SSE framing is delegated to `eventsource-parser` ([the SSE-parser swap](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md)). The twin identity is owning the fetch/translate internals rather than delegating to a full provider SDK, not hand-rolling transport plumbing. - `dsh-llm-pi-ai` — the same endpoint through the `@earendil-works/pi-ai` library (its own event vocabulary). -The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single hand-rolled adapter would have hidden. +The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single direct-fetch adapter would have hidden. ## Alternatives considered @@ -24,4 +24,4 @@ The rule they enforce: **anything the StreamChunk vocabulary cannot express for ## Consequences -The twin doubles adapter and key-gated e2e maintenance—both cover V4 Flash and Pro across representative reasoning modes—in exchange for continuous seam-neutrality validation and a second implementation example. Both use `apiKey`, `baseURL`, and `models`; the hand-rolled adapter exposes `thinking`/`reasoningEffort`, while pi-ai exposes one `reasoning` level. A future conformance suite could justify retiring one adapter through a superseding Agent Note. +The twin doubles adapter and key-gated e2e maintenance—both cover V4 Flash and Pro across representative reasoning modes—in exchange for continuous seam-neutrality validation and a second implementation example. Both use `apiKey`, `baseURL`, and `models`; the direct-fetch adapter exposes `thinking`/`reasoningEffort`, while pi-ai exposes one `reasoning` level. A future conformance suite could justify retiring one adapter through a superseding Agent Note. diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md index 93b084973b..d98b57a0a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md @@ -12,10 +12,10 @@ Status: implemented 从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现构建: -- `dsh-llm-deepseek`:手写 `fetch` + SSE(Server-Sent Events)解析,直接对接 DeepSeek API。 +- `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([SSE 解析器替换](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。 - `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库访问同一端点(该库有自己的事件词汇)。 -二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生体确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一手写适配器会将其隐藏。 +二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生体确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一直接 fetch 适配器会将其隐藏。 ## 曾考虑的替代方案 @@ -24,4 +24,4 @@ Status: implemented ## 后果 -孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;手写适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 Agent Note 论证退役其中一个适配器。 +孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;直接 fetch 适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 Agent Note 论证退役其中一个适配器。 diff --git a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml similarity index 60% rename from .agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml rename to .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml index c486815180..860af008f4 100644 --- a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.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 -2026-07-26-eventsource-parser-for-deepseek-sse.md: 8a93b7f6c7aa0d428f25e87c44e1d29e884ecc81 -2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: b16109d9458f487c7e463cf02e6b2d22fbbde015 +2026-07-26-eventsource-parser-for-deepseek-sse.md: e7835bc738b3dec5aefd6011848525f6604e852e +2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: 933b993d479026d8f2bd2dc3173abd9e60823806 diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md new file mode 100644 index 0000000000..e7835bc738 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md @@ -0,0 +1,28 @@ +# Agent Note: Replace the hand-rolled SSE parser in llm-deepseek with eventsource-parser + +Status: implemented + +English | [中文](2026-07-26-eventsource-parser-for-deepseek-sse.zh.md) + +## Problem + +`packages/llm/llm-deepseek/src/sse.ts` hand-implemented Server-Sent Events parsing: a streaming `TextDecoder`, event-block splitting on `\r?\n\r?\n`, `data:` payload extraction and joining, comment/field skipping, the `[DONE]` sentinel, a `STREAM_CLOSED` error on EOF without it, and a flush of a final unterminated event block. The file was ~67 lines with ~108 lines of dedicated tests (`tests/sse.spec.ts`) re-proving SSE spec behavior — UTF-8 split across chunks, CRLF handling, multi-`data:` joining, no-space-after-colon — that a maintained parser already guarantees. Its only consumer is `adapter.ts` (`yield* translate(parseSse(response.body))`). + +This is exactly the surface `eventsource-parser` owns: the de-facto standard SSE parser (it underlies the Vercel AI SDK and the MCP SDK), zero-dependency, actively maintained, and already present in this repo's lockfile transitively via `@modelcontextprotocol/sdk` — so adopting it directly adds no new supply-chain surface in practice. + +## Decision + +`sse.ts` delegates SSE framing to `EventSourceParserStream` from `eventsource-parser/stream`: `parseSse` pipes the response body through `new TextDecoderStream()` then `new EventSourceParserStream()` and keeps only the DeepSeek protocol shim — yield each event's `data`, terminate on `[DONE]`, and throw `LlmError('STREAM_CLOSED')` when the stream ends without the sentinel. All required builtins (`TextDecoderStream`, `pipeThrough`, async-iterable `ReadableStream`) exist at the Node ^22.19 engine floor. The spec-conformance tests are gone; `tests/sse.spec.ts` pins only the `[DONE]`/`STREAM_CLOSED`/EOF contract. `eventsource-parser` is `llm-deepseek`'s second runtime dependency after schemastery. The [twin-adapters note](../architecture/2026-06-13-twin-llm-adapters.md) and the `dsh-llm` JSDoc that branded this adapter "hand-rolled fetch + SSE parsing" now describe it as direct fetch with library-framed SSE. + +The library also strips a leading BOM (the hand-rolled parser would fail to match `data:` after one) and offers `maxBufferSize` hardening the hand-rolled parser lacked. + +## Alternatives considered + +- **Keep the hand-rolled parser.** Defensible under the [twin-adapters decision](../architecture/2026-06-13-twin-llm-adapters.md): the adapter is deliberately the hand-rolled design-verification twin of the pi-ai adapter. But that note's load-bearing distinction is owning the fetch/translate internals versus delegating to a full provider SDK; a ~700-byte SSE micro-parser is transport plumbing, not the design under verification. The twin-adapters note now states that reading explicitly. +- **`createParser({onEvent})` callback API instead of the stream.** Works fed by a manual `TextDecoder` loop, but the `pipeThrough` composition deletes more of the hand-rolled code. + +## Consequences + +- The remaining shim only encodes the DeepSeek `[DONE]`/`STREAM_CLOSED` protocol; SSE framing edge cases are eventsource-parser's contract and are no longer re-proven here. +- One deliberate robustness deviation is dropped: the hand-rolled parser flushed a final event block that lacked its terminating blank line, so a trailing `data: [DONE]` without `\n\n` still yielded DONE. eventsource-parser is spec-strict and only dispatches on the blank line, so that shape is now `STREAM_CLOSED`. Real providers and `dsh-llm-mock-server` always terminate events properly — the flush was a robustness nicety, not an observed provider shape — and `tests/sse.spec.ts` pins the new truncation verdict for that tail. +- The documented "hand-rolled" identity of the twin adapter narrows to the fetch/translate internals; the twin-adapters note was updated in the same change rather than leaving the claim stale. diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md new file mode 100644 index 0000000000..933b993d47 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md @@ -0,0 +1,28 @@ +# Agent Note: 用 eventsource-parser 替换 llm-deepseek 中手写的 SSE 解析器 + +Status: implemented + +[English](2026-07-26-eventsource-parser-for-deepseek-sse.md) | 中文 + +## 问题 + +`packages/llm/llm-deepseek/src/sse.ts` 曾手写实现 SSE(Server-Sent Events)解析:一个流式 `TextDecoder`、按 `\r?\n\r?\n` 切分事件块、提取并拼接 `data:` 载荷、跳过注释与其他字段、`[DONE]` 哨兵、在未见哨兵即 EOF 时抛出 `STREAM_CLOSED` 错误,以及对最后一个未终结事件块的 flush。该文件约 67 行,另有约 108 行专属测试(`tests/sse.spec.ts`)重复验证 SSE 规范行为——UTF-8 字符被切分到多个分片、CRLF 处理、多条 `data:` 拼接、冒号后无空格——而这些行为,持续维护的解析器早已有保证。它唯一的消费方是 `adapter.ts`(`yield* translate(parseSse(response.body))`)。 + +这恰好是 `eventsource-parser` 负责的接口面:事实标准的 SSE 解析器(Vercel AI SDK 和 MCP SDK 都构建在它之上),零依赖,持续维护,并且已通过 `@modelcontextprotocol/sdk` 作为传递依赖出现在本仓库的 lockfile 中——因此直接采用它实际上不增加新的供应链接触面。 + +## 决策 + +`sse.ts` 将 SSE 分帧委托给 `eventsource-parser/stream` 的 `EventSourceParserStream`:`parseSse` 把响应 body 依次管道接入 `new TextDecoderStream()` 和 `new EventSourceParserStream()`,只保留 DeepSeek 协议垫层——逐个产出事件的 `data`,遇到 `[DONE]` 终止,流在未见哨兵时结束则抛出 `LlmError('STREAM_CLOSED')`。所需的全部内置能力(`TextDecoderStream`、`pipeThrough`、可异步迭代的 `ReadableStream`)在 Node ^22.19 引擎下限即已存在。规范符合性测试已删除;`tests/sse.spec.ts` 只固定 `[DONE]`/`STREAM_CLOSED`/EOF 契约。`eventsource-parser` 是 `llm-deepseek` 继 schemastery 之后的第二个运行时依赖。曾把该适配器标为「手写 fetch + SSE 解析」的[孪生适配器 Agent Note(agent 决策记录)](../architecture/2026-06-13-twin-llm-adapters.md)与 `dsh-llm` JSDoc,现在将其描述为直接 fetch 加库分帧的 SSE。 + +该库还会剥离开头的 BOM(手写解析器在 BOM 之后会无法匹配 `data:`),并提供手写解析器缺少的 `maxBufferSize` 加固能力。 + +## 曾考虑的替代方案 + +- **保留手写解析器。** 依据[孪生适配器决策](../architecture/2026-06-13-twin-llm-adapters.md),这一选择有辩护余地:该适配器有意作为 pi-ai 适配器的手写设计验证孪生体。但那份 Agent Note 起支撑作用的区分在于「自行持有 fetch/translate 内部实现」与「委托给完整的提供方 SDK」;一个约 700 字节的 SSE 微型解析器属于传输层管道,不是被验证的设计本身。孪生适配器 Agent Note 现已明确写出这一解读。 +- **改用 `createParser({onEvent})` 回调 API 而非流。** 配合手动的 `TextDecoder` 循环可以工作,但 `pipeThrough` 组合方式能删除更多手写代码。 + +## 后果 + +- 剩下的垫层只编码 DeepSeek 的 `[DONE]`/`STREAM_CLOSED` 协议;SSE 分帧边界情形属于 eventsource-parser 的契约,不再在这里重复验证。 +- 放弃了一处有意为之的健壮性偏离:手写解析器会 flush 缺少终结空行的最后一个事件块,因此末尾的 `data: [DONE]` 即使没有 `\n\n` 也仍产出 DONE。eventsource-parser 严格遵循规范,只在空行处分发事件,所以这种形态现在是 `STREAM_CLOSED`。真实提供方和 `dsh-llm-mock-server` 总是正确终结事件——该 flush 只是健壮性上的锦上添花,并非实际观测到的提供方形态——`tests/sse.spec.ts` 固定了对该尾部的新截断判定。 +- 孪生适配器有文档记录的「手写」身份收窄到 fetch/translate 内部实现;孪生适配器 Agent Note 在同一次变更中更新,而不是让声明陈旧下去。 diff --git a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md b/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md deleted file mode 100644 index 8a93b7f6c7..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: Replace the hand-rolled SSE parser in llm-deepseek with eventsource-parser - -Status: proposed - -English | [中文](2026-07-26-eventsource-parser-for-deepseek-sse.zh.md) - -## Problem - -`packages/llm/llm-deepseek/src/sse.ts` hand-implements Server-Sent Events parsing: a streaming `TextDecoder`, event-block splitting on `\r?\n\r?\n`, `data:` payload extraction and joining, comment/field skipping, the `[DONE]` sentinel, a `STREAM_CLOSED` error on EOF without it, and a flush of a final unterminated event block. The file is ~67 lines with ~108 lines of dedicated tests (`tests/sse.spec.ts`) re-proving SSE spec behavior — UTF-8 split across chunks, CRLF handling, multi-`data:` joining, no-space-after-colon — that a maintained parser already guarantees. Its only consumer is `adapter.ts` (`yield* translate(parseSse(response.body))`). - -This is exactly the surface `eventsource-parser` owns: the de-facto standard SSE parser (it underlies the Vercel AI SDK and the MCP SDK), zero-dependency, actively maintained, and already present in this repo's lockfile transitively via `@modelcontextprotocol/sdk` — so adopting it directly adds no new supply-chain surface in practice. - -## Proposal - -Replace `sse.ts` with `EventSourceParserStream` from `eventsource-parser/stream`: `response.body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream())`, keeping only the DeepSeek protocol shim (~10–25 lines): yield each event's `data`, terminate on `[DONE]`, and throw `LlmError('STREAM_CLOSED')` when the stream ends without the sentinel. All required builtins (`TextDecoderStream`, `pipeThrough`, async-iterable `ReadableStream`) exist at the Node ^22.19 engine floor. Delete the spec-conformance tests; keep the `[DONE]`/`STREAM_CLOSED`/EOF contract tests. Add `eventsource-parser` to `llm-deepseek`'s dependencies (its second runtime dep after schemastery). Update the [twin-adapters note](../../implemented/architecture/2026-06-13-twin-llm-adapters.md) and the `dsh-llm` JSDoc that brand this adapter "hand-rolled fetch + SSE parsing" in the same PR. - -The library also strips a leading BOM (the hand-rolled parser would fail to match `data:` after one) and offers `maxBufferSize` hardening the current parser lacks. - -## Alternatives considered - -- **Keep the hand-rolled parser.** Defensible under the [twin-adapters decision](../../implemented/architecture/2026-06-13-twin-llm-adapters.md): the adapter is deliberately the hand-rolled design-verification twin of the pi-ai adapter. But the note's load-bearing distinction is owning the fetch/translate internals versus delegating to a full provider SDK; a ~700-byte SSE micro-parser is transport plumbing, not the design under verification. Whether that reading stands is the twin-note owner's call — this proposal explicitly needs their sign-off. -- **`createParser({onEvent})` callback API instead of the stream.** Works fed by a manual `TextDecoder` loop, but the `pipeThrough` composition deletes more of the hand-rolled code. - -## Acceptance criteria - -- `sse.ts`'s parsing internals are gone; the remaining shim only encodes the DeepSeek `[DONE]`/`STREAM_CLOSED` protocol. -- `llm-deepseek` unit tests and the real-API e2e suite pass; keyless snapshots are unchanged (parsing is transport-internal and payload extraction is equivalent). -- The twin-adapters note and `dsh-llm` JSDoc no longer claim hand-rolled SSE parsing. - -## Risks - -- One deliberate robustness deviation is lost: the hand-rolled parser flushes a final event block that lacks its terminating blank line, and `tests/sse.spec.ts` pins that a trailing `data: [DONE]` without `\n\n` still yields DONE. eventsource-parser is spec-strict and only dispatches on the blank line, so that shape becomes `STREAM_CLOSED`. Real providers and `dsh-llm-mock-server` always terminate events properly, so the pinned behavior is a robustness nicety, not an observed provider shape — drop the test, or keep a tiny buffer-tail check if the deviation is judged load-bearing. -- Dilutes the documented "hand-rolled" identity of the twin adapter; mitigated by updating the note in the same change rather than leaving the claim stale. diff --git a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md b/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md deleted file mode 100644 index b16109d945..0000000000 --- a/.agents/notes/proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: 用 eventsource-parser 替换 llm-deepseek 中手写的 SSE 解析器 - -Status: proposed - -[English](2026-07-26-eventsource-parser-for-deepseek-sse.md) | 中文 - -## 问题 - -`packages/llm/llm-deepseek/src/sse.ts` 手写实现了 SSE(Server-Sent Events)解析:一个流式 `TextDecoder`、按 `\r?\n\r?\n` 切分事件块、提取并拼接 `data:` 载荷、跳过注释与其他字段、`[DONE]` 哨兵、在未见哨兵即 EOF 时抛出 `STREAM_CLOSED` 错误,以及对最后一个未终结事件块的 flush。该文件约 67 行,另有约 108 行专属测试(`tests/sse.spec.ts`)重复验证 SSE 规范行为——UTF-8 字符被切分到多个分片、CRLF 处理、多条 `data:` 拼接、冒号后无空格——而这些行为,持续维护的解析器早已有保证。它唯一的消费方是 `adapter.ts`(`yield* translate(parseSse(response.body))`)。 - -这恰好是 `eventsource-parser` 负责的接口面:事实标准的 SSE 解析器(Vercel AI SDK 和 MCP SDK 都构建在它之上),零依赖,持续维护,并且已通过 `@modelcontextprotocol/sdk` 作为传递依赖出现在本仓库的 lockfile 中——因此直接采用它实际上不增加新的供应链接触面。 - -## 提案 - -用 `eventsource-parser/stream` 的 `EventSourceParserStream` 替换 `sse.ts`:`response.body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream())`,只保留 DeepSeek 协议垫层(约 10–25 行):逐个产出事件的 `data`,遇到 `[DONE]` 终止,流在未见哨兵时结束则抛出 `LlmError('STREAM_CLOSED')`。所需的全部内置能力(`TextDecoderStream`、`pipeThrough`、可异步迭代的 `ReadableStream`)在 Node ^22.19 引擎下限即已存在。删除规范符合性测试;保留 `[DONE]`/`STREAM_CLOSED`/EOF 契约测试。将 `eventsource-parser` 加入 `llm-deepseek` 的依赖(这是它继 schemastery 之后的第二个运行时依赖)。在同一个 PR(Pull Request)中更新[孪生适配器 Agent Note(agent 决策记录)](../../implemented/architecture/2026-06-13-twin-llm-adapters.md)以及 `dsh-llm` 中把该适配器标为「手写 fetch + SSE 解析」的 JSDoc。 - -该库还会剥离开头的 BOM(手写解析器在 BOM 之后会无法匹配 `data:`),并提供当前解析器缺少的 `maxBufferSize` 加固能力。 - -## 曾考虑的替代方案 - -- **保留手写解析器。** 依据[孪生适配器决策](../../implemented/architecture/2026-06-13-twin-llm-adapters.md),这一选择有辩护余地:该适配器有意作为 pi-ai 适配器的手写设计验证孪生体。但那份 Agent Note 起支撑作用的区分在于「自行持有 fetch/translate 内部实现」与「委托给完整的提供方 SDK」;一个约 700 字节的 SSE 微型解析器属于传输层管道,不是被验证的设计本身。这一解读是否成立由孪生 Agent Note 的所有者裁定——本提案明确需要其签署确认。 -- **改用 `createParser({onEvent})` 回调 API 而非流。** 配合手动的 `TextDecoder` 循环可以工作,但 `pipeThrough` 组合方式能删除更多手写代码。 - -## 验收标准 - -- `sse.ts` 的解析内部实现消失;剩下的垫层只编码 DeepSeek 的 `[DONE]`/`STREAM_CLOSED` 协议。 -- `llm-deepseek` 单元测试与真实 API 的 e2e 套件通过;无密钥快照不变(解析属于传输层内部,载荷提取等价)。 -- 孪生适配器 Agent Note 与 `dsh-llm` 的 JSDoc 不再声称手写 SSE 解析。 - -## 风险 - -- 会失去一处有意为之的健壮性偏离:手写解析器会 flush 缺少终结空行的最后一个事件块,`tests/sse.spec.ts` 固定了「末尾的 `data: [DONE]` 即使没有 `\n\n` 也仍产出 DONE」这一行为。eventsource-parser 严格遵循规范,只在空行处分发事件,因此这种形态会变成 `STREAM_CLOSED`。真实提供方和 `dsh-llm-mock-server` 总是正确终结事件,所以被固定的行为只是健壮性上的锦上添花,并非实际观测到的提供方形态:可以删除该测试;若判定该偏离确有支撑作用,也可以保留一个小型的缓冲区尾部检查。 -- 稀释了孪生适配器有文档记录的「手写」身份;缓解方式是在同一次变更中更新那份 Agent Note,而不是让声明陈旧下去。 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index 8749dbd0bc..1f10218f24 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.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 -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: c988ca0c75e9c50686551f3be1971d736b971e2a -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: e85161cb2ee616d388aa2a9dd065c315c60cd44a +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 78e133a60edae1523518e1b14cb32ec8928ca26c +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: dec44d89fdaa9d1de38229611103968f0b308315 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index c988ca0c75..78e133a60e 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -18,7 +18,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`vscode-languageserver-types` for lsp-local's wire-type subset**: ~80 type lines and ~45 guard lines, but upstream guards differ in both directions (accept `uri: undefined` the repo must reject; require `targetRange` the repo tolerates absent), and the initialize-result shapes live in `vscode-languageserver-protocol`, dragging `vscode-jsonrpc` in as a runtime dep — ~1 MB for 80 spec-exact lines. - **`json-rpc-2.0` for `dsh-jsonrpc`**: deletable correlation/dispatch is real (~100–130 lines) but the NDJSON wire must stay bit-identical for the hand-rolled Python SDK client, the package is single-maintainer, and the [GUI RPC note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) already treats this package as a frozen narrow surface. `vscode-jsonrpc` is a worse fit still (Content-Length framing, cancellation vocabulary the protocol lacks). - **`jsonrpcclient` for the Python SDK client**: v4 builds/parses messages only — ~20 lines — while the 500 lines that matter (subprocess lifecycle, threaded reader, id correlation, bidirectional server-role responses) stay; the library is in low-maintenance mode. -- **`eventsource-parser` for apiproxy's `readSse`**: only ~15 lines of framing are deletable, both wire ends are in-repo so spec conformance is moot, and it would add a dep to a browser-safe package. (Contrast with the [llm-deepseek proposal](../../proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.) +- **`eventsource-parser` for apiproxy's `readSse`**: only ~15 lines of framing are deletable, both wire ends are in-repo so spec conformance is moot, and it would add a dep to a browser-safe package. (Contrast with the [llm-deepseek proposal](../../implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.) **Retry, timers, async:** diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index e85161cb2e..dec44d89fd 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -18,7 +18,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `vscode-languageserver-types` 承担 lsp-local 的协议类型子集**:约 80 行类型加约 45 行守卫,但上游守卫在两个方向上都与本仓库不一致(接受本仓库必须拒绝的 `uri: undefined`;强制要求本仓库容忍缺失的 `targetRange`),而且 initialize 结果的形状住在 `vscode-languageserver-protocol` 里,会把 `vscode-jsonrpc` 拖成运行时依赖——为 80 行严格贴合规范的代码付出约 1 MB。 - **以 `json-rpc-2.0` 替换 `dsh-jsonrpc`**:可删除的关联/分发代码确实存在(约 100–130 行),但 NDJSON 协议格式(wire format)必须与手写的 Python SDK 客户端逐位一致,该包只有单一维护者,且 [GUI RPC 决策](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)已把这个包当作冻结的窄接口面对待。`vscode-jsonrpc` 更不合适(Content-Length 分帧、该协议并不具备的取消词汇)。 - **以 `jsonrpcclient` 承担 Python SDK 客户端**:v4 只做消息的构造/解析——约 20 行——而真正要紧的 500 行(子进程生命周期、线程化读取器、id 关联、双向的服务端角色应答)全都保留;该库处于低维护模式。 -- **以 `eventsource-parser` 替换 apiproxy 的 `readSse`**:可删除的分帧只有约 15 行,线路两端都在仓库内,规范符合性无关紧要,而且这会给一个浏览器安全的包添加依赖。(对比 [llm-deepseek 提案](../../proposed/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。) +- **以 `eventsource-parser` 替换 apiproxy 的 `readSse`**:可删除的分帧只有约 15 行,线路两端都在仓库内,规范符合性无关紧要,而且这会给一个浏览器安全的包添加依赖。(对比 [llm-deepseek 提案](../../implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。) **重试、定时器与异步:** diff --git a/AGENTS.md b/AGENTS.md index f56c162b52..66aefc770f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop - llm/ LLM seam + DeepSeek adapters (hand-rolled + pi-ai design twin) + llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools pty/ persistent PTY seam/backend/tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9880761894..3bc4c9974d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -585,7 +585,7 @@ export interface Config { streamIdleTimeoutMs?: number } -/** One optional model entry advertised by the hand-written adapter. */ +/** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { /** Wire model id accepted by the configured endpoint. */ id: string diff --git a/docs/cookbook/adding-an-llm-adapter.i18n.yaml b/docs/cookbook/adding-an-llm-adapter.i18n.yaml index 497ae08c32..d7b71dde63 100644 --- a/docs/cookbook/adding-an-llm-adapter.i18n.yaml +++ b/docs/cookbook/adding-an-llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -adding-an-llm-adapter.md: f20442b8c2ce823452a3ea13409f202185d12d04 -adding-an-llm-adapter.zh.md: 2864dd1e18742c7449e24f22504a5a38976ab450 +adding-an-llm-adapter.md: c62292746f3dab4f6ee350621010349b4fbb2f64 +adding-an-llm-adapter.zh.md: 211c54ad319156b614945505f51a3f68760d64d6 diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index f20442b8c2..c62292746f 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -2,7 +2,7 @@ English | [中文](adding-an-llm-adapter.zh.md) -How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against. +How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (direct HTTP, SSE framed by `eventsource-parser`) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against. ## The shape diff --git a/docs/cookbook/adding-an-llm-adapter.zh.md b/docs/cookbook/adding-an-llm-adapter.zh.md index 2864dd1e18..211c54ad31 100644 --- a/docs/cookbook/adding-an-llm-adapter.zh.md +++ b/docs/cookbook/adding-an-llm-adapter.zh.md @@ -2,7 +2,7 @@ [English](adding-an-llm-adapter.md) | 中文 -如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(手写 HTTP/SSE)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。 +如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(直接 HTTP,SSE 由 `eventsource-parser` 分帧)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。 ## 基本形态 diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 5de51d2794..49c75cdf7a 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/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 -llm-streaming.md: fb97e74a9ec01e62ba940295112fb157cc73bd1d -llm-streaming.zh.md: fda59a64aeef1c037372d69dbc15ee0de48de222 +llm-streaming.md: 1dbe50b467e2d6da45babe44e48315c3ff99b3a1 +llm-streaming.zh.md: 8e7bedcb5ec4af86e3faadb0938780f85ebd6552 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index fb97e74a9e..1dbe50b467 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -67,7 +67,7 @@ Every adapter MUST obey these, and every consumer may rely on them: - **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter). - **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state. -This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request. +This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (direct fetch, SSE framing via `eventsource-parser`) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request. ## `AppIdentity` — app attribution @@ -163,8 +163,8 @@ declare class BlockAssembler { /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled - * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch + * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. */ declare abstract class LlmAdapter { /** diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index fda59a64ae..8e7bedcb5e 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -67,7 +67,7 @@ interface LlmFailure { - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 - **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。 -该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(手写 fetch/SSE(Server-Sent Events))和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 +该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(直接 fetch,SSE(Server-Sent Events)分帧经由 `eventsource-parser`)和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 ## `AppIdentity`:应用归属 @@ -163,8 +163,8 @@ declare class BlockAssembler { /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled - * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch + * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. */ declare abstract class LlmAdapter { /** diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml index 9749e012de..0cd2ab4358 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 -README.md: 0278a4a582e535125d001e09736b89f13be72a0c -README.zh.md: e3e2b9559d69e4be10cd4d373bbda2dd47396b72 +README.md: 13a04aa9f73fec5824069644449009989d6fd924 +README.zh.md: 3e417c5f8be1f7831b99940c2a4aec815dc2c5b6 diff --git a/packages/llm/README.md b/packages/llm/README.md index 0278a4a582..13a04aa9f7 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -9,7 +9,7 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a | `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | | `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` | | `llm-retry/` | Bounded transient request retry policy | (listens to `agent/request-error`) | -| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | +| `llm-deepseek/` | DeepSeek API adapter (direct fetch + eventsource-parser SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) | The interface lives at `llm/llm/`; adapters, retry policy, and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The route-owning adapter optionally resolves exact provider/model context capacity; the token meter remains model-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the interface or consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership. diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md index e3e2b9559d..3e417c5f8b 100644 --- a/packages/llm/README.zh.md +++ b/packages/llm/README.zh.md @@ -9,7 +9,7 @@ LLM seam 及其提供方适配器。接口包(`llm`)拥有抽象服务、内 | `llm/` | 抽象 LLM 服务 + 内容块词汇 + 分片组装器 | `ctx.llm` | | `token-meter/` | 感知回放的请求与表层 token 测量 | `ctx.tokenMeter` | | `llm-retry/` | 有界的暂时性请求重试策略 | (监听 `agent/request-error`) | -| `llm-deepseek/` | DeepSeek API 适配器(手写 fetch/SSE) | (注册到 `ctx.llm`) | +| `llm-deepseek/` | DeepSeek API 适配器(直接 fetch + eventsource-parser SSE) | (注册到 `ctx.llm`) | | `llm-pi-ai/` | 通过 `@earendil-works/pi-ai` 实现的多提供方适配器 | (注册到 `ctx.llm`) | 接口位于 `llm/llm/`;适配器、重试策略和可复用的 token 计量器都是该分组下的扁平兄弟包。请求按 `provider` 路由,而 `model` 会原样传给选中的适配器。拥有路由的适配器可以解析精确的提供方/模型上下文容量;token 计量器仍与模型无关。新的提供方适配器只需在 `ctx.llm` 上注册一个或多个提供方路由,无需改动接口或消费方。两个已交付实现见[双生 LLM 适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),测量归属见[回放 token 计量器 Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md),容量与压缩策略归属见[路由模型上下文 Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md)。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index f5b4d04574..b34e9bf4e1 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 -README.md: e191f3fcd265a6ca9cec3a8dae5f730ce27accf1 -README.zh.md: 268096e5f1a145e8d5cf6469524d36fe48984617 +README.md: dc373f32ccb5e4ade6dde3128fee75c913fd99b2 +README.zh.md: 22edefb91dc78a10961f0bc9fa676614ace26a33 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index e191f3fcd2..dc373f32cc 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch` + SSE translation from the official wire format (source of truth: the API docs — guides/thinking_mode, guides/tool_calls, api/create-chat-completion) into the `StreamChunk` protocol. +DeepSeek chat-completions adapter for the harness LLM seam: direct `fetch` + SSE (framed by `eventsource-parser`) translating the official wire format (source of truth: the API docs — guides/thinking_mode, guides/tool_calls, api/create-chat-completion) into the `StreamChunk` protocol. A second, library-backed implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai`. This package always owns the `deepseek` provider route; mounting a pi-ai profile with `provider: deepseek` in the same context throws `LlmError('DUPLICATE_ADAPTER')` by design. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 268096e5f1..22edefb91d 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE,将官方协议格式(真源:API 文档 guides/thinking_mode、guides/tool_calls、api/create-chat-completion)转换为 `StreamChunk` 协议。 +harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE(由 `eventsource-parser` 分帧),将官方协议格式(真源:API 文档 guides/thinking_mode、guides/tool_calls、api/create-chat-completion)转换为 `StreamChunk` 协议。 同一 seam 的第二个库支持实现位于 `@deepseek-ai/dsh-llm-pi-ai`。本包始终拥有 `deepseek` 提供方路由;在同一上下文中装载 `provider: deepseek` 的 pi-ai profile 会按设计抛出 `LlmError('DUPLICATE_ADAPTER')`。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 4946c2bcd9..d233ef7764 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -33,6 +33,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { + "eventsource-parser": "^3.1.0", "schemastery": "^3.18.0" }, "devDependencies": { diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 64faa3b725..1dd2a9a02e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -20,7 +20,7 @@ import { parseSse } from './sse.ts' import { translate } from './translate.ts' import type { WireError } from './types.ts' -/** One optional model entry advertised by the hand-written adapter. */ +/** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { /** Wire model id accepted by the configured endpoint. */ id: string diff --git a/packages/llm/llm-deepseek/src/sse.ts b/packages/llm/llm-deepseek/src/sse.ts index 17080b10b3..a8807a857c 100644 --- a/packages/llm/llm-deepseek/src/sse.ts +++ b/packages/llm/llm-deepseek/src/sse.ts @@ -1,65 +1,33 @@ /** - * Decode an SSE byte stream into event `data` payloads. Network reads may split UTF-8 or lines; - * CRLF, comments, non-data fields, and multi-data events are handled per SSE rules. The literal - * `[DONE]` is yielded so the caller owns final flushing, and EOF before it raises {@link LlmError}. + * Decode an SSE byte stream into event `data` payloads. Framing — chunk + * reassembly, UTF-8/CRLF/BOM handling, comment and non-data field skipping, + * multi-`data:` joining — is `eventsource-parser`'s; this module keeps only + * the DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns + * final flushing, and EOF before it raises {@link LlmError}. Framing is + * spec-strict: an event dispatches only on its blank-line terminator, so an + * unterminated tail at EOF is truncation, not a flushable payload. * - * Minimal SSE (text/event-stream) parser for the chat-completions stream. * @module dsh-llm-deepseek/sse */ +import { EventSourceParserStream } from 'eventsource-parser/stream' import { LlmError } from '@deepseek-ai/dsh-llm' /** The terminal payload DeepSeek (and OpenAI) send after the last chunk. */ export const DONE = '[DONE]' -/** Extract the joined data payload from one raw SSE event block. */ -function eventData(block: string): string | undefined { - const data: string[] = [] - for (const rawLine of block.split('\n')) { - const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine - if (line.startsWith('data:')) { - // The spec strips ONE leading space after the colon. - data.push(line.startsWith('data: ') ? line.slice(6) : line.slice(5)) - } - // Comments (':…') and other fields (event:, id:, retry:) are ignored. - } - if (data.length === 0) return undefined - return data.join('\n') -} - /** - * Parse a byte stream into SSE data payloads. Yields `[DONE]` as the final + * Parse an SSE byte stream into data payloads. Yields `[DONE]` as the final * value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends * without it (truncated response — the model call cannot be trusted). * @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence. * @returns each event's data payload in arrival order, the `[DONE]` sentinel last. */ -export async function* parseSse(stream: AsyncIterable): AsyncGenerator { - const decoder = new TextDecoder() - let buffer = '' - - for await (const bytes of stream) { - buffer += decoder.decode(bytes, { stream: true }) - // Events are separated by a blank line (\n\n; tolerate \r\n\r\n via the - // per-line \r strip in eventData and a normalized split here). - let boundary: number - while ((boundary = buffer.search(/\r?\n\r?\n/)) !== -1) { - const matched = /\r?\n\r?\n/.exec(buffer.slice(boundary)) - const block = buffer.slice(0, boundary) - // matched cannot be null: search() just found the same pattern at 0. - buffer = buffer.slice(boundary + (matched as RegExpExecArray)[0].length) - const data = eventData(block) - if (data === undefined) continue - yield data - if (data === DONE) return - } - } - - // Flush any final un-terminated event (servers usually end with \n\n, but - // a trailing block without one is still parseable). - buffer += decoder.decode() - const data = eventData(buffer) - if (data !== undefined) { +export async function* parseSse(stream: ReadableStream): AsyncGenerator { + const events = stream + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()) + for await (const { data } of events) { yield data if (data === DONE) return } diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index e02476eaef..120ce1a209 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -7,7 +7,7 @@ import type { Config } from '@deepseek-ai/dsh-llm-deepseek' import { assemble, type AssembledResult } from './assemble.ts' /** - * Real-API e2e for the hand-rolled adapter: V4 Flash + V4 Pro across + * Real-API e2e for the direct-fetch adapter: V4 Flash + V4 Pro across * thinking modes and both official effort levels. Key-gated — skips * entirely without $DEEPSEEK_API_KEY (see vitest.e2e.config.ts). */ diff --git a/packages/llm/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts index b18862e4f3..7ebb494a4d 100644 --- a/packages/llm/llm-deepseek/tests/sse.spec.ts +++ b/packages/llm/llm-deepseek/tests/sse.spec.ts @@ -2,12 +2,21 @@ import { describe, expect, it } from 'vitest' import { LlmError } from '@deepseek-ai/dsh-llm' import { DONE, parseSse } from '../src/sse.ts' -/** Build a byte stream from string fragments (fragments = network reads). */ -async function* bytes(...fragments: (string | Uint8Array)[]): AsyncGenerator { +/** + * DeepSeek protocol contract only: the [DONE] sentinel and STREAM_CLOSED on + * EOF without it. SSE framing (chunk splits, CRLF, multi-data joins, comments) + * is eventsource-parser's contract, not re-proven here. + */ + +/** Build an SSE byte stream from string fragments (fragments = network reads). */ +function bytes(...fragments: string[]): ReadableStream> { const encoder = new TextEncoder() - for (const fragment of fragments) { - yield typeof fragment === 'string' ? encoder.encode(fragment) : fragment - } + return new ReadableStream({ + start(controller) { + for (const fragment of fragments) controller.enqueue(encoder.encode(fragment)) + controller.close() + }, + }) } async function collect(stream: AsyncIterable): Promise { @@ -17,57 +26,14 @@ async function collect(stream: AsyncIterable): Promise { } describe('parseSse', () => { - it('parses simple events and the DONE sentinel', async () => { + it('yields event payloads and the DONE sentinel', async () => { const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]\n\n'))) expect(events).toEqual(['{"a":1}', DONE]) }) - it('handles events split across reads at arbitrary positions', async () => { - const events = await collect(parseSse(bytes('da', 'ta: {"a"', ':1}\n', '\ndata: [DO', 'NE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('handles multi-byte UTF-8 split across reads', async () => { - const encoded = new TextEncoder().encode('data: {"text":"日本語"}\n\ndata: [DONE]\n\n') - // Split inside the 3-byte sequence for 日. - const splitAt = 16 - const events = await collect(parseSse(bytes(encoded.slice(0, splitAt), encoded.slice(splitAt)))) - expect(events).toEqual(['{"text":"日本語"}', DONE]) - }) - - it('tolerates CRLF line endings', async () => { - const events = await collect(parseSse(bytes('data: {"a":1}\r\n\r\ndata: [DONE]\r\n\r\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('joins multi-data events with newlines (SSE spec)', async () => { - const events = await collect(parseSse(bytes('data: line1\ndata: line2\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['line1\nline2', DONE]) - }) - - it('ignores comments and non-data fields', async () => { - const events = await collect(parseSse(bytes(': keepalive\nevent: chunk\nid: 7\ndata: {"a":1}\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('skips blocks without data fields', async () => { - const events = await collect(parseSse(bytes(': ping\n\ndata: {"a":1}\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('preserves data lines without the optional space', async () => { - const events = await collect(parseSse(bytes('data:{"a":1}\n\ndata:[DONE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('parses several events from one read', async () => { - const events = await collect(parseSse(bytes('data: 1\n\ndata: 2\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['1', '2', DONE]) - }) - - it('flushes a final un-terminated DONE at stream end', async () => { - const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]'))) - expect(events).toEqual(['{"a":1}', DONE]) + it('stops yielding after DONE even when more data follows', async () => { + const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n'))) + expect(events).toEqual([DONE]) }) it('throws STREAM_CLOSED when the stream ends without DONE', async () => { @@ -83,26 +49,10 @@ describe('parseSse', () => { await expect(collect(parseSse(bytes('data: {"a"')))).rejects.toThrow(/without \[DONE\]/) }) - it('stops yielding after DONE even when more data follows', async () => { - const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n'))) - expect(events).toEqual([DONE]) - }) -}) - -describe('parseSse edge branches', () => { - it('handles a lone CR-terminated data line', async () => { - // Exercises the \r-strip branch on a line that is ONLY "data:…\r". - const events = await collect(parseSse(bytes('data: {"a":1}\r\n\r\ndata:[DONE]\r\n\r\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('strips CR from non-data field lines too', async () => { - const events = await collect(parseSse(bytes('event: chunk\r\ndata: {"a":1}\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('treats bare "data:" lines as empty payload entries', async () => { - const events = await collect(parseSse(bytes('data:\ndata: x\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['\nx', DONE]) + it('treats a final DONE missing its blank-line terminator as truncation', async () => { + // Spec-strict framing: an event dispatches only on its blank-line + // terminator, so an unterminated tail at EOF is STREAM_CLOSED — real + // providers always terminate events, so a missing terminator is truncation. + await expect(collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]')))).rejects.toThrow(/without \[DONE\]/) }) }) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 38ccfa49a7..d23487d123 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 1e725d13af70bdb2f326b43350323763f95b579b -README.zh.md: fe749ca1032b576bef10c1dbdfd41d6299b6cf50 +README.md: e00cfedc4561b70a0f08e6825617fa5c5217f7ad +README.zh.md: ef6582ea2a71128ea2ab17012852b1125b4fe692 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 1e725d13af..e00cfedc45 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -60,7 +60,7 @@ Every product adapter sends application identity on provider HTTP requests. `att ### Real adapters -Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses hand-rolled fetch/SSE for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale. +Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale. ## Model Experience diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index fe749ca103..ef6582ea2a 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -60,7 +60,7 @@ ### 真实适配器 -两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用手写 fetch/SSE,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。 +两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用直接 fetch 加 `eventsource-parser` SSE 分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。 ## 模型体验 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 6765833e8c..58d8fe0e0c 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -106,8 +106,8 @@ export class LlmError extends HarnessError { /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled - * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch + * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. */ export abstract class LlmAdapter { /** diff --git a/packages/sdk/helper/src/features/builtin/provider.ts b/packages/sdk/helper/src/features/builtin/provider.ts index 227d80088c..ba54bdc641 100644 --- a/packages/sdk/helper/src/features/builtin/provider.ts +++ b/packages/sdk/helper/src/features/builtin/provider.ts @@ -1,5 +1,5 @@ /** - * Required hand-rolled DeepSeek and custom pi-ai provider behavior. + * Required direct-fetch DeepSeek and custom pi-ai provider behavior. * * @module @deepseek-ai/dsh-helper/features/builtin/provider */ @@ -74,7 +74,7 @@ export class ProviderFeature extends ExclusiveOptionFeature { override readonly required = true override readonly options = [new DeepSeekOption(), new CustomOption()] - /** Prefer the hand-rolled adapter and its public endpoint defaults. */ + /** Prefer the direct-fetch adapter and its public endpoint defaults. */ override defaultOptions(): readonly string[] { return ['deepseek'] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1946a841bb..62d04a2fc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2475,6 +2475,9 @@ importers: packages/llm/llm-deepseek: dependencies: + eventsource-parser: + specifier: ^3.1.0 + version: 3.1.0 schemastery: specifier: ^3.18.0 version: 3.18.0