Merge remote-tracking branch 'origin/master' into feat/adr0016-type-build-check

This commit is contained in:
imccyu
2026-06-22 00:35:51 +08:00
365 changed files with 13601 additions and 7241 deletions

View File

@@ -1,46 +1,11 @@
# dsh-llm
# llm/ — LLM capability family
Provider-neutral LLM vocabulary and abstract service. This package defines the canonical language spoken by the agent loop, session logs, and every plugin.
The LLM seam and its provider adapters. The interface package (`llm`) owns the abstract service, the content-block vocabulary, and the stream-chunk assembler; the adapters are concrete implementations that register on `ctx.llm`. All **product** packages.
## Service: `LlmService` (ctx key: `llm`)
An adapter registry plus streaming / non-streaming call surfaces. Both call surfaces are interceptable via waterfall events.
### Public API
- `ctx.llm.registerAdapter(models: string[], adapter: LlmAdapter): () => void` Register an adapter for the given model names. Disposed with the calling fiber.
- `ctx.llm.models(): string[]` — model names with a registered adapter.
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas).
- `ctx.llm.streamBlocks(options: GenerateOptions): AsyncIterable<ContentBlock>` Stream as completed content blocks (convenience view).
- `ctx.llm.generate(options: GenerateOptions): Promise<GenerateResult>` One model call, fully assembled.
### Events
| Event | Mode | Purpose |
| Package | Role | ctx key |
|---|---|---|
| `llm/stream` | waterfall | Intercept/wrap every streaming model call (retry, caching, routing) |
| `llm/generate` | waterfall | Intercept/wrap every non-streaming model call |
| `llm/adapter-change` | emit | An adapter was registered or unregistered |
| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
| `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
### Extension points
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(models, adapter)` to add a new model provider.
- Wrap `llm/stream` or `llm/generate` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc.
### Content-block vocabulary (`types.ts`)
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`, `image`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging.
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
### Classes
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. Used by the agent loop (raw chunks for replay
+ assembled for history) and by `streamBlocks()`/`generate()`.
- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams.
- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response.
### Real adapters
Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../docs/rfc/implemented/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths).
The interface lives at `llm/llm/`; adapters are flat siblings under the group. A new provider adapter joins here and registers on `ctx.llm` without touching the interface. See [twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for why two adapters exist.

View File

@@ -0,0 +1,46 @@
# @deepseek-ai/dsh-llm-deepseek
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.
A second, independent implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai` (library-backed). Same Config shape — pick one per context (registering both for the same model names throws by design).
## Config
```yaml
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
models: [deepseek-v4-flash, deepseek-v4-pro] # one adapter, registered for each name
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; high | max — omitted ⇒ not sent
```
`models` lists every model name this one adapter instance serves: the adapter registers itself for each (the harness model name IS the wire `model` string), so a `generate`/`stream` call routes to it whenever `options.model` is any of them. Registering a second adapter for a name already taken throws `LlmError('DUPLICATE_ADAPTER')` (the LLM service enforces one adapter per model, all-or-nothing).
`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default).
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral.
## Wire-format notes (verified live + against the official docs)
- Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`.
- The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block).
- **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens).
- `strict` on tool schemas passes through (officially Beta; the public API wants the `/beta` base URL for it, the internal endpoint accepts it directly).
- Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric.
## Limitations (MVP, documented deliberately)
- `prefill` throws `LlmError('UNSUPPORTED')` — DeepSeek's chat-prefix completion is a Beta feature on the `/beta` base URL; future work.
- `image` blocks are skipped (no vision support on these models).
- `tool_choice` is not mapped (not part of the core vocabulary).
## Errors
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), `HTTP_<status>` otherwise. 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', code: <REASON>}` chunks.
## Testing
Unit suites run against a local `node:http` mock SSE server (no network). Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.

View File

@@ -0,0 +1,35 @@
{
"name": "@deepseek-ai/dsh-llm-deepseek",
"description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,101 @@
/**
* `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible)
* chat-completions endpoint, emitting harness StreamChunks.
*
* @module dsh-llm-deepseek/adapter
*/
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { serializeRequest } from './serialize'
import type { RequestDefaults } from './serialize'
import { parseSse } from './sse'
import { translate } from './translate'
import type { WireError } from './types'
export interface DeepSeekAdapterOptions {
apiKey: string
/** Endpoint base; `/chat/completions` is appended. */
baseURL: string
/** Request defaults applied to every call (thinking mode, effort). */
defaults?: RequestDefaults
}
/**
* Attribution header sent on every request so the provider can identify the
* client. Bump in lockstep with this package's version (no build-time version
* injection is wired in this repo yet).
*/
const USER_AGENT = 'deepseek-harness/0.0.1'
/** Map an HTTP status to a stable LlmError code. */
export function httpErrorCode(status: number): string {
if (status === 401 || status === 403) return 'AUTH'
if (status === 429) return 'RATE_LIMIT'
if (status === 400) return 'INVALID_REQUEST'
if (status >= 500) return 'SERVER'
return `HTTP_${status}`
}
/**
* The first real `LlmAdapter`. One instance serves every model name it was
* registered under (the harness model name IS the wire model name).
*
* Abort: `options.signal` is handed to fetch — both the initial request and
* the body stream reject on abort, which surfaces to the loop as a rejected
* step (the loop already contains step errors).
*/
export class DeepSeekAdapter extends LlmAdapter {
constructor(private readonly options: DeepSeekAdapterOptions) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const body = serializeRequest(options, this.options.defaults ?? {})
// TODO(http): deliberately raw `fetch` for the hand-rolled SSE body.
// `@cordisjs/plugin-http` (ctx.http) would give proxy/intercept/timeout
// uniformity AND can stream (`responseType: 'stream'` yields the same
// ReadableStream<Uint8Array> parseSse consumes), but adopting it today
// costs a hard `undici` dependency (it does `require('undici')` with no
// globalThis.fetch fallback) plus an unconditional `@cordisjs/fetch-file`
// import (pulling file-type + mime-types) for a file:// path we never hit.
// Revisit when a second adapter wants shared proxy/intercept config.
const response = await fetch(`${this.options.baseURL}/chat/completions`, {
method: 'POST',
headers: {
'authorization': `Bearer ${this.options.apiKey}`,
'content-type': 'application/json',
'accept': 'text/event-stream',
'user-agent': USER_AGENT,
},
body: JSON.stringify(body),
...options.signal ? { signal: options.signal } : {},
})
if (!response.ok) {
const code = httpErrorCode(response.status)
let message = `DeepSeek API error (HTTP ${response.status})`
try {
const parsed = await response.json() as WireError
if (parsed.error?.message) message = parsed.error.message
} catch {
// Paranoid by design: `code` and the HTTP status are ALREADY captured
// above (and passed to LlmError below), so the only thing this `try`
// can add is a richer provider-supplied message. A malformed, empty,
// or non-JSON error body is a normal thing for gateways/proxies to
// return on a 5xx/429 — swallowing the parse failure keeps the usable
// status-line message instead of letting a JSON.parse throw mask the
// real HTTP error. Nothing else reaches this catch: response.json()
// is the sole statement, and any non-parse failure (e.g. body already
// consumed) is equally non-actionable here.
}
throw new LlmError(message, code, response.status)
}
if (!response.body) {
throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE')
}
yield* translate(parseSse(response.body))
}
}

View File

@@ -0,0 +1,78 @@
/**
* DeepSeek LLM adapter plugin: registers a {@link DeepSeekAdapter} for the
* configured model names on `ctx.llm`.
*
* Config is cordis-native (schemastery). Secrets flow per the repo policy:
* `apiKey` from cordis.yml via the `!!js` tag (`!!js process.env.DEEPSEEK_API_KEY`)
* or from the environment directly; never from ad-hoc files.
*
* ```yaml
* - id: llm-deepseek
* name: '@deepseek-ai/dsh-llm-deepseek'
* config:
* apiKey: !!js process.env.DEEPSEEK_API_KEY
* baseURL: !!js process.env.DEEPSEEK_BASE_URL
* models: [deepseek-v4-flash, deepseek-v4-pro]
* ```
*
* @module @deepseek-ai/dsh-llm-deepseek
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-llm'
import { DeepSeekAdapter } from './adapter'
export { DeepSeekAdapter, httpErrorCode } from './adapter'
export type { DeepSeekAdapterOptions } from './adapter'
export { serializeMessages, serializeRequest } from './serialize'
export type { RequestDefaults } from './serialize'
export { DONE, parseSse } from './sse'
export { mapFinishReason, mapUsage, translate } from './translate'
export type * from './types'
export const name = 'llm-deepseek'
export const inject = ['llm']
export interface Config {
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
apiKey?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
baseURL?: string
/** Model names to register (sent verbatim on the wire). */
models?: string[]
/** Thinking-mode default for every request (provider default: enabled). */
thinking?: 'enabled' | 'disabled'
/** Thinking effort (only meaningful with thinking enabled). */
reasoningEffort?: 'high' | 'max'
}
export const Config: z<Config> = z.object({
apiKey: z.string(),
baseURL: z.string(),
models: z.array(z.string()).default(['deepseek-v4-flash', 'deepseek-v4-pro']),
thinking: z.union(['enabled', 'disabled']),
reasoningEffort: z.union(['high', 'max']),
})
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
export function apply(ctx: Context, config: Config): void {
const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY
if (apiKey === undefined || apiKey.length === 0) {
throw new Error('llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)')
}
const baseURL = config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL
// schemastery's .default() guarantees models is set after validation.
const models = config.models as string[]
ctx.llm.registerAdapter(models, new DeepSeekAdapter({
apiKey,
baseURL,
defaults: {
thinking: config.thinking,
reasoningEffort: config.reasoningEffort,
},
}))
}

View File

@@ -0,0 +1,144 @@
/**
* Serialize harness vocabulary (`GenerateOptions`, `Message[]`) into the
* DeepSeek chat-completions request body.
*
* Block-type mapping (core types handled explicitly; merge-extensible unions
* mean plugin-added block types exist — they are skipped, never errors):
*
* - user `text` → string content (joined)
* - assistant `text` → `content`; `reasoning` → `reasoning_content`, but
* ONLY on assistant messages that carry tool calls (the official passback
* rule for thinking mode — required there, ignored elsewhere, so we save
* the tokens elsewhere); `tool-call` → `tool_calls[]`
* - `tool-result` → its own `{role: 'tool'}` message (text flattened)
* - `image` → skipped (MVP limitation, documented in the README)
*
* @module dsh-llm-deepseek/serialize
*/
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type { WireMessage, WireRequest, WireTool } from './types'
/** Adapter-level request defaults (from plugin config). */
export interface RequestDefaults {
thinking?: 'enabled' | 'disabled' | undefined
reasoningEffort?: 'high' | 'max' | undefined
}
/** Join the text blocks of a message (used for user/tool-result content). */
function flattenText(blocks: ContentBlock[]): string {
return blocks
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
/** Serialize one assistant message (text + reasoning + tool calls). */
function serializeAssistant(message: Message): WireMessage {
const text = flattenText(message.content)
const reasoning = message.content
.filter(block => block.type === 'reasoning')
.map(block => block.text)
.join('')
const toolCalls = message.content
.filter(block => block.type === 'tool-call')
.map(block => ({
id: block.id,
type: 'function' as const,
function: { name: block.name, arguments: block.arguments },
}))
return {
role: 'assistant',
// Tool-call turns send "" rather than null: the live API answers both,
// but the official samples replay message.content verbatim (which is ""
// for pure tool-call responses) and some gateways reject null outright.
content: text.length > 0 ? text : toolCalls.length > 0 ? '' : null,
// Official passback rule (guides/thinking_mode.mdx): reasoning_content
// must return on tool-call turns; it is ignored on plain turns, so we
// drop it there to save tokens.
...toolCalls.length > 0 && reasoning.length > 0 ? { reasoning_content: reasoning } : {},
...toolCalls.length > 0 ? { tool_calls: toolCalls } : {},
}
}
/**
* Serialize the conversation. `tool-result` blocks become standalone
* `{role: 'tool'}` messages; the harness puts each tool result in its own
* user-role message, so a mixed user message contributes its text first and
* its tool results as separate wire messages after.
*/
export function serializeMessages(messages: Message[]): WireMessage[] {
const wire: WireMessage[] = []
for (const message of messages) {
if (message.role === 'system') {
wire.push({ role: 'system', content: flattenText(message.content) })
continue
}
if (message.role === 'assistant') {
wire.push(serializeAssistant(message))
continue
}
// user role: tool results ride in user messages in the harness
// vocabulary, but DeepSeek wants them as role:'tool' messages.
const toolResults = message.content.filter(block => block.type === 'tool-result')
const text = flattenText(message.content)
if (text.length > 0 || toolResults.length === 0) {
wire.push({ role: 'user', content: text })
}
for (const result of toolResults) {
wire.push({
role: 'tool',
tool_call_id: result.toolCallId,
// Empty tool output still needs SOME content on the wire.
content: flattenText(result.content) || '(no output)',
})
}
}
return wire
}
/**
* Build the full wire request. Throws `LlmError('UNSUPPORTED')` for
* `prefill` (DeepSeek's chat-prefix completion is a Beta feature on a
* different base URL — see README).
*/
export function serializeRequest(options: GenerateOptions, defaults: RequestDefaults = {}): WireRequest {
if (options.prefill !== undefined) {
throw new LlmError(
'prefill is not supported by the DeepSeek adapter (Beta chat-prefix completion is future work)',
'UNSUPPORTED',
)
}
const messages: WireMessage[] = []
if (options.system !== undefined) {
messages.push({ role: 'system', content: options.system })
}
messages.push(...serializeMessages(options.messages))
const tools: WireTool[] | undefined = options.tools?.map(tool => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
// strict is officially supported (Beta); pass the tool author's choice.
...tool.strict !== undefined ? { strict: tool.strict } : {},
},
}))
return {
model: options.model,
messages,
stream: true,
stream_options: { include_usage: true },
...defaults.thinking !== undefined ? { thinking: { type: defaults.thinking } } : {},
...defaults.reasoningEffort !== undefined ? { reasoning_effort: defaults.reasoningEffort } : {},
...tools !== undefined && tools.length > 0 ? { tools } : {},
...options.temperature !== undefined ? { temperature: options.temperature } : {},
...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {},
...options.stop !== undefined ? { stop: options.stop } : {},
}
}

View File

