270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
|
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
|
import { serializeMessages, serializeRequest } from '../src/serialize.ts'
|
|
|
|
function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
|
|
return { provider: 'deepseek', 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 plugin-added block types (merge-extensible ContentBlockMap)', () => {
|
|
const wire = serializeMessages([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'chart', data: 'x' } as unknown as ContentBlock,
|
|
{ type: 'text', text: 'see chart' },
|
|
],
|
|
},
|
|
])
|
|
expect(wire).toEqual([{ role: 'user', content: 'see chart' }])
|
|
})
|
|
|
|
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 to the wire function shape', () => {
|
|
const wire = serializeRequest(request({
|
|
messages: history,
|
|
tools: [
|
|
{ name: 'a', description: 'A', parameters: { type: 'object', properties: {} } },
|
|
{ name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } },
|
|
],
|
|
}))
|
|
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: { x: { type: 'string' } } } } },
|
|
])
|
|
})
|
|
|
|
it('omits an empty tools array', () => {
|
|
const wire = serializeRequest(request({ messages: history, tools: [] }))
|
|
expect(wire.tools).toBeUndefined()
|
|
})
|
|
|
|
it('maps adapter-default thinking and the request reasoning effort', () => {
|
|
const wire = serializeRequest(
|
|
request({ messages: history, reasoningEffort: ReasoningEffortId('max') }),
|
|
{ thinking: 'enabled', reasoningEffort: 'high' },
|
|
)
|
|
expect(wire.thinking).toEqual({ type: 'enabled' })
|
|
expect(wire.reasoning_effort).toBe('max')
|
|
})
|
|
|
|
it('maps off to disabled thinking without a wire reasoning effort', () => {
|
|
const wire = serializeRequest(
|
|
request({ messages: history, reasoningEffort: ReasoningEffortId('off') }),
|
|
{ thinking: 'enabled', reasoningEffort: 'max' },
|
|
)
|
|
expect(wire.thinking).toEqual({ type: 'disabled' })
|
|
expect(wire.reasoning_effort).toBeUndefined()
|
|
})
|
|
|
|
it('re-enables thinking when max overrides an off default', () => {
|
|
const wire = serializeRequest(
|
|
request({ messages: history, reasoningEffort: ReasoningEffortId('max') }),
|
|
{ reasoningEffort: 'off' },
|
|
)
|
|
expect(wire.thinking).toEqual({ type: 'enabled' })
|
|
expect(wire.reasoning_effort).toBe('max')
|
|
})
|
|
|
|
it('rejects enabling thinking when the deployment is locked to disabled', () => {
|
|
expect(() => serializeRequest(
|
|
request({ messages: history, reasoningEffort: ReasoningEffortId('high') }),
|
|
{ thinking: 'disabled' },
|
|
)).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' }))
|
|
})
|
|
|
|
it('disables thinking for session-title requests without changing adapter defaults', () => {
|
|
const wire = serializeRequest(
|
|
request({
|
|
messages: history,
|
|
purpose: 'session-title',
|
|
reasoningEffort: ReasoningEffortId('max'),
|
|
}),
|
|
{ thinking: 'enabled', reasoningEffort: 'max' },
|
|
)
|
|
expect(wire.thinking).toEqual({ type: 'disabled' })
|
|
expect(wire.reasoning_effort).toBeUndefined()
|
|
})
|
|
|
|
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('preserves an explicit enabled default without inventing a wire effort', () => {
|
|
const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled' })
|
|
expect(wire.thinking).toEqual({ type: 'enabled' })
|
|
expect(wire.reasoning_effort).toBeUndefined()
|
|
})
|
|
|
|
it('rejects an effort outside the DeepSeek capability', () => {
|
|
expect(() => serializeRequest(request({
|
|
messages: history,
|
|
reasoningEffort: ReasoningEffortId('medium'),
|
|
}))).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' }))
|
|
})
|
|
})
|
|
|
|
describe('review fixes: assistant content shapes', () => {
|
|
it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => {
|
|
// Aborted/empty assistant turns: no text, no calls → "". The earlier
|
|
// null shape was live-falsified: the API 400s a null-content assistant
|
|
// message without tool_calls ("content or tool_calls must be set").
|
|
const wire = serializeMessages([{ role: 'assistant', content: [] }])
|
|
expect(wire).toEqual([{ role: 'assistant', content: '' }])
|
|
})
|
|
|
|
it('serializes a reasoning-ONLY assistant message as "" content with the reasoning dropped', () => {
|
|
// The model can answer entirely in the reasoning channel (a v4-flash
|
|
// greeting did, live). The passback rule keeps reasoning_content off
|
|
// plain turns, and content must still be SET — a null here poisoned the
|
|
// session log and bricked every later turn of that session.
|
|
const wire = serializeMessages([{ role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }] }])
|
|
expect(wire).toEqual([{ role: 'assistant', content: '' }])
|
|
})
|
|
|
|
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: '' })
|
|
})
|
|
})
|