adapter.e2e.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  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 LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
  6. import type { PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
  7. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  8. import { assemble, type AssembledResult } from './assemble.ts'
  9. /**
  10. * Real-API e2e for the pi-ai-backed adapter: V4 Flash + V4 Pro with provider
  11. * defaults and representative off/high/max reasoning. Mirrors the native
  12. * adapter's StreamChunk contract and exercises a replayed tool follow-up.
  13. * Key-gated.
  14. */
  15. const FLASH = 'deepseek-v4-flash'
  16. const PRO = 'deepseek-v4-pro'
  17. const contexts: Context[] = []
  18. async function harness(_model: string, config: Partial<PiAiProviderProfile> = {}) {
  19. const ctx = new Context()
  20. contexts.push(ctx)
  21. await ctx.plugin(LlmService)
  22. await ctx.plugin(LlmPiAi, {
  23. providers: [{
  24. provider: 'deepseek',
  25. ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY },
  26. ...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL },
  27. ...config,
  28. }],
  29. })
  30. return ctx
  31. }
  32. afterEach(async () => {
  33. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  34. })
  35. function ask(text: string): Message[] {
  36. return [{ role: 'user', content: [{ type: 'text', text }] }]
  37. }
  38. function textOf(result: AssembledResult): string {
  39. return result.message.content
  40. .filter(block => block.type === 'text')
  41. .map(block => block.text)
  42. .join('')
  43. }
  44. function blockKinds(result: AssembledResult): string[] {
  45. return result.message.content.map(block => block.type)
  46. }
  47. const weatherTool: ToolSchema = {
  48. name: 'get_weather',
  49. description: 'Get the current weather for a city.',
  50. parameters: {
  51. type: 'object',
  52. properties: { city: { type: 'string', description: 'City name' } },
  53. required: ['city'],
  54. },
  55. }
  56. describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => {
  57. it.each([FLASH, PRO])('%s + provider-default reasoning: plain text generation', async (model) => {
  58. const ctx = await harness(model)
  59. const result = await assemble(ctx,{
  60. model,
  61. messages: ask('Reply with exactly the word: pong'),
  62. maxTokens: 50,
  63. })
  64. expect(result.finish.kind).toBe('stop')
  65. expect(textOf(result).toLowerCase()).toContain('pong')
  66. })
  67. it('flash + reasoning off: plain text without reasoning blocks', async () => {
  68. const ctx = await harness(FLASH)
  69. const result = await assemble(ctx,{
  70. model: FLASH,
  71. reasoningEffort: ReasoningEffortId('off'),
  72. messages: ask('Reply with exactly the word: pong'),
  73. maxTokens: 50,
  74. })
  75. expect(result.finish.kind).toBe('stop')
  76. expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
  77. expect(textOf(result).toLowerCase()).toContain('pong')
  78. })
  79. it.each([FLASH, PRO])('%s + reasoning high: reasoning blocks present', async (model) => {
  80. const ctx = await harness(model)
  81. const result = await assemble(ctx,{
  82. model,
  83. reasoningEffort: ReasoningEffortId('high'),
  84. messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
  85. maxTokens: 2000,
  86. })
  87. expect(result.finish.kind).toBe('stop')
  88. expect(result.message.content.some(block => block.type === 'reasoning')).toBe(true)
  89. expect(textOf(result)).toContain('9.8')
  90. })
  91. it('pro + reasoning max: tool-call round trip', async () => {
  92. const ctx = await harness(PRO)
  93. const first = await assemble(ctx,{
  94. model: PRO,
  95. reasoningEffort: ReasoningEffortId('max'),
  96. messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
  97. tools: [weatherTool],
  98. maxTokens: 2000,
  99. })
  100. expect(first.finish.kind).toBe('tool-calls')
  101. const call = first.message.content.find(block => block.type === 'tool-call')
  102. expect(call).toBeDefined()
  103. expect(call!.name).toBe('get_weather')
  104. expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string })
  105. const second = await assemble(ctx,{
  106. model: PRO,
  107. reasoningEffort: ReasoningEffortId('max'),
  108. messages: [
  109. ...ask('What is the weather in Paris right now? Use the get_weather tool.'),
  110. first.message,
  111. {
  112. role: 'user',
  113. content: [{
  114. type: 'tool-result',
  115. toolCallId: CallId(call!.id),
  116. content: [{ type: 'text', text: 'Sunny, 22°C' }],
  117. }],
  118. },
  119. ],
  120. tools: [weatherTool],
  121. maxTokens: 2000,
  122. })
  123. expect(second.finish.kind).toBe('stop')
  124. expect(textOf(second).toLowerCase()).toMatch(/sunny|22/)
  125. })
  126. it('produces the same block structure as llm-deepseek for the same prompt', async () => {
  127. // Loose structural equivalence between the two independent adapters:
  128. // same block KINDS in the same order for a deterministic prompt — the
  129. // cross-implementation check that the StreamChunk design holds.
  130. const deepseekCtx = new Context()
  131. contexts.push(deepseekCtx)
  132. await deepseekCtx.plugin(LlmService)
  133. await deepseekCtx.plugin(LlmDeepSeek, { thinking: 'disabled' })
  134. const piCtx = await harness(FLASH)
  135. const prompt = ask('Reply with exactly the word: pong')
  136. const [fromDeepSeek, fromPiAi] = await Promise.all([
  137. assemble(deepseekCtx, { model: FLASH, messages: prompt, maxTokens: 50 }),
  138. assemble(piCtx, { model: FLASH, messages: prompt, maxTokens: 50 }),
  139. ])
  140. expect(blockKinds(fromPiAi)).toEqual(blockKinds(fromDeepSeek))
  141. expect(fromPiAi.finish.kind).toBe(fromDeepSeek.finish.kind)
  142. })
  143. })