Compare commits

...
10 changed files with 700 additions and 30 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ export interface ProviderFailure {
// Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching.
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
export function classifyProviderFailure(input: ProviderFailure) {
const body = input.http?.body ?? ""
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
.filter((code): code is string => code !== undefined)
+39 -5
View File
@@ -185,15 +185,15 @@ 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) =>
Array.from(secretValues(request)).reduce(
const redactBody = (body: string, secrets: ReadonlySet<string>) =>
Array.from(secrets).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) => {
const responseBody = (body: string | void, secrets: ReadonlySet<string>) => {
if (body === undefined) return {}
const redacted = redactBody(body, request)
const redacted = redactBody(body, secrets)
if (redacted.length <= BODY_LIMIT) return { body: redacted }
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
}
@@ -240,7 +240,7 @@ const statusError =
const headers = normalizedHeaders(response.headers)
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, request)
const details = responseBody(body, secretValues(request))
return yield* new AIError({
module: "RequestExecutor",
method: "execute",
@@ -261,6 +261,40 @@ const statusError =
})
})
// Classifies an HTTP failure captured outside the executor (for example by the
// AI SDK's own fetch) onto the same reason types and redacted HttpContext that
// executor-driven requests produce. The originating request is not available on
// that path, so the method is assumed (language model calls are always POST),
// request headers are empty, and only structural body redaction applies.
export const classifyHttpFailure = (input: {
readonly message: string
readonly url: string
readonly status?: number | undefined
readonly responseHeaders?: Record<string, string> | undefined
readonly responseBody?: string | undefined
}) => {
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(input.responseBody ?? undefined, new Set<string>())
return classifyProviderFailure({
message: input.message,
status: input.status,
retryAfterMs: retryAfter,
rateLimit,
http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
response:
input.status === undefined
? undefined
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
...details,
requestId: requestId(headers),
rateLimit,
}),
})
}
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
const transportError = (input: {
readonly message: string
+140 -5
View File
@@ -474,7 +474,34 @@ export type Endpoint5_31Output =
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly http?:
| {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?:
| { readonly status: number; readonly headers: { readonly [x: string]: string } }
| undefined
readonly body?: string | undefined
readonly bodyTruncated?: boolean | undefined
readonly requestId?: string | undefined
readonly rateLimit?:
| {
readonly retryAfterMs?: number | undefined
readonly limit?: { readonly [x: string]: string } | undefined
readonly remaining?: { readonly [x: string]: string } | undefined
readonly reset?: { readonly [x: string]: string } | undefined
}
| undefined
}
| undefined
}
}
}
| {
@@ -601,7 +628,34 @@ export type Endpoint5_31Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly http?:
| {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?:
| { readonly status: number; readonly headers: { readonly [x: string]: string } }
| undefined
readonly body?: string | undefined
readonly bodyTruncated?: boolean | undefined
readonly requestId?: string | undefined
readonly rateLimit?:
| {
readonly retryAfterMs?: number | undefined
readonly limit?: { readonly [x: string]: string } | undefined
readonly remaining?: { readonly [x: string]: string } | undefined
readonly reset?: { readonly [x: string]: string } | undefined
}
| undefined
}
| undefined
}
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
@@ -763,7 +817,34 @@ export type Endpoint5_31Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly http?:
| {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?:
| { readonly status: number; readonly headers: { readonly [x: string]: string } }
| undefined
readonly body?: string | undefined
readonly bodyTruncated?: boolean | undefined
readonly requestId?: string | undefined
readonly rateLimit?:
| {
readonly retryAfterMs?: number | undefined
readonly limit?: { readonly [x: string]: string } | undefined
readonly remaining?: { readonly [x: string]: string } | undefined
readonly reset?: { readonly [x: string]: string } | undefined
}
| undefined
}
| undefined
}
readonly content?:
| readonly [
(
@@ -803,7 +884,34 @@ export type Endpoint5_31Output =
readonly assistantMessageID: SessionMessage.ID
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly http?:
| {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?:
| { readonly status: number; readonly headers: { readonly [x: string]: string } }
| undefined
readonly body?: string | undefined
readonly bodyTruncated?: boolean | undefined
readonly requestId?: string | undefined
readonly rateLimit?:
| {
readonly retryAfterMs?: number | undefined
readonly limit?: { readonly [x: string]: string } | undefined
readonly remaining?: { readonly [x: string]: string } | undefined
readonly reset?: { readonly [x: string]: string } | undefined
}
| undefined
}
| undefined
}
}
}
| {
@@ -853,7 +961,34 @@ export type Endpoint5_31Output =
readonly data: {
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly http?:
| {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?:
| { readonly status: number; readonly headers: { readonly [x: string]: string } }
| undefined
readonly body?: string | undefined
readonly bodyTruncated?: boolean | undefined
readonly requestId?: string | undefined
readonly rateLimit?:
| {
readonly retryAfterMs?: number | undefined
readonly limit?: { readonly [x: string]: string } | undefined
readonly remaining?: { readonly [x: string]: string } | undefined
readonly reset?: { readonly [x: string]: string } | undefined
}
| undefined
}
| undefined
}
readonly inputID?: SessionMessage.ID | undefined
}
}
+291 -13
View File
@@ -102,7 +102,24 @@ export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
export type SessionStructuredError = { type: string; message: string; status?: number }
export type SessionStructuredError = {
type: string
message: string
status?: number
http?: {
request: { method: string; url: string; headers: { [x: string]: string } }
response?: { status: number; headers: { [x: string]: string } }
body?: string
bodyTruncated?: boolean
requestId?: string
rateLimit?: {
retryAfterMs?: number
limit?: { [x: string]: string }
remaining?: { [x: string]: string }
reset?: { [x: string]: string }
}
}
}
export type SessionMessageCompactionRunning = {
type: "compaction"
@@ -2689,7 +2706,31 @@ export type SessionImportInput = {
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly http?: {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?: {
readonly status: number
readonly headers: { readonly [x: string]: string }
}
readonly body?: string
readonly bodyTruncated?: boolean
readonly requestId?: string
readonly rateLimit?: {
readonly retryAfterMs?: number
readonly limit?: { readonly [x: string]: string }
readonly remaining?: { readonly [x: string]: string }
readonly reset?: { readonly [x: string]: string }
}
}
}
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -2724,11 +2765,53 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly error?: {
readonly type: string
readonly message: string
readonly status?: number
readonly http?: {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
readonly body?: string
readonly bodyTruncated?: boolean
readonly requestId?: string
readonly rateLimit?: {
readonly retryAfterMs?: number
readonly limit?: { readonly [x: string]: string }
readonly remaining?: { readonly [x: string]: string }
readonly reset?: { readonly [x: string]: string }
}
}
}
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly http?: {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
readonly body?: string
readonly bodyTruncated?: boolean
readonly requestId?: string
readonly rateLimit?: {
readonly retryAfterMs?: number
readonly limit?: { readonly [x: string]: string }
readonly remaining?: { readonly [x: string]: string }
readonly reset?: { readonly [x: string]: string }
}
}
}
}
}
| (
@@ -2759,7 +2842,28 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly http?: {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
readonly body?: string
readonly bodyTruncated?: boolean
readonly requestId?: string
readonly rateLimit?: {
readonly retryAfterMs?: number
readonly limit?: { readonly [x: string]: string }
readonly remaining?: { readonly [x: string]: string }
readonly reset?: { readonly [x: string]: string }
}
}
}
}
)
>
@@ -2940,7 +3044,31 @@ export type SessionImportInput = {
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly http?: {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?: {
readonly status: number
readonly headers: { readonly [x: string]: string }
}
readonly body?: string
readonly bodyTruncated?: boolean
readonly requestId?: string
readonly rateLimit?: {
readonly retryAfterMs?: number
readonly limit?: { readonly [x: string]: string }
readonly remaining?: { readonly [x: string]: string }
readonly reset?: { readonly [x: string]: string }
}
}
}
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -2975,11 +3103,53 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly error?: {
readonly type: string
readonly message: string
readonly status?: number
readonly http?: {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
readonly body?: string
readonly bodyTruncated?: boolean
readonly requestId?: string
readonly rateLimit?: {
readonly retryAfterMs?: number
readonly limit?: { readonly [x: string]: string }
readonly remaining?: { readonly [x: string]: string }
readonly reset?: { readonly [x: string]: string }
}
}
}
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly http?: {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
readonly body?: string
readonly bodyTruncated?: boolean
readonly requestId?: string
readonly rateLimit?: {
readonly retryAfterMs?: number
readonly limit?: { readonly [x: string]: string }
readonly remaining?: { readonly [x: string]: string }
readonly reset?: { readonly [x: string]: string }
}
}
}
}
}
| (
@@ -3010,7 +3180,28 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly http?: {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
readonly body?: string
readonly bodyTruncated?: boolean
readonly requestId?: string
readonly rateLimit?: {
readonly retryAfterMs?: number
readonly limit?: { readonly [x: string]: string }
readonly remaining?: { readonly [x: string]: string }
readonly reset?: { readonly [x: string]: string }
}
}
}
}
)
>
@@ -3191,7 +3382,31 @@ export type SessionImportInput = {
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly http?: {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?: {
readonly status: number
readonly headers: { readonly [x: string]: string }
}
readonly body?: string
readonly bodyTruncated?: boolean
readonly requestId?: string
readonly rateLimit?: {
readonly retryAfterMs?: number
readonly limit?: { readonly [x: string]: string }
readonly remaining?: { readonly [x: string]: string }
readonly reset?: { readonly [x: string]: string }
}
}
}
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -3226,11 +3441,53 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly error?: {
readonly type: string
readonly message: string
readonly status?: number
readonly http?: {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
readonly body?: string
readonly bodyTruncated?: boolean
readonly requestId?: string
readonly rateLimit?: {
readonly retryAfterMs?: number
readonly limit?: { readonly [x: string]: string }
readonly remaining?: { readonly [x: string]: string }
readonly reset?: { readonly [x: string]: string }
}
}
}
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly http?: {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
readonly body?: string
readonly bodyTruncated?: boolean
readonly requestId?: string
readonly rateLimit?: {
readonly retryAfterMs?: number
readonly limit?: { readonly [x: string]: string }
readonly remaining?: { readonly [x: string]: string }
readonly reset?: { readonly [x: string]: string }
}
}
}
}
}
| (
@@ -3261,7 +3518,28 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly http?: {
readonly request: {
readonly method: string
readonly url: string
readonly headers: { readonly [x: string]: string }
}
readonly response?: { readonly status: number; readonly headers: { readonly [x: string]: string } }
readonly body?: string
readonly bodyTruncated?: boolean
readonly requestId?: string
readonly rateLimit?: {
readonly retryAfterMs?: number
readonly limit?: { readonly [x: string]: string }
readonly remaining?: { readonly [x: string]: string }
readonly reset?: { readonly [x: string]: string }
}
}
}
}
)
>
+52 -2
View File
@@ -1,6 +1,7 @@
export * as AISDK from "./aisdk"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { APICallError } from "@ai-sdk/provider"
import type {
JSONSchema7,
JSONValue,
@@ -22,6 +23,7 @@ import {
LanguageModel,
ProviderID,
ProviderMetadata,
TransportReason,
ToolResultValue,
UnknownProviderReason,
type ContentPart,
@@ -29,7 +31,7 @@ import {
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import type { ID, Info } from "./model"
@@ -723,7 +725,9 @@ function llmError(method: string, error: unknown) {
const reason =
error instanceof AIError
? new InvalidProviderOutputReason({ message: error.message })
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
: APICallError.isInstance(error)
? apiCallErrorReason(error)
: new UnknownProviderReason({ message: unknownErrorMessage(error) })
return new AIError({
module: "AISDK",
method,
@@ -731,4 +735,50 @@ function llmError(method: string, error: unknown) {
})
}
function apiCallErrorReason(error: APICallError) {
const reason = RequestExecutor.classifyHttpFailure({
message: unknownErrorMessage(error),
url: error.url,
status: error.statusCode,
responseHeaders: error.responseHeaders,
responseBody: error.responseBody,
})
if (error.statusCode !== undefined || !error.isRetryable) return reason
return new TransportReason({
message: reason.message,
kind: error.name,
url: error.url,
http: reason.http,
})
}
const ProviderErrorBody = Schema.Struct({
message: Schema.optionalKey(Schema.String),
code: Schema.optionalKey(Schema.String),
error: Schema.optionalKey(
Schema.Struct({
message: Schema.optionalKey(Schema.String),
code: Schema.optionalKey(Schema.String),
}),
),
})
const decodeErrorData = Schema.decodeUnknownOption(ProviderErrorBody)
const decodeErrorBody = Schema.decodeUnknownOption(Schema.fromJsonString(ProviderErrorBody))
// AI SDK errors such as AI_APICallError can carry an empty message while still
// holding structured provider details. Derive a safe non-empty fallback from
// recognized error fields only; never surface the raw response payload.
function unknownErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : String(error)
if (message.trim() !== "") return message
if (!APICallError.isInstance(error)) return "Provider request failed"
const body =
Option.getOrUndefined(decodeErrorData(error.data)) ?? Option.getOrUndefined(decodeErrorBody(error.responseBody))
const detail = body?.error ?? body
if (detail?.message) return detail.message
const prefix =
error.statusCode === undefined ? "Provider request failed" : `Provider request failed with HTTP ${error.statusCode}`
return detail?.code ? `${prefix}: ${detail.code}` : prefix
}
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
@@ -60,7 +60,12 @@ export function toSessionError(cause: unknown): SessionError.Error {
}
function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
const status =
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
const http = "http" in reason ? reason.http : undefined
const status = http?.response?.status ?? ("status" in reason ? reason.status : undefined)
return {
type,
message: reason.message,
...(status === undefined ? {} : { status }),
...(http === undefined ? {} : { http }),
}
}
+131
View File
@@ -1,5 +1,8 @@
import { APICallError } from "@ai-sdk/provider"
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
import { AISDK } from "@opencode-ai/core/aisdk"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { LLM, AIError, LLMEvent, Message } from "@opencode-ai/ai"
@@ -337,3 +340,131 @@ it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
}),
)
const failingModel = (failure: unknown): LanguageModelV3 => ({
specificationVersion: "v3",
provider: "test",
modelId: "test",
supportedUrls: {},
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
doStream: () => Promise.reject(failure),
})
const streamFailure = (failure: unknown) =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => failingModel(failure) }
})
const resolved = yield* aisdk.model(model("test-ai-sdk"))
return yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
Effect.provide(client),
Effect.flip,
)
})
it.effect("preserves non-empty AI SDK error messages", () =>
Effect.gen(function* () {
const error = yield* streamFailure(new Error("Bad Request"))
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Bad Request" })
}),
)
const apiCallError = (input: Partial<ConstructorParameters<typeof APICallError>[0]>) =>
new APICallError({
message: "",
url: "https://api.example.com/chat",
requestBodyValues: { messages: [{ role: "user", content: "private prompt" }] },
responseHeaders: { authorization: "Bearer secret-token" },
...input,
})
it.effect("derives status and code when the AI SDK error message is empty", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 404,
responseBody: '{"error":{"message":"","code":"not_found"}}',
data: { error: { message: "", code: "not_found" } },
}),
)
expect(error.reason.message).toBe("Provider request failed with HTTP 404: not_found")
expect(error.reason.message).not.toContain("secret-token")
expect(error.reason.message).not.toContain("private prompt")
const projected = toSessionError(error)
expect(projected.type).toBe("provider.invalid-request")
expect(projected.status).toBe(404)
expect(projected.message).not.toBe("")
expect(projected.http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
}),
)
it.effect("persists redacted HTTP context from AI SDK call errors", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 404,
responseBody: '{"error":{"message":"","code":"not_found"}}',
}),
)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
const http = toSessionError(error).http
expect(http?.request.url).toBe("https://api.example.com/chat")
expect(http?.response?.status).toBe(404)
expect(http?.response?.headers["authorization"]).toBe("<redacted>")
expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
}),
)
it.effect("classifies retryable AI SDK failures with retry-after details", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 429,
responseHeaders: { "retry-after": "7" },
}),
)
expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 7000 })
}),
)
it.effect("retries status-less AI SDK transport failures", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
message: "Cannot connect to API: connection refused",
isRetryable: true,
}),
)
expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" })
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
expect(toSessionError(error).http?.request.url).toBe("https://api.example.com/chat")
}),
)
it.effect("prefers a structured provider message over the code fallback", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 404,
data: { error: { message: "The requested model does not exist", code: "not_found" } },
}),
)
expect(error.reason.message).toBe("The requested model does not exist")
}),
)
it.effect("falls back to the status alone for malformed response bodies", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 502,
isRetryable: false,
responseBody: "<html>Bad Gateway</html>",
}),
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 502 })
expect(error.reason.message).toBe("Provider request failed with HTTP 502")
}),
)
+2 -1
View File
@@ -74,7 +74,7 @@ describe("toSessionError", () => {
})
})
test("preserves provider HTTP status", () => {
test("preserves provider HTTP context", () => {
const http = new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
response: new HttpResponseDetails({ status: 413, headers: {} }),
@@ -83,6 +83,7 @@ describe("toSessionError", () => {
type: "provider.invalid-request",
message: "too large",
status: 413,
http,
})
expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
type: "provider.internal",
+24
View File
@@ -3,9 +3,33 @@ export * as SessionError from "./session-error.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
const HttpRateLimitDetails = Schema.Struct({
retryAfterMs: Schema.Finite.pipe(optional),
limit: Schema.Record(Schema.String, Schema.String).pipe(optional),
remaining: Schema.Record(Schema.String, Schema.String).pipe(optional),
reset: Schema.Record(Schema.String, Schema.String).pipe(optional),
})
const HttpContext = Schema.Struct({
request: Schema.Struct({
method: Schema.String,
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String),
}),
response: Schema.Struct({
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })),
headers: Schema.Record(Schema.String, Schema.String),
}).pipe(optional),
body: Schema.String.pipe(optional),
bodyTruncated: Schema.Boolean.pipe(optional),
requestId: Schema.String.pipe(optional),
rateLimit: HttpRateLimitDetails.pipe(optional),
})
export interface Error extends Schema.Schema.Type<typeof Error> {}
export const Error = Schema.Struct({
type: Schema.String,
message: Schema.String,
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
http: HttpContext.pipe(optional),
}).annotate({ identifier: "Session.StructuredError" })
@@ -12,6 +12,18 @@ describe("SessionError", () => {
const values: SessionError.Error[] = [
{ type: "provider.rate-limit", message: "Slow down" },
{ type: "provider.auth", message: "Authentication failed" },
{
type: "provider.internal",
message: "Unavailable",
status: 503,
http: {
request: { method: "POST", url: "https://api.example.com/chat", headers: {} },
response: { status: 503, headers: { "retry-after": "5" } },
body: '{"error":{"code":"unavailable"}}',
requestId: "request-1",
rateLimit: { retryAfterMs: 5_000 },
},
},
{ type: "provider.future-condition", message: "A future provider failure" },
{ type: "unknown", message: "Unexpected" },
]