adapter.e2e.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService, { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  4. import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
  5. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  6. import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
  7. import { assemble, type AssembledResult } from './assemble.ts'
  8. /**
  9. * Real-API e2e for the direct-fetch adapter: V4 Flash + V4 Pro across
  10. * thinking modes and both official effort levels. Key-gated — skips
  11. * entirely without $DEEPSEEK_API_KEY (see vitest.e2e.config.ts).
  12. */
  13. const FLASH = 'deepseek-v4-flash'
  14. const PRO = 'deepseek-v4-pro'
  15. const contexts: Context[] = []
  16. async function harness(_model: string, config: Partial<Config> = {}) {
  17. const ctx = new Context()
  18. contexts.push(ctx)
  19. await ctx.plugin(LlmService)
  20. await ctx.plugin(LlmDeepSeek, config)
  21. return ctx
  22. }
  23. afterEach(async () => {
  24. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  25. })
  26. function ask(text: string): Message[] {
  27. return [{ role: 'user', content: [{ type: 'text', text }] }]
  28. }
  29. function textOf(result: AssembledResult): string {
  30. return result.message.content
  31. .filter(block => block.type === 'text')
  32. .map(block => block.text)
  33. .join('')
  34. }
  35. const weatherTool: ToolSchema = {
  36. name: 'get_weather',
  37. description: 'Get the current weather for a city.',
  38. parameters: {
  39. type: 'object',
  40. properties: { city: { type: 'string', description: 'City name' } },
  41. required: ['city'],
  42. },
  43. }
  44. describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => {
  45. it('flash dynamically switches from off to high', async () => {
  46. const ctx = await harness(FLASH, { reasoningEffort: 'off' })
  47. const withoutThinking = await assemble(ctx,{
  48. model: FLASH,
  49. messages: ask('Reply with exactly the word: pong'),
  50. maxTokens: 50,
  51. })
  52. expect(withoutThinking.finish.kind).toBe('stop')
  53. expect(textOf(withoutThinking).toLowerCase()).toContain('pong')
  54. expect(withoutThinking.message.content.some(block => block.type === 'reasoning')).toBe(false)
  55. expect(withoutThinking.usage?.inputTokens).toBeGreaterThan(0)
  56. expect(withoutThinking.usage?.outputTokens).toBeGreaterThan(0)
  57. const withThinking = await assemble(ctx,{
  58. model: FLASH,
  59. reasoningEffort: ReasoningEffortId('high'),
  60. messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
  61. maxTokens: 2000,
  62. })
  63. expect(withThinking.finish.kind).toBe('stop')
  64. expect(withThinking.message.content.some(block => block.type === 'reasoning')).toBe(true)
  65. expect(textOf(withThinking)).toContain('9.8')
  66. expect(withThinking.usage?.reasoningTokens).toBeGreaterThan(0)
  67. })
  68. it.each(['high', 'max'] as const)(
  69. 'pro + thinking enabled (effort %s): tool-call round trip with reasoning passback',
  70. async (effort) => {
  71. const ctx = await harness(PRO, { thinking: 'enabled' })
  72. // Turn 1: the model must call the tool (and think before it).
  73. const first = await assemble(ctx,{
  74. model: PRO,
  75. reasoningEffort: ReasoningEffortId(effort),
  76. messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
  77. tools: [weatherTool],
  78. maxTokens: 2000,
  79. })
  80. expect(first.finish.kind).toBe('tool-calls')
  81. const call = first.message.content.find(block => block.type === 'tool-call')
  82. expect(call).toBeDefined()
  83. expect(call!.name).toBe('get_weather')
  84. expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string })
  85. // Turn 2: send the tool result back WITH the assistant's reasoning
  86. // block in history (the official thinking+tools passback rule).
  87. const second = await assemble(ctx,{
  88. model: PRO,
  89. reasoningEffort: ReasoningEffortId(effort),
  90. messages: [
  91. ...ask('What is the weather in Paris right now? Use the get_weather tool.'),
  92. { role: 'assistant', content: first.message.content },
  93. {
  94. role: 'user',
  95. content: [{
  96. type: 'tool-result',
  97. toolCallId: CallId(call!.id),
  98. content: [{ type: 'text', text: 'Sunny, 22°C' }],
  99. }],
  100. },
  101. ],
  102. tools: [weatherTool],
  103. maxTokens: 2000,
  104. })
  105. expect(second.finish.kind).toBe('stop')
  106. expect(textOf(second).toLowerCase()).toMatch(/sunny|22/)
  107. },
  108. )
  109. it('pro + thinking disabled: plain generation without reasoning blocks', async () => {
  110. const ctx = await harness(PRO, { thinking: 'disabled' })
  111. const result = await assemble(ctx,{
  112. model: PRO,
  113. messages: ask('Reply with exactly the word: pong'),
  114. maxTokens: 50,
  115. })
  116. expect(result.finish.kind).toBe('stop')
  117. expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
  118. })
  119. it('streams raw chunks in protocol order', async () => {
  120. const ctx = await harness(FLASH, { thinking: 'disabled' })
  121. const kinds: string[] = []
  122. for await (const chunk of ctx.llm.stream({
  123. provider: 'deepseek',
  124. model: FLASH,
  125. messages: ask('Count from 1 to 5, digits only.'),
  126. maxTokens: 50,
  127. })) {
  128. kinds.push(chunk.type)
  129. }
  130. expect(kinds[0]).toBe('block-start')
  131. expect(kinds.at(-1)).toBe('finish')
  132. expect(kinds.filter(kind => kind === 'finish')).toHaveLength(1)
  133. // usage precedes finish (deferred-emit contract)
  134. expect(kinds.indexOf('usage')).toBeLessThan(kinds.indexOf('finish'))
  135. })
  136. })