@@ -0,0 +1,71 @@
/**
* Minimal SSE (text/event-stream) parser for the chat-completions stream.
*
* Yields each event's `data:` payload as a string, ending with the literal
* `'[DONE]'` sentinel so the consumer owns end-of-stream flushing. A stream
* that closes WITHOUT `[DONE]` is a protocol violation → `LlmError`.
*
* Handles the wire realities: payloads split across network reads at
* arbitrary byte positions (including mid-UTF-8), CRLF line endings,
* multi-`data:` events (joined with newlines per the SSE spec), comment
* lines, and non-data fields (ignored).
*
* @module dsh-llm-deepseek/sse
*/
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
* value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends
* without it (truncated response — the model call cannot be trusted).
*/
export async function* parseSse(stream: AsyncIterable<Uint8Array>): AsyncGenerator<string> {
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) {
yield data
if (data === DONE) return
}
throw new LlmError('SSE stream ended without [DONE]', 'STREAM_CLOSED')
}

View File

@@ -0,0 +1,169 @@
/**
* Translate DeepSeek wire chunks into the harness `StreamChunk` protocol.
*
* A small state machine over the SSE payload stream:
* - `delta.content` / `delta.reasoning_content` / `delta.tool_calls[i]` each
* own one harness block (index allocated on first sight). The first
* thinking-mode chunk carries `reasoning_content: ""` — that must NOT open
* a reasoning block.
* - `finish_reason` and `usage` are DEFERRED: emitted only at the `[DONE]`
* sentinel, so the wire's two usage shapes (attached to the finish chunk,
* or a trailing usage-only chunk) both work and nothing ever follows
* `finish`. Last usage wins.
*
* @module dsh-llm-deepseek/translate
*/
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import { DONE } from './sse'
import type { WireChunk, WireUsage } from './types'
/** One open block under assembly. */
interface OpenBlock {
index: number
kind: 'text' | 'reasoning' | 'tool-call'
text: string
/** tool-call only */
callId?: string
name?: string
}
/** Map the wire finish_reason vocabulary to the harness FinishReason. */
export function mapFinishReason(reason: string): FinishReason {
switch (reason) {
case 'stop': return { kind: 'stop' }
case 'tool_calls': return { kind: 'tool-calls' }
case 'length': return { kind: 'max-tokens' }
default:
// content_filter, insufficient_system_resource, future additions.
return { kind: 'error', message: `model stopped: ${reason}`, code: reason.toUpperCase() }
}
}
/**
* Map wire usage fields. DeepSeek's `prompt_tokens` INCLUDES cache hits
* (`prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens`,
* api/create-chat-completion); the harness TokenUsage convention is
* DISJOINT counts, so cache reads are subtracted out of `inputTokens`.
*/
export function mapUsage(usage: WireUsage): TokenUsage {
const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens
const reasoning = usage.completion_tokens_details?.reasoning_tokens
return {
inputTokens: usage.prompt_tokens - (cacheRead ?? 0),
outputTokens: usage.completion_tokens,
...cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {},
...reasoning !== undefined ? { reasoningTokens: reasoning } : {},
}
}
/** Assemble the final ContentBlock for one open block. */
function closeBlock(block: OpenBlock): ContentBlock {
switch (block.kind) {
case 'text': return { type: 'text', text: block.text }
case 'reasoning': return { type: 'reasoning', text: block.text }
case 'tool-call': return {
type: 'tool-call',
id: CallId(block.callId ?? ''),
name: block.name ?? '',
arguments: block.text,
}
}
}
/**
* Consume SSE data payloads (ending with `[DONE]`) and yield StreamChunks.
* Malformed JSON payloads abort the stream with `MALFORMED_RESPONSE`.
*/
export async function* translate(payloads: AsyncIterable<string>): AsyncGenerator<StreamChunk> {
let nextIndex = 0
let textBlock: OpenBlock | undefined
let reasoningBlock: OpenBlock | undefined
const toolBlocks = new Map<number, OpenBlock>()
const order: OpenBlock[] = []
let pendingFinish: FinishReason | undefined
let pendingUsage: TokenUsage | undefined
function open(kind: OpenBlock['kind']): OpenBlock {
const block: OpenBlock = { index: nextIndex++, kind, text: '' }
order.push(block)
return block
}
for await (const payload of payloads) {
if (payload === DONE) {
for (const block of order) {
yield { type: 'block-end', index: block.index, block: closeBlock(block) }
}
if (pendingUsage) yield { type: 'usage', usage: pendingUsage }
yield { type: 'finish', reason: pendingFinish ?? { kind: 'stop' } }
return
}
let chunk: WireChunk
try {
chunk = JSON.parse(payload) as WireChunk
} catch {
throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, 'MALFORMED_RESPONSE')
}
for (const choice of chunk.choices ?? []) {
const delta = choice.delta
// Reasoning first: thinking mode interleaves it before text. The
// empty-string first chunk must not open a block.
const reasoning = delta?.reasoning_content
if (typeof reasoning === 'string' && reasoning.length > 0) {
if (!reasoningBlock) {
reasoningBlock = open('reasoning')
yield { type: 'block-start', index: reasoningBlock.index, blockType: 'reasoning' }
}
reasoningBlock.text += reasoning
yield { type: 'reasoning-delta', index: reasoningBlock.index, text: reasoning }
}
const content = delta?.content
if (typeof content === 'string' && content.length > 0) {
if (!textBlock) {
textBlock = open('text')
yield { type: 'block-start', index: textBlock.index, blockType: 'text' }
}
textBlock.text += content
yield { type: 'text-delta', index: textBlock.index, text: content }
}
for (const call of delta?.tool_calls ?? []) {
let block = toolBlocks.get(call.index)
if (!block) {
block = open('tool-call')
toolBlocks.set(call.index, block)
yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
}
if (call.id !== undefined) block.callId = call.id
if (call.function?.name !== undefined) block.name = call.function.name
const fragment = call.function?.arguments ?? ''
block.text += fragment
yield {
type: 'tool-call-delta',
index: block.index,
id: CallId(block.callId ?? ''),
...block.name !== undefined ? { name: block.name } : {},
argumentsDelta: fragment,
}
}
if (typeof choice.finish_reason === 'string') {
pendingFinish = mapFinishReason(choice.finish_reason)
}
}
// Usage may arrive attached to the finish chunk or as a trailing
// usage-only chunk — keep the latest.
if (chunk.usage) pendingUsage = mapUsage(chunk.usage)
}
// parseSse guarantees the [DONE] sentinel (or throws); reaching here means
// the payload source violated that contract.
throw new LlmError('SSE payload stream ended without [DONE]', 'STREAM_CLOSED')
}

View File

@@ -0,0 +1,136 @@
/**
* DeepSeek chat-completions wire format (OpenAI-compatible). Types only.
*
* Source of truth: the official API docs at
* `~/repos/deepsuite-docs/apps/docs/docs` (api/create-chat-completion,
* guides/thinking_mode.mdx, guides/tool_calls.md), cross-checked against
* live streams from the internal endpoint (2026-06).
*
* @module dsh-llm-deepseek/types
*/
/** Request body for `POST {baseURL}/chat/completions`. */
export interface WireRequest {
model: string
messages: WireMessage[]
stream: true
stream_options: { include_usage: true }
/** Thinking-mode toggle (top level, NOT inside extra_body on the wire). */
thinking?: { type: 'enabled' | 'disabled' }
/** Thinking effort (official levels; low/medium map to high server-side). */
reasoning_effort?: 'high' | 'max'
tools?: WireTool[]
temperature?: number
max_tokens?: number
/**
* Stop sequences (OpenAI `stop`): generation halts as soon as the model
* produces any one of these strings. Mapped from `GenerateOptions.stop`.
*/
stop?: string[]
}
/** System-role message: a single string of instructions. */
export interface WireSystemMessage {
role: 'system'
content: string
}
/** User-role message: a single string of user input. */
export interface WireUserMessage {
role: 'user'
content: string
}
/** Tool-role message: the result of one tool call, keyed by its call id. */
export interface WireToolMessage {
role: 'tool'
tool_call_id: string
content: string
}
export type WireMessage =
| WireSystemMessage
| WireUserMessage
| WireAssistantMessage
| WireToolMessage
export interface WireAssistantMessage {
role: 'assistant'
content: string | null
/**
* CoT passback. REQUIRED on assistant turns that carried tool calls
* (thinking mode); ignored on tool-call-free turns (we omit it there to
* save tokens). See guides/thinking_mode.mdx § Tool Calls.
*/
reasoning_content?: string
tool_calls?: WireToolCall[]
}
export interface WireToolCall {
id: string
type: 'function'
function: { name: string; arguments: string }
}
export interface WireTool {
type: 'function'
function: {
name: string
description: string
parameters: Record<string, unknown>
/** Beta: strict schema adherence (official: requires the /beta base URL). */
strict?: boolean
}
}
/** One parsed SSE `data:` payload (a chat.completion.chunk). */
export interface WireChunk {
choices?: WireChoice[]
/** Arrives attached to the finish chunk and/or as a trailing usage-only chunk. */
usage?: WireUsage | null
}
export interface WireChoice {
delta?: WireDelta
finish_reason?: string | null
}
export interface WireDelta {
role?: string
/** Visible text. Null/empty on reasoning/tool-call chunks. */
content?: string | null
/**
* Thinking-mode CoT. The FIRST chunk carries an empty string (must not
* open a reasoning block); absent entirely in non-thinking mode.
*/
reasoning_content?: string | null
tool_calls?: WireToolCallDelta[]
}
export interface WireToolCallDelta {
/** Disambiguates parallel tool calls; stable across a call's deltas. */
index: number
/** Present on the first delta of each call only. */
id?: string
type?: 'function'
function?: {
/** Present on the first delta of each call only. */
name?: string
/** Argument JSON fragment (concatenate across deltas). */
arguments?: string
}
}
export interface WireUsage {
prompt_tokens: number
completion_tokens: number
prompt_cache_hit_tokens?: number
prompt_cache_miss_tokens?: number
prompt_tokens_details?: { cached_tokens?: number }
completion_tokens_details?: { reasoning_tokens?: number }
}
/** Non-2xx error body. */
export interface WireError {
error?: { message?: string; type?: string; code?: string }
}

View File

