diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 273861b26f..6d950aabed 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -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, diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index bec21ac8b2..ca73e91bd0 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -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 || diff --git a/packages/ai/src/route/executor.ts b/packages/ai/src/route/executor.ts index ab97bdaba8..dce362a644 100644 --- a/packages/ai/src/route/executor.ts +++ b/packages/ai/src/route/executor.ts @@ -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) => +export const redactHeaders = (headers: Headers.Input, redactedNames: ReadonlyArray = []) => 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() 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 - readonly body: ReturnType + readonly body: ReturnType 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) => (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) => { const transportError = (input: { readonly message: string readonly kind?: string | undefined @@ -257,23 +286,30 @@ const toHttpError = (redactedNames: ReadonlyArray) => (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 = 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, diff --git a/packages/ai/src/route/transport/http.ts b/packages/ai/src/route/transport/http.ts index 785e450a5e..71d2bb9c1f 100644 --- a/packages/ai/src/route/transport/http.ts +++ b/packages/ai/src/route/transport/http.ts @@ -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 = TransportPrepareInput @@ -134,14 +135,10 @@ export const httpJson = (input: HttpJsonInput): 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))), ), ), ), diff --git a/packages/ai/test/lib/http.ts b/packages/ai/test/lib/http.ts index f6c600555b..7869866b53 100644 --- a/packages/ai/test/lib/http.ts +++ b/packages/ai/test/lib/http.ts @@ -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) => +export const truncatedStream = (chunks: ReadonlyArray, 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 }) diff --git a/packages/ai/test/provider-error.test.ts b/packages/ai/test/provider-error.test.ts index cfad7a8726..47eb146d04 100644 --- a/packages/ai/test/provider-error.test.ts +++ b/packages/ai/test/provider-error.test.ts @@ -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( [ diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index a08a22b037..8dd2920353 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -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" }, + }) }), ) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 4ecda81b9e..160f2c92f5 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -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["content"] type AssistantContent = Extract["content"] type ToolResultContent = Extract +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>>((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 | 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: [] }) diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 59c8f8e8d3..5a82de2773 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -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 = {}) => 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({ + 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(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": "" } }, + body: '{"error":{"code":"insufficient_quota","api_key":""}}', + }, + }, + }) + }), +) + +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" } }) + }), +)