Ver código fonte

refactor(llm): redesign error model as flat tagged union

- Replace LLMError { module, method, reason } wrapper with a flat tagged
  union: BadRequest, Authentication, PermissionDenied, NotFound, RateLimit,
  QuotaExceeded, ContentPolicy, ContextOverflow, ServerError, APIError,
  ConnectionError, TimeoutError, MalformedResponse, NoRoute.
- Delete the provider-error LLMEvent: streams carry output only and every
  failure exits through the typed error channel.
- Add one shared classifyApiFailure classifier used by the HTTP executor,
  protocol stream errors, and the AI SDK adapter so all routes classify
  identically (including OpenAI in-stream rate_limit_exceeded and
  internal_error codes).
- Enforce a terminal contract in LLMClient.stream for every route: EOF
  without finish and output after finish fail as MalformedResponse.
- Classify AI SDK failures properly in core/aisdk.ts instead of collapsing
  to UnknownProvider; preserve status, headers, body, and retry-after.
- Simplify the session runner: drop held-back overflow events, key overflow
  recovery off LLM.ContextOverflow, retry RateLimit | ServerError |
  ConnectionError | TimeoutError.
- Map new tags in toSessionError (provider.context-overflow,
  provider.timeout, provider.not-found).
Aiden Cline 1 mês atrás
pai
commit
bd51cdba12
37 arquivos alterados com 776 adições e 676 exclusões
  1. 88 16
      packages/core/src/aisdk.ts
  2. 1 1
      packages/core/src/generate.ts
  3. 2 7
      packages/core/src/session/compaction.ts
  4. 9 25
      packages/core/src/session/runner/llm.ts
  5. 0 4
      packages/core/src/session/runner/publish-llm-event.ts
  6. 19 15
      packages/core/src/session/runner/retry.ts
  7. 3 5
      packages/core/src/session/title.ts
  8. 32 24
      packages/core/src/session/to-session-error.ts
  9. 55 49
      packages/core/test/session-error.test.ts
  10. 5 12
      packages/core/test/session-execution.test.ts
  11. 63 80
      packages/core/test/session-runner.test.ts
  12. 3 3
      packages/llm/AGENTS.md
  13. 1 1
      packages/llm/src/index.ts
  14. 6 14
      packages/llm/src/llm.ts
  15. 7 11
      packages/llm/src/protocols/anthropic-messages.ts
  16. 14 21
      packages/llm/src/protocols/bedrock-converse.ts
  17. 10 23
      packages/llm/src/protocols/openai-responses.ts
  18. 6 16
      packages/llm/src/protocols/shared.ts
  19. 2 2
      packages/llm/src/protocols/utils/tool-stream.ts
  20. 115 6
      packages/llm/src/provider-error.ts
  21. 4 11
      packages/llm/src/route/auth.ts
  22. 35 3
      packages/llm/src/route/client.ts
  23. 46 92
      packages/llm/src/route/executor.ts
  24. 3 8
      packages/llm/src/route/transport/websocket.ts
  25. 108 79
      packages/llm/src/schema/errors.ts
  26. 1 20
      packages/llm/src/schema/events.ts
  27. 31 35
      packages/llm/test/executor.test.ts
  28. 2 2
      packages/llm/test/prepare.test.ts
  29. 15 8
      packages/llm/test/provider/anthropic-messages.recorded.test.ts
  30. 20 18
      packages/llm/test/provider/anthropic-messages.test.ts
  31. 9 11
      packages/llm/test/provider/bedrock-converse.test.ts
  32. 3 3
      packages/llm/test/provider/gemini.test.ts
  33. 9 5
      packages/llm/test/provider/openai-chat.test.ts
  34. 44 38
      packages/llm/test/provider/openai-responses.test.ts
  35. 4 3
      packages/llm/test/tool-stream.test.ts
  36. 0 3
      packages/opencode/src/session/processor.ts
  37. 1 2
      packages/opencode/test/session/processor-effect.test.ts

+ 88 - 16
packages/core/src/aisdk.ts

@@ -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: [] })

+ 1 - 1
packages/core/src/generate.ts

@@ -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"

+ 2 - 7
packages/core/src/session/compaction.ts

