convert.spec.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
  1. import { describe, expect, it } from 'vitest'
  2. import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm'
  3. import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
  4. import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai'
  5. import { toPiContext } from '../src/context.ts'
  6. import { toPiReplayState } from '../src/replay.ts'
  7. import { mapStopReason, mapUsage, toStreamChunks } from '../src/stream.ts'
  8. function usage(input = 0, output = 0, cacheRead = 0, cacheWrite = 0): Usage {
  9. return {
  10. input,
  11. output,
  12. cacheRead,
  13. cacheWrite,
  14. totalTokens: input + output + cacheRead + cacheWrite,
  15. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
  16. }
  17. }
  18. function assistant(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
  19. return {
  20. role: 'assistant',
  21. content: [],
  22. api: 'openai-completions',
  23. provider: 'deepseek',
  24. model: 'deepseek-v4-flash',
  25. usage: usage(),
  26. stopReason: 'stop',
  27. timestamp: 0,
  28. ...overrides,
  29. }
  30. }
  31. async function* feed(...events: AssistantMessageEvent[]): AsyncGenerator<AssistantMessageEvent> {
  32. for (const event of events) yield event
  33. }
  34. async function collect(stream: AsyncIterable<StreamChunk>): Promise<StreamChunk[]> {
  35. const out: StreamChunk[] = []
  36. for await (const chunk of stream) out.push(chunk)
  37. return out
  38. }
  39. describe('toPiContext', () => {
  40. it('maps system prompt, user text, and tools', () => {
  41. const context = toPiContext({
  42. provider: 'deepseek',
  43. model: 'deepseek-v4-flash',
  44. system: 'be helpful',
  45. messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
  46. tools: [{ name: 'f', description: 'F', parameters: { type: 'object', properties: {} } }],
  47. })
  48. expect(context.systemPrompt).toBe('be helpful')
  49. expect(context.messages).toEqual([{ role: 'user', content: 'hi', timestamp: 0 }])
  50. expect(context.tools).toEqual([
  51. { name: 'f', description: 'F', parameters: { type: 'object', properties: {} } },
  52. ])
  53. })
  54. it('omits empty tools and absent system prompt', () => {
  55. const context = toPiContext({ provider: 'deepseek', model: 'm', messages: [], tools: [] })
  56. expect(context.systemPrompt).toBeUndefined()
  57. expect(context.tools).toBeUndefined()
  58. })
  59. it('maps assistant text/reasoning/tool-call blocks', () => {
  60. const context = toPiContext({
  61. provider: 'deepseek',
  62. model: 'm',
  63. messages: [{
  64. role: 'assistant',
  65. content: [
  66. { type: 'reasoning', text: 'hmm' },
  67. { type: 'text', text: 'calling' },
  68. { type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{"a":1}' },
  69. ],
  70. }],
  71. })
  72. const message = context.messages[0] as AssistantMessage
  73. expect(message.role).toBe('assistant')
  74. expect(message.stopReason).toBe('toolUse')
  75. expect(message.content).toEqual([
  76. { type: 'thinking', thinking: 'hmm' },
  77. { type: 'text', text: 'calling' },
  78. { type: 'toolCall', id: 'c1', name: 'f', arguments: { a: 1 } },
  79. ])
  80. })
  81. it('marks tool-call-free assistant messages with stopReason stop', () => {
  82. const context = toPiContext({
  83. provider: 'deepseek',
  84. model: 'm',
  85. messages: [{ role: 'assistant', content: [{ type: 'text', text: 'done' }] }],
  86. })
  87. expect((context.messages[0] as AssistantMessage).stopReason).toBe('stop')
  88. })
  89. it('parses malformed tool-call arguments to {}', () => {
  90. const context = toPiContext({
  91. provider: 'deepseek',
  92. model: 'm',
  93. messages: [{
  94. role: 'assistant',
  95. content: [{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{broken' }],
  96. }],
  97. })
  98. const message = context.messages[0] as AssistantMessage
  99. expect(message.content[0]).toEqual({ type: 'toolCall', id: 'c1', name: 'f', arguments: {} })
  100. })
  101. it('parses non-object argument JSON (arrays, scalars) to {}', () => {
  102. const context = toPiContext({
  103. provider: 'deepseek',
  104. model: 'm',
  105. messages: [{
  106. role: 'assistant',
  107. content: [{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '[1,2]' }],
  108. }],
  109. })
  110. expect((context.messages[0] as AssistantMessage).content[0]).toMatchObject({ arguments: {} })
  111. })
  112. it('recovers toolName for tool results from the preceding assistant call', () => {
  113. const context = toPiContext({
  114. provider: 'deepseek',
  115. model: 'm',
  116. messages: [
  117. {
  118. role: 'assistant',
  119. content: [{ type: 'tool-call', id: CallId('c1'), name: 'get_weather', arguments: '{}' }],
  120. },
  121. {
  122. role: 'user',
  123. content: [{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'Sunny' }] }],
  124. },
  125. ],
  126. })
  127. expect(context.messages[1]).toEqual({
  128. role: 'toolResult',
  129. toolCallId: 'c1',
  130. toolName: 'get_weather',
  131. content: [{ type: 'text', text: 'Sunny' }],
  132. isError: false,
  133. timestamp: 0,
  134. })
  135. })
  136. it('labels unmatched tool results with toolName unknown and keeps isError', () => {
  137. const context = toPiContext({
  138. provider: 'deepseek',
  139. model: 'm',
  140. messages: [{
  141. role: 'user',
  142. content: [{ type: 'tool-result', toolCallId: CallId('zz'), content: [], isError: true }],
  143. }],
  144. })
  145. expect(context.messages[0]).toMatchObject({
  146. role: 'toolResult',
  147. toolName: 'unknown',
  148. isError: true,
  149. content: [{ type: 'text', text: '(no output)' }],
  150. })
  151. })
  152. it('splits mixed user text + tool results and folds history system messages', () => {
  153. const context = toPiContext({
  154. provider: 'deepseek',
  155. model: 'm',
  156. messages: [
  157. { role: 'system', content: [{ type: 'text', text: 'rule' }] },
  158. {
  159. role: 'user',
  160. content: [
  161. { type: 'text', text: 'note' },
  162. { type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] },
  163. ],
  164. },
  165. ],
  166. })
  167. expect(context.messages.map(message => message.role)).toEqual(['user', 'user', 'toolResult'])
  168. })
  169. it('skips plugin-added (unknown) blocks in assistant content', () => {
  170. const context = toPiContext({
  171. provider: 'deepseek',
  172. model: 'm',
  173. messages: [{
  174. role: 'assistant',
  175. content: [
  176. { type: 'chart', data: 'x' } as unknown as ContentBlock,
  177. { type: 'text', text: 'visible' },
  178. ],
  179. }],
  180. })
  181. expect((context.messages[0] as AssistantMessage).content).toEqual([{ type: 'text', text: 'visible' }])
  182. })
  183. it('recombines durable content with pi-ai replay metadata across target providers and models', () => {
  184. const state = toPiReplayState(assistant({
  185. api: 'openai-responses',
  186. provider: 'openai',
  187. model: 'gpt-5',
  188. responseModel: 'gpt-5-2026-01-01',
  189. responseId: 'resp_123',
  190. stopReason: 'toolUse',
  191. content: [
  192. { type: 'thinking', thinking: 'private reasoning', thinkingSignature: 'think-sig', redacted: true },
  193. { type: 'text', text: 'calling', textSignature: 'text-sig' },
  194. { type: 'toolCall', id: 'c1', name: 'f', arguments: { a: 1 }, thoughtSignature: 'tool-sig' },
  195. ],
  196. }))
  197. const context = toPiContext({
  198. provider: 'anthropic',
  199. model: 'claude-next',
  200. messages: [{
  201. role: 'assistant',
  202. content: [
  203. { type: 'reasoning', text: 'private reasoning' },
  204. { type: 'text', text: 'calling' },
  205. { type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{"a":1}' },
  206. ],
  207. provenance: { provider: 'openai', model: 'gpt-5', replayState: state },
  208. }],
  209. })
  210. expect(context.messages[0]).toMatchObject({
  211. role: 'assistant',
  212. api: 'openai-responses',
  213. provider: 'openai',
  214. model: 'gpt-5',
  215. responseModel: 'gpt-5-2026-01-01',
  216. responseId: 'resp_123',
  217. stopReason: 'toolUse',
  218. content: [
  219. { type: 'thinking', thinking: 'private reasoning', thinkingSignature: 'think-sig', redacted: true },
  220. { type: 'text', text: 'calling', textSignature: 'text-sig' },
  221. { type: 'toolCall', id: 'c1', name: 'f', arguments: { a: 1 }, thoughtSignature: 'tool-sig' },
  222. ],
  223. })
  224. })
  225. it('replays all native block kinds when optional metadata is absent', () => {
  226. const state = toPiReplayState(assistant({
  227. content: [
  228. { type: 'thinking', thinking: 'private reasoning' },
  229. { type: 'text', text: 'calling' },
  230. { type: 'toolCall', id: 'c1', name: 'f', arguments: { a: 1 } },
  231. ],
  232. }))
  233. const context = toPiContext({
  234. provider: 'deepseek',
  235. model: 'new-model',
  236. messages: [{
  237. role: 'assistant',
  238. content: [
  239. { type: 'reasoning', text: 'private reasoning' },
  240. { type: 'text', text: 'calling' },
  241. { type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{"a":1}' },
  242. ],
  243. provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
  244. }],
  245. })
  246. expect(context.messages[0]).toMatchObject({
  247. role: 'assistant',
  248. content: [
  249. { type: 'thinking', thinking: 'private reasoning' },
  250. { type: 'text', text: 'calling' },
  251. { type: 'toolCall', id: 'c1', name: 'f', arguments: { a: 1 } },
  252. ],
  253. })
  254. expect(context.messages[0]).not.toHaveProperty('responseModel')
  255. expect(context.messages[0]).not.toHaveProperty('responseId')
  256. })
  257. it('rejects unsupported replay-state versions with a stable error code', () => {
  258. try {
  259. toPiContext({
  260. provider: 'deepseek',
  261. model: 'm',
  262. messages: [{
  263. role: 'assistant',
  264. content: [{ type: 'text', text: 'done' }],
  265. provenance: {
  266. provider: 'deepseek',
  267. model: 'old',
  268. replayState: { kind: 'pi-ai', version: 2 },
  269. },
  270. }],
  271. })
  272. expect.fail('expected invalid replay state')
  273. } catch (error: unknown) {
  274. expect(error).toBeInstanceOf(LlmError)
  275. expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE')
  276. expect((error as Error).message).toContain('unsupported version 2')
  277. }
  278. })
  279. it('rejects replay metadata whose blocks do not match the durable content', () => {
  280. const state = toPiReplayState(assistant({ content: [{ type: 'text', text: 'done' }] }))
  281. expect(() => toPiContext({
  282. provider: 'deepseek',
  283. model: 'm',
  284. messages: [{
  285. role: 'assistant',
  286. content: [{ type: 'reasoning', text: 'done' }],
  287. provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
  288. }],
  289. })).toThrow(/block 0 does not match assistant content/)
  290. })
  291. it('rejects replay metadata whose block count differs from durable content', () => {
  292. const state = toPiReplayState(assistant())
  293. expect(() => toPiContext({
  294. provider: 'deepseek',
  295. model: 'm',
  296. messages: [{
  297. role: 'assistant',
  298. content: [{ type: 'text', text: 'done' }],
  299. provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
  300. }],
  301. })).toThrow(/block count does not match assistant content/)
  302. })
  303. const validReplay = {
  304. kind: 'pi-ai',
  305. version: 1,
  306. api: 'openai-completions',
  307. provider: 'deepseek',
  308. model: 'deepseek-v4-flash',
  309. stopReason: 'stop',
  310. blocks: [{ type: 'text' }],
  311. }
  312. it.each([
  313. ['provider', { ...validReplay, provider: 'openai' }],
  314. ['model', { ...validReplay, model: 'deepseek-v4-pro' }],
  315. ])('rejects replay metadata whose %s differs from assistant provenance', (field, replayState) => {
  316. try {
  317. toPiContext({
  318. provider: 'deepseek',
  319. model: 'next-model',
  320. messages: [{
  321. role: 'assistant',
  322. content: [{ type: 'text', text: 'done' }],
  323. provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState },
  324. }],
  325. })
  326. expect.fail('expected invalid replay state')
  327. } catch (error: unknown) {
  328. expect(error).toBeInstanceOf(LlmError)
  329. expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE')
  330. expect((error as Error).message).toContain(`${field} does not match assistant provenance`)
  331. }
  332. })
  333. it.each([
  334. ['number state', 1, 'expected an object'],
  335. ['null state', null, 'expected an object'],
  336. ['array state', [], 'expected an object'],
  337. ['unknown kind', { ...validReplay, kind: 'other' }, 'unknown state kind'],
  338. ['non-string api', { ...validReplay, api: 1 }, 'api must be a non-empty string'],
  339. ['empty provider', { ...validReplay, provider: '' }, 'provider must be a non-empty string'],
  340. ['missing model', { ...validReplay, model: undefined }, 'model must be a non-empty string'],
  341. ['unknown stop reason', { ...validReplay, stopReason: 'pause' }, 'unknown stopReason'],
  342. ['non-string response model', { ...validReplay, responseModel: 1 }, 'responseModel must be a string'],
  343. ['non-string response id', { ...validReplay, responseId: 1 }, 'responseId must be a string'],
  344. ['non-array blocks', { ...validReplay, blocks: 'text' }, 'blocks must be an array'],
  345. ['number block', { ...validReplay, blocks: [1] }, 'block 0 must be an object'],
  346. ['null block', { ...validReplay, blocks: [null] }, 'block 0 must be an object'],
  347. ['array block', { ...validReplay, blocks: [[]] }, 'block 0 must be an object'],
  348. ['unknown block type', { ...validReplay, blocks: [{ type: 'audio' }] }, 'block 0 has an unknown type'],
  349. ['non-string signature', { ...validReplay, blocks: [{ type: 'text', textSignature: 1 }] }, 'textSignature must be a string'],
  350. ['non-boolean redaction', { ...validReplay, blocks: [{ type: 'reasoning', redacted: 'yes' }] }, 'redacted must be boolean'],
  351. ])('rejects malformed replay state: %s', (_name, replayState, message) => {
  352. expect(() => toPiContext({
  353. provider: 'deepseek',
  354. model: 'm',
  355. messages: [{
  356. role: 'assistant',
  357. content: [{ type: 'text', text: 'done' }],
  358. provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState },
  359. }],
  360. })).toThrow(message)
  361. })
  362. })
  363. describe('toStreamChunks', () => {
  364. const partialWithToolCall = assistant({
  365. content: [{ type: 'toolCall', id: 'call-1', name: 'f', arguments: {} }],
  366. })
  367. it('maps text events to text blocks', async () => {
  368. const done = assistant({ content: [{ type: 'text', text: 'hi' }], usage: usage(3, 2) })
  369. const chunks = await collect(toStreamChunks(feed(
  370. { type: 'start', partial: assistant() },
  371. { type: 'text_start', contentIndex: 0, partial: assistant() },
  372. { type: 'text_delta', contentIndex: 0, delta: 'hi', partial: assistant() },
  373. { type: 'text_end', contentIndex: 0, content: 'hi', partial: assistant() },
  374. { type: 'done', reason: 'stop', message: done },
  375. )))
  376. expect(chunks).toEqual([
  377. { type: 'block-start', index: 0, blockType: 'text' },
  378. { type: 'text-delta', index: 0, text: 'hi' },
  379. { type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } },
  380. { type: 'usage', usage: { inputTokens: 3, outputTokens: 2 } },
  381. {
  382. type: 'finish',
  383. reason: { kind: 'stop' },
  384. replayState: {
  385. kind: 'pi-ai',
  386. version: 1,
  387. api: 'openai-completions',
  388. provider: 'deepseek',
  389. model: 'deepseek-v4-flash',
  390. stopReason: 'stop',
  391. blocks: [{ type: 'text' }],
  392. },
  393. },
  394. ])
  395. })
  396. it('maps thinking events to reasoning blocks', async () => {
  397. const chunks = await collect(toStreamChunks(feed(
  398. { type: 'thinking_start', contentIndex: 0, partial: assistant() },
  399. { type: 'thinking_delta', contentIndex: 0, delta: 'mull', partial: assistant() },
  400. { type: 'thinking_end', contentIndex: 0, content: 'mull', partial: assistant() },
  401. { type: 'done', reason: 'stop', message: assistant() },
  402. )))
  403. expect(chunks.slice(0, 3)).toEqual([
  404. { type: 'block-start', index: 0, blockType: 'reasoning' },
  405. { type: 'reasoning-delta', index: 0, text: 'mull' },
  406. { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'mull' } },
  407. ])
  408. })
  409. it('maps tool-call events, re-stringifying parsed arguments', async () => {
  410. const chunks = await collect(toStreamChunks(feed(
  411. { type: 'toolcall_start', contentIndex: 0, partial: partialWithToolCall },
  412. { type: 'toolcall_delta', contentIndex: 0, delta: '{"a"', partial: partialWithToolCall },
  413. { type: 'toolcall_delta', contentIndex: 0, delta: ':1}', partial: partialWithToolCall },
  414. {
  415. type: 'toolcall_end',
  416. contentIndex: 0,
  417. toolCall: { type: 'toolCall', id: 'call-1', name: 'f', arguments: { a: 1 } },
  418. partial: partialWithToolCall,
  419. },
  420. { type: 'done', reason: 'toolUse', message: assistant({ content: partialWithToolCall.content, stopReason: 'toolUse' }) },
  421. )))
  422. expect(chunks).toEqual([
  423. { type: 'block-start', index: 0, blockType: 'tool-call' },
  424. { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'f', argumentsDelta: '{"a"' },
  425. { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'f', argumentsDelta: ':1}' },
  426. { type: 'block-end', index: 0, block: { type: 'tool-call', id: 'call-1', name: 'f', arguments: '{"a":1}' } },
  427. { type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } },
  428. {
  429. type: 'finish',
  430. reason: { kind: 'tool-calls' },
  431. replayState: {
  432. kind: 'pi-ai',
  433. version: 1,
  434. api: 'openai-completions',
  435. provider: 'deepseek',
  436. model: 'deepseek-v4-flash',
  437. stopReason: 'toolUse',
  438. blocks: [{ type: 'tool-call' }],
  439. },
  440. },
  441. ])
  442. })
  443. it('tolerates toolcall_start with a missing partial entry', async () => {
  444. const chunks = await collect(toStreamChunks(feed(
  445. { type: 'toolcall_start', contentIndex: 0, partial: assistant() },
  446. { type: 'toolcall_delta', contentIndex: 0, delta: '{}', partial: assistant() },
  447. { type: 'done', reason: 'stop', message: assistant() },
  448. )))
  449. expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' })
  450. })
  451. it('maps error events to error finish chunks (in-stream error style)', async () => {
  452. const error = assistant({ stopReason: 'error', errorMessage: 'boom', usage: usage(1, 0) })
  453. const chunks = await collect(toStreamChunks(feed(
  454. { type: 'error', reason: 'error', error },
  455. )))
  456. expect(chunks).toEqual([
  457. { type: 'usage', usage: { inputTokens: 1, outputTokens: 0 } },
  458. { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'PI_AI_ERROR' } } },
  459. ])
  460. })
  461. it('maps aborted error events to aborted finish', async () => {
  462. const error = assistant({ stopReason: 'aborted' })
  463. const chunks = await collect(toStreamChunks(feed({ type: 'error', reason: 'aborted', error })))
  464. expect(chunks.at(-1)).toEqual({
  465. type: 'finish',
  466. reason: { kind: 'aborted', failure: { message: 'pi-ai stream aborted', code: 'ABORTED' } },
  467. })
  468. })
  469. it('rejects a stream that ends without done or error', async () => {
  470. await expect(collect(toStreamChunks(feed({ type: 'start', partial: assistant() }))))
  471. .rejects.toThrow(/without done\/error/)
  472. })
  473. it('preserves an unknown SDK iterator Error exactly', async () => {
  474. const original = Object.assign(new Error('SDK transport exploded'), { code: 'ECONNRESET' })
  475. async function* failedSdkStream(): AsyncGenerator<AssistantMessageEvent> {
  476. throw original
  477. }
  478. await expect(collect(toStreamChunks(failedSdkStream()))).rejects.toBe(original)
  479. })
  480. })
  481. describe('mapStopReason / mapUsage', () => {
  482. it.each([
  483. ['stop', { kind: 'stop' }],
  484. ['length', { kind: 'max-tokens' }],
  485. ['toolUse', { kind: 'tool-calls' }],
  486. ['aborted', { kind: 'aborted', failure: { message: 'pi-ai stream aborted', code: 'ABORTED' } }],
  487. ] as const)('maps %s', (stopReason, expected) => {
  488. expect(mapStopReason(assistant({ stopReason, content: [{ type: 'text', text: 'ok' }] }))).toEqual(expected)
  489. })
  490. it('classifies a completed stop with no content as an EMPTY_RESPONSE error', () => {
  491. expect(mapStopReason(assistant({ stopReason: 'stop' }))).toEqual({
  492. kind: 'error',
  493. failure: {
  494. message: 'model "deepseek-v4-flash" returned a completed response with no content',
  495. code: EMPTY_RESPONSE_CODE,
  496. },
  497. })
  498. })
  499. it('keeps a thinking-only stop successful (any block counts as content)', () => {
  500. expect(mapStopReason(assistant({ stopReason: 'stop', content: [{ type: 'thinking', thinking: 'mull' }] })))
  501. .toEqual({ kind: 'stop' })
  502. })
  503. it('defaults the error message when pi-ai omits it', () => {
  504. expect(mapStopReason(assistant({ stopReason: 'error' })))
  505. .toEqual({ kind: 'error', failure: { message: 'pi-ai stream error', code: 'PI_AI_ERROR' } })
  506. })
  507. it('maps routable HTTP-ish error messages to stable codes', () => {
  508. expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 401: bad key' })))
  509. .toMatchObject({ kind: 'error', failure: { code: 'AUTH' } })
  510. expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 429: rate limit' })))
  511. .toMatchObject({ kind: 'error', failure: { code: 'RATE_LIMIT' } })
  512. expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 429: insufficient_quota' })))
  513. .toMatchObject({ kind: 'error', failure: { code: 'QUOTA' } })
  514. expect(mapStopReason(assistant({
  515. stopReason: 'error',
  516. errorMessage: 'OpenAI API error (429): You exceeded your current quota, please check your plan and billing details.',
  517. }))).toMatchObject({ kind: 'error', failure: { code: 'QUOTA' } })
  518. expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 500: backend down' })))
  519. .toMatchObject({ kind: 'error', failure: { code: 'SERVER' } })
  520. expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'provider timed out' })))
  521. .toMatchObject({ kind: 'error', failure: { code: 'TIMEOUT' } })
  522. expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'ECONNRESET socket closed' })))
  523. .toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } })
  524. expect(mapStopReason(assistant({
  525. stopReason: 'error',
  526. errorMessage: 'HTTP 400: input exceeds the model context window limit',
  527. }))).toMatchObject({ kind: 'error', failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE } })
  528. expect(mapStopReason(assistant({
  529. stopReason: 'error',
  530. errorMessage: 'HTTP 400: request too large for model context',
  531. }))).toMatchObject({ kind: 'error', failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE } })
  532. expect(mapStopReason(assistant({
  533. stopReason: 'error',
  534. errorMessage: 'HTTP 400: invalid input: temperature exceeds maximum allowed value',
  535. }))).toMatchObject({ kind: 'error', failure: { code: 'INVALID_REQUEST' } })
  536. })
  537. it.each([
  538. 'other side closed',
  539. 'HTTP2 request did not get a response',
  540. 'WebSocket closed unexpectedly',
  541. ])('maps pi-ai transport wording %j', (errorMessage) => {
  542. expect(mapStopReason(assistant({ stopReason: 'error', errorMessage })))
  543. .toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } })
  544. })
  545. it('uses pi-ai provider-specific overflow classification without losing rate-limit exclusions', () => {
  546. expect(mapStopReason(assistant({
  547. stopReason: 'error',
  548. errorMessage: 'prompt is too long: 213462 tokens > 200000 maximum',
  549. }))).toMatchObject({ kind: 'error', failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE } })
  550. expect(mapStopReason(assistant({
  551. stopReason: 'error',
  552. errorMessage: 'ThrottlingException: Too many tokens, rate limit reached',
  553. }))).toMatchObject({ kind: 'error', failure: { code: 'RATE_LIMIT' } })
  554. })
  555. it('uses the resolved context window for silent and length-stop overflows', () => {
  556. // Non-empty content keeps the no-window branch on the successful stop path
  557. // (an empty stop is EMPTY_RESPONSE, covered above); overflow wins over both.
  558. const silent = assistant({ stopReason: 'stop', usage: usage(101, 0), content: [{ type: 'text', text: 'x' }] })
  559. expect(mapStopReason(silent)).toEqual({ kind: 'stop' })
  560. expect(mapStopReason(silent, 100)).toEqual({
  561. kind: 'error',
  562. failure: {
  563. message: 'pi-ai detected context overflow for model "deepseek-v4-flash"',
  564. code: CONTEXT_WINDOW_EXCEEDED_CODE,
  565. },
  566. })
  567. const truncated = assistant({ stopReason: 'length', usage: usage(80, 0, 19) })
  568. expect(mapStopReason(truncated)).toEqual({ kind: 'max-tokens' })
  569. expect(mapStopReason(truncated, 100)).toMatchObject({
  570. kind: 'error',
  571. failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE },
  572. })
  573. })
  574. it('maps cache fields only when nonzero', () => {
  575. expect(mapUsage(usage(10, 5, 8, 2))).toEqual({
  576. inputTokens: 10,
  577. outputTokens: 5,
  578. cacheReadTokens: 8,
  579. cacheWriteTokens: 2,
  580. })
  581. expect(mapUsage(usage(10, 5))).toEqual({ inputTokens: 10, outputTokens: 5 })
  582. })
  583. })
  584. describe('toStreamChunks edge branches', () => {
  585. it('omits the name field for tool calls whose partial carried an empty name', async () => {
  586. const blank = assistant({ content: [{ type: 'toolCall', id: 'x', name: '', arguments: {} }] })
  587. const chunks = await collect(toStreamChunks(feed(
  588. { type: 'toolcall_start', contentIndex: 0, partial: blank },
  589. { type: 'toolcall_delta', contentIndex: 0, delta: '{}', partial: blank },
  590. { type: 'done', reason: 'stop', message: assistant() },
  591. )))
  592. expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'x', argumentsDelta: '{}' })
  593. })
  594. })
  595. describe('toStreamChunks defensive branches', () => {
  596. it('tolerates a toolcall_delta with no preceding toolcall_start', async () => {
  597. const chunks = await collect(toStreamChunks(feed(
  598. { type: 'toolcall_delta', contentIndex: 0, delta: '{}', partial: assistant() },
  599. { type: 'done', reason: 'stop', message: assistant() },
  600. )))
  601. expect(chunks[0]).toEqual({ type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' })
  602. })
  603. })