refactor(llm): replace LLMError reasons with flat tagged union

Replace the LLMError { module, method, reason } wrapper with a flat
tagged union (LLM.BadRequest, LLM.Authentication, LLM.PermissionDenied,
LLM.NotFound, LLM.RateLimit, LLM.QuotaExceeded, LLM.ContentPolicy,
LLM.ContextOverflow, LLM.ServerError, LLM.APIError, LLM.ConnectionError,
LLM.TimeoutError, LLM.MalformedResponse, LLM.NoRoute) plus an isLLMError
guard. Add one shared classifyApiFailure classifier used by the HTTP
executor and the AI SDK adapter so both surfaces classify identically,
preserving status, headers, body, and retry-after.

Core policy moves onto tags: retry RateLimit | ServerError |
ConnectionError | TimeoutError; toSessionError adds
provider.context-overflow, provider.timeout, and provider.not-found.

The provider-error stream event and the runner's held-back overflow
handling are unchanged here; isContextOverflowFailure now bridges old
events and new tags until the event is removed.
This commit is contained in:
Aiden Cline 2026-07-13 10:47:50 -05:00
commit fce506b3f9
29 changed files with 581 additions and 448 deletions

View file

@ -15,20 +15,43 @@ import type {
SharedV3ProviderOptions,
} from "@ai-sdk/provider"
import {
APIError,
Authentication,
BadRequest,
ConnectionError,
FinishReason,
InvalidProviderOutputReason,
HttpContext,
HttpRequestDetails,
HttpResponseDetails,
LLMEvent,
LLMError,
MalformedResponse,
Model,
NotFound,
ProviderID,
ProviderMetadata,
ToolResultValue,
UnknownProviderReason,
classifyApiFailure,
isLLMError,
type LLMError,
type ContentPart,
type LLMRequest,
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/llm"
import {
APICallError,
EmptyResponseBodyError,
InvalidArgumentError,
InvalidPromptError,
InvalidResponseDataError,
JSONParseError,
LoadAPIKeyError,
LoadSettingError,
NoContentGeneratedError,
NoSuchModelError,
TypeValidationError,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { ModelV2 } from "./model"
@ -490,12 +513,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
Stream.unwrap(
Effect.tryPromise({
try: () => language.doStream(options),
catch: (error) => llmError("doStream", error),
catch: (error) => llmError(error),
}).pipe(
Effect.map((result) =>
Stream.fromReadableStream({
evaluate: () => result.stream,
onError: (error) => llmError("readStream", error),
onError: (error) => llmError(error),
}).pipe(
Stream.mapEffect((event) => streamPartEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
@ -608,7 +631,7 @@ function streamPartEvents(
}),
])
case "error":
return Effect.fail(llmError("stream", event.error))
return Effect.fail(llmError(event.error))
}
}
@ -666,16 +689,65 @@ 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) })
return new LLMError({
module: "AISDK",
method,
reason,
})
const BODY_LIMIT = 16_384
const headerRetryAfterMs = (headers: Record<string, string> | undefined) => {
if (!headers) return undefined
const millis = Number(headers["retry-after-ms"])
if (Number.isFinite(millis)) return Math.max(0, millis)
const value = headers["retry-after"]
if (!value) return undefined
const seconds = Number(value)
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
const date = Date.parse(value)
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
return undefined
}
// Classify AI SDK failures into the shared `LLMError` union so the synthetic
// AI SDK route reports failures identically to native protocol routes. An
// `APICallError` without a status code is the AI SDK's representation of a
// network-level failure (connect refused, reset, DNS), not an API rejection.
function llmError(error: unknown): LLMError {
if (isLLMError(error)) return error
if (APICallError.isInstance(error)) {
if (error.statusCode === undefined) {
return new ConnectionError({ message: error.message, url: error.url, cause: error })
}
return classifyApiFailure({
message: error.message,
status: error.statusCode,
retryAfterMs: headerRetryAfterMs(error.responseHeaders),
requestID: error.responseHeaders?.["x-request-id"] ?? error.responseHeaders?.["request-id"],
http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: error.url, headers: {} }),
response: new HttpResponseDetails({ status: error.statusCode, headers: error.responseHeaders ?? {} }),
body: error.responseBody === undefined ? undefined : error.responseBody.slice(0, BODY_LIMIT),
bodyTruncated: error.responseBody !== undefined && error.responseBody.length > BODY_LIMIT ? true : undefined,
}),
})
}
if (LoadAPIKeyError.isInstance(error) || LoadSettingError.isInstance(error)) {
return new Authentication({ message: error.message })
}
if (NoSuchModelError.isInstance(error)) return new NotFound({ message: error.message })
if (
InvalidPromptError.isInstance(error) ||
InvalidArgumentError.isInstance(error) ||
UnsupportedFunctionalityError.isInstance(error)
) {
return new BadRequest({ message: error.message })
}
if (
InvalidResponseDataError.isInstance(error) ||
JSONParseError.isInstance(error) ||
TypeValidationError.isInstance(error) ||
EmptyResponseBodyError.isInstance(error) ||
NoContentGeneratedError.isInstance(error)
) {
return new MalformedResponse({ message: error.message })
}
return new APIError({ message: error instanceof Error ? error.message : String(error) })
}
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })

