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:
Tianyi Cui
2026-06-13 00:28:29 +08:00
parent 8b5a3ef730
commit ab19fed77c
38 changed files with 4567 additions and 28 deletions

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import type { GenerateResult, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
/**
* Real-API e2e for the hand-rolled adapter: V4 Flash + V4 Pro across
* thinking modes and both official effort levels. Key-gated — skips
* entirely without $DEEPSEEK_API_KEY (see vitest.e2e.config.ts).
*/
const FLASH = 'deepseek-v4-flash'
const PRO = 'deepseek-v4-pro'
async function harness(model: string, config: Partial<Config> = {}) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { models: [model], ...config })
return ctx
}
function ask(text: string): Message[] {
return [{ role: 'user', content: [{ type: 'text', text }] }]
}
function textOf(result: GenerateResult): string {
return result.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
const weatherTool: ToolSchema = {
name: 'get_weather',
description: 'Get the current weather for a city.',
parameters: {
type: 'object',
properties: { city: { type: 'string', description: 'City name' } },
required: ['city'],
},
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => {
it('flash + thinking disabled: plain text generation', async () => {
const ctx = await harness(FLASH, { thinking: 'disabled' })
const result = await ctx.llm.generate({
model: FLASH,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
})
expect(result.finish.kind).toBe('stop')
expect(textOf(result).toLowerCase()).toContain('pong')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
expect(result.usage?.inputTokens).toBeGreaterThan(0)
expect(result.usage?.outputTokens).toBeGreaterThan(0)
})
it('flash + thinking enabled (effort high): reasoning blocks + reasoning tokens', async () => {
const ctx = await harness(FLASH, { thinking: 'enabled', reasoningEffort: 'high' })
const result = await ctx.llm.generate({
model: FLASH,
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
maxTokens: 2000,
})
expect(result.finish.kind).toBe('stop')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(true)
expect(textOf(result)).toContain('9.8')
expect(result.usage?.reasoningTokens).toBeGreaterThan(0)
})
it.each(['high', 'max'] as const)(
'pro + thinking enabled (effort %s): tool-call round trip with reasoning passback',
async (effort) => {
const ctx = await harness(PRO, { thinking: 'enabled', reasoningEffort: effort })
// Turn 1: the model must call the tool (and think before it).
const first = await ctx.llm.generate({
model: PRO,
messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
tools: [weatherTool],
maxTokens: 2000,
})
expect(first.finish.kind).toBe('tool-calls')
const call = first.message.content.find(block => block.type === 'tool-call')
expect(call).toBeDefined()
expect(call!.name).toBe('get_weather')
expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string })
// Turn 2: send the tool result back WITH the assistant's reasoning
// block in history (the official thinking+tools passback rule).
const second = await ctx.llm.generate({
model: PRO,
messages: [
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
{ role: 'assistant', content: first.message.content },
{
role: 'user',
content: [{
type: 'tool-result',
toolCallId: CallId(call!.id),
content: [{ type: 'text', text: 'Sunny, 22°C' }],
}],
},
],
tools: [weatherTool],
maxTokens: 2000,
})
expect(second.finish.kind).toBe('stop')
expect(textOf(second).toLowerCase()).toMatch(/sunny|22/)
},
)
it('pro + thinking disabled: plain generation without reasoning blocks', async () => {
const ctx = await harness(PRO, { thinking: 'disabled' })
const result = await ctx.llm.generate({
model: PRO,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
})
expect(result.finish.kind).toBe('stop')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
})
it('streams raw chunks in protocol order', async () => {
const ctx = await harness(FLASH, { thinking: 'disabled' })
const kinds: string[] = []
for await (const chunk of ctx.llm.stream({
model: FLASH,
messages: ask('Count from 1 to 5, digits only.'),
maxTokens: 50,
})) {
kinds.push(chunk.type)
}
expect(kinds[0]).toBe('block-start')
expect(kinds.at(-1)).toBe('finish')
expect(kinds.filter(kind => kind === 'finish')).toHaveLength(1)
// usage precedes finish (deferred-emit contract)
expect(kinds.indexOf('usage')).toBeLessThan(kinds.indexOf('finish'))
})
})

View File

