server.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. import { request } from 'node:http'
  2. import { afterEach, describe, expect, it } from 'vitest'
  3. import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts'
  4. import { startMockLlmServer } from '../src/index.ts'
  5. const running: MockLlmServer[] = []
  6. afterEach(async () => {
  7. await Promise.all(running.splice(0).map(server => server.close()))
  8. })
  9. async function start(
  10. sequence: readonly MockLlmBehavior[],
  11. options: Omit<Parameters<typeof startMockLlmServer>[0], 'sequence'> = {},
  12. ): Promise<MockLlmServer> {
  13. const server = await startMockLlmServer({ sequence, ...options })
  14. running.push(server)
  15. return server
  16. }
  17. function chat(
  18. server: MockLlmServer,
  19. options: { path?: string; key?: string; body?: string; signal?: AbortSignal } = {},
  20. ): Promise<Response> {
  21. return fetch(`${server.baseURL}${options.path ?? '/v1/chat/completions'}`, {
  22. method: 'POST',
  23. headers: {
  24. 'content-type': 'application/json',
  25. ...options.key === undefined ? {} : { authorization: `Bearer ${options.key}` },
  26. },
  27. body: options.body ?? JSON.stringify({ model: 'mock', messages: [], stream: true }),
  28. ...options.signal === undefined ? {} : { signal: options.signal },
  29. })
  30. }
  31. function rawChat(server: MockLlmServer, chunks: readonly Buffer[]): Promise<void> {
  32. return new Promise((resolve, reject) => {
  33. const outgoing = request(`${server.baseURL}/v1/chat/completions`, {
  34. method: 'POST',
  35. headers: { 'content-type': 'application/json' },
  36. }, (response) => {
  37. response.once('error', reject)
  38. response.once('end', resolve)
  39. response.resume()
  40. })
  41. outgoing.once('error', reject)
  42. for (const chunk of chunks) outgoing.write(chunk)
  43. outgoing.end()
  44. })
  45. }
  46. describe('mock LLM server wire behaviors', () => {
  47. it('streams a complete text response and captures the request', async () => {
  48. const events: MockLlmServerEvent[] = []
  49. const server = await start(['success'], {
  50. apiKey: 'mock-key',
  51. successText: 'recovered',
  52. chunkSize: 3,
  53. onEvent: (event) => { events.push(event) },
  54. })
  55. const response = await chat(server, { key: 'mock-key' })
  56. const body = await response.text()
  57. expect(response.status).toBe(200)
  58. expect(response.headers.get('content-type')).toContain('text/event-stream')
  59. expect(body).toContain('"content":"rec"')
  60. expect(body).toContain('"content":"ove"')
  61. expect(body).toContain('"content":"red"')
  62. expect(body).toContain('"finish_reason":"stop"')
  63. expect(body).toContain('data: [DONE]')
  64. expect(server.requests).toEqual([expect.objectContaining({
  65. attempt: 1,
  66. behavior: 'success',
  67. path: '/v1/chat/completions',
  68. body: { model: 'mock', messages: [], stream: true },
  69. chunksSent: 5,
  70. outcome: 'completed',
  71. })])
  72. expect(events).toEqual([
  73. {
  74. type: 'request',
  75. attempt: 1,
  76. scriptBehavior: 'success',
  77. behavior: 'success',
  78. path: '/v1/chat/completions',
  79. },
  80. {
  81. type: 'result',
  82. attempt: 1,
  83. scriptBehavior: 'success',
  84. behavior: 'success',
  85. outcome: 'completed',
  86. chunksSent: 5,
  87. },
  88. ])
  89. })
  90. it('supports root paths and intentionally ignores telemetry observer failures', async () => {
  91. const server = await start(['empty'], {
  92. onEvent() {
  93. throw new Error('observer failed')
  94. },
  95. })
  96. const response = await chat(server, { path: '/chat/completions' })
  97. expect(response.status).toBe(200)
  98. expect(await response.text()).toContain('data: [DONE]')
  99. expect(server.requests[0]).toMatchObject({ path: '/chat/completions', outcome: 'completed' })
  100. })
  101. it.each([
  102. ['empty_body', 0, ''] as const,
  103. ['stream_eof', 1, '"role":"assistant"'] as const,
  104. ['partial_eof', 1, 'discarded partial response'] as const,
  105. ['malformed_json', 2, 'data: {not-json'] as const,
  106. ['malformed_event', 2, '"choices":[null]'] as const,
  107. ])('serves %s without inventing a terminal completion', async (behavior, chunks, marker) => {
  108. const server = await start([behavior], { chunkSize: 100 })
  109. const response = await chat(server)
  110. const body = await response.text()
  111. expect(response.status).toBe(200)
  112. expect(body).toContain(marker)
  113. if (behavior !== 'malformed_json' && behavior !== 'malformed_event') {
  114. expect(body).not.toContain('[DONE]')
  115. }
  116. expect(server.requests[0]).toMatchObject({ behavior, chunksSent: chunks, outcome: 'completed' })
  117. })
  118. it.each([
  119. ['connection_reset', false] as const,
  120. ['stream_disconnect', true] as const,
  121. ['partial_disconnect', true] as const,
  122. ])('forces the %s transport boundary', async (behavior, receivesHeaders) => {
  123. const server = await start([behavior], { disconnectDelayMs: 20, partialText: 'half' })
  124. let headersReceived = false
  125. await expect((async () => {
  126. const response = await chat(server)
  127. headersReceived = true
  128. await response.text()
  129. })()).rejects.toThrow()
  130. expect(headersReceived).toBe(receivesHeaders)
  131. expect(server.requests[0]).toMatchObject({
  132. behavior,
  133. chunksSent: behavior === 'partial_disconnect' ? 1 : 0,
  134. outcome: 'reset',
  135. })
  136. })
  137. it('holds a stalled stream until the client aborts and server close remains idempotent', async () => {
  138. const server = await start(['stall'])
  139. const controller = new AbortController()
  140. const response = await chat(server, { signal: controller.signal })
  141. expect(response.status).toBe(200)
  142. expect(server.requests[0]).toMatchObject({ behavior: 'stall', outcome: 'stalled' })
  143. controller.abort()
  144. await expect(response.text()).rejects.toThrow()
  145. await server.close()
  146. await server.close()
  147. })
  148. it.each([
  149. ['slow_success', 100] as const,
  150. ['stream_disconnect', 100] as const,
  151. ['partial_disconnect', 100] as const,
  152. ])('records a client that closes during %s', async (behavior, delayMs) => {
  153. const events: MockLlmServerEvent[] = []
  154. const result = Promise.withResolvers<Extract<MockLlmServerEvent, { type: 'result' }>>()
  155. const server = await start([behavior], {
  156. chunkDelayMs: delayMs,
  157. disconnectDelayMs: delayMs,
  158. chunkSize: 1,
  159. onEvent: (event) => {
  160. events.push(event)
  161. if (event.type === 'result') result.resolve(event)
  162. },
  163. })
  164. const controller = new AbortController()
  165. const response = await chat(server, { signal: controller.signal })
  166. controller.abort()
  167. await expect(response.text()).rejects.toThrow()
  168. await result.promise
  169. expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' })
  170. expect(events.filter(event => event.type === 'result')).toEqual([
  171. expect.objectContaining({ behavior, outcome: 'client_closed' }),
  172. ])
  173. })
  174. it('preserves UTF-8 code points split across request chunks', async () => {
  175. const server = await start(['success'])
  176. const encoded = Buffer.from(JSON.stringify({ messages: [{ role: 'user', content: '你好' }] }))
  177. const characterOffset = encoded.indexOf(Buffer.from('你'))
  178. expect(characterOffset).toBeGreaterThanOrEqual(0)
  179. await rawChat(server, [
  180. encoded.subarray(0, characterOffset + 1),
  181. encoded.subarray(characterOffset + 1),
  182. ])
  183. expect(server.requests[0]?.body).toEqual({ messages: [{ role: 'user', content: '你好' }] })
  184. })
  185. it('formats an IPv6 listener as a valid base URL', async () => {
  186. const server = await start(['success'], { host: '::1' })
  187. expect(server.baseURL).toMatch(/^http:\/\/\[::1\]:\d+$/)
  188. expect((await chat(server)).status).toBe(200)
  189. })
  190. it('emits reasoning, tool calls, max-token finishes, slow chunks, and a wrong content type', async () => {
  191. const server = await start([
  192. 'reasoning_success',
  193. 'tool_call_success',
  194. 'max_tokens',
  195. 'slow_success',
  196. 'wrong_content_type',
  197. ], {
  198. successText: 'answer',
  199. reasoningText: 'think',
  200. toolName: 'lookup',
  201. toolArguments: '{"id":7}',
  202. chunkDelayMs: 1,
  203. chunkSize: 2,
  204. })
  205. const bodies: string[] = []
  206. const contentTypes: Array<string | null> = []
  207. for (let index = 0; index < 5; index += 1) {
  208. const response = await chat(server)
  209. contentTypes.push(response.headers.get('content-type'))
  210. bodies.push(await response.text())
  211. }
  212. expect(bodies[0]).toContain('"reasoning_content":"th"')
  213. expect(bodies[1]).toContain('"name":"lookup"')
  214. expect(bodies[1]).toContain('"arguments":"{\\"id"')
  215. expect(bodies[1]).toContain('"finish_reason":"tool_calls"')
  216. expect(bodies[2]).toContain('"finish_reason":"length"')
  217. expect(bodies[3]).toContain('"finish_reason":"stop"')
  218. expect(contentTypes[4]).toBe('application/json')
  219. expect(server.requests).toHaveLength(5)
  220. expect(server.requests.every(record => record.outcome === 'completed')).toBe(true)
  221. })
  222. it.each([
  223. ['rate_limit', 429, 'mock rate limit'] as const,
  224. ['server_error', 500, 'mock server error'] as const,
  225. ['service_unavailable', 503, 'mock service unavailable'] as const,
  226. ['auth_error', 401, 'mock authentication failed'] as const,
  227. ['invalid_request', 400, 'mock invalid request'] as const,
  228. ['context_overflow', 400, 'context_length_exceeded'] as const,
  229. ['quota_exceeded', 429, 'insufficient_quota'] as const,
  230. ])('serves %s as a structured HTTP error', async (behavior, status, marker) => {
  231. const server = await start([behavior], { retryAfterMs: 1_001, requestId: 'mock-request-1' })
  232. const response = await chat(server)
  233. const body = await response.text()
  234. expect(response.status).toBe(status)
  235. expect(body).toContain(marker)
  236. expect(response.headers.get('x-request-id')).toBe('mock-request-1')
  237. if (behavior === 'rate_limit') expect(response.headers.get('retry-after')).toBe('2')
  238. else expect(response.headers.get('retry-after')).toBeNull()
  239. expect(server.requests[0]?.outcome).toBe('completed')
  240. })
  241. it('fails loud on script exhaustion and can explicitly repeat the final behavior', async () => {
  242. const exhausted = await start(['success'], { successText: 'once' })
  243. await (await chat(exhausted)).text()
  244. const exhaustedResponse = await chat(exhausted)
  245. expect(exhaustedResponse.status).toBe(500)
  246. expect(await exhaustedResponse.text()).toContain('mock script exhausted')
  247. expect(exhausted.requests.map(record => record.behavior)).toEqual(['success', 'script_exhausted'])
  248. const repeating = await start(['empty'], { repeatLast: true })
  249. await (await chat(repeating)).text()
  250. await (await chat(repeating)).text()
  251. expect(repeating.requests.map(record => record.behavior)).toEqual(['empty', 'empty'])
  252. })
  253. it('selects weighted random behaviors reproducibly and reports the concrete choice', async () => {
  254. const options = {
  255. sequence: ['random'] as const,
  256. repeatLast: true,
  257. randomSeed: 42,
  258. randomWeights: { success: 1, empty: 1 },
  259. successText: 'random success',
  260. }
  261. const first = await startMockLlmServer(options)
  262. const second = await startMockLlmServer(options)
  263. running.push(first, second)
  264. for (let attempt = 0; attempt < 12; attempt += 1) {
  265. await (await chat(first)).text()
  266. await (await chat(second)).text()
  267. }
  268. const firstChoices = first.requests.map(record => record.behavior)
  269. expect(first.randomSeed).toBe(42)
  270. expect(second.randomSeed).toBe(42)
  271. expect(firstChoices).toEqual(second.requests.map(record => record.behavior))
  272. expect(new Set(firstChoices)).toEqual(new Set(['success', 'empty']))
  273. expect(first.requests.every(record => record.scriptBehavior === 'random')).toBe(true)
  274. })
  275. it('rejects invalid method, route, bearer token, and JSON without consuming the script', async () => {
  276. const server = await start(['success'], { apiKey: 'expected' })
  277. const method = await fetch(`${server.baseURL}/v1/chat/completions`)
  278. const route = await fetch(`${server.baseURL}/v1/other`, { method: 'POST', body: '{}' })
  279. const auth = await chat(server, { key: 'wrong' })
  280. const json = await chat(server, { key: 'expected', body: '{' })
  281. expect(method.status).toBe(405)
  282. expect(method.headers.get('allow')).toBe('POST')
  283. expect(route.status).toBe(404)
  284. expect(auth.status).toBe(401)
  285. expect(json.status).toBe(400)
  286. expect(server.requests).toHaveLength(0)
  287. const emptyRequest = await fetch(`${server.baseURL}/v1/chat/completions`, {
  288. method: 'POST',
  289. headers: { authorization: 'Bearer expected' },
  290. })
  291. expect(emptyRequest.status).toBe(200)
  292. expect(server.requests[0]?.behavior).toBe('success')
  293. expect(server.requests[0]?.body).toBeUndefined()
  294. })
  295. })
  296. describe('mock LLM server option validation', () => {
  297. it.each([
  298. [{ sequence: [] }, /sequence/],
  299. [{ sequence: ['success'], host: '' }, /host/],
  300. [{ sequence: ['success'], port: -1 }, /port/],
  301. [{ sequence: ['success'], port: 65_536 }, /port/],
  302. [{ sequence: ['success'], apiKey: '' }, /apiKey/],
  303. [{ sequence: ['success'], successText: '' }, /successText/],
  304. [{ sequence: ['success'], partialText: '' }, /partialText/],
  305. [{ sequence: ['success'], reasoningText: '' }, /reasoningText/],
  306. [{ sequence: ['success'], chunkSize: 0 }, /chunkSize/],
  307. [{ sequence: ['success'], chunkDelayMs: -1 }, /chunkDelayMs/],
  308. [{ sequence: ['success'], disconnectDelayMs: Number.POSITIVE_INFINITY }, /disconnectDelayMs/],
  309. [{ sequence: ['success'], retryAfterMs: 0 }, /retryAfterMs/],
  310. [{ sequence: ['success'], requestId: '' }, /requestId/],
  311. [{ sequence: ['success'], toolName: '' }, /toolName/],
  312. [{ sequence: ['success'], toolArguments: '{' }, /toolArguments/],
  313. [{ sequence: ['random'], randomSeed: -1 }, /randomSeed/],
  314. [{ sequence: ['random'], randomWeights: { random: 1 } }, /unknown concrete behavior/],
  315. [{ sequence: ['random'], randomWeights: { success: -1 } }, /non-negative/],
  316. [{ sequence: ['random'], randomWeights: { success: 0 } }, /positive weight/],
  317. ] as const)('rejects invalid options %#', async (options, expected) => {
  318. await expect(startMockLlmServer(options as Parameters<typeof startMockLlmServer>[0]))
  319. .rejects.toThrow(expected)
  320. })
  321. })