aisdk.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. import { APICallError } from "@ai-sdk/provider"
  2. import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
  3. import { createMistral } from "@ai-sdk/mistral"
  4. import { AISDK } from "@opencode-ai/core/aisdk"
  5. import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
  6. import { toSessionError } from "@opencode-ai/core/session/to-session-error"
  7. import { Model } from "@opencode-ai/core/model"
  8. import { Provider } from "@opencode-ai/core/provider"
  9. import { LLM, AIError, LLMEvent, Message, isContextOverflowFailure } from "@opencode-ai/ai"
  10. import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
  11. import { compileRequest } from "@opencode-ai/ai/route/client"
  12. import { expect } from "bun:test"
  13. import { Effect, Layer } from "effect"
  14. import { testEffect } from "./lib/effect"
  15. const it = testEffect(AISDK.locationLayer)
  16. const model = (packageName: string, settings: Record<string, unknown> = {}) =>
  17. Model.Info.make({
  18. ...Model.Info.default(Provider.ID.make("test-provider"), Model.ID.make("catalog-model")),
  19. modelID: Model.ID.make("api-model"),
  20. package: Provider.aisdk(packageName),
  21. settings,
  22. limit: { context: 100, output: 20 },
  23. })
  24. const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
  25. specificationVersion: "v3",
  26. provider: "test",
  27. modelId: "test",
  28. supportedUrls: {},
  29. doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
  30. doStream: () =>
  31. Promise.resolve({
  32. stream: new ReadableStream({
  33. start(controller) {
  34. events.forEach((event) => controller.enqueue(event))
  35. controller.close()
  36. },
  37. }),
  38. }),
  39. })
  40. const usage = {
  41. inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
  42. outputTokens: { total: 1, text: 0, reasoning: 0 },
  43. } as const
  44. const client = LLMClient.layer.pipe(
  45. Layer.provide(
  46. Layer.succeed(
  47. RequestExecutor.Service,
  48. RequestExecutor.Service.of({
  49. execute: () => Effect.die("Unexpected HTTP request"),
  50. }),
  51. ),
  52. ),
  53. )
  54. it.effect("keys language models by package and flattened overlays", () =>
  55. Effect.gen(function* () {
  56. const aisdk = yield* AISDK.Service
  57. const loaded: string[] = []
  58. yield* aisdk.hook.sdk((event) => {
  59. loaded.push(event.package)
  60. event.sdk = { languageModel: () => ({ package: event.package }) }
  61. })
  62. const first = yield* aisdk.language(model("first", { region: "us-east-1" }))
  63. const second = yield* aisdk.language(model("second", { region: "us-east-1" }))
  64. const third = yield* aisdk.language(model("second", { region: "us-west-2" }))
  65. expect(first).not.toBe(second)
  66. expect(second).not.toBe(third)
  67. expect(loaded).toEqual(["first", "second", "second"])
  68. }),
  69. )
  70. it.effect("projects request settings, headers, and body overlays", () =>
  71. Effect.gen(function* () {
  72. const aisdk = yield* AISDK.Service
  73. let body: unknown
  74. yield* aisdk.hook.sdk((event) => {
  75. body = event.options.body
  76. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  77. })
  78. const input = model("@ai-sdk/google", {
  79. apiKey: "secret",
  80. thinkingConfig: { thinkingBudget: 1024 },
  81. })
  82. const resolved = yield* aisdk.model({
  83. ...input,
  84. headers: { "x-test": "header" },
  85. body: { safety_setting: "strict" },
  86. })
  87. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  88. expect(prepared.body.providerOptions).toEqual({
  89. google: { thinkingConfig: { thinkingBudget: 1024 } },
  90. })
  91. expect(prepared.body.headers).toEqual({ "x-test": "header" })
  92. expect(body).toEqual({ safety_setting: "strict" })
  93. }),
  94. )
  95. it.effect("leaves max output tokens unset when the request omits them", () =>
  96. Effect.gen(function* () {
  97. const aisdk = yield* AISDK.Service
  98. yield* aisdk.hook.sdk((event) => {
  99. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  100. })
  101. const resolved = yield* aisdk.model({
  102. ...model("@openrouter/ai-sdk-provider"),
  103. limit: { context: 500_000, output: 500_000 },
  104. })
  105. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  106. expect(prepared.body.maxOutputTokens).toBeUndefined()
  107. }),
  108. )
  109. it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
  110. Effect.gen(function* () {
  111. const aisdk = yield* AISDK.Service
  112. let body: unknown
  113. yield* aisdk.hook.sdk((event) => {
  114. body = event.options.body
  115. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  116. })
  117. const resolved = yield* aisdk.model({
  118. ...model("@ai-sdk/openai"),
  119. body: { reasoning: { mode: "pro" } },
  120. })
  121. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  122. expect(body).toBeUndefined()
  123. expect(prepared.body.providerOptions).toEqual({
  124. openai: { forceReasoning: true, reasoningMode: "pro" },
  125. })
  126. }),
  127. )
  128. it.effect("maps package-specific AI SDK provider option keys", () =>
  129. Effect.gen(function* () {
  130. const aisdk = yield* AISDK.Service
  131. yield* aisdk.hook.sdk((event) => {
  132. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  133. })
  134. const cases = [
  135. ["@ai-sdk/github-copilot", "copilot", { reasoningEffort: "high" }],
  136. ["@ai-sdk/amazon-bedrock/mantle", "openai", { reasoningEffort: "high", forceReasoning: true }],
  137. ["@ai-sdk/openai-compatible", "test-provider", { reasoningEffort: "high" }],
  138. ["@jerome-benoit/sap-ai-provider-v2", "sap-ai", { reasoningEffort: "high" }],
  139. ["ai-gateway-provider", "openaiCompatible", { reasoningEffort: "high" }],
  140. ] as const
  141. for (const [packageName, key, settings] of cases) {
  142. const resolved = yield* aisdk.model(model(packageName, { reasoningEffort: "high" }))
  143. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  144. expect(prepared.body.providerOptions).toEqual({ [key]: settings })
  145. }
  146. }),
  147. )
  148. it.effect("forces reasoning and projects both Azure AI SDK namespaces", () =>
  149. Effect.gen(function* () {
  150. const aisdk = yield* AISDK.Service
  151. yield* aisdk.hook.sdk((event) => {
  152. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  153. })
  154. const openai = yield* aisdk.model(model("@ai-sdk/openai", { reasoningEffort: "high" }))
  155. const openaiPrepared = yield* compileRequest(LLM.request({ model: openai, prompt: "Hello" }))
  156. expect(openaiPrepared.body.providerOptions).toEqual({
  157. openai: { reasoningEffort: "high", forceReasoning: true },
  158. })
  159. const azure = yield* aisdk.model(model("@ai-sdk/azure", { reasoningEffort: "high" }))
  160. const azurePrepared = yield* compileRequest(LLM.request({ model: azure, prompt: "Hello" }))
  161. expect(azurePrepared.body.providerOptions).toEqual({
  162. openai: { reasoningEffort: "high", forceReasoning: true },
  163. azure: { reasoningEffort: "high", forceReasoning: true },
  164. })
  165. }),
  166. )
  167. it.effect("routes AI Gateway model options by upstream prefix", () =>
  168. Effect.gen(function* () {
  169. const aisdk = yield* AISDK.Service
  170. yield* aisdk.hook.sdk((event) => {
  171. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  172. })
  173. const anthropic = yield* aisdk.model({
  174. ...model("@ai-sdk/gateway", {
  175. gateway: { order: ["anthropic"] },
  176. thinking: { type: "adaptive" },
  177. }),
  178. modelID: Model.ID.make("anthropic/claude-sonnet-5"),
  179. })
  180. const anthropicPrepared = yield* compileRequest(LLM.request({ model: anthropic, prompt: "Hello" }))
  181. expect(anthropicPrepared.body.providerOptions).toEqual({
  182. gateway: { order: ["anthropic"] },
  183. anthropic: { thinking: { type: "adaptive" } },
  184. })
  185. const bedrock = yield* aisdk.model({
  186. ...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }),
  187. modelID: Model.ID.make("amazon/nova-2-lite"),
  188. })
  189. const bedrockPrepared = yield* compileRequest(LLM.request({ model: bedrock, prompt: "Hello" }))
  190. expect(bedrockPrepared.body.providerOptions).toEqual({
  191. bedrock: { reasoningConfig: { type: "enabled" } },
  192. })
  193. const fallback = yield* aisdk.model({
  194. ...model("@ai-sdk/gateway", { reasoningEffort: "high" }),
  195. modelID: Model.ID.make("deepseek/deepseek-v4"),
  196. })
  197. const fallbackPrepared = yield* compileRequest(LLM.request({ model: fallback, prompt: "Hello" }))
  198. expect(fallbackPrepared.body.providerOptions).toEqual({
  199. deepseek: { reasoningEffort: "high" },
  200. })
  201. }),
  202. )
  203. it.effect("projects replay metadata onto AI SDK prompt parts", () =>
  204. Effect.gen(function* () {
  205. const aisdk = yield* AISDK.Service
  206. yield* aisdk.hook.sdk((event) => {
  207. event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
  208. })
  209. const resolved = yield* aisdk.model(model("@ai-sdk/anthropic"))
  210. expect(resolved.route.providerMetadataKey).toBe("anthropic")
  211. const prepared = yield* compileRequest(
  212. LLM.request({
  213. model: resolved,
  214. messages: [
  215. Message.assistant([
  216. { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } },
  217. {
  218. type: "tool-call",
  219. id: "hosted",
  220. name: "web_search",
  221. input: { query: "Effect" },
  222. providerExecuted: true,
  223. providerMetadata: { anthropic: { blockType: "server_tool_use" } },
  224. },
  225. ]),
  226. ],
  227. }),
  228. )
  229. expect(prepared.body.prompt).toEqual([
  230. {
  231. role: "assistant",
  232. content: [
  233. {
  234. type: "reasoning",
  235. text: "Think",
  236. providerOptions: { anthropic: { signature: "signed" } },
  237. },
  238. {
  239. type: "tool-call",
  240. toolCallId: "hosted",
  241. toolName: "web_search",
  242. input: { query: "Effect" },
  243. providerExecuted: true,
  244. providerOptions: { anthropic: { blockType: "server_tool_use" } },
  245. },
  246. ],
  247. },
  248. ])
  249. }),
  250. )
  251. it.effect("moves a tool image through the real Mistral provider as a user message", () =>
  252. Effect.gen(function* () {
  253. const aisdk = yield* AISDK.Service
  254. let body: { messages?: unknown[] } | undefined
  255. const mockFetch = Object.assign(
  256. async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
  257. body = JSON.parse(String(init?.body))
  258. const chunks = [
  259. {
  260. id: "response-1",
  261. created: 0,
  262. model: "pixtral-large-latest",
  263. choices: [{ index: 0, delta: { content: [{ type: "text", text: "I see it." }] } }],
  264. },
  265. {
  266. id: "response-1",
  267. created: 0,
  268. model: "pixtral-large-latest",
  269. choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
  270. usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
  271. },
  272. ]
  273. return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), {
  274. headers: { "Content-Type": "text/event-stream" },
  275. })
  276. },
  277. { preconnect: fetch.preconnect },
  278. )
  279. yield* aisdk.hook.sdk((event) => {
  280. event.sdk = createMistral({ apiKey: "test", fetch: mockFetch })
  281. })
  282. const resolved = yield* aisdk.model({
  283. ...model("@ai-sdk/mistral"),
  284. modelID: Model.ID.make("pixtral-large-latest"),
  285. })
  286. yield* LLMClient.generate(
  287. LLM.request({
  288. model: resolved,
  289. messages: [
  290. Message.user("Inspect the screenshot."),
  291. Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
  292. Message.tool({
  293. type: "tool-result",
  294. id: "call_1",
  295. name: "screenshot",
  296. result: {
  297. type: "content",
  298. value: [
  299. { type: "text", text: "Screenshot captured" },
  300. { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" },
  301. ],
  302. },
  303. }),
  304. ],
  305. }),
  306. ).pipe(Effect.provide(client))
  307. expect(body?.messages).toEqual([
  308. { role: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
  309. {
  310. role: "assistant",
  311. content: "",
  312. tool_calls: [
  313. {
  314. id: "call_1",
  315. type: "function",
  316. function: { name: "screenshot", arguments: "{}" },
  317. },
  318. ],
  319. },
  320. {
  321. role: "tool",
  322. name: "screenshot",
  323. tool_call_id: "call_1",
  324. content: '[{"type":"text","text":"Screenshot captured"}]',
  325. },
  326. {
  327. role: "user",
  328. content: [
  329. { type: "text", text: "Attached media from tool result:" },
  330. { type: "image_url", image_url: "data:image/png;base64,AAAA" },
  331. ],
  332. },
  333. ])
  334. }),
  335. )
  336. it.effect("emits malformed AI SDK tool input without executing it", () =>
  337. Effect.gen(function* () {
  338. const aisdk = yield* AISDK.Service
  339. const raw = '{"query":"partial'
  340. yield* aisdk.hook.sdk((event) => {
  341. event.sdk = {
  342. languageModel: () =>
  343. streamModel([
  344. { type: "tool-input-start", id: "call_1", toolName: "lookup" },
  345. { type: "tool-input-delta", id: "call_1", delta: raw },
  346. { type: "tool-input-end", id: "call_1" },
  347. { type: "tool-call", toolCallId: "call_1", toolName: "lookup", input: raw },
  348. { type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage },
  349. ]),
  350. }
  351. })
  352. const resolved = yield* aisdk.model(model("test-ai-sdk"))
  353. const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Lookup" })).pipe(
  354. Effect.provide(client),
  355. )
  356. expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
  357. id: "call_1",
  358. name: "lookup",
  359. raw,
  360. })
  361. expect(response.events.some(LLMEvent.is.toolInputEnd)).toBeTrue()
  362. expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
  363. expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_calls" })
  364. }),
  365. )
  366. it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
  367. Effect.gen(function* () {
  368. const aisdk = yield* AISDK.Service
  369. const raw = '{"query":"partial'
  370. yield* aisdk.hook.sdk((event) => {
  371. event.sdk = {
  372. languageModel: () =>
  373. streamModel([
  374. { type: "tool-input-start", id: "call_1", toolName: "web_search", providerExecuted: true },
  375. { type: "tool-input-delta", id: "call_1", delta: raw },
  376. { type: "tool-input-end", id: "call_1" },
  377. {
  378. type: "tool-call",
  379. toolCallId: "call_1",
  380. toolName: "web_search",
  381. input: raw,
  382. providerExecuted: true,
  383. },
  384. ]),
  385. }
  386. })
  387. const resolved = yield* aisdk.model(model("hosted-test-ai-sdk"))
  388. const error = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Search" })).pipe(
  389. Effect.provide(client),
  390. Effect.flip,
  391. )
  392. expect(error).toBeInstanceOf(AIError)
  393. expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
  394. }),
  395. )
  396. const failingModel = (failure: unknown): LanguageModelV3 => ({
  397. specificationVersion: "v3",
  398. provider: "test",
  399. modelId: "test",
  400. supportedUrls: {},
  401. doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
  402. doStream: () => Promise.reject(failure),
  403. })
  404. const streamFailure = (failure: unknown) =>
  405. Effect.gen(function* () {
  406. const aisdk = yield* AISDK.Service
  407. yield* aisdk.hook.sdk((event) => {
  408. event.sdk = { languageModel: () => failingModel(failure) }
  409. })
  410. const resolved = yield* aisdk.model(model("test-ai-sdk"))
  411. return yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
  412. Effect.provide(client),
  413. Effect.flip,
  414. )
  415. })
  416. it.effect("preserves non-empty AI SDK error messages", () =>
  417. Effect.gen(function* () {
  418. const error = yield* streamFailure(new Error("Bad Request"))
  419. expect(error).toBeInstanceOf(AIError)
  420. expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Bad Request" })
  421. }),
  422. )
  423. const apiCallError = (input: Partial<ConstructorParameters<typeof APICallError>[0]>) =>
  424. new APICallError({
  425. message: "",
  426. url: "https://api.example.com/chat",
  427. requestBodyValues: { messages: [{ role: "user", content: "private prompt" }] },
  428. responseHeaders: { authorization: "Bearer secret-token" },
  429. ...input,
  430. })
  431. it.effect("derives status and code when the AI SDK error message is empty", () =>
  432. Effect.gen(function* () {
  433. const error = yield* streamFailure(
  434. apiCallError({
  435. statusCode: 404,
  436. responseBody: '{"error":{"message":"","code":"not_found"}}',
  437. data: { error: { message: "", code: "not_found" } },
  438. }),
  439. )
  440. expect(error.reason.message).toBe("Provider request failed with HTTP 404: not_found")
  441. expect(error.reason.message).not.toContain("secret-token")
  442. expect(error.reason.message).not.toContain("private prompt")
  443. const projected = toSessionError(error)
  444. expect(projected.type).toBe("provider.invalid-request")
  445. expect(projected.status).toBe(404)
  446. expect(projected.message).not.toBe("")
  447. }),
  448. )
  449. it.effect("preserves complete HTTP context on AI SDK call errors", () =>
  450. Effect.gen(function* () {
  451. const error = yield* streamFailure(
  452. apiCallError({
  453. statusCode: 404,
  454. responseBody: '{"error":{"message":"","code":"not_found"}}',
  455. }),
  456. )
  457. expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
  458. const http = "http" in error.reason ? error.reason.http : undefined
  459. expect(http?.request.url).toBe("https://api.example.com/chat")
  460. expect(http?.response?.status).toBe(404)
  461. expect(http?.response?.headers["authorization"]).toBe("Bearer secret-token")
  462. expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
  463. }),
  464. )
  465. it.effect("classifies retryable AI SDK failures with retry-after details", () =>
  466. Effect.gen(function* () {
  467. const error = yield* streamFailure(
  468. apiCallError({
  469. statusCode: 429,
  470. responseHeaders: { "retry-after": "7" },
  471. }),
  472. )
  473. expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 7000 })
  474. }),
  475. )
  476. it.effect("classifies data-only AI SDK provider codes", () =>
  477. Effect.gen(function* () {
  478. const error = yield* streamFailure(
  479. apiCallError({
  480. statusCode: 400,
  481. data: { error: { code: "api_error" } },
  482. }),
  483. )
  484. expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 400 })
  485. expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
  486. }),
  487. )
  488. it.effect("classifies data-only AI SDK authentication errors", () =>
  489. Effect.gen(function* () {
  490. const error = yield* streamFailure(
  491. apiCallError({
  492. statusCode: 400,
  493. data: { error: { code: "authentication_error" } },
  494. }),
  495. )
  496. expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
  497. expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
  498. }),
  499. )
  500. it.effect("detects context overflow from data-only AI SDK errors", () =>
  501. Effect.gen(function* () {
  502. const error = yield* streamFailure(
  503. apiCallError({
  504. statusCode: 400,
  505. data: { error: { code: "context_length_exceeded" } },
  506. }),
  507. )
  508. expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
  509. expect(isContextOverflowFailure(error)).toBeTrue()
  510. }),
  511. )
  512. it.effect("retries status-less AI SDK transport failures", () =>
  513. Effect.gen(function* () {
  514. const error = yield* streamFailure(
  515. apiCallError({
  516. message: "Cannot connect to API: connection refused",
  517. isRetryable: true,
  518. }),
  519. )
  520. expect(error.reason).toMatchObject({
  521. _tag: "Transport",
  522. transport: "http",
  523. operation: "request",
  524. code: "AI_APICallError",
  525. })
  526. expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
  527. expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
  528. }),
  529. )
  530. it.effect("prefers a structured provider message over the code fallback", () =>
  531. Effect.gen(function* () {
  532. const error = yield* streamFailure(
  533. apiCallError({
  534. statusCode: 404,
  535. data: { error: { code: "not_found" } },
  536. responseBody: '{"message":"The requested model does not exist"}',
  537. }),
  538. )
  539. expect(error.reason.message).toBe("The requested model does not exist")
  540. }),
  541. )
  542. it.effect("falls back to the status alone for malformed response bodies", () =>
  543. Effect.gen(function* () {
  544. const error = yield* streamFailure(
  545. apiCallError({
  546. statusCode: 502,
  547. isRetryable: false,
  548. responseBody: "<html>Bad Gateway</html>",
  549. }),
  550. )
  551. expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 502 })
  552. expect(error.reason.message).toBe("Provider request failed with HTTP 502")
  553. }),
  554. )