responses-fixture.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import { createServer } from 'node:http'
  2. import type {
  3. IncomingHttpHeaders,
  4. IncomingMessage,
  5. Server,
  6. ServerResponse,
  7. } from 'node:http'
  8. /** One request observed by the package-private Responses fixture. */
  9. interface RecordedResponsesRequest {
  10. readonly method: string | undefined
  11. readonly path: string | undefined
  12. readonly headers: IncomingHttpHeaders
  13. readonly body: Record<string, unknown>
  14. }
  15. /** Behavior consumed by one Responses request. */
  16. export type ResponsesBehavior =
  17. | { readonly kind: 'complete'; readonly text: string }
  18. | { readonly kind: 'error'; readonly status: number; readonly message: string }
  19. | {
  20. readonly kind: 'functionCall'
  21. readonly name: string
  22. readonly arguments: Record<string, unknown>
  23. }
  24. | {
  25. readonly kind: 'advertisedFunctionCall'
  26. readonly choices: readonly {
  27. readonly name: string
  28. readonly arguments: Record<string, unknown>
  29. }[]
  30. }
  31. | { readonly kind: 'hold' }
  32. /** Running package-private Responses fixture. */
  33. export interface ResponsesFixture {
  34. readonly baseUrl: string
  35. readonly requests: RecordedResponsesRequest[]
  36. readonly requestStarted: Promise<void>
  37. close(): Promise<void>
  38. }
  39. function responseObject(text: string): Record<string, unknown> {
  40. const message = {
  41. id: 'msg_fixture',
  42. type: 'message',
  43. status: 'completed',
  44. role: 'assistant',
  45. content: [{
  46. type: 'output_text',
  47. annotations: [],
  48. logprobs: [],
  49. text,
  50. }],
  51. }
  52. return {
  53. id: 'resp_fixture',
  54. object: 'response',
  55. created_at: 1,
  56. status: 'completed',
  57. background: false,
  58. error: null,
  59. incomplete_details: null,
  60. instructions: null,
  61. max_output_tokens: null,
  62. max_tool_calls: null,
  63. model: 'fixture-model',
  64. output: [message],
  65. parallel_tool_calls: true,
  66. previous_response_id: null,
  67. prompt_cache_key: null,
  68. prompt_cache_retention: null,
  69. reasoning: { effort: null, summary: null },
  70. safety_identifier: null,
  71. service_tier: 'default',
  72. store: false,
  73. temperature: null,
  74. text: { format: { type: 'text' }, verbosity: 'medium' },
  75. tool_choice: 'auto',
  76. tools: [],
  77. top_logprobs: 0,
  78. top_p: null,
  79. truncation: 'disabled',
  80. usage: {
  81. input_tokens: 10,
  82. input_tokens_details: { cached_tokens: 0 },
  83. output_tokens: 1,
  84. output_tokens_details: { reasoning_tokens: 0 },
  85. total_tokens: 11,
  86. },
  87. user: null,
  88. metadata: {},
  89. }
  90. }
  91. /**
  92. * Build the minimal Responses SSE event sequence consumed by Codex 0.147.0.
  93. * @param text - exact assistant answer.
  94. * @returns ordered response lifecycle events.
  95. */
  96. export function completeResponsesEvents(text: string): Record<string, unknown>[] {
  97. const completed = responseObject(text)
  98. const message = (completed.output as Record<string, unknown>[])[0]!
  99. const part = (message.content as Record<string, unknown>[])[0]!
  100. return [
  101. {
  102. type: 'response.created',
  103. response: { ...completed, status: 'in_progress', output: [] },
  104. },
  105. {
  106. type: 'response.output_item.added',
  107. output_index: 0,
  108. item: { ...message, status: 'in_progress', content: [] },
  109. },
  110. {
  111. type: 'response.content_part.added',
  112. item_id: message.id,
  113. output_index: 0,
  114. content_index: 0,
  115. part: { ...part, text: '' },
  116. },
  117. {
  118. type: 'response.output_text.delta',
  119. item_id: message.id,
  120. output_index: 0,
  121. content_index: 0,
  122. delta: text,
  123. logprobs: [],
  124. },
  125. {
  126. type: 'response.output_text.done',
  127. item_id: message.id,
  128. output_index: 0,
  129. content_index: 0,
  130. text,
  131. logprobs: [],
  132. },
  133. {
  134. type: 'response.content_part.done',
  135. item_id: message.id,
  136. output_index: 0,
  137. content_index: 0,
  138. part,
  139. },
  140. {
  141. type: 'response.output_item.done',
  142. output_index: 0,
  143. item: message,
  144. },
  145. { type: 'response.completed', response: completed },
  146. ]
  147. }
  148. function functionCallEvents(
  149. name: string,
  150. argumentsValue: Record<string, unknown>,
  151. ): Record<string, unknown>[] {
  152. const argumentsText = JSON.stringify(argumentsValue)
  153. const item = {
  154. id: 'fc_fixture',
  155. type: 'function_call',
  156. status: 'completed',
  157. name,
  158. arguments: argumentsText,
  159. call_id: 'call_fixture',
  160. }
  161. const completed = {
  162. ...responseObject(''),
  163. output: [item],
  164. usage: {
  165. input_tokens: 10,
  166. input_tokens_details: { cached_tokens: 0 },
  167. output_tokens: 5,
  168. output_tokens_details: { reasoning_tokens: 0 },
  169. total_tokens: 15,
  170. },
  171. }
  172. return [
  173. {
  174. type: 'response.created',
  175. response: { ...completed, status: 'in_progress', output: [] },
  176. },
  177. {
  178. type: 'response.output_item.added',
  179. output_index: 0,
  180. item: { ...item, status: 'in_progress', arguments: '' },
  181. },
  182. {
  183. type: 'response.function_call_arguments.delta',
  184. item_id: item.id,
  185. output_index: 0,
  186. delta: argumentsText,
  187. },
  188. {
  189. type: 'response.function_call_arguments.done',
  190. item_id: item.id,
  191. output_index: 0,
  192. arguments: argumentsText,
  193. },
  194. {
  195. type: 'response.output_item.done',
  196. output_index: 0,
  197. item,
  198. },
  199. { type: 'response.completed', response: completed },
  200. ]
  201. }
  202. function readRequest(request: IncomingMessage): Promise<string> {
  203. return new Promise((resolve, reject) => {
  204. let body = ''
  205. request.setEncoding('utf8')
  206. request.on('data', (chunk: string) => { body += chunk })
  207. request.on('end', () => { resolve(body) })
  208. request.on('error', reject)
  209. })
  210. }
  211. function closeServer(server: Server): Promise<void> {
  212. return new Promise((resolve, reject) => {
  213. server.close((error) => {
  214. if (error !== undefined) reject(error)
  215. else resolve()
  216. })
  217. server.closeAllConnections()
  218. })
  219. }
  220. function advertisedFunctionNames(body: Record<string, unknown>): Set<string> {
  221. if (!Array.isArray(body.tools)) return new Set()
  222. return new Set(body.tools.flatMap((tool): string[] => (
  223. tool !== null
  224. && typeof tool === 'object'
  225. && (tool as Record<string, unknown>).type === 'function'
  226. && typeof (tool as Record<string, unknown>).name === 'string'
  227. ? [(tool as Record<string, unknown>).name as string]
  228. : []
  229. )))
  230. }
  231. /**
  232. * Start a loopback-only Responses SSE fixture.
  233. * @param script - one behavior per expected Responses request.
  234. * @returns the running fixture and its observed requests.
  235. */
  236. export async function startResponsesFixture(
  237. script: readonly ResponsesBehavior[],
  238. ): Promise<ResponsesFixture> {
  239. const behaviors = [...script]
  240. const requests: RecordedResponsesRequest[] = []
  241. const started = Promise.withResolvers<undefined>()
  242. const openResponses = new Set<ServerResponse>()
  243. const server = createServer((request, response) => {
  244. openResponses.add(response)
  245. response.on('close', () => { openResponses.delete(response) })
  246. void readRequest(request).then((body) => {
  247. const parsedBody = JSON.parse(body) as Record<string, unknown>
  248. requests.push({
  249. method: request.method,
  250. path: request.url,
  251. headers: request.headers,
  252. body: parsedBody,
  253. })
  254. started.resolve(undefined)
  255. const behavior = behaviors.shift()
  256. if (behavior === undefined) {
  257. response.writeHead(500, { 'content-type': 'application/json' })
  258. response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } }))
  259. return
  260. }
  261. const advertisedCall = behavior.kind === 'advertisedFunctionCall'
  262. ? behavior.choices.find(choice => advertisedFunctionNames(parsedBody).has(choice.name))
  263. : undefined
  264. if (behavior.kind === 'advertisedFunctionCall' && advertisedCall === undefined) {
  265. response.writeHead(500, { 'content-type': 'application/json' })
  266. response.end(JSON.stringify({ error: { message: 'none of the fixture function calls was advertised' } }))
  267. return
  268. }
  269. if (behavior.kind === 'error') {
  270. response.writeHead(behavior.status, { 'content-type': 'application/json' })
  271. response.end(JSON.stringify({ error: { message: behavior.message } }))
  272. return
  273. }
  274. response.writeHead(200, {
  275. 'content-type': 'text/event-stream',
  276. 'cache-control': 'no-cache',
  277. connection: 'keep-alive',
  278. 'x-request-id': 'req_fixture',
  279. })
  280. if (behavior.kind === 'hold') return
  281. let events: Record<string, unknown>[]
  282. if (behavior.kind === 'complete') {
  283. events = completeResponsesEvents(behavior.text)
  284. } else {
  285. const call = behavior.kind === 'functionCall'
  286. ? behavior
  287. : advertisedCall!
  288. events = functionCallEvents(call.name, call.arguments)
  289. }
  290. for (const event of events) {
  291. response.write(`data: ${JSON.stringify(event)}\n\n`)
  292. }
  293. response.end('data: [DONE]\n\n')
  294. }).catch((error: unknown) => {
  295. response.destroy(error instanceof Error ? error : new Error(String(error)))
  296. })
  297. })
  298. await new Promise<void>((resolve, reject) => {
  299. server.once('error', reject)
  300. server.listen(0, '127.0.0.1', () => {
  301. server.off('error', reject)
  302. resolve()
  303. })
  304. })
  305. const address = server.address()
  306. if (address === null || typeof address === 'string') {
  307. throw new Error('responses fixture did not acquire a TCP port')
  308. }
  309. return {
  310. baseUrl: `http://127.0.0.1:${address.port}/v1`,
  311. requests,
  312. requestStarted: started.promise,
  313. async close(): Promise<void> {
  314. for (const response of openResponses) response.destroy()
  315. await closeServer(server)
  316. },
  317. }
  318. }