@@ -0,0 +1,149 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
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'
/**
* Real-API e2e for the hand-rolled 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).
*/
const FLASH = 'deepseek-v4-flash'
const PRO = 'deepseek-v4-pro'
const contexts: Context[] = []
async function harness(model: string, config: Partial<Config> = {}) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { models: [model], ...config })
return ctx
}
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
function ask(text: string): Message[] {
return [{ role: 'user', content: [{ type: 'text', text }] }]
}
function textOf(result: AssembledResult): string {
return result.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
const weatherTool: ToolSchema = {
name: 'get_weather',
description: 'Get the current weather for a city.',
parameters: {
type: 'object',
properties: { city: { type: 'string', description: 'City name' } },
required: ['city'],
},
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => {
it('flash + thinking disabled: plain text generation', async () => {
const ctx = await harness(FLASH, { 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')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
expect(result.usage?.inputTokens).toBeGreaterThan(0)
expect(result.usage?.outputTokens).toBeGreaterThan(0)
})
it('flash + thinking enabled (effort high): reasoning blocks + reasoning tokens', async () => {
const ctx = await harness(FLASH, { thinking: 'enabled', reasoningEffort: 'high' })
const result = await assemble(ctx,{
model: FLASH,
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
maxTokens: 2000,
})
expect(result.finish.kind).toBe('stop')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(true)
expect(textOf(result)).toContain('9.8')
expect(result.usage?.reasoningTokens).toBeGreaterThan(0)
})
it.each(['high', 'max'] as const)(
'pro + thinking enabled (effort %s): tool-call round trip with reasoning passback',
async (effort) => {
const ctx = await harness(PRO, { thinking: 'enabled', reasoningEffort: effort })
// Turn 1: the model must call the tool (and think before it).
const first = await assemble(ctx,{
model: PRO,
messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
tools: [weatherTool],
maxTokens: 2000,
})
expect(first.finish.kind).toBe('tool-calls')
const call = first.message.content.find(block => block.type === 'tool-call')
expect(call).toBeDefined()
expect(call!.name).toBe('get_weather')
expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string })
// Turn 2: send the tool result back WITH the assistant's reasoning
// block in history (the official thinking+tools passback rule).
const second = await assemble(ctx,{
model: PRO,
messages: [
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
{ role: 'assistant', content: first.message.content },
{
role: 'user',
content: [{
type: 'tool-result',
toolCallId: CallId(call!.id),
content: [{ type: 'text', text: 'Sunny, 22°C' }],
}],
},
],
tools: [weatherTool],
maxTokens: 2000,
})
expect(second.finish.kind).toBe('stop')
expect(textOf(second).toLowerCase()).toMatch(/sunny|22/)
},
)
it('pro + thinking disabled: plain generation without reasoning blocks', async () => {
const ctx = await harness(PRO, { thinking: 'disabled' })
const result = await assemble(ctx,{
model: PRO,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
})
expect(result.finish.kind).toBe('stop')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
})
it('streams raw chunks in protocol order', async () => {
const ctx = await harness(FLASH, { thinking: 'disabled' })
const kinds: string[] = []
for await (const chunk of ctx.llm.stream({
model: FLASH,
messages: ask('Count from 1 to 5, digits only.'),
maxTokens: 50,
})) {
kinds.push(chunk.type)
}
expect(kinds[0]).toBe('block-start')
expect(kinds.at(-1)).toBe('finish')
expect(kinds.filter(kind => kind === 'finish')).toHaveLength(1)
// usage precedes finish (deferred-emit contract)
expect(kinds.indexOf('usage')).toBeLessThan(kinds.indexOf('finish'))
})
})

View File

@@ -0,0 +1,310 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { LlmError } from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek'
import { assemble } from './assemble.ts'
/** One scripted behavior for the next request the mock server receives. */
type Behavior =
| { kind: 'sse'; events: string[]; delayMs?: number }
| { kind: 'http-error'; status: number; body: string; contentType?: string }
| { kind: 'close-early'; events: string[] }
interface MockServer {
url: string
/** Bodies of received requests, in order. */
requests: unknown[]
/** Header bags of received requests, in order (parallel to `requests`). */
headers: IncomingMessage['headers'][]
script: Behavior[]
close(): Promise<void>
}
const servers: Server[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
vi.unstubAllEnvs()
})
/** Local chat-completions stand-in: replays scripted behaviors per request. */
async function mockServer(script: Behavior[]): Promise<MockServer> {
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
requests.push(JSON.parse(body))
headers.push(request.headers)
const behavior = script.shift()
if (!behavior) {
response.writeHead(500).end('mock script exhausted')
return
}
if (behavior.kind === 'http-error') {
response.writeHead(behavior.status, { 'content-type': behavior.contentType ?? 'application/json' })
response.end(behavior.body)
return
}
response.writeHead(200, { 'content-type': 'text/event-stream' })
const write = (index: number): void => {
if (index >= behavior.events.length) {
if (behavior.kind === 'sse') response.end()
else response.destroy() // close-early: drop the socket mid-stream
return
}
response.write(`data: ${behavior.events[index]}\n\n`)
setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5)
}
write(0)
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return {
url: `http://127.0.0.1:${address.port}`,
requests,
headers,
script,
close: () => new Promise(resolve => server.close(() => { resolve() })),
}
}
const textEvents = [
'{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
'{"choices":[{"delta":{"content":"hello"}}]}',
'{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'[DONE]',
]
async function harness(baseURL: string, config: object = {}) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, models: ['deepseek-v4-flash'], ...config })
return ctx
}
describe('DeepSeekAdapter against a mock server', () => {
it('streams a text generation end to end through the assembler', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url)
const result = await assemble(ctx, {
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(result.finish).toEqual({ kind: 'stop' })
expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1 })
// The wire request carried the auth header contents we configured.
expect(server.requests[0]).toMatchObject({
model: 'deepseek-v4-flash',
stream: true,
stream_options: { include_usage: true },
})
// Attribution header identifies the harness to the provider.
expect(server.headers[0]?.['user-agent']).toMatch(/^deepseek-harness\//)
})
it('streams raw chunks through ctx.llm.stream', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }])
const ctx = await harness(server.url)
const kinds: string[] = []
for await (const chunk of ctx.llm.stream({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})) {
kinds.push(chunk.type)
}
expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish'])
})
it('forwards thinking config onto the wire', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' })
await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})
expect(server.requests[0]).toMatchObject({
thinking: { type: 'disabled' },
reasoning_effort: 'high',
})
})
it.each([
[401, 'AUTH'],
[403, 'AUTH'],
[429, 'RATE_LIMIT'],
[400, 'INVALID_REQUEST'],
[500, 'SERVER'],
[503, 'SERVER'],
])('maps HTTP %d to LlmError code %s with the body message', async (status, code) => {
const behavior: Behavior = {
kind: 'http-error',
status,
body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }),
}
const server = await mockServer([behavior, behavior, behavior])
const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(`failed with ${status}`)
await expect(
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).code),
).resolves.toBe(code)
// The numeric HTTP status is carried on the error for explicit handling.
await expect(
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).status),
).resolves.toBe(status)
})
it('keeps the status-line message for JSON error bodies without a message', async () => {
const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }])
const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/HTTP 500/)
})
it('keeps the status-line message for non-JSON error bodies', async () => {
const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }])
const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/HTTP 502/)
})
it('maps unusual statuses to HTTP_<status>', () => {
expect(httpErrorCode(418)).toBe('HTTP_418')
})
it('throws EMPTY_RESPONSE when the response has no body', async () => {
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(null, { status: 200 }),
)
try {
const iterate = async (): Promise<void> => {
for await (const _chunk of adapter.stream({ model: 'm', messages: [] })) { /* drain */ }
}
await expect(iterate()).rejects.toThrow(/no response body/)
} finally {
fetchSpy.mockRestore()
}
})
it('rejects with STREAM_CLOSED when the server drops mid-stream', async () => {
const server = await mockServer([{
kind: 'close-early',
events: ['{"choices":[{"delta":{"content":"par"}}]}'],
}])
const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/terminated|socket|without \[DONE\]/)
})
it('aborts mid-stream via the request signal', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 50 }])
const ctx = await harness(server.url)
const controller = new AbortController()
const pending = (async () => {
const chunks = []
for await (const chunk of ctx.llm.stream({
model: 'deepseek-v4-flash',
messages: [],
signal: controller.signal,
})) {
chunks.push(chunk)
}
return chunks
})()
setTimeout(() => { controller.abort() }, 30)
await expect(pending).rejects.toThrow()
})
})
describe('plugin registration and config', () => {
it('registers the configured models and unregisters on dispose (HMR safety)', async () => {
const server = await mockServer([])
const ctx = new Context()
await ctx.plugin(LlmService)
const fiber = await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: server.url,
models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
})
expect(ctx.llm.models().sort()).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
await fiber.dispose()
expect(ctx.llm.models()).toEqual([])
})
it('defaults the model list', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
expect(ctx.llm.models().sort()).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
})
it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1')
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {})
expect(ctx.llm.models().length).toBeGreaterThan(0)
})
it('throws a clear error when no API key is available', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {}))
.rejects.toThrow(/an API key is required/)
expect(ctx.llm.models()).toEqual([])
})
it('prefers explicit config over env for key and base URL', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url) // harness passes explicit config
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests).toHaveLength(1) // hit the explicit URL, not env
})
it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', models: ['deepseek-v4-flash'] })
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests).toHaveLength(1)
})
it('defaults to the public base URL without config or env', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'k')
vi.stubEnv('DEEPSEEK_BASE_URL', undefined)
const ctx = new Context()
await ctx.plugin(LlmService)
// Registration succeeds; no call is made (would hit api.deepseek.com).
await ctx.plugin(LlmDeepSeek, {})
expect(ctx.llm.models().length).toBeGreaterThan(0)
})
it('adapter is constructible directly for embedding', () => {
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
})
})

View File

@@ -0,0 +1,26 @@
/**
* Test helper: drive `ctx.llm.stream()` through a `BlockAssembler` and return
* the assembled message + usage + finish reason. This exercises the same
* streaming path production uses (the loop), rather than a service-level
* one-shot convenience method.
*/
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
export interface AssembledResult {
message: Message
usage?: TokenUsage
finish: FinishReason
}
export async function assemble(ctx: Context, options: GenerateOptions): Promise<AssembledResult> {
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
return {
message: assembler.message(),
...assembler.usage !== undefined ? { usage: assembler.usage } : {},
finish: assembler.finish,
}
}

View File

@@ -0,0 +1,210 @@
import { describe, expect, it } from 'vitest'
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek'
function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
return { model: 'deepseek-v4-flash', messages: [], ...overrides }
}
describe('serializeMessages', () => {
it('maps user text to string content', () => {
const wire = serializeMessages([
{ role: 'user', content: [{ type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }] },
])
expect(wire).toEqual([{ role: 'user', content: 'hello world' }])
})
it('maps system-role messages in history', () => {
const wire = serializeMessages([
{ role: 'system', content: [{ type: 'text', text: 'be brief' }] },
])
expect(wire).toEqual([{ role: 'system', content: 'be brief' }])
})
it('maps plain assistant text without reasoning_content', () => {
const wire = serializeMessages([
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'thinking…' },
{ type: 'text', text: 'answer' },
],
},
])
// Tool-call-free turn: reasoning is dropped (ignored by the API anyway).
expect(wire).toEqual([{ role: 'assistant', content: 'answer' }])
})
it('passes reasoning_content back on tool-call turns (official passback rule)', () => {
const wire = serializeMessages([
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'I should check the weather.' },
{ type: 'tool-call', id: CallId('call-1'), name: 'get_weather', arguments: '{"city":"Paris"}' },
],
},
])
expect(wire).toEqual([{
role: 'assistant',
// "" (not null) on tool-call turns — mirrors the official samples'
// verbatim message replay; some gateways reject null.
content: '',
reasoning_content: 'I should check the weather.',
tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }],
}])
})
it('serializes parallel tool calls in order', () => {
const wire = serializeMessages([
{
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('a'), name: 'one', arguments: '{}' },
{ type: 'tool-call', id: CallId('b'), name: 'two', arguments: '{}' },
],
},
])
const assistant = wire[0] as { tool_calls: { id: string }[] }
expect(assistant.tool_calls.map(call => call.id)).toEqual(['a', 'b'])
})
it('turns tool results into role:tool messages', () => {
const wire = serializeMessages([
{
role: 'user',
content: [{
type: 'tool-result',
toolCallId: CallId('call-1'),
content: [{ type: 'text', text: 'Sunny 22C' }],
}],
},
])
expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: 'Sunny 22C' }])
})
it('sends a sentinel for empty tool-result content', () => {
const wire = serializeMessages([
{
role: 'user',
content: [{ type: 'tool-result', toolCallId: CallId('call-1'), content: [] }],
},
])
expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: '(no output)' }])
})
it('splits mixed user text + tool results into separate wire messages', () => {
const wire = serializeMessages([
{
role: 'user',
content: [
{ type: 'text', text: 'context note' },
{ type: 'tool-result', toolCallId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }] },
],
},
])
expect(wire).toEqual([
{ role: 'user', content: 'context note' },
{ role: 'tool', tool_call_id: 'call-1', content: 'ok' },
])
})
it('skips image blocks (documented MVP limitation)', () => {
const wire = serializeMessages([
{ role: 'user', content: [{ type: 'image', url: 'data:image/png;base64,x' }, { type: 'text', text: 'see image' }] },
])
expect(wire).toEqual([{ role: 'user', content: 'see image' }])
})
it('emits an empty user message rather than dropping block-less messages', () => {
const wire = serializeMessages([{ role: 'user', content: [] }])
expect(wire).toEqual([{ role: 'user', content: '' }])
})
})
describe('serializeRequest', () => {
const history: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]
it('always streams with usage and maps the basics', () => {
const wire = serializeRequest(request({ messages: history }))
expect(wire).toEqual({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
stream_options: { include_usage: true },
})
})
it('prepends the system prompt', () => {
const wire = serializeRequest(request({ messages: history, system: 'be helpful' }))
expect(wire.messages[0]).toEqual({ role: 'system', content: 'be helpful' })
expect(wire.messages[1]).toEqual({ role: 'user', content: 'hi' })
})
it('maps sampling params and stop sequences', () => {
const wire = serializeRequest(request({ messages: history, temperature: 0.2, maxTokens: 100, stop: ['END'] }))
expect(wire.temperature).toBe(0.2)
expect(wire.max_tokens).toBe(100)
expect(wire.stop).toEqual(['END'])
})
it('maps tools with strict passthrough', () => {
const wire = serializeRequest(request({
messages: history,
tools: [
{ name: 'a', description: 'A', parameters: { type: 'object', properties: {} } },
{ name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true },
],
}))
expect(wire.tools).toEqual([
{ type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } },
{ type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true } },
])
})
it('omits an empty tools array', () => {
const wire = serializeRequest(request({ messages: history, tools: [] }))
expect(wire.tools).toBeUndefined()
})
it('applies adapter defaults for thinking and effort', () => {
const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled', reasoningEffort: 'max' })
expect(wire.thinking).toEqual({ type: 'enabled' })
expect(wire.reasoning_effort).toBe('max')
})
it('omits thinking fields when unset (provider default applies)', () => {
const wire = serializeRequest(request({ messages: history }))
expect(wire.thinking).toBeUndefined()
expect(wire.reasoning_effort).toBeUndefined()
})
it('rejects prefill with an UNSUPPORTED LlmError', () => {
expect(() => serializeRequest(request({ prefill: [{ type: 'text', text: 'Sure' }] })))
.toThrow(LlmError)
try {
serializeRequest(request({ prefill: [] }))
expect.unreachable()
} catch (error) {
expect((error as LlmError).code).toBe('UNSUPPORTED')
}
})
})
describe('review fixes: assistant content shapes', () => {
it('serializes a content-less, tool-call-less assistant message as null content', () => {
// Aborted/empty assistant turns: no text, no calls → null (the wire
// accepts it; "" is reserved for tool-call turns per the samples).
const wire = serializeMessages([{ role: 'assistant', content: [] }])
expect(wire).toEqual([{ role: 'assistant', content: null }])
})
it('serializes tool-call turns with empty string content, not null', () => {
const wire = serializeMessages([{
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c'), name: 'f', arguments: '{}' }],
}])
expect(wire[0]).toMatchObject({ content: '' })
})
})

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { LlmError } from '@deepseek-ai/dsh-llm'
import { DONE, parseSse } from '@deepseek-ai/dsh-llm-deepseek'
/** Build a byte stream from string fragments (fragments = network reads). */
async function* bytes(...fragments: (string | Uint8Array)[]): AsyncGenerator<Uint8Array> {
const encoder = new TextEncoder()
for (const fragment of fragments) {
yield typeof fragment === 'string' ? encoder.encode(fragment) : fragment
}
}
async function collect(stream: AsyncIterable<string>): Promise<string[]> {
const out: string[] = []
for await (const item of stream) out.push(item)
return out
}
describe('parseSse', () => {
it('parses simple events 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('throws STREAM_CLOSED when the stream ends without DONE', async () => {
await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(LlmError)
await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(/without \[DONE\]/)
})
it('throws STREAM_CLOSED for an empty stream', async () => {
await expect(collect(parseSse(bytes()))).rejects.toThrow(/without \[DONE\]/)
})
it('throws STREAM_CLOSED for a mid-event close', async () => {
await expect(collect(parseSse(bytes('data: {"a"')))).rejects.toThrow(/without \[DONE\]/)
})
it('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])
})
})

View File

