fix(ai): handle retryable SDK failures
This commit is contained in:
parent
7236eb106b
commit
7c655f6a61
5 changed files with 331 additions and 49 deletions
|
|
@ -69,6 +69,8 @@ const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
|||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
/** Provider text used only for classification and never retained on the resulting reason. */
|
||||
readonly evidence?: string | undefined
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
readonly retryAfterMs?: number | undefined
|
||||
|
|
@ -81,10 +83,11 @@ export interface ProviderFailure {
|
|||
// session retry policy never needs provider-specific string matching.
|
||||
export function classifyProviderFailure(input: ProviderFailure): LLMError["reason"] {
|
||||
const body = input.http?.body ?? ""
|
||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
||||
const evidence = input.evidence ?? ""
|
||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message), ...providerCodes(evidence)]
|
||||
.filter((code): code is string => code !== undefined)
|
||||
.map((code) => code.toLowerCase())
|
||||
const text = body || input.message
|
||||
const texts = [body, input.message, evidence]
|
||||
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
|
||||
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||
|
||||
|
|
@ -92,12 +95,14 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||
clientScoped &&
|
||||
(codes.includes("context_length_exceeded") ||
|
||||
codes.includes("model_context_window_exceeded") ||
|
||||
isContextOverflow(body) ||
|
||||
isContextOverflow(input.message))
|
||||
texts.some(isContextOverflow))
|
||||
)
|
||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
if (texts.some((text) => CONTENT_POLICY_TEXT.test(text))) return new ContentPolicyReason(common)
|
||||
if (
|
||||
codes.some((code) => QUOTA_CODES.has(code)) ||
|
||||
(input.status === 429 && texts.some((text) => QUOTA_TEXT.test(text)))
|
||||
)
|
||||
return new QuotaExceededReason(common)
|
||||
if (input.status === 401) return new AuthenticationReason({ ...common, kind: "invalid" })
|
||||
if (input.status === 403) return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
|
||||
|
|
@ -112,7 +117,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (RATE_LIMIT_TEXT.test(text))
|
||||
if (texts.some((text) => RATE_LIMIT_TEXT.test(text)))
|
||||
return new RateLimitReason({
|
||||
...common,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
|
|
@ -153,7 +158,10 @@ function providerCodes(value: string) {
|
|||
const decoded = Option.getOrUndefined(decodeJson(value))
|
||||
if (!isRecord(decoded)) return []
|
||||
const error = isRecord(decoded.error) ? decoded.error : undefined
|
||||
return [decoded.code, error?.code, error?.type].filter((value): value is string => typeof value === "string")
|
||||
const response = isRecord(decoded.response) ? decoded.response : undefined
|
||||
const responseError = isRecord(response?.error) ? response.error : undefined
|
||||
return [decoded.code, decoded.status, error?.code, error?.type, error?.status, responseError?.code, responseError?.type]
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ export type {
|
|||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
} from "./client"
|
||||
export * from "./executor"
|
||||
export { RequestExecutor, Service, fetchLayer, layer } from "./executor"
|
||||
export type { Interface } from "./executor"
|
||||
export { Auth } from "./auth"
|
||||
export { AuthOptions } from "./auth-options"
|
||||
export { Endpoint } from "./endpoint"
|
||||
|
|
|
|||
|
|
@ -61,8 +61,9 @@ describe("provider error classification", () => {
|
|||
'{"code":"bad_request","error":{"code":"usage_not_included"}}',
|
||||
'{"code":"bad_request","error":{"code":"server_error"}}',
|
||||
'{"code":"bad_request","error":{"type":"invalid_request_error"}}',
|
||||
'{"type":"response.failed","response":{"error":{"code":"server_error"}}}',
|
||||
].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(["QuotaExceeded", "ProviderInternal", "InvalidRequest"])
|
||||
).toEqual(["QuotaExceeded", "ProviderInternal", "InvalidRequest", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("keeps unknown and malformed provider payloads non-retryable", () => {
|
||||
|
|
|
|||
|
|
@ -1,38 +1,6 @@
|
|||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import type {
|
||||
JSONSchema7,
|
||||
JSONValue,
|
||||
LanguageModelV3,
|
||||
LanguageModelV3CallOptions,
|
||||
LanguageModelV3FinishReason,
|
||||
LanguageModelV3FunctionTool,
|
||||
LanguageModelV3Message,
|
||||
LanguageModelV3Prompt,
|
||||
LanguageModelV3StreamPart,
|
||||
LanguageModelV3ToolChoice,
|
||||
SharedV3ProviderOptions,
|
||||
} from "@ai-sdk/provider"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
classifyProviderFailure,
|
||||
FinishReason,
|
||||
InvalidRequestReason,
|
||||
InvalidProviderOutputReason,
|
||||
LLMEvent,
|
||||
LLMError,
|
||||
Model,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
ToolResultValue,
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/ai"
|
||||
import {
|
||||
APICallError,
|
||||
EmptyResponseBodyError,
|
||||
|
|
@ -46,7 +14,38 @@ import {
|
|||
NoSuchModelError,
|
||||
TypeValidationError,
|
||||
UnsupportedFunctionalityError,
|
||||
type JSONSchema7,
|
||||
type JSONValue,
|
||||
type LanguageModelV3,
|
||||
type LanguageModelV3CallOptions,
|
||||
type LanguageModelV3FinishReason,
|
||||
type LanguageModelV3FunctionTool,
|
||||
type LanguageModelV3Message,
|
||||
type LanguageModelV3Prompt,
|
||||
type LanguageModelV3StreamPart,
|
||||
type LanguageModelV3ToolChoice,
|
||||
type SharedV3ProviderOptions,
|
||||
} from "@ai-sdk/provider"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
classifyProviderFailure,
|
||||
FinishReason,
|
||||
InvalidRequestReason,
|
||||
InvalidProviderOutputReason,
|
||||
LLMEvent,
|
||||
LLMError,
|
||||
Model,
|
||||
ProviderID,
|
||||
ProviderInternalReason,
|
||||
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 { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
|
|
@ -762,9 +761,12 @@ function llmError(method: string, error: unknown) {
|
|||
UnsupportedFunctionalityError.isInstance(error)
|
||||
)
|
||||
return new InvalidRequestReason({ message: error.message })
|
||||
const providerCode = apiFailureCode(error)
|
||||
if (providerCode)
|
||||
return classifyProviderFailure({ message: apiFailureMessage(error), code: providerCode })
|
||||
const providerReason = classifyProviderFailure({
|
||||
message: apiFailureMessage(error),
|
||||
code: apiFailureCode(error),
|
||||
status: apiFailureStatus(error),
|
||||
})
|
||||
if (providerReason._tag !== "UnknownProvider") return providerReason
|
||||
return new UnknownProviderReason({ message: errorMessage(error) })
|
||||
})()
|
||||
return new LLMError({
|
||||
|
|
@ -780,15 +782,28 @@ function apiCallReason(error: APICallError) {
|
|||
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 })
|
||||
const reason =
|
||||
code === undefined && error.responseBody === undefined
|
||||
? undefined
|
||||
: classifyProviderFailure({ message: error.message, code, evidence: error.responseBody })
|
||||
if (reason && reason._tag !== "UnknownProvider") return reason
|
||||
if (error.isRetryable) return new TransportReason({ message: error.message })
|
||||
return new UnknownProviderReason({ message: error.message })
|
||||
return reason ?? new UnknownProviderReason({ message: error.message })
|
||||
}
|
||||
return classifyProviderFailure({
|
||||
const retryAfter = retryAfterMs(error.responseHeaders)
|
||||
const reason = classifyProviderFailure({
|
||||
message: error.message,
|
||||
evidence: error.responseBody,
|
||||
status: error.statusCode,
|
||||
code,
|
||||
retryAfterMs: retryAfter,
|
||||
})
|
||||
if (!error.isRetryable || (reason._tag !== "UnknownProvider" && reason._tag !== "InvalidRequest")) return reason
|
||||
if (
|
||||
classifyProviderFailure({ message: error.message, evidence: error.responseBody, code })._tag !== "UnknownProvider"
|
||||
)
|
||||
return reason
|
||||
return new ProviderInternalReason({ message: error.message, status: error.statusCode, retryAfterMs: retryAfter })
|
||||
}
|
||||
|
||||
const TRANSPORT_TIMEOUT_CODES = new Set([
|
||||
|
|
@ -798,6 +813,8 @@ const TRANSPORT_TIMEOUT_CODES = new Set([
|
|||
"UND_ERR_HEADERS_TIMEOUT",
|
||||
])
|
||||
const TRANSPORT_CONNECTION_CODES = new Set([
|
||||
"CONNECTIONCLOSED",
|
||||
"CONNECTIONREFUSED",
|
||||
"EAI_AGAIN",
|
||||
"ECONNREFUSED",
|
||||
"ECONNRESET",
|
||||
|
|
@ -805,6 +822,7 @@ const TRANSPORT_CONNECTION_CODES = new Set([
|
|||
"ENETUNREACH",
|
||||
"ENOTFOUND",
|
||||
"EPIPE",
|
||||
"FAILEDTOOPENSOCKET",
|
||||
"UND_ERR_SOCKET",
|
||||
])
|
||||
|
||||
|
|
@ -822,19 +840,46 @@ function apiFailureCode(error: unknown): string | undefined {
|
|||
const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(error))
|
||||
return apiFailureCode(decoded)
|
||||
}
|
||||
const nested = field(error, "error") ?? field(field(error, "response"), "error")
|
||||
const nestedCode = nested === undefined || nested === error ? undefined : apiFailureCode(nested)
|
||||
if (nestedCode) return nestedCode
|
||||
const code = field(error, "code")
|
||||
if (typeof code === "string") return code
|
||||
if (typeof code === "string" || typeof code === "number") return String(code)
|
||||
const status = field(error, "status")
|
||||
if (typeof status === "string") return status
|
||||
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) {
|
||||
function apiFailureMessage(error: unknown): string {
|
||||
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)
|
||||
const nested = field(error, "error") ?? field(field(error, "response"), "error")
|
||||
return nested === undefined || nested === error ? String(error) : apiFailureMessage(nested)
|
||||
}
|
||||
|
||||
function apiFailureStatus(error: unknown): number | undefined {
|
||||
const status = field(error, "status")
|
||||
const code = field(error, "code")
|
||||
const value = [status, code]
|
||||
.map((value) => (typeof value === "number" ? value : typeof value === "string" ? Number(value) : undefined))
|
||||
.find((value) => value !== undefined && Number.isInteger(value) && value >= 400 && value < 600)
|
||||
if (value !== undefined) return value
|
||||
const nested = field(error, "error") ?? field(field(error, "response"), "error")
|
||||
return nested === undefined || nested === error ? undefined : apiFailureStatus(nested)
|
||||
}
|
||||
|
||||
function retryAfterMs(headers: Record<string, string> | undefined) {
|
||||
if (!headers) return undefined
|
||||
const normalized = Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]))
|
||||
const millis = Number(normalized["retry-after-ms"])
|
||||
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||
const value = normalized["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 errorMessage(error: unknown) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
APICallError,
|
||||
InvalidPromptError,
|
||||
InvalidResponseDataError,
|
||||
LoadAPIKeyError,
|
||||
type LanguageModelV3,
|
||||
type LanguageModelV3CallOptions,
|
||||
type LanguageModelV3StreamPart,
|
||||
|
|
@ -8,7 +10,7 @@ import {
|
|||
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 { InvalidRequestReason, LLM, LLMError, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient } from "@opencode-ai/ai/route"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Stream } from "effect"
|
||||
|
|
@ -289,6 +291,26 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
|||
)
|
||||
|
||||
it.effect("classifies AI SDK API failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(
|
||||
new APICallError({
|
||||
message: "Bad Request",
|
||||
url: "https://provider.test/v1",
|
||||
requestBodyValues: {},
|
||||
statusCode: 400,
|
||||
responseBody: '{"error":{"code":"insufficient_quota","detail":"quota-body-secret"}}',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "QuotaExceeded" } })
|
||||
expect("http" in error.reason ? error.reason.http : undefined).toBeUndefined()
|
||||
expect(JSON.stringify(error)).not.toContain("quota-body-secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps retryable quota responses terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(
|
||||
|
|
@ -306,6 +328,132 @@ it.effect("classifies AI SDK API failures", () =>
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("uses response bodies as transient classification evidence", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(
|
||||
new APICallError({
|
||||
message: "Bad Request",
|
||||
url: "https://provider.test/v1",
|
||||
requestBodyValues: {},
|
||||
statusCode: 400,
|
||||
responseBody: "Input is too long for requested model overflow-body-secret",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest", classification: "context-overflow" } })
|
||||
expect("http" in error.reason ? error.reason.http : undefined).toBeUndefined()
|
||||
expect(JSON.stringify(error)).not.toContain("overflow-body-secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("honors retryable AI SDK request timeouts", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(
|
||||
new APICallError({
|
||||
message: "HTTP 408",
|
||||
url: "https://provider.test/v1",
|
||||
requestBodyValues: {},
|
||||
statusCode: 408,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", status: 408 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("honors retryable AI SDK conflicts", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(
|
||||
new APICallError({
|
||||
message: "HTTP 409",
|
||||
url: "https://provider.test/v1",
|
||||
requestBodyValues: {},
|
||||
statusCode: 409,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", status: 409 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps semantic invalid requests terminal on retryable statuses", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(
|
||||
new APICallError({
|
||||
message: "Conflict",
|
||||
url: "https://provider.test/v1",
|
||||
requestBodyValues: {},
|
||||
statusCode: 409,
|
||||
responseBody: '{"error":{"code":"request_too_large"}}',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries unknown coded conflicts", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(
|
||||
new APICallError({
|
||||
message: "Conflict",
|
||||
url: "https://provider.test/v1",
|
||||
requestBodyValues: {},
|
||||
statusCode: 409,
|
||||
responseBody: '{"error":{"code":"conflict"}}',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", status: 409 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves AI SDK retry delays without HTTP diagnostics", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(
|
||||
new APICallError({
|
||||
message: "Too Many Requests",
|
||||
url: "https://provider.test/v1",
|
||||
requestBodyValues: {},
|
||||
statusCode: 429,
|
||||
responseHeaders: { "Retry-After-Ms": "250" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit", retryAfterMs: 250 } })
|
||||
expect("http" in error.reason ? error.reason.http : undefined).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies statusless retryable API failures as transport failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(
|
||||
new APICallError({
|
||||
message: "Cannot connect to API",
|
||||
url: "https://provider.test/v1",
|
||||
requestBodyValues: {},
|
||||
isRetryable: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "Transport" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies AI SDK timeouts", () =>
|
||||
Effect.gen(function* () {
|
||||
const timeout = yield* streamFailure(failingLanguage(new DOMException("timed out", "TimeoutError")))
|
||||
|
|
@ -324,6 +472,16 @@ it.effect("classifies AI SDK connection failures", () =>
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies Bun connection failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(Object.assign(new Error("connection refused"), { code: "ConnectionRefused" })),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "Transport", kind: "CONNECTIONREFUSED" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies structured AI SDK stream errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
|
|
@ -334,6 +492,75 @@ it.effect("classifies structured AI SDK stream errors", () =>
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies structured stream messages without codes", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
streamingLanguage({ type: "error", error: { message: "Rate limit exceeded" } }),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies nested OpenAI Responses stream errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
streamingLanguage({
|
||||
type: "error",
|
||||
error: {
|
||||
type: "response.failed",
|
||||
response: { error: { code: "server_error", message: "Provider failed" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", message: "Provider failed" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies numeric string stream statuses", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
streamingLanguage({ type: "error", error: { code: "503", message: "Unavailable" } }),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", status: 503 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies missing AI SDK API keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(new LoadAPIKeyError({ message: "API key is missing" })),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "Authentication", kind: "missing" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies invalid AI SDK prompts", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(new InvalidPromptError({ prompt: [], message: "unsupported prompt" })),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves existing LLM errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const original = new LLMError({
|
||||
module: "test",
|
||||
method: "run",
|
||||
reason: new InvalidRequestReason({ message: "invalid" }),
|
||||
})
|
||||
const error = yield* streamFailure(failingLanguage(original))
|
||||
|
||||
expect(error).toBe(original)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies malformed AI SDK response causes", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue