ソースを参照

refactor(llm): remove the provider-error stream event

Delete the provider-error LLMEvent so streams carry output only and
every provider-reported failure exits through the typed error channel.
Anthropic SSE error events, OpenAI Responses response.failed/error
events, and Bedrock exception frames now fail the stream with an
LLMError from the shared classifier (carrying the provider code, e.g.
rate_limit_exceeded -> LLM.RateLimit).

Core follows: the publisher drops its provider-error case, the runner
drops held-back overflow events (overflow recovery keys off a thrown
LLM.ContextOverflow), compaction and title stop scanning events for
failures, and isContextOverflowFailure is deleted with its last
consumer. V1 packages/opencode gets minimal compile fixes only (dead
switch case, one test stream).
Aiden Cline 1 ヶ月 前
コミット
5d87c7ad1f

+ 0 - 5
packages/core/src/session/compaction.ts

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

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

@@ -1,15 +1,6 @@
 export * as SessionRunnerLLM from "./llm"
 
-import {
-  LLM,
-  LLMClient,
-  LLMEvent,
-  Message,
-  SystemPart,
-  isContextOverflowFailure,
-  isLLMError,
-  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"
@@ -227,17 +218,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) {
@@ -317,22 +301,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 !== undefined && isLLMError(streamFailure) ? 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 (
@@ -349,7 +332,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

@@ -438,10 +438,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
     }
   })
 

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

@@ -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.catchIf(isLLMError, () => Effect.succeed(false)),
       )
-    if (!streamed || failed) return
+    if (!streamed) return
     const title = chunks
       .join("")
       .split("\n")

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

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import {
+  APIError,
   BadRequest,
   ConnectionError,
   ContextOverflow,
@@ -70,8 +71,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
@@ -94,9 +96,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(
@@ -486,6 +491,11 @@ const setup = Effect.gen(function* () {
 
 const providerUnavailable = () => new ConnectionError({ message: "Provider unavailable" })
 
+const contextOverflow = () => new ContextOverflow({ message: "prompt too long" })
+
+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 })
@@ -1744,14 +1754,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" },
       })
     }),
   )
@@ -1861,7 +1871,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))
@@ -1884,10 +1894,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"),
       ]
@@ -1914,7 +1921,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"),
       ]
@@ -1934,7 +1941,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"),
       ]
@@ -1952,10 +1959,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")
@@ -1971,7 +1975,7 @@ describe("SessionRunnerLLM", () => {
   it.effect("recovers once from a raw context overflow failure", () =>
     Effect.gen(function* () {
       const session = yield* setupOverflowRecovery
-      responseStream = Stream.fail(new ContextOverflow({ message: "prompt too long" }))
+      responseStream = Stream.fail(contextOverflow())
       responses = [
         reply.text("## Objective\n- Recover raw overflow", "text-summary"),
         reply.text("Recovered", "text-final"),
@@ -1990,10 +1994,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")
 
@@ -2004,7 +2005,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([
@@ -2018,10 +2019,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
@@ -3604,7 +3602,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")
 
@@ -3621,7 +3622,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")
 
@@ -3709,13 +3710,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)
@@ -3908,11 +3911,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)
@@ -3939,11 +3944,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")
 
@@ -3970,11 +3974,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")
 

+ 1 - 1
packages/llm/AGENTS.md

@@ -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 { classifyApiFailure, isContextOverflow, isContextOverflowFailure, type ApiFailure } from "./provider-error"
+export { classifyApiFailure, isContextOverflow, type ApiFailure } from "./provider-error"
 export type {
   RouteModelInput,
   RouteRoutedModelInput,

+ 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])
 }
 

+ 0 - 8
packages/llm/src/provider-error.ts

@@ -1,4 +1,3 @@
-import { Schema } from "effect"
 import {
   APIError,
   Authentication,
@@ -9,12 +8,10 @@ import {
   HttpRateLimitDetails,
   NotFound,
   PermissionDenied,
-  ProviderErrorEvent,
   ProviderMetadata,
   QuotaExceeded,
   RateLimit,
   ServerError,
-  isLLMError,
   type LLMError,
 } from "./schema"
 
@@ -44,11 +41,6 @@ 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) =>
-  isLLMError(failure)
-    ? failure._tag === "LLM.ContextOverflow"
-    : 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

+ 0 - 3
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,

+ 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

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

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

+ 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",
       })
     }),
   )

+ 41 - 35
packages/llm/test/provider/openai-responses.test.ts

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

+ 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]])