From fce506b3f93bac83228bcc46e27266bf45ea5d2e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 13 Jul 2026 10:47:50 -0500 Subject: [PATCH 1/2] refactor(llm): replace LLMError reasons with flat tagged union Replace the LLMError { module, method, reason } wrapper with a flat tagged union (LLM.BadRequest, LLM.Authentication, LLM.PermissionDenied, LLM.NotFound, LLM.RateLimit, LLM.QuotaExceeded, LLM.ContentPolicy, LLM.ContextOverflow, LLM.ServerError, LLM.APIError, LLM.ConnectionError, LLM.TimeoutError, LLM.MalformedResponse, LLM.NoRoute) plus an isLLMError guard. Add one shared classifyApiFailure classifier used by the HTTP executor and the AI SDK adapter so both surfaces classify identically, preserving status, headers, body, and retry-after. Core policy moves onto tags: retry RateLimit | ServerError | ConnectionError | TimeoutError; toSessionError adds provider.context-overflow, provider.timeout, and provider.not-found. The provider-error stream event and the runner's held-back overflow handling are unchanged here; isContextOverflowFailure now bridges old events and new tags until the event is removed. --- packages/core/src/aisdk.ts | 104 ++++++++-- packages/core/src/generate.ts | 2 +- packages/core/src/session/compaction.ts | 4 +- packages/core/src/session/runner/llm.ts | 4 +- packages/core/src/session/runner/retry.ts | 34 ++-- packages/core/src/session/title.ts | 4 +- packages/core/src/session/to-session-error.ts | 56 +++--- packages/core/test/session-error.test.ts | 104 +++++----- packages/core/test/session-execution.test.ts | 17 +- packages/core/test/session-runner.test.ts | 49 ++--- packages/llm/AGENTS.md | 4 +- packages/llm/src/index.ts | 2 +- packages/llm/src/llm.ts | 20 +- packages/llm/src/protocols/shared.ts | 22 +-- .../llm/src/protocols/utils/tool-stream.ts | 4 +- packages/llm/src/provider-error.ts | 123 +++++++++++- packages/llm/src/route/auth.ts | 15 +- packages/llm/src/route/client.ts | 4 +- packages/llm/src/route/executor.ts | 138 +++++-------- packages/llm/src/route/transport/websocket.ts | 11 +- packages/llm/src/schema/errors.ts | 184 ++++++++++-------- packages/llm/test/executor.test.ts | 66 +++---- packages/llm/test/prepare.test.ts | 4 +- .../anthropic-messages.recorded.test.ts | 23 ++- .../test/provider/anthropic-messages.test.ts | 6 +- packages/llm/test/provider/gemini.test.ts | 6 +- .../llm/test/provider/openai-chat.test.ts | 6 +- .../test/provider/openai-responses.test.ts | 6 +- packages/llm/test/tool-stream.test.ts | 7 +- 29 files changed, 581 insertions(+), 448 deletions(-) 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..3640781f8d24 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" @@ -261,7 +261,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 932470d869c7..b8d561cee183 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -3,11 +3,11 @@ export * as SessionRunnerLLM from "./llm" import { LLM, LLMClient, - LLMError, LLMEvent, Message, SystemPart, isContextOverflowFailure, + isLLMError, type ProviderErrorEvent, } from "@opencode-ai/llm" import { SessionError } from "@opencode-ai/schema/session-error" @@ -332,7 +332,7 @@ const layer = Layer.effect( // 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 + const llmFailure = streamFailure !== undefined && isLLMError(streamFailure) ? streamFailure : undefined if (llmFailure && !publisher.hasProviderError()) { const error = toSessionError(llmFailure) if ( diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts index 3ada83b4e413..a3a8461807a3 100644 --- a/packages/core/src/session/runner/retry.ts +++ b/packages/core/src/session/runner/retry.ts @@ -1,6 +1,6 @@ export * as SessionRunnerRetry from "./retry" -import { LLMError } from "@opencode-ai/llm" +import type { LLMError } from "@opencode-ai/llm" import { SessionError } from "@opencode-ai/schema/session-error" import { Data, Duration, Effect, Schedule } from "effect" import { EventV2 } from "../../event" @@ -17,29 +17,33 @@ export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableF }> {} 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..c2cc9880eae4 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" @@ -66,7 +66,7 @@ const make = (dependencies: Dependencies) => { return Effect.void }), Effect.as(true), - Effect.catchTag("LLM.Error", () => Effect.succeed(false)), + Effect.catchIf(isLLMError, () => Effect.succeed(false)), ) if (!streamed || failed) return const title = chunks 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 63171b9a44e5..23e1e1a76370 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1,15 +1,16 @@ import { describe, expect, test } from "bun:test" import { + 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" @@ -483,26 +484,11 @@ 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 invalidRequest = () => new BadRequest({ message: "Invalid request" }) -const rateLimited = (retryAfterMs?: number) => - new LLMError({ - module: "test", - method: "stream", - reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }), - }) +const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs }) const setupOverflowRecovery = Effect.gen(function* () { const session = yield* setup @@ -1985,16 +1971,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(new ContextOverflow({ message: "prompt too long" })) responses = [ reply.text("## Objective\n- Recover raw overflow", "text-summary"), reply.text("Recovered", "text-final"), @@ -3892,11 +3869,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" }), diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index e883a9e47d12..6814986dcbec 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. diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 83edee7155ef..9e83f64d38c0 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, isContextOverflowFailure, 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/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..77740f04ee4c 100644 --- a/packages/llm/src/provider-error.ts +++ b/packages/llm/src/provider-error.ts @@ -1,5 +1,22 @@ import { Schema } from "effect" -import { LLMError, ProviderErrorEvent } from "./schema" +import { + APIError, + Authentication, + BadRequest, + ContentPolicy, + ContextOverflow, + HttpContext, + HttpRateLimitDetails, + NotFound, + PermissionDenied, + ProviderErrorEvent, + ProviderMetadata, + QuotaExceeded, + RateLimit, + ServerError, + isLLMError, + type LLMError, +} from "./schema" const patterns = [ /prompt is too long/i, @@ -28,6 +45,106 @@ 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" + isLLMError(failure) + ? failure._tag === "LLM.ContextOverflow" : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" + +const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"]) +const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"]) +const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i +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..a8add1cdad9d 100644 --- a/packages/llm/src/schema/errors.ts +++ b/packages/llm/src/schema/errors.ts @@ -31,118 +31,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/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..6e73b72a72f4 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" @@ -553,8 +553,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/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..1c27c90d5495 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" @@ -1562,8 +1562,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" }) }), ) From 5d87c7ad1fd0f22af4ad04f2b5ca11424b1c0cf4 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 13 Jul 2026 10:49:43 -0500 Subject: [PATCH 2/2] refactor(llm): remove the provider-error stream event Delete the provider-error LLMEvent so streams carry output only and every provider-reported failure exits through the typed error channel. Anthropic SSE error events, OpenAI Responses response.failed/error events, and Bedrock exception frames now fail the stream with an LLMError from the shared classifier (carrying the provider code, e.g. rate_limit_exceeded -> LLM.RateLimit). Core follows: the publisher drops its provider-error case, the runner drops held-back overflow events (overflow recovery keys off a thrown LLM.ContextOverflow), compaction and title stop scanning events for failures, and isContextOverflowFailure is deleted with its last consumer. V1 packages/opencode gets minimal compile fixes only (dead switch case, one test stream). --- packages/core/src/session/compaction.ts | 5 - packages/core/src/session/runner/llm.ts | 34 ++---- .../src/session/runner/publish-llm-event.ts | 4 - packages/core/src/session/title.ts | 4 +- packages/core/test/session-runner.test.ts | 104 +++++++++--------- packages/llm/AGENTS.md | 2 +- packages/llm/src/index.ts | 2 +- .../llm/src/protocols/anthropic-messages.ts | 18 ++- .../llm/src/protocols/bedrock-converse.ts | 35 +++--- .../llm/src/protocols/openai-responses.ts | 33 ++---- packages/llm/src/provider-error.ts | 8 -- packages/llm/src/schema/errors.ts | 3 - packages/llm/src/schema/events.ts | 21 +--- .../test/provider/anthropic-messages.test.ts | 32 +++--- .../test/provider/bedrock-converse.test.ts | 20 ++-- .../test/provider/openai-responses.test.ts | 76 +++++++------ packages/opencode/src/session/processor.ts | 3 - .../test/session/processor-effect.test.ts | 3 +- 18 files changed, 167 insertions(+), 240 deletions(-) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 3640781f8d24..c468c9dc1af3 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -247,11 +247,6 @@ const make = (dependencies: Dependencies) => { ) .pipe( Stream.runForEach((event) => { - if (LLMEvent.is.providerError(event)) - failure = { - type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error", - message: event.message, - } if (LLMEvent.is.textDelta(event)) { chunks.push(event.text) return dependencies.events.publish(SessionEvent.Compaction.Delta, { diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index b8d561cee183..ce5289f7cfdc 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, - LLMEvent, - Message, - SystemPart, - isContextOverflowFailure, - isLLMError, - type ProviderErrorEvent, -} from "@opencode-ai/llm" +import { LLM, LLMClient, LLMEvent, Message, SystemPart, isLLMError, type LLMError } from "@opencode-ai/llm" import { SessionError } from "@opencode-ai/schema/session-error" import { Money } from "@opencode-ai/schema/money" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" @@ -227,17 +218,10 @@ const layer = Layer.effect( // mid-event. const serialized = (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 !== undefined && isLLMError(streamFailure) ? streamFailure : undefined + // A thrown LLM failure records the assistant failure unless a provider failure + // was already recorded from the stream. Terminal publication waits for owned tools. if (llmFailure && !publisher.hasProviderError()) { const error = toSessionError(llmFailure) if ( @@ -349,7 +332,8 @@ const layer = Layer.effect( } yield* serialized(publisher.failAssistant(error)) } - // Provider error events only arrive from the stream, so the flag is final here. + // The provider-failed flag is only set while consuming the stream (content-filter + // step finish), so it is final here. const providerFailed = publisher.hasProviderError() // Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain 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 { ).pipe(Effect.catch(() => Effect.succeed(undefined))) if (!resolved) return const chunks: string[] = [] - let failed = false const streamed = yield* dependencies.llm .stream( LLM.request({ @@ -61,14 +60,13 @@ const make = (dependencies: Dependencies) => { ) .pipe( Stream.runForEach((event) => { - if (LLMEvent.is.providerError(event)) failed = true if (LLMEvent.is.textDelta(event)) chunks.push(event.text) return Effect.void }), Effect.as(true), Effect.catchIf(isLLMError, () => Effect.succeed(false)), ) - if (!streamed || failed) return + if (!streamed) return const title = chunks .join("") .split("\n") diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 23e1e1a76370..355587c6503f 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { + APIError, BadRequest, ConnectionError, ContextOverflow, @@ -70,8 +71,9 @@ import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" const requests: LLMRequest[] = [] +type ScriptedResponse = LLMEvent[] | Stream.Stream 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 @@ -94,9 +96,12 @@ const client = Layer.succeed( responseStream = undefined return stream } + const scripted = responses === undefined ? response : (responses.shift() ?? []) const events = streamFailure ? Stream.fail(streamFailure) - : Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? [])) + : Array.isArray(scripted) + ? Stream.fromIterable(scripted) + : scripted if (!streamGate) return events return Stream.unwrap( (streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe( @@ -486,6 +491,11 @@ const setup = Effect.gen(function* () { const providerUnavailable = () => new ConnectionError({ message: "Provider unavailable" }) +const contextOverflow = () => new ContextOverflow({ message: "prompt too long" }) + +const failingResponse = (events: LLMEvent[], failure: LLMError): Stream.Stream => + Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(failure))) + const invalidRequest = () => new BadRequest({ message: "Invalid request" }) const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs }) @@ -1744,14 +1754,14 @@ describe("SessionRunnerLLM", () => { yield* admit(session, "Earlier question") yield* session.resume(sessionID) - response = [LLMEvent.providerError({ message: "summary unavailable" })] + responseStream = Stream.fail(new APIError({ message: "summary unavailable" })) const compaction = yield* session.compact({ sessionID }) yield* session.resume(sessionID) expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({ type: "compaction", status: "failed", - error: { type: "provider.error", message: "summary unavailable" }, + error: { type: "provider.unknown", message: "summary unavailable" }, }) }), ) @@ -1861,7 +1871,7 @@ describe("SessionRunnerLLM", () => { currentModel = compactModel requests.length = 0 responses = [ - [LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })], + Stream.fail(new BadRequest({ message: "Unsupported parameter: max_output_tokens" })), reply.text("Must not run", "text-after-failed-compaction"), ] yield* admit(session, "Recent exact request ".repeat(180)) @@ -1884,10 +1894,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setupOverflowRecovery responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), - ], + failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow()), reply.text("## Objective\n- Recover overflow", "text-summary"), reply.text("Recovered", "text-final"), ] @@ -1914,7 +1921,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setupOverflowRecovery currentModel = model responses = [ - [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], + Stream.fail(contextOverflow()), reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"), reply.text("Recovered", "text-final-unknown-limit"), ] @@ -1934,7 +1941,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setupOverflowRecovery currentModel = undersizedContextModel responses = [ - [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], + Stream.fail(contextOverflow()), reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"), reply.text("Recovered", "text-final-undersized-limit"), ] @@ -1952,10 +1959,7 @@ describe("SessionRunnerLLM", () => { it.effect("persists a second context overflow after one recovery", () => Effect.gen(function* () { const session = yield* setupOverflowRecovery - const overflow = () => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), - ] + const overflow = () => failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow()) responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()] yield* admit(session, "Continue") expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") @@ -1971,7 +1975,7 @@ describe("SessionRunnerLLM", () => { it.effect("recovers once from a raw context overflow failure", () => Effect.gen(function* () { const session = yield* setupOverflowRecovery - responseStream = Stream.fail(new ContextOverflow({ message: "prompt too long" })) + responseStream = Stream.fail(contextOverflow()) responses = [ reply.text("## Objective\n- Recover raw overflow", "text-summary"), reply.text("Recovered", "text-final"), @@ -1990,10 +1994,7 @@ describe("SessionRunnerLLM", () => { it.effect("publishes the original overflow when recovery summarization fails", () => Effect.gen(function* () { const session = yield* setupOverflowRecovery - responses = [ - [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], - [LLMEvent.providerError({ message: "summary unavailable" })], - ] + responses = [Stream.fail(contextOverflow()), Stream.fail(new APIError({ message: "summary unavailable" }))] yield* admit(session, "Continue") expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") @@ -2004,7 +2005,7 @@ describe("SessionRunnerLLM", () => { type: "compaction", status: "failed", reason: "auto", - error: { type: "provider.error", message: "summary unavailable" }, + error: { type: "provider.unknown", message: "summary unavailable" }, }), ) expect(context.slice(-3)).toMatchObject([ @@ -2018,10 +2019,7 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts overflow recovery while the summary provider is running", () => Effect.gen(function* () { const session = yield* setupOverflowRecovery - responses = [ - [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], - reply.text("## Objective\n- Interrupted", "text-summary"), - ] + responses = [Stream.fail(contextOverflow()), reply.text("## Objective\n- Interrupted", "text-summary")] const firstGate = yield* Deferred.make() const summaryGate = yield* Deferred.make() streamGate = firstGate @@ -3604,7 +3602,10 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Fail durably") - response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })] + responseStream = failingResponse( + [LLMEvent.stepStart({ index: 0 })], + new APIError({ message: "Provider unavailable" }), + ) expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") @@ -3621,7 +3622,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Fail before step") - response = [LLMEvent.providerError({ message: "Provider unavailable" })] + responseStream = Stream.fail(new APIError({ message: "Provider unavailable" })) expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") @@ -3709,13 +3710,15 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Fail after output") - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-partial" }), - LLMEvent.textDelta({ id: "text-partial", text: "Partial" }), - LLMEvent.textEnd({ id: "text-partial" }), - LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), - ] + responseStream = failingResponse( + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-partial" }), + LLMEvent.textDelta({ id: "text-partial", text: "Partial" }), + LLMEvent.textEnd({ id: "text-partial" }), + ], + contextOverflow(), + ) expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") expect(requests).toHaveLength(1) @@ -3908,11 +3911,13 @@ describe("SessionRunnerLLM", () => { toolExecutionGate = yield* Deferred.make() 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) @@ -3939,11 +3944,10 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Fail hosted tool durably") - response = [ - LLMEvent.stepStart({ index: 0 }), - hostedCall("call-hosted-provider-error", "effect"), - LLMEvent.providerError({ message: "Provider unavailable" }), - ] + responseStream = failingResponse( + [LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-provider-error", "effect")], + new APIError({ message: "Provider unavailable" }), + ) expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") @@ -3970,11 +3974,13 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Defect while provider fails") - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }), - LLMEvent.providerError({ message: "Provider unavailable" }), - ] + responseStream = failingResponse( + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }), + ], + new APIError({ message: "Provider unavailable" }), + ) expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index 6814986dcbec..769524bff7f2 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -291,7 +291,7 @@ Use this order for every protocol module: - Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`. - Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`. -- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing. +- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event for each completed response. Provider-reported failures (SSE error events, exception frames) fail the stream with a typed `LLMError` via `classifyApiFailure` — never an ordinary event. If a provider splits reason and usage across events, merge them in parser state before flushing. - Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends. - Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly. - Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names. diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 9e83f64d38c0..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 { classifyApiFailure, isContextOverflow, isContextOverflowFailure, type ApiFailure } from "./provider-error" +export { classifyApiFailure, isContextOverflow, type ApiFailure } from "./provider-error" export type { RouteModelInput, RouteRoutedModelInput, 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/provider-error.ts b/packages/llm/src/provider-error.ts index 77740f04ee4c..2458012f4d23 100644 --- a/packages/llm/src/provider-error.ts +++ b/packages/llm/src/provider-error.ts @@ -1,4 +1,3 @@ -import { Schema } from "effect" import { APIError, Authentication, @@ -9,12 +8,10 @@ import { HttpRateLimitDetails, NotFound, PermissionDenied, - ProviderErrorEvent, ProviderMetadata, QuotaExceeded, RateLimit, ServerError, - isLLMError, type LLMError, } from "./schema" @@ -44,11 +41,6 @@ const patterns = [ export const isContextOverflow = (message: string) => patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message) -export const isContextOverflowFailure = (failure: unknown) => - isLLMError(failure) - ? failure._tag === "LLM.ContextOverflow" - : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" - const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"]) const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"]) const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts index a8add1cdad9d..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, 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/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 6e73b72a72f4..fa314167e58f 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -484,23 +484,25 @@ describe("Anthropic Messages route", () => { }), ) - it.effect("emits provider-error events for mid-stream provider errors", () => + it.effect("fails the stream for mid-stream provider errors", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })), ), + Effect.flip, ) // Prefix the error type so consumers can distinguish overloads, rate // limits, and quota errors without parsing the message string. - expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }]) + expect(isLLMError(error)).toBe(true) + expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error: Overloaded" }) }), ) it.effect("classifies prompt-too-long provider errors", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -509,35 +511,35 @@ describe("Anthropic Messages route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([ - { - type: "provider-error", - message: "invalid_request_error: prompt is too long: 210000 tokens", - classification: "context-overflow", - }, - ]) + expect(error).toMatchObject({ + _tag: "LLM.ContextOverflow", + message: "invalid_request_error: prompt is too long: 210000 tokens", + }) }), ) it.effect("falls back to error type when no message is present", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }]) + expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error" }) }), ) it.effect("falls back to a stable default when error payload is absent", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error" }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }]) + expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Anthropic Messages stream error" }) }), ) 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/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 1c27c90d5495..eb642f76d6f1 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -1368,37 +1368,41 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("emits provider-error events for mid-stream provider errors", () => + it.effect("fails the stream for mid-stream provider errors", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))), + Effect.flip, ) // Prefix the code so consumers see the failure mode, not just the // sometimes-generic provider message. The bare message alone meant // production errors like rate limits were indistinguishable from // unrelated stream failures. - expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }]) + expect(isLLMError(error)).toBe(true) + expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "rate_limit_exceeded: Slow down" }) }), ) it.effect("falls back to error code when no message is present", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }]) + expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" }) }), ) it.effect("falls back to error code when message is empty", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }]) + expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" }) }), ) @@ -1408,7 +1412,7 @@ describe("OpenAI Responses route", () => { // "OpenAI Responses response failed" string, hiding the real cause. it.effect("surfaces response.failed details from response.error", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1420,15 +1424,16 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }]) + expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "server_error: Upstream model unavailable" }) }), ) it.effect("surfaces response.failed code when no nested message is present", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1437,9 +1442,10 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }]) + expect(error).toMatchObject({ _tag: "LLM.BadRequest", message: "invalid_prompt" }) }), ) @@ -1450,7 +1456,7 @@ describe("OpenAI Responses route", () => { // when they bubble up an HTTP error as an SSE `error` event. Honour // both shapes so the user still sees the underlying cause instead // of the catch-all string. - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1459,21 +1465,19 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([ - { - type: "provider-error", - message: "context_length_exceeded: prompt too long", - classification: "context-overflow", - }, - ]) + expect(error).toMatchObject({ + _tag: "LLM.ContextOverflow", + message: "context_length_exceeded: prompt too long", + }) }), ) it.effect("surfaces error event details nested under error", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1488,21 +1492,19 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([ - { - type: "provider-error", - message: "context_length_exceeded: prompt too long", - classification: "context-overflow", - }, - ]) + expect(error).toMatchObject({ + _tag: "LLM.ContextOverflow", + message: "context_length_exceeded: prompt too long", + }) }), ) it.effect("accepts nullable fields in spec-compliant error events", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse( sseEvents({ @@ -1514,39 +1516,43 @@ describe("OpenAI Responses route", () => { }), ), ), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }]) + expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Something went wrong" }) }), ) it.effect("falls back to a stable default when error is null", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }]) + expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" }) }), ) it.effect("falls back to a stable default when both error and response are absent", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "error" }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }]) + expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" }) }), ) it.effect("falls back to a stable default when response.failed has no error payload", () => Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( + const error = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))), + Effect.flip, ) - expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }]) + expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses response failed" }) }), ) 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]])