translate.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import { describe, expect, it } from 'vitest'
  2. import { BlockAssembler, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm'
  3. import type { StreamChunk } from '@deepseek-ai/dsh-llm'
  4. import { DONE } from '../src/sse.ts'
  5. import { mapFinishReason, mapUsage, translate } from '../src/translate.ts'
  6. async function* feed(...payloads: (string | object)[]): AsyncGenerator<string> {
  7. for (const payload of payloads) {
  8. yield typeof payload === 'string' ? payload : JSON.stringify(payload)
  9. }
  10. }
  11. async function collect(stream: AsyncIterable<StreamChunk>): Promise<StreamChunk[]> {
  12. const out: StreamChunk[] = []
  13. for await (const chunk of stream) out.push(chunk)
  14. return out
  15. }
  16. /** The live first-chunk signature: role + null content + EMPTY reasoning. */
  17. const firstChunk = { choices: [{ delta: { role: 'assistant', content: null, reasoning_content: '' } }] }
  18. describe('translate: text', () => {
  19. it('streams a text block and defers finish to DONE', async () => {
  20. const chunks = await collect(translate(feed(
  21. firstChunk,
  22. { choices: [{ delta: { content: 'Hel' } }] },
  23. { choices: [{ delta: { content: 'lo' } }] },
  24. { choices: [{ delta: { content: '' }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 2 } },
  25. DONE,
  26. )))
  27. expect(chunks).toEqual([
  28. { type: 'block-start', index: 0, blockType: 'text' },
  29. { type: 'text-delta', index: 0, text: 'Hel' },
  30. { type: 'text-delta', index: 0, text: 'lo' },
  31. { type: 'block-end', index: 0, block: { type: 'text', text: 'Hello' } },
  32. { type: 'usage', usage: { inputTokens: 5, outputTokens: 2 } },
  33. { type: 'finish', reason: { kind: 'stop' } },
  34. ])
  35. })
  36. it('assembles into the message BlockAssembler expects', async () => {
  37. const assembler = new BlockAssembler()
  38. for await (const chunk of translate(feed(
  39. firstChunk,
  40. { choices: [{ delta: { content: 'hi' } }] },
  41. { choices: [{ delta: {}, finish_reason: 'stop' }] },
  42. DONE,
  43. ))) {
  44. assembler.push(chunk)
  45. }
  46. const result = { message: assembler.message(), finish: assembler.finish }
  47. expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }])
  48. expect(result.finish).toEqual({ kind: 'stop' })
  49. })
  50. })
  51. describe('translate: reasoning', () => {
  52. it('does NOT open a reasoning block for the empty first-chunk signature', async () => {
  53. const chunks = await collect(translate(feed(
  54. firstChunk,
  55. { choices: [{ delta: { content: 'plain' } }] },
  56. { choices: [{ delta: {}, finish_reason: 'stop' }] },
  57. DONE,
  58. )))
  59. expect(chunks.some(chunk => chunk.type === 'block-start' && chunk.blockType === 'reasoning')).toBe(false)
  60. })
  61. it('streams reasoning then text as separate blocks', async () => {
  62. const chunks = await collect(translate(feed(
  63. firstChunk,
  64. { choices: [{ delta: { content: null, reasoning_content: 'think' } }] },
  65. { choices: [{ delta: { content: null, reasoning_content: 'ing' } }] },
  66. { choices: [{ delta: { content: 'answer', reasoning_content: null } }] },
  67. { choices: [{ delta: {}, finish_reason: 'stop' }] },
  68. DONE,
  69. )))
  70. expect(chunks).toEqual([
  71. { type: 'block-start', index: 0, blockType: 'reasoning' },
  72. { type: 'reasoning-delta', index: 0, text: 'think' },
  73. { type: 'reasoning-delta', index: 0, text: 'ing' },
  74. { type: 'block-start', index: 1, blockType: 'text' },
  75. { type: 'text-delta', index: 1, text: 'answer' },
  76. { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'thinking' } },
  77. { type: 'block-end', index: 1, block: { type: 'text', text: 'answer' } },
  78. { type: 'finish', reason: { kind: 'stop' } },
  79. ])
  80. })
  81. it('treats an entirely absent reasoning_content field as non-thinking', async () => {
  82. const chunks = await collect(translate(feed(
  83. { choices: [{ delta: { role: 'assistant', content: 'x' } }] },
  84. { choices: [{ delta: {}, finish_reason: 'stop' }] },
  85. DONE,
  86. )))
  87. expect(chunks.filter(chunk => chunk.type === 'block-start')).toEqual([
  88. { type: 'block-start', index: 0, blockType: 'text' },
  89. ])
  90. })
  91. })
  92. describe('translate: tool calls', () => {
  93. it('reassembles a tool call from fragmented argument deltas (live capture shape)', async () => {
  94. const chunks = await collect(translate(feed(
  95. firstChunk,
  96. { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_00_x', type: 'function', function: { name: 'get_weather', arguments: '' } }] } }] },
  97. { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"city"' } }] } }] },
  98. { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: ': "Paris"}' } }] } }] },
  99. { choices: [{ delta: { content: '' }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 28, completion_tokens: 6 } },
  100. DONE,
  101. )))
  102. expect(chunks).toEqual([
  103. { type: 'block-start', index: 0, blockType: 'tool-call' },
  104. { type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '' },
  105. { type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '{"city"' },
  106. { type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: ': "Paris"}' },
  107. {
  108. type: 'block-end',
  109. index: 0,
  110. block: { type: 'tool-call', id: 'call_00_x', name: 'get_weather', arguments: '{"city": "Paris"}' },
  111. },
  112. { type: 'usage', usage: { inputTokens: 28, outputTokens: 6 } },
  113. { type: 'finish', reason: { kind: 'tool-calls' } },
  114. ])
  115. })
  116. it('disambiguates parallel tool calls by wire index', async () => {
  117. const chunks = await collect(translate(feed(
  118. firstChunk,
  119. {
  120. choices: [{
  121. delta: {
  122. tool_calls: [
  123. { index: 0, id: 'a', type: 'function', function: { name: 'one', arguments: '{}' } },
  124. { index: 1, id: 'b', type: 'function', function: { name: 'two', arguments: '' } },
  125. ],
  126. },
  127. }],
  128. },
  129. { choices: [{ delta: { tool_calls: [{ index: 1, function: { arguments: '{}' } }] } }] },
  130. { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
  131. DONE,
  132. )))
  133. const ends = chunks.filter(chunk => chunk.type === 'block-end')
  134. expect(ends).toEqual([
  135. { type: 'block-end', index: 0, block: { type: 'tool-call', id: 'a', name: 'one', arguments: '{}' } },
  136. { type: 'block-end', index: 1, block: { type: 'tool-call', id: 'b', name: 'two', arguments: '{}' } },
  137. ])
  138. })
  139. it('interleaves text and tool-call blocks with distinct indices', async () => {
  140. const chunks = await collect(translate(feed(
  141. firstChunk,
  142. { choices: [{ delta: { content: 'Checking.' } }] },
  143. { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f', arguments: '{}' } }] } }] },
  144. { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
  145. DONE,
  146. )))
  147. const starts = chunks.filter(chunk => chunk.type === 'block-start')
  148. expect(starts).toEqual([
  149. { type: 'block-start', index: 0, blockType: 'text' },
  150. { type: 'block-start', index: 1, blockType: 'tool-call' },
  151. ])
  152. })
  153. })
  154. describe('translate: finish and usage handling', () => {
  155. it('takes usage from a trailing usage-only chunk (docs shape)', async () => {
  156. const chunks = await collect(translate(feed(
  157. firstChunk,
  158. { choices: [{ delta: { content: 'x' } }] },
  159. { choices: [{ delta: {}, finish_reason: 'stop' }], usage: null },
  160. { choices: [], usage: { prompt_tokens: 9, completion_tokens: 1 } },
  161. DONE,
  162. )))
  163. expect(chunks.at(-2)).toEqual({ type: 'usage', usage: { inputTokens: 9, outputTokens: 1 } })
  164. expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } })
  165. })
  166. it('last usage wins when both attached and trailing arrive', async () => {
  167. const chunks = await collect(translate(feed(
  168. firstChunk,
  169. { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } },
  170. { choices: [], usage: { prompt_tokens: 2, completion_tokens: 2 } },
  171. DONE,
  172. )))
  173. const usage = chunks.find(chunk => chunk.type === 'usage')
  174. expect(usage).toEqual({ type: 'usage', usage: { inputTokens: 2, outputTokens: 2 } })
  175. })
  176. it('defaults to finish stop when no finish_reason ever arrives', async () => {
  177. const chunks = await collect(translate(feed(
  178. firstChunk,
  179. { choices: [{ delta: { content: 'x' } }] },
  180. DONE,
  181. )))
  182. expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } })
  183. })
  184. it('omits the usage chunk when none arrived', async () => {
  185. const chunks = await collect(translate(feed(firstChunk, DONE)))
  186. expect(chunks.some(chunk => chunk.type === 'usage')).toBe(false)
  187. })
  188. it('handles chunks with no choices at all', async () => {
  189. const chunks = await collect(translate(feed({}, DONE)))
  190. expect(chunks).toEqual([{
  191. type: 'finish',
  192. reason: {
  193. kind: 'error',
  194. failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE },
  195. },
  196. }])
  197. })
  198. it('classifies an explicit stop with no opened blocks as EMPTY_RESPONSE, after usage', async () => {
  199. const chunks = await collect(translate(feed(
  200. firstChunk,
  201. { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 7, completion_tokens: 0 } },
  202. DONE,
  203. )))
  204. expect(chunks).toEqual([
  205. { type: 'usage', usage: { inputTokens: 7, outputTokens: 0 } },
  206. {
  207. type: 'finish',
  208. reason: {
  209. kind: 'error',
  210. failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE },
  211. },
  212. },
  213. ])
  214. })
  215. it('keeps a reasoning-only stream a successful stop (any opened block counts)', async () => {
  216. const chunks = await collect(translate(feed(
  217. firstChunk,
  218. { choices: [{ delta: { content: null, reasoning_content: 'mull' } }] },
  219. { choices: [{ delta: {}, finish_reason: 'stop' }] },
  220. DONE,
  221. )))
  222. expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } })
  223. })
  224. it('leaves non-stop finishes unclassified even with no opened blocks', async () => {
  225. const chunks = await collect(translate(feed(
  226. firstChunk,
  227. { choices: [{ delta: {}, finish_reason: 'length' }] },
  228. DONE,
  229. )))
  230. expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'max-tokens' } })
  231. })
  232. })
  233. describe('translate: errors', () => {
  234. it('throws MALFORMED_RESPONSE for invalid JSON payloads', async () => {
  235. await expect(collect(translate(feed('{bad json')))).rejects.toThrow(LlmError)
  236. await expect(collect(translate(feed('{bad json')))).rejects.toThrow(/malformed SSE payload/)
  237. })
  238. it('throws STREAM_CLOSED when the payload source ends without DONE', async () => {
  239. await expect(collect(translate(feed(firstChunk)))).rejects.toThrow(/without \[DONE\]/)
  240. })
  241. })
  242. describe('mapFinishReason', () => {
  243. it.each([
  244. ['stop', { kind: 'stop' }],
  245. ['tool_calls', { kind: 'tool-calls' }],
  246. ['length', { kind: 'max-tokens' }],
  247. ])('maps %s', (wire, expected) => {
  248. expect(mapFinishReason(wire)).toEqual(expected)
  249. })
  250. it.each(['content_filter', 'insufficient_system_resource', 'mystery_reason'])(
  251. 'maps %s to an error kind with the wire code',
  252. (wire) => {
  253. expect(mapFinishReason(wire)).toEqual({
  254. kind: 'error',
  255. failure: { message: `model stopped: ${wire}`, code: wire.toUpperCase() },
  256. })
  257. },
  258. )
  259. })
  260. describe('mapUsage', () => {
  261. it('maps the full live-capture shape', () => {
  262. expect(mapUsage({
  263. prompt_tokens: 283,
  264. completion_tokens: 69,
  265. prompt_cache_hit_tokens: 256,
  266. prompt_cache_miss_tokens: 27,
  267. prompt_tokens_details: { cached_tokens: 256 },
  268. completion_tokens_details: { reasoning_tokens: 24 },
  269. })).toEqual({
  270. // 283 wire prompt_tokens minus the 256 cached → 27 uncached input
  271. // (TokenUsage counts are disjoint).
  272. inputTokens: 27,
  273. outputTokens: 69,
  274. cacheReadTokens: 256,
  275. reasoningTokens: 24,
  276. })
  277. })
  278. it('falls back to prompt_cache_hit_tokens when details are absent', () => {
  279. expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2, prompt_cache_hit_tokens: 8 }))
  280. .toEqual({ inputTokens: 2, outputTokens: 2, cacheReadTokens: 8 })
  281. })
  282. it('omits optional fields when the wire omits them', () => {
  283. expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2 }))
  284. .toEqual({ inputTokens: 10, outputTokens: 2 })
  285. })
  286. })
  287. describe('translate: defensive tool-call branches', () => {
  288. it('handles deltas that never carry id or name (empty-string fallbacks)', async () => {
  289. const chunks = await collect(translate(feed(
  290. firstChunk,
  291. // Hypothetical lenient wire: argument fragments with no id/name at all.
  292. { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{}' } }] } }] },
  293. { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
  294. DONE,
  295. )))
  296. expect(chunks).toEqual([
  297. { type: 'block-start', index: 0, blockType: 'tool-call' },
  298. { type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' },
  299. { type: 'block-end', index: 0, block: { type: 'tool-call', id: '', name: '', arguments: '{}' } },
  300. { type: 'finish', reason: { kind: 'tool-calls' } },
  301. ])
  302. })
  303. it('handles tool_call deltas with a function object but no arguments field', async () => {
  304. const chunks = await collect(translate(feed(
  305. firstChunk,
  306. { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f' } }] } }] },
  307. { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
  308. DONE,
  309. )))
  310. expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', name: 'f', argumentsDelta: '' })
  311. })
  312. it('handles tool_call deltas with no function object at all', async () => {
  313. const chunks = await collect(translate(feed(
  314. firstChunk,
  315. { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c' }] } }] },
  316. { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
  317. DONE,
  318. )))
  319. expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' })
  320. })
  321. })