@@ -0,0 +1,307 @@
import { describe, expect, it } from 'vitest'
import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { DONE, mapFinishReason, mapUsage, translate } from '@deepseek-ai/dsh-llm-deepseek'
async function* feed(...payloads: (string | object)[]): AsyncGenerator<string> {
for (const payload of payloads) {
yield typeof payload === 'string' ? payload : JSON.stringify(payload)
}
}
async function collect(stream: AsyncIterable<StreamChunk>): Promise<StreamChunk[]> {
const out: StreamChunk[] = []
for await (const chunk of stream) out.push(chunk)
return out
}
/** The live first-chunk signature: role + null content + EMPTY reasoning. */
const firstChunk = { choices: [{ delta: { role: 'assistant', content: null, reasoning_content: '' } }] }
describe('translate: text', () => {
it('streams a text block and defers finish to DONE', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'Hel' } }] },
{ choices: [{ delta: { content: 'lo' } }] },
{ choices: [{ delta: { content: '' }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 2 } },
DONE,
)))
expect(chunks).toEqual([
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'Hel' },
{ type: 'text-delta', index: 0, text: 'lo' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'Hello' } },
{ type: 'usage', usage: { inputTokens: 5, outputTokens: 2 } },
{ type: 'finish', reason: { kind: 'stop' } },
])
})
it('assembles into the message BlockAssembler expects', async () => {
const assembler = new BlockAssembler()
for await (const chunk of translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'hi' } }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
DONE,
))) {
assembler.push(chunk)
}
const result = { message: assembler.message(), finish: assembler.finish }
expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }])
expect(result.finish).toEqual({ kind: 'stop' })
})
})
describe('translate: reasoning', () => {
it('does NOT open a reasoning block for the empty first-chunk signature', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'plain' } }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
DONE,
)))
expect(chunks.some(chunk => chunk.type === 'block-start' && chunk.blockType === 'reasoning')).toBe(false)
})
it('streams reasoning then text as separate blocks', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: null, reasoning_content: 'think' } }] },
{ choices: [{ delta: { content: null, reasoning_content: 'ing' } }] },
{ choices: [{ delta: { content: 'answer', reasoning_content: null } }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
DONE,
)))
expect(chunks).toEqual([
{ type: 'block-start', index: 0, blockType: 'reasoning' },
{ type: 'reasoning-delta', index: 0, text: 'think' },
{ type: 'reasoning-delta', index: 0, text: 'ing' },
{ type: 'block-start', index: 1, blockType: 'text' },
{ type: 'text-delta', index: 1, text: 'answer' },
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'thinking' } },
{ type: 'block-end', index: 1, block: { type: 'text', text: 'answer' } },
{ type: 'finish', reason: { kind: 'stop' } },
])
})
it('treats an entirely absent reasoning_content field as non-thinking', async () => {
const chunks = await collect(translate(feed(
{ choices: [{ delta: { role: 'assistant', content: 'x' } }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
DONE,
)))
expect(chunks.filter(chunk => chunk.type === 'block-start')).toEqual([
{ type: 'block-start', index: 0, blockType: 'text' },
])
})
})
describe('translate: tool calls', () => {
it('reassembles a tool call from fragmented argument deltas (live capture shape)', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_00_x', type: 'function', function: { name: 'get_weather', arguments: '' } }] } }] },
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"city"' } }] } }] },
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: ': "Paris"}' } }] } }] },
{ choices: [{ delta: { content: '' }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 28, completion_tokens: 6 } },
DONE,
)))
expect(chunks).toEqual([
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '' },
{ type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '{"city"' },
{ type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: ': "Paris"}' },
{
type: 'block-end',
index: 0,
block: { type: 'tool-call', id: 'call_00_x', name: 'get_weather', arguments: '{"city": "Paris"}' },
},
{ type: 'usage', usage: { inputTokens: 28, outputTokens: 6 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
])
})
it('disambiguates parallel tool calls by wire index', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{
choices: [{
delta: {
tool_calls: [
{ index: 0, id: 'a', type: 'function', function: { name: 'one', arguments: '{}' } },
{ index: 1, id: 'b', type: 'function', function: { name: 'two', arguments: '' } },
],
},
}],
},
{ choices: [{ delta: { tool_calls: [{ index: 1, function: { arguments: '{}' } }] } }] },
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
DONE,
)))
const ends = chunks.filter(chunk => chunk.type === 'block-end')
expect(ends).toEqual([
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: 'a', name: 'one', arguments: '{}' } },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: 'b', name: 'two', arguments: '{}' } },
])
})
it('interleaves text and tool-call blocks with distinct indices', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'Checking.' } }] },
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f', arguments: '{}' } }] } }] },
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
DONE,
)))
const starts = chunks.filter(chunk => chunk.type === 'block-start')
expect(starts).toEqual([
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
])
})
})
describe('translate: finish and usage handling', () => {
it('takes usage from a trailing usage-only chunk (docs shape)', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'x' } }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }], usage: null },
{ choices: [], usage: { prompt_tokens: 9, completion_tokens: 1 } },
DONE,
)))
expect(chunks.at(-2)).toEqual({ type: 'usage', usage: { inputTokens: 9, outputTokens: 1 } })
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } })
})
it('last usage wins when both attached and trailing arrive', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } },
{ choices: [], usage: { prompt_tokens: 2, completion_tokens: 2 } },
DONE,
)))
const usage = chunks.find(chunk => chunk.type === 'usage')
expect(usage).toEqual({ type: 'usage', usage: { inputTokens: 2, outputTokens: 2 } })
})
it('defaults to finish stop when no finish_reason ever arrives', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'x' } }] },
DONE,
)))
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } })
})
it('omits the usage chunk when none arrived', async () => {
const chunks = await collect(translate(feed(firstChunk, DONE)))
expect(chunks.some(chunk => chunk.type === 'usage')).toBe(false)
})
it('handles chunks with no choices at all', async () => {
const chunks = await collect(translate(feed({}, DONE)))
expect(chunks).toEqual([{ type: 'finish', reason: { kind: 'stop' } }])
})
})
describe('translate: errors', () => {
it('throws MALFORMED_RESPONSE for invalid JSON payloads', async () => {
await expect(collect(translate(feed('{bad json')))).rejects.toThrow(LlmError)
await expect(collect(translate(feed('{bad json')))).rejects.toThrow(/malformed SSE payload/)
})
it('throws STREAM_CLOSED when the payload source ends without DONE', async () => {
await expect(collect(translate(feed(firstChunk)))).rejects.toThrow(/without \[DONE\]/)
})
})
describe('mapFinishReason', () => {
it.each([
['stop', { kind: 'stop' }],
['tool_calls', { kind: 'tool-calls' }],
['length', { kind: 'max-tokens' }],
])('maps %s', (wire, expected) => {
expect(mapFinishReason(wire)).toEqual(expected)
})
it.each(['content_filter', 'insufficient_system_resource', 'mystery_reason'])(
'maps %s to an error kind with the wire code',
(wire) => {
expect(mapFinishReason(wire)).toEqual({
kind: 'error',
message: `model stopped: ${wire}`,
code: wire.toUpperCase(),
})
},
)
})
describe('mapUsage', () => {
it('maps the full live-capture shape', () => {
expect(mapUsage({
prompt_tokens: 283,
completion_tokens: 69,
prompt_cache_hit_tokens: 256,
prompt_cache_miss_tokens: 27,
prompt_tokens_details: { cached_tokens: 256 },
completion_tokens_details: { reasoning_tokens: 24 },
})).toEqual({
// 283 wire prompt_tokens minus the 256 cached → 27 uncached input
// (TokenUsage counts are disjoint).
inputTokens: 27,
outputTokens: 69,
cacheReadTokens: 256,
reasoningTokens: 24,
})
})
it('falls back to prompt_cache_hit_tokens when details are absent', () => {
expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2, prompt_cache_hit_tokens: 8 }))
.toEqual({ inputTokens: 2, outputTokens: 2, cacheReadTokens: 8 })
})
it('omits optional fields when the wire omits them', () => {
expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2 }))
.toEqual({ inputTokens: 10, outputTokens: 2 })
})
})
describe('translate: defensive tool-call branches', () => {
it('handles deltas that never carry id or name (empty-string fallbacks)', async () => {
const chunks = await collect(translate(feed(
firstChunk,
// Hypothetical lenient wire: argument fragments with no id/name at all.
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{}' } }] } }] },
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
DONE,
)))
expect(chunks).toEqual([
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: '', name: '', arguments: '{}' } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
])
})
it('handles tool_call deltas with a function object but no arguments field', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f' } }] } }] },
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
DONE,
)))
expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', name: 'f', argumentsDelta: '' })
})
it('handles tool_call deltas with no function object at all', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c' }] } }] },
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
DONE,
)))
expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' })
})
})

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
}
]
}

View File

@@ -0,0 +1,38 @@
# @deepseek-ai/dsh-llm-pi-ai
DeepSeek adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) (the LLM library behind the pi agent).
## Why a second adapter exists
`@deepseek-ai/dsh-llm-deepseek` already talks to the same endpoint. This package is its **design-verification twin**: same models, same wire protocol, completely different internals — a unified LLM library with its own event vocabulary versus hand-rolled fetch/SSE. Anything the harness `StreamChunk` protocol cannot express for BOTH implementations is a core-vocabulary bug. The differences it exercised on purpose:
- pi-ai hands tool-call `arguments` around as **parsed objects**; the harness keeps raw JSON strings. The adapter patches replay payloads back to the original raw strings before sending them, and re-stringifies parsed output tool calls at `block-end`.
- pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses).
- pi-ai folds reasoning tokens into `usage.output`; there is no separate reasoning count to map.
- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, per-tool `strict`, omitted reasoning effort, raw replayed tool arguments).
## Config
Same shape as llm-deepseek (one-line swap in cordis.yml), with pi-ai's thinking-level vocabulary:
```yaml
- id: llm
name: '@deepseek-ai/dsh-llm-pi-ai'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
models: [deepseek-v4-flash, deepseek-v4-pro]
reasoning: high # off | high | xhigh (xhigh → wire 'max')
```
## Dependency weight
pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time dependencies. They are lazy-loaded — only the openai SDK actually loads for this adapter — but they do land in `node_modules`. Accepted for a package whose purpose is design verification.
## Limitations
Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, images are not representable, `tool_choice` is not mapped.
## Testing
Unit suites run against a local `node:http` mock SSE server (pi-ai's openai SDK happily talks to any base URL). Real-API coverage in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across all exposed reasoning levels (off/high/xhigh), the thinking+tools round trip, and a cross-adapter structural-equivalence check against llm-deepseek.

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-llm-pi-ai",
"description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.79.1",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,188 @@
/**
* `PiAiAdapter`: the `@earendil-works/pi-ai`-backed implementation of the
* harness LLM seam, pointed at a DeepSeek (OpenAI-compatible) endpoint.
*
* This adapter exists as a design-verification twin of
* `@deepseek-ai/dsh-llm-deepseek`: same models, same wire protocol,
* completely different internals (a unified LLM library with its own event
* vocabulary vs hand-rolled fetch/SSE). Anything the StreamChunk protocol
* cannot express for BOTH implementations is a core-vocabulary bug.
*
* @module dsh-llm-pi-ai/adapter
*/
import { stream as piStream } from '@earendil-works/pi-ai'
import type { Model } from '@earendil-works/pi-ai'
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm'
import { toPiContext, toStreamChunks } from './convert'
/** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */
export type PiAiReasoning = 'off' | 'high' | 'xhigh'
export interface PiAiAdapterOptions {
apiKey: string
baseURL: string
/** Thinking level applied to every request ('off' disables thinking). */
reasoning?: PiAiReasoning | undefined
}
/** Build the inline pi-ai model descriptor for one DeepSeek model name. */
export function buildModel(modelId: string, options: PiAiAdapterOptions): Model<'openai-completions'> {
return {
id: modelId,
name: modelId,
api: 'openai-completions',
provider: 'deepseek',
baseUrl: options.baseURL,
// Always true: pi-ai only emits the DeepSeek `thinking` field for
// reasoning-capable models, deriving enabled/disabled from whether a
// reasoningEffort option is passed. DeepSeek's provider default is
// ENABLED, so 'off' must send an explicit {type: 'disabled'} — which
// requires this flag to stay on.
reasoning: true,
// DeepSeek's official effort levels: high|max (xhigh maps to max).
thinkingLevelMap: { minimal: null, low: null, medium: null, high: 'high', xhigh: 'max' },
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 64_000,
compat: {
// Auto-detection only fires for *.deepseek.com base URLs; the internal
// endpoint (and test mocks) need these set explicitly.
thinkingFormat: 'deepseek',
requiresReasoningContentOnAssistantMessages: true,
supportsReasoningEffort: true,
// DeepSeek documents max_tokens (not OpenAI's max_completion_tokens).
maxTokensField: 'max_tokens',
},
}
}
type Payload = {
tools?: { function?: { name?: unknown; strict?: unknown } }[]
messages?: {
role?: unknown
tool_calls?: { id?: unknown; function?: { arguments?: unknown } }[]
}[]
reasoning_effort?: unknown
stop?: unknown
}
function rawToolArguments(options: GenerateOptions): Map<CallId, string> {
const raw = new Map<CallId, string>()
for (const message of options.messages) {
if (message.role !== 'assistant') continue
for (const block of message.content) {
if (block.type === 'tool-call') raw.set(block.id, block.arguments)
}
}
return raw
}
function strictByToolName(tools: ToolSchema[] | undefined): Map<string, boolean | undefined> {
return new Map((tools ?? []).map(tool => [tool.name, tool.strict]))
}
function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiAiReasoning | undefined): unknown {
/* v8 ignore next -- pi-ai onPayload always receives an object; tolerate unusual future hooks defensively */
if (typeof payload !== 'object' || payload === null) return payload
const body = payload as Payload
if (reasoning === undefined) {
delete body.reasoning_effort
}
if (options.stop !== undefined) {
body.stop = options.stop
}
const strictByName = strictByToolName(options.tools)
for (const tool of body.tools ?? []) {
/* v8 ignore next -- malformed pi-ai payload guard: real tool entries always carry function */
if (tool.function === undefined) continue
const name = tool.function.name
/* v8 ignore next -- malformed pi-ai payload guard: real function entries always carry a string name */
if (typeof name !== 'string') continue
const strict = strictByName.get(name)
if (strict === undefined) delete tool.function.strict
else tool.function.strict = strict
}
const rawById = rawToolArguments(options)
/* v8 ignore next -- defensive for non-chat payloads; OpenAI chat payloads always carry messages */
for (const message of body.messages ?? []) {
if (message.role !== 'assistant') continue
/* v8 ignore next -- assistant messages without tool_calls need no raw-argument patch */
for (const call of message.tool_calls ?? []) {
/* v8 ignore next -- malformed pi-ai payload guard: real tool calls always carry a string id */
if (typeof call.id !== 'string') continue
const raw = rawById.get(CallId(call.id))
/* v8 ignore next -- pi-ai always emits a function object for assistant tool_calls; guard malformed payloads defensively */
if (raw !== undefined && call.function !== undefined) call.function.arguments = raw
}
}
return body
}
/**
* pi-ai-backed adapter. One instance serves every registered model name.
*
* Implementation notes:
* - `onPayload` patches provider payload details pi-ai cannot express directly:
* stop sequences, per-tool strict, omitted reasoning effort, and raw replayed
* tool-call arguments.
* - `prefill` throws UNSUPPORTED (same contract as dsh-llm-deepseek).
* - pi-ai reports request failures as in-stream error events; convert.ts
* maps them to `finish {kind:'error'|'aborted'}` chunks rather than
* throwing — both are sanctioned StreamChunk error paths.
*/
export class PiAiAdapter extends LlmAdapter {
constructor(private readonly options: PiAiAdapterOptions) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
if (options.prefill !== undefined) {
throw new LlmError(
'prefill is not supported by the pi-ai adapter',
'UNSUPPORTED',
)
}
const model = buildModel(options.model, this.options)
// Undefined config means "provider default" (DeepSeek: thinking ENABLED),
// matching llm-deepseek's omission semantics. pi-ai derives the wire
// thinking toggle from whether reasoningEffort is passed, so undefined maps
// internally to 'high' to get `thinking: enabled`; patchPayload then removes
// `reasoning_effort` so the provider chooses its default effort.
const reasoning = this.options.reasoning ?? 'high'
// pi-ai's event stream has no iterator-return cancellation hook: if our
// consumer stops early (break / loop abort), the underlying HTTP stream
// would keep draining. Chain an internal controller onto the caller's
// signal and abort it when this generator exits for any reason.
const controller = new AbortController()
const onCallerAbort = (): void => { controller.abort(options.signal?.reason) }
if (options.signal?.aborted) controller.abort(options.signal.reason)
else options.signal?.addEventListener('abort', onCallerAbort, { once: true })
try {
const events = piStream(model, toPiContext(options), {
apiKey: this.options.apiKey,
...options.temperature !== undefined ? { temperature: options.temperature } : {},
...options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {},
signal: controller.signal,
...reasoning !== 'off' ? { reasoningEffort: reasoning } : {},
onPayload: payload => patchPayload(payload, options, this.options.reasoning),
maxRetries: 0,
})
yield* toStreamChunks(events)
} finally {
options.signal?.removeEventListener('abort', onCallerAbort)
controller.abort('consumer stopped streaming')
}
}
}

