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

@ -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<number>, 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: "<redacted>", "x-safe": "visible" },
},
response: {
status: 429,
headers: {
"retry-after-ms": "0",
"x-request-id": "req_123",
"x-api-key": "<redacted>",
},
_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: "<redacted>", "x-safe": "visible" },
},
response: {
status: 429,
headers: {
"retry-after-ms": "0",
"x-request-id": "req_123",
"x-api-key": "<redacted>",
},
},
},
@ -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)
}),
)

View file

@ -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",
})
}),

View file

@ -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")
}),
)
})

View file

@ -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")
}),
)

View file

@ -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")
}),
)

View file

@ -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")
}),
)

View file

@ -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")
}),
)

View file

@ -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<number>(), 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" })
}),
)