fix(ai): classify provider failures consistently

This commit is contained in:
Aiden Cline 2026-07-17 09:45:59 -05:00
commit 0960ef48fd
9 changed files with 398 additions and 46 deletions

View file

@ -2,7 +2,7 @@ export { LLMClient } from "./route/client"
export { Auth } from "./route/auth"
export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
export { classifyProviderFailure, isContextOverflow, isContextOverflowFailure } from "./provider-error"
export type {
RouteModelInput,
RouteRoutedModelInput,

View file

@ -57,7 +57,12 @@ const SERVER_CODES = new Set([
"server_is_overloaded",
"serviceunavailableexception",
])
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
const INVALID_REQUEST_CODES = new Set([
"invalid_prompt",
"invalid_request_error",
"request_too_large",
"validationexception",
])
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
@ -87,7 +92,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
clientScoped &&
(codes.includes("context_length_exceeded") ||
codes.includes("model_context_window_exceeded") ||
isContextOverflow(text))
isContextOverflow(body) ||
isContextOverflow(input.message))
)
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
@ -112,6 +118,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
return new ProviderInternalReason({
...common,
@ -131,7 +138,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
status: input.status,
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if (
input.status === 400 ||
input.status === 404 ||

View file

@ -47,15 +47,14 @@ const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name)
const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name)
const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray<string | RegExp>) =>
export const redactHeaders = (headers: Headers.Input, redactedNames: ReadonlyArray<string | RegExp> = []) =>
Object.fromEntries(
Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [
name,
String(value),
]),
Object.entries(Headers.redact(Headers.fromInput(headers), [...redactedNames, SENSITIVE_NAME])).map(
([name, value]) => [name, String(value)],
),
)
const redactUrl = (value: string) => {
export const redactUrl = (value: string) => {
if (!URL.canParse(value)) return REDACTED
const url = new URL(value)
url.searchParams.forEach((_, key) => {
@ -151,7 +150,12 @@ const responseDetails = (
headers: redactHeaders(response.headers, redactedNames),
})
const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
interface RedactionRequest {
readonly url: string
readonly headers: Headers.Input
}
const secretValues = (request: RedactionRequest) => {
const values = new Set<string>()
const add = (value: string) => {
if (value.length < 4) return
@ -176,13 +180,13 @@ const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
// for any field name that looks sensitive) plus literal (replace any actual
// secret values we sent in the request, in case the response echoes one back).
const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
const redactBody = (body: string, request: RedactionRequest) =>
Array.from(secretValues(request)).reduce(
(text, secret) => text.split(secret).join(REDACTED),
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
)
const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
export const redactResponseBody = (body: string | void, request: RedactionRequest) => {
if (body === undefined) return {}
const redacted = redactBody(body, request)
if (redacted.length <= BODY_LIMIT) return { body: redacted }
@ -198,7 +202,7 @@ const responseHttp = (input: {
readonly request: HttpClientRequest.HttpClientRequest
readonly response: HttpClientResponse.HttpClientResponse
readonly redactedNames: ReadonlyArray<string | RegExp>
readonly body: ReturnType<typeof responseBody>
readonly body: ReturnType<typeof redactResponseBody>
readonly requestId?: string | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
}) =>
@ -219,7 +223,7 @@ const statusError =
const headers = normalizedHeaders(response.headers)
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, request)
const details = redactResponseBody(body, request)
return yield* new LLMError({
module: "RequestExecutor",
method: "execute",
@ -240,7 +244,32 @@ const statusError =
})
})
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
const TIMEOUT_CODES = new Set([
"ETIMEDOUT",
"UND_ERR_BODY_TIMEOUT",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_HEADERS_TIMEOUT",
])
const errorCause = (error: unknown) => {
if (HttpClientError.isHttpClientError(error) && "cause" in error.reason) return error.reason.cause
return error instanceof Error ? error.cause : undefined
}
const errorCode = (error: unknown) => {
if (typeof error !== "object" || error === null) return undefined
const code = Reflect.get(error, "code")
return typeof code === "string" ? code.toUpperCase() : undefined
}
const errorMessage = (error: unknown, fallback: string) => {
const cause = errorCause(error)
if (cause instanceof Error) return cause.message
if (error instanceof Error) return error.message
return fallback
}
export const mapHttpClientError = (error: unknown, redactedNames: ReadonlyArray<string | RegExp>) => {
const transportError = (input: {
readonly message: string
readonly kind?: string | undefined
@ -257,23 +286,30 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
}),
})
if (Cause.isTimeoutError(error)) {
return transportError({ message: error.message, kind: "Timeout" })
}
const cause = errorCause(error)
const code = errorCode(cause) ?? errorCode(error)
const request = HttpClientError.isHttpClientError(error) ? error.request : undefined
if (
Cause.isTimeoutError(error) ||
Cause.isTimeoutError(cause) ||
(error instanceof Error && error.name === "TimeoutError") ||
(cause instanceof Error && cause.name === "TimeoutError") ||
(code !== undefined && TIMEOUT_CODES.has(code))
)
return transportError({ message: errorMessage(error, "HTTP transport timed out"), kind: code ?? "Timeout", request })
if (!HttpClientError.isHttpClientError(error)) {
return transportError({ message: "HTTP transport failed" })
return transportError({ message: errorMessage(error, "HTTP transport failed"), kind: code })
}
const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") {
return transportError({
message: error.reason.description ?? "HTTP transport failed",
kind: error.reason._tag,
message: error.reason.description ?? errorMessage(error, "HTTP transport failed"),
kind: code ?? error.reason._tag,
request,
})
}
return transportError({
message: `HTTP transport failed: ${error.reason._tag}`,
kind: error.reason._tag,
message: errorMessage(error, `HTTP transport failed: ${error.reason._tag}`),
kind: code ?? error.reason._tag,
request,
})
}
@ -287,7 +323,10 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
const redactedNames = yield* Headers.CurrentRedactedNames
return yield* http
.execute(request)
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
.pipe(
Effect.mapError((error) => mapHttpClientError(error, redactedNames)),
Effect.flatMap(statusError(request, redactedNames)),
)
})
return Service.of({
execute: executeOnce,

View file

@ -6,6 +6,7 @@ import { Framing } from "../framing"
import type { Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
import { mapHttpClientError } from "../executor"
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@ -134,14 +135,10 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
.execute(prepared.request)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
Stream.unwrap(
Effect.map(Headers.CurrentRedactedNames, (redactedNames) =>
prepared.framing.frame(
response.stream.pipe(Stream.mapError((error) => mapHttpClientError(error, redactedNames))),
),
),
),

View file

@ -63,14 +63,14 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
* exercise transport errors that surface during parsing.
*/
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
export const truncatedStream = (chunks: ReadonlyArray<string>, error: unknown = new Error("connection reset")) =>
dynamicResponse((input) =>
Effect.sync(() => {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
controller.error(new Error("connection reset"))
controller.error(error)
},
})
return input.respond(stream, { headers: SSE_HEADERS })

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { isContextOverflow } from "../src"
import { HttpContext, HttpRequestDetails, isContextOverflow } from "../src"
import { classifyProviderFailure } from "../src/provider-error"
describe("provider error classification", () => {
@ -7,6 +7,25 @@ describe("provider error classification", () => {
expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
})
test("checks overflow evidence in the message when the response body is uninformative", () => {
expect(
classifyProviderFailure({
message: "Input is too long for requested model",
status: 400,
http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: "https://provider.test", headers: {} }),
body: "{}",
}),
}),
).toMatchObject({ classification: "context-overflow" })
})
test("lets semantic invalid-request codes override server status", () => {
expect(classifyProviderFailure({ message: "too large", status: 500, code: "request_too_large" })._tag).toBe(
"InvalidRequest",
)
})
test("classifies V1 plain-text rate limit fallbacks", () => {
expect(
[

View file

@ -651,7 +651,22 @@ describe("OpenAI Chat route", () => {
])
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
expect(error.message).toContain("Failed to read openai/openai-chat stream")
expect(error).toMatchObject({ reason: { _tag: "Transport", message: "connection reset" } })
}),
)
it.effect("classifies response body timeouts as transport timeouts", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
truncatedStream([], Object.assign(new Error("body timed out"), { code: "UND_ERR_BODY_TIMEOUT" })),
),
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "Transport", kind: "UND_ERR_BODY_TIMEOUT", message: "body timed out" },
})
}),
)

