Bläddra i källkod

fix(llm): port provider retry classification (#36887)

Shoubhit Dash 1 månad sedan
förälder
incheckning
40fedf086e

+ 26 - 0
packages/core/test/session-runner.test.ts

@@ -3854,6 +3854,32 @@ describe("SessionRunnerLLM", () => {
     }),
   )
 
+  it.effect("does not retry eligible failures after observable output", () =>
+    Effect.gen(function* () {
+      const session = yield* setup
+      yield* admit(session, "Do not replay partial output")
+      const failure = rateLimited()
+      responseStream = Stream.fromIterable([
+        LLMEvent.stepStart({ index: 0 }),
+        LLMEvent.textStart({ id: "partial-rate-limit" }),
+        LLMEvent.textDelta({ id: "partial-rate-limit", text: "Partial" }),
+      ]).pipe(Stream.concat(Stream.fail(failure)))
+
+      expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
+      expect(requests).toHaveLength(1)
+      expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
+      expect(yield* session.context(sessionID)).toMatchObject([
+        { type: "user" },
+        {
+          type: "assistant",
+          finish: "error",
+          error: { type: "provider.rate-limit" },
+          content: [{ type: "text", text: "Partial" }],
+        },
+      ])
+    }),
+  )
+
   it.effect("stops after five total retry attempts", () =>
     Effect.gen(function* () {
       const session = yield* setup

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

@@ -5,6 +5,7 @@ import { Endpoint } from "../route/endpoint"
 import { Framing } from "../route/framing"
 import { Protocol } from "../route/protocol"
 import {
+  LLMError,
   LLMEvent,
   Usage,
   type CacheHint,
@@ -19,7 +20,7 @@ import {
   type ToolResultPart,
 } from "../schema"
 import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
-import { isContextOverflow } from "../provider-error"
+import { classifyProviderFailure } from "../provider-error"
 import * as Cache from "./utils/cache"
 import { Lifecycle } from "./utils/lifecycle"
 import { ToolSchemaProjection } from "./utils/tool-schema"
@@ -832,15 +833,12 @@ 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) =>
+  new LLMError({
+    module: ADAPTER,
+    method: "stream",
+    reason: classifyProviderFailure({ 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 +846,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 onError(event)
   return Effect.succeed<StepResult>([state, NO_EVENTS])
 }
 

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

@@ -3,6 +3,7 @@ import { Route } from "../route/client"
 import { Endpoint } from "../route/endpoint"
 import { Protocol } from "../route/protocol"
 import {
+  LLMError,
   LLMEvent,
   Usage,
   type CacheHint,
@@ -17,7 +18,7 @@ import {
   type ToolResultPart,
 } from "../schema"
 import { BedrockEventStream } from "./bedrock-event-stream"
-import { isContextOverflow } from "../provider-error"
+import { classifyProviderFailure } from "../provider-error"
 import { JsonObject, optionalArray, ProviderShared } from "./shared"
 import { BedrockAuth } from "./utils/bedrock-auth"
 import { BedrockCache } from "./utils/bedrock-cache"
@@ -586,27 +587,24 @@ 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* new LLMError({
+        module: ADAPTER,
+        method: "stream",
+        reason: classifyProviderFailure({
+          message: exception[1]?.message ?? "Bedrock Converse stream error",
+          code: exception[0],
+        }),
+      })
     }
 
     return [state, []] as const

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

@@ -5,6 +5,7 @@ import { Endpoint } from "../route/endpoint"
 import { HttpTransport, WebSocketTransport } from "../route/transport"
 import { Protocol } from "../route/protocol"
 import {
+  LLMError,
   LLMEvent,
   Usage,
   type FinishReason,
@@ -19,7 +20,7 @@ import {
   type ToolResultPart,
 } from "../schema"
 import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
-import { isContextOverflow } from "../provider-error"
+import { classifyProviderFailure } from "../provider-error"
 import { OpenAIOptions } from "./utils/openai-options"
 import { Lifecycle } from "./utils/lifecycle"
 import { ToolSchemaProjection } from "./utils/tool-schema"
@@ -606,9 +607,8 @@ 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. All three end the stream,
+// so keep this set aligned with `step` and the protocol's terminal predicate.
 const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
 
 const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
@@ -910,22 +910,13 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st
 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,
+  return new LLMError({
+    module: ADAPTER,
+    method: "stream",
+    reason: classifyProviderFailure({ message, code }),
   })
 }
 
-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))
   if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
@@ -950,8 +941,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 providerError(event, "OpenAI Responses response failed")
+  if (event.type === "error") return providerError(event, "OpenAI Responses stream error")
   return Effect.succeed<StepResult>([state, NO_EVENTS])
 }
 

+ 124 - 2
packages/llm/src/provider-error.ts

@@ -1,5 +1,18 @@
-import { Schema } from "effect"
-import { LLMError, ProviderErrorEvent } from "./schema"
+import { Option, Schema } from "effect"
+import {
+  AuthenticationReason,
+  ContentPolicyReason,
+  InvalidRequestReason,
+  LLMError,
+  ProviderErrorEvent,
+  ProviderInternalReason,
+  QuotaExceededReason,
+  RateLimitReason,
+  UnknownProviderReason,
+  type HttpContext,
+  type HttpRateLimitDetails,
+  type ProviderMetadata,
+} from "./schema"
 
 const patterns = [
   /prompt is too long/i,
@@ -31,3 +44,112 @@ 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 decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
+const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
+const SERVER_CODES = new Set([
+  "api_error",
+  "internal_error",
+  "internalserverexception",
+  "modelstreamerrorexception",
+  "overloaded_error",
+  "server_error",
+  "server_is_overloaded",
+  "serviceunavailableexception",
+])
+const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
+const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
+const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
+const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
+
+export interface ProviderFailure {
+  readonly message: string
+  readonly status?: number | undefined
+  readonly code?: string | undefined
+  readonly retryAfterMs?: number | undefined
+  readonly rateLimit?: HttpRateLimitDetails | undefined
+  readonly http?: HttpContext | undefined
+  readonly providerMetadata?: ProviderMetadata | undefined
+}
+
+// Keep HTTP failures and provider-reported stream failures on one typed path so
+// session retry policy never needs provider-specific string matching.
+export function classifyProviderFailure(input: ProviderFailure): LLMError["reason"] {
+  const body = input.http?.body ?? ""
+  const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
+    .filter((code): code is string => code !== undefined)
+    .map((code) => code.toLowerCase())
+  const text = body || input.message
+  const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
+  const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
+
+  if (
+    clientScoped &&
+    (codes.includes("context_length_exceeded") ||
+      codes.includes("model_context_window_exceeded") ||
+      isContextOverflow(text))
+  )
+    return new InvalidRequestReason({ ...common, classification: "context-overflow" })
+  if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
+  if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
+    return new QuotaExceededReason(common)
+  if (input.status === 401) return new AuthenticationReason({ ...common, kind: "invalid" })
+  if (input.status === 403) return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
+  if (codes.includes("authentication_error")) return new AuthenticationReason({ ...common, kind: "invalid" })
+  if (codes.includes("permission_error"))
+    return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
+  if (
+    codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception")
+  )
+    return new RateLimitReason({
+      ...common,
+      retryAfterMs: input.retryAfterMs,
+      rateLimit: input.rateLimit,
+    })
+  if (RATE_LIMIT_TEXT.test(text))
+    return new RateLimitReason({
+      ...common,
+      retryAfterMs: input.retryAfterMs,
+      rateLimit: input.rateLimit,
+    })
+  if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
+    return new ProviderInternalReason({
+      ...common,
+      status: input.status,
+      retryAfterMs: input.retryAfterMs,
+    })
+  if (input.status === 429) {
+    return new RateLimitReason({
+      ...common,
+      retryAfterMs: input.retryAfterMs,
+      rateLimit: input.rateLimit,
+    })
+  }
+  if (input.status !== undefined && input.status >= 500)
+    return new ProviderInternalReason({
+      ...common,
+      status: input.status,
+      retryAfterMs: input.retryAfterMs,
+    })
+  if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
+  if (
+    input.status === 400 ||
+    input.status === 404 ||
+    input.status === 409 ||
+    input.status === 413 ||
+    input.status === 422
+  )
+    return new InvalidRequestReason(common)
+  return new UnknownProviderReason({ ...common, status: input.status })
+}
+
+function providerCodes(value: string) {
+  const decoded = Option.getOrUndefined(decodeJson(value))
+  if (!isRecord(decoded)) return []
+  const error = isRecord(decoded.error) ? decoded.error : undefined
+  return [decoded.code, error?.code, error?.type].filter((value): value is string => typeof value === "string")
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null
+}

+ 2 - 63
packages/llm/src/route/executor.ts

@@ -8,21 +8,14 @@ import {
   HttpClientResponse,
 } from "effect/unstable/http"
 import {
-  AuthenticationReason,
-  ContentPolicyReason,
   HttpContext,
   HttpRateLimitDetails,
   HttpRequestDetails,
   HttpResponseDetails,
-  InvalidRequestReason,
   LLMError,
-  ProviderInternalReason,
-  QuotaExceededReason,
-  RateLimitReason,
   TransportReason,
-  UnknownProviderReason,
 } from "../schema"
-import { isContextOverflow } from "../provider-error"
+import { classifyProviderFailure } from "../provider-error"
 
 export interface Interface {
   readonly execute: (
@@ -85,8 +78,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,58 +210,6 @@ 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 statusError =
   (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
   (response: HttpClientResponse.HttpClientResponse) =>
@@ -284,7 +223,7 @@ const statusError =
       return yield* new LLMError({
         module: "RequestExecutor",
         method: "execute",
-        reason: statusReason({
+        reason: classifyProviderFailure({
           status: response.status,
           message: providerMessage(response.status, details),
           retryAfterMs: retryAfter,

+ 1 - 1
packages/llm/src/schema/errors.ts

@@ -85,7 +85,7 @@ export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.
 export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({
   _tag: Schema.tag("ProviderInternal"),
   message: Schema.String,
-  status: Schema.Number,
+  status: Schema.optional(Schema.Number),
   retryAfterMs: Schema.optional(Schema.Number),
   providerMetadata: Schema.optional(ProviderMetadata),
   http: Schema.optional(HttpContext),

+ 33 - 0
packages/llm/test/executor.test.ts

@@ -107,6 +107,39 @@ describe("RequestExecutor", () => {
     }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
   )
 
+  it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
+    Effect.gen(function* () {
+      const classify = (body: string) =>
+        Effect.gen(function* () {
+          const executor = yield* RequestExecutor.Service
+          const error = yield* executor.execute(request).pipe(Effect.flip)
+
+          expectLLMError(error)
+          expect(error.reason).toMatchObject({ _tag: "RateLimit" })
+        }).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
+
+      yield* classify("Request rate increased too quickly")
+      yield* classify('{"type":"error","error":{"type":"too_many_requests"}}')
+      yield* classify('{"type":"error","error":{"code":"rate_limit_exceeded"}}')
+    }),
+  )
+
+  it.effect("classifies provider overloads hidden behind HTTP 400", () =>
+    Effect.gen(function* () {
+      const classify = (body: string) =>
+        Effect.gen(function* () {
+          const executor = yield* RequestExecutor.Service
+          const error = yield* executor.execute(request).pipe(Effect.flip)
+
+          expectLLMError(error)
+          expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
+        }).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
+
+      yield* classify('{"code":"resource_exhausted"}')
+      yield* classify('{"code":"service_unavailable"}')
+    }),
+  )
+
   it.effect("returns redacted diagnostics for rate limits", () =>
     Effect.gen(function* () {
       const executor = yield* RequestExecutor.Service

+ 46 - 0
packages/llm/test/provider-error.test.ts

@@ -1,8 +1,54 @@
 import { describe, expect, test } from "bun:test"
 import { isContextOverflow } from "../src"
+import { classifyProviderFailure } from "../src/provider-error"
 
 describe("provider error classification", () => {
   test("classifies Z.AI GLM token limit messages as context overflow", () => {
     expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
   })
+
+  test("classifies V1 plain-text rate limit fallbacks", () => {
+    expect(
+      [
+        "Request rate increased too quickly",
+        "Rate limit exceeded, please try again later",
+        "Too many requests, please slow down",
+      ].map((message) => classifyProviderFailure({ message })._tag),
+    ).toEqual(["RateLimit", "RateLimit", "RateLimit"])
+  })
+
+  test("classifies V1 JSON rate limit fallbacks", () => {
+    expect(
+      [
+        '{"type":"error","error":{"type":"too_many_requests"}}',
+        '{"type":"error","error":{"code":"rate_limit_exceeded"}}',
+        '{"code":"bad_request","error":{"code":"rate_limit_exceeded"}}',
+        '{"type":"error","error":{"code":"unknown","type":"too_many_requests"}}',
+      ].map((message) => classifyProviderFailure({ message })._tag),
+    ).toEqual(["RateLimit", "RateLimit", "RateLimit", "RateLimit"])
+  })
+
+  test("classifies V1 overloaded provider codes", () => {
+    expect(
+      ['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
+        (message) => classifyProviderFailure({ message })._tag,
+      ),
+    ).toEqual(["ProviderInternal", "ProviderInternal"])
+  })
+
+  test("classifies nested provider codes when a top-level code is also present", () => {
+    expect(
+      [
+        '{"code":"bad_request","error":{"code":"usage_not_included"}}',
+        '{"code":"bad_request","error":{"code":"server_error"}}',
+        '{"code":"bad_request","error":{"type":"invalid_request_error"}}',
+      ].map((message) => classifyProviderFailure({ message })._tag),
+    ).toEqual(["QuotaExceeded", "ProviderInternal", "InvalidRequest"])
+  })
+
+  test("keeps unknown and malformed provider payloads non-retryable", () => {
+    expect(classifyProviderFailure({ message: '{"error":{"message":"no_kv_space"}}' })._tag).toBe("UnknownProvider")
+    expect(classifyProviderFailure({ message: '{"type":"error","error":{"code":123}}' })._tag).toBe("UnknownProvider")
+    expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
+  })
 })

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

@@ -484,23 +484,22 @@ describe("Anthropic Messages route", () => {
     }),
   )
 
-  it.effect("emits provider-error events for mid-stream provider errors", () =>
+  it.effect("fails with a typed provider error for stream error frames", () =>
     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(error.reason).toMatchObject({ _tag: "ProviderInternal", 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 +508,36 @@ 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.reason).toMatchObject({
+        _tag: "InvalidRequest",
+        message: "invalid_request_error: prompt is too long: 210000 tokens",
+        classification: "context-overflow",
+      })
     }),
   )
 
   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.reason).toMatchObject({ _tag: "ProviderInternal", 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.reason).toMatchObject({ _tag: "UnknownProvider", message: "Anthropic Messages stream error" })
     }),
   )
 

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

@@ -355,31 +355,29 @@ describe("Bedrock Converse route", () => {
     }),
   )
 
-  it.effect("emits provider-error for throttlingException", () =>
+  it.effect("classifies throttlingException as a rate limit", () =>
     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(error.reason).toMatchObject({ _tag: "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.reason).toMatchObject({
+        _tag: "InvalidRequest",
         message: "Input is too long for requested model",
         classification: "context-overflow",
       })

+ 46 - 39
packages/llm/test/provider/openai-responses.test.ts

@@ -1368,37 +1368,37 @@ describe("OpenAI Responses route", () => {
     }),
   )
 
-  it.effect("emits provider-error events for mid-stream provider errors", () =>
+  it.effect("fails with a typed rate limit for provider error frames", () =>
     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(error).toBeInstanceOf(LLMError)
+      expect(error.reason).toMatchObject({ _tag: "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.reason).toMatchObject({ _tag: "ProviderInternal", 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.reason).toMatchObject({ _tag: "ProviderInternal", message: "internal_error" })
     }),
   )
 
@@ -1408,7 +1408,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 +1420,19 @@ describe("OpenAI Responses route", () => {
             }),
           ),
         ),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }])
+      expect(error.reason).toMatchObject({
+        _tag: "ProviderInternal",
+        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 +1441,10 @@ describe("OpenAI Responses route", () => {
             }),
           ),
         ),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
+      expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "invalid_prompt" })
     }),
   )
 
@@ -1450,7 +1455,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 +1464,20 @@ 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.reason).toMatchObject({
+        _tag: "InvalidRequest",
+        message: "context_length_exceeded: prompt too long",
+        classification: "context-overflow",
+      })
     }),
   )
 
   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,20 @@ 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.reason).toMatchObject({
+        _tag: "InvalidRequest",
+        message: "context_length_exceeded: prompt too long",
+        classification: "context-overflow",
+      })
     }),
   )
 
   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 +1517,43 @@ describe("OpenAI Responses route", () => {
             }),
           ),
         ),
+        Effect.flip,
       )
 
-      expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }])
+      expect(error.reason).toMatchObject({ _tag: "UnknownProvider", 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.reason).toMatchObject({ _tag: "UnknownProvider", 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.reason).toMatchObject({ _tag: "UnknownProvider", 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.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" })
     }),
   )