View File

@@ -0,0 +1,276 @@
/**
* Bidirectional mapping between the harness vocabulary and pi-ai's:
* `GenerateOptions`/`Message[]` → pi-ai `Context`, and pi-ai
* `AssistantMessageEvent`s → harness `StreamChunk`s.
*
* Vocabulary differences worth knowing (they are exactly why this adapter
* exists — an independent implementation stress-tests the StreamChunk
* protocol):
* - pi-ai tool-call `arguments` are PARSED OBJECTS; the harness keeps the
* raw JSON string. We parse on the way into pi-ai, patch provider payloads
* back to the original raw string in the adapter, and re-stringify on output.
* - pi-ai reports errors as in-stream `error` events (it never throws
* mid-stream); the harness expresses those as `finish {kind:'error'}` /
* `{kind:'aborted'}` chunks.
* - pi-ai folds reasoning tokens into `usage.output`; there is no separate
* reasoning count to map.
*
* @module dsh-llm-pi-ai/convert
*/
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
import type { FinishReason, GenerateOptions, Message, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import type {
AssistantMessage,
AssistantMessageEvent,
Context as PiContext,
Message as PiMessage,
Tool as PiTool,
Usage as PiUsage,
} from '@earendil-works/pi-ai'
/** Join the text blocks of a harness message. */
function flattenText(message: Message): string {
return message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
/** Parse tool-call argument JSON; tolerate model malformations with {}. */
function parseArguments(raw: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(raw)
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
// fall through
}
return {}
}
/**
* Convert harness history to a pi-ai Context. Tool results need the tool
* NAME (pi-ai's `toolName`), which the harness doesn't carry on the result
* block — it is recovered from the preceding assistant tool-call with the
* same id.
*/
export function toPiContext(options: GenerateOptions): PiContext {
const toolNames = new Map<CallId, string>()
const messages: PiMessage[] = []
for (const message of options.messages) {
if (message.role === 'system') {
// pi-ai has a single systemPrompt slot; in-history system messages are
// folded into user messages to preserve order (rare in practice — the
// harness sends the system prompt via options.system).
messages.push({ role: 'user', content: flattenText(message), timestamp: 0 })
continue
}
if (message.role === 'assistant') {
const content: AssistantMessage['content'] = []
for (const block of message.content) {
switch (block.type) {
case 'text':
content.push({ type: 'text', text: block.text })
break
case 'reasoning':
// thinkingSignature names the wire field pi-ai replays the CoT
// under. Without it pi-ai falls back to reasoning_content: ""
// (its requiresReasoningContentOnAssistantMessages shim), which
// violates DeepSeek's thinking-mode passback rule on tool-call
// turns (guides/thinking_mode.mdx § Tool Calls).
content.push({ type: 'thinking', thinking: block.text, thinkingSignature: 'reasoning_content' })
break
case 'tool-call':
toolNames.set(block.id, block.name)
content.push({
type: 'toolCall',
id: block.id,
name: block.name,
arguments: parseArguments(block.arguments),
})
break
default:
// image / plugin-added block types: not representable here.
break
}
}
messages.push({
role: 'assistant',
content,
api: 'openai-completions',
provider: 'deepseek',
model: options.model,
usage: emptyPiUsage(),
stopReason: content.some(piece => piece.type === 'toolCall') ? 'toolUse' : 'stop',
timestamp: 0,
})
continue
}
// user role: text + tool results (each result becomes its own message).
const text = flattenText(message)
const results = message.content.filter(block => block.type === 'tool-result')
if (text.length > 0 || results.length === 0) {
messages.push({ role: 'user', content: text, timestamp: 0 })
}
for (const result of results) {
messages.push({
role: 'toolResult',
toolCallId: result.toolCallId,
toolName: toolNames.get(result.toolCallId) ?? 'unknown',
content: [{
type: 'text',
text: result.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('') || '(no output)',
}],
isError: result.isError ?? false,
timestamp: 0,
})
}
}
const tools: PiTool[] | undefined = options.tools?.map(tool => ({
name: tool.name,
description: tool.description,
// ToolSchema.parameters is a JSON Schema object; pi-ai's TSchema
// (TypeBox) is structurally JSON Schema, so it assigns directly.
parameters: tool.parameters,
}))
return {
...options.system !== undefined ? { systemPrompt: options.system } : {},
messages,
...tools !== undefined && tools.length > 0 ? { tools } : {},
}
}
function emptyPiUsage(): PiUsage {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
}
}
/** Map pi-ai usage (reasoning folded into output by pi-ai). */
export function mapUsage(usage: PiUsage): TokenUsage {
return {
inputTokens: usage.input,
outputTokens: usage.output,
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
}
}
function classifyPiAiError(message: string): string {
if (/\b(?:401|403)\b/.test(message)) return 'AUTH'
if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT'
if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST'
if (/\b5\d\d\b/.test(message)) return 'SERVER'
return 'PI_AI_ERROR'
}
/** Map a terminal pi-ai event to the harness finish reason. */
export function mapStopReason(message: AssistantMessage): FinishReason {
switch (message.stopReason) {
case 'stop': return { kind: 'stop' }
case 'length': return { kind: 'max-tokens' }
case 'toolUse': return { kind: 'tool-calls' }
case 'aborted': return { kind: 'aborted' }
case 'error': {
const text = message.errorMessage ?? 'pi-ai stream error'
return { kind: 'error', message: text, code: classifyPiAiError(text) }
}
}
}
/**
* Translate the pi-ai event stream into StreamChunks. pi-ai never throws
* mid-stream — failures arrive as `error` events, which become error/aborted
* `finish` chunks (the harness protocol's other error-delivery style).
*/
export async function* toStreamChunks(events: AsyncIterable<AssistantMessageEvent>): AsyncGenerator<StreamChunk> {
// pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0
// in stream order), but we track ids per index for tool calls.
const toolIds = new Map<number, { id: string; name: string }>()
for await (const event of events) {
switch (event.type) {
case 'start':
break
case 'text_start':
yield { type: 'block-start', index: event.contentIndex, blockType: 'text' }
break
case 'text_delta':
yield { type: 'text-delta', index: event.contentIndex, text: event.delta }
break
case 'text_end':
yield { type: 'block-end', index: event.contentIndex, block: { type: 'text', text: event.content } }
break
case 'thinking_start':
yield { type: 'block-start', index: event.contentIndex, blockType: 'reasoning' }
break
case 'thinking_delta':
yield { type: 'reasoning-delta', index: event.contentIndex, text: event.delta }
break
case 'thinking_end':
yield { type: 'block-end', index: event.contentIndex, block: { type: 'reasoning', text: event.content } }
break
case 'toolcall_start': {
// The id/name live on the partial's content at this index.
const partial = event.partial.content[event.contentIndex]
const id = partial?.type === 'toolCall' ? partial.id : ''
const name = partial?.type === 'toolCall' ? partial.name : ''
toolIds.set(event.contentIndex, { id, name })
yield { type: 'block-start', index: event.contentIndex, blockType: 'tool-call' }
break
}
case 'toolcall_delta': {
const known = toolIds.get(event.contentIndex)
yield {
type: 'tool-call-delta',
index: event.contentIndex,
id: CallId(known?.id ?? ''),
...known?.name !== undefined && known.name.length > 0 ? { name: known.name } : {},
argumentsDelta: event.delta,
}
break
}
case 'toolcall_end':
yield {
type: 'block-end',
index: event.contentIndex,
block: {
type: 'tool-call',
id: CallId(event.toolCall.id),
name: event.toolCall.name,
// pi-ai hands back the PARSED arguments; the harness vocabulary
// keeps the raw string.
arguments: JSON.stringify(event.toolCall.arguments),
},
}
break
case 'done':
yield { type: 'usage', usage: mapUsage(event.message.usage) }
yield { type: 'finish', reason: mapStopReason(event.message) }
return
case 'error':
// In-stream error delivery (pi-ai's style) → error finish chunk
// (the harness's other sanctioned error path besides throwing).
yield { type: 'usage', usage: mapUsage(event.error.usage) }
yield { type: 'finish', reason: mapStopReason(event.error) }
return
// no default: AssistantMessageEvent is pi-ai's closed union; a new
// event type should fail compilation here via tsc's exhaustiveness
// when one is added (switch covers all current variants).
}
}
throw new LlmError('pi-ai event stream ended without done/error', 'STREAM_CLOSED')
}

View File

@@ -0,0 +1,71 @@
/**
* pi-ai-backed DeepSeek adapter plugin. Same Config shape as
* `@deepseek-ai/dsh-llm-deepseek` (one-line swap in cordis.yml), different
* implementation underneath — see `./adapter.ts` for why both exist.
*
* ```yaml
* - id: llm
* name: '@deepseek-ai/dsh-llm-pi-ai'
* config:
* apiKey: !!js process.env.DEEPSEEK_API_KEY
* baseURL: !!js process.env.DEEPSEEK_BASE_URL
* models: [deepseek-v4-flash, deepseek-v4-pro]
* reasoning: high
* ```
*
* @module @deepseek-ai/dsh-llm-pi-ai
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-llm'
import { PiAiAdapter } from './adapter'
import type { PiAiReasoning } from './adapter'
export { buildModel, PiAiAdapter } from './adapter'
export type { PiAiAdapterOptions, PiAiReasoning } from './adapter'
export { mapStopReason, mapUsage, toPiContext, toStreamChunks } from './convert'
export const name = 'llm-pi-ai'
export const inject = ['llm']
export interface Config {
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
apiKey?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
baseURL?: string
/** Model names to register (sent verbatim on the wire). */
models?: string[]
/**
* Thinking level for every request: 'off' disables thinking mode; 'high'
* and 'xhigh' (wire 'max') set the effort. Omitted = provider default
* (thinking enabled), matching llm-deepseek's omission semantics.
*/
reasoning?: PiAiReasoning
}
export const Config: z<Config> = z.object({
apiKey: z.string(),
baseURL: z.string(),
models: z.array(z.string()).default(['deepseek-v4-flash', 'deepseek-v4-pro']),
reasoning: z.union(['off', 'high', 'xhigh']),
})
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
export function apply(ctx: Context, config: Config): void {
const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY
if (apiKey === undefined || apiKey.length === 0) {
throw new Error('llm-pi-ai: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)')
}
const baseURL = config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL
// schemastery's .default() guarantees models is set after validation.
const models = config.models as string[]
ctx.llm.registerAdapter(models, new PiAiAdapter({
apiKey,
baseURL,
reasoning: config.reasoning,
}))
}

View File

@@ -0,0 +1,138 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import type { Config } from '@deepseek-ai/dsh-llm-pi-ai'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { assemble, type AssembledResult } from './assemble.ts'
/**
* Real-API e2e for the pi-ai-backed adapter: V4 Flash + V4 Pro across all
* reasoning levels the adapter exposes (off / high / xhigh→wire 'max').
* Mirrors the llm-deepseek matrix so the two independent implementations
* verify the same StreamChunk contract. Key-gated.
*/
const FLASH = 'deepseek-v4-flash'
const PRO = 'deepseek-v4-pro'
const contexts: Context[] = []
async function harness(model: string, config: Partial<Config> = {}) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { models: [model], ...config })
return ctx
}
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
function ask(text: string): Message[] {
return [{ role: 'user', content: [{ type: 'text', text }] }]
}
function textOf(result: AssembledResult): string {
return result.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
function blockKinds(result: AssembledResult): string[] {
return result.message.content.map(block => block.type)
}
const weatherTool: ToolSchema = {
name: 'get_weather',
description: 'Get the current weather for a city.',
parameters: {
type: 'object',
properties: { city: { type: 'string', description: 'City name' } },
required: ['city'],
},
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => {
it.each([FLASH, PRO])('%s + reasoning off: plain text generation', async (model) => {
const ctx = await harness(model, { reasoning: 'off' })
const result = await assemble(ctx,{
model,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
})
expect(result.finish.kind).toBe('stop')
expect(textOf(result).toLowerCase()).toContain('pong')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
})
it.each([FLASH, PRO])('%s + reasoning high: reasoning blocks present', async (model) => {
const ctx = await harness(model, { reasoning: 'high' })
const result = await assemble(ctx,{
model,
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
maxTokens: 2000,
})
expect(result.finish.kind).toBe('stop')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(true)
expect(textOf(result)).toContain('9.8')
})
it('pro + reasoning xhigh (wire max): tool-call round trip', async () => {
const ctx = await harness(PRO, { reasoning: 'xhigh' })
const first = await assemble(ctx,{
model: PRO,
messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
tools: [weatherTool],
maxTokens: 2000,
})
expect(first.finish.kind).toBe('tool-calls')
const call = first.message.content.find(block => block.type === 'tool-call')
expect(call).toBeDefined()
expect(call!.name).toBe('get_weather')
expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string })
const second = await assemble(ctx,{
model: PRO,
messages: [
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
{ role: 'assistant', content: first.message.content },
{
role: 'user',
content: [{
type: 'tool-result',
toolCallId: CallId(call!.id),
content: [{ type: 'text', text: 'Sunny, 22°C' }],
}],
},
],
tools: [weatherTool],
maxTokens: 2000,
})
expect(second.finish.kind).toBe('stop')
expect(textOf(second).toLowerCase()).toMatch(/sunny|22/)
})
it('produces the same block structure as llm-deepseek for the same prompt', async () => {
// Loose structural equivalence between the two independent adapters:
// same block KINDS in the same order for a deterministic prompt — the
// cross-implementation check that the StreamChunk design holds.
const deepseekCtx = new Context()
contexts.push(deepseekCtx)
await deepseekCtx.plugin(LlmService)
await deepseekCtx.plugin(LlmDeepSeek, { models: [FLASH], thinking: 'disabled' })
const piCtx = await harness(FLASH, { reasoning: 'off' })
const prompt = ask('Reply with exactly the word: pong')
const [fromDeepSeek, fromPiAi] = await Promise.all([
assemble(deepseekCtx, { model: FLASH, messages: prompt, maxTokens: 50 }),
assemble(piCtx, { model: FLASH, messages: prompt, maxTokens: 50 }),
])
expect(blockKinds(fromPiAi)).toEqual(blockKinds(fromDeepSeek))
expect(fromPiAi.finish.kind).toBe(fromDeepSeek.finish.kind)
})
})

