executor.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Fiber, Layer, Random, Ref } from "effect"
  3. import * as TestClock from "effect/testing/TestClock"
  4. import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  5. import { LLM, LLMError } from "../src"
  6. import { LLMClient, RequestExecutor } from "../src/route"
  7. import * as OpenAIChat from "../src/protocols/openai-chat"
  8. import { dynamicResponse } from "./lib/http"
  9. import { deltaChunk } from "./lib/openai-chunks"
  10. import { 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 randomMidpoint = {
  58. nextDoubleUnsafe: () => 0.5,
  59. nextIntUnsafe: () => 0,
  60. }
  61. const expectLLMError = (error: unknown) => {
  62. expect(error).toBeInstanceOf(LLMError)
  63. if (!(error instanceof LLMError)) throw new Error("expected LLMError")
  64. return error
  65. }
  66. const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined)
  67. describe("RequestExecutor", () => {
  68. it.effect("classifies context overflow responses", () =>
  69. Effect.gen(function* () {
  70. const executor = yield* RequestExecutor.Service
  71. const error = yield* executor.execute(request).pipe(Effect.flip)
  72. expectLLMError(error)
  73. expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
  74. }).pipe(
  75. Effect.provide(
  76. responsesLayer([
  77. new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
  78. status: 400,
  79. }),
  80. ]),
  81. ),
  82. ),
  83. )
  84. it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
  85. Effect.gen(function* () {
  86. const executor = yield* RequestExecutor.Service
  87. const error = yield* executor.execute(request).pipe(Effect.flip)
  88. expectLLMError(error)
  89. expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
  90. expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
  91. }).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
  92. )
  93. it.effect("does not classify ordinary invalid requests as context overflow", () =>
  94. Effect.gen(function* () {
  95. const executor = yield* RequestExecutor.Service
  96. const error = yield* executor.execute(request).pipe(Effect.flip)
  97. expectLLMError(error)
  98. expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
  99. expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
  100. }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
  101. )
  102. it.effect("returns redacted diagnostics for retryable rate limits", () =>
  103. Effect.gen(function* () {
  104. const executor = yield* RequestExecutor.Service
  105. const error = yield* executor.execute(request).pipe(Effect.flip)
  106. expectLLMError(error)
  107. expect(error).toMatchObject({
  108. retryable: true,
  109. retryAfterMs: 0,
  110. reason: {
  111. _tag: "RateLimit",
  112. rateLimit: { retryAfterMs: 0 },
  113. http: {
  114. requestId: "req_123",
  115. request: {
  116. method: "POST",
  117. url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
  118. headers: { authorization: "<redacted>", "x-safe": "visible" },
  119. },
  120. response: {
  121. status: 429,
  122. headers: {
  123. "retry-after-ms": "0",
  124. "x-request-id": "req_123",
  125. "x-api-key": "<redacted>",
  126. },
  127. },
  128. },
  129. },
  130. })
  131. expect(errorHttp(error)?.body).toBe("rate limited")
  132. }).pipe(
  133. Effect.provide(
  134. responsesLayer(
  135. Array.from(
  136. { length: 3 },
  137. () =>
  138. new Response("rate limited", {
  139. status: 429,
  140. headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
  141. }),
  142. ),
  143. ),
  144. ),
  145. ),
  146. )
  147. it.effect("honors current redacted header names in diagnostics", () =>
  148. Effect.gen(function* () {
  149. const executor = yield* RequestExecutor.Service
  150. const error = yield* executor.execute(request).pipe(Effect.flip)
  151. expectLLMError(error)
  152. expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
  153. expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
  154. }).pipe(
  155. Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
  156. Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
  157. ),
  158. )
  159. it.effect("extracts OpenAI-style rate-limit diagnostics", () =>
  160. Effect.gen(function* () {
  161. const executor = yield* RequestExecutor.Service
  162. const error = yield* executor.execute(request).pipe(Effect.flip)
  163. expectLLMError(error)
  164. expect(error.reason).toMatchObject({ _tag: "RateLimit" })
  165. expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
  166. retryAfterMs: 0,
  167. limit: { requests: "500", tokens: "30000" },
  168. remaining: { requests: "499", tokens: "29900" },
  169. reset: { requests: "1s", tokens: "10s" },
  170. })
  171. }).pipe(
  172. Effect.provide(
  173. responsesLayer(
  174. Array.from(
  175. { length: 3 },
  176. () =>
  177. new Response("rate limited", {
  178. status: 429,
  179. headers: {
  180. "retry-after-ms": "0",
  181. "x-ratelimit-limit-requests": "500",
  182. "x-ratelimit-limit-tokens": "30000",
  183. "x-ratelimit-remaining-requests": "499",
  184. "x-ratelimit-remaining-tokens": "29900",
  185. "x-ratelimit-reset-requests": "1s",
  186. "x-ratelimit-reset-tokens": "10s",
  187. },
  188. }),
  189. ),
  190. ),
  191. ),
  192. ),
  193. )
  194. it.effect("extracts Anthropic-style rate-limit diagnostics", () =>
  195. Effect.gen(function* () {
  196. const executor = yield* RequestExecutor.Service
  197. const error = yield* executor.execute(request).pipe(Effect.flip)
  198. expectLLMError(error)
  199. expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
  200. expect(errorHttp(error)?.rateLimit).toEqual({
  201. retryAfterMs: 0,
  202. limit: { requests: "100", "input-tokens": "10000" },
  203. remaining: { requests: "12", "input-tokens": "9000" },
  204. reset: { requests: "2026-05-06T12:00:00Z", "input-tokens": "2026-05-06T12:00:10Z" },
  205. })
  206. }).pipe(
  207. Effect.provide(
  208. responsesLayer(
  209. Array.from(
  210. { length: 3 },
  211. () =>
  212. new Response("overloaded", {
  213. status: 529,
  214. headers: {
  215. "retry-after-ms": "0",
  216. "anthropic-ratelimit-requests-limit": "100",
  217. "anthropic-ratelimit-requests-remaining": "12",
  218. "anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
  219. "anthropic-ratelimit-input-tokens-limit": "10000",
  220. "anthropic-ratelimit-input-tokens-remaining": "9000",
  221. "anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
  222. },
  223. }),
  224. ),
  225. ),
  226. ),
  227. ),
  228. )
  229. it.effect("retries retryable status responses before returning the stream", () =>
  230. Effect.gen(function* () {
  231. const executor = yield* RequestExecutor.Service
  232. const response = yield* executor.execute(request)
  233. expect(response.status).toBe(200)
  234. expect(yield* response.text).toBe("ok")
  235. }).pipe(
  236. Effect.provide(
  237. responsesLayer([
  238. new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
  239. new Response("ok", { status: 200 }),
  240. ]),
  241. ),
  242. ),
  243. )
  244. it.effect("marks 504 and 529 status responses retryable", () =>
  245. Effect.gen(function* () {
  246. const failWith = (status: number) =>
  247. Effect.gen(function* () {
  248. const executor = yield* RequestExecutor.Service
  249. const error = yield* executor.execute(request).pipe(Effect.flip)
  250. expectLLMError(error)
  251. expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
  252. expect(error.retryable).toBe(true)
  253. }).pipe(
  254. Effect.provide(
  255. responsesLayer(
  256. Array.from(
  257. { length: 3 },
  258. () =>
  259. new Response("retry", {
  260. status,
  261. headers: { "retry-after-ms": "0" },
  262. }),
  263. ),
  264. ),
  265. ),
  266. )
  267. yield* failWith(504)
  268. yield* failWith(529)
  269. }),
  270. )
  271. it.effect("does not retry non-retryable status responses and truncates large bodies", () =>
  272. Effect.gen(function* () {
  273. const executor = yield* RequestExecutor.Service
  274. const error = yield* executor.execute(request).pipe(Effect.flip)
  275. expectLLMError(error)
  276. expect(error.reason).toMatchObject({ _tag: "Authentication" })
  277. expect(error.retryable).toBe(false)
  278. expect(errorHttp(error)?.bodyTruncated).toBe(true)
  279. expect(errorHttp(error)?.body).toHaveLength(16_384)
  280. }).pipe(
  281. Effect.provide(
  282. responsesLayer([
  283. new Response("x".repeat(20_000), { status: 401 }),
  284. new Response("should not retry", { status: 200 }),
  285. ]),
  286. ),
  287. ),
  288. )
  289. it.effect("redacts common secret fields in response bodies", () =>
  290. Effect.gen(function* () {
  291. const executor = yield* RequestExecutor.Service
  292. const error = yield* executor.execute(request).pipe(Effect.flip)
  293. expectLLMError(error)
  294. expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
  295. expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
  296. expect(errorHttp(error)?.body).not.toContain("body-secret")
  297. expect(errorHttp(error)?.body).not.toContain("query-secret")
  298. }).pipe(
  299. Effect.provide(
  300. responsesLayer([
  301. new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
  302. status: 400,
  303. }),
  304. ]),
  305. ),
  306. ),
  307. )
  308. it.effect("redacts echoed request secret values in response bodies", () =>
  309. Effect.gen(function* () {
  310. const executor = yield* RequestExecutor.Service
  311. const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
  312. expectLLMError(error)
  313. expect(errorHttp(error)?.body).toContain("provider echoed <redacted>")
  314. expect(errorHttp(error)?.body).toContain("authorization <redacted>")
  315. expect(errorHttp(error)?.body).not.toContain("query-secret-123")
  316. expect(errorHttp(error)?.body).not.toContain("header-secret-456")
  317. }).pipe(
  318. Effect.provide(
  319. responsesLayer([
  320. new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
  321. ]),
  322. ),
  323. ),
  324. )
  325. it.effect("honors Retry-After delta seconds before retrying", () =>
  326. Effect.gen(function* () {
  327. const attempts = yield* Ref.make(0)
  328. return yield* Effect.gen(function* () {
  329. const executor = yield* RequestExecutor.Service
  330. const fiber = yield* executor.execute(request).pipe(Effect.forkChild)
  331. yield* Effect.yieldNow
  332. expect(yield* Ref.get(attempts)).toBe(1)
  333. yield* TestClock.adjust(1_999)
  334. yield* Effect.yieldNow
  335. expect(yield* Ref.get(attempts)).toBe(1)
  336. yield* TestClock.adjust(1)
  337. const response = yield* Fiber.join(fiber)
  338. expect(response.status).toBe(200)
  339. expect(yield* Ref.get(attempts)).toBe(2)
  340. }).pipe(
  341. Effect.provide(
  342. countedResponsesLayer(attempts, [
  343. new Response("busy", { status: 503, headers: { "retry-after": "2" } }),
  344. new Response("ok", { status: 200 }),
  345. ]),
  346. ),
  347. )
  348. }),
  349. )
  350. it.effect("uses exponential jittered delay when retry-after is absent", () =>
  351. Effect.gen(function* () {
  352. const attempts = yield* Ref.make(0)
  353. return yield* Effect.gen(function* () {
  354. const executor = yield* RequestExecutor.Service
  355. const fiber = yield* executor.execute(request).pipe(Effect.flip, Effect.forkChild)
  356. yield* Effect.yieldNow
  357. expect(yield* Ref.get(attempts)).toBe(1)
  358. yield* TestClock.adjust(499)
  359. yield* Effect.yieldNow
  360. expect(yield* Ref.get(attempts)).toBe(1)
  361. yield* TestClock.adjust(1)
  362. yield* Effect.yieldNow
  363. expect(yield* Ref.get(attempts)).toBe(2)
  364. yield* TestClock.adjust(999)
  365. yield* Effect.yieldNow
  366. expect(yield* Ref.get(attempts)).toBe(2)
  367. yield* TestClock.adjust(1)
  368. const error = yield* Fiber.join(fiber)
  369. expectLLMError(error)
  370. expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
  371. expect(yield* Ref.get(attempts)).toBe(3)
  372. }).pipe(
  373. Effect.provide(
  374. countedResponsesLayer(attempts, [
  375. new Response("busy", { status: 503 }),
  376. new Response("still busy", { status: 503 }),
  377. new Response("done retrying", { status: 503 }),
  378. ]),
  379. ),
  380. )
  381. }).pipe(Effect.provideService(Random.Random, randomMidpoint)),
  382. )
  383. it.effect("does not retry after a successful response reaches stream parsing", () =>
  384. Effect.gen(function* () {
  385. const attempts = yield* Ref.make(0)
  386. const model = OpenAIChat.route
  387. .with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
  388. .model({ id: "gpt-4o-mini" })
  389. const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
  390. Effect.provide(
  391. dynamicResponse((input) =>
  392. Ref.update(attempts, (value) => value + 1).pipe(
  393. Effect.as(
  394. input.respond(
  395. sseRaw(
  396. `data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}`,
  397. "data: not-json",
  398. ),
  399. { headers: { "content-type": "text/event-stream" } },
  400. ),
  401. ),
  402. ),
  403. ),
  404. ),
  405. Effect.flip,
  406. )
  407. expectLLMError(error)
  408. expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
  409. expect(yield* Ref.get(attempts)).toBe(1)
  410. }),
  411. )
  412. })