refactor(llm): redesign error model as flat tagged union

- Replace LLMError { module, method, reason } wrapper with a flat tagged
  union: BadRequest, Authentication, PermissionDenied, NotFound, RateLimit,
  QuotaExceeded, ContentPolicy, ContextOverflow, ServerError, APIError,
  ConnectionError, TimeoutError, MalformedResponse, NoRoute.
- Delete the provider-error LLMEvent: streams carry output only and every
  failure exits through the typed error channel.
- Add one shared classifyApiFailure classifier used by the HTTP executor,
  protocol stream errors, and the AI SDK adapter so all routes classify
  identically (including OpenAI in-stream rate_limit_exceeded and
  internal_error codes).
- Enforce a terminal contract in LLMClient.stream for every route: EOF
  without finish and output after finish fail as MalformedResponse.
- Classify AI SDK failures properly in core/aisdk.ts instead of collapsing
  to UnknownProvider; preserve status, headers, body, and retry-after.
- Simplify the session runner: drop held-back overflow events, key overflow
  recovery off LLM.ContextOverflow, retry RateLimit | ServerError |
  ConnectionError | TimeoutError.
- Map new tags in toSessionError (provider.context-overflow,
  provider.timeout, provider.not-found).
This commit is contained in:
Aiden Cline 2026-07-13 00:47:32 -05:00
commit bd51cdba12
37 changed files with 776 additions and 676 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"
@ -484,23 +484,25 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("emits provider-error events for mid-stream provider errors", () =>
it.effect("fails the stream for mid-stream provider errors", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
),
Effect.flip,
)
// Prefix the error type so consumers can distinguish overloads, rate
// limits, and quota errors without parsing the message string.
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error: Overloaded" })
}),
)
it.effect("classifies prompt-too-long provider errors", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@ -509,35 +511,35 @@ describe("Anthropic Messages route", () => {
}),
),
),
Effect.flip,
)
expect(response.events).toEqual([
{
type: "provider-error",
message: "invalid_request_error: prompt is too long: 210000 tokens",
classification: "context-overflow",
},
])
expect(error).toMatchObject({
_tag: "LLM.ContextOverflow",
message: "invalid_request_error: prompt is too long: 210000 tokens",
})
}),
)
it.effect("falls back to error type when no message is present", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }])
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error" })
}),
)
it.effect("falls back to a stable default when error payload is absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }])
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Anthropic Messages stream error" })
}),
)
@ -553,8 +555,8 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.message).toContain("HTTP 400")
}),
)

View file

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

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"
@ -614,8 +614,11 @@ describe("OpenAI Chat route", () => {
const input = LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
})
const events = Array.from(
yield* LLMClient.stream(input).pipe(Stream.runCollect, Effect.provide(fixedResponse(body))),
const events: LLMEvent[] = []
const streamError = yield* LLMClient.stream(input).pipe(
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
Effect.flip,
Effect.provide(fixedResponse(body)),
)
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
@ -626,6 +629,7 @@ describe("OpenAI Chat route", () => {
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(streamError.message).toContain("Provider stream ended without a terminal finish event")
expect(error.message).toContain("Provider stream ended without a terminal finish event")
}),
)
@ -662,8 +666,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"
@ -1368,37 +1368,41 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("emits provider-error events for mid-stream provider errors", () =>
it.effect("fails the stream for mid-stream provider errors", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
Effect.flip,
)
// Prefix the code so consumers see the failure mode, not just the
// sometimes-generic provider message. The bare message alone meant
// production errors like rate limits were indistinguishable from
// unrelated stream failures.
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "rate_limit_exceeded: Slow down" })
}),
)
it.effect("falls back to error code when no message is present", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
}),
)
it.effect("falls back to error code when message is empty", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
}),
)
@ -1408,7 +1412,7 @@ describe("OpenAI Responses route", () => {
// "OpenAI Responses response failed" string, hiding the real cause.
it.effect("surfaces response.failed details from response.error", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@ -1420,15 +1424,16 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }])
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "server_error: Upstream model unavailable" })
}),
)
it.effect("surfaces response.failed code when no nested message is present", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@ -1437,9 +1442,10 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
expect(error).toMatchObject({ _tag: "LLM.BadRequest", message: "invalid_prompt" })
}),
)
@ -1450,7 +1456,7 @@ describe("OpenAI Responses route", () => {
// when they bubble up an HTTP error as an SSE `error` event. Honour
// both shapes so the user still sees the underlying cause instead
// of the catch-all string.
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@ -1459,21 +1465,19 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(response.events).toEqual([
{
type: "provider-error",
message: "context_length_exceeded: prompt too long",
classification: "context-overflow",
},
])
expect(error).toMatchObject({
_tag: "LLM.ContextOverflow",
message: "context_length_exceeded: prompt too long",
})
}),
)
it.effect("surfaces error event details nested under error", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@ -1488,21 +1492,19 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(response.events).toEqual([
{
type: "provider-error",
message: "context_length_exceeded: prompt too long",
classification: "context-overflow",
},
])
expect(error).toMatchObject({
_tag: "LLM.ContextOverflow",
message: "context_length_exceeded: prompt too long",
})
}),
)
it.effect("accepts nullable fields in spec-compliant error events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@ -1514,39 +1516,43 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }])
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Something went wrong" })
}),
)
it.effect("falls back to a stable default when error is null", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
}),
)
it.effect("falls back to a stable default when both error and response are absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
}),
)
it.effect("falls back to a stable default when response.failed has no error payload", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }])
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses response failed" })
}),
)
@ -1562,8 +1568,8 @@ describe("OpenAI Responses route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.message).toContain("HTTP 400")
}),
)

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