diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index ca73e91bd0..57c2b5604b 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -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 { diff --git a/packages/ai/src/route/index.ts b/packages/ai/src/route/index.ts index 70db881ea4..e3ca7e286f 100644 --- a/packages/ai/src/route/index.ts +++ b/packages/ai/src/route/index.ts @@ -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" diff --git a/packages/ai/test/provider-error.test.ts b/packages/ai/test/provider-error.test.ts index 47eb146d04..f0920126a5 100644 --- a/packages/ai/test/provider-error.test.ts +++ b/packages/ai/test/provider-error.test.ts @@ -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", () => { diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 00337d8d23..9ddc7fe5b6 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -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 | 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) { diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index a6d9ff2a63..53647a2b38 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -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(