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:
38 files changed
+4567
-28
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user