protocol.spec.ts 4.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
  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 { 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('keeps the prepared Messages protocol, credential reference and endpoint after switching to Chat', async () => {
  28. const first = await endpoint(), second = await endpoint(response => response.end(chat))
  29. let connection = resolveAdapterOptions({ protocol: 'messages', baseURL: first.url, apiKeyEnv: 'MESSAGES_KEY', maxTokens: 12 })
  30. const llm = adapter(() => connection)
  31. const prepared = await llm.prepareCall('deepseek-official', MODEL)
  32. connection = resolveAdapterOptions({ baseURL: second.url, apiKeyEnv: 'CHAT_KEY', maxTokens: 24 })
  33. await chunks(prepared.stream(options()))
  34. await chunks(prepared.stream(options()))
  35. expect(prepared.model.defaultMaxTokens).toBe(12)
  36. expect((await llm.resolveModel('deepseek-official', MODEL)).defaultMaxTokens).toBe(24)
  37. await chunks(llm.stream(options()))
  38. expect(first.requests).toHaveLength(2)
  39. for (const request of first.requests) expect(request).toMatchObject({
  40. path: '/anthropic/v1/messages', headers: { 'x-api-key': 'key-for-MESSAGES_KEY' }, body: { max_tokens: 12 },
  41. })
  42. expect(second.requests).toHaveLength(1)
  43. expect(second.requests[0]).toMatchObject({ path: '/anthropic/chat/completions', headers: { authorization: 'Bearer key-for-CHAT_KEY' } })
  44. })
  45. it('continues Messages → Chat → Messages with the same provider and without leaking native signatures to Chat', async () => {
  46. const signed = [start,
  47. { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: 'Reasoning', signature: 'native-signature' } },
  48. { type: 'content_block_stop', index: 0 },
  49. { type: 'content_block_start', index: 1, content_block: { type: 'text', text: 'Messages answer' } },
  50. { type: 'content_block_stop', index: 1 }, ...end(),
  51. ]
  52. const http = await endpoint((response, count) => response.end(count === 1 ? sse(signed) : count === 2 ? chat : sse(textEvents)))
  53. let connection = resolveAdapterOptions({ protocol: 'messages', baseURL: http.url })
  54. const llm = adapter(() => connection)
  55. const history: Message[] = [user()]
  56. const first = await assemble(llm.stream(options({ messages: history })))
  57. history.push(first.message, user('continue with Chat'))
  58. const saved = JSON.stringify(history)
  59. connection = resolveAdapterOptions({ baseURL: http.url })
  60. const second = await assemble(llm.stream(options({ messages: history })))
  61. expect(JSON.stringify(http.requests[1]?.body)).not.toContain('signature')
  62. expect(http.requests[1]?.body.messages).toContainEqual({ role: 'assistant', content: 'Messages answer', reasoning_content: 'Reasoning' })
  63. expect(JSON.stringify(history)).toBe(saved)
  64. expect(second.message.source.replayState).toBeUndefined()
  65. history.push(second.message, user('continue with Messages'))
  66. connection = resolveAdapterOptions({ protocol: 'messages', baseURL: http.url })
  67. const third = await assemble(llm.stream(options({ messages: history })))
  68. expect(third.assembler.finish.kind).toBe('stop')
  69. const messages = http.requests[2]?.body.messages as { role: string; content: unknown[] }[]
  70. expect(messages.filter(message => message.role === 'assistant')).toEqual([
  71. { role: 'assistant', content: [{ type: 'thinking', thinking: 'Reasoning', signature: 'native-signature' }, { type: 'text', text: 'Messages answer' }] },
  72. { role: 'assistant', content: [{ type: 'text', text: 'Chat answer' }] },
  73. ])
  74. })