View file

@ -1,6 +1,6 @@
export * as Generate from "./generate"
import { LLM, LLMClient, LLMError } from "@opencode-ai/llm"
import { LLM, LLMClient, type LLMError } from "@opencode-ai/llm"
import { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "./catalog"
import { makeLocationNode } from "./effect/app-node"

View file

@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest, type Model } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config"
@ -261,7 +261,7 @@ const make = (dependencies: Dependencies) => {
}
return Effect.void
}),
Effect.catchTag("LLM.Error", (error) =>
Effect.catchIf(isLLMError, (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),

View file

@ -3,11 +3,11 @@ export * as SessionRunnerLLM from "./llm"
import {
LLM,
LLMClient,
LLMError,
LLMEvent,
Message,
SystemPart,
isContextOverflowFailure,
isLLMError,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
@ -332,7 +332,7 @@ const layer = Layer.effect(
// thrown LLM failure records the assistant failure unless a provider error was
// already recorded from the stream. Terminal publication waits for owned tools.
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
const llmFailure = streamFailure !== undefined && isLLMError(streamFailure) ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) {
const error = toSessionError(llmFailure)
if (

View file

@ -1,6 +1,6 @@
export * as SessionRunnerRetry from "./retry"
import { LLMError } from "@opencode-ai/llm"
import type { LLMError } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Data, Duration, Effect, Schedule } from "effect"
import { EventV2 } from "../../event"
@ -17,29 +17,33 @@ export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableF
}> {}
export function isRetryable(error: LLMError) {
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
case "Transport":
switch (error._tag) {
case "LLM.RateLimit":
case "LLM.ServerError":
case "LLM.ConnectionError":
case "LLM.TimeoutError":
return true
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidProviderOutput":
case "InvalidRequest":
case "NoRoute":
case "UnknownProvider":
case "LLM.Authentication":
case "LLM.PermissionDenied":
case "LLM.NotFound":
case "LLM.QuotaExceeded":
case "LLM.ContentPolicy":
case "LLM.ContextOverflow":
case "LLM.MalformedResponse":
case "LLM.BadRequest":
case "LLM.NoRoute":
case "LLM.APIError":
return false
default: {
const exhaustive: never = error.reason
const exhaustive: never = error
return exhaustive
}
}
}
const retryAfter = (failure: RetryableFailure) => {
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
return failure.cause.reason.retryAfterMs
if (failure.cause._tag === "LLM.RateLimit" || failure.cause._tag === "LLM.ServerError")
return failure.cause.retryAfterMs
return undefined
}

View file

@ -1,6 +1,6 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm"
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest } from "@opencode-ai/llm"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent"
import { Database } from "../database/database"
@ -66,7 +66,7 @@ const make = (dependencies: Dependencies) => {
return Effect.void
}),
Effect.as(true),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
Effect.catchIf(isLLMError, () => Effect.succeed(false)),
)
if (!streamed || failed) return
const title = chunks

View file

