Files
deepseek-harness/packages/llm-pi-ai/tests/adapter.e2e.ts
Tianyi Cui ab19fed77c 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.
2026-06-13 18:30:03 +08:00

131 lines
4.8 KiB
TypeScript

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 LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import type { Config } from '@deepseek-ai/dsh-llm-pi-ai'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
/**
* Real-API e2e for the pi-ai-backed adapter: V4 Flash + V4 Pro across all
* reasoning levels the adapter exposes (off / high / xhigh→wire 'max').
* Mirrors the llm-deepseek matrix so the two independent implementations
* verify the same StreamChunk contract. Key-gated.
*/
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(LlmPiAi, { 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('')
}
function blockKinds(result: GenerateResult): string[] {
return result.message.content.map(block => block.type)
}
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-pi-ai e2e (real API)', () => {
it.each([FLASH, PRO])('%s + reasoning off: plain text generation', async (model) => {
const ctx = await harness(model, { reasoning: 'off' })
const result = await ctx.llm.generate({
model,
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)
})
it.each([FLASH, PRO])('%s + reasoning high: reasoning blocks present', async (model) => {
const ctx = await harness(model, { reasoning: 'high' })
const result = await ctx.llm.generate({
model,
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')
})
it('pro + reasoning xhigh (wire max): tool-call round trip', async () => {
const ctx = await harness(PRO, { reasoning: 'xhigh' })
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 })
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('produces the same block structure as llm-deepseek for the same prompt', async () => {
// Loose structural equivalence between the two independent adapters:
// same block KINDS in the same order for a deterministic prompt — the
// cross-implementation check that the StreamChunk design holds.
const deepseekCtx = new Context()
await deepseekCtx.plugin(LlmService)
await deepseekCtx.plugin(LlmDeepSeek, { models: [FLASH], thinking: 'disabled' })
const piCtx = await harness(FLASH, { reasoning: 'off' })
const prompt = ask('Reply with exactly the word: pong')
const [fromDeepSeek, fromPiAi] = await Promise.all([
deepseekCtx.llm.generate({ model: FLASH, messages: prompt, maxTokens: 50 }),
piCtx.llm.generate({ model: FLASH, messages: prompt, maxTokens: 50 }),
])
expect(blockKinds(fromPiAi)).toEqual(blockKinds(fromDeepSeek))
expect(fromPiAi.finish.kind).toBe(fromDeepSeek.finish.kind)
})
})