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.
This commit is contained in:
Aiden Cline 2026-07-13 10:47:50 -05:00
commit fce506b3f9
29 changed files with 581 additions and 448 deletions

View file

@ -15,20 +15,43 @@ import type {
SharedV3ProviderOptions, SharedV3ProviderOptions,
} from "@ai-sdk/provider" } from "@ai-sdk/provider"
import { import {
APIError,
Authentication,
BadRequest,
ConnectionError,
FinishReason, FinishReason,
InvalidProviderOutputReason, HttpContext,
HttpRequestDetails,
HttpResponseDetails,
LLMEvent, LLMEvent,
LLMError, MalformedResponse,
Model, Model,
NotFound,
ProviderID, ProviderID,
ProviderMetadata, ProviderMetadata,
ToolResultValue, ToolResultValue,
UnknownProviderReason, classifyApiFailure,
isLLMError,
type LLMError,
type ContentPart, type ContentPart,
type LLMRequest, type LLMRequest,
type ToolDefinition, type ToolDefinition,
type UsageInput, type UsageInput,
} from "@opencode-ai/llm" } 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 { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect" import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
@ -490,12 +513,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
Stream.unwrap( Stream.unwrap(
Effect.tryPromise({ Effect.tryPromise({
try: () => language.doStream(options), try: () => language.doStream(options),
catch: (error) => llmError("doStream", error), catch: (error) => llmError(error),
}).pipe( }).pipe(
Effect.map((result) => Effect.map((result) =>
Stream.fromReadableStream({ Stream.fromReadableStream({
evaluate: () => result.stream, evaluate: () => result.stream,
onError: (error) => llmError("readStream", error), onError: (error) => llmError(error),
}).pipe( }).pipe(
Stream.mapEffect((event) => streamPartEvents(state, event)), Stream.mapEffect((event) => streamPartEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)), Stream.flatMap((events) => Stream.fromIterable(events)),
@ -608,7 +631,7 @@ function streamPartEvents(
}), }),
]) ])
case "error": 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 BODY_LIMIT = 16_384
const reason =
error instanceof LLMError const headerRetryAfterMs = (headers: Record<string, string> | undefined) => {
? new InvalidProviderOutputReason({ message: error.message }) if (!headers) return undefined
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) }) const millis = Number(headers["retry-after-ms"])
return new LLMError({ if (Number.isFinite(millis)) return Math.max(0, millis)
module: "AISDK", const value = headers["retry-after"]
method, if (!value) return undefined
reason, 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: [] }) export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })

View file

@ -1,6 +1,6 @@
export * as Generate from "./generate" 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 { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "./catalog" import { Catalog } from "./catalog"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"

View file

@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction" 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 { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect" import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config" import { Config } from "../config"
@ -261,7 +261,7 @@ const make = (dependencies: Dependencies) => {
} }
return Effect.void return Effect.void
}), }),
Effect.catchTag("LLM.Error", (error) => Effect.catchIf(isLLMError, (error) =>
Effect.sync(() => { Effect.sync(() => {
failure = toSessionError(error) failure = toSessionError(error)
}), }),

View file

@ -3,11 +3,11 @@ export * as SessionRunnerLLM from "./llm"
import { import {
LLM, LLM,
LLMClient, LLMClient,
LLMError,
LLMEvent, LLMEvent,
Message, Message,
SystemPart, SystemPart,
isContextOverflowFailure, isContextOverflowFailure,
isLLMError,
type ProviderErrorEvent, type ProviderErrorEvent,
} from "@opencode-ai/llm" } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error" 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 // thrown LLM failure records the assistant failure unless a provider error was
// already recorded from the stream. Terminal publication waits for owned tools. // already recorded from the stream. Terminal publication waits for owned tools.
if (overflowFailure) yield* publish(overflowFailure) if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined const llmFailure = streamFailure !== undefined && isLLMError(streamFailure) ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) { if (llmFailure && !publisher.hasProviderError()) {
const error = toSessionError(llmFailure) const error = toSessionError(llmFailure)
if ( if (

View file

@ -1,6 +1,6 @@
export * as SessionRunnerRetry from "./retry" 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 { SessionError } from "@opencode-ai/schema/session-error"
import { Data, Duration, Effect, Schedule } from "effect" import { Data, Duration, Effect, Schedule } from "effect"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
@ -17,29 +17,33 @@ export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableF
}> {} }> {}
export function isRetryable(error: LLMError) { export function isRetryable(error: LLMError) {
switch (error.reason._tag) { switch (error._tag) {
case "RateLimit": case "LLM.RateLimit":
case "ProviderInternal": case "LLM.ServerError":
case "Transport": case "LLM.ConnectionError":
case "LLM.TimeoutError":
return true return true
case "Authentication": case "LLM.Authentication":
case "QuotaExceeded": case "LLM.PermissionDenied":
case "ContentPolicy": case "LLM.NotFound":
case "InvalidProviderOutput": case "LLM.QuotaExceeded":
case "InvalidRequest": case "LLM.ContentPolicy":
case "NoRoute": case "LLM.ContextOverflow":
case "UnknownProvider": case "LLM.MalformedResponse":
case "LLM.BadRequest":
case "LLM.NoRoute":
case "LLM.APIError":
return false return false
default: { default: {
const exhaustive: never = error.reason const exhaustive: never = error
return exhaustive return exhaustive
} }
} }
} }
const retryAfter = (failure: RetryableFailure) => { const retryAfter = (failure: RetryableFailure) => {
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal") if (failure.cause._tag === "LLM.RateLimit" || failure.cause._tag === "LLM.ServerError")
return failure.cause.reason.retryAfterMs return failure.cause.retryAfterMs
return undefined return undefined
} }

View file

@ -1,6 +1,6 @@
export * as SessionTitle from "./title" 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 { Context, DateTime, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { Database } from "../database/database" import { Database } from "../database/database"
@ -66,7 +66,7 @@ const make = (dependencies: Dependencies) => {
return Effect.void return Effect.void
}), }),
Effect.as(true), Effect.as(true),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)), Effect.catchIf(isLLMError, () => Effect.succeed(false)),
) )
if (!streamed || failed) return if (!streamed || failed) return
const title = chunks const title = chunks

