llm-server.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795
  1. import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
  2. import * as Http from "node:http"
  3. import { Deferred, Effect, Layer, ServiceMap, Stream } from "effect"
  4. import * as HttpServer from "effect/unstable/http/HttpServer"
  5. import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
  6. export type Usage = { input: number; output: number }
  7. type Line = Record<string, unknown>
  8. type Flow =
  9. | { type: "text"; text: string }
  10. | { type: "reason"; text: string }
  11. | { type: "tool-start"; id: string; name: string }
  12. | { type: "tool-args"; text: string }
  13. | { type: "usage"; usage: Usage }
  14. type Hit = {
  15. url: URL
  16. body: Record<string, unknown>
  17. }
  18. type Match = (hit: Hit) => boolean
  19. type Queue = {
  20. item: Item
  21. match?: Match
  22. }
  23. type Wait = {
  24. count: number
  25. ready: Deferred.Deferred<void>
  26. }
  27. type Sse = {
  28. type: "sse"
  29. head: unknown[]
  30. tail: unknown[]
  31. wait?: PromiseLike<unknown>
  32. hang?: boolean
  33. error?: unknown
  34. reset?: boolean
  35. }
  36. type HttpError = {
  37. type: "http-error"
  38. status: number
  39. body: unknown
  40. }
  41. export type Item = Sse | HttpError
  42. const done = Symbol("done")
  43. function line(input: unknown) {
  44. if (input === done) return "data: [DONE]\n\n"
  45. return `data: ${JSON.stringify(input)}\n\n`
  46. }
  47. function tokens(input?: Usage) {
  48. if (!input) return
  49. return {
  50. prompt_tokens: input.input,
  51. completion_tokens: input.output,
  52. total_tokens: input.input + input.output,
  53. }
  54. }
  55. function chunk(input: { delta?: Record<string, unknown>; finish?: string; usage?: Usage }) {
  56. return {
  57. id: "chatcmpl-test",
  58. object: "chat.completion.chunk",
  59. choices: [
  60. {
  61. delta: input.delta ?? {},
  62. ...(input.finish ? { finish_reason: input.finish } : {}),
  63. },
  64. ],
  65. ...(input.usage ? { usage: tokens(input.usage) } : {}),
  66. } satisfies Line
  67. }
  68. function role() {
  69. return chunk({ delta: { role: "assistant" } })
  70. }
  71. function textLine(value: string) {
  72. return chunk({ delta: { content: value } })
  73. }
  74. function reasonLine(value: string) {
  75. return chunk({ delta: { reasoning_content: value } })
  76. }
  77. function finishLine(reason: string, usage?: Usage) {
  78. return chunk({ finish: reason, usage })
  79. }
  80. function toolStartLine(id: string, name: string) {
  81. return chunk({
  82. delta: {
  83. tool_calls: [
  84. {
  85. index: 0,
  86. id,
  87. type: "function",
  88. function: {
  89. name,
  90. arguments: "",
  91. },
  92. },
  93. ],
  94. },
  95. })
  96. }
  97. function toolArgsLine(value: string) {
  98. return chunk({
  99. delta: {
  100. tool_calls: [
  101. {
  102. index: 0,
  103. function: {
  104. arguments: value,
  105. },
  106. },
  107. ],
  108. },
  109. })
  110. }
  111. function bytes(input: Iterable<unknown>) {
  112. return Stream.fromIterable([...input].map(line)).pipe(Stream.encodeText)
  113. }
  114. function responseCreated(model: string) {
  115. return {
  116. type: "response.created",
  117. sequence_number: 1,
  118. response: {
  119. id: "resp_test",
  120. created_at: Math.floor(Date.now() / 1000),
  121. model,
  122. service_tier: null,
  123. },
  124. }
  125. }
  126. function responseCompleted(input: { seq: number; usage?: Usage }) {
  127. return {
  128. type: "response.completed",
  129. sequence_number: input.seq,
  130. response: {
  131. incomplete_details: null,
  132. service_tier: null,
  133. usage: {
  134. input_tokens: input.usage?.input ?? 0,
  135. input_tokens_details: { cached_tokens: null },
  136. output_tokens: input.usage?.output ?? 0,
  137. output_tokens_details: { reasoning_tokens: null },
  138. },
  139. },
  140. }
  141. }
  142. function responseMessage(id: string, seq: number) {
  143. return {
  144. type: "response.output_item.added",
  145. sequence_number: seq,
  146. output_index: 0,
  147. item: { type: "message", id },
  148. }
  149. }
  150. function responseText(id: string, text: string, seq: number) {
  151. return {
  152. type: "response.output_text.delta",
  153. sequence_number: seq,
  154. item_id: id,
  155. delta: text,
  156. logprobs: null,
  157. }
  158. }
  159. function responseMessageDone(id: string, seq: number) {
  160. return {
  161. type: "response.output_item.done",
  162. sequence_number: seq,
  163. output_index: 0,
  164. item: { type: "message", id },
  165. }
  166. }
  167. function responseReason(id: string, seq: number) {
  168. return {
  169. type: "response.output_item.added",
  170. sequence_number: seq,
  171. output_index: 0,
  172. item: { type: "reasoning", id, encrypted_content: null },
  173. }
  174. }
  175. function responseReasonPart(id: string, seq: number) {
  176. return {
  177. type: "response.reasoning_summary_part.added",
  178. sequence_number: seq,
  179. item_id: id,
  180. summary_index: 0,
  181. }
  182. }
  183. function responseReasonText(id: string, text: string, seq: number) {
  184. return {
  185. type: "response.reasoning_summary_text.delta",
  186. sequence_number: seq,
  187. item_id: id,
  188. summary_index: 0,
  189. delta: text,
  190. }
  191. }
  192. function responseReasonDone(id: string, seq: number) {
  193. return {
  194. type: "response.output_item.done",
  195. sequence_number: seq,
  196. output_index: 0,
  197. item: { type: "reasoning", id, encrypted_content: null },
  198. }
  199. }
  200. function responseTool(id: string, item: string, name: string, seq: number) {
  201. return {
  202. type: "response.output_item.added",
  203. sequence_number: seq,
  204. output_index: 0,
  205. item: {
  206. type: "function_call",
  207. id: item,
  208. call_id: id,
  209. name,
  210. arguments: "",
  211. status: "in_progress",
  212. },
  213. }
  214. }
  215. function responseToolArgs(id: string, text: string, seq: number) {
  216. return {
  217. type: "response.function_call_arguments.delta",
  218. sequence_number: seq,
  219. output_index: 0,
  220. item_id: id,
  221. delta: text,
  222. }
  223. }
  224. function responseToolArgsDone(id: string, args: string, seq: number) {
  225. return {
  226. type: "response.function_call_arguments.done",
  227. sequence_number: seq,
  228. output_index: 0,
  229. item_id: id,
  230. arguments: args,
  231. }
  232. }
  233. function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
  234. return {
  235. type: "response.output_item.done",
  236. sequence_number: seq,
  237. output_index: 0,
  238. item: {
  239. type: "function_call",
  240. id: tool.item,
  241. call_id: tool.id,
  242. name: tool.name,
  243. arguments: tool.args,
  244. status: "completed",
  245. },
  246. }
  247. }
  248. function choices(part: unknown) {
  249. if (!part || typeof part !== "object") return
  250. if (!("choices" in part) || !Array.isArray(part.choices)) return
  251. const choice = part.choices[0]
  252. if (!choice || typeof choice !== "object") return
  253. return choice
  254. }
  255. function flow(item: Sse) {
  256. const out: Flow[] = []
  257. for (const part of [...item.head, ...item.tail]) {
  258. const choice = choices(part)
  259. const delta =
  260. choice && "delta" in choice && choice.delta && typeof choice.delta === "object" ? choice.delta : undefined
  261. if (delta && "content" in delta && typeof delta.content === "string") {
  262. out.push({ type: "text", text: delta.content })
  263. }
  264. if (delta && "reasoning_content" in delta && typeof delta.reasoning_content === "string") {
  265. out.push({ type: "reason", text: delta.reasoning_content })
  266. }
  267. if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) {
  268. for (const tool of delta.tool_calls) {
  269. if (!tool || typeof tool !== "object") continue
  270. const fn = "function" in tool && tool.function && typeof tool.function === "object" ? tool.function : undefined
  271. if ("id" in tool && typeof tool.id === "string" && fn && "name" in fn && typeof fn.name === "string") {
  272. out.push({ type: "tool-start", id: tool.id, name: fn.name })
  273. }
  274. if (fn && "arguments" in fn && typeof fn.arguments === "string" && fn.arguments) {
  275. out.push({ type: "tool-args", text: fn.arguments })
  276. }
  277. }
  278. }
  279. if (part && typeof part === "object" && "usage" in part && part.usage && typeof part.usage === "object") {
  280. const raw = part.usage as Record<string, unknown>
  281. if (typeof raw.prompt_tokens === "number" && typeof raw.completion_tokens === "number") {
  282. out.push({
  283. type: "usage",
  284. usage: { input: raw.prompt_tokens, output: raw.completion_tokens },
  285. })
  286. }
  287. }
  288. }
  289. return out
  290. }
  291. function responses(item: Sse, model: string) {
  292. let seq = 1
  293. let msg: string | undefined
  294. let reason: string | undefined
  295. let hasMsg = false
  296. let hasReason = false
  297. let call:
  298. | {
  299. id: string
  300. item: string
  301. name: string
  302. args: string
  303. }
  304. | undefined
  305. let usage: Usage | undefined
  306. const lines: unknown[] = [responseCreated(model)]
  307. for (const part of flow(item)) {
  308. if (part.type === "text") {
  309. msg ??= "msg_1"
  310. if (!hasMsg) {
  311. hasMsg = true
  312. seq += 1
  313. lines.push(responseMessage(msg, seq))
  314. }
  315. seq += 1
  316. lines.push(responseText(msg, part.text, seq))
  317. continue
  318. }
  319. if (part.type === "reason") {
  320. reason ||= "rs_1"
  321. if (!hasReason) {
  322. hasReason = true
  323. seq += 1
  324. lines.push(responseReason(reason, seq))
  325. seq += 1
  326. lines.push(responseReasonPart(reason, seq))
  327. }
  328. seq += 1
  329. lines.push(responseReasonText(reason, part.text, seq))
  330. continue
  331. }
  332. if (part.type === "tool-start") {
  333. call ||= { id: part.id, item: "fc_1", name: part.name, args: "" }
  334. seq += 1
  335. lines.push(responseTool(call.id, call.item, call.name, seq))
  336. continue
  337. }
  338. if (part.type === "tool-args") {
  339. if (!call) continue
  340. call.args += part.text
  341. seq += 1
  342. lines.push(responseToolArgs(call.item, part.text, seq))
  343. continue
  344. }
  345. usage = part.usage
  346. }
  347. if (msg) {
  348. seq += 1
  349. lines.push(responseMessageDone(msg, seq))
  350. }
  351. if (reason) {
  352. seq += 1
  353. lines.push(responseReasonDone(reason, seq))
  354. }
  355. if (call && !item.hang && !item.error) {
  356. seq += 1
  357. lines.push(responseToolArgsDone(call.item, call.args, seq))
  358. seq += 1
  359. lines.push(responseToolDone(call, seq))
  360. }
  361. if (!item.hang && !item.error) lines.push(responseCompleted({ seq: seq + 1, usage }))
  362. return { ...item, head: lines, tail: [] } satisfies Sse
  363. }
  364. function modelFrom(body: unknown) {
  365. if (!body || typeof body !== "object") return "test-model"
  366. if (!("model" in body) || typeof body.model !== "string") return "test-model"
  367. return body.model
  368. }
  369. function send(item: Sse) {
  370. const head = bytes(item.head)
  371. const tail = bytes([...item.tail, ...(item.hang || item.error ? [] : [done])])
  372. const empty = Stream.fromIterable<Uint8Array>([])
  373. const wait = item.wait
  374. const body: Stream.Stream<Uint8Array, unknown> = wait
  375. ? Stream.concat(head, Stream.fromEffect(Effect.promise(() => wait)).pipe(Stream.flatMap(() => tail)))
  376. : Stream.concat(head, tail)
  377. let end: Stream.Stream<Uint8Array, unknown> = empty
  378. if (item.error) end = Stream.concat(empty, Stream.fail(item.error))
  379. else if (item.hang) end = Stream.concat(empty, Stream.never)
  380. return HttpServerResponse.stream(Stream.concat(body, end), { contentType: "text/event-stream" })
  381. }
  382. const reset = Effect.fn("TestLLMServer.reset")(function* (item: Sse) {
  383. const req = yield* HttpServerRequest.HttpServerRequest
  384. const res = NodeHttpServerRequest.toServerResponse(req)
  385. yield* Effect.sync(() => {
  386. res.writeHead(200, { "content-type": "text/event-stream" })
  387. for (const part of item.head) res.write(line(part))
  388. for (const part of item.tail) res.write(line(part))
  389. res.destroy(new Error("connection reset"))
  390. })
  391. return yield* Effect.never
  392. })
  393. function fail(item: HttpError) {
  394. return HttpServerResponse.text(JSON.stringify(item.body), {
  395. status: item.status,
  396. contentType: "application/json",
  397. })
  398. }
  399. export class Reply {
  400. #head: unknown[] = [role()]
  401. #tail: unknown[] = []
  402. #usage: Usage | undefined
  403. #finish: string | undefined
  404. #wait: PromiseLike<unknown> | undefined
  405. #hang = false
  406. #error: unknown
  407. #reset = false
  408. #seq = 0
  409. #id() {
  410. this.#seq += 1
  411. return `call_${this.#seq}`
  412. }
  413. text(value: string) {
  414. this.#tail = [...this.#tail, textLine(value)]
  415. return this
  416. }
  417. reason(value: string) {
  418. this.#tail = [...this.#tail, reasonLine(value)]
  419. return this
  420. }
  421. usage(value: Usage) {
  422. this.#usage = value
  423. return this
  424. }
  425. wait(value: PromiseLike<unknown>) {
  426. this.#wait = value
  427. return this
  428. }
  429. stop() {
  430. this.#finish = "stop"
  431. this.#hang = false
  432. this.#error = undefined
  433. this.#reset = false
  434. return this
  435. }
  436. toolCalls() {
  437. this.#finish = "tool_calls"
  438. this.#hang = false
  439. this.#error = undefined
  440. this.#reset = false
  441. return this
  442. }
  443. tool(name: string, input: unknown) {
  444. const id = this.#id()
  445. const args = JSON.stringify(input)
  446. this.#tail = [...this.#tail, toolStartLine(id, name), toolArgsLine(args)]
  447. return this.toolCalls()
  448. }
  449. pendingTool(name: string, input: unknown) {
  450. const id = this.#id()
  451. const args = JSON.stringify(input)
  452. const size = Math.max(1, Math.floor(args.length / 2))
  453. this.#tail = [...this.#tail, toolStartLine(id, name), toolArgsLine(args.slice(0, size))]
  454. return this
  455. }
  456. hang() {
  457. this.#finish = undefined
  458. this.#hang = true
  459. this.#error = undefined
  460. this.#reset = false
  461. return this
  462. }
  463. streamError(error: unknown = "boom") {
  464. this.#finish = undefined
  465. this.#hang = false
  466. this.#error = error
  467. this.#reset = false
  468. return this
  469. }
  470. reset() {
  471. this.#finish = undefined
  472. this.#hang = false
  473. this.#error = undefined
  474. this.#reset = true
  475. return this
  476. }
  477. item(): Item {
  478. return {
  479. type: "sse",
  480. head: this.#head,
  481. tail: this.#finish ? [...this.#tail, finishLine(this.#finish, this.#usage)] : this.#tail,
  482. wait: this.#wait,
  483. hang: this.#hang,
  484. error: this.#error,
  485. reset: this.#reset,
  486. }
  487. }
  488. }
  489. export function reply() {
  490. return new Reply()
  491. }
  492. export function httpError(status: number, body: unknown): Item {
  493. return {
  494. type: "http-error",
  495. status,
  496. body,
  497. }
  498. }
  499. export function raw(input: {
  500. chunks?: unknown[]
  501. head?: unknown[]
  502. tail?: unknown[]
  503. wait?: PromiseLike<unknown>
  504. hang?: boolean
  505. error?: unknown
  506. reset?: boolean
  507. }): Item {
  508. return {
  509. type: "sse",
  510. head: input.head ?? input.chunks ?? [],
  511. tail: input.tail ?? [],
  512. wait: input.wait,
  513. hang: input.hang,
  514. error: input.error,
  515. reset: input.reset,
  516. }
  517. }
  518. function item(input: Item | Reply) {
  519. return input instanceof Reply ? input.item() : input
  520. }
  521. function hit(url: string, body: unknown) {
  522. return {
  523. url: new URL(url, "http://localhost"),
  524. body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
  525. } satisfies Hit
  526. }
  527. /** Auto-acknowledging tool-result follow-ups avoids requiring tests to queue two responses per tool call. */
  528. function isToolResultFollowUp(body: unknown): boolean {
  529. if (!body || typeof body !== "object") return false
  530. // OpenAI chat format: last message has role "tool"
  531. if ("messages" in body && Array.isArray(body.messages)) {
  532. const last = body.messages[body.messages.length - 1]
  533. return last?.role === "tool"
  534. }
  535. // Responses API: input contains function_call_output
  536. if ("input" in body && Array.isArray(body.input)) {
  537. return body.input.some((item: Record<string, unknown>) => item?.type === "function_call_output")
  538. }
  539. return false
  540. }
  541. function isTitleRequest(body: unknown): boolean {
  542. if (!body || typeof body !== "object") return false
  543. return JSON.stringify(body).includes("Generate a title for this conversation")
  544. }
  545. function requestSummary(body: unknown): string {
  546. if (!body || typeof body !== "object") return "empty body"
  547. if ("messages" in body && Array.isArray(body.messages)) {
  548. const roles = body.messages.map((m: Record<string, unknown>) => m.role).join(",")
  549. return `messages=[${roles}]`
  550. }
  551. return `keys=[${Object.keys(body).join(",")}]`
  552. }
  553. namespace TestLLMServer {
  554. export interface Service {
  555. readonly url: string
  556. readonly push: (...input: (Item | Reply)[]) => Effect.Effect<void>
  557. readonly pushMatch: (match: Match, ...input: (Item | Reply)[]) => Effect.Effect<void>
  558. readonly textMatch: (match: Match, value: string, opts?: { usage?: Usage }) => Effect.Effect<void>
  559. readonly toolMatch: (match: Match, name: string, input: unknown) => Effect.Effect<void>
  560. readonly text: (value: string, opts?: { usage?: Usage }) => Effect.Effect<void>
  561. readonly tool: (name: string, input: unknown) => Effect.Effect<void>
  562. readonly toolHang: (name: string, input: unknown) => Effect.Effect<void>
  563. readonly reason: (value: string, opts?: { text?: string; usage?: Usage }) => Effect.Effect<void>
  564. readonly fail: (message?: unknown) => Effect.Effect<void>
  565. readonly error: (status: number, body: unknown) => Effect.Effect<void>
  566. readonly hang: Effect.Effect<void>
  567. readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
  568. readonly reset: Effect.Effect<void>
  569. readonly hits: Effect.Effect<Hit[]>
  570. readonly calls: Effect.Effect<number>
  571. readonly wait: (count: number) => Effect.Effect<void>
  572. readonly inputs: Effect.Effect<Record<string, unknown>[]>
  573. readonly pending: Effect.Effect<number>
  574. readonly misses: Effect.Effect<Hit[]>
  575. }
  576. }
  577. export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServer.Service>()("@test/LLMServer") {
  578. static readonly layer = Layer.effect(
  579. TestLLMServer,
  580. Effect.gen(function* () {
  581. const server = yield* HttpServer.HttpServer
  582. const router = yield* HttpRouter.HttpRouter
  583. let hits: Hit[] = []
  584. let list: Queue[] = []
  585. let waits: Wait[] = []
  586. let misses: Hit[] = []
  587. const queue = (...input: (Item | Reply)[]) => {
  588. list = [...list, ...input.map((value) => ({ item: item(value) }))]
  589. }
  590. const queueMatch = (match: Match, ...input: (Item | Reply)[]) => {
  591. list = [...list, ...input.map((value) => ({ item: item(value), match }))]
  592. }
  593. const notify = Effect.fnUntraced(function* () {
  594. const ready = waits.filter((item) => hits.length >= item.count)
  595. if (!ready.length) return
  596. waits = waits.filter((item) => hits.length < item.count)
  597. yield* Effect.forEach(ready, (item) => Deferred.succeed(item.ready, void 0))
  598. })
  599. const pull = (hit: Hit) => {
  600. const index = list.findIndex((entry) => !entry.match || entry.match(hit))
  601. if (index === -1) return
  602. const first = list[index]
  603. list = [...list.slice(0, index), ...list.slice(index + 1)]
  604. return first.item
  605. }
  606. const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") {
  607. const req = yield* HttpServerRequest.HttpServerRequest
  608. const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
  609. const current = hit(req.originalUrl, body)
  610. if (isTitleRequest(body)) {
  611. hits = [...hits, current]
  612. yield* notify()
  613. const auto: Sse = { type: "sse", head: [role()], tail: [textLine("E2E Title"), finishLine("stop")] }
  614. if (mode === "responses") return send(responses(auto, modelFrom(body)))
  615. return send(auto)
  616. }
  617. const next = pull(current)
  618. if (!next) {
  619. hits = [...hits, current]
  620. yield* notify()
  621. const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
  622. if (mode === "responses") return send(responses(auto, modelFrom(body)))
  623. return send(auto)
  624. }
  625. hits = [...hits, current]
  626. yield* notify()
  627. if (next.type !== "sse") return fail(next)
  628. if (mode === "responses") return send(responses(next, modelFrom(body)))
  629. if (next.reset) {
  630. yield* reset(next)
  631. return HttpServerResponse.empty()
  632. }
  633. return send(next)
  634. })
  635. yield* router.add("POST", "/v1/chat/completions", handle("chat"))
  636. yield* router.add("POST", "/v1/responses", handle("responses"))
  637. yield* server.serve(router.asHttpEffect())
  638. return TestLLMServer.of({
  639. url:
  640. server.address._tag === "TcpAddress"
  641. ? `http://127.0.0.1:${server.address.port}/v1`
  642. : `unix://${server.address.path}/v1`,
  643. push: Effect.fn("TestLLMServer.push")(function* (...input: (Item | Reply)[]) {
  644. queue(...input)
  645. }),
  646. pushMatch: Effect.fn("TestLLMServer.pushMatch")(function* (match: Match, ...input: (Item | Reply)[]) {
  647. queueMatch(match, ...input)
  648. }),
  649. textMatch: Effect.fn("TestLLMServer.textMatch")(function* (
  650. match: Match,
  651. value: string,
  652. opts?: { usage?: Usage },
  653. ) {
  654. const out = reply().text(value)
  655. if (opts?.usage) out.usage(opts.usage)
  656. queueMatch(match, out.stop().item())
  657. }),
  658. toolMatch: Effect.fn("TestLLMServer.toolMatch")(function* (match: Match, name: string, input: unknown) {
  659. queueMatch(match, reply().tool(name, input).item())
  660. }),
  661. text: Effect.fn("TestLLMServer.text")(function* (value: string, opts?: { usage?: Usage }) {
  662. const out = reply().text(value)
  663. if (opts?.usage) out.usage(opts.usage)
  664. queue(out.stop().item())
  665. }),
  666. tool: Effect.fn("TestLLMServer.tool")(function* (name: string, input: unknown) {
  667. queue(reply().tool(name, input).item())
  668. }),
  669. toolHang: Effect.fn("TestLLMServer.toolHang")(function* (name: string, input: unknown) {
  670. queue(reply().pendingTool(name, input).hang().item())
  671. }),
  672. reason: Effect.fn("TestLLMServer.reason")(function* (value: string, opts?: { text?: string; usage?: Usage }) {
  673. const out = reply().reason(value)
  674. if (opts?.text) out.text(opts.text)
  675. if (opts?.usage) out.usage(opts.usage)
  676. queue(out.stop().item())
  677. }),
  678. fail: Effect.fn("TestLLMServer.fail")(function* (message: unknown = "boom") {
  679. queue(reply().streamError(message).item())
  680. }),
  681. error: Effect.fn("TestLLMServer.error")(function* (status: number, body: unknown) {
  682. queue(httpError(status, body))
  683. }),
  684. hang: Effect.gen(function* () {
  685. queue(reply().hang().item())
  686. }).pipe(Effect.withSpan("TestLLMServer.hang")),
  687. hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
  688. queue(reply().wait(wait).text(value).stop().item())
  689. }),
  690. reset: Effect.sync(() => {
  691. hits = []
  692. list = []
  693. waits = []
  694. misses = []
  695. }),
  696. hits: Effect.sync(() => [...hits]),
  697. calls: Effect.sync(() => hits.length),
  698. wait: Effect.fn("TestLLMServer.wait")(function* (count: number) {
  699. if (hits.length >= count) return
  700. const ready = yield* Deferred.make<void>()
  701. waits = [...waits, { count, ready }]
  702. yield* Deferred.await(ready)
  703. }),
  704. inputs: Effect.sync(() => hits.map((hit) => hit.body)),
  705. pending: Effect.sync(() => list.length),
  706. misses: Effect.sync(() => [...misses]),
  707. })
  708. }),
  709. ).pipe(Layer.provide(HttpRouter.layer), Layer.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 0 })))
  710. }