feat(llm): route adapters by provider
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai'
|
||||
import { mapStopReason, mapUsage, toPiContext, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { mapStopReason, mapUsage, toPiContext, toPiReplayState, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
|
||||
function usage(input = 0, output = 0, cacheRead = 0, cacheWrite = 0): Usage {
|
||||
return {
|
||||
@@ -42,6 +42,7 @@ async function collect(stream: AsyncIterable<StreamChunk>): Promise<StreamChunk[
|
||||
describe('toPiContext', () => {
|
||||
it('maps system prompt, user text, and tools', () => {
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
system: 'be helpful',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
@@ -55,13 +56,14 @@ describe('toPiContext', () => {
|
||||
})
|
||||
|
||||
it('omits empty tools and absent system prompt', () => {
|
||||
const context = toPiContext({ model: 'm', messages: [], tools: [] })
|
||||
const context = toPiContext({ provider: 'deepseek', model: 'm', messages: [], tools: [] })
|
||||
expect(context.systemPrompt).toBeUndefined()
|
||||
expect(context.tools).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps assistant text/reasoning/tool-call blocks', () => {
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
@@ -76,8 +78,7 @@ describe('toPiContext', () => {
|
||||
expect(message.role).toBe('assistant')
|
||||
expect(message.stopReason).toBe('toolUse')
|
||||
expect(message.content).toEqual([
|
||||
// thinkingSignature names the replay field — DeepSeek's passback rule.
|
||||
{ type: 'thinking', thinking: 'hmm', thinkingSignature: 'reasoning_content' },
|
||||
{ type: 'thinking', thinking: 'hmm' },
|
||||
{ type: 'text', text: 'calling' },
|
||||
{ type: 'toolCall', id: 'c1', name: 'f', arguments: { a: 1 } },
|
||||
])
|
||||
@@ -85,6 +86,7 @@ describe('toPiContext', () => {
|
||||
|
||||
it('marks tool-call-free assistant messages with stopReason stop', () => {
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [{ role: 'assistant', content: [{ type: 'text', text: 'done' }] }],
|
||||
})
|
||||
@@ -93,6 +95,7 @@ describe('toPiContext', () => {
|
||||
|
||||
it('parses malformed tool-call arguments to {}', () => {
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
@@ -105,6 +108,7 @@ describe('toPiContext', () => {
|
||||
|
||||
it('parses non-object argument JSON (arrays, scalars) to {}', () => {
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
@@ -116,6 +120,7 @@ describe('toPiContext', () => {
|
||||
|
||||
it('recovers toolName for tool results from the preceding assistant call', () => {
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [
|
||||
{
|
||||
@@ -140,6 +145,7 @@ describe('toPiContext', () => {
|
||||
|
||||
it('labels unmatched tool results with toolName unknown and keeps isError', () => {
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
@@ -156,6 +162,7 @@ describe('toPiContext', () => {
|
||||
|
||||
it('splits mixed user text + tool results and folds history system messages', () => {
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [
|
||||
{ role: 'system', content: [{ type: 'text', text: 'rule' }] },
|
||||
@@ -173,6 +180,7 @@ describe('toPiContext', () => {
|
||||
|
||||
it('skips plugin-added (unknown) blocks in assistant content', () => {
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
@@ -184,6 +192,173 @@ describe('toPiContext', () => {
|
||||
})
|
||||
expect((context.messages[0] as AssistantMessage).content).toEqual([{ type: 'text', text: 'visible' }])
|
||||
})
|
||||
|
||||
it('recombines durable content with pi-ai replay metadata across target providers and models', () => {
|
||||
const state = toPiReplayState(assistant({
|
||||
api: 'openai-responses',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5',
|
||||
responseModel: 'gpt-5-2026-01-01',
|
||||
responseId: 'resp_123',
|
||||
stopReason: 'toolUse',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'private reasoning', thinkingSignature: 'think-sig', redacted: true },
|
||||
{ type: 'text', text: 'calling', textSignature: 'text-sig' },
|
||||
{ type: 'toolCall', id: 'c1', name: 'f', arguments: { a: 1 }, thoughtSignature: 'tool-sig' },
|
||||
],
|
||||
}))
|
||||
const context = toPiContext({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-next',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'private reasoning' },
|
||||
{ type: 'text', text: 'calling' },
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{"a":1}' },
|
||||
],
|
||||
provenance: { provider: 'openai', model: 'gpt-5', replayState: state },
|
||||
}],
|
||||
})
|
||||
|
||||
expect(context.messages[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
api: 'openai-responses',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5',
|
||||
responseModel: 'gpt-5-2026-01-01',
|
||||
responseId: 'resp_123',
|
||||
stopReason: 'toolUse',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'private reasoning', thinkingSignature: 'think-sig', redacted: true },
|
||||
{ type: 'text', text: 'calling', textSignature: 'text-sig' },
|
||||
{ type: 'toolCall', id: 'c1', name: 'f', arguments: { a: 1 }, thoughtSignature: 'tool-sig' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('replays all native block kinds when optional metadata is absent', () => {
|
||||
const state = toPiReplayState(assistant({
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'private reasoning' },
|
||||
{ type: 'text', text: 'calling' },
|
||||
{ type: 'toolCall', id: 'c1', name: 'f', arguments: { a: 1 } },
|
||||
],
|
||||
}))
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'new-model',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'private reasoning' },
|
||||
{ type: 'text', text: 'calling' },
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{"a":1}' },
|
||||
],
|
||||
provenance: { provider: 'deepseek', model: 'old-model', replayState: state },
|
||||
}],
|
||||
})
|
||||
|
||||
expect(context.messages[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'private reasoning' },
|
||||
{ type: 'text', text: 'calling' },
|
||||
{ type: 'toolCall', id: 'c1', name: 'f', arguments: { a: 1 } },
|
||||
],
|
||||
})
|
||||
expect(context.messages[0]).not.toHaveProperty('responseModel')
|
||||
expect(context.messages[0]).not.toHaveProperty('responseId')
|
||||
})
|
||||
|
||||
it('rejects unsupported replay-state versions with a stable error code', () => {
|
||||
try {
|
||||
toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
provenance: {
|
||||
provider: 'deepseek',
|
||||
model: 'old',
|
||||
replayState: { kind: 'pi-ai', version: 2 },
|
||||
},
|
||||
}],
|
||||
})
|
||||
expect.fail('expected invalid replay state')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(LlmError)
|
||||
expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE')
|
||||
expect((error as Error).message).toContain('unsupported version 2')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects replay metadata whose blocks do not match the durable content', () => {
|
||||
const state = toPiReplayState(assistant({ content: [{ type: 'text', text: 'done' }] }))
|
||||
expect(() => toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'reasoning', text: 'done' }],
|
||||
provenance: { provider: 'deepseek', model: 'old', replayState: state },
|
||||
}],
|
||||
})).toThrow(/block 0 does not match assistant content/)
|
||||
})
|
||||
|
||||
it('rejects replay metadata whose block count differs from durable content', () => {
|
||||
const state = toPiReplayState(assistant())
|
||||
expect(() => toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
provenance: { provider: 'deepseek', model: 'old', replayState: state },
|
||||
}],
|
||||
})).toThrow(/block count does not match assistant content/)
|
||||
})
|
||||
|
||||
const validReplay = {
|
||||
kind: 'pi-ai',
|
||||
version: 1,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
stopReason: 'stop',
|
||||
blocks: [{ type: 'text' }],
|
||||
}
|
||||
|
||||
it.each([
|
||||
['number state', 1, 'expected an object'],
|
||||
['null state', null, 'expected an object'],
|
||||
['array state', [], 'expected an object'],
|
||||
['unknown kind', { ...validReplay, kind: 'other' }, 'unknown state kind'],
|
||||
['non-string api', { ...validReplay, api: 1 }, 'api must be a non-empty string'],
|
||||
['empty provider', { ...validReplay, provider: '' }, 'provider must be a non-empty string'],
|
||||
['missing model', { ...validReplay, model: undefined }, 'model must be a non-empty string'],
|
||||
['unknown stop reason', { ...validReplay, stopReason: 'pause' }, 'unknown stopReason'],
|
||||
['non-string response model', { ...validReplay, responseModel: 1 }, 'responseModel must be a string'],
|
||||
['non-string response id', { ...validReplay, responseId: 1 }, 'responseId must be a string'],
|
||||
['non-array blocks', { ...validReplay, blocks: 'text' }, 'blocks must be an array'],
|
||||
['number block', { ...validReplay, blocks: [1] }, 'block 0 must be an object'],
|
||||
['null block', { ...validReplay, blocks: [null] }, 'block 0 must be an object'],
|
||||
['array block', { ...validReplay, blocks: [[]] }, 'block 0 must be an object'],
|
||||
['unknown block type', { ...validReplay, blocks: [{ type: 'audio' }] }, 'block 0 has an unknown type'],
|
||||
['non-string signature', { ...validReplay, blocks: [{ type: 'text', textSignature: 1 }] }, 'textSignature must be a string'],
|
||||
['non-boolean redaction', { ...validReplay, blocks: [{ type: 'reasoning', redacted: 'yes' }] }, 'redacted must be boolean'],
|
||||
])('rejects malformed replay state: %s', (_name, replayState, message) => {
|
||||
expect(() => toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
provenance: { provider: 'deepseek', model: 'old', replayState },
|
||||
}],
|
||||
})).toThrow(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toStreamChunks', () => {
|
||||
@@ -205,7 +380,19 @@ describe('toStreamChunks', () => {
|
||||
{ type: 'text-delta', index: 0, text: 'hi' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } },
|
||||
{ type: 'usage', usage: { inputTokens: 3, outputTokens: 2 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
{
|
||||
type: 'finish',
|
||||
reason: { kind: 'stop' },
|
||||
replayState: {
|
||||
kind: 'pi-ai',
|
||||
version: 1,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
stopReason: 'stop',
|
||||
blocks: [{ type: 'text' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -234,7 +421,7 @@ describe('toStreamChunks', () => {
|
||||
toolCall: { type: 'toolCall', id: 'call-1', name: 'f', arguments: { a: 1 } },
|
||||
partial: partialWithToolCall,
|
||||
},
|
||||
{ type: 'done', reason: 'toolUse', message: assistant({ stopReason: 'toolUse' }) },
|
||||
{ type: 'done', reason: 'toolUse', message: assistant({ content: partialWithToolCall.content, stopReason: 'toolUse' }) },
|
||||
)))
|
||||
expect(chunks).toEqual([
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
@@ -242,7 +429,19 @@ describe('toStreamChunks', () => {
|
||||
{ type: 'tool-call-delta', index: 0, id: 'call-1', name: 'f', argumentsDelta: ':1}' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: 'call-1', name: 'f', arguments: '{"a":1}' } },
|
||||
{ type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
{
|
||||
type: 'finish',
|
||||
reason: { kind: 'tool-calls' },
|
||||
replayState: {
|
||||
kind: 'pi-ai',
|
||||
version: 1,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
stopReason: 'toolUse',
|
||||
blocks: [{ type: 'tool-call' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user