diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 3330c77f4a87..3efd037a5a6d 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -15,20 +15,43 @@ import type { SharedV3ProviderOptions, } from "@ai-sdk/provider" import { + APIError, + Authentication, + BadRequest, + ConnectionError, FinishReason, - InvalidProviderOutputReason, + HttpContext, + HttpRequestDetails, + HttpResponseDetails, LLMEvent, - LLMError, + MalformedResponse, Model, + NotFound, ProviderID, ProviderMetadata, ToolResultValue, - UnknownProviderReason, + classifyApiFailure, + isLLMError, + type LLMError, type ContentPart, type LLMRequest, type ToolDefinition, type UsageInput, } from "@opencode-ai/llm" +import { + APICallError, + EmptyResponseBodyError, + InvalidArgumentError, + InvalidPromptError, + InvalidResponseDataError, + JSONParseError, + LoadAPIKeyError, + LoadSettingError, + NoContentGeneratedError, + NoSuchModelError, + TypeValidationError, + UnsupportedFunctionalityError, +} from "@ai-sdk/provider" import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route" import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect" import { ModelV2 } from "./model" @@ -490,12 +513,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO Stream.unwrap( Effect.tryPromise({ try: () => language.doStream(options), - catch: (error) => llmError("doStream", error), + catch: (error) => llmError(error), }).pipe( Effect.map((result) => Stream.fromReadableStream({ evaluate: () => result.stream, - onError: (error) => llmError("readStream", error), + onError: (error) => llmError(error), }).pipe( Stream.mapEffect((event) => streamPartEvents(state, event)), Stream.flatMap((events) => Stream.fromIterable(events)), @@ -608,7 +631,7 @@ function streamPartEvents( }), ]) case "error": - return Effect.fail(llmError("stream", event.error)) + return Effect.fail(llmError(event.error)) } } @@ -666,16 +689,65 @@ function messageValue(input: unknown) { } } -function llmError(method: string, error: unknown) { - const reason = - error instanceof LLMError - ? new InvalidProviderOutputReason({ message: error.message }) - : new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) }) - return new LLMError({ - module: "AISDK", - method, - reason, - }) +const BODY_LIMIT = 16_384 + +const headerRetryAfterMs = (headers: Record | undefined) => { + if (!headers) return undefined + const millis = Number(headers["retry-after-ms"]) + if (Number.isFinite(millis)) return Math.max(0, millis) + const value = headers["retry-after"] + if (!value) return undefined + const seconds = Number(value) + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000) + const date = Date.parse(value) + if (!Number.isNaN(date)) return Math.max(0, date - Date.now()) + return undefined +} + +// Classify AI SDK failures into the shared `LLMError` union so the synthetic +// AI SDK route reports failures identically to native protocol routes. An +// `APICallError` without a status code is the AI SDK's representation of a +// network-level failure (connect refused, reset, DNS), not an API rejection. +function llmError(error: unknown): LLMError { + if (isLLMError(error)) return error + if (APICallError.isInstance(error)) { + if (error.statusCode === undefined) { + return new ConnectionError({ message: error.message, url: error.url, cause: error }) + } + return classifyApiFailure({ + message: error.message, + status: error.statusCode, + retryAfterMs: headerRetryAfterMs(error.responseHeaders), + requestID: error.responseHeaders?.["x-request-id"] ?? error.responseHeaders?.["request-id"], + http: new HttpContext({ + request: new HttpRequestDetails({ method: "POST", url: error.url, headers: {} }), + response: new HttpResponseDetails({ status: error.statusCode, headers: error.responseHeaders ?? {} }), + body: error.responseBody === undefined ? undefined : error.responseBody.slice(0, BODY_LIMIT), + bodyTruncated: error.responseBody !== undefined && error.responseBody.length > BODY_LIMIT ? true : undefined, + }), + }) + } + if (LoadAPIKeyError.isInstance(error) || LoadSettingError.isInstance(error)) { + return new Authentication({ message: error.message }) + } + if (NoSuchModelError.isInstance(error)) return new NotFound({ message: error.message }) + if ( + InvalidPromptError.isInstance(error) || + InvalidArgumentError.isInstance(error) || + UnsupportedFunctionalityError.isInstance(error) + ) { + return new BadRequest({ message: error.message }) + } + if ( + InvalidResponseDataError.isInstance(error) || + JSONParseError.isInstance(error) || + TypeValidationError.isInstance(error) || + EmptyResponseBodyError.isInstance(error) || + NoContentGeneratedError.isInstance(error) + ) { + return new MalformedResponse({ message: error.message }) + } + return new APIError({ message: error instanceof Error ? error.message : String(error) }) } export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] }) diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index 81c2a0f495e7..16c49fc6cbfd 100644 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -1,6 +1,6 @@ export * as Generate from "./generate" -import { LLM, LLMClient, LLMError } from "@opencode-ai/llm" +import { LLM, LLMClient, type LLMError } from "@opencode-ai/llm" import { Context, Effect, Layer, Schema } from "effect" import { Catalog } from "./catalog" import { makeLocationNode } from "./effect/app-node" diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index d9db191b57ac..c468c9dc1af3 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -1,6 +1,6 @@ export * as SessionCompaction from "./compaction" -import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm" +import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest, type Model } from "@opencode-ai/llm" import { SessionError } from "@opencode-ai/schema/session-error" import { Context, Effect, Layer, Stream } from "effect" import { Config } from "../config" @@ -247,11 +247,6 @@ const make = (dependencies: Dependencies) => { ) .pipe( Stream.runForEach((event) => { - if (LLMEvent.is.providerError(event)) - failure = { - type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error", - message: event.message, - } if (LLMEvent.is.textDelta(event)) { chunks.push(event.text) return dependencies.events.publish(SessionEvent.Compaction.Delta, { @@ -261,7 +256,7 @@ const make = (dependencies: Dependencies) => { } return Effect.void }), - Effect.catchTag("LLM.Error", (error) => + Effect.catchIf(isLLMError, (error) => Effect.sync(() => { failure = toSessionError(error) }), diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index d18351b53e86..38718403f08c 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -1,15 +1,6 @@ export * as SessionRunnerLLM from "./llm" -import { - LLM, - LLMClient, - LLMError, - LLMEvent, - Message, - SystemPart, - isContextOverflowFailure, - type ProviderErrorEvent, -} from "@opencode-ai/llm" +import { LLM, LLMClient, LLMEvent, Message, SystemPart, isLLMError, type LLMError } from "@opencode-ai/llm" import { SessionError } from "@opencode-ai/schema/session-error" import { Money } from "@opencode-ai/schema/money" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" @@ -227,17 +218,10 @@ const layer = Layer.effect( // mid-event. const serialized = (effect: Effect.Effect) => 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 instanceof LLMError ? streamFailure : undefined + // A thrown LLM failure records the assistant failure unless a provider failure + // was already recorded from the stream. Terminal publication waits for owned tools. if (llmFailure && !publisher.hasProviderError()) { const error = toSessionError(llmFailure) if ( @@ -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 diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 643228e49d51..48a590428c9c 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -438,10 +438,6 @@ export const createLLMEventPublisher = (events: Pick {} export function isRetryable(error: LLMError) { - switch (error.reason._tag) { - case "RateLimit": - case "ProviderInternal": - case "Transport": + switch (error._tag) { + case "LLM.RateLimit": + case "LLM.ServerError": + case "LLM.ConnectionError": + case "LLM.TimeoutError": return true - case "Authentication": - case "QuotaExceeded": - case "ContentPolicy": - case "InvalidProviderOutput": - case "InvalidRequest": - case "NoRoute": - case "UnknownProvider": + case "LLM.Authentication": + case "LLM.PermissionDenied": + case "LLM.NotFound": + case "LLM.QuotaExceeded": + case "LLM.ContentPolicy": + case "LLM.ContextOverflow": + case "LLM.MalformedResponse": + case "LLM.BadRequest": + case "LLM.NoRoute": + case "LLM.APIError": return false default: { - const exhaustive: never = error.reason + const exhaustive: never = error return exhaustive } } } const retryAfter = (failure: RetryableFailure) => { - if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal") - return failure.cause.reason.retryAfterMs + if (failure.cause._tag === "LLM.RateLimit" || failure.cause._tag === "LLM.ServerError") + return failure.cause.retryAfterMs return undefined } diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index 8e7b5857b7f1..25a7e0d36f0c 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -1,6 +1,6 @@ export * as SessionTitle from "./title" -import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm" +import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest } from "@opencode-ai/llm" import { Context, DateTime, Effect, Layer, Stream } from "effect" import { AgentV2 } from "../agent" import { Database } from "../database/database" @@ -49,7 +49,6 @@ const make = (dependencies: Dependencies) => { ).pipe(Effect.catch(() => Effect.succeed(undefined))) if (!resolved) return const chunks: string[] = [] - let failed = false const streamed = yield* dependencies.llm .stream( LLM.request({ @@ -61,14 +60,13 @@ const make = (dependencies: Dependencies) => { ) .pipe( Stream.runForEach((event) => { - if (LLMEvent.is.providerError(event)) failed = true if (LLMEvent.is.textDelta(event)) chunks.push(event.text) return Effect.void }), Effect.as(true), - Effect.catchTag("LLM.Error", () => Effect.succeed(false)), + Effect.catchIf(isLLMError, () => Effect.succeed(false)), ) - if (!streamed || failed) return + if (!streamed) return const title = chunks .join("") .split("\n") diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index 6d3d521fb7ca..2dfc15d3176d 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -1,4 +1,4 @@ -import { LLMError, ToolFailure } from "@opencode-ai/llm" +import { isLLMError, ToolFailure } from "@opencode-ai/llm" import { Tool } from "@opencode-ai/plugin/v2/effect/tool" import { SessionError } from "@opencode-ai/schema/session-error" import { PermissionV2 } from "../permission" @@ -9,30 +9,38 @@ import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./err import { SessionRunnerModel } from "./runner/model" export function toSessionError(cause: unknown): SessionError.Error { - if (cause instanceof LLMError) { - switch (cause.reason._tag) { - case "RateLimit": - return { type: "provider.rate-limit", message: cause.reason.message } - case "Authentication": - return { type: "provider.auth", message: cause.reason.message } - case "QuotaExceeded": - return { type: "provider.quota", message: cause.reason.message } - case "ContentPolicy": - return { type: "provider.content-filter", message: cause.reason.message } - case "Transport": - return { type: "provider.transport", message: cause.reason.message } - case "ProviderInternal": - return { type: "provider.internal", message: cause.reason.message } - case "InvalidProviderOutput": - return { type: "provider.invalid-output", message: cause.reason.message } - case "InvalidRequest": - return { type: "provider.invalid-request", message: cause.reason.message } - case "NoRoute": - return { type: "provider.no-route", message: cause.reason.message } - case "UnknownProvider": - return { type: "provider.unknown", message: cause.reason.message } + if (isLLMError(cause)) { + switch (cause._tag) { + case "LLM.RateLimit": + return { type: "provider.rate-limit", message: cause.message } + case "LLM.Authentication": + return { type: "provider.auth", message: cause.message } + case "LLM.PermissionDenied": + return { type: "provider.auth", message: cause.message } + case "LLM.NotFound": + return { type: "provider.not-found", message: cause.message } + case "LLM.QuotaExceeded": + return { type: "provider.quota", message: cause.message } + case "LLM.ContentPolicy": + return { type: "provider.content-filter", message: cause.message } + case "LLM.ContextOverflow": + return { type: "provider.context-overflow", message: cause.message } + case "LLM.ConnectionError": + return { type: "provider.transport", message: cause.message } + case "LLM.TimeoutError": + return { type: "provider.timeout", message: cause.message } + case "LLM.ServerError": + return { type: "provider.internal", message: cause.message } + case "LLM.MalformedResponse": + return { type: "provider.invalid-output", message: cause.message } + case "LLM.BadRequest": + return { type: "provider.invalid-request", message: cause.message } + case "LLM.NoRoute": + return { type: "provider.no-route", message: cause.message } + case "LLM.APIError": + return { type: "provider.unknown", message: cause.message } default: { - const exhaustive: never = cause.reason + const exhaustive: never = cause return exhaustive } } diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 46bdb09e7040..f7e5c7b6f005 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -1,18 +1,22 @@ import { describe, expect, test } from "bun:test" import { - AuthenticationReason, - ContentPolicyReason, - InvalidProviderOutputReason, - InvalidRequestReason, - LLMError, - NoRouteReason, + APIError, + Authentication, + BadRequest, + ConnectionError, + ContentPolicy, + ContextOverflow, + MalformedResponse, ModelID, + NoRoute, + NotFound, + PermissionDenied, ProviderID, - ProviderInternalReason, - QuotaExceededReason, - RateLimitReason, - TransportReason, - UnknownProviderReason, + QuotaExceeded, + RateLimit, + RouteID, + ServerError, + TimeoutError, ToolFailure, } from "@opencode-ai/llm" import { PermissionV2 } from "@opencode-ai/core/permission" @@ -20,39 +24,33 @@ import { Tool } from "@opencode-ai/plugin/v2/effect/tool" import { toSessionError } from "@opencode-ai/core/session/to-session-error" import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry" -const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason }) - describe("toSessionError", () => { - test("maps every LLM reason to the open wire type", () => { - expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({ + test("maps every LLM error tag to the open wire type", () => { + expect(toSessionError(new RateLimit({ message: "rate", retryAfterMs: 123 }))).toEqual({ type: "provider.rate-limit", message: "rate", }) - expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe( - "provider.auth", - ) - expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota") - expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter") - expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport") - expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe( - "provider.internal", - ) - expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe( - "provider.invalid-output", - ) - expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request") + expect(toSessionError(new Authentication({ message: "auth" })).type).toBe("provider.auth") + expect(toSessionError(new PermissionDenied({ message: "forbidden" })).type).toBe("provider.auth") + expect(toSessionError(new NotFound({ message: "missing" })).type).toBe("provider.not-found") + expect(toSessionError(new QuotaExceeded({ message: "quota" })).type).toBe("provider.quota") + expect(toSessionError(new ContentPolicy({ message: "blocked" })).type).toBe("provider.content-filter") + expect(toSessionError(new ContextOverflow({ message: "too long" })).type).toBe("provider.context-overflow") + expect(toSessionError(new ConnectionError({ message: "reset" })).type).toBe("provider.transport") + expect(toSessionError(new TimeoutError({ message: "timed out" })).type).toBe("provider.timeout") + expect(toSessionError(new ServerError({ message: "internal", status: 500 })).type).toBe("provider.internal") + expect(toSessionError(new MalformedResponse({ message: "output" })).type).toBe("provider.invalid-output") + expect(toSessionError(new BadRequest({ message: "request" })).type).toBe("provider.invalid-request") expect( toSessionError( - llm( - new NoRouteReason({ - route: "route", - provider: ProviderID.make("provider"), - model: ModelID.make("model"), - }), - ), + new NoRoute({ + route: RouteID.make("route"), + provider: ProviderID.make("provider"), + model: ModelID.make("model"), + }), ).type, ).toBe("provider.no-route") - expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown") + expect(toSessionError(new APIError({ message: "unknown", status: 418 })).type).toBe("provider.unknown") }) test("preserves the permission rejection type without exposing internal fields", () => { @@ -71,23 +69,31 @@ describe("toSessionError", () => { }) }) - test("retries only rate limits, provider-internal failures, and transport failures", () => { + test("retries only rate limits, server errors, connection failures, and timeouts", () => { const eligible = [ - llm(new RateLimitReason({ message: "rate" })), - llm(new ProviderInternalReason({ message: "internal", status: 500 })), - llm(new TransportReason({ message: "transport" })), + new RateLimit({ message: "rate" }), + new ServerError({ message: "internal", status: 500 }), + new ConnectionError({ message: "reset" }), + new TimeoutError({ message: "timed out" }), ] const ineligible = [ - llm(new AuthenticationReason({ message: "auth", kind: "invalid" })), - llm(new QuotaExceededReason({ message: "quota" })), - llm(new ContentPolicyReason({ message: "blocked" })), - llm(new InvalidProviderOutputReason({ message: "output" })), - llm(new InvalidRequestReason({ message: "request" })), - llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })), - llm(new UnknownProviderReason({ message: "unknown" })), + new Authentication({ message: "auth" }), + new PermissionDenied({ message: "forbidden" }), + new NotFound({ message: "missing" }), + new QuotaExceeded({ message: "quota" }), + new ContentPolicy({ message: "blocked" }), + new ContextOverflow({ message: "too long" }), + new MalformedResponse({ message: "output" }), + new BadRequest({ message: "request" }), + new NoRoute({ + route: RouteID.make("route"), + provider: ProviderID.make("provider"), + model: ModelID.make("model"), + }), + new APIError({ message: "unknown" }), ] - expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true]) - expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false]) + expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true]) + expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual(ineligible.map(() => false)) }) }) diff --git a/packages/core/test/session-execution.test.ts b/packages/core/test/session-execution.test.ts index 2775df356482..6058ca81efe4 100644 --- a/packages/core/test/session-execution.test.ts +++ b/packages/core/test/session-execution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { LLMError, TransportReason } from "@opencode-ai/llm" +import { ConnectionError } from "@opencode-ai/llm" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -25,17 +25,10 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Event describe("SessionExecution lifecycle", () => { test("classifies success and typed failure terminals", () => { expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" }) - expect( - SessionExecution.terminal( - Exit.fail( - new LLMError({ - module: "test", - method: "stream", - reason: new TransportReason({ message: "Disconnected" }), - }), - ), - ), - ).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } }) + expect(SessionExecution.terminal(Exit.fail(new ConnectionError({ message: "Disconnected" })))).toEqual({ + type: "failed", + error: { type: "provider.transport", message: "Disconnected" }, + }) const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") }) expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({ type: "failed", diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 3bbd2560f054..41525367f3c9 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1,15 +1,17 @@ import { describe, expect, test } from "bun:test" import { + APIError, + BadRequest, + ConnectionError, + ContextOverflow, LLMClient, - LLMError, LLMEvent, + MalformedResponse, Model, + RateLimit, ToolFailure, - TransportReason, - InvalidProviderOutputReason, - InvalidRequestReason, - RateLimitReason, type LLMClientShape, + type LLMError, type LLMRequest, } from "@opencode-ai/llm" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" @@ -69,8 +71,9 @@ import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" const requests: LLMRequest[] = [] +type ScriptedResponse = LLMEvent[] | Stream.Stream let response: LLMEvent[] = [] -let responses: LLMEvent[][] | undefined +let responses: ScriptedResponse[] | undefined let responseStream: Stream.Stream | undefined let responseStreams: Stream.Stream[] | undefined let streamGate: Deferred.Deferred | undefined @@ -93,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( @@ -483,26 +489,16 @@ const setup = Effect.gen(function* () { return yield* SessionV2.Service }) -const providerUnavailable = () => - new LLMError({ - module: "test", - method: "stream", - reason: new TransportReason({ message: "Provider unavailable" }), - }) +const providerUnavailable = () => new ConnectionError({ message: "Provider unavailable" }) -const invalidRequest = () => - new LLMError({ - module: "test", - method: "stream", - reason: new InvalidRequestReason({ message: "Invalid request" }), - }) +const contextOverflow = () => new ContextOverflow({ message: "prompt too long" }) -const rateLimited = (retryAfterMs?: number) => - new LLMError({ - module: "test", - method: "stream", - reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }), - }) +const failingResponse = (events: LLMEvent[], failure: LLMError): Stream.Stream => + Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(failure))) + +const invalidRequest = () => new BadRequest({ message: "Invalid request" }) + +const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs }) const setupOverflowRecovery = Effect.gen(function* () { const session = yield* setup @@ -1758,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" }, }) }), ) @@ -1875,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)) @@ -1898,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"), ] @@ -1928,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"), ] @@ -1948,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"), ] @@ -1966,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") @@ -1985,16 +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 LLMError({ - module: "test", - method: "stream", - reason: new InvalidRequestReason({ - message: "prompt too long", - classification: "context-overflow", - }), - }), - ) + responseStream = Stream.fail(contextOverflow()) responses = [ reply.text("## Objective\n- Recover raw overflow", "text-summary"), reply.text("Recovered", "text-final"), @@ -2013,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") @@ -2027,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([ @@ -2041,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() const summaryGate = yield* Deferred.make() streamGate = firstGate @@ -3627,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") @@ -3644,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") @@ -3732,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) @@ -3892,11 +3872,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Call a malformed tool") - const failure = new LLMError({ - module: "test", - method: "stream", - reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }), - }) + const failure = new MalformedResponse({ message: "Invalid JSON input for tool call echo" }) responseStream = Stream.fromIterable([ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }), @@ -3935,11 +3911,13 @@ describe("SessionRunnerLLM", () => { toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() 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) @@ -3966,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") @@ -3997,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") diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index e883a9e47d12..769524bff7f2 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -182,8 +182,8 @@ The dependency arrow points down: `providers/*.ts` files import protocol routes - `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field. - `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `` tool call ``" error message. Treats empty input as `{}`. - `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies. -- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures. -- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it. +- `eventError(route, message, ...)` — typed `MalformedResponse` constructor for stream-time decode failures. +- `validateWith(decoder)` — maps Schema decode errors to `BadRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it. - `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering. If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating. @@ -291,7 +291,7 @@ Use this order for every protocol module: - Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`. - Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`. -- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing. +- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event for each completed response. Provider-reported failures (SSE error events, exception frames) fail the stream with a typed `LLMError` via `classifyApiFailure` — never an ordinary event. If a provider splits reason and usage across events, merge them in parser state before flushing. - Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends. - Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly. - Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names. diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 83edee7155ef..3d78d4feff56 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -2,7 +2,7 @@ export { LLMClient } from "./route/client" export { Auth } from "./route/auth" export { Provider } from "./provider" export { ProviderPackage } from "./provider-package" -export { isContextOverflow, isContextOverflowFailure } from "./provider-error" +export { classifyApiFailure, isContextOverflow, type ApiFailure } from "./provider-error" export type { RouteModelInput, RouteRoutedModelInput, diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts index e4781d8608b0..b252dcbe99e5 100644 --- a/packages/llm/src/llm.ts +++ b/packages/llm/src/llm.ts @@ -3,8 +3,8 @@ import { LLMClient } from "./route/client" import { GenerationOptions, HttpOptions, - InvalidProviderOutputReason, - LLMError, + MalformedResponse, + type LLMError, LLMEvent, LLMRequest, LLMResponse, @@ -121,22 +121,14 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* ( (event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME, ) if (!call || !LLMEvent.is.toolCall(call)) - return yield* new LLMError({ - module: "LLM", - method: "generateObject", - reason: new InvalidProviderOutputReason({ - message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`, - }), + return yield* new MalformedResponse({ + message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`, }) const object = yield* tool._decode(call.input).pipe( Effect.mapError( (error) => - new LLMError({ - module: "LLM", - method: "generateObject", - reason: new InvalidProviderOutputReason({ - message: `generateObject: tool input failed schema decode: ${error.message}`, - }), + new MalformedResponse({ + message: `generateObject: tool input failed schema decode: ${error.message}`, }), ), ) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 6117cd5c202a..2b6ecff89cf7 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/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([state, NO_EVENTS]) } diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 4984e3236588..8e1c1ca36205 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/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 diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index c6d621f1523a..328da5bc53b3 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/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] 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([state, NO_EVENTS]) } diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 173dc511bb03..baea037ab753 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -3,9 +3,9 @@ import { Effect, Schema, Stream } from "effect" import * as Sse from "effect/unstable/encoding/Sse" import { Headers, HttpClientRequest } from "effect/unstable/http" import { - InvalidProviderOutputReason, - InvalidRequestReason, - LLMError, + BadRequest, + MalformedResponse, + type LLMError, type ContentPart, type LLMRequest, type MediaPart, @@ -88,11 +88,7 @@ export const sumTokens = (...values: ReadonlyArray): number } export const eventError = (route: string, message: string, raw?: string) => - new LLMError({ - module: "ProviderShared", - method: "stream", - reason: new InvalidProviderOutputReason({ route, message, raw }), - }) + new MalformedResponse({ route, message, raw }) export const parseJson = (route: string, input: string, message: string) => Effect.try({ @@ -252,15 +248,9 @@ export const sseFraming = (bytes: Stream.Stream): Stream.S * Canonical invalid-request constructor. Lift one-line `const invalid = * (message) => invalidRequest(message)` aliases out of every * route so the error constructor lives in one place. If we ever extend - * `InvalidRequestReason` with route context or trace metadata, the change - * lands here. + * `BadRequest` with route context or trace metadata, the change lands here. */ -export const invalidRequest = (message: string) => - new LLMError({ - module: "ProviderShared", - method: "request", - reason: new InvalidRequestReason({ message }), - }) +export const invalidRequest = (message: string) => new BadRequest({ message }) export const matchToolChoice = ( route: string, diff --git a/packages/llm/src/protocols/utils/tool-stream.ts b/packages/llm/src/protocols/utils/tool-stream.ts index 8e07a64bfed8..27f614a8acb3 100644 --- a/packages/llm/src/protocols/utils/tool-stream.ts +++ b/packages/llm/src/protocols/utils/tool-stream.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema" +import { isLLMError, LLMEvent, type LLMError, type ProviderMetadata, type ToolCall } from "../../schema" import { eventError, parseToolInput, type ToolAccumulator } from "../shared" type StreamKey = string | number @@ -95,7 +95,7 @@ const appendTool = ( } export const isError = (result: AppendOutcome | LLMError): result is LLMError => - result instanceof LLMError + isLLMError(result) /** * Register a tool call whose start event arrived before any argument deltas. diff --git a/packages/llm/src/provider-error.ts b/packages/llm/src/provider-error.ts index 321bd7927efd..2458012f4d23 100644 --- a/packages/llm/src/provider-error.ts +++ b/packages/llm/src/provider-error.ts @@ -1,5 +1,19 @@ -import { Schema } from "effect" -import { LLMError, ProviderErrorEvent } from "./schema" +import { + APIError, + Authentication, + BadRequest, + ContentPolicy, + ContextOverflow, + HttpContext, + HttpRateLimitDetails, + NotFound, + PermissionDenied, + ProviderMetadata, + QuotaExceeded, + RateLimit, + ServerError, + type LLMError, +} from "./schema" const patterns = [ /prompt is too long/i, @@ -27,7 +41,102 @@ const patterns = [ export const isContextOverflow = (message: string) => patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message) -export const isContextOverflowFailure = (failure: unknown) => - failure instanceof LLMError - ? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow" - : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" +const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"]) +const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"]) +const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i +const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i +const SERVER_ERROR_STATUS = (status: number) => status >= 500 || status === 529 + +const CODE_CLASSIFICATION: Record LLMError> = { + overloaded_error: serverError, + api_error: serverError, + server_error: serverError, + internal_error: serverError, + server_is_overloaded: serverError, + internalServerException: serverError, + serviceUnavailableException: serverError, + modelStreamErrorException: serverError, + rate_limit_error: rateLimit, + rate_limit_exceeded: rateLimit, + too_many_requests: rateLimit, + throttlingException: rateLimit, + authentication_error: (_input, common) => new Authentication(common), + permission_error: (_input, common) => new PermissionDenied(common), + not_found_error: (_input, common) => new NotFound(common), + invalid_request_error: (_input, common) => new BadRequest(common), + invalid_prompt: (_input, common) => new BadRequest(common), + validationException: (_input, common) => new BadRequest(common), +} + +export interface ApiFailure { + readonly message: string + readonly status?: number | undefined + /** Provider machine-readable error code or type string (e.g. `context_length_exceeded`, `overloaded_error`). */ + readonly code?: string | undefined + readonly retryAfterMs?: number | undefined + readonly rateLimit?: HttpRateLimitDetails | undefined + readonly requestID?: string | undefined + readonly http?: HttpContext | undefined + readonly providerMetadata?: ProviderMetadata | undefined +} + +type CommonFields = { + readonly message: string + readonly status: number | undefined + readonly code: string | undefined + readonly requestID: string | undefined + readonly http: HttpContext | undefined + readonly providerMetadata: ProviderMetadata | undefined +} + +function serverError(input: ApiFailure, common: CommonFields) { + return new ServerError({ ...common, retryAfterMs: input.retryAfterMs }) +} + +function rateLimit(input: ApiFailure, common: CommonFields) { + return new RateLimit({ ...common, retryAfterMs: input.retryAfterMs, rateLimit: input.rateLimit }) +} + +/** + * One classifier for every failure a remote API deliberately reports. + * Protocols call it with in-stream error payloads, the request executor with + * non-2xx responses, and the AI SDK adapter with `APICallError`s, so all + * three surfaces produce identical `LLMError` tags. + * + * Precedence: context overflow (most specific, 4xx-scoped), content policy, + * HTTP status, provider code, then the generic `APIError` fallback. + */ +export const classifyApiFailure = (input: ApiFailure): LLMError => { + const common: CommonFields = { + message: input.message, + status: input.status, + code: input.code, + requestID: input.requestID, + http: input.http, + providerMetadata: input.providerMetadata, + } + const body = input.http?.body ?? "" + const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500) + if ( + clientScoped && + ((input.code !== undefined && OVERFLOW_CODES.has(input.code)) || + isContextOverflow(input.message) || + (body.length > 0 && isContextOverflow(body))) + ) + return new ContextOverflow(common) + if (CONTENT_POLICY_TEXT.test(body.length > 0 ? body : input.message)) return new ContentPolicy(common) + if (input.code !== undefined && QUOTA_CODES.has(input.code)) return new QuotaExceeded(common) + if (input.status === 401) return new Authentication(common) + if (input.status === 403) return new PermissionDenied(common) + if (input.status === 404) return new NotFound(common) + if (input.status === 429) { + if (QUOTA_TEXT.test(body.length > 0 ? body : input.message)) return new QuotaExceeded(common) + return rateLimit(input, common) + } + if (input.status !== undefined && SERVER_ERROR_STATUS(input.status)) return serverError(input, common) + if (input.status === 400 || input.status === 409 || input.status === 413 || input.status === 422) + return new BadRequest(common) + const byCode = input.code === undefined ? undefined : CODE_CLASSIFICATION[input.code] + if (byCode) return byCode(input, common) + return new APIError(common) +} diff --git a/packages/llm/src/route/auth.ts b/packages/llm/src/route/auth.ts index 32871c04547e..26471f9cb1cf 100644 --- a/packages/llm/src/route/auth.ts +++ b/packages/llm/src/route/auth.ts @@ -1,6 +1,6 @@ import { Config, Effect, Redacted } from "effect" import { Headers } from "effect/unstable/http" -import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema" +import { Authentication, BadRequest, type LLMError, type LLMRequest } from "../schema" export class MissingCredentialError extends Error { readonly _tag = "MissingCredentialError" @@ -135,16 +135,9 @@ export function bearerHeader(name: string, source?: Secret | Credential) { } const toLLMError = (error: AuthError): LLMError => { - if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) { - return new LLMError({ - module: "Auth", - method: "apply", - reason: - error instanceof MissingCredentialError - ? new AuthenticationReason({ message: error.message, kind: "missing" }) - : new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }), - }) - } + if (error instanceof MissingCredentialError) return new Authentication({ message: error.message }) + if (error instanceof Config.ConfigError) + return new BadRequest({ message: `Failed to resolve auth config: ${error.message}` }) return error } diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index a258aae94487..70a2ddf049a8 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -14,11 +14,11 @@ import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions import { GenerationOptions, HttpOptions, + isLLMError, LLMRequest, LLMResponse, Model, ModelLimits, - LLMError as LLMErrorClass, PreparedRequest, ProviderID, mergeGenerationOptions, @@ -225,7 +225,7 @@ export interface MakeTransportInput { const streamError = (route: string, message: string, cause: Cause.Cause) => { const failed = cause.reasons.find(Cause.isFailReason)?.error - if (failed instanceof LLMErrorClass) return failed + if (failed !== undefined && isLLMError(failed)) return failed return ProviderShared.eventError(route, message, Cause.pretty(cause)) } diff --git a/packages/llm/src/route/executor.ts b/packages/llm/src/route/executor.ts index 0f7a94916860..ddb7214df827 100644 --- a/packages/llm/src/route/executor.ts +++ b/packages/llm/src/route/executor.ts @@ -1,4 +1,4 @@ -import { Cause, Context, Effect, Layer } from "effect" +import { Cause, Context, Effect, Layer, Option, Schema } from "effect" import { FetchHttpClient, Headers, @@ -8,21 +8,15 @@ import { HttpClientResponse, } from "effect/unstable/http" import { - AuthenticationReason, - ContentPolicyReason, + ConnectionError, HttpContext, HttpRateLimitDetails, HttpRequestDetails, HttpResponseDetails, - InvalidRequestReason, - LLMError, - ProviderInternalReason, - QuotaExceededReason, - RateLimitReason, - TransportReason, - UnknownProviderReason, + TimeoutError, + type LLMError, } from "../schema" -import { isContextOverflow } from "../provider-error" +import { classifyApiFailure } from "../provider-error" export interface Interface { readonly execute: ( @@ -85,8 +79,6 @@ const requestId = (headers: Record) => { ) } -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,56 +211,21 @@ const responseHttp = (input: { rateLimit: input.rateLimit, }) -const statusReason = (input: { - readonly status: number - readonly message: string - readonly retryAfterMs?: number | undefined - readonly rateLimit?: HttpRateLimitDetails | undefined - readonly http: HttpContext -}) => { - const body = input.http.body ?? "" - if (/content[-_\s]?policy|content_filter|safety/i.test(body)) { - return new ContentPolicyReason({ message: input.message, http: input.http }) - } - if (input.status === 401) { - return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http }) - } - if (input.status === 403) { - return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http }) - } - if (input.status === 429) { - if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) { - return new QuotaExceededReason({ message: input.message, http: input.http }) - } - return new RateLimitReason({ - message: input.message, - retryAfterMs: input.retryAfterMs, - rateLimit: input.rateLimit, - http: input.http, - }) - } - if ( - input.status === 400 || - input.status === 404 || - input.status === 409 || - input.status === 413 || - input.status === 422 - ) { - return new InvalidRequestReason({ - message: input.message, - classification: isContextOverflow(body) ? "context-overflow" : undefined, - http: input.http, - }) - } - if (input.status >= 500 || providerInternalStatus(input.status)) { - return new ProviderInternalReason({ - message: input.message, - status: input.status, - retryAfterMs: input.retryAfterMs, - http: input.http, - }) - } - return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http }) +const decodeBodyJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) + +// Provider machine code from a JSON error body (`error.code` / `error.type`), +// fed to the shared classifier so code-based rules (overflow, quota) work on +// HTTP rejections too. Truncated or non-JSON bodies yield undefined. +const providerCode = (body: string | undefined) => { + if (!body) return undefined + const decoded = Option.getOrUndefined(decodeBodyJson(body)) + if (typeof decoded !== "object" || decoded === null) return undefined + const error = (decoded as Record).error + if (typeof error !== "object" || error === null) return undefined + const fields = error as Record + if (typeof fields.code === "string") return fields.code + if (typeof fields.type === "string") return fields.type + return undefined } const statusError = @@ -281,58 +238,55 @@ const statusError = const retryAfter = retryAfterMs(headers) const rateLimit = rateLimitDetails(headers, retryAfter) const details = responseBody(body, request) - return yield* new LLMError({ - module: "RequestExecutor", - method: "execute", - reason: statusReason({ - status: response.status, - message: providerMessage(response.status, details), - retryAfterMs: retryAfter, + return yield* classifyApiFailure({ + status: response.status, + message: providerMessage(response.status, details), + code: providerCode(details.body), + retryAfterMs: retryAfter, + rateLimit, + requestID: requestId(headers), + http: responseHttp({ + request, + response, + redactedNames, + body: details, + requestId: requestId(headers), rateLimit, - http: responseHttp({ - request, - response, - redactedNames, - body: details, - requestId: requestId(headers), - rateLimit, - }), }), }) }) const toHttpError = (redactedNames: ReadonlyArray) => (error: unknown) => { - const transportError = (input: { + const httpContext = (request: HttpClientRequest.HttpClientRequest | undefined) => + request ? new HttpContext({ request: requestDetails(request, redactedNames) }) : undefined + const connectionError = (input: { readonly message: string readonly kind?: string | undefined readonly request?: HttpClientRequest.HttpClientRequest | undefined }) => - new LLMError({ - module: "RequestExecutor", - method: "execute", - reason: new TransportReason({ - message: input.message, - kind: input.kind, - url: input.request ? redactUrl(input.request.url) : undefined, - http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined, - }), + new ConnectionError({ + message: input.message, + kind: input.kind, + url: input.request ? redactUrl(input.request.url) : undefined, + http: httpContext(input.request), + cause: error, }) if (Cause.isTimeoutError(error)) { - return transportError({ message: error.message, kind: "Timeout" }) + return new TimeoutError({ message: error.message }) } if (!HttpClientError.isHttpClientError(error)) { - return transportError({ message: "HTTP transport failed" }) + return connectionError({ message: "HTTP transport failed" }) } const request = "request" in error ? error.request : undefined if (error.reason._tag === "TransportError") { - return transportError({ + return connectionError({ message: error.reason.description ?? "HTTP transport failed", kind: error.reason._tag, request, }) } - return transportError({ + return connectionError({ message: `HTTP transport failed: ${error.reason._tag}`, kind: error.reason._tag, request, diff --git a/packages/llm/src/route/transport/websocket.ts b/packages/llm/src/route/transport/websocket.ts index 310121420c4f..f0d2e8f4f227 100644 --- a/packages/llm/src/route/transport/websocket.ts +++ b/packages/llm/src/route/transport/websocket.ts @@ -1,6 +1,6 @@ import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { Headers } from "effect/unstable/http" -import { LLMError, TransportReason } from "../../schema" +import { ConnectionError, type LLMError } from "../../schema" import * as HttpTransport from "./http" import type { Transport } from "./index" @@ -27,15 +27,10 @@ type WebSocketConstructorWithHeaders = new ( export class Service extends Context.Service()("@opencode/LLM/WebSocketExecutor") {} const transportError = ( - method: string, + _method: string, message: string, input: { readonly url?: string; readonly kind?: string } = {}, -) => - new LLMError({ - module: "WebSocketExecutor", - method, - reason: new TransportReason({ message, url: input.url, kind: input.kind }), - }) +) => new ConnectionError({ message, url: input.url, kind: input.kind }) const eventMessage = (event: Event) => { if ("message" in event && typeof event.message === "string") return event.message diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts index 3592ea729f73..acae525d013c 100644 --- a/packages/llm/src/schema/errors.ts +++ b/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("LLM.HttpRequestDetails")({ method: Schema.String, url: Schema.String, @@ -31,118 +28,150 @@ export class HttpContext extends Schema.Class("LLM.HttpContext")({ rateLimit: Schema.optional(HttpRateLimitDetails), }) {} -export class InvalidRequestReason extends Schema.Class("LLM.Error.InvalidRequest")({ - _tag: Schema.tag("InvalidRequest"), +/** + * Fields shared by every failure the remote API deliberately reported — + * whether as a non-2xx response, an SSE error event, a WebSocket error + * message, or a binary exception frame. `status` is absent when the error + * arrived mid-stream without an HTTP status; `code` carries the provider's + * machine-readable error code (e.g. `context_length_exceeded`) when one + * exists. + */ +const apiFailureFields = { message: Schema.String, - parameter: Schema.optional(Schema.String), - classification: Schema.optional(ProviderFailureClassification), - providerMetadata: Schema.optional(ProviderMetadata), + status: Schema.optional(Schema.Number), + code: Schema.optional(Schema.String), + requestID: Schema.optional(Schema.String), http: Schema.optional(HttpContext), + providerMetadata: Schema.optional(ProviderMetadata), +} + +/** Provider rejected the request as invalid (400/409/422, `invalid_request_error`, ...). */ +export class BadRequest extends Schema.TaggedErrorClass()("LLM.BadRequest", { + ...apiFailureFields, + parameter: Schema.optional(Schema.String), }) {} -export class NoRouteReason extends Schema.Class("LLM.Error.NoRoute")({ - _tag: Schema.tag("NoRoute"), - route: RouteID, - provider: ProviderID, - model: ModelID, -}) { - get message() { - return `No LLM route for ${this.provider}/${this.model} using ${this.route}` - } -} +/** Credentials are missing, invalid, or expired (401). */ +export class Authentication extends Schema.TaggedErrorClass()("LLM.Authentication", { + ...apiFailureFields, +}) {} -export class AuthenticationReason extends Schema.Class("LLM.Error.Authentication")({ - _tag: Schema.tag("Authentication"), - message: Schema.String, - kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]), - providerMetadata: Schema.optional(ProviderMetadata), - http: Schema.optional(HttpContext), +/** Authenticated but not allowed (403). */ +export class PermissionDenied extends Schema.TaggedErrorClass()("LLM.PermissionDenied", { + ...apiFailureFields, }) {} -export class RateLimitReason extends Schema.Class("LLM.Error.RateLimit")({ - _tag: Schema.tag("RateLimit"), - message: Schema.String, +/** Model or endpoint does not exist (404). */ +export class NotFound extends Schema.TaggedErrorClass()("LLM.NotFound", { + ...apiFailureFields, +}) {} + +/** Transient request throttling (429). Retryable; honor `retryAfterMs` when present. */ +export class RateLimit extends Schema.TaggedErrorClass()("LLM.RateLimit", { + ...apiFailureFields, retryAfterMs: Schema.optional(Schema.Number), rateLimit: Schema.optional(HttpRateLimitDetails), - providerMetadata: Schema.optional(ProviderMetadata), - http: Schema.optional(HttpContext), }) {} -export class QuotaExceededReason extends Schema.Class("LLM.Error.QuotaExceeded")({ - _tag: Schema.tag("QuotaExceeded"), - message: Schema.String, - providerMetadata: Schema.optional(ProviderMetadata), - http: Schema.optional(HttpContext), +/** Account-level quota or billing exhaustion. Unlike `RateLimit`, waiting does not help. */ +export class QuotaExceeded extends Schema.TaggedErrorClass()("LLM.QuotaExceeded", { + ...apiFailureFields, }) {} -export class ContentPolicyReason extends Schema.Class("LLM.Error.ContentPolicy")({ - _tag: Schema.tag("ContentPolicy"), - message: Schema.String, - providerMetadata: Schema.optional(ProviderMetadata), - http: Schema.optional(HttpContext), +/** Provider refused the content for policy/safety reasons. */ +export class ContentPolicy extends Schema.TaggedErrorClass()("LLM.ContentPolicy", { + ...apiFailureFields, }) {} -export class ProviderInternalReason extends Schema.Class("LLM.Error.ProviderInternal")({ - _tag: Schema.tag("ProviderInternal"), - message: Schema.String, - status: Schema.Number, +/** + * The request exceeds the model's context window. Designated tag because + * Core recovers from it structurally (compaction) rather than surfacing it. + * Upgraded from `BadRequest` by the shared classifier in `provider-error.ts`. + */ +export class ContextOverflow extends Schema.TaggedErrorClass()("LLM.ContextOverflow", { + ...apiFailureFields, +}) {} + +/** Provider-side failure (5xx, `overloaded_error`, internal exceptions). Retryable. */ +export class ServerError extends Schema.TaggedErrorClass()("LLM.ServerError", { + ...apiFailureFields, retryAfterMs: Schema.optional(Schema.Number), - providerMetadata: Schema.optional(ProviderMetadata), - http: Schema.optional(HttpContext), }) {} -export class TransportReason extends Schema.Class("LLM.Error.Transport")({ - _tag: Schema.tag("Transport"), +/** Any other deliberate API rejection that matches no designated tag (402, 405, 410, ...). */ +export class APIError extends Schema.TaggedErrorClass()("LLM.APIError", { + ...apiFailureFields, +}) {} + +/** Communication failed: connect failure, reset, socket close, DNS. No API response involved. */ +export class ConnectionError extends Schema.TaggedErrorClass()("LLM.ConnectionError", { message: Schema.String, kind: Schema.optional(Schema.String), url: Schema.optional(Schema.String), http: Schema.optional(HttpContext), + cause: Schema.optional(Schema.Defect()), }) {} -export class InvalidProviderOutputReason extends Schema.Class( - "LLM.Error.InvalidProviderOutput", -)({ - _tag: Schema.tag("InvalidProviderOutput"), +/** The request or stream read timed out before the provider answered. */ +export class TimeoutError extends Schema.TaggedErrorClass()("LLM.TimeoutError", { message: Schema.String, - route: Schema.optional(Schema.String), - raw: Schema.optional(Schema.String), - providerMetadata: Schema.optional(ProviderMetadata), + url: Schema.optional(Schema.String), + http: Schema.optional(HttpContext), }) {} -export class UnknownProviderReason extends Schema.Class("LLM.Error.UnknownProvider")({ - _tag: Schema.tag("UnknownProvider"), +/** + * Transport succeeded but the content broke the protocol contract: + * undecodable frames, premature EOF without a terminal `finish`, duplicate + * terminals, or output after a terminal event. + */ +export class MalformedResponse extends Schema.TaggedErrorClass()("LLM.MalformedResponse", { message: Schema.String, - status: Schema.optional(Schema.Number), + route: Schema.optional(Schema.String), + raw: Schema.optional(Schema.String), providerMetadata: Schema.optional(ProviderMetadata), - http: Schema.optional(HttpContext), }) {} -export const LLMErrorReason = Schema.Union([ - InvalidRequestReason, - NoRouteReason, - AuthenticationReason, - RateLimitReason, - QuotaExceededReason, - ContentPolicyReason, - ProviderInternalReason, - TransportReason, - InvalidProviderOutputReason, - UnknownProviderReason, -]).pipe(Schema.toTaggedUnion("_tag")) -export type LLMErrorReason = Schema.Schema.Type - -export class LLMError extends Schema.TaggedErrorClass()("LLM.Error", { - module: Schema.String, - method: Schema.String, - reason: LLMErrorReason, +/** Request construction failed locally: the selected model resolves to no executable route. */ +export class NoRoute extends Schema.TaggedErrorClass()("LLM.NoRoute", { + route: RouteID, + provider: ProviderID, + model: ModelID, }) { - override readonly cause = this.reason - override get message() { - return `${this.module}.${this.method}: ${this.reason.message}` + return `No LLM route for ${this.provider}/${this.model} using ${this.route}` } } +const members = [ + BadRequest, + Authentication, + PermissionDenied, + NotFound, + RateLimit, + QuotaExceeded, + ContentPolicy, + ContextOverflow, + ServerError, + APIError, + ConnectionError, + TimeoutError, + MalformedResponse, + NoRoute, +] as const + +export const LLMErrorSchema = Schema.Union(members) + +/** + * Every failure of one LLM request. `LLMEvent` streams carry output only; + * all failures — HTTP rejections, in-stream provider error events, transport + * failures, and protocol-contract violations — exit through this union on + * the stream's error channel. + */ +export type LLMError = typeof LLMErrorSchema.Type + +export const isLLMError = (value: unknown): value is LLMError => + members.some((member) => value instanceof member) + /** * Failure type for tool execute handlers. Handlers must map their internal * errors to this shape; the runtime catches `ToolFailure`s and surfaces them diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index f43251f67030..d84052b8727f 100644 --- a/packages/llm/src/schema/events.ts +++ b/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 -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 - const llmEventTagged = Schema.Union([ StepStart, TextStart, @@ -221,7 +212,6 @@ const llmEventTagged = Schema.Union([ ToolError, StepFinish, Finish, - ProviderErrorEvent, ]).pipe(Schema.toTaggedUnion("type")) type WithID = Omit & { 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 @@ -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 diff --git a/packages/llm/test/executor.test.ts b/packages/llm/test/executor.test.ts index 1227dbbf969c..c58a30541f55 100644 --- a/packages/llm/test/executor.test.ts +++ b/packages/llm/test/executor.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer, Ref } from "effect" import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { LLM, LLMError } from "../src" +import { LLM, isLLMError, type LLMError } from "../src" import { LLMClient, RequestExecutor } from "../src/route" import * as OpenAIChat from "../src/protocols/openai-chat" import { dynamicResponse } from "./lib/http" @@ -59,12 +59,12 @@ const countedResponsesLayer = (attempts: Ref.Ref, responses: ReadonlyArr ) const expectLLMError = (error: unknown) => { - expect(error).toBeInstanceOf(LLMError) - if (!(error instanceof LLMError)) throw new Error("expected LLMError") + expect(isLLMError(error)).toBe(true) + if (!isLLMError(error)) throw new Error("expected LLMError") return error } -const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined) +const errorHttp = (error: LLMError) => ("http" in error ? error.http : undefined) describe("RequestExecutor", () => { it.effect("classifies context overflow responses", () => @@ -73,7 +73,7 @@ describe("RequestExecutor", () => { const error = yield* executor.execute(request).pipe(Effect.flip) expectLLMError(error) - expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" }) + expect(error).toMatchObject({ _tag: "LLM.ContextOverflow" }) }).pipe( Effect.provide( responsesLayer([ @@ -91,8 +91,7 @@ describe("RequestExecutor", () => { const error = yield* executor.execute(request).pipe(Effect.flip) expectLLMError(error) - expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) - expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined() + expect(error).toMatchObject({ _tag: "LLM.BadRequest" }) }).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))), ) @@ -102,8 +101,7 @@ describe("RequestExecutor", () => { const error = yield* executor.execute(request).pipe(Effect.flip) expectLLMError(error) - expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) - expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined() + expect(error).toMatchObject({ _tag: "LLM.BadRequest" }) }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))), ) @@ -114,24 +112,22 @@ describe("RequestExecutor", () => { expectLLMError(error) expect(error).toMatchObject({ - reason: { - _tag: "RateLimit", - retryAfterMs: 0, - rateLimit: { retryAfterMs: 0 }, - http: { - requestId: "req_123", - request: { - method: "POST", - url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1", - headers: { authorization: "", "x-safe": "visible" }, - }, - response: { - status: 429, - headers: { - "retry-after-ms": "0", - "x-request-id": "req_123", - "x-api-key": "", - }, + _tag: "LLM.RateLimit", + retryAfterMs: 0, + rateLimit: { retryAfterMs: 0 }, + http: { + requestId: "req_123", + request: { + method: "POST", + url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1", + headers: { authorization: "", "x-safe": "visible" }, + }, + response: { + status: 429, + headers: { + "retry-after-ms": "0", + "x-request-id": "req_123", + "x-api-key": "", }, }, }, @@ -169,8 +165,8 @@ describe("RequestExecutor", () => { const error = yield* executor.execute(request).pipe(Effect.flip) expectLLMError(error) - expect(error.reason).toMatchObject({ _tag: "RateLimit" }) - expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({ + expect(error).toMatchObject({ _tag: "LLM.RateLimit" }) + expect(error._tag === "LLM.RateLimit" ? error.rateLimit : undefined).toEqual({ retryAfterMs: 0, limit: { requests: "500", tokens: "30000" }, remaining: { requests: "499", tokens: "29900" }, @@ -202,7 +198,7 @@ describe("RequestExecutor", () => { const error = yield* executor.execute(request).pipe(Effect.flip) expectLLMError(error) - expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) + expect(error).toMatchObject({ _tag: "LLM.ServerError" }) expect(errorHttp(error)?.rateLimit).toEqual({ retryAfterMs: 0, limit: { requests: "100", "input-tokens": "10000" }, @@ -245,12 +241,12 @@ describe("RequestExecutor", () => { ) expectLLMError(error) - expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 }) + expect(error).toMatchObject({ _tag: "LLM.ServerError", status: 503 }) expect(yield* Ref.get(attempts)).toBe(1) }), ) - it.effect("marks 504 and 529 status responses as provider-internal", () => + it.effect("marks 504 and 529 status responses as server errors", () => Effect.gen(function* () { const failWith = (status: number) => Effect.gen(function* () { @@ -258,7 +254,7 @@ describe("RequestExecutor", () => { const error = yield* executor.execute(request).pipe(Effect.flip) expectLLMError(error) - expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status }) + expect(error).toMatchObject({ _tag: "LLM.ServerError", status }) }).pipe( Effect.provide( responsesLayer([ @@ -281,7 +277,7 @@ describe("RequestExecutor", () => { const error = yield* executor.execute(request).pipe(Effect.flip) expectLLMError(error) - expect(error.reason).toMatchObject({ _tag: "Authentication" }) + expect(error).toMatchObject({ _tag: "LLM.Authentication" }) expect(errorHttp(error)?.bodyTruncated).toBe(true) expect(errorHttp(error)?.body).toHaveLength(16_384) }).pipe( @@ -360,7 +356,7 @@ describe("RequestExecutor", () => { ) expectLLMError(error) - expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) + expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" }) expect(yield* Ref.get(attempts)).toBe(1) }), ) diff --git a/packages/llm/test/prepare.test.ts b/packages/llm/test/prepare.test.ts index 6923c5a678ed..d9ccc0fcdf5c 100644 --- a/packages/llm/test/prepare.test.ts +++ b/packages/llm/test/prepare.test.ts @@ -149,8 +149,8 @@ describe("request option precedence", () => { }), ).pipe(Effect.flip) - expect(error.reason).toMatchObject({ - _tag: "InvalidRequest", + expect(error).toMatchObject({ + _tag: "LLM.BadRequest", message: "http.body cannot overlay protocol-owned field(s): model, messages, tools", }) }), diff --git a/packages/llm/test/provider/anthropic-messages.recorded.test.ts b/packages/llm/test/provider/anthropic-messages.recorded.test.ts index 9b552550add4..3f441f567560 100644 --- a/packages/llm/test/provider/anthropic-messages.recorded.test.ts +++ b/packages/llm/test/provider/anthropic-messages.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMError, Message, ToolCallPart } from "../../src" +import { isLLMError, LLM, Message, ToolCallPart } from "../../src" import { LLMClient } from "../../src/route" import * as Anthropic from "../../src/providers/anthropic" import { weatherToolName } from "../recorded-scenarios" @@ -22,6 +22,9 @@ const malformedToolOrderRequest = LLM.request({ Message.user("Use that result to answer briefly."), ], tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }], + // The cassette predates the `cache: "auto"` default; pin the policy off so + // the replayed request matches the recorded wire shape. + cache: "none", }) const recorded = recordedTests({ @@ -33,13 +36,17 @@ const recorded = recordedTests({ }) describe("Anthropic Messages sad-path recorded", () => { - recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () => - Effect.gen(function* () { - const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip) + recorded.effect.with( + "rejects malformed assistant tool order", + // The cassette predates a test rename; keep replaying the existing recording. + { id: "rejects-malformed-assistant-tool-order-without-patch", tags: ["tool", "sad-path"] }, + () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip) - expect(error).toBeInstanceOf(LLMError) - expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) - expect(error.message).toContain("HTTP 400") - }), + expect(isLLMError(error)).toBe(true) + expect(error).toMatchObject({ _tag: "LLM.BadRequest" }) + expect(error.message).toContain("HTTP 400") + }), ) }) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 14a35a4fa76e..fa314167e58f 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" +import { CacheHint, isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src" import { Auth, LLMClient } from "../../src/route" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios" @@ -484,23 +484,25 @@ describe("Anthropic Messages route", () => { }), ) - it.effect("emits provider-error events for mid-stream provider errors", () => + it.effect("fails the stream for mid-stream provider errors", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })), ), + Effect.flip, ) // Prefix the error type so consumers can distinguish overloads, rate // limits, and quota errors without parsing the message string. - expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }]) + expect(isLLMError(error)).toBe(true) + expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error: Overloaded" }) }), ) it.effect("classifies prompt-too-long provider errors", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -509,35 +511,35 @@ describe("Anthropic Messages route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([ - { - type: "provider-error", - message: "invalid_request_error: prompt is too long: 210000 tokens", - classification: "context-overflow", - }, - ]) + expect(error).toMatchObject({ + _tag: "LLM.ContextOverflow", + message: "invalid_request_error: prompt is too long: 210000 tokens", + }) }), ) it.effect("falls back to error type when no message is present", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }]) + expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error" }) }), ) it.effect("falls back to a stable default when error payload is absent", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error" }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }]) + expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Anthropic Messages stream error" }) }), ) @@ -553,8 +555,8 @@ describe("Anthropic Messages route", () => { Effect.flip, ) - expect(error).toBeInstanceOf(LLMError) - expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(isLLMError(error)).toBe(true) + expect(error).toMatchObject({ _tag: "LLM.BadRequest" }) expect(error.message).toContain("HTTP 400") }), ) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 87186949aea1..b422334f9260 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/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", }) }), ) diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index 1dc253c0ea88..843393215c30 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" +import { isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src" import { Auth, LLMClient } from "../../src/route" import * as Gemini from "../../src/protocols/gemini" import { ProviderShared } from "../../src/protocols/shared" @@ -560,8 +560,8 @@ describe("Gemini route", () => { Effect.flip, ) - expect(error).toBeInstanceOf(LLMError) - expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) + expect(isLLMError(error)).toBe(true) + expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" }) expect(error.message).toContain("Invalid google/gemini stream event") }), ) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 63ae09bdd8d8..004edd22631c 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src" +import { isLLMError, LLM, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" import * as OpenAIChat from "../../src/protocols/openai-chat" @@ -662,8 +662,8 @@ describe("OpenAI Chat route", () => { Effect.flip, ) - expect(error).toBeInstanceOf(LLMError) - expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(isLLMError(error)).toBe(true) + expect(error).toMatchObject({ _tag: "LLM.BadRequest" }) expect(error.message).toContain("HTTP 400") }), ) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 421617b6db96..eb642f76d6f1 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { ConfigProvider, Effect, Layer, Stream } from "effect" import { Headers, HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src" +import { isLLMError, LLM, Message, Model, ToolCallPart, Usage } from "../../src" import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" @@ -1368,37 +1368,41 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("emits provider-error events for mid-stream provider errors", () => + it.effect("fails the stream for mid-stream provider errors", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))), + Effect.flip, ) // Prefix the code so consumers see the failure mode, not just the // sometimes-generic provider message. The bare message alone meant // production errors like rate limits were indistinguishable from // unrelated stream failures. - expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }]) + expect(isLLMError(error)).toBe(true) + expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "rate_limit_exceeded: Slow down" }) }), ) it.effect("falls back to error code when no message is present", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }]) + expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" }) }), ) it.effect("falls back to error code when message is empty", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }]) + expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" }) }), ) @@ -1408,7 +1412,7 @@ describe("OpenAI Responses route", () => { // "OpenAI Responses response failed" string, hiding the real cause. it.effect("surfaces response.failed details from response.error", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1420,15 +1424,16 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }]) + expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "server_error: Upstream model unavailable" }) }), ) it.effect("surfaces response.failed code when no nested message is present", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1437,9 +1442,10 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }]) + expect(error).toMatchObject({ _tag: "LLM.BadRequest", message: "invalid_prompt" }) }), ) @@ -1450,7 +1456,7 @@ describe("OpenAI Responses route", () => { // when they bubble up an HTTP error as an SSE `error` event. Honour // both shapes so the user still sees the underlying cause instead // of the catch-all string. - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1459,21 +1465,19 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([ - { - type: "provider-error", - message: "context_length_exceeded: prompt too long", - classification: "context-overflow", - }, - ]) + expect(error).toMatchObject({ + _tag: "LLM.ContextOverflow", + message: "context_length_exceeded: prompt too long", + }) }), ) it.effect("surfaces error event details nested under error", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1488,21 +1492,19 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([ - { - type: "provider-error", - message: "context_length_exceeded: prompt too long", - classification: "context-overflow", - }, - ]) + expect(error).toMatchObject({ + _tag: "LLM.ContextOverflow", + message: "context_length_exceeded: prompt too long", + }) }), ) it.effect("accepts nullable fields in spec-compliant error events", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1514,39 +1516,43 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }]) + expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Something went wrong" }) }), ) it.effect("falls back to a stable default when error is null", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }]) + expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" }) }), ) it.effect("falls back to a stable default when both error and response are absent", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error" }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }]) + expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" }) }), ) it.effect("falls back to a stable default when response.failed has no error payload", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }]) + expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses response failed" }) }), ) @@ -1562,8 +1568,8 @@ describe("OpenAI Responses route", () => { Effect.flip, ) - expect(error).toBeInstanceOf(LLMError) - expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(isLLMError(error)).toBe(true) + expect(error).toMatchObject({ _tag: "LLM.BadRequest" }) expect(error.message).toContain("HTTP 400") }), ) diff --git a/packages/llm/test/tool-stream.test.ts b/packages/llm/test/tool-stream.test.ts index b005d2666c8f..48ab2d7a8a97 100644 --- a/packages/llm/test/tool-stream.test.ts +++ b/packages/llm/test/tool-stream.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLMError } from "../src/schema" +import { isLLMError } from "../src/schema" import { ToolStream } from "../src/protocols/utils/tool-stream" import { it } from "./lib/effect" @@ -40,8 +40,9 @@ describe("ToolStream", () => { Effect.gen(function* () { const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty(), 0, "{}", "missing tool") - expect(error).toBeInstanceOf(LLMError) - if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool") + expect(isLLMError(error)).toBe(true) + if (ToolStream.isError(error)) + expect(error).toMatchObject({ _tag: "LLM.MalformedResponse", message: "missing tool" }) }), ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404d8..8a190910f2d8 100644 --- a/packages/opencode/src/session/processor.ts +++ b/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({ diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 528760543656..b05c88caa1fb 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/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]])