index.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  1. /**
  2. * Scriptable OpenAI-compatible HTTP/SSE server for transport, protocol, and
  3. * semantic-empty LLM recovery tests. Each accepted chat-completions request
  4. * consumes one behavior; the server never retries or interprets harness policy.
  5. *
  6. * @module @deepseek-ai/dsh-llm-mock-server
  7. */
  8. import { createServer } from 'node:http'
  9. import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'
  10. import { randomBytes } from 'node:crypto'
  11. import { isIP, type AddressInfo } from 'node:net'
  12. import { setTimeout as delay } from 'node:timers/promises'
  13. /** Request-scoped behaviors accepted by {@link startMockLlmServer}. */
  14. export const MOCK_LLM_BEHAVIORS = [
  15. 'connection_reset',
  16. 'stream_disconnect',
  17. 'empty',
  18. 'empty_body',
  19. 'stream_eof',
  20. 'partial_eof',
  21. 'partial_disconnect',
  22. 'stall',
  23. 'malformed_json',
  24. 'malformed_event',
  25. 'wrong_content_type',
  26. 'rate_limit',
  27. 'server_error',
  28. 'service_unavailable',
  29. 'auth_error',
  30. 'invalid_request',
  31. 'context_overflow',
  32. 'quota_exceeded',
  33. 'success',
  34. 'reasoning_success',
  35. 'tool_call_success',
  36. 'max_tokens',
  37. 'slow_success',
  38. 'random',
  39. ] as const
  40. /** One scripted mock behavior name; `random` selects a concrete behavior per request. */
  41. export type MockLlmBehavior = typeof MOCK_LLM_BEHAVIORS[number]
  42. /** One concrete request behavior after resolving a `random` script entry. */
  43. export type ConcreteMockLlmBehavior = Exclude<MockLlmBehavior, 'random'>
  44. /** Relative non-negative weights for random request behavior selection. */
  45. export type MockLlmRandomWeights = Partial<Record<ConcreteMockLlmBehavior, number>>
  46. /**
  47. * Default stress profile for `random`. Weights are configurable test pressure,
  48. * not a claim about production incident frequency.
  49. */
  50. export const DEFAULT_MOCK_LLM_RANDOM_WEIGHTS: Readonly<MockLlmRandomWeights> = Object.freeze({
  51. success: 48,
  52. slow_success: 10,
  53. max_tokens: 2,
  54. connection_reset: 5,
  55. stream_disconnect: 5,
  56. partial_disconnect: 10,
  57. empty: 5,
  58. stall: 2,
  59. rate_limit: 5,
  60. server_error: 4,
  61. service_unavailable: 2,
  62. partial_eof: 1,
  63. malformed_json: 1,
  64. })
  65. /** Largest millisecond delay accepted by Node timers without truncation. */
  66. export const MAX_MOCK_LLM_TIMER_DELAY_MS = 2_147_483_647
  67. /** How one accepted request ended at the mock boundary. */
  68. export type MockLlmRequestOutcome = 'completed' | 'reset' | 'stalled' | 'client_closed' | 'server_error'
  69. /** Immutable telemetry emitted when a request starts or reaches an outcome. */
  70. export type MockLlmServerEvent =
  71. | {
  72. readonly type: 'request'
  73. readonly attempt: number
  74. readonly scriptBehavior: MockLlmBehavior | 'script_exhausted'
  75. readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted'
  76. readonly path: string
  77. }
  78. | {
  79. readonly type: 'result'
  80. readonly attempt: number
  81. readonly scriptBehavior: MockLlmBehavior | 'script_exhausted'
  82. readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted'
  83. readonly outcome: MockLlmRequestOutcome
  84. readonly chunksSent: number
  85. }
  86. /** Captured wire request and its final server-side outcome. */
  87. export interface MockLlmRequestRecord {
  88. /** One-based accepted chat-completions request number. */
  89. readonly attempt: number
  90. /** Script entry consumed for this request before random resolution. */
  91. readonly scriptBehavior: MockLlmBehavior | 'script_exhausted'
  92. /** Concrete behavior selected for this request, or exhaustion after the configured script. */
  93. readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted'
  94. /** Original request path, including a `/v1` prefix when the client supplied one. */
  95. readonly path: string
  96. /** Detached request headers. */
  97. readonly headers: Readonly<IncomingHttpHeaders>
  98. /** Parsed JSON request body. */
  99. readonly body: unknown
  100. /** Number of SSE `data:` events handed to Node before the outcome. */
  101. chunksSent: number
  102. /** Final server-side outcome; absent while a stalled request remains open. */
  103. outcome?: MockLlmRequestOutcome
  104. }
  105. /** Configuration for one mock server instance. */
  106. export interface MockLlmServerOptions {
  107. /** Loopback host by default. */
  108. readonly host?: string
  109. /** TCP port; zero requests an OS-assigned port. */
  110. readonly port?: number
  111. /** Optional exact bearer token; omission accepts any authorization header. */
  112. readonly apiKey?: string
  113. /** Ordered request behaviors; exhaustion fails loud unless `repeatLast` is true. */
  114. readonly sequence: readonly MockLlmBehavior[]
  115. /** Reuse the final behavior after the sequence is consumed. */
  116. readonly repeatLast?: boolean
  117. /** Optional deterministic unsigned 32-bit seed; omission generates and exposes one. */
  118. readonly randomSeed?: number
  119. /** Relative weights used whenever a script entry is `random`. */
  120. readonly randomWeights?: Readonly<MockLlmRandomWeights>
  121. /** Complete text returned by success-shaped behaviors. */
  122. readonly successText?: string
  123. /** Text emitted before partial EOF/reset behaviors terminate. */
  124. readonly partialText?: string
  125. /** Reasoning text emitted by `reasoning_success`. */
  126. readonly reasoningText?: string
  127. /** Unicode code-point count per text or reasoning SSE delta. */
  128. readonly chunkSize?: number
  129. /** Inter-chunk delay for `slow_success`, in milliseconds. */
  130. readonly chunkDelayMs?: number
  131. /** Delay after headers/deltas before a forced disconnect, in milliseconds. */
  132. readonly disconnectDelayMs?: number
  133. /** Provider retry delay; the wire `Retry-After` value rounds up to whole seconds. */
  134. readonly retryAfterMs?: number
  135. /** Optional provider request id returned on HTTP failures. */
  136. readonly requestId?: string
  137. /** Tool name emitted by `tool_call_success`. */
  138. readonly toolName?: string
  139. /** Raw JSON arguments emitted by `tool_call_success`. */
  140. readonly toolArguments?: string
  141. /** Optional observer for JSONL CLI telemetry; observer failures never affect wire behavior. */
  142. readonly onEvent?: (event: MockLlmServerEvent) => void
  143. }
  144. /** Running mock server and captured request state. */
  145. export interface MockLlmServer {
  146. /** Base URL without `/v1`; both root and `/v1` chat-completions paths are accepted. */
  147. readonly baseURL: string
  148. /** Actual bound port, including an OS-assigned value. */
  149. readonly port: number
  150. /** Seed used for random behavior selection, including the generated default. */
  151. readonly randomSeed: number
  152. /** Live request records in arrival order. */
  153. readonly requests: readonly MockLlmRequestRecord[]
  154. /** Stop accepting requests and force-close stalled/streaming connections; idempotent. */
  155. close(): Promise<void>
  156. }
  157. interface ResolvedOptions {
  158. readonly host: string
  159. readonly port: number
  160. readonly apiKey?: string
  161. readonly sequence: readonly MockLlmBehavior[]
  162. readonly lastBehavior: MockLlmBehavior
  163. readonly repeatLast: boolean
  164. readonly randomSeed: number
  165. readonly randomWeights: readonly (readonly [ConcreteMockLlmBehavior, number])[]
  166. readonly successText: string
  167. readonly partialText: string
  168. readonly reasoningText: string
  169. readonly chunkSize: number
  170. readonly chunkDelayMs: number
  171. readonly disconnectDelayMs: number
  172. readonly retryAfterMs: number
  173. readonly requestId?: string
  174. readonly toolName: string
  175. readonly toolArguments: string
  176. readonly onEvent?: (event: MockLlmServerEvent) => void
  177. }
  178. const DEFAULT_SUCCESS_TEXT = 'mock response recovered'
  179. const DEFAULT_PARTIAL_TEXT = 'discarded partial response'
  180. const DEFAULT_REASONING_TEXT = 'mock reasoning'
  181. const CONCRETE_BEHAVIORS = new Set<string>(MOCK_LLM_BEHAVIORS.filter(behavior => behavior !== 'random'))
  182. function boundedInteger(name: string, value: number, min: number, max: number): number {
  183. if (!Number.isInteger(value) || value < min || value > max) {
  184. throw new Error(`llm-mock-server: ${name} must be an integer between ${min} and ${max}`)
  185. }
  186. return value
  187. }
  188. function resolveOptions(options: MockLlmServerOptions): ResolvedOptions {
  189. const host = options.host ?? '127.0.0.1'
  190. const port = boundedInteger('port', options.port ?? 0, 0, 65_535)
  191. const chunkSize = boundedInteger('chunkSize', options.chunkSize ?? 8, 1, Number.MAX_SAFE_INTEGER)
  192. const chunkDelayMs = boundedInteger(
  193. 'chunkDelayMs',
  194. options.chunkDelayMs ?? 25,
  195. 0,
  196. MAX_MOCK_LLM_TIMER_DELAY_MS,
  197. )
  198. const disconnectDelayMs = boundedInteger(
  199. 'disconnectDelayMs',
  200. options.disconnectDelayMs ?? 10,
  201. 0,
  202. MAX_MOCK_LLM_TIMER_DELAY_MS,
  203. )
  204. const retryAfterMs = boundedInteger(
  205. 'retryAfterMs',
  206. options.retryAfterMs ?? 1_000,
  207. 1,
  208. MAX_MOCK_LLM_TIMER_DELAY_MS,
  209. )
  210. const randomSeed = boundedInteger(
  211. 'randomSeed',
  212. options.randomSeed ?? randomBytes(4).readUInt32LE(0),
  213. 0,
  214. 0xffff_ffff,
  215. )
  216. const successText = options.successText ?? DEFAULT_SUCCESS_TEXT
  217. const partialText = options.partialText ?? DEFAULT_PARTIAL_TEXT
  218. const reasoningText = options.reasoningText ?? DEFAULT_REASONING_TEXT
  219. const toolName = options.toolName ?? 'mock_tool'
  220. const toolArguments = options.toolArguments ?? '{"value":"mock"}'
  221. if (host.length === 0) throw new Error('llm-mock-server: host must not be empty')
  222. if (options.sequence.length === 0) throw new Error('llm-mock-server: sequence must not be empty')
  223. const lastBehavior = options.sequence.reduce((_previous, behavior) => behavior)
  224. if (options.apiKey === '') throw new Error('llm-mock-server: apiKey must not be empty')
  225. if (successText.length === 0) throw new Error('llm-mock-server: successText must not be empty')
  226. if (partialText.length === 0) throw new Error('llm-mock-server: partialText must not be empty')
  227. if (reasoningText.length === 0) throw new Error('llm-mock-server: reasoningText must not be empty')
  228. if (toolName.length === 0) throw new Error('llm-mock-server: toolName must not be empty')
  229. if (options.requestId === '') throw new Error('llm-mock-server: requestId must not be empty')
  230. try {
  231. JSON.parse(toolArguments)
  232. } catch {
  233. throw new Error('llm-mock-server: toolArguments must be valid JSON')
  234. }
  235. const configuredWeights = options.randomWeights ?? DEFAULT_MOCK_LLM_RANDOM_WEIGHTS
  236. const randomWeights: Array<readonly [ConcreteMockLlmBehavior, number]> = []
  237. for (const [behavior, weight] of Object.entries(configuredWeights)) {
  238. if (!CONCRETE_BEHAVIORS.has(behavior)) {
  239. throw new Error(`llm-mock-server: randomWeights contains unknown concrete behavior ${JSON.stringify(behavior)}`)
  240. }
  241. if (!Number.isFinite(weight) || weight < 0) {
  242. throw new Error(`llm-mock-server: random weight for ${behavior} must be a non-negative finite number`)
  243. }
  244. if (weight > 0) randomWeights.push([behavior as ConcreteMockLlmBehavior, weight])
  245. }
  246. if (randomWeights.length === 0) {
  247. throw new Error('llm-mock-server: randomWeights must contain at least one positive weight')
  248. }
  249. return {
  250. host,
  251. port,
  252. ...options.apiKey === undefined ? {} : { apiKey: options.apiKey },
  253. sequence: [...options.sequence],
  254. lastBehavior,
  255. repeatLast: options.repeatLast ?? false,
  256. randomSeed,
  257. randomWeights,
  258. successText,
  259. partialText,
  260. reasoningText,
  261. chunkSize,
  262. chunkDelayMs,
  263. disconnectDelayMs,
  264. retryAfterMs,
  265. ...options.requestId === undefined ? {} : { requestId: options.requestId },
  266. toolName,
  267. toolArguments,
  268. ...options.onEvent === undefined ? {} : { onEvent: options.onEvent },
  269. }
  270. }
  271. function emit(options: ResolvedOptions, event: MockLlmServerEvent): void {
  272. try {
  273. options.onEvent?.(Object.freeze(event))
  274. } catch (_telemetryObserverFailure) {
  275. // Test telemetry is observational; a broken observer cannot change provider wire behavior.
  276. }
  277. }
  278. async function readJsonBody(request: IncomingMessage): Promise<unknown> {
  279. const chunks: Buffer[] = []
  280. for await (const chunk of request) chunks.push(Buffer.from(chunk as Uint8Array))
  281. const body = Buffer.concat(chunks).toString('utf8')
  282. return body.length === 0 ? undefined : JSON.parse(body)
  283. }
  284. function splitText(text: string, size: number): string[] {
  285. const points = Array.from(text)
  286. const chunks: string[] = []
  287. for (let index = 0; index < points.length; index += size) chunks.push(points.slice(index, index + size).join(''))
  288. return chunks
  289. }
  290. function openSse(response: ServerResponse, contentType = 'text/event-stream; charset=utf-8'): void {
  291. response.writeHead(200, {
  292. 'content-type': contentType,
  293. 'cache-control': 'no-cache',
  294. 'connection': 'keep-alive',
  295. })
  296. response.flushHeaders()
  297. }
  298. function writeSse(record: MockLlmRequestRecord, response: ServerResponse, payload: unknown): void {
  299. response.write(`data: ${typeof payload === 'string' ? payload : JSON.stringify(payload)}\n\n`)
  300. record.chunksSent += 1
  301. }
  302. function writeDone(record: MockLlmRequestRecord, response: ServerResponse): void {
  303. writeSse(record, response, '[DONE]')
  304. }
  305. function finishRecord(
  306. options: ResolvedOptions,
  307. record: MockLlmRequestRecord,
  308. outcome: MockLlmRequestOutcome,
  309. ): void {
  310. if (record.outcome !== undefined) return
  311. record.outcome = outcome
  312. emit(options, {
  313. type: 'result',
  314. attempt: record.attempt,
  315. scriptBehavior: record.scriptBehavior,
  316. behavior: record.behavior,
  317. outcome,
  318. chunksSent: record.chunksSent,
  319. })
  320. }
  321. function httpError(
  322. options: ResolvedOptions,
  323. record: MockLlmRequestRecord,
  324. response: ServerResponse,
  325. status: number,
  326. message: string,
  327. code: string,
  328. type = 'mock_error',
  329. ): void {
  330. const headers: Record<string, string> = { 'content-type': 'application/json' }
  331. if (record.behavior === 'rate_limit') {
  332. headers['retry-after'] = String(Math.ceil(options.retryAfterMs / 1_000))
  333. }
  334. if (options.requestId !== undefined) headers['x-request-id'] = options.requestId
  335. response.writeHead(status, headers)
  336. response.end(JSON.stringify({ error: { message, type, code } }))
  337. finishRecord(options, record, 'completed')
  338. }
  339. function terminalChunk(reason: string, outputTokens: number): unknown {
  340. return {
  341. choices: [{ index: 0, delta: { content: '' }, finish_reason: reason }],
  342. usage: { prompt_tokens: 3, completion_tokens: outputTokens },
  343. }
  344. }
  345. async function pause(milliseconds: number, response: ServerResponse): Promise<boolean> {
  346. if (milliseconds === 0) return !response.destroyed
  347. const controller = new AbortController()
  348. const stop = (): void => { controller.abort() }
  349. response.once('close', stop)
  350. try {
  351. await delay(milliseconds, undefined, { signal: controller.signal })
  352. return true
  353. } catch (_responseClosed) {
  354. // The timer only receives this response-owned abort signal; closing the response cancels its wait.
  355. return false
  356. } finally {
  357. response.off('close', stop)
  358. }
  359. }
  360. async function streamText(
  361. options: ResolvedOptions,
  362. record: MockLlmRequestRecord,
  363. response: ServerResponse,
  364. text: string,
  365. delayMs: number,
  366. ): Promise<boolean> {
  367. for (const chunk of splitText(text, options.chunkSize)) {
  368. writeSse(record, response, { choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }] })
  369. if (!await pause(delayMs, response)) return false
  370. }
  371. return true
  372. }
  373. async function completeText(
  374. options: ResolvedOptions,
  375. record: MockLlmRequestRecord,
  376. response: ServerResponse,
  377. reason: 'stop' | 'length',
  378. delayMs: number,
  379. ): Promise<void> {
  380. if (!await streamText(options, record, response, options.successText, delayMs)) {
  381. finishRecord(options, record, 'client_closed')
  382. return
  383. }
  384. writeSse(record, response, terminalChunk(reason, Array.from(options.successText).length))
  385. writeDone(record, response)
  386. response.end()
  387. finishRecord(options, record, 'completed')
  388. }
  389. async function disconnect(
  390. options: ResolvedOptions,
  391. record: MockLlmRequestRecord,
  392. response: ServerResponse,
  393. ): Promise<void> {
  394. if (!await pause(options.disconnectDelayMs, response)) {
  395. finishRecord(options, record, 'client_closed')
  396. return
  397. }
  398. finishRecord(options, record, 'reset')
  399. response.destroy()
  400. }
  401. function toolCallChunks(options: ResolvedOptions): readonly unknown[] {
  402. const midpoint = Math.max(1, Math.floor(options.toolArguments.length / 2))
  403. return [
  404. {
  405. choices: [{
  406. index: 0,
  407. delta: {
  408. tool_calls: [{
  409. index: 0,
  410. id: 'mock-call-1',
  411. type: 'function',
  412. function: { name: options.toolName, arguments: options.toolArguments.slice(0, midpoint) },
  413. }],
  414. },
  415. finish_reason: null,
  416. }],
  417. },
  418. {
  419. choices: [{
  420. index: 0,
  421. delta: { tool_calls: [{ index: 0, function: { arguments: options.toolArguments.slice(midpoint) } }] },
  422. finish_reason: null,
  423. }],
  424. },
  425. ]
  426. }
  427. async function runBehavior(
  428. options: ResolvedOptions,
  429. record: MockLlmRequestRecord,
  430. request: IncomingMessage,
  431. response: ServerResponse,
  432. ): Promise<void> {
  433. switch (record.behavior) {
  434. case 'script_exhausted':
  435. httpError(options, record, response, 500, 'mock script exhausted', 'MOCK_SCRIPT_EXHAUSTED')
  436. return
  437. case 'connection_reset':
  438. finishRecord(options, record, 'reset')
  439. request.socket.destroy()
  440. return
  441. case 'stream_disconnect':
  442. openSse(response)
  443. await disconnect(options, record, response)
  444. return
  445. case 'empty':
  446. openSse(response)
  447. writeSse(record, response, terminalChunk('stop', 0))
  448. writeDone(record, response)
  449. response.end()
  450. finishRecord(options, record, 'completed')
  451. return
  452. case 'empty_body':
  453. openSse(response)
  454. response.end()
  455. finishRecord(options, record, 'completed')
  456. return
  457. case 'stream_eof':
  458. openSse(response)
  459. writeSse(record, response, { choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })
  460. response.end()
  461. finishRecord(options, record, 'completed')
  462. return
  463. case 'partial_eof':
  464. openSse(response)
  465. await streamText(options, record, response, options.partialText, 0)
  466. response.end()
  467. finishRecord(options, record, 'completed')
  468. return
  469. case 'partial_disconnect':
  470. openSse(response)
  471. if (!await streamText(options, record, response, options.partialText, options.chunkDelayMs)) return
  472. await disconnect(options, record, response)
  473. return
  474. case 'stall':
  475. openSse(response)
  476. finishRecord(options, record, 'stalled')
  477. return
  478. case 'malformed_json':
  479. openSse(response)
  480. writeSse(record, response, '{not-json')
  481. writeDone(record, response)
  482. response.end()
  483. finishRecord(options, record, 'completed')
  484. return
  485. case 'malformed_event':
  486. openSse(response)
  487. writeSse(record, response, { choices: [null] })
  488. writeDone(record, response)
  489. response.end()
  490. finishRecord(options, record, 'completed')
  491. return
  492. case 'wrong_content_type':
  493. openSse(response, 'application/json')
  494. await completeText(options, record, response, 'stop', 0)
  495. return
  496. case 'rate_limit':
  497. httpError(options, record, response, 429, 'mock rate limit', 'rate_limit')
  498. return
  499. case 'server_error':
  500. httpError(options, record, response, 500, 'mock server error', 'server_error')
  501. return
  502. case 'service_unavailable':
  503. httpError(options, record, response, 503, 'mock service unavailable', 'service_unavailable')
  504. return
  505. case 'auth_error':
  506. httpError(options, record, response, 401, 'mock authentication failed', 'invalid_api_key')
  507. return
  508. case 'invalid_request':
  509. httpError(options, record, response, 400, 'mock invalid request', 'invalid_request')
  510. return
  511. case 'context_overflow':
  512. httpError(
  513. options,
  514. record,
  515. response,
  516. 400,
  517. 'mock input exceeds the model context window',
  518. 'context_length_exceeded',
  519. 'invalid_request_error',
  520. )
  521. return
  522. case 'quota_exceeded':
  523. httpError(options, record, response, 429, 'mock insufficient quota', 'insufficient_quota')
  524. return
  525. case 'success':
  526. openSse(response)
  527. await completeText(options, record, response, 'stop', 0)
  528. return
  529. case 'reasoning_success':
  530. openSse(response)
  531. for (const chunk of splitText(options.reasoningText, options.chunkSize)) {
  532. writeSse(record, response, {
  533. choices: [{ index: 0, delta: { reasoning_content: chunk }, finish_reason: null }],
  534. })
  535. }
  536. await completeText(options, record, response, 'stop', 0)
  537. return
  538. case 'tool_call_success':
  539. openSse(response)
  540. for (const chunk of toolCallChunks(options)) writeSse(record, response, chunk)
  541. writeSse(record, response, terminalChunk('tool_calls', 2))
  542. writeDone(record, response)
  543. response.end()
  544. finishRecord(options, record, 'completed')
  545. return
  546. case 'max_tokens':
  547. openSse(response)
  548. await completeText(options, record, response, 'length', 0)
  549. return
  550. case 'slow_success':
  551. openSse(response)
  552. await completeText(options, record, response, 'stop', options.chunkDelayMs)
  553. return
  554. }
  555. }
  556. function seededRandom(seed: number): () => number {
  557. let state = seed
  558. return () => {
  559. state = (state + 0x6d2b_79f5) >>> 0
  560. let mixed = state
  561. mixed = Math.imul(mixed ^ mixed >>> 15, mixed | 1)
  562. mixed ^= mixed + Math.imul(mixed ^ mixed >>> 7, mixed | 61)
  563. return ((mixed ^ mixed >>> 14) >>> 0) / 0x1_0000_0000
  564. }
  565. }
  566. function chooseRandomBehavior(
  567. weights: readonly (readonly [ConcreteMockLlmBehavior, number])[],
  568. random: () => number,
  569. ): ConcreteMockLlmBehavior {
  570. const total = weights.reduce((sum, entry) => sum + entry[1], 0)
  571. let draw = random() * total
  572. for (const [behavior, weight] of weights) {
  573. if (draw < weight) return behavior
  574. draw -= weight
  575. }
  576. // Floating-point subtraction can only leave a rounding residue at the upper boundary.
  577. /* v8 ignore next -- seededRandom is strictly less than one; this guards floating-point residue only */
  578. return (weights.at(-1) as readonly [ConcreteMockLlmBehavior, number])[0]
  579. }
  580. /**
  581. * Start a local chat-completions server that consumes one configured behavior
  582. * per accepted request. Only a `POST` path ending in `/chat/completions` consumes the script;
  583. * invalid routes, methods, authorization, and JSON receive ordinary 4xx
  584. * responses. Closing the handle terminates stalled connections.
  585. *
  586. * @param options - listener, script, response content, timing, and telemetry options.
  587. * @returns the listening handle after the port is bound.
  588. */
  589. export async function startMockLlmServer(options: MockLlmServerOptions): Promise<MockLlmServer> {
  590. const resolved = resolveOptions(options)
  591. const requests: MockLlmRequestRecord[] = []
  592. const random = seededRandom(resolved.randomSeed)
  593. let cursor = 0
  594. const selectBehavior = (): {
  595. scriptBehavior: MockLlmBehavior | 'script_exhausted'
  596. behavior: ConcreteMockLlmBehavior | 'script_exhausted'
  597. } => {
  598. const selected = resolved.sequence[cursor]
  599. cursor += 1
  600. const scriptBehavior = selected
  601. ?? (resolved.repeatLast ? resolved.lastBehavior : 'script_exhausted')
  602. return {
  603. scriptBehavior,
  604. behavior: scriptBehavior === 'random'
  605. ? chooseRandomBehavior(resolved.randomWeights, random)
  606. : scriptBehavior,
  607. }
  608. }
  609. const handle = async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
  610. /* v8 ignore next -- node:http server requests always carry a URL despite the shared optional type */
  611. const path = new URL(request.url ?? '/', 'http://mock.invalid').pathname
  612. if (request.method !== 'POST') {
  613. response.writeHead(405, { allow: 'POST' }).end()
  614. return
  615. }
  616. if (!path.endsWith('/chat/completions')) {
  617. response.writeHead(404).end()
  618. return
  619. }
  620. if (resolved.apiKey !== undefined && request.headers.authorization !== `Bearer ${resolved.apiKey}`) {
  621. response.writeHead(401, { 'content-type': 'application/json' })
  622. response.end(JSON.stringify({ error: { message: 'invalid mock bearer token', code: 'invalid_api_key' } }))
  623. return
  624. }
  625. let body: unknown
  626. try {
  627. body = await readJsonBody(request)
  628. } catch {
  629. response.writeHead(400, { 'content-type': 'application/json' })
  630. response.end(JSON.stringify({ error: { message: 'request body must be valid JSON', code: 'invalid_json' } }))
  631. return
  632. }
  633. const selected = selectBehavior()
  634. const record: MockLlmRequestRecord = {
  635. attempt: requests.length + 1,
  636. scriptBehavior: selected.scriptBehavior,
  637. behavior: selected.behavior,
  638. path,
  639. headers: { ...request.headers },
  640. body,
  641. chunksSent: 0,
  642. }
  643. requests.push(record)
  644. response.once('close', () => {
  645. if (!response.writableFinished && record.outcome === undefined) {
  646. finishRecord(resolved, record, 'client_closed')
  647. }
  648. })
  649. emit(resolved, {
  650. type: 'request',
  651. attempt: record.attempt,
  652. scriptBehavior: record.scriptBehavior,
  653. behavior: record.behavior,
  654. path,
  655. })
  656. await runBehavior(resolved, record, request, response)
  657. }
  658. const server = createServer((request, response) => {
  659. /* v8 ignore start -- last-resort containment for Node response failures after validated test inputs */
  660. handle(request, response).catch((error: unknown) => {
  661. const record = requests.at(-1)
  662. if (record !== undefined) finishRecord(resolved, record, 'server_error')
  663. if (response.headersSent) {
  664. response.destroy(error instanceof Error ? error : new Error(String(error)))
  665. return
  666. }
  667. response.writeHead(500, { 'content-type': 'application/json' })
  668. response.end(JSON.stringify({ error: { message: 'mock server handler failed', code: 'MOCK_HANDLER_FAILED' } }))
  669. })
  670. /* v8 ignore stop */
  671. })
  672. let closing: Promise<void> | undefined
  673. const close = (): Promise<void> => (closing ??= new Promise((resolveClose) => {
  674. server.close(() => { resolveClose() })
  675. server.closeAllConnections()
  676. }))
  677. await new Promise<void>((resolveListen, rejectListen) => {
  678. server.once('error', rejectListen)
  679. server.listen(resolved.port, resolved.host, () => {
  680. server.off('error', rejectListen)
  681. resolveListen()
  682. })
  683. })
  684. const address = server.address() as AddressInfo
  685. const advertisedHost = isIP(resolved.host) === 6 ? `[${resolved.host}]` : resolved.host
  686. return {
  687. baseURL: `http://${advertisedHost}:${address.port}`,
  688. port: address.port,
  689. randomSeed: resolved.randomSeed,
  690. requests,
  691. close,
  692. }
  693. }