Add two DeepSeek LLM adapters: dsh-llm-deepseek and dsh-llm-pi-ai
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.
This commit is contained in:
101
packages/llm-deepseek/src/adapter.ts
Normal file
101
packages/llm-deepseek/src/adapter.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible)
|
||||
* chat-completions endpoint, emitting harness StreamChunks.
|
||||
*
|
||||
* @module dsh-llm-deepseek/adapter
|
||||
*/
|
||||
|
||||
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { serializeRequest } from './serialize.ts'
|
||||
import type { RequestDefaults } from './serialize.ts'
|
||||
import { parseSse } from './sse.ts'
|
||||
import { translate } from './translate.ts'
|
||||
import type { WireError } from './types.ts'
|
||||
|
||||
export interface DeepSeekAdapterOptions {
|
||||
apiKey: string
|
||||
/** Endpoint base; `/chat/completions` is appended. */
|
||||
baseURL: string
|
||||
/** Request defaults applied to every call (thinking mode, effort). */
|
||||
defaults?: RequestDefaults
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribution header sent on every request so the provider can identify the
|
||||
* client. Bump in lockstep with this package's version (no build-time version
|
||||
* injection is wired in this repo yet).
|
||||
*/
|
||||
const USER_AGENT = 'deepseek-harness/0.0.1'
|
||||
|
||||
/** Map an HTTP status to a stable LlmError code. */
|
||||
export function httpErrorCode(status: number): string {
|
||||
if (status === 401 || status === 403) return 'AUTH'
|
||||
if (status === 429) return 'RATE_LIMIT'
|
||||
if (status === 400) return 'INVALID_REQUEST'
|
||||
if (status >= 500) return 'SERVER'
|
||||
return `HTTP_${status}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The first real `LlmAdapter`. One instance serves every model name it was
|
||||
* registered under (the harness model name IS the wire model name).
|
||||
*
|
||||
* Abort: `options.signal` is handed to fetch — both the initial request and
|
||||
* the body stream reject on abort, which surfaces to the loop as a rejected
|
||||
* step (the loop already contains step errors).
|
||||
*/
|
||||
export class DeepSeekAdapter extends LlmAdapter {
|
||||
constructor(private readonly options: DeepSeekAdapterOptions) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const body = serializeRequest(options, this.options.defaults ?? {})
|
||||
|
||||
// TODO(http): deliberately raw `fetch` for the hand-rolled SSE body.
|
||||
// `@cordisjs/plugin-http` (ctx.http) would give proxy/intercept/timeout
|
||||
// uniformity AND can stream (`responseType: 'stream'` yields the same
|
||||
// ReadableStream<Uint8Array> parseSse consumes), but adopting it today
|
||||
// costs a hard `undici` dependency (it does `require('undici')` with no
|
||||
// globalThis.fetch fallback) plus an unconditional `@cordisjs/fetch-file`
|
||||
// import (pulling file-type + mime-types) for a file:// path we never hit.
|
||||
// Revisit when a second adapter wants shared proxy/intercept config.
|
||||
const response = await fetch(`${this.options.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'authorization': `Bearer ${this.options.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
'accept': 'text/event-stream',
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
...options.signal ? { signal: options.signal } : {},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const code = httpErrorCode(response.status)
|
||||
let message = `DeepSeek API error (HTTP ${response.status})`
|
||||
try {
|
||||
const parsed = await response.json() as WireError
|
||||
if (parsed.error?.message) message = parsed.error.message
|
||||
} catch {
|
||||
// Paranoid by design: `code` and the HTTP status are ALREADY captured
|
||||
// above (and passed to LlmError below), so the only thing this `try`
|
||||
// can add is a richer provider-supplied message. A malformed, empty,
|
||||
// or non-JSON error body is a normal thing for gateways/proxies to
|
||||
// return on a 5xx/429 — swallowing the parse failure keeps the usable
|
||||
// status-line message instead of letting a JSON.parse throw mask the
|
||||
// real HTTP error. Nothing else reaches this catch: response.json()
|
||||
// is the sole statement, and any non-parse failure (e.g. body already
|
||||
// consumed) is equally non-actionable here.
|
||||
}
|
||||
throw new LlmError(message, code, response.status)
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE')
|
||||
}
|
||||
|
||||
yield* translate(parseSse(response.body))
|
||||
}
|
||||
}
|
||||
78
packages/llm-deepseek/src/index.ts
Normal file
78
packages/llm-deepseek/src/index.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* DeepSeek LLM adapter plugin: registers a {@link DeepSeekAdapter} for the
|
||||
* configured model names on `ctx.llm`.
|
||||
*
|
||||
* Config is cordis-native (schemastery). Secrets flow per the repo policy:
|
||||
* `apiKey` from cordis.yml via the `!!js` tag (`!!js process.env.DEEPSEEK_API_KEY`)
|
||||
* or from the environment directly; never from ad-hoc files.
|
||||
*
|
||||
* ```yaml
|
||||
* - id: llm-deepseek
|
||||
* name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
* config:
|
||||
* apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
* baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
* models: [deepseek-v4-flash, deepseek-v4-pro]
|
||||
* ```
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm-deepseek
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { DeepSeekAdapter } from './adapter.ts'
|
||||
|
||||
export { DeepSeekAdapter, httpErrorCode } from './adapter.ts'
|
||||
export type { DeepSeekAdapterOptions } from './adapter.ts'
|
||||
export { serializeMessages, serializeRequest } from './serialize.ts'
|
||||
export type { RequestDefaults } from './serialize.ts'
|
||||
export { DONE, parseSse } from './sse.ts'
|
||||
export { mapFinishReason, mapUsage, translate } from './translate.ts'
|
||||
export type * from './types.ts'
|
||||
|
||||
export const name = 'llm-deepseek'
|
||||
export const inject = ['llm']
|
||||
|
||||
export interface Config {
|
||||
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
|
||||
apiKey?: string
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
|
||||
baseURL?: string
|
||||
/** Model names to register (sent verbatim on the wire). */
|
||||
models?: string[]
|
||||
/** Thinking-mode default for every request (provider default: enabled). */
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
/** Thinking effort (only meaningful with thinking enabled). */
|
||||
reasoningEffort?: 'high' | 'max'
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
apiKey: z.string(),
|
||||
baseURL: z.string(),
|
||||
models: z.array(z.string()).default(['deepseek-v4-flash', 'deepseek-v4-pro']),
|
||||
thinking: z.union(['enabled', 'disabled']),
|
||||
reasoningEffort: z.union(['high', 'max']),
|
||||
})
|
||||
|
||||
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
|
||||
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY
|
||||
if (apiKey === undefined || apiKey.length === 0) {
|
||||
throw new Error('llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)')
|
||||
}
|
||||
const baseURL = config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL
|
||||
// schemastery's .default() guarantees models is set after validation.
|
||||
const models = config.models as string[]
|
||||
|
||||
ctx.llm.registerAdapter(models, new DeepSeekAdapter({
|
||||
apiKey,
|
||||
baseURL,
|
||||
defaults: {
|
||||
thinking: config.thinking,
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
},
|
||||
}))
|
||||
}
|
||||
144
packages/llm-deepseek/src/serialize.ts
Normal file
144
packages/llm-deepseek/src/serialize.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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 } : {},
|
||||
}
|
||||
}
|
||||
71
packages/llm-deepseek/src/sse.ts
Normal file
71
packages/llm-deepseek/src/sse.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Minimal SSE (text/event-stream) parser for the chat-completions stream.
|
||||
*
|
||||
* Yields each event's `data:` payload as a string, ending with the literal
|
||||
* `'[DONE]'` sentinel so the consumer owns end-of-stream flushing. A stream
|
||||
* that closes WITHOUT `[DONE]` is a protocol violation → `LlmError`.
|
||||
*
|
||||
* Handles the wire realities: payloads split across network reads at
|
||||
* arbitrary byte positions (including mid-UTF-8), CRLF line endings,
|
||||
* multi-`data:` events (joined with newlines per the SSE spec), comment
|
||||
* lines, and non-data fields (ignored).
|
||||
*
|
||||
* @module dsh-llm-deepseek/sse
|
||||
*/
|
||||
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** The terminal payload DeepSeek (and OpenAI) send after the last chunk. */
|
||||
export const DONE = '[DONE]'
|
||||
|
||||
/** Extract the joined data payload from one raw SSE event block. */
|
||||
function eventData(block: string): string | undefined {
|
||||
const data: string[] = []
|
||||
for (const rawLine of block.split('\n')) {
|
||||
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
|
||||
if (line.startsWith('data:')) {
|
||||
// The spec strips ONE leading space after the colon.
|
||||
data.push(line.startsWith('data: ') ? line.slice(6) : line.slice(5))
|
||||
}
|
||||
// Comments (':…') and other fields (event:, id:, retry:) are ignored.
|
||||
}
|
||||
if (data.length === 0) return undefined
|
||||
return data.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a byte stream into SSE data payloads. Yields `[DONE]` as the final
|
||||
* value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends
|
||||
* without it (truncated response — the model call cannot be trusted).
|
||||
*/
|
||||
export async function* parseSse(stream: AsyncIterable<Uint8Array>): AsyncGenerator<string> {
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
for await (const bytes of stream) {
|
||||
buffer += decoder.decode(bytes, { stream: true })
|
||||
// Events are separated by a blank line (\n\n; tolerate \r\n\r\n via the
|
||||
// per-line \r strip in eventData and a normalized split here).
|
||||
let boundary: number
|
||||
while ((boundary = buffer.search(/\r?\n\r?\n/)) !== -1) {
|
||||
const matched = /\r?\n\r?\n/.exec(buffer.slice(boundary))
|
||||
const block = buffer.slice(0, boundary)
|
||||
// matched cannot be null: search() just found the same pattern at 0.
|
||||
buffer = buffer.slice(boundary + (matched as RegExpExecArray)[0].length)
|
||||
const data = eventData(block)
|
||||
if (data === undefined) continue
|
||||
yield data
|
||||
if (data === DONE) return
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any final un-terminated event (servers usually end with \n\n, but
|
||||
// a trailing block without one is still parseable).
|
||||
buffer += decoder.decode()
|
||||
const data = eventData(buffer)
|
||||
if (data !== undefined) {
|
||||
yield data
|
||||
if (data === DONE) return
|
||||
}
|
||||
throw new LlmError('SSE stream ended without [DONE]', 'STREAM_CLOSED')
|
||||
}
|
||||
169
packages/llm-deepseek/src/translate.ts
Normal file
169
packages/llm-deepseek/src/translate.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
136
packages/llm-deepseek/src/types.ts
Normal file
136
packages/llm-deepseek/src/types.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* DeepSeek chat-completions wire format (OpenAI-compatible). Types only.
|
||||
*
|
||||
* Source of truth: the official API docs at
|
||||
* `~/repos/deepsuite-docs/apps/docs/docs` (api/create-chat-completion,
|
||||
* guides/thinking_mode.mdx, guides/tool_calls.md), cross-checked against
|
||||
* live streams from the internal endpoint (2026-06).
|
||||
*
|
||||
* @module dsh-llm-deepseek/types
|
||||
*/
|
||||
|
||||
/** Request body for `POST {baseURL}/chat/completions`. */
|
||||
export interface WireRequest {
|
||||
model: string
|
||||
messages: WireMessage[]
|
||||
stream: true
|
||||
stream_options: { include_usage: true }
|
||||
/** Thinking-mode toggle (top level, NOT inside extra_body on the wire). */
|
||||
thinking?: { type: 'enabled' | 'disabled' }
|
||||
/** Thinking effort (official levels; low/medium map to high server-side). */
|
||||
reasoning_effort?: 'high' | 'max'
|
||||
tools?: WireTool[]
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
/**
|
||||
* Stop sequences (OpenAI `stop`): generation halts as soon as the model
|
||||
* produces any one of these strings. Mapped from `GenerateOptions.stop`.
|
||||
*/
|
||||
stop?: string[]
|
||||
}
|
||||
|
||||
/** System-role message: a single string of instructions. */
|
||||
export interface WireSystemMessage {
|
||||
role: 'system'
|
||||
content: string
|
||||
}
|
||||
|
||||
/** User-role message: a single string of user input. */
|
||||
export interface WireUserMessage {
|
||||
role: 'user'
|
||||
content: string
|
||||
}
|
||||
|
||||
/** Tool-role message: the result of one tool call, keyed by its call id. */
|
||||
export interface WireToolMessage {
|
||||
role: 'tool'
|
||||
tool_call_id: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type WireMessage =
|
||||
| WireSystemMessage
|
||||
| WireUserMessage
|
||||
| WireAssistantMessage
|
||||
| WireToolMessage
|
||||
|
||||
export interface WireAssistantMessage {
|
||||
role: 'assistant'
|
||||
content: string | null
|
||||
/**
|
||||
* CoT passback. REQUIRED on assistant turns that carried tool calls
|
||||
* (thinking mode); ignored on tool-call-free turns (we omit it there to
|
||||
* save tokens). See guides/thinking_mode.mdx § Tool Calls.
|
||||
*/
|
||||
reasoning_content?: string
|
||||
tool_calls?: WireToolCall[]
|
||||
}
|
||||
|
||||
export interface WireToolCall {
|
||||
id: string
|
||||
type: 'function'
|
||||
function: { name: string; arguments: string }
|
||||
}
|
||||
|
||||
export interface WireTool {
|
||||
type: 'function'
|
||||
function: {
|
||||
name: string
|
||||
description: string
|
||||
parameters: Record<string, unknown>
|
||||
/** Beta: strict schema adherence (official: requires the /beta base URL). */
|
||||
strict?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** One parsed SSE `data:` payload (a chat.completion.chunk). */
|
||||
export interface WireChunk {
|
||||
choices?: WireChoice[]
|
||||
/** Arrives attached to the finish chunk and/or as a trailing usage-only chunk. */
|
||||
usage?: WireUsage | null
|
||||
}
|
||||
|
||||
export interface WireChoice {
|
||||
delta?: WireDelta
|
||||
finish_reason?: string | null
|
||||
}
|
||||
|
||||
export interface WireDelta {
|
||||
role?: string
|
||||
/** Visible text. Null/empty on reasoning/tool-call chunks. */
|
||||
content?: string | null
|
||||
/**
|
||||
* Thinking-mode CoT. The FIRST chunk carries an empty string (must not
|
||||
* open a reasoning block); absent entirely in non-thinking mode.
|
||||
*/
|
||||
reasoning_content?: string | null
|
||||
tool_calls?: WireToolCallDelta[]
|
||||
}
|
||||
|
||||
export interface WireToolCallDelta {
|
||||
/** Disambiguates parallel tool calls; stable across a call's deltas. */
|
||||
index: number
|
||||
/** Present on the first delta of each call only. */
|
||||
id?: string
|
||||
type?: 'function'
|
||||
function?: {
|
||||
/** Present on the first delta of each call only. */
|
||||
name?: string
|
||||
/** Argument JSON fragment (concatenate across deltas). */
|
||||
arguments?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WireUsage {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
prompt_cache_hit_tokens?: number
|
||||
prompt_cache_miss_tokens?: number
|
||||
prompt_tokens_details?: { cached_tokens?: number }
|
||||
completion_tokens_details?: { reasoning_tokens?: number }
|
||||
}
|
||||
|
||||
/** Non-2xx error body. */
|
||||
export interface WireError {
|
||||
error?: { message?: string; type?: string; code?: string }
|
||||
}
|
||||
Reference in New Issue
Block a user