View File

@@ -0,0 +1,403 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, LlmError } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { buildModel, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
import { assemble } from './assemble.ts'
/** Scripted SSE responses, one per request (OpenAI chat-completions shape). */
interface MockServer {
url: string
requests: unknown[]
close(): Promise<void>
}
const servers: Server[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
})
async function mockServer(script: { status?: number; events?: string[]; body?: string }[]): Promise<MockServer> {
const requests: unknown[] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
requests.push(JSON.parse(body))
const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
if (behavior.status !== undefined && behavior.status !== 200) {
response.writeHead(behavior.status, { 'content-type': 'application/json' })
response.end(behavior.body ?? '{}')
return
}
response.writeHead(200, { 'content-type': 'text/event-stream' })
for (const event of behavior.events ?? []) response.write(`data: ${event}\n\n`)
response.end()
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return {
url: `http://127.0.0.1:${address.port}`,
requests,
close: () => new Promise(resolve => server.close(() => { resolve() })),
}
}
const textEvents = [
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'[DONE]',
]
const toolEvents = [
'{"choices":[{"delta":{"role":"assistant","content":null},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"get_weather","arguments":""}}]},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"city\\":\\"Paris\\"}"}}]},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":20,"completion_tokens":6}}',
'[DONE]',
]
const thinkingEvents = [
'{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{"reasoning_content":"pondering"},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{"content":"answer","reasoning_content":null},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":9}}',
'[DONE]',
]
async function harness(baseURL: string, config: object = {}) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { apiKey: 'test-key', baseURL, models: ['deepseek-v4-flash'], ...config })
return ctx
}
describe('PiAiAdapter against a mock server', () => {
it('streams a text generation through the assembler', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url)
const result = await assemble(ctx, {
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(result.finish).toEqual({ kind: 'stop' })
expect(result.usage).toMatchObject({ inputTokens: 3, outputTokens: 1 })
})
it('streams tool calls with re-stringified arguments', async () => {
const server = await mockServer([{ events: toolEvents }])
const ctx = await harness(server.url)
const result = await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'weather?' }] }],
tools: [{
name: 'get_weather',
description: 'Get weather',
parameters: { type: 'object', properties: { city: { type: 'string' } } },
}],
})
expect(result.finish).toEqual({ kind: 'tool-calls' })
const call = result.message.content.find(block => block.type === 'tool-call')
expect(call).toMatchObject({ name: 'get_weather', arguments: '{"city":"Paris"}' })
})
it('maps reasoning_content streams to reasoning blocks', async () => {
const server = await mockServer([{ events: thinkingEvents }])
const ctx = await harness(server.url, { reasoning: 'high' })
const result = await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'think' }] }],
})
expect(result.message.content).toEqual([
{ type: 'reasoning', text: 'pondering' },
{ type: 'text', text: 'answer' },
])
})
it('sends DeepSeek thinking fields when reasoning is configured', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url, { reasoning: 'xhigh' })
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests[0]).toMatchObject({
thinking: { type: 'enabled' },
reasoning_effort: 'max', // xhigh maps to max via thinkingLevelMap
})
})
it('disables thinking for reasoning: off', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url, { reasoning: 'off' })
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests[0]).toMatchObject({ thinking: { type: 'disabled' } })
})
it('injects stop sequences through onPayload', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url)
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [], stop: ['END'] })
expect(server.requests[0]).toMatchObject({ stop: ['END'] })
})
it('preserves per-tool strict exactly through onPayload', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url)
await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [],
tools: [
{ name: 'strict_true', description: 'true', parameters: {}, strict: true },
{ name: 'strict_false', description: 'false', parameters: {}, strict: false },
{ name: 'strict_omitted', description: 'omitted', parameters: {} },
],
})
const request = server.requests[0] as { tools: { function: { name: string; strict?: boolean } }[] }
expect(request.tools.map(tool => [tool.function.name, tool.function.strict])).toEqual([
['strict_true', true],
['strict_false', false],
['strict_omitted', undefined],
])
expect('strict' in request.tools[2]!.function).toBe(false)
})
it('preserves raw replayed tool-call arguments in the provider payload', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url)
await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [{
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('broken'), name: 'f', arguments: '{broken' }],
}],
})
const request = server.requests[0] as { messages: { role: string; tool_calls?: { id: string; function: { arguments: string } }[] }[] }
const assistant = request.messages.find(message => message.role === 'assistant')
expect(assistant?.tool_calls?.[0]?.function.arguments).toBe('{broken')
})
it('maps HTTP errors to error finish chunks (pi-ai in-stream style)', async () => {
const server = await mockServer([{
status: 401,
body: JSON.stringify({ error: { message: 'bad key' } }),
}])
const ctx = await harness(server.url)
const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toMatchObject({ kind: 'error', code: 'AUTH' })
expect((result.finish as { message: string }).message).toMatch(/bad key|401/)
})
it.each([
[400, 'INVALID_REQUEST'],
[429, 'RATE_LIMIT'],
[500, 'SERVER'],
] as const)('maps HTTP %s to stable error code %s', async (status, code) => {
const server = await mockServer([{ status, body: JSON.stringify({ error: { message: `provider ${status}` } }) }])
const ctx = await harness(server.url)
const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toMatchObject({ kind: 'error', code })
})
it('rejects prefill with UNSUPPORTED', async () => {
const ctx = await harness('http://127.0.0.1:1')
await expect(assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [],
prefill: [{ type: 'text', text: 'Sure' }],
})).rejects.toThrow(LlmError)
})
it('registers/unregisters models on the llm service (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const fiber = await ctx.plugin(LlmPiAi, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
expect(ctx.llm.models().sort()).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
await fiber.dispose()
expect(ctx.llm.models()).toEqual([])
})
it('throws a clear error when no API key is available', async () => {
const previous = process.env.DEEPSEEK_API_KEY
delete process.env.DEEPSEEK_API_KEY
try {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmPiAi, {})).rejects.toThrow(/an API key is required/)
} finally {
if (previous !== undefined) process.env.DEEPSEEK_API_KEY = previous
}
})
})
describe('option spreads and env fallbacks', () => {
it('forwards temperature, maxTokens, and signal', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url)
const controller = new AbortController()
await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [],
temperature: 0.5,
maxTokens: 40,
signal: controller.signal,
})
expect(server.requests[0]).toMatchObject({ temperature: 0.5, max_tokens: 40 })
})
it('falls back to DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL env vars', async () => {
const server = await mockServer([{ events: textEvents }])
vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
try {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { models: ['deepseek-v4-flash'] })
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests).toHaveLength(1)
} finally {
vi.unstubAllEnvs()
}
})
it('defaults to the public base URL without config or env', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'k')
vi.stubEnv('DEEPSEEK_BASE_URL', undefined)
try {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {})
expect(ctx.llm.models().length).toBeGreaterThan(0)
} finally {
vi.unstubAllEnvs()
}
})
})
describe('buildModel', () => {
it('builds a DeepSeek-compat openai-completions model descriptor', () => {
const model = buildModel('deepseek-v4-pro', { apiKey: 'k', baseURL: 'http://x', reasoning: 'high' })
expect(model).toMatchObject({
id: 'deepseek-v4-pro',
api: 'openai-completions',
provider: 'deepseek',
baseUrl: 'http://x',
reasoning: true,
compat: { thinkingFormat: 'deepseek', requiresReasoningContentOnAssistantMessages: true },
})
})
it('keeps reasoning true even for off (pi-ai gates the thinking field on it)', () => {
// 'off' yields {thinking: {type: 'disabled'}} on the wire — pi-ai only
// emits the field at all when model.reasoning is true.
expect(buildModel('m', { apiKey: 'k', baseURL: 'http://x', reasoning: 'off' }).reasoning).toBe(true)
})
it('adapter is constructible directly for embedding', () => {
expect(new PiAiAdapter({ apiKey: 'k', baseURL: 'http://x' })).toBeInstanceOf(PiAiAdapter)
})
})
describe('review fixes', () => {
it('defaults omitted reasoning config to thinking ENABLED (provider default)', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url) // no reasoning key at all
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
const request = server.requests[0] as Record<string, unknown>
expect(request.thinking).toEqual({ type: 'enabled' })
expect('reasoning_effort' in request).toBe(false)
})
it('replays reasoning_content on assistant tool-call turns (passback rule)', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url)
await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [
{ role: 'user', content: [{ type: 'text', text: 'weather?' }] },
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'I should check.' },
{ type: 'tool-call', id: CallId('c1'), name: 'get_weather', arguments: '{"city":"Paris"}' },
],
},
{
role: 'user',
content: [{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'Sunny' }] }],
},
],
})
const request = server.requests[0] as { messages: { role: string; reasoning_content?: string }[] }
const assistant = request.messages.find(message => message.role === 'assistant')
expect(assistant?.reasoning_content).toBe('I should check.')
})
it('aborts the upstream request when the consumer stops streaming early', async () => {
// Slow server: write one chunk, then hold the connection open and record
// whether the socket closes (the adapter must cancel on early break).
let socketClosed = false
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
request.on('data', () => undefined)
request.on('end', () => {
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.write(`data: ${textEvents[0]}\n\n`)
response.write(`data: ${textEvents[1]}\n\n`)
// never finish; rely on client abort
request.socket.on('close', () => { socketClosed = true })
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
const ctx = await harness(`http://127.0.0.1:${address.port}`)
for await (const chunk of ctx.llm.stream({ model: 'deepseek-v4-flash', messages: [] })) {
if (chunk.type === 'text-delta') break // stop early mid-stream
}
// The finally-abort must reach the server as a closed socket.
await vi.waitFor(() => { expect(socketClosed).toBe(true) }, { timeout: 5_000 })
})
})
describe('review fixes: abort wiring', () => {
it('honors a pre-aborted caller signal', async () => {
const ctx = await harness('http://127.0.0.1:1')
const controller = new AbortController()
controller.abort('already cancelled')
// pi-ai surfaces the abort as an in-stream error event → aborted finish.
const result = await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [],
signal: controller.signal,
})
expect(result.finish.kind).toBe('aborted')
})
it('propagates a mid-stream caller abort to the upstream request', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url)
const controller = new AbortController()
const pending = assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [],
signal: controller.signal,
})
controller.abort()
const result = await pending
// Either the abort lands before any chunk (aborted) or after the tiny
// mock stream finished (stop) — both are valid races; never a hang.
expect(['aborted', 'stop']).toContain(result.finish.kind)
})
})

View File

@@ -0,0 +1,26 @@
/**
* Test helper: drive `ctx.llm.stream()` through a `BlockAssembler` and return
* the assembled message + usage + finish reason. This exercises the same
* streaming path production uses (the loop), rather than a service-level
* one-shot convenience method.
*/
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
export interface AssembledResult {
message: Message
usage?: TokenUsage
finish: FinishReason
}
export async function assemble(ctx: Context, options: GenerateOptions): Promise<AssembledResult> {
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
return {
message: assembler.message(),
...assembler.usage !== undefined ? { usage: assembler.usage } : {},
finish: assembler.finish,
}
}

View File

