executor.test.ts 15 KB

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