diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index ed1c1b3e212..c457c8b294c 100644 --- a/packages/core/test/session-runner.test.ts +++ b/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 diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 6117cd5c202..d61ae5c7bd8 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/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([state, NO_EVENTS]) } diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 4984e323658..4ac3eeaf680 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/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 diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index c6d621f1523..d03cb003ffd 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/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] 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([state, NO_EVENTS]) } diff --git a/packages/llm/src/provider-error.ts b/packages/llm/src/provider-error.ts index 321bd7927ef..bec21ac8b27 100644 --- a/packages/llm/src/provider-error.ts +++ b/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 { + return typeof value === "object" && value !== null +} diff --git a/packages/llm/src/route/executor.ts b/packages/llm/src/route/executor.ts index 0f7a9491686..ab97bdaba89 100644 --- a/packages/llm/src/route/executor.ts +++ b/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) => { ) } -const providerInternalStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529 - const retryAfterMs = (headers: Record) => { 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) => (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, diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts index 3592ea729f7..82acb7cb788 100644 --- a/packages/llm/src/schema/errors.ts +++ b/packages/llm/src/schema/errors.ts @@ -85,7 +85,7 @@ export class ContentPolicyReason extends Schema.Class("LLM. export class ProviderInternalReason extends Schema.Class("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), diff --git a/packages/llm/test/executor.test.ts b/packages/llm/test/executor.test.ts index 1227dbbf969..2a0056896ff 100644 --- a/packages/llm/test/executor.test.ts +++ b/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 diff --git a/packages/llm/test/provider-error.test.ts b/packages/llm/test/provider-error.test.ts index 3622c894541..cfad7a87262 100644 --- a/packages/llm/test/provider-error.test.ts +++ b/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") + }) }) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 14a35a4fa76..dcb20aa0ae5 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/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" }) }), ) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 87186949aea..c7b519d3682 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/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", }) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 421617b6db9..75483406903 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/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" }) }), )