Files
deepseek-harness/packages/llm/llm-deepseek/src/serialize.ts
Tianyi Cui 5a8234643a refactor(llm): drop the inert request knobs — prefill and strict
GenerateOptions.prefill had no production setter and both adapters
rejected it with LlmError('UNSUPPORTED') — its entire observable
behavior was two throws, each pinned by one adapter test. DeepSeek's
chat-prefix completion is a Beta feature on a base URL neither adapter
targets. ToolSchema.strict was threaded through defineTool, the
registry's schemas() allowlist, the deepseek wire mapping, a per-tool
payload-patching pass in the pi-ai adapter, and a tool-catalog render
row, yet no shipped tool set it and the internal endpoint story for
strict mode was never built.

Remove both fields end-to-end: the vocabulary in dsh-llm, the adapter
guards and wire branches, the dsh-tools threading, the tool-catalog
Strict row, the pinning tests, the core.md pastes, the adapter README
rows, and the cookbook line that used prefill as the UNSUPPORTED
example (now stated generically). The pi-ai payload fixup keeps the
half with a job: pi-ai stamps strict:false on every serialized tool,
so the fixup scrubs it unconditionally for wire parity with the
hand-rolled twin (per-tool set/delete machinery gone). temperature/
stop/maxTokens are untouched — honored end-to-end by both adapters.

Each knob returns with its first real producer: prefill with an
adapter that implements chat-prefix completion, strict with a tool
that wants it and a beta-endpoint story.

RFC: docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md
(moved from proposed/, amended to shipped reality); the content-block
vocabulary RFC's consequence line now records prefill as producer-gated.
2026-07-04 18:38:39 +08:00

130 lines
4.9 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)
*
* @module dsh-llm-deepseek/serialize
*/
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. */
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,
},
}))
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 } : {},
}
}