serialize.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import { describe, expect, it } from 'vitest'
  2. import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  3. import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
  4. import { serializeMessages, serializeRequest } from '../src/serialize.ts'
  5. function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
  6. return { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], ...overrides }
  7. }
  8. describe('serializeMessages', () => {
  9. it('maps user text to string content', () => {
  10. const wire = serializeMessages([
  11. { role: 'user', content: [{ type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }] },
  12. ])
  13. expect(wire).toEqual([{ role: 'user', content: 'hello world' }])
  14. })
  15. it('maps system-role messages in history', () => {
  16. const wire = serializeMessages([
  17. { role: 'system', content: [{ type: 'text', text: 'be brief' }] },
  18. ])
  19. expect(wire).toEqual([{ role: 'system', content: 'be brief' }])
  20. })
  21. it('maps plain assistant text without reasoning_content', () => {
  22. const wire = serializeMessages([
  23. {
  24. role: 'assistant',
  25. content: [
  26. { type: 'reasoning', text: 'thinking…' },
  27. { type: 'text', text: 'answer' },
  28. ],
  29. },
  30. ])
  31. // Tool-call-free turn: reasoning is dropped (ignored by the API anyway).
  32. expect(wire).toEqual([{ role: 'assistant', content: 'answer' }])
  33. })
  34. it('passes reasoning_content back on tool-call turns (official passback rule)', () => {
  35. const wire = serializeMessages([
  36. {
  37. role: 'assistant',
  38. content: [
  39. { type: 'reasoning', text: 'I should check the weather.' },
  40. { type: 'tool-call', id: CallId('call-1'), name: 'get_weather', arguments: '{"city":"Paris"}' },
  41. ],
  42. },
  43. ])
  44. expect(wire).toEqual([{
  45. role: 'assistant',
  46. // "" (not null) on tool-call turns — mirrors the official samples'
  47. // verbatim message replay; some gateways reject null.
  48. content: '',
  49. reasoning_content: 'I should check the weather.',
  50. tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }],
  51. }])
  52. })
  53. it('serializes parallel tool calls in order', () => {
  54. const wire = serializeMessages([
  55. {
  56. role: 'assistant',
  57. content: [
  58. { type: 'tool-call', id: CallId('a'), name: 'one', arguments: '{}' },
  59. { type: 'tool-call', id: CallId('b'), name: 'two', arguments: '{}' },
  60. ],
  61. },
  62. ])
  63. const assistant = wire[0] as { tool_calls: { id: string }[] }
  64. expect(assistant.tool_calls.map(call => call.id)).toEqual(['a', 'b'])
  65. })
  66. it('turns tool results into role:tool messages', () => {
  67. const wire = serializeMessages([
  68. {
  69. role: 'user',
  70. content: [{
  71. type: 'tool-result',
  72. toolCallId: CallId('call-1'),
  73. content: [{ type: 'text', text: 'Sunny 22C' }],
  74. }],
  75. },
  76. ])
  77. expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: 'Sunny 22C' }])
  78. })
  79. it('sends a sentinel for empty tool-result content', () => {
  80. const wire = serializeMessages([
  81. {
  82. role: 'user',
  83. content: [{ type: 'tool-result', toolCallId: CallId('call-1'), content: [] }],
  84. },
  85. ])
  86. expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: '(no output)' }])
  87. })
  88. it('splits mixed user text + tool results into separate wire messages', () => {
  89. const wire = serializeMessages([
  90. {
  91. role: 'user',
  92. content: [
  93. { type: 'text', text: 'context note' },
  94. { type: 'tool-result', toolCallId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }] },
  95. ],
  96. },
  97. ])
  98. expect(wire).toEqual([
  99. { role: 'user', content: 'context note' },
  100. { role: 'tool', tool_call_id: 'call-1', content: 'ok' },
  101. ])
  102. })
  103. it('skips plugin-added block types (merge-extensible ContentBlockMap)', () => {
  104. const wire = serializeMessages([
  105. {
  106. role: 'user',
  107. content: [
  108. { type: 'chart', data: 'x' } as unknown as ContentBlock,
  109. { type: 'text', text: 'see chart' },
  110. ],
  111. },
  112. ])
  113. expect(wire).toEqual([{ role: 'user', content: 'see chart' }])
  114. })
  115. it('emits an empty user message rather than dropping block-less messages', () => {
  116. const wire = serializeMessages([{ role: 'user', content: [] }])
  117. expect(wire).toEqual([{ role: 'user', content: '' }])
  118. })
  119. })
  120. describe('serializeRequest', () => {
  121. const history: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]
  122. it('always streams with usage and maps the basics', () => {
  123. const wire = serializeRequest(request({ messages: history }))
  124. expect(wire).toEqual({
  125. model: 'deepseek-v4-flash',
  126. messages: [{ role: 'user', content: 'hi' }],
  127. stream: true,
  128. stream_options: { include_usage: true },
  129. })
  130. })
  131. it('prepends the system prompt', () => {
  132. const wire = serializeRequest(request({ messages: history, system: 'be helpful' }))
  133. expect(wire.messages[0]).toEqual({ role: 'system', content: 'be helpful' })
  134. expect(wire.messages[1]).toEqual({ role: 'user', content: 'hi' })
  135. })
  136. it('maps sampling params and stop sequences', () => {
  137. const wire = serializeRequest(request({ messages: history, temperature: 0.2, maxTokens: 100, stop: ['END'] }))
  138. expect(wire.temperature).toBe(0.2)
  139. expect(wire.max_tokens).toBe(100)
  140. expect(wire.stop).toEqual(['END'])
  141. })
  142. it('maps tools to the wire function shape', () => {
  143. const wire = serializeRequest(request({
  144. messages: history,
  145. tools: [
  146. { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } },
  147. { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } },
  148. ],
  149. }))
  150. expect(wire.tools).toEqual([
  151. { type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } },
  152. { type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } } },
  153. ])
  154. })
  155. it('omits an empty tools array', () => {
  156. const wire = serializeRequest(request({ messages: history, tools: [] }))
  157. expect(wire.tools).toBeUndefined()
  158. })
  159. it('maps adapter-default thinking and the request reasoning effort', () => {
  160. const wire = serializeRequest(
  161. request({ messages: history, reasoningEffort: ReasoningEffortId('max') }),
  162. { thinking: 'enabled', reasoningEffort: 'high' },
  163. )
  164. expect(wire.thinking).toEqual({ type: 'enabled' })
  165. expect(wire.reasoning_effort).toBe('max')
  166. })
  167. it('maps off to disabled thinking without a wire reasoning effort', () => {
  168. const wire = serializeRequest(
  169. request({ messages: history, reasoningEffort: ReasoningEffortId('off') }),
  170. { thinking: 'enabled', reasoningEffort: 'max' },
  171. )
  172. expect(wire.thinking).toEqual({ type: 'disabled' })
  173. expect(wire.reasoning_effort).toBeUndefined()
  174. })
  175. it('re-enables thinking when max overrides an off default', () => {
  176. const wire = serializeRequest(
  177. request({ messages: history, reasoningEffort: ReasoningEffortId('max') }),
  178. { reasoningEffort: 'off' },
  179. )
  180. expect(wire.thinking).toEqual({ type: 'enabled' })
  181. expect(wire.reasoning_effort).toBe('max')
  182. })
  183. it('rejects enabling thinking when the deployment is locked to disabled', () => {
  184. expect(() => serializeRequest(
  185. request({ messages: history, reasoningEffort: ReasoningEffortId('high') }),
  186. { thinking: 'disabled' },
  187. )).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' }))
  188. })
  189. it('disables thinking for session-title requests without changing adapter defaults', () => {
  190. const wire = serializeRequest(
  191. request({
  192. messages: history,
  193. purpose: 'session-title',
  194. reasoningEffort: ReasoningEffortId('max'),
  195. }),
  196. { thinking: 'enabled', reasoningEffort: 'max' },
  197. )
  198. expect(wire.thinking).toEqual({ type: 'disabled' })
  199. expect(wire.reasoning_effort).toBeUndefined()
  200. })
  201. it('omits thinking fields when unset (provider default applies)', () => {
  202. const wire = serializeRequest(request({ messages: history }))
  203. expect(wire.thinking).toBeUndefined()
  204. expect(wire.reasoning_effort).toBeUndefined()
  205. })
  206. it('preserves an explicit enabled default without inventing a wire effort', () => {
  207. const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled' })
  208. expect(wire.thinking).toEqual({ type: 'enabled' })
  209. expect(wire.reasoning_effort).toBeUndefined()
  210. })
  211. it('rejects an effort outside the DeepSeek capability', () => {
  212. expect(() => serializeRequest(request({
  213. messages: history,
  214. reasoningEffort: ReasoningEffortId('medium'),
  215. }))).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' }))
  216. })
  217. })
  218. describe('review fixes: assistant content shapes', () => {
  219. it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => {
  220. // Aborted/empty assistant turns: no text, no calls → "". The earlier
  221. // null shape was live-falsified: the API 400s a null-content assistant
  222. // message without tool_calls ("content or tool_calls must be set").
  223. const wire = serializeMessages([{ role: 'assistant', content: [] }])
  224. expect(wire).toEqual([{ role: 'assistant', content: '' }])
  225. })
  226. it('serializes a reasoning-ONLY assistant message as "" content with the reasoning dropped', () => {
  227. // The model can answer entirely in the reasoning channel (a v4-flash
  228. // greeting did, live). The passback rule keeps reasoning_content off
  229. // plain turns, and content must still be SET — a null here poisoned the
  230. // session log and bricked every later turn of that session.
  231. const wire = serializeMessages([{ role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }] }])
  232. expect(wire).toEqual([{ role: 'assistant', content: '' }])
  233. })
  234. it('serializes tool-call turns with empty string content, not null', () => {
  235. const wire = serializeMessages([{
  236. role: 'assistant',
  237. content: [{ type: 'tool-call', id: CallId('c'), name: 'f', arguments: '{}' }],
  238. }])
  239. expect(wire[0]).toMatchObject({ content: '' })
  240. })
  241. })