ImageBlock had no production producer and every consumer dropped it: the deepseek serializer skipped it, the pi-ai converter skipped it as unrepresentable, the ACP bridge neither advertises image prompt capability nor forwards image blocks, and compact-basic charged a flat 85-token estimate and rendered an [image] placeholder. A block constructed today would silently vanish from the wire — the vocabulary advertised a capability no path honors, the silent-data-loss shape the defensive patterns warn against. The only constructors were tests pinning the skip/estimate branches. Remove ImageBlock and its ContentBlockMap entry (its cache?: CacheHint field leaves with it; CacheHint itself and the other two cache? fields are out of scope). compact-basic loses its explicit image estimate and placeholder arms (the merge-extensible default arms absorb the case); the deepseek serializer, pi-ai converter, and ACP codec already handled image in their default arms, so only their image-naming comments change. The codec's inbound rejection of ACP-protocol image prompt content stays — that guards wire content a client can send regardless of our vocabulary. Tests that constructed harness image blocks to pin the removed branches are dropped (the 85-token estimate pin) or retargeted onto plugin-added block types / other non-text blocks, which the surviving default arms own. Docs, the type-equiv pastes, and the content-block vocabulary RFC's block list and multimodal-home consequence are updated in the same change; the RFC moves to implemented/ and the index is regenerated. A real multimodal feature reintroduces image via declaration merging together with the adapter mapping, ACP advertisement, and compaction pricing that honor it.
217 lines
7.8 KiB
TypeScript
217 lines
7.8 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
|
import type { ContentBlock, 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 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 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: '' })
|
|
})
|
|
})
|