Files
deepseek-harness/packages/llm/llm-deepseek/src/serialize.ts
2026-07-30 21:49:58 +08:00

179 lines
7.1 KiB
TypeScript

/**
* Serialize harness messages into DeepSeek chat completions. User text is joined; assistant text
* becomes `content`, tool calls become `tool_calls`, and tool results become separate tool messages.
* Assistant reasoning is replayed as `reasoning_content` only on tool-call turns, as required by
* thinking-mode passback. Unknown declaration-merged block types are skipped rather than rejected.
* @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?: 'off' | 'high' | 'max' | undefined
}
interface ResolvedThinking {
thinking?: 'enabled' | 'disabled'
reasoningEffort?: 'high' | 'max'
}
/** Validate the adapter-owned effort before resolving its DeepSeek wire fields. */
function reasoningEffort(effort: NonNullable<GenerateOptions['reasoningEffort']>): 'off' | 'high' | 'max' {
if (effort === 'off' || effort === 'high' || effort === 'max') {
return effort as 'off' | 'high' | 'max'
}
throw new LlmError(
`DeepSeek does not support reasoning effort "${effort}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
/** Resolve one legal thinking/effort pair without exposing `off` as a wire effort. */
function resolveThinking(options: GenerateOptions, defaults: RequestDefaults): ResolvedThinking {
if (options.purpose === 'session-title') return { thinking: 'disabled' }
const effort = options.reasoningEffort === undefined
? defaults.reasoningEffort
: reasoningEffort(options.reasoningEffort)
if (defaults.thinking === 'disabled' && effort !== undefined && effort !== 'off') {
throw new LlmError(
`DeepSeek deployment does not support reasoning effort "${effort}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
if (effort === 'off') return { thinking: 'disabled' }
if (effort === 'high' || effort === 'max') {
return { thinking: 'enabled', reasoningEffort: effort }
}
return defaults.thinking === undefined ? {} : { thinking: defaults.thinking }
}
/** 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',
// Text-less turns send "" — NEVER null. Pure tool-call turns: the
// official samples replay message.content verbatim (which is "") and
// some gateways reject null outright. Reasoning-ONLY turns (the model
// can answer entirely in the reasoning channel, e.g. a v4-flash
// greeting): the live API rejects null-content/no-tool_calls assistant
// messages with a 400 ("content or tool_calls must be set"), and since
// the message sits durably in the session log, a null here bricks every
// later turn of that session.
content: text,
// 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.
* @param messages - the harness conversation, in order.
* @returns the wire messages; order preserved, each tool result expanded into its own entry.
*/
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. Always streaming (`stream: true`, usage
* reporting on); optional fields are omitted rather than sent as null, so
* provider defaults apply.
* @param options - the harness request (model, history, system, tools, sampling).
* @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire.
* @returns the chat-completions request body.
*/
export function serializeRequest(
options: GenerateOptions,
defaults: RequestDefaults = {},
): WireRequest {
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,
},
}))
// A short title budget must produce visible text; conversation and
// compaction calls continue to inherit the adapter's thinking defaults.
const resolvedThinking = resolveThinking(options, defaults)
return {
model: options.model,
messages,
stream: true,
stream_options: { include_usage: true },
...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {},
...resolvedThinking.reasoningEffort !== undefined
? { reasoning_effort: resolvedThinking.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 } : {},
}
}