@@ -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"
@@ -247,11 +247,6 @@ const make = (dependencies: Dependencies) => {
       )
       .pipe(
         Stream.runForEach((event) => {
-          if (LLMEvent.is.providerError(event))
-            failure = {
-              type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
-              message: event.message,
-            }
           if (LLMEvent.is.textDelta(event)) {
             chunks.push(event.text)
             return dependencies.events.publish(SessionEvent.Compaction.Delta, {
@@ -261,7 +256,7 @@ const make = (dependencies: Dependencies) => {
           }
           return Effect.void
         }),
-        Effect.catchTag("LLM.Error", (error) =>
+        Effect.catchIf(isLLMError, (error) =>
           Effect.sync(() => {
             failure = toSessionError(error)
           }),

+ 9 - 25
packages/core/src/session/runner/llm.ts

@@ -1,15 +1,6 @@
 export * as SessionRunnerLLM from "./llm"
 
-import {
-  LLM,
-  LLMClient,
-  LLMError,
-  LLMEvent,
-  Message,
-  SystemPart,
-  isContextOverflowFailure,
-  type ProviderErrorEvent,
-} from "@opencode-ai/llm"
+import { LLM, LLMClient, LLMEvent, Message, SystemPart, isLLMError, type LLMError } from "@opencode-ai/llm"
 import { SessionError } from "@opencode-ai/schema/session-error"
 import { Money } from "@opencode-ai/schema/money"
 import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
@@ -230,17 +221,10 @@ const layer = Layer.effect(
       // mid-event.
       const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
       const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
-      let overflowFailure: ProviderErrorEvent | undefined
       const providerStream = llm.stream(request).pipe(
         Stream.runForEach((event) =>
           Effect.gen(function* () {
-            if (overflowFailure || publisher.hasProviderError()) return
-            if (LLMEvent.is.providerError(event)) {
-              if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
-                overflowFailure = event
-                return
-              }
-            }
+            if (publisher.hasProviderError()) return
             yield* publish(event)
             if (event.type !== "tool-call" || event.providerExecuted) return
             if (!toolMaterialization) {
@@ -320,22 +304,21 @@ const layer = Layer.effect(
           // away non-interrupt failures, so both interrupt checks stay Cause-based.
           const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
 
+          const llmFailure = streamFailure !== undefined && isLLMError(streamFailure) ? streamFailure : undefined
+
           // A context overflow before any assistant output is recoverable: compact and
           // restart the step instead of surfacing the provider error.
           if (
             recoverOverflow &&
             !publisher.hasRetryEvidence() &&
-            isContextOverflowFailure(overflowFailure ?? streamFailure) &&
+            llmFailure?._tag === "LLM.ContextOverflow" &&
             (yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status ===
               "completed"
           )
             return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
 
-          // An unrecovered held-back overflow becomes the step's durable provider error. A
-          // 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
+          // A thrown LLM failure records the assistant failure unless a provider failure
+          // was already recorded from the stream. Terminal publication waits for owned tools.
           if (llmFailure && !publisher.hasProviderError()) {
             const error = toSessionError(llmFailure)
             if (
@@ -352,7 +335,8 @@ const layer = Layer.effect(
             }
             yield* serialized(publisher.failAssistant(error))
           }
-          // Provider error events only arrive from the stream, so the flag is final here.
+          // The provider-failed flag is only set while consuming the stream (content-filter
+          // step finish), so it is final here.
           const providerFailed = publisher.hasProviderError()
 
           // Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain

+ 0 - 4
packages/core/src/session/runner/publish-llm-event.ts

@@ -428,10 +428,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
         return
       case "finish":
         return
-      case "provider-error":
-        providerFailed = true
-        yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
-        return
     }
   })
 

+ 19 - 15
packages/core/src/session/runner/retry.ts

@@ -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
 }
 

+ 3 - 5
packages/core/src/session/title.ts

@@ -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"
@@ -49,7 +49,6 @@ const make = (dependencies: Dependencies) => {
     ).pipe(Effect.catch(() => Effect.succeed(undefined)))
     if (!resolved) return
     const chunks: string[] = []
-    let failed = false
     const streamed = yield* dependencies.llm
       .stream(
         LLM.request({
@@ -61,14 +60,13 @@ const make = (dependencies: Dependencies) => {
       )
       .pipe(
         Stream.runForEach((event) => {
-          if (LLMEvent.is.providerError(event)) failed = true
           if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
           return Effect.void
         }),
         Effect.as(true),
-        Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
+        Effect.catchIf(isLLMError, () => Effect.succeed(false)),
       )
-    if (!streamed || failed) return
+    if (!streamed) return
     const title = chunks
       .join("")
       .split("\n")

+ 32 - 24
packages/core/src/session/to-session-error.ts

@@ -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
       }
     }

+ 55 - 49
packages/core/test/session-error.test.ts

@@ -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))
   })
 })

+ 5 - 12
packages/core/test/session-execution.test.ts

@@ -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",

+ 63 - 80
packages/core/test/session-runner.test.ts

@@ -1,14 +1,16 @@
 import { describe, expect, test } from "bun:test"
 import {
+  APIError,
+  BadRequest,
+  ConnectionError,
+  ContextOverflow,
   LLMClient,
-  LLMError,
   LLMEvent,
   Model,
+  RateLimit,
   ToolFailure,
-  TransportReason,
-  InvalidRequestReason,
-  RateLimitReason,
   type LLMClientShape,
+  type LLMError,
   type LLMRequest,
 } from "@opencode-ai/llm"
 import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
@@ -68,8 +70,9 @@ import { asc, eq } from "drizzle-orm"
 import { testEffect } from "./lib/effect"
 
 const requests: LLMRequest[] = []
+type ScriptedResponse = LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
 let response: LLMEvent[] = []
-let responses: LLMEvent[][] | undefined
+let responses: ScriptedResponse[] | undefined
 let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
 let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
 let streamGate: Deferred.Deferred<void> | undefined
@@ -92,9 +95,12 @@ const client = Layer.succeed(
         responseStream = undefined
         return stream
       }
+      const scripted = responses === undefined ? response : (responses.shift() ?? [])
       const events = streamFailure
         ? Stream.fail(streamFailure)
-        : Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
+        : Array.isArray(scripted)
+          ? Stream.fromIterable(scripted)
+          : scripted
       if (!streamGate) return events
       return Stream.unwrap(
         (streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
@@ -480,26 +486,16 @@ 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 contextOverflow = () => new ContextOverflow({ message: "prompt too long" })
 
-const rateLimited = (retryAfterMs?: number) =>
-  new LLMError({
-    module: "test",
-    method: "stream",
-    reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
-  })
+const failingResponse = (events: LLMEvent[], failure: LLMError): Stream.Stream<LLMEvent, LLMError> =>
+  Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(failure)))
+
+const invalidRequest = () => new BadRequest({ message: "Invalid request" })
+
+const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs })
 
 const setupOverflowRecovery = Effect.gen(function* () {
   const session = yield* setup
@@ -1646,14 +1642,14 @@ describe("SessionRunnerLLM", () => {
       yield* admit(session, "Earlier question")
       yield* session.resume(sessionID)
 
-      response = [LLMEvent.providerError({ message: "summary unavailable" })]
+      responseStream = Stream.fail(new APIError({ message: "summary unavailable" }))
       const compaction = yield* session.compact({ sessionID })
       yield* session.resume(sessionID)
 
       expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
         type: "compaction",
         status: "failed",
-        error: { type: "provider.error", message: "summary unavailable" },
+        error: { type: "provider.unknown", message: "summary unavailable" },
       })
     }),
   )
@@ -1763,7 +1759,7 @@ describe("SessionRunnerLLM", () => {
       currentModel = compactModel
       requests.length = 0
       responses = [
-        [LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
+        Stream.fail(new BadRequest({ message: "Unsupported parameter: max_output_tokens" })),
         reply.text("Must not run", "text-after-failed-compaction"),
       ]
       yield* admit(session, "Recent exact request ".repeat(180))
@@ -1786,10 +1782,7 @@ describe("SessionRunnerLLM", () => {
     Effect.gen(function* () {
       const session = yield* setupOverflowRecovery
       responses = [
-        [
-          LLMEvent.stepStart({ index: 0 }),
-          LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
-        ],
+        failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow()),
         reply.text("## Objective\n- Recover overflow", "text-summary"),
         reply.text("Recovered", "text-final"),
       ]
@@ -1816,7 +1809,7 @@ describe("SessionRunnerLLM", () => {
       const session = yield* setupOverflowRecovery
       currentModel = model
       responses = [
-        [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
+        Stream.fail(contextOverflow()),
         reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
         reply.text("Recovered", "text-final-unknown-limit"),
       ]
@@ -1836,7 +1829,7 @@ describe("SessionRunnerLLM", () => {
       const session = yield* setupOverflowRecovery
       currentModel = undersizedContextModel
       responses = [
-        [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
+        Stream.fail(contextOverflow()),
         reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
         reply.text("Recovered", "text-final-undersized-limit"),
       ]
@@ -1854,10 +1847,7 @@ describe("SessionRunnerLLM", () => {
   it.effect("persists a second context overflow after one recovery", () =>
     Effect.gen(function* () {
       const session = yield* setupOverflowRecovery
-      const overflow = () => [
-        LLMEvent.stepStart({ index: 0 }),
-        LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
-      ]
+      const overflow = () => failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow())
       responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()]
       yield* admit(session, "Continue")
       expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
@@ -1873,16 +1863,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(contextOverflow())
       responses = [
         reply.text("## Objective\n- Recover raw overflow", "text-summary"),
         reply.text("Recovered", "text-final"),
@@ -1901,10 +1882,7 @@ describe("SessionRunnerLLM", () => {
   it.effect("publishes the original overflow when recovery summarization fails", () =>
     Effect.gen(function* () {
       const session = yield* setupOverflowRecovery
-      responses = [
-        [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
-        [LLMEvent.providerError({ message: "summary unavailable" })],
-      ]
+      responses = [Stream.fail(contextOverflow()), Stream.fail(new APIError({ message: "summary unavailable" }))]
       yield* admit(session, "Continue")
       expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
 
@@ -1915,7 +1893,7 @@ describe("SessionRunnerLLM", () => {
           type: "compaction",
           status: "failed",
           reason: "auto",
-          error: { type: "provider.error", message: "summary unavailable" },
+          error: { type: "provider.unknown", message: "summary unavailable" },
         }),
       )
       expect(context.slice(-3)).toMatchObject([
@@ -1929,10 +1907,7 @@ describe("SessionRunnerLLM", () => {
   it.effect("interrupts overflow recovery while the summary provider is running", () =>
     Effect.gen(function* () {
       const session = yield* setupOverflowRecovery
-      responses = [
-        [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
-        reply.text("## Objective\n- Interrupted", "text-summary"),
-      ]
+      responses = [Stream.fail(contextOverflow()), reply.text("## Objective\n- Interrupted", "text-summary")]
       const firstGate = yield* Deferred.make<void>()
       const summaryGate = yield* Deferred.make<void>()
       streamGate = firstGate
@@ -3515,7 +3490,10 @@ describe("SessionRunnerLLM", () => {
       const session = yield* setup
       yield* admit(session, "Fail durably")
 
-      response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
+      responseStream = failingResponse(
+        [LLMEvent.stepStart({ index: 0 })],
+        new APIError({ message: "Provider unavailable" }),
+      )
 
       expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
 
@@ -3532,7 +3510,7 @@ describe("SessionRunnerLLM", () => {
       const session = yield* setup
       yield* admit(session, "Fail before step")
 
-      response = [LLMEvent.providerError({ message: "Provider unavailable" })]
+      responseStream = Stream.fail(new APIError({ message: "Provider unavailable" }))
 
       expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
 
@@ -3620,13 +3598,15 @@ describe("SessionRunnerLLM", () => {
       const session = yield* setup
       yield* admit(session, "Fail after output")
 
-      response = [
-        LLMEvent.stepStart({ index: 0 }),
-        LLMEvent.textStart({ id: "text-partial" }),
-        LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
-        LLMEvent.textEnd({ id: "text-partial" }),
-        LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
-      ]
+      responseStream = failingResponse(
+        [
+          LLMEvent.stepStart({ index: 0 }),
+          LLMEvent.textStart({ id: "text-partial" }),
+          LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
+          LLMEvent.textEnd({ id: "text-partial" }),
+        ],
+        contextOverflow(),
+      )
       expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
 
       expect(requests).toHaveLength(1)
@@ -3784,11 +3764,13 @@ describe("SessionRunnerLLM", () => {
       toolExecutionGate = yield* Deferred.make<void>()
       toolExecutionsStarted = yield* Deferred.make<void>()
       toolExecutionsReady = 1
-      response = [
-        LLMEvent.stepStart({ index: 0 }),
-        LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
-        LLMEvent.providerError({ message: "Provider unavailable" }),
-      ]
+      responseStream = failingResponse(
+        [
+          LLMEvent.stepStart({ index: 0 }),
+          LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
+        ],
+        new APIError({ message: "Provider unavailable" }),
+      )
 
       const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
       yield* Deferred.await(toolExecutionsStarted)
@@ -3815,11 +3797,10 @@ describe("SessionRunnerLLM", () => {
       const session = yield* setup
       yield* admit(session, "Fail hosted tool durably")
 
-      response = [
-        LLMEvent.stepStart({ index: 0 }),
-        hostedCall("call-hosted-provider-error", "effect"),
-        LLMEvent.providerError({ message: "Provider unavailable" }),
-      ]
+      responseStream = failingResponse(
+        [LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-provider-error", "effect")],
+        new APIError({ message: "Provider unavailable" }),
+      )
 
       expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
 
@@ -3846,11 +3827,13 @@ describe("SessionRunnerLLM", () => {
     Effect.gen(function* () {
       const session = yield* setup
       yield* admit(session, "Defect while provider fails")
-      response = [
-        LLMEvent.stepStart({ index: 0 }),
-        LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
-        LLMEvent.providerError({ message: "Provider unavailable" }),
-      ]
+      responseStream = failingResponse(
+        [
+          LLMEvent.stepStart({ index: 0 }),
+          LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
+        ],
+        new APIError({ message: "Provider unavailable" }),
+      )
 
       expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
 

+ 3 - 3
packages/llm/AGENTS.md

@@ -182,8 +182,8 @@ The dependency arrow points down: `providers/*.ts` files import protocol routes
 - `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field.
 - `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
 - `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
-- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures.
-- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
+- `eventError(route, message, ...)` — typed `MalformedResponse` constructor for stream-time decode failures.
+- `validateWith(decoder)` — maps Schema decode errors to `BadRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
 - `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering.
 
 If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating.
@@ -291,7 +291,7 @@ Use this order for every protocol module:
 
 - Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
 - Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
-- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
+- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event for each completed response. Provider-reported failures (SSE error events, exception frames) fail the stream with a typed `LLMError` via `classifyApiFailure` — never an ordinary event. If a provider splits reason and usage across events, merge them in parser state before flushing.
 - Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
 - Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
 - Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.

+ 1 - 1
packages/llm/src/index.ts

@@ -2,7 +2,7 @@ export { LLMClient } from "./route/client"
 export { Auth } from "./route/auth"
 export { Provider } from "./provider"
 export { ProviderPackage } from "./provider-package"
-export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
+export { classifyApiFailure, isContextOverflow, type ApiFailure } from "./provider-error"
 export type {
   RouteModelInput,
   RouteRoutedModelInput,

+ 6 - 14
packages/llm/src/llm.ts

@@ -3,8 +3,8 @@ import { LLMClient } from "./route/client"
 import {
   GenerationOptions,
   HttpOptions,
-  InvalidProviderOutputReason,
-  LLMError,
+  MalformedResponse,
+  type LLMError,
   LLMEvent,
   LLMRequest,
   LLMResponse,
@@ -121,22 +121,14 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
     (event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
   )
   if (!call || !LLMEvent.is.toolCall(call))
-    return yield* new LLMError({
-      module: "LLM",
-      method: "generateObject",
-      reason: new InvalidProviderOutputReason({
-        message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
-      }),
+    return yield* new MalformedResponse({
+      message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
     })
   const object = yield* tool._decode(call.input).pipe(
     Effect.mapError(
       (error) =>
-        new LLMError({
-          module: "LLM",
-          method: "generateObject",
-          reason: new InvalidProviderOutputReason({
-            message: `generateObject: tool input failed schema decode: ${error.message}`,
-          }),
+        new MalformedResponse({
+          message: `generateObject: tool input failed schema decode: ${error.message}`,
         }),
     ),
   )

+ 7 - 11
packages/llm/src/protocols/anthropic-messages.ts

@@ -19,7 +19,7 @@ import {
   type ToolResultPart,
 } from "../schema"
 import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
-import { isContextOverflow } from "../provider-error"
+import { classifyApiFailure } from "../provider-error"
 import * as Cache from "./utils/cache"
 import { Lifecycle } from "./utils/lifecycle"
 import { ToolSchemaProjection } from "./utils/tool-schema"
@@ -832,15 +832,11 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
   return message || type || "Anthropic Messages stream error"
 }
 
-const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
-  state,
-  [
-    LLMEvent.providerError({
-      message: providerErrorMessage(event),
-      classification: isContextOverflow(event.error?.message ?? "") ? "context-overflow" : undefined,
-    }),
-  ],
-]
+const onError = (event: AnthropicEvent) =>
+  classifyApiFailure({
+    message: providerErrorMessage(event),
+    code: event.error?.type,
+  })
 
 const step = (state: ParserState, event: AnthropicEvent) => {
   if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
@@ -848,7 +844,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
   if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
   if (event.type === "content_block_stop") return onContentBlockStop(state, event)
   if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
-  if (event.type === "error") return Effect.succeed(onError(state, event))
+  if (event.type === "error") return Effect.fail(onError(event))
   return Effect.succeed<StepResult>([state, NO_EVENTS])
 }
 

+ 14 - 21
packages/llm/src/protocols/bedrock-converse.ts

@@ -17,7 +17,7 @@ import {
   type ToolResultPart,
 } from "../schema"
 import { BedrockEventStream } from "./bedrock-event-stream"
-import { isContextOverflow } from "../provider-error"
+import { classifyApiFailure } from "../provider-error"
 import { JsonObject, optionalArray, ProviderShared } from "./shared"
 import { BedrockAuth } from "./utils/bedrock-auth"
 import { BedrockCache } from "./utils/bedrock-cache"
@@ -586,27 +586,20 @@ const step = (state: ParserState, event: BedrockEvent) =>
       return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
     }
 
-    if (event.internalServerException || event.modelStreamErrorException || event.serviceUnavailableException) {
-      const message =
-        event.internalServerException?.message ??
-        event.modelStreamErrorException?.message ??
-        event.serviceUnavailableException?.message ??
-        "Bedrock Converse stream error"
-      return [state, [LLMEvent.providerError({ message })]] as const
-    }
-
-    if (event.validationException || event.throttlingException) {
-      const message =
-        event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
-      return [
-        state,
-        [
-          LLMEvent.providerError({
-            message,
-            classification: event.validationException && isContextOverflow(message) ? "context-overflow" : undefined,
-          }),
-        ],
+    const exception = (
+      [
+        ["internalServerException", event.internalServerException],
+        ["modelStreamErrorException", event.modelStreamErrorException],
+        ["serviceUnavailableException", event.serviceUnavailableException],
+        ["throttlingException", event.throttlingException],
+        ["validationException", event.validationException],
       ] as const
+    ).find((entry) => entry[1] !== undefined)
+    if (exception) {
+      return yield* classifyApiFailure({
+        message: exception[1]?.message ?? "Bedrock Converse stream error",
+        code: exception[0],
+      })
     }
 
     return [state, []] as const

+ 10 - 23
packages/llm/src/protocols/openai-responses.ts

@@ -19,7 +19,7 @@ import {
   type ToolResultPart,
 } from "../schema"
 import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
-import { isContextOverflow } from "../provider-error"
+import { classifyApiFailure } from "../provider-error"
 import { OpenAIOptions } from "./utils/openai-options"
 import { Lifecycle } from "./utils/lifecycle"
 import { ToolSchemaProjection } from "./utils/tool-schema"
@@ -606,9 +606,9 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
 const NO_EVENTS: StepResult["1"] = []
 
 // `response.completed` / `response.incomplete` are clean finishes that emit a
-// `finish` event; `response.failed` is a hard failure that emits a
-// `provider-error`. All three end the stream — kept in one set so `step` and
-// the protocol's `terminal` predicate stay in sync.
+// `finish` event; `response.failed` is a hard failure that fails the stream
+// with a classified `LLMError`. All three end the stream — kept in one set so
+// `step` and the protocol's `terminal` predicate stay in sync.
 const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
 
 const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
@@ -907,24 +907,11 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st
   return message || code || fallback
 }
 
-const providerError = (event: OpenAIResponsesEvent, fallback: string) => {
-  const code = event.code || event.error?.code || event.response?.error?.code || undefined
-  const message = providerErrorMessage(event, fallback)
-  return LLMEvent.providerError({
-    message,
-    classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined,
+const providerError = (event: OpenAIResponsesEvent, fallback: string) =>
+  classifyApiFailure({
+    message: providerErrorMessage(event, fallback),
+    code: event.code || event.error?.code || event.response?.error?.code || undefined,
   })
-}
-
-const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
-  state,
-  [providerError(event, "OpenAI Responses response failed")],
-]
-
-const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
-  state,
-  [providerError(event, "OpenAI Responses stream error")],
-]
 
 const step = (state: ParserState, event: OpenAIResponsesEvent) => {
   if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
@@ -950,8 +937,8 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
   if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
   if (event.type === "response.completed" || event.type === "response.incomplete")
     return Effect.succeed(onResponseFinish(state, event))
-  if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event))
-  if (event.type === "error") return Effect.succeed(onError(state, event))
+  if (event.type === "response.failed") return Effect.fail(providerError(event, "OpenAI Responses response failed"))
+  if (event.type === "error") return Effect.fail(providerError(event, "OpenAI Responses stream error"))
   return Effect.succeed<StepResult>([state, NO_EVENTS])
 }
 

+ 6 - 16
packages/llm/src/protocols/shared.ts

@@ -3,9 +3,9 @@ import { Effect, Schema, Stream } from "effect"
 import * as Sse from "effect/unstable/encoding/Sse"
 import { Headers, HttpClientRequest } from "effect/unstable/http"
 import {
-  InvalidProviderOutputReason,
-  InvalidRequestReason,
-  LLMError,
+  BadRequest,
+  MalformedResponse,
+  type LLMError,
   type ContentPart,
   type LLMRequest,
   type MediaPart,
@@ -88,11 +88,7 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
 }
 
 export const eventError = (route: string, message: string, raw?: string) =>
-  new LLMError({
-    module: "ProviderShared",
-    method: "stream",
-    reason: new InvalidProviderOutputReason({ route, message, raw }),
-  })
+  new MalformedResponse({ route, message, raw })
 
 export const parseJson = (route: string, input: string, message: string) =>
   Effect.try({
@@ -252,15 +248,9 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
  * Canonical invalid-request constructor. Lift one-line `const invalid =
  * (message) => invalidRequest(message)` aliases out of every
  * route so the error constructor lives in one place. If we ever extend
- * `InvalidRequestReason` with route context or trace metadata, the change
- * lands here.
+ * `BadRequest` with route context or trace metadata, the change lands here.
  */
-export const invalidRequest = (message: string) =>
-  new LLMError({
-    module: "ProviderShared",
-    method: "request",
-    reason: new InvalidRequestReason({ message }),
-  })
+export const invalidRequest = (message: string) => new BadRequest({ message })
 
 export const matchToolChoice = <Auto, None, Required, Tool>(
   route: string,

+ 2 - 2
packages/llm/src/protocols/utils/tool-stream.ts

@@ -1,5 +1,5 @@
 import { Effect } from "effect"
-import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
+import { isLLMError, LLMEvent, type LLMError, type ProviderMetadata, type ToolCall } from "../../schema"
 import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
 
 type StreamKey = string | number
@@ -95,7 +95,7 @@ const appendTool = <K extends StreamKey>(
 }
 
 export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
-  result instanceof LLMError
+  isLLMError(result)
 
 /**
  * Register a tool call whose start event arrived before any argument deltas.

+ 115 - 6
packages/llm/src/provider-error.ts

@@ -1,5 +1,19 @@
-import { Schema } from "effect"
-import { LLMError, ProviderErrorEvent } from "./schema"
+import {
+  APIError,
+  Authentication,
+  BadRequest,
+  ContentPolicy,
+  ContextOverflow,
+  HttpContext,
+  HttpRateLimitDetails,
+  NotFound,
+  PermissionDenied,
+  ProviderMetadata,
+  QuotaExceeded,
+  RateLimit,
+  ServerError,
+  type LLMError,
+} from "./schema"
 
 const patterns = [
   /prompt is too long/i,
@@ -27,7 +41,102 @@ const patterns = [
 export const isContextOverflow = (message: string) =>
   patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
 
-export const isContextOverflowFailure = (failure: unknown) =>
-  failure instanceof LLMError
-    ? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
-    : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
+const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"])
+const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
+const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
+const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
+const SERVER_ERROR_STATUS = (status: number) => status >= 500 || status === 529
+
+const CODE_CLASSIFICATION: Record<string, (input: ApiFailure, common: CommonFields) => LLMError> = {
+  overloaded_error: serverError,
+  api_error: serverError,
+  server_error: serverError,
+  internal_error: serverError,
+  server_is_overloaded: serverError,
+  internalServerException: serverError,
+  serviceUnavailableException: serverError,
+  modelStreamErrorException: serverError,
+  rate_limit_error: rateLimit,
+  rate_limit_exceeded: rateLimit,
+  too_many_requests: rateLimit,
+  throttlingException: rateLimit,
+  authentication_error: (_input, common) => new Authentication(common),
+  permission_error: (_input, common) => new PermissionDenied(common),
+  not_found_error: (_input, common) => new NotFound(common),
+  invalid_request_error: (_input, common) => new BadRequest(common),
+  invalid_prompt: (_input, common) => new BadRequest(common),
+  validationException: (_input, common) => new BadRequest(common),
+}
+
+export interface ApiFailure {
+  readonly message: string
+  readonly status?: number | undefined
+  /** Provider machine-readable error code or type string (e.g. `context_length_exceeded`, `overloaded_error`). */
+  readonly code?: string | undefined
+  readonly retryAfterMs?: number | undefined
+  readonly rateLimit?: HttpRateLimitDetails | undefined
+  readonly requestID?: string | undefined
+  readonly http?: HttpContext | undefined
+  readonly providerMetadata?: ProviderMetadata | undefined
+}
+
+type CommonFields = {
+  readonly message: string
+  readonly status: number | undefined
+  readonly code: string | undefined
+  readonly requestID: string | undefined
+  readonly http: HttpContext | undefined
+  readonly providerMetadata: ProviderMetadata | undefined
+}
+
+function serverError(input: ApiFailure, common: CommonFields) {
+  return new ServerError({ ...common, retryAfterMs: input.retryAfterMs })
+}
+
+function rateLimit(input: ApiFailure, common: CommonFields) {
+  return new RateLimit({ ...common, retryAfterMs: input.retryAfterMs, rateLimit: input.rateLimit })
+}
+
+/**
+ * One classifier for every failure a remote API deliberately reports.
+ * Protocols call it with in-stream error payloads, the request executor with
+ * non-2xx responses, and the AI SDK adapter with `APICallError`s, so all
+ * three surfaces produce identical `LLMError` tags.
+ *
+ * Precedence: context overflow (most specific, 4xx-scoped), content policy,
+ * HTTP status, provider code, then the generic `APIError` fallback.
+ */
+export const classifyApiFailure = (input: ApiFailure): LLMError => {
+  const common: CommonFields = {
+    message: input.message,
+    status: input.status,
+    code: input.code,
+    requestID: input.requestID,
+    http: input.http,
+    providerMetadata: input.providerMetadata,
+  }
+  const body = input.http?.body ?? ""
+  const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
+  if (
+    clientScoped &&
+    ((input.code !== undefined && OVERFLOW_CODES.has(input.code)) ||
+      isContextOverflow(input.message) ||
+      (body.length > 0 && isContextOverflow(body)))
+  )
+    return new ContextOverflow(common)
+  if (CONTENT_POLICY_TEXT.test(body.length > 0 ? body : input.message)) return new ContentPolicy(common)
+  if (input.code !== undefined && QUOTA_CODES.has(input.code)) return new QuotaExceeded(common)
+  if (input.status === 401) return new Authentication(common)
+  if (input.status === 403) return new PermissionDenied(common)
+  if (input.status === 404) return new NotFound(common)
+  if (input.status === 429) {
+    if (QUOTA_TEXT.test(body.length > 0 ? body : input.message)) return new QuotaExceeded(common)
+    return rateLimit(input, common)
+  }
+  if (input.status !== undefined && SERVER_ERROR_STATUS(input.status)) return serverError(input, common)
+  if (input.status === 400 || input.status === 409 || input.status === 413 || input.status === 422)
+    return new BadRequest(common)
+  const byCode = input.code === undefined ? undefined : CODE_CLASSIFICATION[input.code]
+  if (byCode) return byCode(input, common)
+  return new APIError(common)
+}

+ 4 - 11
packages/llm/src/route/auth.ts

@@ -1,6 +1,6 @@
 import { Config, Effect, Redacted } from "effect"
 import { Headers } from "effect/unstable/http"
-import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
+import { Authentication, BadRequest, type LLMError, type LLMRequest } from "../schema"
 
 export class MissingCredentialError extends Error {
   readonly _tag = "MissingCredentialError"
@@ -135,16 +135,9 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
 }
 
 const toLLMError = (error: AuthError): LLMError => {
-  if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
-    return new LLMError({
-      module: "Auth",
-      method: "apply",
-      reason:
-        error instanceof MissingCredentialError
-          ? new AuthenticationReason({ message: error.message, kind: "missing" })
-          : new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }),
-    })
-  }
+  if (error instanceof MissingCredentialError) return new Authentication({ message: error.message })
+  if (error instanceof Config.ConfigError)
+    return new BadRequest({ message: `Failed to resolve auth config: ${error.message}` })
   return error
 }
 

+ 35 - 3
packages/llm/src/route/client.ts

@@ -14,11 +14,11 @@ import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions
 import {
   GenerationOptions,
   HttpOptions,
+  isLLMError,
   LLMRequest,
   LLMResponse,
   Model,
   ModelLimits,
-  LLMError as LLMErrorClass,
   PreparedRequest,
   ProviderID,
   mergeGenerationOptions,
@@ -225,10 +225,39 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
 
 const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
   const failed = cause.reasons.find(Cause.isFailReason)?.error
-  if (failed instanceof LLMErrorClass) return failed
+  if (failed !== undefined && isLLMError(failed)) return failed
   return ProviderShared.eventError(route, message, Cause.pretty(cause))
 }
 
+/**
+ * Terminal contract for every route, native or synthetic: a successful
+ * stream emits exactly one `finish`, and nothing after it. EOF before
+ * `finish` means the provider stream was truncated (proxy cut, silent
+ * drop) and must fail rather than let a partial response settle as
+ * complete. Applied after protocol parsing so `stream.onHalt` flushes are
+ * still subject to it.
+ */
+const enforceTerminal = (route: string) => (events: Stream.Stream<LLMEvent, LLMError>) => {
+  let finished = false
+  return events.pipe(
+    Stream.mapEffect((event) => {
+      if (finished)
+        return Effect.fail(
+          ProviderShared.eventError(route, `Provider emitted ${event.type} after the terminal finish event`),
+        )
+      if (event.type === "finish") finished = true
+      return Effect.succeed(event)
+    }),
+    Stream.concat(
+      Stream.suspend(() =>
+        finished
+          ? Stream.empty
+          : Stream.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")),
+      ),
+    ),
+  )
+}
+
 function makeFromTransport<Body, Prepared, Frame, Event, State>(
   input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
 ): Route<Body, Prepared> {
@@ -383,7 +412,10 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
   Stream.unwrap(
     Effect.gen(function* () {
       const compiled = yield* compile(request)
-      return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
+      const route = `${compiled.request.model.provider}/${compiled.route.id}`
+      return compiled.route
+        .streamPrepared(compiled.prepared, compiled.request, runtime)
+        .pipe(enforceTerminal(route))
     }),
   )
 

+ 46 - 92
packages/llm/src/route/executor.ts

@@ -1,4 +1,4 @@
-import { Cause, Context, Effect, Layer } from "effect"
+import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
 import {
   FetchHttpClient,
   Headers,
@@ -8,21 +8,15 @@ import {
   HttpClientResponse,
 } from "effect/unstable/http"
 import {
-  AuthenticationReason,
-  ContentPolicyReason,
+  ConnectionError,
   HttpContext,
   HttpRateLimitDetails,
   HttpRequestDetails,
   HttpResponseDetails,
-  InvalidRequestReason,
-  LLMError,
-  ProviderInternalReason,
-  QuotaExceededReason,
-  RateLimitReason,
-  TransportReason,
-  UnknownProviderReason,
+  TimeoutError,
+  type LLMError,
 } from "../schema"
-import { isContextOverflow } from "../provider-error"
+import { classifyApiFailure } from "../provider-error"
 
 export interface Interface {
   readonly execute: (
@@ -85,8 +79,6 @@ const requestId = (headers: Record<string, string>) => {
   )
 }
 
-const providerInternalStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529
-
 const retryAfterMs = (headers: Record<string, string>) => {
   const millis = Number(headers["retry-after-ms"])
   if (Number.isFinite(millis)) return Math.max(0, millis)
@@ -219,56 +211,21 @@ const responseHttp = (input: {
     rateLimit: input.rateLimit,
   })
 
-const statusReason = (input: {
-  readonly status: number
-  readonly message: string
-  readonly retryAfterMs?: number | undefined
-  readonly rateLimit?: HttpRateLimitDetails | undefined
-  readonly http: HttpContext
-}) => {
-  const body = input.http.body ?? ""
-  if (/content[-_\s]?policy|content_filter|safety/i.test(body)) {
-    return new ContentPolicyReason({ message: input.message, http: input.http })
-  }
-  if (input.status === 401) {
-    return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http })
-  }
-  if (input.status === 403) {
-    return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http })
-  }
-  if (input.status === 429) {
-    if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) {
-      return new QuotaExceededReason({ message: input.message, http: input.http })
-    }
-    return new RateLimitReason({
-      message: input.message,
-      retryAfterMs: input.retryAfterMs,
-      rateLimit: input.rateLimit,
-      http: input.http,
-    })
-  }
-  if (
-    input.status === 400 ||
-    input.status === 404 ||
-    input.status === 409 ||
-    input.status === 413 ||
-    input.status === 422
-  ) {
-    return new InvalidRequestReason({
-      message: input.message,
-      classification: isContextOverflow(body) ? "context-overflow" : undefined,
-      http: input.http,
-    })
-  }
-  if (input.status >= 500 || providerInternalStatus(input.status)) {
-    return new ProviderInternalReason({
-      message: input.message,
-      status: input.status,
-      retryAfterMs: input.retryAfterMs,
-      http: input.http,
-    })
-  }
-  return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http })
+const decodeBodyJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
+
+// Provider machine code from a JSON error body (`error.code` / `error.type`),
+// fed to the shared classifier so code-based rules (overflow, quota) work on
+// HTTP rejections too. Truncated or non-JSON bodies yield undefined.
+const providerCode = (body: string | undefined) => {
+  if (!body) return undefined
+  const decoded = Option.getOrUndefined(decodeBodyJson(body))
+  if (typeof decoded !== "object" || decoded === null) return undefined
+  const error = (decoded as Record<string, unknown>).error
+  if (typeof error !== "object" || error === null) return undefined
+  const fields = error as Record<string, unknown>
+  if (typeof fields.code === "string") return fields.code
+  if (typeof fields.type === "string") return fields.type
+  return undefined
 }
 
 const statusError =
@@ -281,58 +238,55 @@ const statusError =
       const retryAfter = retryAfterMs(headers)
       const rateLimit = rateLimitDetails(headers, retryAfter)
       const details = responseBody(body, request)
-      return yield* new LLMError({
-        module: "RequestExecutor",
-        method: "execute",
-        reason: statusReason({
-          status: response.status,
-          message: providerMessage(response.status, details),
-          retryAfterMs: retryAfter,
+      return yield* classifyApiFailure({
+        status: response.status,
+        message: providerMessage(response.status, details),
+        code: providerCode(details.body),
+        retryAfterMs: retryAfter,
+        rateLimit,
+        requestID: requestId(headers),
+        http: responseHttp({
+          request,
+          response,
+          redactedNames,
+          body: details,
+          requestId: requestId(headers),
           rateLimit,
-          http: responseHttp({
-            request,
-            response,
-            redactedNames,
-            body: details,
-            requestId: requestId(headers),
-            rateLimit,
-          }),
         }),
       })
     })
 
 const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
-  const transportError = (input: {
+  const httpContext = (request: HttpClientRequest.HttpClientRequest | undefined) =>
+    request ? new HttpContext({ request: requestDetails(request, redactedNames) }) : undefined
+  const connectionError = (input: {
     readonly message: string
     readonly kind?: string | undefined
     readonly request?: HttpClientRequest.HttpClientRequest | undefined
   }) =>
-    new LLMError({
-      module: "RequestExecutor",
-      method: "execute",
-      reason: new TransportReason({
-        message: input.message,
-        kind: input.kind,
-        url: input.request ? redactUrl(input.request.url) : undefined,
-        http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
-      }),
+    new ConnectionError({
+      message: input.message,
+      kind: input.kind,
+      url: input.request ? redactUrl(input.request.url) : undefined,
+      http: httpContext(input.request),
+      cause: error,
     })
 
   if (Cause.isTimeoutError(error)) {
-    return transportError({ message: error.message, kind: "Timeout" })
+    return new TimeoutError({ message: error.message })
   }
   if (!HttpClientError.isHttpClientError(error)) {
-    return transportError({ message: "HTTP transport failed" })
+    return connectionError({ message: "HTTP transport failed" })
   }
   const request = "request" in error ? error.request : undefined
   if (error.reason._tag === "TransportError") {
-    return transportError({
+    return connectionError({
       message: error.reason.description ?? "HTTP transport failed",
       kind: error.reason._tag,
       request,
     })
   }
-  return transportError({
+  return connectionError({
     message: `HTTP transport failed: ${error.reason._tag}`,
     kind: error.reason._tag,
     request,

+ 3 - 8
packages/llm/src/route/transport/websocket.ts

@@ -1,6 +1,6 @@
 import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
 import { Headers } from "effect/unstable/http"
-import { LLMError, TransportReason } from "../../schema"
+import { ConnectionError, type LLMError } from "../../schema"
 import * as HttpTransport from "./http"
 import type { Transport } from "./index"
 
@@ -27,15 +27,10 @@ type WebSocketConstructorWithHeaders = new (
 export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {}
 
 const transportError = (
-  method: string,
+  _method: string,
   message: string,
   input: { readonly url?: string; readonly kind?: string } = {},
-) =>
-  new LLMError({
-    module: "WebSocketExecutor",
-    method,
-    reason: new TransportReason({ message, url: input.url, kind: input.kind }),
-  })
+) => new ConnectionError({ message, url: input.url, kind: input.kind })
 
 const eventMessage = (event: Event) => {
   if ("message" in event && typeof event.message === "string") return event.message

+ 108 - 79
packages/llm/src/schema/errors.ts

@@ -1,9 +1,6 @@
 import { Schema } from "effect"
 import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
 
-export const ProviderFailureClassification = Schema.Literal("context-overflow")
-export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
-
 export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
   method: Schema.String,
   url: Schema.String,
@@ -31,118 +28,150 @@ export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
   rateLimit: Schema.optional(HttpRateLimitDetails),
 }) {}
 
-export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LLM.Error.InvalidRequest")({
-  _tag: Schema.tag("InvalidRequest"),
+/**
+ * Fields shared by every failure the remote API deliberately reported —
+ * whether as a non-2xx response, an SSE error event, a WebSocket error
+ * message, or a binary exception frame. `status` is absent when the error
+ * arrived mid-stream without an HTTP status; `code` carries the provider's
+ * machine-readable error code (e.g. `context_length_exceeded`) when one
+ * exists.
+ */
+const apiFailureFields = {
   message: Schema.String,
-  parameter: Schema.optional(Schema.String),
-  classification: Schema.optional(ProviderFailureClassification),
-  providerMetadata: Schema.optional(ProviderMetadata),
+  status: Schema.optional(Schema.Number),
+  code: Schema.optional(Schema.String),
+  requestID: Schema.optional(Schema.String),
   http: Schema.optional(HttpContext),
+  providerMetadata: Schema.optional(ProviderMetadata),
+}
+
+/** Provider rejected the request as invalid (400/409/422, `invalid_request_error`, ...). */
+export class BadRequest extends Schema.TaggedErrorClass<BadRequest>()("LLM.BadRequest", {
+  ...apiFailureFields,
+  parameter: Schema.optional(Schema.String),
 }) {}
 
-export class NoRouteReason extends Schema.Class<NoRouteReason>("LLM.Error.NoRoute")({
-  _tag: Schema.tag("NoRoute"),
-  route: RouteID,
-  provider: ProviderID,
-  model: ModelID,
-}) {
-  get message() {
-    return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
-  }
-}
+/** Credentials are missing, invalid, or expired (401). */
+export class Authentication extends Schema.TaggedErrorClass<Authentication>()("LLM.Authentication", {
+  ...apiFailureFields,
+}) {}
 
-export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.Error.Authentication")({
-  _tag: Schema.tag("Authentication"),
-  message: Schema.String,
-  kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
-  providerMetadata: Schema.optional(ProviderMetadata),
-  http: Schema.optional(HttpContext),
+/** Authenticated but not allowed (403). */
+export class PermissionDenied extends Schema.TaggedErrorClass<PermissionDenied>()("LLM.PermissionDenied", {
+  ...apiFailureFields,
 }) {}
 
-export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.RateLimit")({
-  _tag: Schema.tag("RateLimit"),
-  message: Schema.String,
+/** Model or endpoint does not exist (404). */
+export class NotFound extends Schema.TaggedErrorClass<NotFound>()("LLM.NotFound", {
+  ...apiFailureFields,
+}) {}
+
+/** Transient request throttling (429). Retryable; honor `retryAfterMs` when present. */
+export class RateLimit extends Schema.TaggedErrorClass<RateLimit>()("LLM.RateLimit", {
+  ...apiFailureFields,
   retryAfterMs: Schema.optional(Schema.Number),
   rateLimit: Schema.optional(HttpRateLimitDetails),
-  providerMetadata: Schema.optional(ProviderMetadata),
-  http: Schema.optional(HttpContext),
 }) {}
 
-export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({
-  _tag: Schema.tag("QuotaExceeded"),
-  message: Schema.String,
-  providerMetadata: Schema.optional(ProviderMetadata),
-  http: Schema.optional(HttpContext),
+/** Account-level quota or billing exhaustion. Unlike `RateLimit`, waiting does not help. */
+export class QuotaExceeded extends Schema.TaggedErrorClass<QuotaExceeded>()("LLM.QuotaExceeded", {
+  ...apiFailureFields,
 }) {}
 
-export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({
-  _tag: Schema.tag("ContentPolicy"),
-  message: Schema.String,
-  providerMetadata: Schema.optional(ProviderMetadata),
-  http: Schema.optional(HttpContext),
+/** Provider refused the content for policy/safety reasons. */
+export class ContentPolicy extends Schema.TaggedErrorClass<ContentPolicy>()("LLM.ContentPolicy", {
+  ...apiFailureFields,
 }) {}
 
-export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({
-  _tag: Schema.tag("ProviderInternal"),
-  message: Schema.String,
-  status: Schema.Number,
+/**
+ * The request exceeds the model's context window. Designated tag because
+ * Core recovers from it structurally (compaction) rather than surfacing it.
+ * Upgraded from `BadRequest` by the shared classifier in `provider-error.ts`.
+ */
+export class ContextOverflow extends Schema.TaggedErrorClass<ContextOverflow>()("LLM.ContextOverflow", {
+  ...apiFailureFields,
+}) {}
+
+/** Provider-side failure (5xx, `overloaded_error`, internal exceptions). Retryable. */
+export class ServerError extends Schema.TaggedErrorClass<ServerError>()("LLM.ServerError", {
+  ...apiFailureFields,
   retryAfterMs: Schema.optional(Schema.Number),
-  providerMetadata: Schema.optional(ProviderMetadata),
-  http: Schema.optional(HttpContext),
 }) {}
 
-export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Transport")({
-  _tag: Schema.tag("Transport"),
+/** Any other deliberate API rejection that matches no designated tag (402, 405, 410, ...). */
+export class APIError extends Schema.TaggedErrorClass<APIError>()("LLM.APIError", {
+  ...apiFailureFields,
+}) {}
+
+/** Communication failed: connect failure, reset, socket close, DNS. No API response involved. */
+export class ConnectionError extends Schema.TaggedErrorClass<ConnectionError>()("LLM.ConnectionError", {
   message: Schema.String,
   kind: Schema.optional(Schema.String),
   url: Schema.optional(Schema.String),
   http: Schema.optional(HttpContext),
+  cause: Schema.optional(Schema.Defect()),
 }) {}
 
-export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
-  "LLM.Error.InvalidProviderOutput",
-)({
-  _tag: Schema.tag("InvalidProviderOutput"),
+/** The request or stream read timed out before the provider answered. */
+export class TimeoutError extends Schema.TaggedErrorClass<TimeoutError>()("LLM.TimeoutError", {
   message: Schema.String,
-  route: Schema.optional(Schema.String),
-  raw: Schema.optional(Schema.String),
-  providerMetadata: Schema.optional(ProviderMetadata),
+  url: Schema.optional(Schema.String),
+  http: Schema.optional(HttpContext),
 }) {}
 
-export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.Error.UnknownProvider")({
-  _tag: Schema.tag("UnknownProvider"),
+/**
+ * Transport succeeded but the content broke the protocol contract:
+ * undecodable frames, premature EOF without a terminal `finish`, duplicate
+ * terminals, or output after a terminal event.
+ */
+export class MalformedResponse extends Schema.TaggedErrorClass<MalformedResponse>()("LLM.MalformedResponse", {
   message: Schema.String,
-  status: Schema.optional(Schema.Number),
+  route: Schema.optional(Schema.String),
+  raw: Schema.optional(Schema.String),
   providerMetadata: Schema.optional(ProviderMetadata),
-  http: Schema.optional(HttpContext),
 }) {}
 
-export const LLMErrorReason = Schema.Union([
-  InvalidRequestReason,
-  NoRouteReason,
-  AuthenticationReason,
-  RateLimitReason,
-  QuotaExceededReason,
-  ContentPolicyReason,
-  ProviderInternalReason,
-  TransportReason,
-  InvalidProviderOutputReason,
-  UnknownProviderReason,
-]).pipe(Schema.toTaggedUnion("_tag"))
-export type LLMErrorReason = Schema.Schema.Type<typeof LLMErrorReason>
-
-export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
-  module: Schema.String,
-  method: Schema.String,
-  reason: LLMErrorReason,
+/** Request construction failed locally: the selected model resolves to no executable route. */
+export class NoRoute extends Schema.TaggedErrorClass<NoRoute>()("LLM.NoRoute", {
+  route: RouteID,
+  provider: ProviderID,
+  model: ModelID,
 }) {
-  override readonly cause = this.reason
-
   override get message() {
-    return `${this.module}.${this.method}: ${this.reason.message}`
+    return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
   }
 }
 
+const members = [
+  BadRequest,
+  Authentication,
+  PermissionDenied,
+  NotFound,
+  RateLimit,
+  QuotaExceeded,
+  ContentPolicy,
+  ContextOverflow,
+  ServerError,
+  APIError,
+  ConnectionError,
+  TimeoutError,
+  MalformedResponse,
+  NoRoute,
+] as const
+
+export const LLMErrorSchema = Schema.Union(members)
+
+/**
+ * Every failure of one LLM request. `LLMEvent` streams carry output only;
+ * all failures — HTTP rejections, in-stream provider error events, transport
+ * failures, and protocol-contract violations — exit through this union on
+ * the stream's error channel.
+ */
+export type LLMError = typeof LLMErrorSchema.Type
+
+export const isLLMError = (value: unknown): value is LLMError =>
+  members.some((member) => value instanceof member)
+
 /**
  * Failure type for tool execute handlers. Handlers must map their internal
  * errors to this shape; the runtime catches `ToolFailure`s and surfaces them

+ 1 - 20
packages/llm/src/schema/events.ts

@@ -2,7 +2,6 @@ import { Schema } from "effect"
 import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
 import { ModelSchema } from "./options"
 import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
-import { ProviderFailureClassification } from "./errors"
 
 /**
  * Token usage reported by an LLM provider.
@@ -197,14 +196,6 @@ export const Finish = Schema.Struct({
 }).annotate({ identifier: "LLM.Event.Finish" })
 export type Finish = Schema.Schema.Type<typeof Finish>
 
-export const ProviderErrorEvent = Schema.Struct({
-  type: Schema.tag("provider-error"),
-  message: Schema.String,
-  classification: Schema.optional(ProviderFailureClassification),
-  providerMetadata: Schema.optional(ProviderMetadata),
-}).annotate({ identifier: "LLM.Event.ProviderError" })
-export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
-
 const llmEventTagged = Schema.Union([
   StepStart,
   TextStart,
@@ -221,7 +212,6 @@ const llmEventTagged = Schema.Union([
   ToolError,
   StepFinish,
   Finish,
-  ProviderErrorEvent,
 ]).pipe(Schema.toTaggedUnion("type"))
 
 type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
@@ -271,7 +261,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
       ...input,
       usage: input.usage === undefined ? undefined : Usage.from(input.usage),
     }),
-  providerError: ProviderErrorEvent.make,
   is: {
     stepStart: llmEventTagged.guards["step-start"],
     textStart: llmEventTagged.guards["text-start"],
@@ -288,7 +277,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
     toolError: llmEventTagged.guards["tool-error"],
     stepFinish: llmEventTagged.guards["step-finish"],
     finish: llmEventTagged.guards.finish,
-    providerError: llmEventTagged.guards["provider-error"],
   },
 })
 export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
@@ -374,13 +362,6 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
       finishReason: event.reason,
     }
   }
-  if (LLMEvent.is.providerError(event)) {
-    return {
-      ...state,
-      events,
-      finishReason: state.finishReason ?? "error",
-    }
-  }
   return {
     ...state,
     events,
@@ -589,7 +570,7 @@ export namespace LLMResponse {
   /** Purely fold one provider-neutral event into the attempt assembly state. */
   export const reduce = reduceResponseState
 
-  /** Return a completed response only after a terminal finish or provider error. */
+  /** Return a completed response only after a terminal finish event. */
   export const complete = (state: State): LLMResponse | undefined =>
     state.finishReason === undefined
       ? undefined

+ 31 - 35
packages/llm/test/executor.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer, Ref } from "effect"
 import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
-import { LLM, LLMError } from "../src"
+import { LLM, isLLMError, type LLMError } from "../src"
 import { LLMClient, RequestExecutor } from "../src/route"
 import * as OpenAIChat from "../src/protocols/openai-chat"
 import { dynamicResponse } from "./lib/http"
@@ -59,12 +59,12 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
   )
 
 const expectLLMError = (error: unknown) => {
-  expect(error).toBeInstanceOf(LLMError)
-  if (!(error instanceof LLMError)) throw new Error("expected LLMError")
+  expect(isLLMError(error)).toBe(true)
+  if (!isLLMError(error)) throw new Error("expected LLMError")
   return error
 }
 
-const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined)
+const errorHttp = (error: LLMError) => ("http" in error ? error.http : undefined)
 
 describe("RequestExecutor", () => {
   it.effect("classifies context overflow responses", () =>
@@ -73,7 +73,7 @@ describe("RequestExecutor", () => {
       const error = yield* executor.execute(request).pipe(Effect.flip)
 
       expectLLMError(error)
-      expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
+      expect(error).toMatchObject({ _tag: "LLM.ContextOverflow" })
     }).pipe(
       Effect.provide(
         responsesLayer([
@@ -91,8 +91,7 @@ describe("RequestExecutor", () => {
       const error = yield* executor.execute(request).pipe(Effect.flip)
 
       expectLLMError(error)
-      expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
-      expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
+      expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
     }).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
   )
 
@@ -102,8 +101,7 @@ describe("RequestExecutor", () => {
       const error = yield* executor.execute(request).pipe(Effect.flip)
 
       expectLLMError(error)
-      expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
-      expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
+      expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
     }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
   )
 
@@ -114,24 +112,22 @@ describe("RequestExecutor", () => {
 
       expectLLMError(error)
       expect(error).toMatchObject({
-        reason: {
-          _tag: "RateLimit",
-          retryAfterMs: 0,
-          rateLimit: { retryAfterMs: 0 },
-          http: {
-            requestId: "req_123",
-            request: {
-              method: "POST",
-              url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
-              headers: { authorization: "<redacted>", "x-safe": "visible" },
-            },
-            response: {
-              status: 429,
-              headers: {
-                "retry-after-ms": "0",
-                "x-request-id": "req_123",
-                "x-api-key": "<redacted>",
-              },
+        _tag: "LLM.RateLimit",
+        retryAfterMs: 0,
+        rateLimit: { retryAfterMs: 0 },
+        http: {
+          requestId: "req_123",
+          request: {
+            method: "POST",
+            url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
+            headers: { authorization: "<redacted>", "x-safe": "visible" },
+          },
+          response: {
+            status: 429,
+            headers: {
+              "retry-after-ms": "0",
+              "x-request-id": "req_123",
+              "x-api-key": "<redacted>",
             },
           },
         },
@@ -169,8 +165,8 @@ describe("RequestExecutor", () => {
       const error = yield* executor.execute(request).pipe(Effect.flip)
 
       expectLLMError(error)
-      expect(error.reason).toMatchObject({ _tag: "RateLimit" })
-      expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
+      expect(error).toMatchObject({ _tag: "LLM.RateLimit" })
+      expect(error._tag === "LLM.RateLimit" ? error.rateLimit : undefined).toEqual({
         retryAfterMs: 0,
         limit: { requests: "500", tokens: "30000" },
         remaining: { requests: "499", tokens: "29900" },
@@ -202,7 +198,7 @@ describe("RequestExecutor", () => {
       const error = yield* executor.execute(request).pipe(Effect.flip)
 
       expectLLMError(error)
-      expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
+      expect(error).toMatchObject({ _tag: "LLM.ServerError" })
       expect(errorHttp(error)?.rateLimit).toEqual({
         retryAfterMs: 0,
         limit: { requests: "100", "input-tokens": "10000" },
@@ -245,12 +241,12 @@ describe("RequestExecutor", () => {
       )
 
       expectLLMError(error)
-      expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
+      expect(error).toMatchObject({ _tag: "LLM.ServerError", status: 503 })
       expect(yield* Ref.get(attempts)).toBe(1)
     }),
   )
 
-  it.effect("marks 504 and 529 status responses as provider-internal", () =>
+  it.effect("marks 504 and 529 status responses as server errors", () =>
     Effect.gen(function* () {
       const failWith = (status: number) =>
         Effect.gen(function* () {
@@ -258,7 +254,7 @@ describe("RequestExecutor", () => {
           const error = yield* executor.execute(request).pipe(Effect.flip)
 
           expectLLMError(error)
-          expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
+          expect(error).toMatchObject({ _tag: "LLM.ServerError", status })
         }).pipe(
           Effect.provide(
             responsesLayer([
@@ -281,7 +277,7 @@ describe("RequestExecutor", () => {
       const error = yield* executor.execute(request).pipe(Effect.flip)
 
       expectLLMError(error)
-      expect(error.reason).toMatchObject({ _tag: "Authentication" })
+      expect(error).toMatchObject({ _tag: "LLM.Authentication" })
       expect(errorHttp(error)?.bodyTruncated).toBe(true)
       expect(errorHttp(error)?.body).toHaveLength(16_384)
     }).pipe(
@@ -360,7 +356,7 @@ describe("RequestExecutor", () => {
       )
 
       expectLLMError(error)
-      expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
+      expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
       expect(yield* Ref.get(attempts)).toBe(1)
     }),
   )

+ 2 - 2
packages/llm/test/prepare.test.ts

@@ -149,8 +149,8 @@ describe("request option precedence", () => {
         }),
       ).pipe(Effect.flip)
 
-      expect(error.reason).toMatchObject({
-        _tag: "InvalidRequest",
+      expect(error).toMatchObject({
+        _tag: "LLM.BadRequest",
         message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
       })
     }),

+ 15 - 8
packages/llm/test/provider/anthropic-messages.recorded.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
-import { LLM, LLMError, Message, ToolCallPart } from "../../src"
+import { isLLMError, LLM, Message, ToolCallPart } from "../../src"
 import { LLMClient } from "../../src/route"
 import * as Anthropic from "../../src/providers/anthropic"
 import { weatherToolName } from "../recorded-scenarios"
@@ -22,6 +22,9 @@ const malformedToolOrderRequest = LLM.request({
     Message.user("Use that result to answer briefly."),
   ],
   tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }],
+  // The cassette predates the `cache: "auto"` default; pin the policy off so
+  // the replayed request matches the recorded wire shape.
+  cache: "none",
 })
 
 const recorded = recordedTests({
@@ -33,13 +36,17 @@ const recorded = recordedTests({
 })
 
 describe("Anthropic Messages sad-path recorded", () => {
-  recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () =>
-    Effect.gen(function* () {
-      const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
+  recorded.effect.with(
+    "rejects malformed assistant tool order",
+    // The cassette predates a test rename; keep replaying the existing recording.
+    { id: "rejects-malformed-assistant-tool-order-without-patch", tags: ["tool", "sad-path"] },
+    () =>
+      Effect.gen(function* () {
+        const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
 
-      expect(error).toBeInstanceOf(LLMError)
-      expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
-      expect(error.message).toContain("HTTP 400")
-    }),
+        expect(isLLMError(error)).toBe(true)
+        expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
+        expect(error.message).toContain("HTTP 400")
+      }),
   )
 })

+ 20 - 18
packages/llm/test/provider/anthropic-messages.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
 import { HttpClientRequest } from "effect/unstable/http"
-import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
+import { CacheHint, isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src"
 import { Auth, LLMClient } from "../../src/route"
 import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
 import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
@@ -484,23 +484,25 @@ describe("Anthropic Messages route", () => {
     }),
   )
 
-  it.effect("emits provider-error events for mid-stream provider errors", () =>
+  it.effect("fails the stream for mid-stream provider errors", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(
           fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
         ),
+        Effect.flip,
       )
 
       // Prefix the error type so consumers can distinguish overloads, rate
       // limits, and quota errors without parsing the message string.
-      expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
+      expect(isLLMError(error)).toBe(true)
+      expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error: Overloaded" })
     }),
   )
 
   it.effect("classifies prompt-too-long provider errors", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(
           fixedResponse(
             sseEvents({
@@ -509,35 +511,35 @@ describe("Anthropic Messages route", () => {
             }),
           ),
         ),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([
-        {
-          type: "provider-error",
-          message: "invalid_request_error: prompt is too long: 210000 tokens",
-          classification: "context-overflow",
-        },
-      ])
+      expect(error).toMatchObject({
+        _tag: "LLM.ContextOverflow",
+        message: "invalid_request_error: prompt is too long: 210000 tokens",
+      })
     }),
   )
 
   it.effect("falls back to error type when no message is present", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }])
+      expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error" })
     }),
   )
 
   it.effect("falls back to a stable default when error payload is absent", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }])
+      expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Anthropic Messages stream error" })
     }),
   )
 
@@ -553,8 +555,8 @@ describe("Anthropic Messages route", () => {
         Effect.flip,
       )
 
-      expect(error).toBeInstanceOf(LLMError)
-      expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
+      expect(isLLMError(error)).toBe(true)
+      expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
       expect(error.message).toContain("HTTP 400")
     }),
   )

+ 9 - 11
packages/llm/test/provider/bedrock-converse.test.ts

@@ -2,7 +2,7 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
 import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
-import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
+import { CacheHint, isLLMError, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
 import { LLMClient } from "../../src/route"
 import { AmazonBedrock } from "../../src/providers"
 import * as BedrockConverse from "../../src/protocols/bedrock-converse"
@@ -355,33 +355,31 @@ describe("Bedrock Converse route", () => {
     }),
   )
 
-  it.effect("emits provider-error for throttlingException", () =>
+  it.effect("fails the stream for throttlingException", () =>
     Effect.gen(function* () {
       const body = eventStreamBody(
         ["messageStart", { role: "assistant" }],
         ["throttlingException", { message: "Slow down" }],
       )
-      const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
+      const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
 
-      expect(response.events.find((event) => event.type === "provider-error")).toEqual({
-        type: "provider-error",
-        message: "Slow down",
-      })
+      expect(isLLMError(error)).toBe(true)
+      expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "Slow down" })
     }),
   )
 
   it.effect("classifies input-too-long validation exceptions", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(baseRequest).pipe(
+      const error = yield* LLMClient.generate(baseRequest).pipe(
         Effect.provide(
           fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
         ),
+        Effect.flip,
       )
 
-      expect(response.events.find((event) => event.type === "provider-error")).toEqual({
-        type: "provider-error",
+      expect(error).toMatchObject({
+        _tag: "LLM.ContextOverflow",
         message: "Input is too long for requested model",
-        classification: "context-overflow",
       })
     }),
   )

+ 3 - 3
packages/llm/test/provider/gemini.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
-import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
+import { isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src"
 import { Auth, LLMClient } from "../../src/route"
 import * as Gemini from "../../src/protocols/gemini"
 import { ProviderShared } from "../../src/protocols/shared"
@@ -560,8 +560,8 @@ describe("Gemini route", () => {
         Effect.flip,
       )
 
-      expect(error).toBeInstanceOf(LLMError)
-      expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
+      expect(isLLMError(error)).toBe(true)
+      expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
       expect(error.message).toContain("Invalid google/gemini stream event")
     }),
   )

+ 9 - 5
packages/llm/test/provider/openai-chat.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect } from "bun:test"
 import { Effect, Schema, Stream } from "effect"
 import { HttpClientRequest } from "effect/unstable/http"
-import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
+import { isLLMError, LLM, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
 import * as Azure from "../../src/providers/azure"
 import * as OpenAI from "../../src/providers/openai"
 import * as OpenAIChat from "../../src/protocols/openai-chat"
@@ -614,8 +614,11 @@ describe("OpenAI Chat route", () => {
       const input = LLM.updateRequest(request, {
         tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
       })
-      const events = Array.from(
-        yield* LLMClient.stream(input).pipe(Stream.runCollect, Effect.provide(fixedResponse(body))),
+      const events: LLMEvent[] = []
+      const streamError = yield* LLMClient.stream(input).pipe(
+        Stream.runForEach((event) => Effect.sync(() => events.push(event))),
+        Effect.flip,
+        Effect.provide(fixedResponse(body)),
       )
       const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
 
@@ -626,6 +629,7 @@ describe("OpenAI Chat route", () => {
         { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
       ])
       expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
+      expect(streamError.message).toContain("Provider stream ended without a terminal finish event")
       expect(error.message).toContain("Provider stream ended without a terminal finish event")
     }),
   )
@@ -662,8 +666,8 @@ describe("OpenAI Chat route", () => {
         Effect.flip,
       )
 
-      expect(error).toBeInstanceOf(LLMError)
-      expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
+      expect(isLLMError(error)).toBe(true)
+      expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
       expect(error.message).toContain("HTTP 400")
     }),
   )

+ 44 - 38
packages/llm/test/provider/openai-responses.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect } from "bun:test"
 import { ConfigProvider, Effect, Layer, Stream } from "effect"
 import { Headers, HttpClientRequest } from "effect/unstable/http"
-import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
+import { isLLMError, LLM, Message, Model, ToolCallPart, Usage } from "../../src"
 import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
 import * as Azure from "../../src/providers/azure"
 import * as OpenAI from "../../src/providers/openai"
@@ -1368,37 +1368,41 @@ describe("OpenAI Responses route", () => {
     }),
   )
 
-  it.effect("emits provider-error events for mid-stream provider errors", () =>
+  it.effect("fails the stream for mid-stream provider errors", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
+        Effect.flip,
       )
 
       // Prefix the code so consumers see the failure mode, not just the
       // sometimes-generic provider message. The bare message alone meant
       // production errors like rate limits were indistinguishable from
       // unrelated stream failures.
-      expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
+      expect(isLLMError(error)).toBe(true)
+      expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "rate_limit_exceeded: Slow down" })
     }),
   )
 
   it.effect("falls back to error code when no message is present", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
+      expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
     }),
   )
 
   it.effect("falls back to error code when message is empty", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
+      expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
     }),
   )
 
@@ -1408,7 +1412,7 @@ describe("OpenAI Responses route", () => {
   // "OpenAI Responses response failed" string, hiding the real cause.
   it.effect("surfaces response.failed details from response.error", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(
           fixedResponse(
             sseEvents({
@@ -1420,15 +1424,16 @@ describe("OpenAI Responses route", () => {
             }),
           ),
         ),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }])
+      expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "server_error: Upstream model unavailable" })
     }),
   )
 
   it.effect("surfaces response.failed code when no nested message is present", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(
           fixedResponse(
             sseEvents({
@@ -1437,9 +1442,10 @@ describe("OpenAI Responses route", () => {
             }),
           ),
         ),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
+      expect(error).toMatchObject({ _tag: "LLM.BadRequest", message: "invalid_prompt" })
     }),
   )
 
@@ -1450,7 +1456,7 @@ describe("OpenAI Responses route", () => {
       // when they bubble up an HTTP error as an SSE `error` event. Honour
       // both shapes so the user still sees the underlying cause instead
       // of the catch-all string.
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(
           fixedResponse(
             sseEvents({
@@ -1459,21 +1465,19 @@ describe("OpenAI Responses route", () => {
             }),
           ),
         ),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([
-        {
-          type: "provider-error",
-          message: "context_length_exceeded: prompt too long",
-          classification: "context-overflow",
-        },
-      ])
+      expect(error).toMatchObject({
+        _tag: "LLM.ContextOverflow",
+        message: "context_length_exceeded: prompt too long",
+      })
     }),
   )
 
   it.effect("surfaces error event details nested under error", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(
           fixedResponse(
             sseEvents({
@@ -1488,21 +1492,19 @@ describe("OpenAI Responses route", () => {
             }),
           ),
         ),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([
-        {
-          type: "provider-error",
-          message: "context_length_exceeded: prompt too long",
-          classification: "context-overflow",
-        },
-      ])
+      expect(error).toMatchObject({
+        _tag: "LLM.ContextOverflow",
+        message: "context_length_exceeded: prompt too long",
+      })
     }),
   )
 
   it.effect("accepts nullable fields in spec-compliant error events", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(
           fixedResponse(
             sseEvents({
@@ -1514,39 +1516,43 @@ describe("OpenAI Responses route", () => {
             }),
           ),
         ),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }])
+      expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Something went wrong" })
     }),
   )
 
   it.effect("falls back to a stable default when error is null", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
+      expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
     }),
   )
 
   it.effect("falls back to a stable default when both error and response are absent", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
+      expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
     }),
   )
 
   it.effect("falls back to a stable default when response.failed has no error payload", () =>
     Effect.gen(function* () {
-      const response = yield* LLMClient.generate(request).pipe(
+      const error = yield* LLMClient.generate(request).pipe(
         Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }])
+      expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses response failed" })
     }),
   )
 
@@ -1562,8 +1568,8 @@ describe("OpenAI Responses route", () => {
         Effect.flip,
       )
 
-      expect(error).toBeInstanceOf(LLMError)
-      expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
+      expect(isLLMError(error)).toBe(true)
+      expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
       expect(error.message).toContain("HTTP 400")
     }),
   )

+ 4 - 3
packages/llm/test/tool-stream.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
-import { LLMError } from "../src/schema"
+import { isLLMError } from "../src/schema"
 import { ToolStream } from "../src/protocols/utils/tool-stream"
 import { it } from "./lib/effect"
 
@@ -40,8 +40,9 @@ describe("ToolStream", () => {
     Effect.gen(function* () {
       const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
 
-      expect(error).toBeInstanceOf(LLMError)
-      if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool")
+      expect(isLLMError(error)).toBe(true)
+      if (ToolStream.isError(error))
+        expect(error).toMatchObject({ _tag: "LLM.MalformedResponse", message: "missing tool" })
     }),
   )
 

+ 0 - 3
packages/opencode/src/session/processor.ts

@@ -418,9 +418,6 @@ const layer = Layer.effect(
             return
           }
 
-          case "provider-error":
-            throw new Error(value.message)
-
           case "step-start":
             if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
             yield* session.updatePart({

+ 1 - 2
packages/opencode/test/session/processor-effect.test.ts

@@ -219,8 +219,7 @@ const fragmentFailureLLM = Layer.succeed(
         LLMEvent.reasoningDelta({ id: "reasoning-1", text: "thinking" }),
         LLMEvent.textStart({ id: "text-1" }),
         LLMEvent.textDelta({ id: "text-1", text: "partial" }),
-        LLMEvent.providerError({ message: "provider boom" }),
-      ),
+      ).pipe(Stream.concat(Stream.fail(new Error("provider boom")))),
   }),
 )
 const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]])