executor.test.ts 14 KB

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