1
0

executor.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer, Ref } from "effect"
  3. import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  4. import { LLM, isLLMError, type LLMError } from "../src"
  5. import { LLMClient, RequestExecutor } from "../src/route"
  6. import * as OpenAIChat from "../src/protocols/openai-chat"
  7. import { dynamicResponse } from "./lib/http"
  8. import { deltaChunk } from "./lib/openai-chunks"
  9. import { sseRaw } from "./lib/sse"
  10. import { it } from "./lib/effect"
  11. const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe(
  12. HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer secret", "x-safe": "visible" })),
  13. )
  14. const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_key=query-secret-123&debug=1").pipe(
  15. HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
  16. )
  17. const responsesLayer = (responses: ReadonlyArray<Response>) =>
  18. RequestExecutor.layer.pipe(
  19. Layer.provide(
  20. Layer.unwrap(
  21. Effect.gen(function* () {
  22. const cursor = yield* Ref.make(0)
  23. return Layer.succeed(
  24. HttpClient.HttpClient,
  25. HttpClient.make((request) =>
  26. Effect.gen(function* () {
  27. const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
  28. return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
  29. }),
  30. ),
  31. )
  32. }),
  33. ),
  34. ),
  35. )
  36. const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArray<Response>) =>
  37. RequestExecutor.layer.pipe(
  38. Layer.provide(
  39. Layer.unwrap(
  40. Effect.gen(function* () {
  41. const cursor = yield* Ref.make(0)
  42. return Layer.succeed(
  43. HttpClient.HttpClient,
  44. HttpClient.make((request) =>
  45. Effect.gen(function* () {
  46. yield* Ref.update(attempts, (value) => value + 1)
  47. const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
  48. return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
  49. }),
  50. ),
  51. )
  52. }),
  53. ),
  54. ),
  55. )
  56. const expectLLMError = (error: unknown) => {
  57. expect(isLLMError(error)).toBe(true)
  58. if (!isLLMError(error)) throw new Error("expected LLMError")
  59. return error
  60. }
  61. const errorHttp = (error: LLMError) => ("http" in error ? error.http : undefined)
  62. describe("RequestExecutor", () => {
  63. it.effect("classifies context overflow responses", () =>
  64. Effect.gen(function* () {
  65. const executor = yield* RequestExecutor.Service
  66. const error = yield* executor.execute(request).pipe(Effect.flip)
  67. expectLLMError(error)
  68. expect(error).toMatchObject({ _tag: "LLM.ContextOverflow" })
  69. }).pipe(
  70. Effect.provide(
  71. responsesLayer([
  72. new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
  73. status: 400,
  74. }),
  75. ]),
  76. ),
  77. ),
  78. )
  79. it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
  80. Effect.gen(function* () {
  81. const executor = yield* RequestExecutor.Service
  82. const error = yield* executor.execute(request).pipe(Effect.flip)
  83. expectLLMError(error)
  84. expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
  85. }).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
  86. )
  87. it.effect("does not classify ordinary invalid requests as context overflow", () =>
  88. Effect.gen(function* () {
  89. const executor = yield* RequestExecutor.Service
  90. const error = yield* executor.execute(request).pipe(Effect.flip)
  91. expectLLMError(error)
  92. expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
  93. }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
  94. )
  95. it.effect("returns redacted diagnostics for rate limits", () =>
  96. Effect.gen(function* () {
  97. const executor = yield* RequestExecutor.Service
  98. const error = yield* executor.execute(request).pipe(Effect.flip)
  99. expectLLMError(error)
  100. expect(error).toMatchObject({
  101. _tag: "LLM.RateLimit",
  102. retryAfterMs: 0,
  103. rateLimit: { retryAfterMs: 0 },
  104. http: {
  105. requestId: "req_123",
  106. request: {
  107. method: "POST",
  108. url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
  109. headers: { authorization: "<redacted>", "x-safe": "visible" },
  110. },
  111. response: {
  112. status: 429,
  113. headers: {
  114. "retry-after-ms": "0",
  115. "x-request-id": "req_123",
  116. "x-api-key": "<redacted>",
  117. },
  118. },
  119. },
  120. })
  121. expect(errorHttp(error)?.body).toBe("rate limited")
  122. }).pipe(
  123. Effect.provide(
  124. responsesLayer([
  125. new Response("rate limited", {
  126. status: 429,
  127. headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
  128. }),
  129. ]),
  130. ),
  131. ),
  132. )
  133. it.effect("honors current redacted header names in diagnostics", () =>
  134. Effect.gen(function* () {
  135. const executor = yield* RequestExecutor.Service
  136. const error = yield* executor.execute(request).pipe(Effect.flip)
  137. expectLLMError(error)
  138. expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
  139. expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
  140. }).pipe(
  141. Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
  142. Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
  143. ),
  144. )
  145. it.effect("extracts OpenAI-style rate-limit diagnostics", () =>
  146. Effect.gen(function* () {
  147. const executor = yield* RequestExecutor.Service
  148. const error = yield* executor.execute(request).pipe(Effect.flip)
  149. expectLLMError(error)
  150. expect(error).toMatchObject({ _tag: "LLM.RateLimit" })
  151. expect(error._tag === "LLM.RateLimit" ? error.rateLimit : undefined).toEqual({
  152. retryAfterMs: 0,
  153. limit: { requests: "500", tokens: "30000" },
  154. remaining: { requests: "499", tokens: "29900" },
  155. reset: { requests: "1s", tokens: "10s" },
  156. })
  157. }).pipe(
  158. Effect.provide(
  159. responsesLayer([
  160. new Response("rate limited", {
  161. status: 429,
  162. headers: {
  163. "retry-after-ms": "0",
  164. "x-ratelimit-limit-requests": "500",
  165. "x-ratelimit-limit-tokens": "30000",
  166. "x-ratelimit-remaining-requests": "499",
  167. "x-ratelimit-remaining-tokens": "29900",
  168. "x-ratelimit-reset-requests": "1s",
  169. "x-ratelimit-reset-tokens": "10s",
  170. },
  171. }),
  172. ]),
  173. ),
  174. ),
  175. )
  176. it.effect("extracts Anthropic-style rate-limit diagnostics", () =>
  177. Effect.gen(function* () {
  178. const executor = yield* RequestExecutor.Service
  179. const error = yield* executor.execute(request).pipe(Effect.flip)
  180. expectLLMError(error)
  181. expect(error).toMatchObject({ _tag: "LLM.ServerError" })
  182. expect(errorHttp(error)?.rateLimit).toEqual({
  183. retryAfterMs: 0,
  184. limit: { requests: "100", "input-tokens": "10000" },
  185. remaining: { requests: "12", "input-tokens": "9000" },
  186. reset: { requests: "2026-05-06T12:00:00Z", "input-tokens": "2026-05-06T12:00:10Z" },
  187. })
  188. }).pipe(
  189. Effect.provide(
  190. responsesLayer([
  191. new Response("overloaded", {
  192. status: 529,
  193. headers: {
  194. "retry-after-ms": "0",
  195. "anthropic-ratelimit-requests-limit": "100",
  196. "anthropic-ratelimit-requests-remaining": "12",
  197. "anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
  198. "anthropic-ratelimit-input-tokens-limit": "10000",
  199. "anthropic-ratelimit-input-tokens-remaining": "9000",
  200. "anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
  201. },
  202. }),
  203. ]),
  204. ),
  205. ),
  206. )
  207. it.effect("returns provider status failures without retrying", () =>
  208. Effect.gen(function* () {
  209. const attempts = yield* Ref.make(0)
  210. const error = yield* Effect.gen(function* () {
  211. const executor = yield* RequestExecutor.Service
  212. return yield* executor.execute(request).pipe(Effect.flip)
  213. }).pipe(
  214. Effect.provide(
  215. countedResponsesLayer(attempts, [
  216. new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
  217. new Response("ok", { status: 200 }),
  218. ]),
  219. ),
  220. )
  221. expectLLMError(error)
  222. expect(error).toMatchObject({ _tag: "LLM.ServerError", status: 503 })
  223. expect(yield* Ref.get(attempts)).toBe(1)
  224. }),
  225. )
  226. it.effect("marks 504 and 529 status responses as server errors", () =>
  227. Effect.gen(function* () {
  228. const failWith = (status: number) =>
  229. Effect.gen(function* () {
  230. const executor = yield* RequestExecutor.Service
  231. const error = yield* executor.execute(request).pipe(Effect.flip)
  232. expectLLMError(error)
  233. expect(error).toMatchObject({ _tag: "LLM.ServerError", status })
  234. }).pipe(
  235. Effect.provide(
  236. responsesLayer([
  237. new Response("provider failure", {
  238. status,
  239. headers: { "retry-after-ms": "0" },
  240. }),
  241. ]),
  242. ),
  243. )
  244. yield* failWith(504)
  245. yield* failWith(529)
  246. }),
  247. )
  248. it.effect("truncates large authentication error bodies", () =>
  249. Effect.gen(function* () {
  250. const executor = yield* RequestExecutor.Service
  251. const error = yield* executor.execute(request).pipe(Effect.flip)
  252. expectLLMError(error)
  253. expect(error).toMatchObject({ _tag: "LLM.Authentication" })
  254. expect(errorHttp(error)?.bodyTruncated).toBe(true)
  255. expect(errorHttp(error)?.body).toHaveLength(16_384)
  256. }).pipe(
  257. Effect.provide(
  258. responsesLayer([
  259. new Response("x".repeat(20_000), { status: 401 }),
  260. new Response("should not retry", { status: 200 }),
  261. ]),
  262. ),
  263. ),
  264. )
  265. it.effect("redacts common secret fields in response bodies", () =>
  266. Effect.gen(function* () {
  267. const executor = yield* RequestExecutor.Service
  268. const error = yield* executor.execute(request).pipe(Effect.flip)
  269. expectLLMError(error)
  270. expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
  271. expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
  272. expect(errorHttp(error)?.body).not.toContain("body-secret")
  273. expect(errorHttp(error)?.body).not.toContain("query-secret")
  274. }).pipe(
  275. Effect.provide(
  276. responsesLayer([
  277. new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
  278. status: 400,
  279. }),
  280. ]),
  281. ),
  282. ),
  283. )
  284. it.effect("redacts echoed request secret values in response bodies", () =>
  285. Effect.gen(function* () {
  286. const executor = yield* RequestExecutor.Service
  287. const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
  288. expectLLMError(error)
  289. expect(errorHttp(error)?.body).toContain("provider echoed <redacted>")
  290. expect(errorHttp(error)?.body).toContain("authorization <redacted>")
  291. expect(errorHttp(error)?.body).not.toContain("query-secret-123")
  292. expect(errorHttp(error)?.body).not.toContain("header-secret-456")
  293. }).pipe(
  294. Effect.provide(
  295. responsesLayer([
  296. new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
  297. ]),
  298. ),
  299. ),
  300. )
  301. it.effect("does not re-execute after a successful response reaches stream parsing", () =>
  302. Effect.gen(function* () {
  303. const attempts = yield* Ref.make(0)
  304. const model = OpenAIChat.route
  305. .with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
  306. .model({ id: "gpt-4o-mini" })
  307. const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
  308. Effect.provide(
  309. dynamicResponse((input) =>
  310. Ref.update(attempts, (value) => value + 1).pipe(
  311. Effect.as(
  312. input.respond(
  313. sseRaw(
  314. `data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}`,
  315. "data: not-json",
  316. ),
  317. { headers: { "content-type": "text/event-stream" } },
  318. ),
  319. ),
  320. ),
  321. ),
  322. ),
  323. Effect.flip,
  324. )
  325. expectLLMError(error)
  326. expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
  327. expect(yield* Ref.get(attempts)).toBe(1)
  328. }),
  329. )
  330. })