View file

@ -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 { Tool } from "@opencode-ai/plugin/v2/effect/tool"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
@ -9,30 +9,38 @@ import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./err
import { SessionRunnerModel } from "./runner/model" import { SessionRunnerModel } from "./runner/model"
export function toSessionError(cause: unknown): SessionError.Error { export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof LLMError) { if (isLLMError(cause)) {
switch (cause.reason._tag) { switch (cause._tag) {
case "RateLimit": case "LLM.RateLimit":
return { type: "provider.rate-limit", message: cause.reason.message } return { type: "provider.rate-limit", message: cause.message }
case "Authentication": case "LLM.Authentication":
return { type: "provider.auth", message: cause.reason.message } return { type: "provider.auth", message: cause.message }
case "QuotaExceeded": case "LLM.PermissionDenied":
return { type: "provider.quota", message: cause.reason.message } return { type: "provider.auth", message: cause.message }
case "ContentPolicy": case "LLM.NotFound":
return { type: "provider.content-filter", message: cause.reason.message } return { type: "provider.not-found", message: cause.message }
case "Transport": case "LLM.QuotaExceeded":
return { type: "provider.transport", message: cause.reason.message } return { type: "provider.quota", message: cause.message }
case "ProviderInternal": case "LLM.ContentPolicy":
return { type: "provider.internal", message: cause.reason.message } return { type: "provider.content-filter", message: cause.message }
case "InvalidProviderOutput": case "LLM.ContextOverflow":
return { type: "provider.invalid-output", message: cause.reason.message } return { type: "provider.context-overflow", message: cause.message }
case "InvalidRequest": case "LLM.ConnectionError":
return { type: "provider.invalid-request", message: cause.reason.message } return { type: "provider.transport", message: cause.message }
case "NoRoute": case "LLM.TimeoutError":
return { type: "provider.no-route", message: cause.reason.message } return { type: "provider.timeout", message: cause.message }
case "UnknownProvider": case "LLM.ServerError":
return { type: "provider.unknown", message: cause.reason.message } 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: { default: {
const exhaustive: never = cause.reason const exhaustive: never = cause
return exhaustive return exhaustive
} }
} }

View file

@ -1,18 +1,22 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import {
AuthenticationReason, APIError,
ContentPolicyReason, Authentication,
InvalidProviderOutputReason, BadRequest,
InvalidRequestReason, ConnectionError,
LLMError, ContentPolicy,
NoRouteReason, ContextOverflow,
MalformedResponse,
ModelID, ModelID,
NoRoute,
NotFound,
PermissionDenied,
ProviderID, ProviderID,
ProviderInternalReason, QuotaExceeded,
QuotaExceededReason, RateLimit,
RateLimitReason, RouteID,
TransportReason, ServerError,
UnknownProviderReason, TimeoutError,
ToolFailure, ToolFailure,
} from "@opencode-ai/llm" } from "@opencode-ai/llm"
import { PermissionV2 } from "@opencode-ai/core/permission" 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 { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry" import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
describe("toSessionError", () => { describe("toSessionError", () => {
test("maps every LLM reason to the open wire type", () => { test("maps every LLM error tag to the open wire type", () => {
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({ expect(toSessionError(new RateLimit({ message: "rate", retryAfterMs: 123 }))).toEqual({
type: "provider.rate-limit", type: "provider.rate-limit",
message: "rate", message: "rate",
}) })
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe( expect(toSessionError(new Authentication({ message: "auth" })).type).toBe("provider.auth")
"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(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota") expect(toSessionError(new QuotaExceeded({ message: "quota" })).type).toBe("provider.quota")
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter") expect(toSessionError(new ContentPolicy({ message: "blocked" })).type).toBe("provider.content-filter")
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport") expect(toSessionError(new ContextOverflow({ message: "too long" })).type).toBe("provider.context-overflow")
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe( expect(toSessionError(new ConnectionError({ message: "reset" })).type).toBe("provider.transport")
"provider.internal", 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(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe( expect(toSessionError(new MalformedResponse({ message: "output" })).type).toBe("provider.invalid-output")
"provider.invalid-output", expect(toSessionError(new BadRequest({ message: "request" })).type).toBe("provider.invalid-request")
)
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
expect( expect(
toSessionError( toSessionError(
llm( new NoRoute({
new NoRouteReason({ route: RouteID.make("route"),
route: "route", provider: ProviderID.make("provider"),
provider: ProviderID.make("provider"), model: ModelID.make("model"),
model: ModelID.make("model"), }),
}),
),
).type, ).type,
).toBe("provider.no-route") ).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", () => { 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 = [ const eligible = [
llm(new RateLimitReason({ message: "rate" })), new RateLimit({ message: "rate" }),
llm(new ProviderInternalReason({ message: "internal", status: 500 })), new ServerError({ message: "internal", status: 500 }),
llm(new TransportReason({ message: "transport" })), new ConnectionError({ message: "reset" }),
new TimeoutError({ message: "timed out" }),
] ]
const ineligible = [ const ineligible = [
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })), new Authentication({ message: "auth" }),
llm(new QuotaExceededReason({ message: "quota" })), new PermissionDenied({ message: "forbidden" }),
llm(new ContentPolicyReason({ message: "blocked" })), new NotFound({ message: "missing" }),
llm(new InvalidProviderOutputReason({ message: "output" })), new QuotaExceeded({ message: "quota" }),
llm(new InvalidRequestReason({ message: "request" })), new ContentPolicy({ message: "blocked" }),
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })), new ContextOverflow({ message: "too long" }),
llm(new UnknownProviderReason({ message: "unknown" })), 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(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false]) expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual(ineligible.map(() => false))
}) })
}) })

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" 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 { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" 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", () => { describe("SessionExecution lifecycle", () => {
test("classifies success and typed failure terminals", () => { test("classifies success and typed failure terminals", () => {
expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" }) expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
expect( expect(SessionExecution.terminal(Exit.fail(new ConnectionError({ message: "Disconnected" })))).toEqual({
SessionExecution.terminal( type: "failed",
Exit.fail( error: { type: "provider.transport", message: "Disconnected" },
new LLMError({ })
module: "test",
method: "stream",
reason: new TransportReason({ message: "Disconnected" }),
}),
),
),
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") }) const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({ expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({
type: "failed", type: "failed",

View file

@ -1,15 +1,16 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import {
BadRequest,
ConnectionError,
ContextOverflow,
LLMClient, LLMClient,
LLMError,
LLMEvent, LLMEvent,
MalformedResponse,
Model, Model,
RateLimit,
ToolFailure, ToolFailure,
TransportReason,
InvalidProviderOutputReason,
InvalidRequestReason,
RateLimitReason,
type LLMClientShape, type LLMClientShape,
type LLMError,
type LLMRequest, type LLMRequest,
} from "@opencode-ai/llm" } from "@opencode-ai/llm"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
@ -483,26 +484,11 @@ const setup = Effect.gen(function* () {
return yield* SessionV2.Service return yield* SessionV2.Service
}) })
const providerUnavailable = () => const providerUnavailable = () => new ConnectionError({ message: "Provider unavailable" })
new LLMError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Provider unavailable" }),
})
const invalidRequest = () => const invalidRequest = () => new BadRequest({ message: "Invalid request" })
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({ message: "Invalid request" }),
})
const rateLimited = (retryAfterMs?: number) => const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs })
new LLMError({
module: "test",
method: "stream",
reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
})
const setupOverflowRecovery = Effect.gen(function* () { const setupOverflowRecovery = Effect.gen(function* () {
const session = yield* setup const session = yield* setup
@ -1985,16 +1971,7 @@ describe("SessionRunnerLLM", () => {
it.effect("recovers once from a raw context overflow failure", () => it.effect("recovers once from a raw context overflow failure", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setupOverflowRecovery const session = yield* setupOverflowRecovery
responseStream = Stream.fail( responseStream = Stream.fail(new ContextOverflow({ message: "prompt too long" }))
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({
message: "prompt too long",
classification: "context-overflow",
}),
}),
)
responses = [ responses = [
reply.text("## Objective\n- Recover raw overflow", "text-summary"), reply.text("## Objective\n- Recover raw overflow", "text-summary"),
reply.text("Recovered", "text-final"), reply.text("Recovered", "text-final"),
@ -3892,11 +3869,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
yield* admit(session, "Call a malformed tool") yield* admit(session, "Call a malformed tool")
const failure = new LLMError({ const failure = new MalformedResponse({ message: "Invalid JSON input for tool call echo" })
module: "test",
method: "stream",
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
})
responseStream = Stream.fromIterable([ responseStream = Stream.fromIterable([
LLMEvent.stepStart({ index: 0 }), LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }), LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),

View file

@ -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. - `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 `<route>` tool call `<name>`" error message. Treats empty input as `{}`. - `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies. - `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures. - `eventError(route, message, ...)` — typed `MalformedResponse` 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. - `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. - `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. 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.

View file

@ -2,7 +2,7 @@ export { LLMClient } from "./route/client"
export { Auth } from "./route/auth" export { Auth } from "./route/auth"
export { Provider } from "./provider" export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package" export { ProviderPackage } from "./provider-package"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error" export { classifyApiFailure, isContextOverflow, isContextOverflowFailure, type ApiFailure } from "./provider-error"
export type { export type {
RouteModelInput, RouteModelInput,
RouteRoutedModelInput, RouteRoutedModelInput,

View file

@ -3,8 +3,8 @@ import { LLMClient } from "./route/client"
import { import {
GenerationOptions, GenerationOptions,
HttpOptions, HttpOptions,
InvalidProviderOutputReason, MalformedResponse,
LLMError, type LLMError,
LLMEvent, LLMEvent,
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
@ -121,22 +121,14 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME, (event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
) )
if (!call || !LLMEvent.is.toolCall(call)) if (!call || !LLMEvent.is.toolCall(call))
return yield* new LLMError({ return yield* new MalformedResponse({
module: "LLM", message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
method: "generateObject",
reason: new InvalidProviderOutputReason({
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
}),
}) })
const object = yield* tool._decode(call.input).pipe( const object = yield* tool._decode(call.input).pipe(
Effect.mapError( Effect.mapError(
(error) => (error) =>
new LLMError({ new MalformedResponse({
module: "LLM", message: `generateObject: tool input failed schema decode: ${error.message}`,
method: "generateObject",
reason: new InvalidProviderOutputReason({
message: `generateObject: tool input failed schema decode: ${error.message}`,
}),
}), }),
), ),
) )

View file

@ -3,9 +3,9 @@ import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse" import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
import { import {
InvalidProviderOutputReason, BadRequest,
InvalidRequestReason, MalformedResponse,
LLMError, type LLMError,
type ContentPart, type ContentPart,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
@ -88,11 +88,7 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
} }
export const eventError = (route: string, message: string, raw?: string) => export const eventError = (route: string, message: string, raw?: string) =>
new LLMError({ new MalformedResponse({ route, message, raw })
module: "ProviderShared",
method: "stream",
reason: new InvalidProviderOutputReason({ route, message, raw }),
})
export const parseJson = (route: string, input: string, message: string) => export const parseJson = (route: string, input: string, message: string) =>
Effect.try({ Effect.try({
@ -252,15 +248,9 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
* Canonical invalid-request constructor. Lift one-line `const invalid = * Canonical invalid-request constructor. Lift one-line `const invalid =
* (message) => invalidRequest(message)` aliases out of every * (message) => invalidRequest(message)` aliases out of every
* route so the error constructor lives in one place. If we ever extend * route so the error constructor lives in one place. If we ever extend
* `InvalidRequestReason` with route context or trace metadata, the change * `BadRequest` with route context or trace metadata, the change lands here.
* lands here.
*/ */
export const invalidRequest = (message: string) => export const invalidRequest = (message: string) => new BadRequest({ message })
new LLMError({
module: "ProviderShared",
method: "request",
reason: new InvalidRequestReason({ message }),
})
export const matchToolChoice = <Auto, None, Required, Tool>( export const matchToolChoice = <Auto, None, Required, Tool>(
route: string, route: string,

View file

@ -1,5 +1,5 @@
import { Effect } from "effect" 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" import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number type StreamKey = string | number
@ -95,7 +95,7 @@ const appendTool = <K extends StreamKey>(
} }
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError => export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
result instanceof LLMError isLLMError(result)
/** /**
* Register a tool call whose start event arrived before any argument deltas. * Register a tool call whose start event arrived before any argument deltas.

View file

@ -1,5 +1,22 @@
import { Schema } from "effect" 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 = [ const patterns = [
/prompt is too long/i, /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) patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
export const isContextOverflowFailure = (failure: unknown) => export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError isLLMError(failure)
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow" ? failure._tag === "LLM.ContextOverflow"
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"])
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
const SERVER_ERROR_STATUS = (status: number) => status >= 500 || status === 529
const CODE_CLASSIFICATION: Record<string, (input: ApiFailure, common: CommonFields) => 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)
}

View file

@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect" import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http" 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 { export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError" readonly _tag = "MissingCredentialError"
@ -135,16 +135,9 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
} }
const toLLMError = (error: AuthError): LLMError => { const toLLMError = (error: AuthError): LLMError => {
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) { if (error instanceof MissingCredentialError) return new Authentication({ message: error.message })
return new LLMError({ if (error instanceof Config.ConfigError)
module: "Auth", return new BadRequest({ message: `Failed to resolve auth config: ${error.message}` })
method: "apply",
reason:
error instanceof MissingCredentialError
? new AuthenticationReason({ message: error.message, kind: "missing" })
: new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }),
})
}
return error return error
} }

View file

@ -14,11 +14,11 @@ import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions
import { import {
GenerationOptions, GenerationOptions,
HttpOptions, HttpOptions,
isLLMError,
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
Model, Model,
ModelLimits, ModelLimits,
LLMError as LLMErrorClass,
PreparedRequest, PreparedRequest,
ProviderID, ProviderID,
mergeGenerationOptions, mergeGenerationOptions,
@ -225,7 +225,7 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => { const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
const failed = cause.reasons.find(Cause.isFailReason)?.error 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)) return ProviderShared.eventError(route, message, Cause.pretty(cause))
} }

View file

@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer } from "effect" import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
import { import {
FetchHttpClient, FetchHttpClient,
Headers, Headers,
@ -8,21 +8,15 @@ import {
HttpClientResponse, HttpClientResponse,
} from "effect/unstable/http" } from "effect/unstable/http"
import { import {
AuthenticationReason, ConnectionError,
ContentPolicyReason,
HttpContext, HttpContext,
HttpRateLimitDetails, HttpRateLimitDetails,
HttpRequestDetails, HttpRequestDetails,
HttpResponseDetails, HttpResponseDetails,
InvalidRequestReason, TimeoutError,
LLMError, type LLMError,
ProviderInternalReason,
QuotaExceededReason,
RateLimitReason,
TransportReason,
UnknownProviderReason,
} from "../schema" } from "../schema"
import { isContextOverflow } from "../provider-error" import { classifyApiFailure } from "../provider-error"
export interface Interface { export interface Interface {
readonly execute: ( readonly execute: (
@ -85,8 +79,6 @@ const requestId = (headers: Record<string, string>) => {
) )
} }
const providerInternalStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529
const retryAfterMs = (headers: Record<string, string>) => { const retryAfterMs = (headers: Record<string, string>) => {
const millis = Number(headers["retry-after-ms"]) const millis = Number(headers["retry-after-ms"])
if (Number.isFinite(millis)) return Math.max(0, millis) if (Number.isFinite(millis)) return Math.max(0, millis)
@ -219,56 +211,21 @@ const responseHttp = (input: {
rateLimit: input.rateLimit, rateLimit: input.rateLimit,
}) })
const statusReason = (input: { const decodeBodyJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
readonly status: number
readonly message: string // Provider machine code from a JSON error body (`error.code` / `error.type`),
readonly retryAfterMs?: number | undefined // fed to the shared classifier so code-based rules (overflow, quota) work on
readonly rateLimit?: HttpRateLimitDetails | undefined // HTTP rejections too. Truncated or non-JSON bodies yield undefined.
readonly http: HttpContext const providerCode = (body: string | undefined) => {
}) => { if (!body) return undefined
const body = input.http.body ?? "" const decoded = Option.getOrUndefined(decodeBodyJson(body))
if (/content[-_\s]?policy|content_filter|safety/i.test(body)) { if (typeof decoded !== "object" || decoded === null) return undefined
return new ContentPolicyReason({ message: input.message, http: input.http }) const error = (decoded as Record<string, unknown>).error
} if (typeof error !== "object" || error === null) return undefined
if (input.status === 401) { const fields = error as Record<string, unknown>
return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http }) if (typeof fields.code === "string") return fields.code
} if (typeof fields.type === "string") return fields.type
if (input.status === 403) { return undefined
return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http })
}
if (input.status === 429) {
if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) {
return new QuotaExceededReason({ message: input.message, http: input.http })
}
return new RateLimitReason({
message: input.message,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
http: input.http,
})
}
if (
input.status === 400 ||
input.status === 404 ||
input.status === 409 ||
input.status === 413 ||
input.status === 422
) {
return new InvalidRequestReason({
message: input.message,
classification: isContextOverflow(body) ? "context-overflow" : undefined,
http: input.http,
})
}
if (input.status >= 500 || providerInternalStatus(input.status)) {
return new ProviderInternalReason({
message: input.message,
status: input.status,
retryAfterMs: input.retryAfterMs,
http: input.http,
})
}
return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http })
} }
const statusError = const statusError =
@ -281,58 +238,55 @@ const statusError =
const retryAfter = retryAfterMs(headers) const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter) const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, request) const details = responseBody(body, request)
return yield* new LLMError({ return yield* classifyApiFailure({
module: "RequestExecutor", status: response.status,
method: "execute", message: providerMessage(response.status, details),
reason: statusReason({ code: providerCode(details.body),
status: response.status, retryAfterMs: retryAfter,
message: providerMessage(response.status, details), rateLimit,
retryAfterMs: retryAfter, requestID: requestId(headers),
http: responseHttp({
request,
response,
redactedNames,
body: details,
requestId: requestId(headers),
rateLimit, rateLimit,
http: responseHttp({
request,
response,
redactedNames,
body: details,
requestId: requestId(headers),
rateLimit,
}),
}), }),
}) })
}) })
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => { const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (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 message: string
readonly kind?: string | undefined readonly kind?: string | undefined
readonly request?: HttpClientRequest.HttpClientRequest | undefined readonly request?: HttpClientRequest.HttpClientRequest | undefined
}) => }) =>
new LLMError({ new ConnectionError({
module: "RequestExecutor", message: input.message,
method: "execute", kind: input.kind,
reason: new TransportReason({ url: input.request ? redactUrl(input.request.url) : undefined,
message: input.message, http: httpContext(input.request),
kind: input.kind, cause: error,
url: input.request ? redactUrl(input.request.url) : undefined,
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
}),
}) })
if (Cause.isTimeoutError(error)) { if (Cause.isTimeoutError(error)) {
return transportError({ message: error.message, kind: "Timeout" }) return new TimeoutError({ message: error.message })
} }
if (!HttpClientError.isHttpClientError(error)) { if (!HttpClientError.isHttpClientError(error)) {
return transportError({ message: "HTTP transport failed" }) return connectionError({ message: "HTTP transport failed" })
} }
const request = "request" in error ? error.request : undefined const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") { if (error.reason._tag === "TransportError") {
return transportError({ return connectionError({
message: error.reason.description ?? "HTTP transport failed", message: error.reason.description ?? "HTTP transport failed",
kind: error.reason._tag, kind: error.reason._tag,
request, request,
}) })
} }
return transportError({ return connectionError({
message: `HTTP transport failed: ${error.reason._tag}`, message: `HTTP transport failed: ${error.reason._tag}`,
kind: error.reason._tag, kind: error.reason._tag,
request, request,

View file

@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { LLMError, TransportReason } from "../../schema" import { ConnectionError, type LLMError } from "../../schema"
import * as HttpTransport from "./http" import * as HttpTransport from "./http"
import type { Transport } from "./index" import type { Transport } from "./index"
@ -27,15 +27,10 @@ type WebSocketConstructorWithHeaders = new (
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {}
const transportError = ( const transportError = (
method: string, _method: string,
message: string, message: string,
input: { readonly url?: string; readonly kind?: string } = {}, input: { readonly url?: string; readonly kind?: string } = {},
) => ) => new ConnectionError({ message, url: input.url, kind: input.kind })
new LLMError({
module: "WebSocketExecutor",
method,
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
})
const eventMessage = (event: Event) => { const eventMessage = (event: Event) => {
if ("message" in event && typeof event.message === "string") return event.message if ("message" in event && typeof event.message === "string") return event.message

View file

@ -31,118 +31,150 @@ export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
rateLimit: Schema.optional(HttpRateLimitDetails), rateLimit: Schema.optional(HttpRateLimitDetails),
}) {} }) {}
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("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, message: Schema.String,
parameter: Schema.optional(Schema.String), status: Schema.optional(Schema.Number),
classification: Schema.optional(ProviderFailureClassification), code: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata), requestID: Schema.optional(Schema.String),
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} providerMetadata: Schema.optional(ProviderMetadata),
export class NoRouteReason extends Schema.Class<NoRouteReason>("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}`
}
} }
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.Error.Authentication")({ /** Provider rejected the request as invalid (400/409/422, `invalid_request_error`, ...). */
_tag: Schema.tag("Authentication"), export class BadRequest extends Schema.TaggedErrorClass<BadRequest>()("LLM.BadRequest", {
message: Schema.String, ...apiFailureFields,
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]), parameter: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {} }) {}
export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.RateLimit")({ /** Credentials are missing, invalid, or expired (401). */
_tag: Schema.tag("RateLimit"), export class Authentication extends Schema.TaggedErrorClass<Authentication>()("LLM.Authentication", {
message: Schema.String, ...apiFailureFields,
}) {}
/** Authenticated but not allowed (403). */
export class PermissionDenied extends Schema.TaggedErrorClass<PermissionDenied>()("LLM.PermissionDenied", {
...apiFailureFields,
}) {}
/** Model or endpoint does not exist (404). */
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("LLM.NotFound", {
...apiFailureFields,
}) {}
/** Transient request throttling (429). Retryable; honor `retryAfterMs` when present. */
export class RateLimit extends Schema.TaggedErrorClass<RateLimit>()("LLM.RateLimit", {
...apiFailureFields,
retryAfterMs: Schema.optional(Schema.Number), retryAfterMs: Schema.optional(Schema.Number),
rateLimit: Schema.optional(HttpRateLimitDetails), rateLimit: Schema.optional(HttpRateLimitDetails),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {} }) {}
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({ /** Account-level quota or billing exhaustion. Unlike `RateLimit`, waiting does not help. */
_tag: Schema.tag("QuotaExceeded"), export class QuotaExceeded extends Schema.TaggedErrorClass<QuotaExceeded>()("LLM.QuotaExceeded", {
message: Schema.String, ...apiFailureFields,
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {} }) {}
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({ /** Provider refused the content for policy/safety reasons. */
_tag: Schema.tag("ContentPolicy"), export class ContentPolicy extends Schema.TaggedErrorClass<ContentPolicy>()("LLM.ContentPolicy", {
message: Schema.String, ...apiFailureFields,
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {} }) {}
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({ /**
_tag: Schema.tag("ProviderInternal"), * The request exceeds the model's context window. Designated tag because
message: Schema.String, * Core recovers from it structurally (compaction) rather than surfacing it.
status: Schema.Number, * Upgraded from `BadRequest` by the shared classifier in `provider-error.ts`.
*/
export class ContextOverflow extends Schema.TaggedErrorClass<ContextOverflow>()("LLM.ContextOverflow", {
...apiFailureFields,
}) {}
/** Provider-side failure (5xx, `overloaded_error`, internal exceptions). Retryable. */
export class ServerError extends Schema.TaggedErrorClass<ServerError>()("LLM.ServerError", {
...apiFailureFields,
retryAfterMs: Schema.optional(Schema.Number), retryAfterMs: Schema.optional(Schema.Number),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {} }) {}
export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Transport")({ /** Any other deliberate API rejection that matches no designated tag (402, 405, 410, ...). */
_tag: Schema.tag("Transport"), export class APIError extends Schema.TaggedErrorClass<APIError>()("LLM.APIError", {
...apiFailureFields,
}) {}
/** Communication failed: connect failure, reset, socket close, DNS. No API response involved. */
export class ConnectionError extends Schema.TaggedErrorClass<ConnectionError>()("LLM.ConnectionError", {
message: Schema.String, message: Schema.String,
kind: Schema.optional(Schema.String), kind: Schema.optional(Schema.String),
url: Schema.optional(Schema.String), url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
cause: Schema.optional(Schema.Defect()),
}) {} }) {}
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>( /** The request or stream read timed out before the provider answered. */
"LLM.Error.InvalidProviderOutput", export class TimeoutError extends Schema.TaggedErrorClass<TimeoutError>()("LLM.TimeoutError", {
)({ message: Schema.String,
_tag: Schema.tag("InvalidProviderOutput"), url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
}) {}
/**
* 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<MalformedResponse>()("LLM.MalformedResponse", {
message: Schema.String, message: Schema.String,
route: Schema.optional(Schema.String), route: Schema.optional(Schema.String),
raw: Schema.optional(Schema.String), raw: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}) {} }) {}
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.Error.UnknownProvider")({ /** Request construction failed locally: the selected model resolves to no executable route. */
_tag: Schema.tag("UnknownProvider"), export class NoRoute extends Schema.TaggedErrorClass<NoRoute>()("LLM.NoRoute", {
message: Schema.String, route: RouteID,
status: Schema.optional(Schema.Number), provider: ProviderID,
providerMetadata: Schema.optional(ProviderMetadata), model: ModelID,
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<typeof LLMErrorReason>
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
module: Schema.String,
method: Schema.String,
reason: LLMErrorReason,
}) { }) {
override readonly cause = this.reason
override get message() { 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 * Failure type for tool execute handlers. Handlers must map their internal
* errors to this shape; the runtime catches `ToolFailure`s and surfaces them * errors to this shape; the runtime catches `ToolFailure`s and surfaces them

View file

@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Ref } from "effect" import { Effect, Layer, Ref } from "effect"
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" 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 { LLMClient, RequestExecutor } from "../src/route"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import { dynamicResponse } from "./lib/http" import { dynamicResponse } from "./lib/http"
@ -59,12 +59,12 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
) )
const expectLLMError = (error: unknown) => { const expectLLMError = (error: unknown) => {
expect(error).toBeInstanceOf(LLMError) expect(isLLMError(error)).toBe(true)
if (!(error instanceof LLMError)) throw new Error("expected LLMError") if (!isLLMError(error)) throw new Error("expected LLMError")
return error 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", () => { describe("RequestExecutor", () => {
it.effect("classifies context overflow responses", () => it.effect("classifies context overflow responses", () =>
@ -73,7 +73,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" }) expect(error).toMatchObject({ _tag: "LLM.ContextOverflow" })
}).pipe( }).pipe(
Effect.provide( Effect.provide(
responsesLayer([ responsesLayer([
@ -91,8 +91,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))), }).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) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))), }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
) )
@ -114,24 +112,22 @@ describe("RequestExecutor", () => {
expectLLMError(error) expectLLMError(error)
expect(error).toMatchObject({ expect(error).toMatchObject({
reason: { _tag: "LLM.RateLimit",
_tag: "RateLimit", retryAfterMs: 0,
retryAfterMs: 0, rateLimit: { retryAfterMs: 0 },
rateLimit: { retryAfterMs: 0 }, http: {
http: { requestId: "req_123",
requestId: "req_123", request: {
request: { method: "POST",
method: "POST", url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1", headers: { authorization: "<redacted>", "x-safe": "visible" },
headers: { authorization: "<redacted>", "x-safe": "visible" }, },
}, response: {
response: { status: 429,
status: 429, headers: {
headers: { "retry-after-ms": "0",
"retry-after-ms": "0", "x-request-id": "req_123",
"x-request-id": "req_123", "x-api-key": "<redacted>",
"x-api-key": "<redacted>",
},
}, },
}, },
}, },
@ -169,8 +165,8 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" }) expect(error).toMatchObject({ _tag: "LLM.RateLimit" })
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({ expect(error._tag === "LLM.RateLimit" ? error.rateLimit : undefined).toEqual({
retryAfterMs: 0, retryAfterMs: 0,
limit: { requests: "500", tokens: "30000" }, limit: { requests: "500", tokens: "30000" },
remaining: { requests: "499", tokens: "29900" }, remaining: { requests: "499", tokens: "29900" },
@ -202,7 +198,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) expect(error).toMatchObject({ _tag: "LLM.ServerError" })
expect(errorHttp(error)?.rateLimit).toEqual({ expect(errorHttp(error)?.rateLimit).toEqual({
retryAfterMs: 0, retryAfterMs: 0,
limit: { requests: "100", "input-tokens": "10000" }, limit: { requests: "100", "input-tokens": "10000" },
@ -245,12 +241,12 @@ describe("RequestExecutor", () => {
) )
expectLLMError(error) 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) 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* () { Effect.gen(function* () {
const failWith = (status: number) => const failWith = (status: number) =>
Effect.gen(function* () { Effect.gen(function* () {
@ -258,7 +254,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status }) expect(error).toMatchObject({ _tag: "LLM.ServerError", status })
}).pipe( }).pipe(
Effect.provide( Effect.provide(
responsesLayer([ responsesLayer([
@ -281,7 +277,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" }) expect(error).toMatchObject({ _tag: "LLM.Authentication" })
expect(errorHttp(error)?.bodyTruncated).toBe(true) expect(errorHttp(error)?.bodyTruncated).toBe(true)
expect(errorHttp(error)?.body).toHaveLength(16_384) expect(errorHttp(error)?.body).toHaveLength(16_384)
}).pipe( }).pipe(
@ -360,7 +356,7 @@ describe("RequestExecutor", () => {
) )
expectLLMError(error) expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
expect(yield* Ref.get(attempts)).toBe(1) expect(yield* Ref.get(attempts)).toBe(1)
}), }),
) )

View file

@ -149,8 +149,8 @@ describe("request option precedence", () => {
}), }),
).pipe(Effect.flip) ).pipe(Effect.flip)
expect(error.reason).toMatchObject({ expect(error).toMatchObject({
_tag: "InvalidRequest", _tag: "LLM.BadRequest",
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools", message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
}) })
}), }),

View file

@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLM, LLMError, Message, ToolCallPart } from "../../src" import { isLLMError, LLM, Message, ToolCallPart } from "../../src"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import * as Anthropic from "../../src/providers/anthropic" import * as Anthropic from "../../src/providers/anthropic"
import { weatherToolName } from "../recorded-scenarios" import { weatherToolName } from "../recorded-scenarios"
@ -22,6 +22,9 @@ const malformedToolOrderRequest = LLM.request({
Message.user("Use that result to answer briefly."), Message.user("Use that result to answer briefly."),
], ],
tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }], 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({ const recorded = recordedTests({
@ -33,13 +36,17 @@ const recorded = recordedTests({
}) })
describe("Anthropic Messages sad-path recorded", () => { describe("Anthropic Messages sad-path recorded", () => {
recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () => recorded.effect.with(
Effect.gen(function* () { "rejects malformed assistant tool order",
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip) // 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(isLLMError(error)).toBe(true)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.message).toContain("HTTP 400") expect(error.message).toContain("HTTP 400")
}), }),
) )
}) })

View file

@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http" 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 { Auth, LLMClient } from "../../src/route"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios" import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
@ -553,8 +553,8 @@ describe("Anthropic Messages route", () => {
Effect.flip, Effect.flip,
) )
expect(error).toBeInstanceOf(LLMError) expect(isLLMError(error)).toBe(true)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.message).toContain("HTTP 400") expect(error.message).toContain("HTTP 400")
}), }),
) )