@@ -0,0 +1,309 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { LlmError } from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek'
/** One scripted behavior for the next request the mock server receives. */
type Behavior =
| { kind: 'sse'; events: string[]; delayMs?: number }
| { kind: 'http-error'; status: number; body: string; contentType?: string }
| { kind: 'close-early'; events: string[] }
interface MockServer {
url: string
/** Bodies of received requests, in order. */
requests: unknown[]
/** Header bags of received requests, in order (parallel to `requests`). */
headers: IncomingMessage['headers'][]
script: Behavior[]
close(): Promise<void>
}
const servers: Server[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
vi.unstubAllEnvs()
})
/** Local chat-completions stand-in: replays scripted behaviors per request. */
async function mockServer(script: Behavior[]): Promise<MockServer> {
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
requests.push(JSON.parse(body))
headers.push(request.headers)
const behavior = script.shift()
if (!behavior) {
response.writeHead(500).end('mock script exhausted')
return
}
if (behavior.kind === 'http-error') {
response.writeHead(behavior.status, { 'content-type': behavior.contentType ?? 'application/json' })
response.end(behavior.body)
return
}
response.writeHead(200, { 'content-type': 'text/event-stream' })
const write = (index: number): void => {
if (index >= behavior.events.length) {
if (behavior.kind === 'sse') response.end()
else response.destroy() // close-early: drop the socket mid-stream
return
}
response.write(`data: ${behavior.events[index]}\n\n`)
setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5)
}
write(0)
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return {
url: `http://127.0.0.1:${address.port}`,
requests,
headers,
script,
close: () => new Promise(resolve => server.close(() => { resolve() })),
}
}
const textEvents = [
'{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
'{"choices":[{"delta":{"content":"hello"}}]}',
'{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'[DONE]',
]
async function harness(baseURL: string, config: object = {}) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, models: ['deepseek-v4-flash'], ...config })
return ctx
}
describe('DeepSeekAdapter against a mock server', () => {
it('streams a text generation end to end through ctx.llm.generate', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url)
const result = await ctx.llm.generate({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(result.finish).toEqual({ kind: 'stop' })
expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1 })
// The wire request carried the auth header contents we configured.
expect(server.requests[0]).toMatchObject({
model: 'deepseek-v4-flash',
stream: true,
stream_options: { include_usage: true },
})
// Attribution header identifies the harness to the provider.
expect(server.headers[0]?.['user-agent']).toMatch(/^deepseek-harness\//)
})
it('streams raw chunks through ctx.llm.stream', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }])
const ctx = await harness(server.url)
const kinds: string[] = []
for await (const chunk of ctx.llm.stream({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})) {
kinds.push(chunk.type)
}
expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish'])
})
it('forwards thinking config onto the wire', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' })
await ctx.llm.generate({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})
expect(server.requests[0]).toMatchObject({
thinking: { type: 'disabled' },
reasoning_effort: 'high',
})
})
it.each([
[401, 'AUTH'],
[403, 'AUTH'],
[429, 'RATE_LIMIT'],
[400, 'INVALID_REQUEST'],
[500, 'SERVER'],
[503, 'SERVER'],
])('maps HTTP %d to LlmError code %s with the body message', async (status, code) => {
const behavior: Behavior = {
kind: 'http-error',
status,
body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }),
}
const server = await mockServer([behavior, behavior, behavior])
const ctx = await harness(server.url)
await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(`failed with ${status}`)
await expect(
ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).code),
).resolves.toBe(code)
// The numeric HTTP status is carried on the error for explicit handling.
await expect(
ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).status),
).resolves.toBe(status)
})
it('keeps the status-line message for JSON error bodies without a message', async () => {
const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }])
const ctx = await harness(server.url)
await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/HTTP 500/)
})
it('keeps the status-line message for non-JSON error bodies', async () => {
const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }])
const ctx = await harness(server.url)
await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/HTTP 502/)
})
it('maps unusual statuses to HTTP_<status>', () => {
expect(httpErrorCode(418)).toBe('HTTP_418')
})
it('throws EMPTY_RESPONSE when the response has no body', async () => {
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(null, { status: 200 }),
)
try {
const iterate = async (): Promise<void> => {
for await (const _chunk of adapter.stream({ model: 'm', messages: [] })) { /* drain */ }
}
await expect(iterate()).rejects.toThrow(/no response body/)
} finally {
fetchSpy.mockRestore()
}
})
it('rejects with STREAM_CLOSED when the server drops mid-stream', async () => {
const server = await mockServer([{
kind: 'close-early',
events: ['{"choices":[{"delta":{"content":"par"}}]}'],
}])
const ctx = await harness(server.url)
await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/terminated|socket|without \[DONE\]/)
})
it('aborts mid-stream via the request signal', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 50 }])
const ctx = await harness(server.url)
const controller = new AbortController()
const pending = (async () => {
const chunks = []
for await (const chunk of ctx.llm.stream({
model: 'deepseek-v4-flash',
messages: [],
signal: controller.signal,
})) {
chunks.push(chunk)
}
return chunks
})()
setTimeout(() => { controller.abort() }, 30)
await expect(pending).rejects.toThrow()
})
})
describe('plugin registration and config', () => {
it('registers the configured models and unregisters on dispose (HMR safety)', async () => {
const server = await mockServer([])
const ctx = new Context()
await ctx.plugin(LlmService)
const fiber = await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: server.url,
models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
})
expect(ctx.llm.models().sort()).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
await fiber.dispose()
expect(ctx.llm.models()).toEqual([])
})
it('defaults the model list', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
expect(ctx.llm.models().sort()).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
})
it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1')
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {})
expect(ctx.llm.models().length).toBeGreaterThan(0)
})
it('throws a clear error when no API key is available', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {}))
.rejects.toThrow(/an API key is required/)
expect(ctx.llm.models()).toEqual([])
})
it('prefers explicit config over env for key and base URL', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url) // harness passes explicit config
await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests).toHaveLength(1) // hit the explicit URL, not env
})
it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', models: ['deepseek-v4-flash'] })
await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests).toHaveLength(1)
})
it('defaults to the public base URL without config or env', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'k')
vi.stubEnv('DEEPSEEK_BASE_URL', undefined)
const ctx = new Context()
await ctx.plugin(LlmService)
// Registration succeeds; no call is made (would hit api.deepseek.com).
await ctx.plugin(LlmDeepSeek, {})
expect(ctx.llm.models().length).toBeGreaterThan(0)
})
it('adapter is constructible directly for embedding', () => {
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
})
})

