Files
deepseek-harness/packages/llm/llm-deepseek/tests/adapter.e2e.ts
T
Tianyi Cui bfdaf7600b Merge remote-tracking branch 'origin/master' into worktree/pr674-retarget-20260727
# Conflicts:
#	docs/cookbook/adding-an-llm-adapter.i18n.yaml
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	packages/llm/llm-deepseek/README.i18n.yaml
#	packages/llm/llm/README.i18n.yaml
2026-07-27 15:48:17 +08:00

151 lines
5.5 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { 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'
import { assemble, type AssembledResult } from './assemble.ts'
/**
* Real-API e2e for the direct-fetch 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'
const contexts: Context[] = []
async function harness(_model: string, config: Partial<Config> = {}) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, config)
return ctx
}
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
function ask(text: string): Message[] {
return [{ role: 'user', content: [{ type: 'text', text }] }]
}
function textOf(result: AssembledResult): 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 dynamically switches from off to high', async () => {
const ctx = await harness(FLASH, { reasoningEffort: 'off' })
const withoutThinking = await assemble(ctx,{
model: FLASH,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
})
expect(withoutThinking.finish.kind).toBe('stop')
expect(textOf(withoutThinking).toLowerCase()).toContain('pong')
expect(withoutThinking.message.content.some(block => block.type === 'reasoning')).toBe(false)
expect(withoutThinking.usage?.inputTokens).toBeGreaterThan(0)
expect(withoutThinking.usage?.outputTokens).toBeGreaterThan(0)
const withThinking = await assemble(ctx,{
model: FLASH,
reasoningEffort: ReasoningEffortId('high'),
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
maxTokens: 2000,
})
expect(withThinking.finish.kind).toBe('stop')
expect(withThinking.message.content.some(block => block.type === 'reasoning')).toBe(true)
expect(textOf(withThinking)).toContain('9.8')
expect(withThinking.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' })
// Turn 1: the model must call the tool (and think before it).
const first = await assemble(ctx,{
model: PRO,
reasoningEffort: ReasoningEffortId(effort),
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 assemble(ctx,{
model: PRO,
reasoningEffort: ReasoningEffortId(effort),
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 assemble(ctx,{
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({
provider: 'deepseek',
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'))
})
})