@@ -0,0 +1,336 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai'
import { mapStopReason, mapUsage, toPiContext, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai'
function usage(input = 0, output = 0, cacheRead = 0, cacheWrite = 0): Usage {
return {
input,
output,
cacheRead,
cacheWrite,
totalTokens: input + output + cacheRead + cacheWrite,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
}
}
function assistant(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
return {
role: 'assistant',
content: [],
api: 'openai-completions',
provider: 'deepseek',
model: 'deepseek-v4-flash',
usage: usage(),
stopReason: 'stop',
timestamp: 0,
...overrides,
}
}
async function* feed(...events: AssistantMessageEvent[]): AsyncGenerator<AssistantMessageEvent> {
for (const event of events) yield event
}
async function collect(stream: AsyncIterable<StreamChunk>): Promise<StreamChunk[]> {
const out: StreamChunk[] = []
for await (const chunk of stream) out.push(chunk)
return out
}
describe('toPiContext', () => {
it('maps system prompt, user text, and tools', () => {
const context = toPiContext({
model: 'deepseek-v4-flash',
system: 'be helpful',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
tools: [{ name: 'f', description: 'F', parameters: { type: 'object', properties: {} } }],
})
expect(context.systemPrompt).toBe('be helpful')
expect(context.messages).toEqual([{ role: 'user', content: 'hi', timestamp: 0 }])
expect(context.tools).toEqual([
{ name: 'f', description: 'F', parameters: { type: 'object', properties: {} } },
])
})
it('omits empty tools and absent system prompt', () => {
const context = toPiContext({ model: 'm', messages: [], tools: [] })
expect(context.systemPrompt).toBeUndefined()
expect(context.tools).toBeUndefined()
})
it('maps assistant text/reasoning/tool-call blocks', () => {
const context = toPiContext({
model: 'm',
messages: [{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'hmm' },
{ type: 'text', text: 'calling' },
{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{"a":1}' },
],
}],
})
const message = context.messages[0] as AssistantMessage
expect(message.role).toBe('assistant')
expect(message.stopReason).toBe('toolUse')
expect(message.content).toEqual([
// thinkingSignature names the replay field — DeepSeek's passback rule.
{ type: 'thinking', thinking: 'hmm', thinkingSignature: 'reasoning_content' },
{ type: 'text', text: 'calling' },
{ type: 'toolCall', id: 'c1', name: 'f', arguments: { a: 1 } },
])
})
it('marks tool-call-free assistant messages with stopReason stop', () => {
const context = toPiContext({
model: 'm',
messages: [{ role: 'assistant', content: [{ type: 'text', text: 'done' }] }],
})
expect((context.messages[0] as AssistantMessage).stopReason).toBe('stop')
})
it('parses malformed tool-call arguments to {}', () => {
const context = toPiContext({
model: 'm',
messages: [{
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{broken' }],
}],
})
const message = context.messages[0] as AssistantMessage
expect(message.content[0]).toEqual({ type: 'toolCall', id: 'c1', name: 'f', arguments: {} })
})
it('parses non-object argument JSON (arrays, scalars) to {}', () => {
const context = toPiContext({
model: 'm',
messages: [{
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '[1,2]' }],
}],
})
expect((context.messages[0] as AssistantMessage).content[0]).toMatchObject({ arguments: {} })
})
it('recovers toolName for tool results from the preceding assistant call', () => {
const context = toPiContext({
model: 'm',
messages: [
{
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c1'), name: 'get_weather', arguments: '{}' }],
},
{
role: 'user',
content: [{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'Sunny' }] }],
},
],
})
expect(context.messages[1]).toEqual({
role: 'toolResult',
toolCallId: 'c1',
toolName: 'get_weather',
content: [{ type: 'text', text: 'Sunny' }],
isError: false,
timestamp: 0,
})
})
it('labels unmatched tool results with toolName unknown and keeps isError', () => {
const context = toPiContext({
model: 'm',
messages: [{
role: 'user',
content: [{ type: 'tool-result', toolCallId: CallId('zz'), content: [], isError: true }],
}],
})
expect(context.messages[0]).toMatchObject({
role: 'toolResult',
toolName: 'unknown',
isError: true,
content: [{ type: 'text', text: '(no output)' }],
})
})
it('splits mixed user text + tool results and folds history system messages', () => {
const context = toPiContext({
model: 'm',
messages: [
{ role: 'system', content: [{ type: 'text', text: 'rule' }] },
{
role: 'user',
content: [
{ type: 'text', text: 'note' },
{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] },
],
},
],
})
expect(context.messages.map(message => message.role)).toEqual(['user', 'user', 'toolResult'])
})
it('skips image and unknown blocks in assistant content', () => {
const context = toPiContext({
model: 'm',
messages: [{
role: 'assistant',
content: [
{ type: 'image', url: 'data:,x' },
{ type: 'text', text: 'visible' },
],
}],
})
expect((context.messages[0] as AssistantMessage).content).toEqual([{ type: 'text', text: 'visible' }])
})
})
describe('toStreamChunks', () => {
const partialWithToolCall = assistant({
content: [{ type: 'toolCall', id: 'call-1', name: 'f', arguments: {} }],
})
it('maps text events to text blocks', async () => {
const done = assistant({ content: [{ type: 'text', text: 'hi' }], usage: usage(3, 2) })
const chunks = await collect(toStreamChunks(feed(
{ type: 'start', partial: assistant() },
{ type: 'text_start', contentIndex: 0, partial: assistant() },
{ type: 'text_delta', contentIndex: 0, delta: 'hi', partial: assistant() },
{ type: 'text_end', contentIndex: 0, content: 'hi', partial: assistant() },
{ type: 'done', reason: 'stop', message: done },
)))
expect(chunks).toEqual([
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'hi' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } },
{ type: 'usage', usage: { inputTokens: 3, outputTokens: 2 } },
{ type: 'finish', reason: { kind: 'stop' } },
])
})
it('maps thinking events to reasoning blocks', async () => {
const chunks = await collect(toStreamChunks(feed(
{ type: 'thinking_start', contentIndex: 0, partial: assistant() },
{ type: 'thinking_delta', contentIndex: 0, delta: 'mull', partial: assistant() },
{ type: 'thinking_end', contentIndex: 0, content: 'mull', partial: assistant() },
{ type: 'done', reason: 'stop', message: assistant() },
)))
expect(chunks.slice(0, 3)).toEqual([
{ type: 'block-start', index: 0, blockType: 'reasoning' },
{ type: 'reasoning-delta', index: 0, text: 'mull' },
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'mull' } },
])
})
it('maps tool-call events, re-stringifying parsed arguments', async () => {
const chunks = await collect(toStreamChunks(feed(
{ type: 'toolcall_start', contentIndex: 0, partial: partialWithToolCall },
{ type: 'toolcall_delta', contentIndex: 0, delta: '{"a"', partial: partialWithToolCall },
{ type: 'toolcall_delta', contentIndex: 0, delta: ':1}', partial: partialWithToolCall },
{
type: 'toolcall_end',
contentIndex: 0,
toolCall: { type: 'toolCall', id: 'call-1', name: 'f', arguments: { a: 1 } },
partial: partialWithToolCall,
},
{ type: 'done', reason: 'toolUse', message: assistant({ stopReason: 'toolUse' }) },
)))
expect(chunks).toEqual([
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: 'call-1', name: 'f', argumentsDelta: '{"a"' },
{ type: 'tool-call-delta', index: 0, id: 'call-1', name: 'f', argumentsDelta: ':1}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: 'call-1', name: 'f', arguments: '{"a":1}' } },
{ type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
])
})
it('tolerates toolcall_start with a missing partial entry', async () => {
const chunks = await collect(toStreamChunks(feed(
{ type: 'toolcall_start', contentIndex: 0, partial: assistant() },
{ type: 'toolcall_delta', contentIndex: 0, delta: '{}', partial: assistant() },
{ type: 'done', reason: 'stop', message: assistant() },
)))
expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' })
})
it('maps error events to error finish chunks (in-stream error style)', async () => {
const error = assistant({ stopReason: 'error', errorMessage: 'boom', usage: usage(1, 0) })
const chunks = await collect(toStreamChunks(feed(
{ type: 'error', reason: 'error', error },
)))
expect(chunks).toEqual([
{ type: 'usage', usage: { inputTokens: 1, outputTokens: 0 } },
{ type: 'finish', reason: { kind: 'error', message: 'boom', code: 'PI_AI_ERROR' } },
])
})
it('maps aborted error events to aborted finish', async () => {
const error = assistant({ stopReason: 'aborted' })
const chunks = await collect(toStreamChunks(feed({ type: 'error', reason: 'aborted', error })))
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'aborted' } })
})
it('rejects a stream that ends without done or error', async () => {
await expect(collect(toStreamChunks(feed({ type: 'start', partial: assistant() }))))
.rejects.toThrow(/without done\/error/)
})
})
describe('mapStopReason / mapUsage', () => {
it.each([
['stop', { kind: 'stop' }],
['length', { kind: 'max-tokens' }],
['toolUse', { kind: 'tool-calls' }],
['aborted', { kind: 'aborted' }],
] as const)('maps %s', (stopReason, expected) => {
expect(mapStopReason(assistant({ stopReason }))).toEqual(expected)
})
it('defaults the error message when pi-ai omits it', () => {
expect(mapStopReason(assistant({ stopReason: 'error' })))
.toEqual({ kind: 'error', message: 'pi-ai stream error', code: 'PI_AI_ERROR' })
})
it('maps routable HTTP-ish error messages to stable codes', () => {
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 401: bad key' })))
.toMatchObject({ kind: 'error', code: 'AUTH' })
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 429: rate limit' })))
.toMatchObject({ kind: 'error', code: 'RATE_LIMIT' })
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 500: backend down' })))
.toMatchObject({ kind: 'error', code: 'SERVER' })
})
it('maps cache fields only when nonzero', () => {
expect(mapUsage(usage(10, 5, 8, 2))).toEqual({
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 8,
cacheWriteTokens: 2,
})
expect(mapUsage(usage(10, 5))).toEqual({ inputTokens: 10, outputTokens: 5 })
})
})
describe('toStreamChunks edge branches', () => {
it('omits the name field for tool calls whose partial carried an empty name', async () => {
const blank = assistant({ content: [{ type: 'toolCall', id: 'x', name: '', arguments: {} }] })
const chunks = await collect(toStreamChunks(feed(
{ type: 'toolcall_start', contentIndex: 0, partial: blank },
{ type: 'toolcall_delta', contentIndex: 0, delta: '{}', partial: blank },
{ type: 'done', reason: 'stop', message: assistant() },
)))
expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'x', argumentsDelta: '{}' })
})
})
describe('toStreamChunks defensive branches', () => {
it('tolerates a toolcall_delta with no preceding toolcall_start', async () => {
const chunks = await collect(toStreamChunks(feed(
{ type: 'toolcall_delta', contentIndex: 0, delta: '{}', partial: assistant() },
{ type: 'done', reason: 'stop', message: assistant() },
)))
expect(chunks[0]).toEqual({ type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' })
})
})

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
}
]
}

View File

@@ -0,0 +1,41 @@
# dsh-llm
Provider-neutral LLM vocabulary and abstract service. This package defines the canonical language spoken by the agent loop, session logs, and every plugin.
## Service: `LlmService` (ctx key: `llm`)
An adapter registry plus a single streaming call surface, interceptable via a waterfall event.
### Public API
- `ctx.llm.registerAdapter(models: string[], adapter: LlmAdapter): () => void` Register an adapter for the given model names. Disposed with the calling fiber.
- `ctx.llm.models(): string[]` — model names with a registered adapter.
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
### Events
| Event | Mode | Purpose |
|---|---|---|
| `llm/stream` | waterfall | Intercept/wrap every streaming model call (retry, caching, routing) |
### Extension points
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(models, adapter)` to add a new model provider.
- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc.
### Content-block vocabulary (`types.ts`)
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`, `image`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging.
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
### Classes
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history.
- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams.
- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response.
### Real adapters
Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths).

View File