@ -1,4 +1,4 @@
import { LLMError, ToolFailure } from "@opencode-ai/llm"
import { isLLMError, ToolFailure } from "@opencode-ai/llm"
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { PermissionV2 } from "../permission"
@ -9,30 +9,38 @@ import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./err
import { SessionRunnerModel } from "./runner/model"
export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof LLMError) {
switch (cause.reason._tag) {
case "RateLimit":
return { type: "provider.rate-limit", message: cause.reason.message }
case "Authentication":
return { type: "provider.auth", message: cause.reason.message }
case "QuotaExceeded":
return { type: "provider.quota", message: cause.reason.message }
case "ContentPolicy":
return { type: "provider.content-filter", message: cause.reason.message }
case "Transport":
return { type: "provider.transport", message: cause.reason.message }
case "ProviderInternal":
return { type: "provider.internal", message: cause.reason.message }
case "InvalidProviderOutput":
return { type: "provider.invalid-output", message: cause.reason.message }
case "InvalidRequest":
return { type: "provider.invalid-request", message: cause.reason.message }
case "NoRoute":
return { type: "provider.no-route", message: cause.reason.message }
case "UnknownProvider":
return { type: "provider.unknown", message: cause.reason.message }
if (isLLMError(cause)) {
switch (cause._tag) {
case "LLM.RateLimit":
return { type: "provider.rate-limit", message: cause.message }
case "LLM.Authentication":
return { type: "provider.auth", message: cause.message }
case "LLM.PermissionDenied":
return { type: "provider.auth", message: cause.message }
case "LLM.NotFound":
return { type: "provider.not-found", message: cause.message }
case "LLM.QuotaExceeded":
return { type: "provider.quota", message: cause.message }
case "LLM.ContentPolicy":
return { type: "provider.content-filter", message: cause.message }
case "LLM.ContextOverflow":
return { type: "provider.context-overflow", message: cause.message }
case "LLM.ConnectionError":
return { type: "provider.transport", message: cause.message }
case "LLM.TimeoutError":
return { type: "provider.timeout", message: cause.message }
case "LLM.ServerError":
return { type: "provider.internal", message: cause.message }
case "LLM.MalformedResponse":
return { type: "provider.invalid-output", message: cause.message }
case "LLM.BadRequest":
return { type: "provider.invalid-request", message: cause.message }
case "LLM.NoRoute":
return { type: "provider.no-route", message: cause.message }
case "LLM.APIError":
return { type: "provider.unknown", message: cause.message }
default: {
const exhaustive: never = cause.reason
const exhaustive: never = cause
return exhaustive
}
}

View file

@ -1,18 +1,22 @@
import { describe, expect, test } from "bun:test"
import {
AuthenticationReason,
ContentPolicyReason,
InvalidProviderOutputReason,
InvalidRequestReason,
LLMError,
NoRouteReason,
APIError,
Authentication,
BadRequest,
ConnectionError,
ContentPolicy,
ContextOverflow,
MalformedResponse,
ModelID,
NoRoute,
NotFound,
PermissionDenied,
ProviderID,
ProviderInternalReason,
QuotaExceededReason,
RateLimitReason,
TransportReason,
UnknownProviderReason,
QuotaExceeded,
RateLimit,
RouteID,
ServerError,
TimeoutError,
ToolFailure,
} from "@opencode-ai/llm"
import { PermissionV2 } from "@opencode-ai/core/permission"
@ -20,39 +24,33 @@ import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
describe("toSessionError", () => {
test("maps every LLM reason to the open wire type", () => {
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
test("maps every LLM error tag to the open wire type", () => {
expect(toSessionError(new RateLimit({ message: "rate", retryAfterMs: 123 }))).toEqual({
type: "provider.rate-limit",
message: "rate",
})
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
"provider.auth",
)
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
"provider.internal",
)
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
"provider.invalid-output",
)
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
expect(toSessionError(new Authentication({ message: "auth" })).type).toBe("provider.auth")
expect(toSessionError(new PermissionDenied({ message: "forbidden" })).type).toBe("provider.auth")
expect(toSessionError(new NotFound({ message: "missing" })).type).toBe("provider.not-found")
expect(toSessionError(new QuotaExceeded({ message: "quota" })).type).toBe("provider.quota")
expect(toSessionError(new ContentPolicy({ message: "blocked" })).type).toBe("provider.content-filter")
expect(toSessionError(new ContextOverflow({ message: "too long" })).type).toBe("provider.context-overflow")
expect(toSessionError(new ConnectionError({ message: "reset" })).type).toBe("provider.transport")
expect(toSessionError(new TimeoutError({ message: "timed out" })).type).toBe("provider.timeout")
expect(toSessionError(new ServerError({ message: "internal", status: 500 })).type).toBe("provider.internal")
expect(toSessionError(new MalformedResponse({ message: "output" })).type).toBe("provider.invalid-output")
expect(toSessionError(new BadRequest({ message: "request" })).type).toBe("provider.invalid-request")
expect(
toSessionError(
llm(
new NoRouteReason({
route: "route",
provider: ProviderID.make("provider"),
model: ModelID.make("model"),
}),
),
new NoRoute({
route: RouteID.make("route"),
provider: ProviderID.make("provider"),
model: ModelID.make("model"),
}),
).type,
).toBe("provider.no-route")
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
expect(toSessionError(new APIError({ message: "unknown", status: 418 })).type).toBe("provider.unknown")
})
test("preserves the permission rejection type without exposing internal fields", () => {
@ -71,23 +69,31 @@ describe("toSessionError", () => {
})
})
test("retries only rate limits, provider-internal failures, and transport failures", () => {
test("retries only rate limits, server errors, connection failures, and timeouts", () => {
const eligible = [
llm(new RateLimitReason({ message: "rate" })),
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
llm(new TransportReason({ message: "transport" })),
new RateLimit({ message: "rate" }),
new ServerError({ message: "internal", status: 500 }),
new ConnectionError({ message: "reset" }),
new TimeoutError({ message: "timed out" }),
]
const ineligible = [
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
llm(new QuotaExceededReason({ message: "quota" })),
llm(new ContentPolicyReason({ message: "blocked" })),
llm(new InvalidProviderOutputReason({ message: "output" })),
llm(new InvalidRequestReason({ message: "request" })),
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
llm(new UnknownProviderReason({ message: "unknown" })),
new Authentication({ message: "auth" }),
new PermissionDenied({ message: "forbidden" }),
new NotFound({ message: "missing" }),
new QuotaExceeded({ message: "quota" }),
new ContentPolicy({ message: "blocked" }),
new ContextOverflow({ message: "too long" }),
new MalformedResponse({ message: "output" }),
new BadRequest({ message: "request" }),
new NoRoute({
route: RouteID.make("route"),
provider: ProviderID.make("provider"),
model: ModelID.make("model"),
}),
new APIError({ message: "unknown" }),
]
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual(ineligible.map(() => false))
})
})

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { LLMError, TransportReason } from "@opencode-ai/llm"
import { ConnectionError } from "@opencode-ai/llm"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@ -25,17 +25,10 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Event
describe("SessionExecution lifecycle", () => {
test("classifies success and typed failure terminals", () => {
expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
expect(
SessionExecution.terminal(
Exit.fail(
new LLMError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Disconnected" }),
}),
),
),
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
expect(SessionExecution.terminal(Exit.fail(new ConnectionError({ message: "Disconnected" })))).toEqual({
type: "failed",
error: { type: "provider.transport", message: "Disconnected" },
})
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({
type: "failed",

View file

@ -1,15 +1,16 @@
import { describe, expect, test } from "bun:test"
import {
BadRequest,
ConnectionError,
ContextOverflow,
LLMClient,
LLMError,
LLMEvent,
MalformedResponse,
Model,
RateLimit,
ToolFailure,
TransportReason,
InvalidProviderOutputReason,
InvalidRequestReason,
RateLimitReason,
type LLMClientShape,
type LLMError,
type LLMRequest,
} from "@opencode-ai/llm"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
@ -483,26 +484,11 @@ const setup = Effect.gen(function* () {
return yield* SessionV2.Service
})
const providerUnavailable = () =>
new LLMError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Provider unavailable" }),
})
const providerUnavailable = () => new ConnectionError({ message: "Provider unavailable" })
const invalidRequest = () =>
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({ message: "Invalid request" }),
})
const invalidRequest = () => new BadRequest({ message: "Invalid request" })
const rateLimited = (retryAfterMs?: number) =>
new LLMError({
module: "test",
method: "stream",
reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
})
const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs })
const setupOverflowRecovery = Effect.gen(function* () {
const session = yield* setup
@ -1985,16 +1971,7 @@ describe("SessionRunnerLLM", () => {
it.effect("recovers once from a raw context overflow failure", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responseStream = Stream.fail(
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({
message: "prompt too long",
classification: "context-overflow",
}),
}),
)
responseStream = Stream.fail(new ContextOverflow({ message: "prompt too long" }))
responses = [
reply.text("## Objective\n- Recover raw overflow", "text-summary"),
reply.text("Recovered", "text-final"),
@ -3892,11 +3869,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Call a malformed tool")
const failure = new LLMError({
module: "test",
method: "stream",
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
})
const failure = new MalformedResponse({ message: "Invalid JSON input for tool call echo" })
responseStream = Stream.fromIterable([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),