executor.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. import { describe, expect } from "bun:test"
  2. import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
  3. import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  4. import { LLM, AIError } from "../src"
  5. import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route"
  6. import * as OpenAIChat from "../src/protocols/openai-chat"
  7. import * as OpenAI from "../src/providers/openai"
  8. import { dynamicResponse, fixedResponse } from "./lib/http"
  9. import { deltaChunk } from "./lib/openai-chunks"
  10. import { sseEvents, sseRaw } from "./lib/sse"
  11. import { it } from "./lib/effect"
  12. const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe(
  13. HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer secret", "x-safe": "visible" })),
  14. )
  15. const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_key=query-secret-123&debug=1").pipe(
  16. HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
  17. )
  18. const responsesLayer = (responses: ReadonlyArray<Response>) =>
  19. RequestExecutor.layer.pipe(
  20. Layer.provide(
  21. Layer.unwrap(
  22. Effect.gen(function* () {
  23. const cursor = yield* Ref.make(0)
  24. return Layer.succeed(
  25. HttpClient.HttpClient,
  26. HttpClient.make((request) =>
  27. Effect.gen(function* () {
  28. const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
  29. return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
  30. }),
  31. ),
  32. )
  33. }),
  34. ),
  35. ),
  36. )
  37. const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArray<Response>) =>
  38. RequestExecutor.layer.pipe(
  39. Layer.provide(
  40. Layer.unwrap(
  41. Effect.gen(function* () {
  42. const cursor = yield* Ref.make(0)
  43. return Layer.succeed(
  44. HttpClient.HttpClient,
  45. HttpClient.make((request) =>
  46. Effect.gen(function* () {
  47. yield* Ref.update(attempts, (value) => value + 1)
  48. const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
  49. return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
  50. }),
  51. ),
  52. )
  53. }),
  54. ),
  55. ),
  56. )
  57. const expectAIError = (error: unknown) => {
  58. expect(error).toBeInstanceOf(AIError)
  59. if (!(error instanceof AIError)) throw new Error("expected AIError")
  60. return error
  61. }
  62. const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
  63. describe("RequestExecutor", () => {
  64. it.effect("preserves middleware error messages", () =>
  65. Effect.gen(function* () {
  66. const executor = yield* RequestExecutor.Service
  67. const error = yield* executor
  68. .execute(request, () => Effect.fail(new Error("plugin rejected request")))
  69. .pipe(Effect.flip)
  70. expectAIError(error)
  71. expect(error.reason.message).toBe("plugin rejected request")
  72. }).pipe(Effect.provide(responsesLayer([]))),
  73. )
  74. it.effect("classifies context overflow responses", () =>
  75. Effect.gen(function* () {
  76. const executor = yield* RequestExecutor.Service
  77. const error = yield* executor.execute(request).pipe(Effect.flip)
  78. expectAIError(error)
  79. expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
  80. }).pipe(
  81. Effect.provide(
  82. responsesLayer([
  83. new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
  84. status: 400,
  85. }),
  86. ]),
  87. ),
  88. ),
  89. )
  90. it.effect("classifies generic HTTP 413 payload errors", () =>
  91. Effect.gen(function* () {
  92. const executor = yield* RequestExecutor.Service
  93. const error = yield* executor.execute(request).pipe(Effect.flip)
  94. expectAIError(error)
  95. expect(error.reason).toMatchObject({
  96. _tag: "InvalidRequest",
  97. classification: "payload-too-large",
  98. http: { response: { status: 413 } },
  99. })
  100. }).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
  101. )
  102. it.effect("does not classify ordinary invalid requests as context overflow", () =>
  103. Effect.gen(function* () {
  104. const executor = yield* RequestExecutor.Service
  105. const error = yield* executor.execute(request).pipe(Effect.flip)
  106. expectAIError(error)
  107. expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
  108. expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
  109. }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
  110. )
  111. it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
  112. Effect.gen(function* () {
  113. const classify = (body: string) =>
  114. Effect.gen(function* () {
  115. const executor = yield* RequestExecutor.Service
  116. const error = yield* executor.execute(request).pipe(Effect.flip)
  117. expectAIError(error)
  118. expect(error.reason).toMatchObject({ _tag: "RateLimit" })
  119. }).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
  120. yield* classify("Request rate increased too quickly")
  121. yield* classify('{"type":"error","error":{"type":"too_many_requests"}}')
  122. yield* classify('{"type":"error","error":{"code":"rate_limit_exceeded"}}')
  123. }),
  124. )
  125. it.effect("classifies provider overloads hidden behind HTTP 400", () =>
  126. Effect.gen(function* () {
  127. const classify = (body: string) =>
  128. Effect.gen(function* () {
  129. const executor = yield* RequestExecutor.Service
  130. const error = yield* executor.execute(request).pipe(Effect.flip)
  131. expectAIError(error)
  132. expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
  133. }).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
  134. yield* classify('{"code":"resource_exhausted"}')
  135. yield* classify('{"code":"service_unavailable"}')
  136. }),
  137. )
  138. it.effect("returns redacted diagnostics for rate limits", () =>
  139. Effect.gen(function* () {
  140. const executor = yield* RequestExecutor.Service
  141. const error = yield* executor.execute(request).pipe(Effect.flip)
  142. expectAIError(error)
  143. expect(error).toMatchObject({
  144. reason: {
  145. _tag: "RateLimit",
  146. retryAfterMs: 0,
  147. rateLimit: { retryAfterMs: 0 },
  148. http: {
  149. requestId: "req_123",
  150. request: {
  151. method: "POST",
  152. url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
  153. headers: { authorization: "<redacted>", "x-safe": "visible" },
  154. },
  155. response: {
  156. status: 429,
  157. headers: {
  158. "retry-after-ms": "0",
  159. "x-request-id": "req_123",
  160. "x-api-key": "<redacted>",
  161. },
  162. },
  163. },
  164. },
  165. })
  166. expect(errorHttp(error)?.body).toBe("rate limited")
  167. }).pipe(
  168. Effect.provide(
  169. responsesLayer([
  170. new Response("rate limited", {
  171. status: 429,
  172. headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
  173. }),
  174. ]),
  175. ),
  176. ),
  177. )
  178. it.effect("honors current redacted header names in diagnostics", () =>
  179. Effect.gen(function* () {
  180. const executor = yield* RequestExecutor.Service
  181. const error = yield* executor.execute(request).pipe(Effect.flip)
  182. expectAIError(error)
  183. expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
  184. expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
  185. }).pipe(
  186. Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
  187. Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
  188. ),
  189. )
  190. it.effect("extracts OpenAI-style rate-limit diagnostics", () =>
  191. Effect.gen(function* () {
  192. const executor = yield* RequestExecutor.Service
  193. const error = yield* executor.execute(request).pipe(Effect.flip)
  194. expectAIError(error)
  195. expect(error.reason).toMatchObject({ _tag: "RateLimit" })
  196. expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
  197. retryAfterMs: 0,
  198. limit: { requests: "500", tokens: "30000" },
  199. remaining: { requests: "499", tokens: "29900" },
  200. reset: { requests: "1s", tokens: "10s" },
  201. })
  202. }).pipe(
  203. Effect.provide(
  204. responsesLayer([
  205. new Response("rate limited", {
  206. status: 429,
  207. headers: {
  208. "retry-after-ms": "0",
  209. "x-ratelimit-limit-requests": "500",
  210. "x-ratelimit-limit-tokens": "30000",
  211. "x-ratelimit-remaining-requests": "499",
  212. "x-ratelimit-remaining-tokens": "29900",
  213. "x-ratelimit-reset-requests": "1s",
  214. "x-ratelimit-reset-tokens": "10s",
  215. },
  216. }),
  217. ]),
  218. ),
  219. ),
  220. )
  221. it.effect("extracts Anthropic-style rate-limit diagnostics", () =>
  222. Effect.gen(function* () {
  223. const executor = yield* RequestExecutor.Service
  224. const error = yield* executor.execute(request).pipe(Effect.flip)
  225. expectAIError(error)
  226. expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
  227. expect(errorHttp(error)?.rateLimit).toEqual({
  228. retryAfterMs: 0,
  229. limit: { requests: "100", "input-tokens": "10000" },
  230. remaining: { requests: "12", "input-tokens": "9000" },
  231. reset: { requests: "2026-05-06T12:00:00Z", "input-tokens": "2026-05-06T12:00:10Z" },
  232. })
  233. }).pipe(
  234. Effect.provide(
  235. responsesLayer([
  236. new Response("overloaded", {
  237. status: 529,
  238. headers: {
  239. "retry-after-ms": "0",
  240. "anthropic-ratelimit-requests-limit": "100",
  241. "anthropic-ratelimit-requests-remaining": "12",
  242. "anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
  243. "anthropic-ratelimit-input-tokens-limit": "10000",
  244. "anthropic-ratelimit-input-tokens-remaining": "9000",
  245. "anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
  246. },
  247. }),
  248. ]),
  249. ),
  250. ),
  251. )
  252. it.effect("returns provider status failures without retrying", () =>
  253. Effect.gen(function* () {
  254. const attempts = yield* Ref.make(0)
  255. const error = yield* Effect.gen(function* () {
  256. const executor = yield* RequestExecutor.Service
  257. return yield* executor.execute(request).pipe(Effect.flip)
  258. }).pipe(
  259. Effect.provide(
  260. countedResponsesLayer(attempts, [
  261. new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
  262. new Response("ok", { status: 200 }),
  263. ]),
  264. ),
  265. )
  266. expectAIError(error)
  267. expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
  268. expect(yield* Ref.get(attempts)).toBe(1)
  269. }),
  270. )
  271. it.effect("marks 504 and 529 status responses as provider-internal", () =>
  272. Effect.gen(function* () {
  273. const failWith = (status: number) =>
  274. Effect.gen(function* () {
  275. const executor = yield* RequestExecutor.Service
  276. const error = yield* executor.execute(request).pipe(Effect.flip)
  277. expectAIError(error)
  278. expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
  279. }).pipe(
  280. Effect.provide(
  281. responsesLayer([
  282. new Response("provider failure", {
  283. status,
  284. headers: { "retry-after-ms": "0" },
  285. }),
  286. ]),
  287. ),
  288. )
  289. yield* failWith(504)
  290. yield* failWith(529)
  291. }),
  292. )
  293. it.effect("truncates large authentication error bodies", () =>
  294. Effect.gen(function* () {
  295. const executor = yield* RequestExecutor.Service
  296. const error = yield* executor.execute(request).pipe(Effect.flip)
  297. expectAIError(error)
  298. expect(error.reason).toMatchObject({ _tag: "Authentication" })
  299. expect(errorHttp(error)?.bodyTruncated).toBe(true)
  300. expect(errorHttp(error)?.body).toHaveLength(16_384)
  301. }).pipe(
  302. Effect.provide(
  303. responsesLayer([
  304. new Response("x".repeat(20_000), { status: 401 }),
  305. new Response("should not retry", { status: 200 }),
  306. ]),
  307. ),
  308. ),
  309. )
  310. it.effect("redacts common secret fields in response bodies", () =>
  311. Effect.gen(function* () {
  312. const executor = yield* RequestExecutor.Service
  313. const error = yield* executor.execute(request).pipe(Effect.flip)
  314. expectAIError(error)
  315. expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
  316. expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
  317. expect(errorHttp(error)?.body).not.toContain("body-secret")
  318. expect(errorHttp(error)?.body).not.toContain("query-secret")
  319. }).pipe(
  320. Effect.provide(
  321. responsesLayer([
  322. new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
  323. status: 400,
  324. }),
  325. ]),
  326. ),
  327. ),
  328. )
  329. it.effect("redacts echoed request secret values in response bodies", () =>
  330. Effect.gen(function* () {
  331. const executor = yield* RequestExecutor.Service
  332. const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
  333. expectAIError(error)
  334. expect(errorHttp(error)?.body).toContain("provider echoed <redacted>")
  335. expect(errorHttp(error)?.body).toContain("authorization <redacted>")
  336. expect(errorHttp(error)?.body).not.toContain("query-secret-123")
  337. expect(errorHttp(error)?.body).not.toContain("header-secret-456")
  338. }).pipe(
  339. Effect.provide(
  340. responsesLayer([
  341. new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
  342. ]),
  343. ),
  344. ),
  345. )
  346. it.effect("does not re-execute after a successful response reaches stream parsing", () =>
  347. Effect.gen(function* () {
  348. const attempts = yield* Ref.make(0)
  349. const model = OpenAIChat.route
  350. .with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
  351. .model({ id: "gpt-4o-mini" })
  352. const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
  353. Effect.provide(
  354. dynamicResponse((input) =>
  355. Ref.update(attempts, (value) => value + 1).pipe(
  356. Effect.as(
  357. input.respond(
  358. sseRaw(
  359. `data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}`,
  360. "data: not-json",
  361. ),
  362. { headers: { "content-type": "text/event-stream" } },
  363. ),
  364. ),
  365. ),
  366. ),
  367. ),
  368. Effect.flip,
  369. )
  370. expectAIError(error)
  371. expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
  372. expect(yield* Ref.get(attempts)).toBe(1)
  373. }),
  374. )
  375. })
  376. describe("WebSocket channel execution", () => {
  377. const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
  378. const request = LLM.request({ model, prompt: "Say hello." })
  379. const frames = [
  380. JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
  381. JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
  382. ]
  383. it.effect("runs a channel driver through the direct executor", () =>
  384. Effect.gen(function* () {
  385. const sent = yield* Ref.make("")
  386. const closed = yield* Ref.make(false)
  387. const observed = yield* Ref.make(0)
  388. const webSocket = WebSocketTransport.makeDirect({
  389. open: () =>
  390. Effect.succeed({
  391. sendText: (message) => Ref.set(sent, message),
  392. messages: Stream.make("one", "done", "late"),
  393. close: Ref.set(closed, true),
  394. }),
  395. })
  396. const received = yield* Effect.scoped(
  397. Effect.gen(function* () {
  398. const execution = yield* webSocket.execute({
  399. id: "exchange_1",
  400. connect: { url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
  401. fallback: () => Stream.empty,
  402. driver: {
  403. create: () => Effect.succeed({ message: "create", mode: "full" }),
  404. observe: (_create, frame) =>
  405. Ref.update(observed, (value) => value + 1).pipe(
  406. Effect.as(
  407. frame === "done" ? { type: "completed" as const, frame } : { type: "frame" as const, frame },
  408. ),
  409. ),
  410. },
  411. })
  412. return yield* Stream.runCollect(execution.frames)
  413. }),
  414. )
  415. expect(Array.from(received)).toEqual(["one", "done"])
  416. expect(yield* Ref.get(sent)).toBe("create")
  417. expect(yield* Ref.get(observed)).toBe(2)
  418. expect(yield* Ref.get(closed)).toBe(true)
  419. }),
  420. )
  421. it.effect("rejects a closed socket before attempting to send", () =>
  422. Effect.gen(function* () {
  423. class ClosedBeforeSend extends EventTarget {
  424. readyState = globalThis.WebSocket.OPEN
  425. sends = 0
  426. send() {
  427. this.sends++
  428. }
  429. close() {}
  430. }
  431. const socket = new ClosedBeforeSend()
  432. const connection = yield* WebSocketTransport.fromWebSocket(
  433. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
  434. socket as unknown as globalThis.WebSocket,
  435. { url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
  436. )
  437. socket.readyState = globalThis.WebSocket.CLOSED
  438. const error = yield* connection.sendText("create").pipe(Effect.flip)
  439. expect(error.reason).toMatchObject({ _tag: "Transport", phase: "send", delivery: "not-sent" })
  440. expect(socket.sends).toBe(0)
  441. yield* connection.close
  442. }),
  443. )
  444. it.effect("uses HTTP when no per-call WebSocket executor is provided", () =>
  445. Effect.gen(function* () {
  446. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(sseEvents(...frames))))
  447. expect(response.text).toBe("Hi")
  448. }),
  449. )
  450. it.effect("commits channel execution only after complete consumption", () =>
  451. Effect.gen(function* () {
  452. const commits = yield* Ref.make(0)
  453. const executor = (input: Stream.Stream<string, AIError>): WebSocketChannelExecutor => ({
  454. execute: () =>
  455. Effect.succeed({
  456. frames: input,
  457. complete: Ref.update(commits, (value) => value + 1),
  458. }),
  459. })
  460. const response = yield* LLMClient.generate(request, {
  461. webSocket: executor(Stream.fromArray(frames)),
  462. }).pipe(Effect.provide(fixedResponse("")))
  463. expect(response.text).toBe("Hi")
  464. expect(yield* Ref.get(commits)).toBe(1)
  465. yield* LLMClient.generate(request, { webSocket: executor(Stream.make("not-json")) }).pipe(
  466. Effect.provide(fixedResponse("")),
  467. Effect.flip,
  468. )
  469. expect(yield* Ref.get(commits)).toBe(1)
  470. yield* LLMClient.stream(request, { webSocket: executor(Stream.fromArray(frames)) }).pipe(
  471. Stream.take(1),
  472. Stream.runDrain,
  473. Effect.provide(fixedResponse("")),
  474. )
  475. expect(yield* Ref.get(commits)).toBe(1)
  476. }),
  477. )
  478. it.effect("does not commit interrupted channel execution", () =>
  479. Effect.gen(function* () {
  480. const commits = yield* Ref.make(0)
  481. const started = yield* Deferred.make<void>()
  482. const executor: WebSocketChannelExecutor = {
  483. execute: () =>
  484. Effect.succeed({
  485. frames: Stream.fromEffect(
  486. Deferred.succeed(started, undefined).pipe(
  487. Effect.as(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })),
  488. ),
  489. ).pipe(Stream.concat(Stream.never)),
  490. complete: Ref.update(commits, (value) => value + 1),
  491. }),
  492. }
  493. const fiber = yield* LLMClient.stream(request, { webSocket: executor }).pipe(
  494. Stream.runDrain,
  495. Effect.provide(fixedResponse("")),
  496. Effect.forkChild({ startImmediately: true }),
  497. )
  498. yield* Deferred.await(started)
  499. yield* Fiber.interrupt(fiber)
  500. expect(yield* Ref.get(commits)).toBe(0)
  501. }),
  502. )
  503. })