protocol.spec.ts 5.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. /** Same-provider protocol changes retain prepared requests and durable conversation content. */
  2. import { afterEach, expect, it } from 'vitest'
  3. import type { Message } from '@deepseek-ai/dsh-llm'
  4. import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
  5. import { Config, DeepSeekAdapter, resolveAdapterOptions } from '../src/index.ts'
  6. import type { DeepSeekConnectionOptions } from '../src/index.ts'
  7. import { assemble, chunks, end, MODEL, options, server, sse, start, textEvents, user } from './messages/helpers.ts'
  8. const close: (() => Promise<void>)[] = []
  9. afterEach(async () => {
  10. while (close.length) await close.pop()!()
  11. })
  12. async function endpoint(...args: Parameters<typeof server>) {
  13. const instance = await server(...args)
  14. close.push(() => instance.close())
  15. return instance
  16. }
  17. const chat = 'data: {"choices":[{"delta":{"content":"Chat answer"}}]}\n\n'
  18. + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n'
  19. function adapter(connection: () => DeepSeekConnectionOptions) {
  20. return new DeepSeekAdapter({
  21. options: connection,
  22. resolveApiKey: snapshot => Promise.resolve(`key-for-${snapshot.apiKeyEnv}`),
  23. resolveUserId: () => '00000000-0000-4000-8000-000000000001' as AnonymousUserId,
  24. prepareExtensions: () => Promise.resolve({ fields: {}, accept: () => Promise.resolve() }),
  25. })
  26. }
  27. it.each([false, true])('uses Messages when protocol is omitted, schema=%s', async (schema) => {
  28. const http = await endpoint()
  29. const raw = { baseURL: http.url }
  30. const connection = resolveAdapterOptions(schema ? Config(raw) : raw)
  31. const response = await assemble(adapter(() => connection).stream(options()))
  32. expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }])
  33. expect(http.requests).toHaveLength(1)
  34. expect(http.requests[0]).toMatchObject({
  35. path: '/anthropic/v1/messages',
  36. headers: { 'x-api-key': 'key-for-DEEPSEEK_API_KEY', 'anthropic-version': '2023-06-01' },
  37. body: { model: MODEL, messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }] },
  38. })
  39. })
  40. it('keeps the prepared Messages protocol, credential reference and endpoint after switching to Chat', async () => {
  41. const first = await endpoint(), second = await endpoint(response => response.end(chat))
  42. let connection = resolveAdapterOptions({ protocol: 'messages', baseURL: first.url, apiKeyEnv: 'MESSAGES_KEY', maxTokens: 12 })
  43. const llm = adapter(() => connection)
  44. const prepared = await llm.prepareCall('deepseek-official', MODEL)
  45. connection = resolveAdapterOptions({ protocol: 'chat-completions', baseURL: second.url, apiKeyEnv: 'CHAT_KEY', maxTokens: 24 })
  46. await chunks(prepared.stream(options()))
  47. await chunks(prepared.stream(options()))
  48. expect(prepared.model.defaultMaxTokens).toBe(12)
  49. expect((await llm.resolveModel('deepseek-official', MODEL)).defaultMaxTokens).toBe(24)
  50. await chunks(llm.stream(options()))
  51. expect(first.requests).toHaveLength(2)
  52. for (const request of first.requests) expect(request).toMatchObject({
  53. path: '/anthropic/v1/messages', headers: { 'x-api-key': 'key-for-MESSAGES_KEY' }, body: { max_tokens: 12 },
  54. })
  55. expect(second.requests).toHaveLength(1)
  56. expect(second.requests[0]).toMatchObject({ path: '/anthropic/chat/completions', headers: { authorization: 'Bearer key-for-CHAT_KEY' } })
  57. })
  58. it('continues Messages → Chat → Messages with the same provider and without leaking native signatures to Chat', async () => {
  59. const signed = [start,
  60. { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: 'Reasoning', signature: 'native-signature' } },
  61. { type: 'content_block_stop', index: 0 },
  62. { type: 'content_block_start', index: 1, content_block: { type: 'text', text: 'Messages answer' } },
  63. { type: 'content_block_stop', index: 1 }, ...end(),
  64. ]
  65. const http = await endpoint((response, count) => response.end(count === 1 ? sse(signed) : count === 2 ? chat : sse(textEvents)))
  66. let connection = resolveAdapterOptions({ protocol: 'messages', baseURL: http.url })
  67. const llm = adapter(() => connection)
  68. const history: Message[] = [user()]
  69. const first = await assemble(llm.stream(options({ messages: history })))
  70. history.push(first.message, user('continue with Chat'))
  71. const saved = JSON.stringify(history)
  72. connection = resolveAdapterOptions({ protocol: 'chat-completions', baseURL: http.url })
  73. const second = await assemble(llm.stream(options({ messages: history })))
  74. expect(JSON.stringify(http.requests[1]?.body)).not.toContain('signature')
  75. expect(http.requests[1]?.body.messages).toContainEqual({ role: 'assistant', content: 'Messages answer', reasoning_content: 'Reasoning' })
  76. expect(JSON.stringify(history)).toBe(saved)
  77. expect(second.message.source.replayState).toBeUndefined()
  78. history.push(second.message, user('continue with Messages'))
  79. connection = resolveAdapterOptions({ protocol: 'messages', baseURL: http.url })
  80. const third = await assemble(llm.stream(options({ messages: history })))
  81. expect(third.assembler.finish.kind).toBe('stop')
  82. const messages = http.requests[2]?.body.messages as { role: string; content: unknown[] }[]
  83. expect(messages.filter(message => message.role === 'assistant')).toEqual([
  84. { role: 'assistant', content: [{ type: 'thinking', thinking: 'Reasoning', signature: 'native-signature' }, { type: 'text', text: 'Messages answer' }] },
  85. { role: 'assistant', content: [{ type: 'text', text: 'Chat answer' }] },
  86. ])
  87. })