adapter.e2e.ts 7.1 KB

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