Add two DeepSeek LLM adapters: dsh-llm-deepseek and dsh-llm-pi-ai

The first real LlmAdapter implementations, shipped as a deliberate pair:
same models and wire protocol, completely different internals, so the
StreamChunk protocol is verified across independent implementations.

- dsh-llm-deepseek: hand-rolled fetch + SSE parser + chunk-translation
  state machine against the official chat-completions format (thinking
  mode via top-level thinking/reasoning_effort; the empty-string
  reasoning_content first chunk; usage attached to the finish chunk or
  trailing; reasoning_content passback on tool-call turns; disjoint
  cache-token accounting).
- dsh-llm-pi-ai: the same endpoint through @earendil-works/pi-ai,
  mapping its event vocabulary (parsed tool arguments, in-stream error
  events, folded reasoning tokens) onto the same chunks.

The agent loop now honors the in-band error path: an adapter that ends
its stream with finish {kind:error|aborted} (the only option for
adapters that can't throw mid-stream, like pi-ai) is translated into a
step error, so the turn ends error/aborted with a logged error event
instead of a normal completed assistant message. This makes the
StreamChunk error contract real for both adapters; docs/architecture.md
and the StreamChunk doc are updated accordingly.

New yarn test:e2e (vitest.e2e.config.ts, *.e2e.ts) runs key-gated
real-API matrices for both adapters across V4 Flash/Pro and all
thinking/effort levels; it self-skips without DEEPSEEK_API_KEY. Unit
suites run against local node:http mock SSE servers at 100% per-file
coverage.
This commit is contained in:
Tianyi Cui
2026-06-13 18:30:03 +08:00
parent 8b5a3ef730
commit ab19fed77c
38 files changed
+4567 -28

No files matched your search

+11 -4
View File
@@ -30,9 +30,14 @@ declare module 'cordis' {
}
}
/** Typed error for LLM-related failures. The `code` string enables programmatic handling. */
/**
* Typed error for LLM-related failures. The `code` string enables programmatic
* handling (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`); `status` carries the HTTP
* status when the error originated from a non-2xx provider response (absent for
* protocol/usage errors that have no HTTP status).
*/
export class LlmError extends Error {
constructor(message: string, public code: string) {
constructor(message: string, public code: string, public status?: number) {
super(message)
this.name = 'LlmError'
}
@@ -45,8 +50,10 @@ export class LlmError extends Error {
* StreamChunk) and one provider's wire format. Adapters register themselves
* via `ctx.llm.registerAdapter(models, adapter)`.
*
* TODO: the first real adapter (DeepSeek V4) lands in a later phase; until
* then only mock adapters (tests, demo) exist.
* Real implementations: `@deepseek-ai/dsh-llm-deepseek` (hand-rolled
* fetch/SSE) and `@deepseek-ai/dsh-llm-pi-ai` (pi-ai-backed) — two
* deliberately different internals over the same contract; see the
* adapter contract documented on `StreamChunk` in `./types.ts`.
*/
export abstract class LlmAdapter {
/** Stream one model call as raw chunks. The only required method. */
+26 -4
View File
@@ -111,7 +111,14 @@ export interface FinishReasonMap {
export type FinishReason = FinishReasonMap[keyof FinishReasonMap]
/** Token accounting for one model call (cache fields are optional). */
/**
* Token accounting for one model call (cache fields are optional).
*
* Counts are DISJOINT: `inputTokens` is uncached input only; cached input is
* reported separately as `cacheReadTokens`/`cacheWriteTokens` (billed input =
* sum of the three). Adapters whose providers fold cache hits into a total
* prompt count (DeepSeek's `prompt_tokens`) subtract them out.
*/
export interface TokenUsage {
inputTokens: number
outputTokens: number
@@ -128,9 +135,19 @@ export interface TokenUsage {
* carries the fully-assembled ContentBlock so consumers don't have to
* re-assemble deltas themselves (use {@link BlockAssembler} when they do).
*
* TODO(review): this protocol needs careful review before the first real
* adapter lands (DeepSeek V4 wire format, partial JSON arguments, interleaved
* reasoning signatures, …).
* Adapter contract — every adapter MUST obey these, and every consumer may
* rely on them:
* - Emit `usage` BEFORE `finish`, and nothing after `finish` (defer both to
* the provider's end-of-stream marker so trailing usage-only chunks can't
* violate this).
* - Tool-call `arguments` stay RAW JSON strings end-to-end; partial fragments
* stream via `argumentsDelta` (providers that hand back parsed objects
* re-stringify at `block-end`).
* - Failures may either THROW from `stream()` (transport/protocol errors) or
* end the stream with `finish {kind:'error'|'aborted'}` (provider in-band
* errors, for adapters that can't throw mid-stream); consumers must handle
* both. The agent loop translates a finish-error/aborted into a turn error —
* it never logs a normal completed assistant message for a failed step.
*/
export type StreamChunk =
| { type: 'block-start'; index: number; blockType: ContentBlockType }
@@ -168,6 +185,11 @@ export interface GenerateOptions {
prefill?: ContentBlock[]
temperature?: number
maxTokens?: number
/**
* Stop sequences: generation halts as soon as the model produces any one of
* these strings (adapters map to the provider's stop field, e.g. OpenAI
* `stop`). The stop string itself is not included in the output.
*/
stop?: string[]
signal?: AbortSignal
}