mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
The first real LlmAdapter implementations, shipped as a deliberate pair:
same models and wire protocol, completely different internals, so the
StreamChunk protocol is verified across independent implementations.
- dsh-llm-deepseek: hand-rolled fetch + SSE parser + chunk-translation
state machine against the official chat-completions format (thinking
mode via top-level thinking/reasoning_effort; the empty-string
reasoning_content first chunk; usage attached to the finish chunk or
trailing; reasoning_content passback on tool-call turns; disjoint
cache-token accounting).
- dsh-llm-pi-ai: the same endpoint through @earendil-works/pi-ai,
mapping its event vocabulary (parsed tool arguments, in-stream error
events, folded reasoning tokens) onto the same chunks.
The agent loop now honors the in-band error path: an adapter that ends
its stream with finish {kind:error|aborted} (the only option for
adapters that can't throw mid-stream, like pi-ai) is translated into a
step error, so the turn ends error/aborted with a logged error event
instead of a normal completed assistant message. This makes the
StreamChunk error contract real for both adapters; docs/architecture.md
and the StreamChunk doc are updated accordingly.
New yarn test:e2e (vitest.e2e.config.ts, *.e2e.ts) runs key-gated
real-API matrices for both adapters across V4 Flash/Pro and all
thinking/effort levels; it self-skips without DEEPSEEK_API_KEY. Unit
suites run against local node:http mock SSE servers at 100% per-file
coverage.
170 lines
6.2 KiB
TypeScript
170 lines
6.2 KiB
TypeScript
/**
|
|
* 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.ts'
|
|
import type { WireChunk, WireUsage } from './types.ts'
|
|
|
|
/** 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')
|
|
}
|