@@ -22,9 +22,11 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -1,13 +1,14 @@
/**
* Incremental chunk-to-message assembler. This is the single canonical assembly
* algorithm used by both the agent loop and the LLM service convenience views.
* algorithm used by the agent loop to build an assistant message from a chunk
* stream while logging the raw chunks for replay fidelity.
*
* @module @deepseek-ai/dsh-llm/assembler
*/
import { CallId } from './brand'
import { assertNever } from './never'
import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types'
import { CallId } from './brand.ts'
import { assertNever } from './never.ts'
import type { ContentBlock, FinishReason, Message, StreamChunk, TokenUsage } from './types'
interface PartialBlock {
blockType: string
@@ -23,9 +24,8 @@ interface PartialBlock {
* Incrementally assembles raw {@link StreamChunk}s into complete
* {@link ContentBlock}s and a final assistant {@link Message}.
*
* This is the single shared assembly implementation: the agent loop feeds it
* while logging raw chunks for replay fidelity, and `LlmService.generate()` /
* `streamBlocks()` use it to offer assembled views of the same stream.
* The agent loop feeds it while logging raw chunks for replay fidelity, then
* reads `blocks()` / `message()` / `usage` / `finish` once the stream ends.
*
* Tolerant of delta-only protocols (no block-start/end); deltas arriving for
* an index already closed by `block-end` are ignored (malformed stream) so a
@@ -34,7 +34,6 @@ interface PartialBlock {
export class BlockAssembler {
private partials = new Map<number, PartialBlock>()
private order: number[] = []
private flushed = 0
private _usage: TokenUsage | undefined
private _finish: FinishReason | undefined
@@ -129,44 +128,6 @@ export class BlockAssembler {
return this.order.map(index => this.assemble(this.mustGet(index), index))
}
/**
* Streaming flush: returns (once) every block that is complete AND has no
* incomplete block before it in stream order. Call after each `push()`;
* blocks come out strictly in stream order, so a streaming consumer sees
* exactly the sequence `blocks()` would produce.
*/
flushReady(): ContentBlock[] {
const ready: ContentBlock[] = []
while (this.flushed < this.order.length) {
const index = this.order[this.flushed]
/* v8 ignore next 3 -- noUncheckedIndexedAccess guard: loop condition guarantees index exists in a non-empty array */
if (index === undefined) break
const partial = this.mustGet(index)
if (!partial.block) break
ready.push(partial.block)
this.flushed += 1
}
return ready
}
/**
* End-of-stream flush: returns (once) all not-yet-flushed blocks, in stream
* order, assembling still-open ones from their deltas (delta-only
* protocols). After this, `flushReady()` + `flushRemaining()` together have
* yielded exactly `blocks()`.
*/
flushRemaining(): ContentBlock[] {
const remaining: ContentBlock[] = []
while (this.flushed < this.order.length) {
const index = this.order[this.flushed]
/* v8 ignore next 3 -- noUncheckedIndexedAccess guard: loop condition guarantees index exists */
if (index === undefined) break
remaining.push(this.assemble(this.mustGet(index), index))
this.flushed += 1
}
return remaining
}
get usage(): TokenUsage | undefined {
return this._usage
}
@@ -179,13 +140,4 @@ export class BlockAssembler {
message(): Message {
return { role: 'assistant', content: this.blocks() }
}
/** The assembled non-streaming result. */
result(): GenerateResult {
return {
message: this.message(),
...this._usage !== undefined ? { usage: this._usage } : {},
finish: this.finish,
}
}
}

View File

@@ -0,0 +1,23 @@
/**
* dsh-llm's owned branded id: `CallId` (tool-call correlation).
*
* The `Branded<B>` primitive itself lives in `@deepseek-ai/dsh-brand` (a
* zero-dependency type-only package) so every owner of a cross-boundary id can
* brand it without depending on dsh-llm; see that package's README for the
* nominal-typing policy.
*
* @module @deepseek-ai/dsh-llm/brand
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
/**
* Correlates a model-issued tool call with its result. Provider-issued for
* real adapters; synthesized by mocks/assembler fallbacks.
*/
export type CallId = Branded<'CallId'>
/** Brand a string as a {@link CallId}. */
export function CallId(id: string): CallId {
return id as CallId
}

View File

@@ -1,14 +1,13 @@
/**
* LLM service: adapter registry with waterfall-interceptable streaming and
* non-streaming call surfaces. Exports the `LlmService` default, the abstract
* `LlmAdapter` for provider backends, and `BlockAssembler` for chunk assembly.
* LLM service: adapter registry with a waterfall-interceptable streaming call
* surface. Exports the `LlmService` default, the abstract `LlmAdapter` for
* provider backends, and `BlockAssembler` for chunk assembly.
*
* @module @deepseek-ai/dsh-llm
*/
import { Context, Service } from 'cordis'
import type { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from './types'
import { BlockAssembler } from './assembler'
import type { GenerateOptions, StreamChunk } from './types'
import { HarnessError } from './error'
export * from './brand'
@@ -23,12 +22,13 @@ declare module 'cordis' {
}
interface Events {
/** Waterfall around every streaming model call (retry, caching, routing). */
/**
* Waterfall around every streaming model call (retry, caching, routing).
* Bound to the {@link LlmService}; call `next()` to reach the resolved
* adapter's stream, or yield your own chunks to short-circuit.
* @mode waterfall
*/
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
/** Waterfall around every non-streaming model call. */
'llm/generate'(this: LlmService, options: GenerateOptions, next: () => Promise<GenerateResult>): Promise<GenerateResult>
/** An adapter was registered or unregistered. */
'llm/adapter-change'(): void
}
}
@@ -63,8 +63,8 @@ export abstract class LlmAdapter {
}
/**
* The abstract `llm` service: an adapter registry plus streaming /
* non-streaming call surfaces, both interceptable via waterfall events.
* The abstract `llm` service: an adapter registry plus a streaming model-call
* surface, interceptable via the `llm/stream` waterfall.
*/
export class LlmService extends Service {
private adapters = new Map<string, LlmAdapter>()
@@ -76,8 +76,7 @@ export class LlmService extends Service {
/**
* Register an adapter for the given model names. Throws `LlmError` with code
* `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing).
* Emits `llm/adapter-change` on registration and disposal. Disposed with the
* fiber.
* Disposed with the fiber.
*/
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
const dispose = this.ctx.effect(function* (this: LlmService) {
@@ -87,17 +86,9 @@ export class LlmService extends Service {
}
}
for (const model of models) this.adapters.set(model, adapter)
// Yield the rollback BEFORE emitting the change event: a generator effect
// collects each yielded disposer before running the next step, so a
// throwing `llm/adapter-change` listener rolls the mutation back instead
// of leaking the entry (which would wedge the duplicate check until
// restart). The duplicate throws above fire before any mutation, so they
// correctly leak nothing.
yield () => {
for (const model of models) this.adapters.delete(model)
this.ctx.emit('llm/adapter-change')
}
this.ctx.emit('llm/adapter-change')
}.bind(this), 'llm.registerAdapter()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
@@ -125,36 +116,6 @@ export class LlmService extends Service {
return this.adapter(options.model).stream(options)
})
}
/**
* Stream one model call as completed content blocks — a convenience view
* for consumers that don't care about token-level deltas. Blocks are
* yielded strictly in stream order as soon as they (and everything before
* them) complete; blocks left open at end of stream (delta-only protocols)
* are assembled and flushed last, so the sequence always equals
* `generate()`'s `message.content`.
*/
async * streamBlocks(options: GenerateOptions): AsyncIterable<ContentBlock> {
const assembler = new BlockAssembler()
for await (const chunk of this.stream(options)) {
assembler.push(chunk)
yield * assembler.flushReady()
}
yield * assembler.flushRemaining()
}
/**
* One model call, fully assembled (drains the chunk stream). Dispatches
* through the `llm/generate` waterfall (and the inner stream through
* `llm/stream`). Same completion guarantees as `streamBlocks()`.
*/
generate(options: GenerateOptions): Promise<GenerateResult> {
return this.ctx.waterfall(this, 'llm/generate', options, async () => {
const assembler = new BlockAssembler()
for await (const chunk of this.stream(options)) assembler.push(chunk)
return assembler.result()
})
}
}
export default LlmService

View File

@@ -193,10 +193,3 @@ export interface GenerateOptions {
stop?: string[]
signal?: AbortSignal
}
/** Non-streaming result, assembled from the chunk stream. */
export interface GenerateResult {
message: Message
usage?: TokenUsage
finish: FinishReason
}

View File

@@ -81,36 +81,6 @@ describe('BlockAssembler', () => {
expect(() => assembler.blocks()).toThrow('BlockAssembler invariant violated')
})
it('assembles open blocks at end of stream via flushRemaining', () => {
const assembler = new BlockAssembler()
assembler.push({ type: 'text-delta', index: 0, text: 'open' })
assembler.push({ type: 'reasoning-delta', index: 1, text: 'thinking' })
// flushReady returns nothing because index 0 is incomplete and blocking
const ready = assembler.flushReady()
expect(ready).toEqual([])
// flushRemaining assembles everything still open
const remaining = assembler.flushRemaining()
expect(remaining).toEqual([
{ type: 'text', text: 'open' },
{ type: 'reasoning', text: 'thinking' },
])
// blocks() now matches the flushed view
expect(assembler.blocks()).toEqual(remaining)
})
it('result() omits usage key when no usage was received', () => {
const assembler = new BlockAssembler()
assembler.push({ type: 'text-delta', index: 0, text: 'msg' })
const result = assembler.result()
expect(result.message).toBeDefined()
expect(result.finish).toEqual({ kind: 'stop' })
// usage should NOT be present on the object at all
expect('usage' in result).toBe(false)
})
it('ignores duplicate block-start for the same index', () => {
const assembler = new BlockAssembler()
assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
@@ -142,13 +112,11 @@ describe('BlockAssembler', () => {
])
})
it('includes usage in result() when usage was received', () => {
it('exposes usage via the getter when a usage chunk was received', () => {
const assembler = new BlockAssembler()
assembler.push({ type: 'text-delta', index: 0, text: 'msg' })
assembler.push({ type: 'usage', usage: { inputTokens: 5, outputTokens: 3 } })
const result = assembler.result()
expect(result.usage).toEqual({ inputTokens: 5, outputTokens: 3 })
expect('usage' in result).toBe(true)
expect(assembler.usage).toEqual({ inputTokens: 5, outputTokens: 3 })
})
})
@@ -172,25 +140,25 @@ describe('BlockAssembler regressions (property-test findings)', () => {
// Found by fast-check (the property-testing RFC): two block-ends at the same index made the
// streamed prefix (first block) disagree with final blocks() (second
// block). The first close must win — same straggler rule as post-close
// deltas — so streaming and one-shot assembly stay identical.
// deltas — so the prefix returned incrementally by push() and the final
// blocks() stay identical.
const chunks: StreamChunk[] = [
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'first' } },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'second' } },
]
const streaming = new BlockAssembler()
const flushed = []
const closed = []
for (const chunk of chunks) {
streaming.push(chunk)
flushed.push(...streaming.flushReady())
const block = streaming.push(chunk)
if (block) closed.push(block)
}
flushed.push(...streaming.flushRemaining())
const oneShot = new BlockAssembler()
for (const chunk of chunks) oneShot.push(chunk)
expect(flushed).toEqual([{ type: 'reasoning', text: 'first' }])
expect(closed).toEqual([{ type: 'reasoning', text: 'first' }])
expect(oneShot.blocks()).toEqual([{ type: 'reasoning', text: 'first' }])
expect(flushed).toEqual(oneShot.blocks())
expect(closed).toEqual(oneShot.blocks())
})
it('push returns undefined for a duplicate block-end (it closed nothing)', () => {

View File

@@ -4,13 +4,13 @@
* The assembler is protocol-shaped: arbitrary interleavings of block-start,
* deltas, block-end, usage, and finish — valid and malformed (duplicate
* indices, stragglers after block-end, missing block-start, delta-only). The
* invariants below are the contract the agent loop and LlmService rely on.
* invariants below are the contract the agent loop relies on.
*/
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
// A small pool of indices so collisions (duplicate-index bugs) are common.
@@ -55,38 +55,6 @@ function feed(chunks: StreamChunk[]): BlockAssembler {
}
describe('BlockAssembler properties', () => {
it('flushReady() ++ flushRemaining() === blocks(), in order', () => {
fc.assert(fc.property(streamArb, (chunks) => {
const streaming = new BlockAssembler()
const flushed: ContentBlock[] = []
for (const chunk of chunks) {
streaming.push(chunk)
flushed.push(...streaming.flushReady())
}
flushed.push(...streaming.flushRemaining())
const oneShot = feed(chunks).blocks()
expect(flushed).toEqual(oneShot)
}))
})
it('streamBlocks-style flush never yields a block before an earlier open one', () => {
// flushReady is strict-order: once it stops at an open index, no later
// index may be emitted until that one closes. We assert the flushed prefix
// is always a prefix of the final blocks() order.
fc.assert(fc.property(streamArb, (chunks) => {
const streaming = new BlockAssembler()
const flushed: ContentBlock[] = []
for (const chunk of chunks) {
streaming.push(chunk)
flushed.push(...streaming.flushReady())
}
const finalSoFar = streaming.blocks()
// Everything flushed mid-stream is a prefix of the full ordered blocks.
expect(finalSoFar.slice(0, flushed.length)).toEqual(flushed)
}))
})
it('partials map size never exceeds the number of distinct indices seen', () => {
fc.assert(fc.property(streamArb, (chunks) => {
const distinct = new Set<number>()
@@ -131,20 +99,4 @@ describe('BlockAssembler properties', () => {
}
}))
})
it('streaming and one-shot assembly agree on usage and finish', () => {
fc.assert(fc.property(streamArb, (chunks) => {
// Streaming consumer: push + flush as it goes.
const streaming = new BlockAssembler()
for (const chunk of chunks) {
streaming.push(chunk)
streaming.flushReady()
}
streaming.flushRemaining()
// One-shot consumer: push all, then read.
const oneShot = feed(chunks)
expect(streaming.usage).toEqual(oneShot.usage)
expect(streaming.finish).toEqual(oneShot.finish)
}))
})
})

View File

@@ -19,24 +19,22 @@ const SCRIPT: StreamChunk[] = [
]
describe('LlmService', () => {
it('routes stream() to the registered adapter and generate() assembles it', async () => {
it('routes stream() to the registered adapter', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT))
const chunks: StreamChunk[] = []
for await (const chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) chunks.push(chunk)
expect(chunks).toHaveLength(3)
const result = await ctx.llm.generate({ model: 'test-model', messages: [] })
expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }])
expect(result.finish).toEqual({ kind: 'stop' })
expect(chunks).toEqual(SCRIPT)
})
it('throws NO_ADAPTER for unregistered models', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.llm.generate({ model: 'nope', messages: [] })).rejects.toThrow('no adapter registered')
await expect((async () => {
for await (const _ of ctx.llm.stream({ model: 'nope', messages: [] })) { /* drain */ }
})()).rejects.toThrow('no adapter registered')
})
it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => {
@@ -71,21 +69,6 @@ describe('LlmService', () => {
expect(chunks[0]).toMatchObject({ index: 99 })
})
it('lets llm/generate waterfall listeners intercept and transform the result', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT))
ctx.on('llm/generate', async function (_options, next) {
const result = await next()
return { ...result, finish: { kind: 'max-tokens' } as const }
})
const result = await ctx.llm.generate({ model: 'test-model', messages: [] })
expect(result.finish).toEqual({ kind: 'max-tokens' })
expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }])
})
it('creates LlmError with a code for programmatic handling', () => {
const err = new LlmError('something went wrong', 'CUSTOM_CODE')
expect(err).toBeInstanceOf(Error)
@@ -116,20 +99,13 @@ describe('LlmService', () => {
expect(isHarnessError('nope')).toBe(false)
})
it('disposes adapter registration on adapter-change event emission', async () => {
it('removes the adapter when the returned disposer is called', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const changes: string[][] = []
ctx.on('llm/adapter-change', () => {
changes.push([...ctx.llm.models()])
})
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
expect(changes).toEqual([['m1']])
expect(ctx.llm.models()).toEqual(['m1'])
dispose()
expect(changes).toEqual([['m1'], []])
expect(ctx.llm.models()).toEqual([])
})
@@ -147,25 +123,19 @@ describe('LlmService', () => {
}
})
it('rolls back the adapter entry when an adapter-change listener throws (P1-1)', async () => {
it('re-registers a model after its prior registration is disposed', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
// A change listener that throws on the FIRST emit only.
let threw = false
ctx.on('llm/adapter-change', () => {
if (!threw) { threw = true; throw new Error('boom change listener') }
})
// The throwing emit must roll the mutation back, not leak it.
expect(() => ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))).toThrow('boom change listener')
expect(ctx.llm.models()).toEqual([]) // entry rolled back, not leaked
// A subsequent listener-free register of the SAME model succeeds and
// contributes exactly once (the duplicate check is not wedged).
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
expect(ctx.llm.models()).toEqual(['m1'])
dispose()
expect(ctx.llm.models()).toEqual([])
// The duplicate check is not wedged: the same model registers cleanly again.
const disposeAgain = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
expect(ctx.llm.models()).toEqual(['m1'])
disposeAgain()
expect(ctx.llm.models()).toEqual([])
})
})

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
}
]
}

View File

@@ -1,32 +0,0 @@
/**
* Branded (nominal) ID types.
*
* A brand makes structurally-identical strings non-interchangeable at the
* type level: an `AgentId` cannot be passed where a `CallId` is expected,
* even though both are strings at runtime. Construction goes through the
* per-type factory (a plain cast inside — zero runtime cost); comparison,
* logging, and serialization all behave as ordinary strings.
*
* Policy: core packages brand the IDs they own — `CallId` here (tool-call
* correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent. Branding
* is for IDs that cross package boundaries and could plausibly be confused;
* not every string needs a brand.
*
* @module @deepseek-ai/dsh-llm/brand
*/
declare const BRAND: unique symbol
/** A string carrying a compile-time-only brand `B`. */
export type Branded<B extends string> = string & { readonly [BRAND]: B }
/**
* Correlates a model-issued tool call with its result. Provider-issued for
* real adapters; synthesized by mocks/assembler fallbacks.
*/
export type CallId = Branded<'CallId'>
/** Brand a string as a {@link CallId}. */
export function CallId(id: string): CallId {
return id as CallId
}

View File

@@ -1,12 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../vendor/cosmokit" },
{ "path": "../../vendor/cordis" }
]
}