View File

@@ -0,0 +1,210 @@
import { describe, expect, it } from 'vitest'
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek'
function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
return { model: 'deepseek-v4-flash', messages: [], ...overrides }
}
describe('serializeMessages', () => {
it('maps user text to string content', () => {
const wire = serializeMessages([
{ role: 'user', content: [{ type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }] },
])
expect(wire).toEqual([{ role: 'user', content: 'hello world' }])
})
it('maps system-role messages in history', () => {
const wire = serializeMessages([
{ role: 'system', content: [{ type: 'text', text: 'be brief' }] },
])
expect(wire).toEqual([{ role: 'system', content: 'be brief' }])
})
it('maps plain assistant text without reasoning_content', () => {
const wire = serializeMessages([
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'thinking…' },
{ type: 'text', text: 'answer' },
],
},
])
// Tool-call-free turn: reasoning is dropped (ignored by the API anyway).
expect(wire).toEqual([{ role: 'assistant', content: 'answer' }])
})
it('passes reasoning_content back on tool-call turns (official passback rule)', () => {
const wire = serializeMessages([
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'I should check the weather.' },
{ type: 'tool-call', id: CallId('call-1'), name: 'get_weather', arguments: '{"city":"Paris"}' },
],
},
])
expect(wire).toEqual([{
role: 'assistant',
// "" (not null) on tool-call turns — mirrors the official samples'
// verbatim message replay; some gateways reject null.
content: '',
reasoning_content: 'I should check the weather.',
tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }],
}])
})
it('serializes parallel tool calls in order', () => {
const wire = serializeMessages([
{
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('a'), name: 'one', arguments: '{}' },
{ type: 'tool-call', id: CallId('b'), name: 'two', arguments: '{}' },
],
},
])
const assistant = wire[0] as { tool_calls: { id: string }[] }
expect(assistant.tool_calls.map(call => call.id)).toEqual(['a', 'b'])
})
it('turns tool results into role:tool messages', () => {
const wire = serializeMessages([
{
role: 'user',
content: [{
type: 'tool-result',
toolCallId: CallId('call-1'),
content: [{ type: 'text', text: 'Sunny 22C' }],
}],
},
])
expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: 'Sunny 22C' }])
})
it('sends a sentinel for empty tool-result content', () => {
const wire = serializeMessages([
{
role: 'user',
content: [{ type: 'tool-result', toolCallId: CallId('call-1'), content: [] }],
},
])
expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: '(no output)' }])
})
it('splits mixed user text + tool results into separate wire messages', () => {
const wire = serializeMessages([
{
role: 'user',
content: [
{ type: 'text', text: 'context note' },
{ type: 'tool-result', toolCallId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }] },
],
},
])
expect(wire).toEqual([
{ role: 'user', content: 'context note' },
{ role: 'tool', tool_call_id: 'call-1', content: 'ok' },
])
})
it('skips image blocks (documented MVP limitation)', () => {
const wire = serializeMessages([
{ role: 'user', content: [{ type: 'image', url: 'data:image/png;base64,x' }, { type: 'text', text: 'see image' }] },
])
expect(wire).toEqual([{ role: 'user', content: 'see image' }])
})
it('emits an empty user message rather than dropping block-less messages', () => {
const wire = serializeMessages([{ role: 'user', content: [] }])
expect(wire).toEqual([{ role: 'user', content: '' }])
})
})
describe('serializeRequest', () => {
const history: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]
it('always streams with usage and maps the basics', () => {
const wire = serializeRequest(request({ messages: history }))
expect(wire).toEqual({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
stream_options: { include_usage: true },
})
})
it('prepends the system prompt', () => {
const wire = serializeRequest(request({ messages: history, system: 'be helpful' }))
expect(wire.messages[0]).toEqual({ role: 'system', content: 'be helpful' })
expect(wire.messages[1]).toEqual({ role: 'user', content: 'hi' })
})
it('maps sampling params and stop sequences', () => {
const wire = serializeRequest(request({ messages: history, temperature: 0.2, maxTokens: 100, stop: ['END'] }))
expect(wire.temperature).toBe(0.2)
expect(wire.max_tokens).toBe(100)
expect(wire.stop).toEqual(['END'])
})
it('maps tools with strict passthrough', () => {
const wire = serializeRequest(request({
messages: history,
tools: [
{ name: 'a', description: 'A', parameters: { type: 'object', properties: {} } },
{ name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true },
],
}))
expect(wire.tools).toEqual([
{ type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } },
{ type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true } },
])
})
it('omits an empty tools array', () => {
const wire = serializeRequest(request({ messages: history, tools: [] }))
expect(wire.tools).toBeUndefined()
})
it('applies adapter defaults for thinking and effort', () => {
const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled', reasoningEffort: 'max' })
expect(wire.thinking).toEqual({ type: 'enabled' })
expect(wire.reasoning_effort).toBe('max')
})
it('omits thinking fields when unset (provider default applies)', () => {
const wire = serializeRequest(request({ messages: history }))
expect(wire.thinking).toBeUndefined()
expect(wire.reasoning_effort).toBeUndefined()
})
it('rejects prefill with an UNSUPPORTED LlmError', () => {
expect(() => serializeRequest(request({ prefill: [{ type: 'text', text: 'Sure' }] })))
.toThrow(LlmError)
try {
serializeRequest(request({ prefill: [] }))
expect.unreachable()
} catch (error) {
expect((error as LlmError).code).toBe('UNSUPPORTED')
}
})
})
describe('review fixes: assistant content shapes', () => {
it('serializes a content-less, tool-call-less assistant message as null content', () => {
// Aborted/empty assistant turns: no text, no calls → null (the wire
// accepts it; "" is reserved for tool-call turns per the samples).
const wire = serializeMessages([{ role: 'assistant', content: [] }])
expect(wire).toEqual([{ role: 'assistant', content: null }])
})
it('serializes tool-call turns with empty string content, not null', () => {
const wire = serializeMessages([{
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c'), name: 'f', arguments: '{}' }],
}])
expect(wire[0]).toMatchObject({ content: '' })
})
})

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { LlmError } from '@deepseek-ai/dsh-llm'
import { DONE, parseSse } from '@deepseek-ai/dsh-llm-deepseek'
/** Build a byte stream from string fragments (fragments = network reads). */
async function* bytes(...fragments: (string | Uint8Array)[]): AsyncGenerator<Uint8Array> {
const encoder = new TextEncoder()
for (const fragment of fragments) {
yield typeof fragment === 'string' ? encoder.encode(fragment) : fragment
}
}
async function collect(stream: AsyncIterable<string>): Promise<string[]> {
const out: string[] = []
for await (const item of stream) out.push(item)
return out
}
describe('parseSse', () => {
it('parses simple events and the DONE sentinel', async () => {
const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('handles events split across reads at arbitrary positions', async () => {
const events = await collect(parseSse(bytes('da', 'ta: {"a"', ':1}\n', '\ndata: [DO', 'NE]\n\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('handles multi-byte UTF-8 split across reads', async () => {
const encoded = new TextEncoder().encode('data: {"text":"日本語"}\n\ndata: [DONE]\n\n')
// Split inside the 3-byte sequence for 日.
const splitAt = 16
const events = await collect(parseSse(bytes(encoded.slice(0, splitAt), encoded.slice(splitAt))))
expect(events).toEqual(['{"text":"日本語"}', DONE])
})
it('tolerates CRLF line endings', async () => {
const events = await collect(parseSse(bytes('data: {"a":1}\r\n\r\ndata: [DONE]\r\n\r\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('joins multi-data events with newlines (SSE spec)', async () => {
const events = await collect(parseSse(bytes('data: line1\ndata: line2\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['line1\nline2', DONE])
})
it('ignores comments and non-data fields', async () => {
const events = await collect(parseSse(bytes(': keepalive\nevent: chunk\nid: 7\ndata: {"a":1}\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('skips blocks without data fields', async () => {
const events = await collect(parseSse(bytes(': ping\n\ndata: {"a":1}\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('preserves data lines without the optional space', async () => {
const events = await collect(parseSse(bytes('data:{"a":1}\n\ndata:[DONE]\n\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('parses several events from one read', async () => {
const events = await collect(parseSse(bytes('data: 1\n\ndata: 2\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['1', '2', DONE])
})
it('flushes a final un-terminated DONE at stream end', async () => {
const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('throws STREAM_CLOSED when the stream ends without DONE', async () => {
await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(LlmError)
await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(/without \[DONE\]/)
})
it('throws STREAM_CLOSED for an empty stream', async () => {
await expect(collect(parseSse(bytes()))).rejects.toThrow(/without \[DONE\]/)
})
it('throws STREAM_CLOSED for a mid-event close', async () => {
await expect(collect(parseSse(bytes('data: {"a"')))).rejects.toThrow(/without \[DONE\]/)
})
it('stops yielding after DONE even when more data follows', async () => {
const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n')))
expect(events).toEqual([DONE])
})
})
describe('parseSse edge branches', () => {
it('handles a lone CR-terminated data line', async () => {
// Exercises the \r-strip branch on a line that is ONLY "data:…\r".
const events = await collect(parseSse(bytes('data: {"a":1}\r\n\r\ndata:[DONE]\r\n\r\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('strips CR from non-data field lines too', async () => {
const events = await collect(parseSse(bytes('event: chunk\r\ndata: {"a":1}\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('treats bare "data:" lines as empty payload entries', async () => {
const events = await collect(parseSse(bytes('data:\ndata: x\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['\nx', DONE])
})
})

View File

@@ -0,0 +1,307 @@
import { describe, expect, it } from 'vitest'
import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { DONE, mapFinishReason, mapUsage, translate } from '@deepseek-ai/dsh-llm-deepseek'
async function* feed(...payloads: (string | object)[]): AsyncGenerator<string> {
for (const payload of payloads) {
yield typeof payload === 'string' ? payload : JSON.stringify(payload)
}
}
async function collect(stream: AsyncIterable<StreamChunk>): Promise<StreamChunk[]> {
const out: StreamChunk[] = []
for await (const chunk of stream) out.push(chunk)
return out
}
/** The live first-chunk signature: role + null content + EMPTY reasoning. */
const firstChunk = { choices: [{ delta: { role: 'assistant', content: null, reasoning_content: '' } }] }
describe('translate: text', () => {
it('streams a text block and defers finish to DONE', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'Hel' } }] },
{ choices: [{ delta: { content: 'lo' } }] },
{ choices: [{ delta: { content: '' }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 2 } },
DONE,
)))
expect(chunks).toEqual([
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'Hel' },
{ type: 'text-delta', index: 0, text: 'lo' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'Hello' } },
{ type: 'usage', usage: { inputTokens: 5, outputTokens: 2 } },
{ type: 'finish', reason: { kind: 'stop' } },
])
})
it('assembles into the message BlockAssembler expects', async () => {
const assembler = new BlockAssembler()
for await (const chunk of translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'hi' } }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
DONE,
))) {
assembler.push(chunk)
}
const result = assembler.result()
expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }])
expect(result.finish).toEqual({ kind: 'stop' })
})
})
describe('translate: reasoning', () => {
it('does NOT open a reasoning block for the empty first-chunk signature', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'plain' } }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
DONE,
)))
expect(chunks.some(chunk => chunk.type === 'block-start' && chunk.blockType === 'reasoning')).toBe(false)
})
it('streams reasoning then text as separate blocks', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: null, reasoning_content: 'think' } }] },
{ choices: [{ delta: { content: null, reasoning_content: 'ing' } }] },
{ choices: [{ delta: { content: 'answer', reasoning_content: null } }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
DONE,
)))
expect(chunks).toEqual([
{ type: 'block-start', index: 0, blockType: 'reasoning' },
{ type: 'reasoning-delta', index: 0, text: 'think' },
{ type: 'reasoning-delta', index: 0, text: 'ing' },
{ type: 'block-start', index: 1, blockType: 'text' },
{ type: 'text-delta', index: 1, text: 'answer' },
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'thinking' } },
{ type: 'block-end', index: 1, block: { type: 'text', text: 'answer' } },
{ type: 'finish', reason: { kind: 'stop' } },
])
})
it('treats an entirely absent reasoning_content field as non-thinking', async () => {
const chunks = await collect(translate(feed(
{ choices: [{ delta: { role: 'assistant', content: 'x' } }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
DONE,
)))
expect(chunks.filter(chunk => chunk.type === 'block-start')).toEqual([
{ type: 'block-start', index: 0, blockType: 'text' },
])
})
})
describe('translate: tool calls', () => {
it('reassembles a tool call from fragmented argument deltas (live capture shape)', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_00_x', type: 'function', function: { name: 'get_weather', arguments: '' } }] } }] },
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"city"' } }] } }] },
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: ': "Paris"}' } }] } }] },
{ choices: [{ delta: { content: '' }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 28, completion_tokens: 6 } },
DONE,
)))
expect(chunks).toEqual([
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '' },
{ type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '{"city"' },
{ type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: ': "Paris"}' },
{
type: 'block-end',
index: 0,
block: { type: 'tool-call', id: 'call_00_x', name: 'get_weather', arguments: '{"city": "Paris"}' },
},
{ type: 'usage', usage: { inputTokens: 28, outputTokens: 6 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
])
})
it('disambiguates parallel tool calls by wire index', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{
choices: [{
delta: {
tool_calls: [
{ index: 0, id: 'a', type: 'function', function: { name: 'one', arguments: '{}' } },
{ index: 1, id: 'b', type: 'function', function: { name: 'two', arguments: '' } },
],
},
}],
},
{ choices: [{ delta: { tool_calls: [{ index: 1, function: { arguments: '{}' } }] } }] },
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
DONE,
)))
const ends = chunks.filter(chunk => chunk.type === 'block-end')
expect(ends).toEqual([
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: 'a', name: 'one', arguments: '{}' } },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: 'b', name: 'two', arguments: '{}' } },
])
})
it('interleaves text and tool-call blocks with distinct indices', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'Checking.' } }] },
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f', arguments: '{}' } }] } }] },
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
DONE,
)))
const starts = chunks.filter(chunk => chunk.type === 'block-start')
expect(starts).toEqual([
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
])
})
})
describe('translate: finish and usage handling', () => {
it('takes usage from a trailing usage-only chunk (docs shape)', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'x' } }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }], usage: null },
{ choices: [], usage: { prompt_tokens: 9, completion_tokens: 1 } },
DONE,
)))
expect(chunks.at(-2)).toEqual({ type: 'usage', usage: { inputTokens: 9, outputTokens: 1 } })
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } })
})
it('last usage wins when both attached and trailing arrive', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } },
{ choices: [], usage: { prompt_tokens: 2, completion_tokens: 2 } },
DONE,
)))
const usage = chunks.find(chunk => chunk.type === 'usage')
expect(usage).toEqual({ type: 'usage', usage: { inputTokens: 2, outputTokens: 2 } })
})
it('defaults to finish stop when no finish_reason ever arrives', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'x' } }] },
DONE,
)))
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } })
})
it('omits the usage chunk when none arrived', async () => {
const chunks = await collect(translate(feed(firstChunk, DONE)))
expect(chunks.some(chunk => chunk.type === 'usage')).toBe(false)
})
it('handles chunks with no choices at all', async () => {
const chunks = await collect(translate(feed({}, DONE)))
expect(chunks).toEqual([{ type: 'finish', reason: { kind: 'stop' } }])
})
})
describe('translate: errors', () => {
it('throws MALFORMED_RESPONSE for invalid JSON payloads', async () => {
await expect(collect(translate(feed('{bad json')))).rejects.toThrow(LlmError)
await expect(collect(translate(feed('{bad json')))).rejects.toThrow(/malformed SSE payload/)
})
it('throws STREAM_CLOSED when the payload source ends without DONE', async () => {
await expect(collect(translate(feed(firstChunk)))).rejects.toThrow(/without \[DONE\]/)
})
})
describe('mapFinishReason', () => {
it.each([
['stop', { kind: 'stop' }],
['tool_calls', { kind: 'tool-calls' }],
['length', { kind: 'max-tokens' }],
])('maps %s', (wire, expected) => {
expect(mapFinishReason(wire)).toEqual(expected)
})
it.each(['content_filter', 'insufficient_system_resource', 'mystery_reason'])(
'maps %s to an error kind with the wire code',
(wire) => {
expect(mapFinishReason(wire)).toEqual({
kind: 'error',
message: `model stopped: ${wire}`,
code: wire.toUpperCase(),
})
},
)
})
describe('mapUsage', () => {
it('maps the full live-capture shape', () => {
expect(mapUsage({
prompt_tokens: 283,
completion_tokens: 69,
prompt_cache_hit_tokens: 256,
prompt_cache_miss_tokens: 27,
prompt_tokens_details: { cached_tokens: 256 },
completion_tokens_details: { reasoning_tokens: 24 },
})).toEqual({
// 283 wire prompt_tokens minus the 256 cached → 27 uncached input
// (TokenUsage counts are disjoint).
inputTokens: 27,
outputTokens: 69,
cacheReadTokens: 256,
reasoningTokens: 24,
})
})
it('falls back to prompt_cache_hit_tokens when details are absent', () => {
expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2, prompt_cache_hit_tokens: 8 }))
.toEqual({ inputTokens: 2, outputTokens: 2, cacheReadTokens: 8 })
})
it('omits optional fields when the wire omits them', () => {
expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2 }))
.toEqual({ inputTokens: 10, outputTokens: 2 })
})
})
describe('translate: defensive tool-call branches', () => {
it('handles deltas that never carry id or name (empty-string fallbacks)', async () => {
const chunks = await collect(translate(feed(
firstChunk,
// Hypothetical lenient wire: argument fragments with no id/name at all.
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{}' } }] } }] },
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
DONE,
)))
expect(chunks).toEqual([
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: '', name: '', arguments: '{}' } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
])
})
it('handles tool_call deltas with a function object but no arguments field', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f' } }] } }] },
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
DONE,
)))
expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', name: 'f', argumentsDelta: '' })
})
it('handles tool_call deltas with no function object at all', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c' }] } }] },
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
DONE,
)))
expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' })
})
})