View file

@ -15,7 +15,13 @@ import type {
SharedV3ProviderOptions,
} from "@ai-sdk/provider"
import {
AuthenticationReason,
classifyProviderFailure,
FinishReason,
HttpContext,
HttpRequestDetails,
HttpResponseDetails,
InvalidRequestReason,
InvalidProviderOutputReason,
LLMEvent,
LLMError,
@ -23,13 +29,28 @@ import {
ProviderID,
ProviderMetadata,
ToolResultValue,
TransportReason,
UnknownProviderReason,
type ContentPart,
type LLMRequest,
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import {
APICallError,
EmptyResponseBodyError,
InvalidArgumentError,
InvalidPromptError,
InvalidResponseDataError,
JSONParseError,
LoadAPIKeyError,
LoadSettingError,
NoContentGeneratedError,
NoSuchModelError,
TypeValidationError,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
@ -40,6 +61,8 @@ type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
class ChunkTimeoutError extends Error {}
export interface SDKEvent {
readonly model: ModelV2.Info
readonly package: string
@ -64,7 +87,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
async pull(ctrl) {
const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
const id = setTimeout(() => {
const err = new Error("SSE read timed out")
const err = new ChunkTimeoutError("SSE read timed out")
ctl.abort(err)
void reader.cancel(err)
reject(err)
@ -716,10 +739,37 @@ function messageValue(input: unknown) {
}
function llmError(method: string, error: unknown) {
const reason =
error instanceof LLMError
? new InvalidProviderOutputReason({ message: error.message })
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
if (error instanceof LLMError) return error
const cause = error instanceof Error ? error.cause : undefined
const failures = [error, cause]
const code = failures.map(machineCode).find((value) => value !== undefined)
const reason = (() => {
if (
error instanceof ChunkTimeoutError ||
failures.some((failure) => failure instanceof Error && failure.name === "TimeoutError") ||
(code !== undefined && TRANSPORT_TIMEOUT_CODES.has(code))
)
return new TransportReason({ message: errorMessage(error), kind: code ?? "Timeout" })
if (APICallError.isInstance(error)) return apiCallReason(error)
if (code !== undefined && TRANSPORT_CONNECTION_CODES.has(code))
return new TransportReason({ message: errorMessage(error), kind: code })
const malformed = failures.find(isMalformedError)
if (malformed) return new InvalidProviderOutputReason({ message: malformed.message })
if (LoadAPIKeyError.isInstance(error))
return new AuthenticationReason({ message: error.message, kind: "missing" })
if (NoSuchModelError.isInstance(error)) return new InvalidRequestReason({ message: error.message })
if (
LoadSettingError.isInstance(error) ||
InvalidPromptError.isInstance(error) ||
InvalidArgumentError.isInstance(error) ||
UnsupportedFunctionalityError.isInstance(error)
)
return new InvalidRequestReason({ message: error.message })
const providerCode = apiFailureCode(error)
if (providerCode)
return classifyProviderFailure({ message: apiFailureMessage(error), code: providerCode })
return new UnknownProviderReason({ message: errorMessage(error) })
})()
return new LLMError({
module: "AISDK",
method,
@ -727,4 +777,107 @@ function llmError(method: string, error: unknown) {
})
}
function apiCallReason(error: APICallError) {
const malformed = isMalformedError(error.cause) ? error.cause : undefined
if (error.statusCode !== undefined && error.statusCode < 400 && malformed)
return new InvalidProviderOutputReason({ message: malformed.message })
const code = apiFailureCode(error.data) ?? apiFailureCode(error.responseBody)
if (error.statusCode === undefined) {
if (code) return classifyProviderFailure({ message: error.message, code })
if (error.isRetryable)
return new TransportReason({ message: error.message, url: RequestExecutor.redactUrl(error.url) })
return new UnknownProviderReason({ message: error.message })
}
const body = RequestExecutor.redactResponseBody(error.responseBody, {
url: error.url,
headers: error.responseHeaders ?? {},
})
return classifyProviderFailure({
message: error.message,
status: error.statusCode,
code,
retryAfterMs: retryAfterMs(error.responseHeaders),
http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: RequestExecutor.redactUrl(error.url), headers: {} }),
response: new HttpResponseDetails({
status: error.statusCode,
headers: RequestExecutor.redactHeaders(error.responseHeaders ?? {}),
}),
...body,
}),
})
}
const TRANSPORT_TIMEOUT_CODES = new Set([
"ETIMEDOUT",
"UND_ERR_BODY_TIMEOUT",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_HEADERS_TIMEOUT",
])
const TRANSPORT_CONNECTION_CODES = new Set([
"EAI_AGAIN",
"ECONNREFUSED",
"ECONNRESET",
"EHOSTUNREACH",
"ENETUNREACH",
"ENOTFOUND",
"EPIPE",
"UND_ERR_SOCKET",
])
function retryAfterMs(headers: Record<string, string> | undefined) {
if (!headers) return undefined
const millis = Number(headers["retry-after-ms"])
if (Number.isFinite(millis)) return Math.max(0, millis)
const value = headers["retry-after"]
if (!value) return undefined
const seconds = Number(value)
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
const date = Date.parse(value)
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now())
}
function field(error: unknown, name: string) {
return typeof error === "object" && error !== null ? Reflect.get(error, name) : undefined
}
function machineCode(error: unknown) {
const code = field(error, "code")
return typeof code === "string" ? code.toUpperCase() : undefined
}
function apiFailureCode(error: unknown): string | undefined {
if (typeof error === "string") {
const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(error))
return apiFailureCode(decoded)
}
const code = field(error, "code")
if (typeof code === "string") return code
const type = field(error, "type")
if (typeof type === "string" && type !== "error") return type
const nested = field(error, "error")
return nested === undefined || nested === error ? undefined : apiFailureCode(nested)
}
function apiFailureMessage(error: unknown) {
const message = field(error, "message")
if (typeof message === "string") return message
const nested = field(field(error, "error"), "message")
return typeof nested === "string" ? nested : String(error)
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
function isMalformedError(error: unknown): error is Error {
return (
InvalidResponseDataError.isInstance(error) ||
JSONParseError.isInstance(error) ||
TypeValidationError.isInstance(error) ||
EmptyResponseBodyError.isInstance(error) ||
NoContentGeneratedError.isInstance(error)
)
}
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })

View file

@ -1,11 +1,17 @@
import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"
import {
APICallError,
InvalidResponseDataError,
type LanguageModelV3,
type LanguageModelV3CallOptions,
type LanguageModelV3StreamPart,
} from "@ai-sdk/provider"
import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { LLM, Message } from "@opencode-ai/ai"
import { LLMClient } from "@opencode-ai/ai/route"
import { expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer)
@ -19,6 +25,49 @@ const model = (packageName: string, settings: Record<string, unknown> = {}) =>
limit: { context: 100, output: 20 },
})
const failingLanguage = (error: unknown): LanguageModelV3 => ({
specificationVersion: "v3",
provider: "test-provider",
modelId: "api-model",
supportedUrls: {},
doGenerate: async () => {
throw error
},
doStream: async () => {
throw error
},
})
const streamingLanguage = (...events: LanguageModelV3StreamPart[]): LanguageModelV3 => ({
...failingLanguage(new Error("unused")),
doStream: async () => ({
stream: new ReadableStream<LanguageModelV3StreamPart>({
start(controller) {
events.forEach((event) => controller.enqueue(event))
controller.close()
},
}),
request: { body: {} },
}),
})
const streamFailure = (language: LanguageModelV3) =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = {}
})
yield* aisdk.hook.language((event) => {
event.language = language
})
const resolved = yield* aisdk.model(model("@ai-sdk/openai"))
const request = LLM.request({ model: resolved, prompt: "Hello" })
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(request)
return yield* resolved.route
.streamPrepared(prepared.body, request, { http: { execute: () => Effect.die("unused") } })
.pipe(Stream.runDrain, Effect.flip)
})
it.effect("keys language models by package and flattened overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
@ -238,3 +287,77 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
])
}),
)
it.effect("classifies AI SDK API failures with redacted diagnostics", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(
new APICallError({
message: "Quota exceeded",
url: "https://provider.test/v1?key=url-secret",
requestBodyValues: {},
statusCode: 429,
responseHeaders: { "retry-after-ms": "250", "x-api-key": "header-secret" },
responseBody: '{"error":{"code":"insufficient_quota","api_key":"body-secret"}}',
}),
),
)
expect(error).toMatchObject({
reason: {
_tag: "QuotaExceeded",
http: {
request: { url: "https://provider.test/v1?key=%3Credacted%3E" },
response: { headers: { "x-api-key": "<redacted>" } },
body: '{"error":{"code":"insufficient_quota","api_key":"<redacted>"}}',
},
},
})
}),
)
it.effect("classifies AI SDK timeouts", () =>
Effect.gen(function* () {
const timeout = yield* streamFailure(failingLanguage(new DOMException("timed out", "TimeoutError")))
expect(timeout).toMatchObject({ reason: { _tag: "Transport", kind: "Timeout" } })
}),
)
it.effect("classifies AI SDK connection failures", () =>
Effect.gen(function* () {
const reset = yield* streamFailure(
failingLanguage(Object.assign(new Error("connection reset"), { code: "ECONNRESET" })),
)
expect(reset).toMatchObject({ reason: { _tag: "Transport", kind: "ECONNRESET" } })
}),
)
it.effect("classifies structured AI SDK stream errors", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
streamingLanguage({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } }),
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", message: "Overloaded" } })
}),
)
it.effect("classifies malformed AI SDK response causes", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(
new APICallError({
message: "Failed to process response",
url: "https://provider.test/v1",
requestBodyValues: {},
statusCode: 200,
cause: new InvalidResponseDataError({ data: { invalid: true } }),
}),
),
)
expect(error).toMatchObject({ reason: { _tag: "InvalidProviderOutput" } })
}),
)