View file

@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" 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 { Auth, LLMClient } from "../../src/route"
import * as Gemini from "../../src/protocols/gemini" import * as Gemini from "../../src/protocols/gemini"
import { ProviderShared } from "../../src/protocols/shared" import { ProviderShared } from "../../src/protocols/shared"
@ -560,8 +560,8 @@ describe("Gemini route", () => {
Effect.flip, Effect.flip,
) )
expect(error).toBeInstanceOf(LLMError) expect(isLLMError(error)).toBe(true)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
expect(error.message).toContain("Invalid google/gemini stream event") expect(error.message).toContain("Invalid google/gemini stream event")
}), }),
) )

View file

@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http" 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 Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai" import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat" import * as OpenAIChat from "../../src/protocols/openai-chat"
@ -662,8 +662,8 @@ describe("OpenAI Chat route", () => {
Effect.flip, Effect.flip,
) )
expect(error).toBeInstanceOf(LLMError) expect(isLLMError(error)).toBe(true)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.message).toContain("HTTP 400") expect(error.message).toContain("HTTP 400")
}), }),
) )

View file

@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect" import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http" 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 { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure" import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai" import * as OpenAI from "../../src/providers/openai"
@ -1562,8 +1562,8 @@ describe("OpenAI Responses route", () => {
Effect.flip, Effect.flip,
) )
expect(error).toBeInstanceOf(LLMError) expect(isLLMError(error)).toBe(true)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.message).toContain("HTTP 400") expect(error.message).toContain("HTTP 400")
}), }),
) )

View file

@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLMError } from "../src/schema" import { isLLMError } from "../src/schema"
import { ToolStream } from "../src/protocols/utils/tool-stream" import { ToolStream } from "../src/protocols/utils/tool-stream"
import { it } from "./lib/effect" import { it } from "./lib/effect"
@ -40,8 +40,9 @@ describe("ToolStream", () => {
Effect.gen(function* () { Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool") const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
expect(error).toBeInstanceOf(LLMError) expect(isLLMError(error)).toBe(true)
if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool") if (ToolStream.isError(error))
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse", message: "missing tool" })
}), }),
) )