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.
145 lines
5.5 KiB
TypeScript
145 lines
5.5 KiB
TypeScript
/**
|
|
* 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.ts'
|
|
|
|
/** 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 } : {},
|
|
}
|
|
}
|