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).
This commit is contained in:
Aiden Cline 2026-07-13 10:49:43 -05:00
commit 5d87c7ad1f
18 changed files with 167 additions and 240 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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