messages-fixture.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import { createServer, type IncomingHttpHeaders, type ServerResponse } from 'node:http'
  2. /** One deterministic response emitted by the package-private Messages server. */
  3. export type MessagesBehavior =
  4. | { readonly kind: 'complete'; readonly text: string }
  5. | { readonly kind: 'hold' }
  6. | {
  7. readonly kind: 'tool-use'
  8. readonly toolName: string
  9. readonly input: Record<string, unknown>
  10. readonly finalText?: string
  11. }
  12. /** One recorded Anthropic Messages request. */
  13. interface RecordedMessagesRequest {
  14. readonly method: string
  15. readonly path: string
  16. readonly headers: IncomingHttpHeaders
  17. readonly body: Record<string, unknown>
  18. }
  19. /** Running package-private Anthropic Messages fixture. */
  20. export interface MessagesFixture {
  21. readonly baseUrl: string
  22. readonly requests: RecordedMessagesRequest[]
  23. readonly requestStarted: Promise<void>
  24. close(): Promise<void>
  25. }
  26. function event(
  27. response: ServerResponse,
  28. type: string,
  29. payload: Record<string, unknown>,
  30. ): void {
  31. response.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`)
  32. }
  33. function complete(
  34. response: ServerResponse,
  35. body: Record<string, unknown>,
  36. text: string,
  37. ): void {
  38. const model = typeof body.model === 'string' ? body.model : 'fixture-model'
  39. response.writeHead(200, {
  40. 'content-type': 'text/event-stream',
  41. 'cache-control': 'no-cache',
  42. connection: 'keep-alive',
  43. })
  44. event(response, 'message_start', {
  45. type: 'message_start',
  46. message: {
  47. id: 'msg_dsh_fixture',
  48. type: 'message',
  49. role: 'assistant',
  50. model,
  51. content: [],
  52. stop_reason: null,
  53. stop_sequence: null,
  54. usage: {
  55. input_tokens: 7,
  56. output_tokens: 0,
  57. cache_creation_input_tokens: 0,
  58. cache_read_input_tokens: 0,
  59. },
  60. },
  61. })
  62. event(response, 'content_block_start', {
  63. type: 'content_block_start',
  64. index: 0,
  65. content_block: { type: 'text', text: '' },
  66. })
  67. event(response, 'content_block_delta', {
  68. type: 'content_block_delta',
  69. index: 0,
  70. delta: { type: 'text_delta', text },
  71. })
  72. event(response, 'content_block_stop', {
  73. type: 'content_block_stop',
  74. index: 0,
  75. })
  76. event(response, 'message_delta', {
  77. type: 'message_delta',
  78. delta: { stop_reason: 'end_turn', stop_sequence: null },
  79. usage: { output_tokens: 1 },
  80. })
  81. event(response, 'message_stop', { type: 'message_stop' })
  82. response.end()
  83. }
  84. function toolUse(
  85. response: ServerResponse,
  86. body: Record<string, unknown>,
  87. toolName: string,
  88. input: Record<string, unknown>,
  89. ): void {
  90. const model = typeof body.model === 'string' ? body.model : 'fixture-model'
  91. response.writeHead(200, {
  92. 'content-type': 'text/event-stream',
  93. 'cache-control': 'no-cache',
  94. connection: 'keep-alive',
  95. })
  96. event(response, 'message_start', {
  97. type: 'message_start',
  98. message: {
  99. id: 'msg_dsh_fixture_tool_use',
  100. type: 'message',
  101. role: 'assistant',
  102. model,
  103. content: [],
  104. stop_reason: null,
  105. stop_sequence: null,
  106. usage: {
  107. input_tokens: 7,
  108. output_tokens: 0,
  109. cache_creation_input_tokens: 0,
  110. cache_read_input_tokens: 0,
  111. },
  112. },
  113. })
  114. event(response, 'content_block_start', {
  115. type: 'content_block_start',
  116. index: 0,
  117. content_block: {
  118. type: 'tool_use',
  119. id: 'toolu_dsh_fixture',
  120. name: toolName,
  121. input: {},
  122. },
  123. })
  124. event(response, 'content_block_delta', {
  125. type: 'content_block_delta',
  126. index: 0,
  127. delta: {
  128. type: 'input_json_delta',
  129. partial_json: JSON.stringify(input),
  130. },
  131. })
  132. event(response, 'content_block_stop', {
  133. type: 'content_block_stop',
  134. index: 0,
  135. })
  136. event(response, 'message_delta', {
  137. type: 'message_delta',
  138. delta: { stop_reason: 'tool_use', stop_sequence: null },
  139. usage: { output_tokens: 1 },
  140. })
  141. event(response, 'message_stop', { type: 'message_stop' })
  142. response.end()
  143. }
  144. /**
  145. * Start a loopback-only Anthropic Messages SSE fixture.
  146. * @param behavior - the single response behavior for this fixture.
  147. * @returns the bound server and its recorded requests.
  148. */
  149. export async function startMessagesFixture(
  150. behavior: MessagesBehavior,
  151. ): Promise<MessagesFixture> {
  152. const requests: RecordedMessagesRequest[] = []
  153. let requestStartedResolve!: () => void
  154. const requestStarted = new Promise<void>((resolve) => {
  155. requestStartedResolve = resolve
  156. })
  157. const server = createServer((request, response) => {
  158. const chunks: Buffer[] = []
  159. request.on('data', (chunk: Buffer) => { chunks.push(chunk) })
  160. request.on('end', () => {
  161. const path = request.url ?? ''
  162. if (path !== '/v1/messages' && !path.startsWith('/v1/messages?')) {
  163. response.writeHead(404, { 'content-type': 'application/json' })
  164. response.end(JSON.stringify({
  165. type: 'error',
  166. error: { type: 'not_found_error', message: `unexpected path ${path}` },
  167. }))
  168. return
  169. }
  170. const text = Buffer.concat(chunks).toString('utf8')
  171. const body = JSON.parse(text) as Record<string, unknown>
  172. requests.push({
  173. method: request.method ?? '',
  174. path,
  175. headers: request.headers,
  176. body,
  177. })
  178. requestStartedResolve()
  179. if (behavior.kind === 'complete') {
  180. complete(response, body, behavior.text)
  181. } else if (behavior.kind === 'tool-use' && requests.length === 1) {
  182. toolUse(response, body, behavior.toolName, behavior.input)
  183. } else if (
  184. behavior.kind === 'tool-use'
  185. && behavior.finalText !== undefined
  186. ) {
  187. complete(response, body, behavior.finalText)
  188. }
  189. // A hold, or a tool-use without final text, waits for client abort.
  190. })
  191. })
  192. await new Promise<void>((resolve, reject) => {
  193. server.once('error', reject)
  194. server.listen(0, '127.0.0.1', () => {
  195. server.off('error', reject)
  196. resolve()
  197. })
  198. })
  199. const address = server.address()
  200. if (address === null || typeof address === 'string') {
  201. throw new Error('Messages fixture did not bind a TCP port')
  202. }
  203. return {
  204. baseUrl: `http://127.0.0.1:${address.port}`,
  205. requests,
  206. requestStarted,
  207. async close(): Promise<void> {
  208. server.closeAllConnections()
  209. await new Promise<void>((resolve, reject) => {
  210. server.close((error) => {
  211. if (error !== undefined) reject(error)
  212. else resolve()
  213. })
  214. })
  215. },
  216. }
  217. }