mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
A well-formed provider stream that ends with finish_reason stop and zero
content blocks previously became a successful empty assistant message: the
turn completed silently, and drivers like goal-session counted the no-op
round. Both adapters now map that degenerate completion to a finish
{kind:'error'} with the new canonical EMPTY_RESPONSE code from dsh-llm, and
dsh-llm-retry adds the code to its default retryable set, so the existing
closed-step recovery path retries it and fails loud once the budget is
exhausted.
Covered by adapter unit tests, an llm-retry default-policy test, and a new
authored keyless ACP snapshot (empty-response-retry) with a deterministic
1 ms zero-jitter retry overlay.
186 lines
7.1 KiB
TypeScript
186 lines
7.1 KiB
TypeScript
/**
|
|
* Translate DeepSeek SSE payloads with one stateful harness block per content, reasoning, or tool
|
|
* call index. An empty initial reasoning delta does not open a block. Finish reason and the latest
|
|
* usage are deferred until `[DONE]`, covering both finish-attached and trailing usage-only shapes
|
|
* while ensuring no chunk follows `finish`.
|
|
*
|
|
* Translate DeepSeek wire chunks into the harness `StreamChunk` protocol.
|
|
* @module dsh-llm-deepseek/translate
|
|
*/
|
|
|
|
import { CallId, EMPTY_RESPONSE_CODE, 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.
|
|
* @param reason - the wire `finish_reason` string.
|
|
* @returns the mapped reason; unrecognized values (content_filter, …) become `{kind: 'error'}` with the uppercased value as `code`.
|
|
*/
|
|
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',
|
|
failure: { 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`.
|
|
* @param usage - wire usage from the finish chunk or the trailing usage-only chunk.
|
|
* @returns disjoint harness counts; cache/reasoning fields present only when the wire reported them.
|
|
*/
|
|
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`.
|
|
* @param payloads - SSE data payloads from {@link parseSse}, `[DONE]`-terminated.
|
|
* @returns deltas as they arrive; `block-end`s, `usage`, and `finish` are all deferred to the `[DONE]` sentinel.
|
|
* A `stop` (or absent) finish with no opened blocks is a degenerate provider completion and maps to an
|
|
* `EMPTY_RESPONSE` error finish instead of a successful empty message.
|
|
*/
|
|
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 }
|
|
const reason = pendingFinish ?? { kind: 'stop' as const }
|
|
yield {
|
|
type: 'finish',
|
|
reason: reason.kind === 'stop' && order.length === 0
|
|
? {
|
|
kind: 'error',
|
|
failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE },
|
|
}
|
|
: reason,
|
|
}
|
|
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')
|
|
}
|