Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd51cdba12 |
+88
-16
@@ -15,20 +15,43 @@ import type {
|
||||
SharedV3ProviderOptions,
|
||||
} from "@ai-sdk/provider"
|
||||
import {
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ConnectionError,
|
||||
FinishReason,
|
||||
InvalidProviderOutputReason,
|
||||
HttpContext,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
LLMEvent,
|
||||
LLMError,
|
||||
MalformedResponse,
|
||||
Model,
|
||||
NotFound,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
ToolResultValue,
|
||||
UnknownProviderReason,
|
||||
classifyApiFailure,
|
||||
isLLMError,
|
||||
type LLMError,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/llm"
|
||||
import {
|
||||
APICallError,
|
||||
EmptyResponseBodyError,
|
||||
InvalidArgumentError,
|
||||
InvalidPromptError,
|
||||
InvalidResponseDataError,
|
||||
JSONParseError,
|
||||
LoadAPIKeyError,
|
||||
LoadSettingError,
|
||||
NoContentGeneratedError,
|
||||
NoSuchModelError,
|
||||
TypeValidationError,
|
||||
UnsupportedFunctionalityError,
|
||||
} from "@ai-sdk/provider"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
@@ -490,12 +513,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
|
||||
Stream.unwrap(
|
||||
Effect.tryPromise({
|
||||
try: () => language.doStream(options),
|
||||
catch: (error) => llmError("doStream", error),
|
||||
catch: (error) => llmError(error),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
Stream.fromReadableStream({
|
||||
evaluate: () => result.stream,
|
||||
onError: (error) => llmError("readStream", error),
|
||||
onError: (error) => llmError(error),
|
||||
}).pipe(
|
||||
Stream.mapEffect((event) => streamPartEvents(state, event)),
|
||||
Stream.flatMap((events) => Stream.fromIterable(events)),
|
||||
@@ -608,7 +631,7 @@ function streamPartEvents(
|
||||
}),
|
||||
])
|
||||
case "error":
|
||||
return Effect.fail(llmError("stream", event.error))
|
||||
return Effect.fail(llmError(event.error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,16 +689,65 @@ function messageValue(input: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function llmError(method: string, error: unknown) {
|
||||
const reason =
|
||||
error instanceof LLMError
|
||||
? new InvalidProviderOutputReason({ message: error.message })
|
||||
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
|
||||
return new LLMError({
|
||||
module: "AISDK",
|
||||
method,
|
||||
reason,
|
||||
})
|
||||
const BODY_LIMIT = 16_384
|
||||
|
||||
const headerRetryAfterMs = (headers: Record<string, string> | undefined) => {
|
||||
if (!headers) return undefined
|
||||
const millis = Number(headers["retry-after-ms"])
|
||||
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||
const value = headers["retry-after"]
|
||||
if (!value) return undefined
|
||||
const seconds = Number(value)
|
||||
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
|
||||
const date = Date.parse(value)
|
||||
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Classify AI SDK failures into the shared `LLMError` union so the synthetic
|
||||
// AI SDK route reports failures identically to native protocol routes. An
|
||||
// `APICallError` without a status code is the AI SDK's representation of a
|
||||
// network-level failure (connect refused, reset, DNS), not an API rejection.
|
||||
function llmError(error: unknown): LLMError {
|
||||
if (isLLMError(error)) return error
|
||||
if (APICallError.isInstance(error)) {
|
||||
if (error.statusCode === undefined) {
|
||||
return new ConnectionError({ message: error.message, url: error.url, cause: error })
|
||||
}
|
||||
return classifyApiFailure({
|
||||
message: error.message,
|
||||
status: error.statusCode,
|
||||
retryAfterMs: headerRetryAfterMs(error.responseHeaders),
|
||||
requestID: error.responseHeaders?.["x-request-id"] ?? error.responseHeaders?.["request-id"],
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: error.url, headers: {} }),
|
||||
response: new HttpResponseDetails({ status: error.statusCode, headers: error.responseHeaders ?? {} }),
|
||||
body: error.responseBody === undefined ? undefined : error.responseBody.slice(0, BODY_LIMIT),
|
||||
bodyTruncated: error.responseBody !== undefined && error.responseBody.length > BODY_LIMIT ? true : undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (LoadAPIKeyError.isInstance(error) || LoadSettingError.isInstance(error)) {
|
||||
return new Authentication({ message: error.message })
|
||||
}
|
||||
if (NoSuchModelError.isInstance(error)) return new NotFound({ message: error.message })
|
||||
if (
|
||||
InvalidPromptError.isInstance(error) ||
|
||||
InvalidArgumentError.isInstance(error) ||
|
||||
UnsupportedFunctionalityError.isInstance(error)
|
||||
) {
|
||||
return new BadRequest({ message: error.message })
|
||||
}
|
||||
if (
|
||||
InvalidResponseDataError.isInstance(error) ||
|
||||
JSONParseError.isInstance(error) ||
|
||||
TypeValidationError.isInstance(error) ||
|
||||
EmptyResponseBodyError.isInstance(error) ||
|
||||
NoContentGeneratedError.isInstance(error)
|
||||
) {
|
||||
return new MalformedResponse({ message: error.message })
|
||||
}
|
||||
return new APIError({ message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as Generate from "./generate"
|
||||
|
||||
import { LLM, LLMClient, LLMError } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, type LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Catalog } from "./catalog"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction"
|
||||
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "../config"
|
||||
@@ -247,11 +247,6 @@ const make = (dependencies: Dependencies) => {
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||
@@ -261,7 +256,7 @@ const make = (dependencies: Dependencies) => {
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("LLM.Error", (error) =>
|
||||
Effect.catchIf(isLLMError, (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
export * as SessionRunnerLLM from "./llm"
|
||||
|
||||
import {
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Message,
|
||||
SystemPart,
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMEvent, Message, SystemPart, isLLMError, type LLMError } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
@@ -230,17 +221,10 @@ const layer = Layer.effect(
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
}
|
||||
if (publisher.hasProviderError()) return
|
||||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
if (!toolMaterialization) {
|
||||
@@ -320,22 +304,21 @@ const layer = Layer.effect(
|
||||
// away non-interrupt failures, so both interrupt checks stay Cause-based.
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
|
||||
const llmFailure = streamFailure !== undefined && isLLMError(streamFailure) ? streamFailure : undefined
|
||||
|
||||
// A context overflow before any assistant output is recoverable: compact and
|
||||
// restart the step instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
llmFailure?._tag === "LLM.ContextOverflow" &&
|
||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status ===
|
||||
"completed"
|
||||
)
|
||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||
|
||||
// An unrecovered held-back overflow becomes the step's durable provider error. A
|
||||
// thrown LLM failure records the assistant failure unless a provider error was
|
||||
// already recorded from the stream. Terminal publication waits for owned tools.
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||
// A thrown LLM failure records the assistant failure unless a provider failure
|
||||
// was already recorded from the stream. Terminal publication waits for owned tools.
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
const error = toSessionError(llmFailure)
|
||||
if (
|
||||
@@ -352,7 +335,8 @@ const layer = Layer.effect(
|
||||
}
|
||||
yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
// The provider-failed flag is only set while consuming the stream (content-filter
|
||||
// step finish), so it is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
|
||||
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
|
||||
|
||||
@@ -428,10 +428,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
return
|
||||
case "finish":
|
||||
return
|
||||
case "provider-error":
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionRunnerRetry from "./retry"
|
||||
|
||||
import { LLMError } from "@opencode-ai/llm"
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Data, Duration, Effect, Schedule } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
@@ -17,29 +17,33 @@ export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableF
|
||||
}> {}
|
||||
|
||||
export function isRetryable(error: LLMError) {
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
case "Transport":
|
||||
switch (error._tag) {
|
||||
case "LLM.RateLimit":
|
||||
case "LLM.ServerError":
|
||||
case "LLM.ConnectionError":
|
||||
case "LLM.TimeoutError":
|
||||
return true
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidProviderOutput":
|
||||
case "InvalidRequest":
|
||||
case "NoRoute":
|
||||
case "UnknownProvider":
|
||||
case "LLM.Authentication":
|
||||
case "LLM.PermissionDenied":
|
||||
case "LLM.NotFound":
|
||||
case "LLM.QuotaExceeded":
|
||||
case "LLM.ContentPolicy":
|
||||
case "LLM.ContextOverflow":
|
||||
case "LLM.MalformedResponse":
|
||||
case "LLM.BadRequest":
|
||||
case "LLM.NoRoute":
|
||||
case "LLM.APIError":
|
||||
return false
|
||||
default: {
|
||||
const exhaustive: never = error.reason
|
||||
const exhaustive: never = error
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const retryAfter = (failure: RetryableFailure) => {
|
||||
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
|
||||
return failure.cause.reason.retryAfterMs
|
||||
if (failure.cause._tag === "LLM.RateLimit" || failure.cause._tag === "LLM.ServerError")
|
||||
return failure.cause.retryAfterMs
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionTitle from "./title"
|
||||
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest } from "@opencode-ai/llm"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Database } from "../database/database"
|
||||
@@ -49,7 +49,6 @@ const make = (dependencies: Dependencies) => {
|
||||
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!resolved) return
|
||||
const chunks: string[] = []
|
||||
let failed = false
|
||||
const streamed = yield* dependencies.llm
|
||||
.stream(
|
||||
LLM.request({
|
||||
@@ -61,14 +60,13 @@ const make = (dependencies: Dependencies) => {
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.as(true),
|
||||
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
||||
Effect.catchIf(isLLMError, () => Effect.succeed(false)),
|
||||
)
|
||||
if (!streamed || failed) return
|
||||
if (!streamed) return
|
||||
const title = chunks
|
||||
.join("")
|
||||
.split("\n")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LLMError, ToolFailure } from "@opencode-ai/llm"
|
||||
import { isLLMError, ToolFailure } from "@opencode-ai/llm"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { PermissionV2 } from "../permission"
|
||||
@@ -9,30 +9,38 @@ import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./err
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
|
||||
export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof LLMError) {
|
||||
switch (cause.reason._tag) {
|
||||
case "RateLimit":
|
||||
return { type: "provider.rate-limit", message: cause.reason.message }
|
||||
case "Authentication":
|
||||
return { type: "provider.auth", message: cause.reason.message }
|
||||
case "QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.reason.message }
|
||||
case "ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.reason.message }
|
||||
case "Transport":
|
||||
return { type: "provider.transport", message: cause.reason.message }
|
||||
case "ProviderInternal":
|
||||
return { type: "provider.internal", message: cause.reason.message }
|
||||
case "InvalidProviderOutput":
|
||||
return { type: "provider.invalid-output", message: cause.reason.message }
|
||||
case "InvalidRequest":
|
||||
return { type: "provider.invalid-request", message: cause.reason.message }
|
||||
case "NoRoute":
|
||||
return { type: "provider.no-route", message: cause.reason.message }
|
||||
case "UnknownProvider":
|
||||
return { type: "provider.unknown", message: cause.reason.message }
|
||||
if (isLLMError(cause)) {
|
||||
switch (cause._tag) {
|
||||
case "LLM.RateLimit":
|
||||
return { type: "provider.rate-limit", message: cause.message }
|
||||
case "LLM.Authentication":
|
||||
return { type: "provider.auth", message: cause.message }
|
||||
case "LLM.PermissionDenied":
|
||||
return { type: "provider.auth", message: cause.message }
|
||||
case "LLM.NotFound":
|
||||
return { type: "provider.not-found", message: cause.message }
|
||||
case "LLM.QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.message }
|
||||
case "LLM.ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.message }
|
||||
case "LLM.ContextOverflow":
|
||||
return { type: "provider.context-overflow", message: cause.message }
|
||||
case "LLM.ConnectionError":
|
||||
return { type: "provider.transport", message: cause.message }
|
||||
case "LLM.TimeoutError":
|
||||
return { type: "provider.timeout", message: cause.message }
|
||||
case "LLM.ServerError":
|
||||
return { type: "provider.internal", message: cause.message }
|
||||
case "LLM.MalformedResponse":
|
||||
return { type: "provider.invalid-output", message: cause.message }
|
||||
case "LLM.BadRequest":
|
||||
return { type: "provider.invalid-request", message: cause.message }
|
||||
case "LLM.NoRoute":
|
||||
return { type: "provider.no-route", message: cause.message }
|
||||
case "LLM.APIError":
|
||||
return { type: "provider.unknown", message: cause.message }
|
||||
default: {
|
||||
const exhaustive: never = cause.reason
|
||||
const exhaustive: never = cause
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
NoRouteReason,
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ConnectionError,
|
||||
ContentPolicy,
|
||||
ContextOverflow,
|
||||
MalformedResponse,
|
||||
ModelID,
|
||||
NoRoute,
|
||||
NotFound,
|
||||
PermissionDenied,
|
||||
ProviderID,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
QuotaExceeded,
|
||||
RateLimit,
|
||||
RouteID,
|
||||
ServerError,
|
||||
TimeoutError,
|
||||
ToolFailure,
|
||||
} from "@opencode-ai/llm"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
@@ -20,39 +24,33 @@ import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
|
||||
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
|
||||
|
||||
describe("toSessionError", () => {
|
||||
test("maps every LLM reason to the open wire type", () => {
|
||||
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
|
||||
test("maps every LLM error tag to the open wire type", () => {
|
||||
expect(toSessionError(new RateLimit({ message: "rate", retryAfterMs: 123 }))).toEqual({
|
||||
type: "provider.rate-limit",
|
||||
message: "rate",
|
||||
})
|
||||
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
|
||||
"provider.auth",
|
||||
)
|
||||
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
|
||||
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
|
||||
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
|
||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
|
||||
"provider.internal",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
|
||||
"provider.invalid-output",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
|
||||
expect(toSessionError(new Authentication({ message: "auth" })).type).toBe("provider.auth")
|
||||
expect(toSessionError(new PermissionDenied({ message: "forbidden" })).type).toBe("provider.auth")
|
||||
expect(toSessionError(new NotFound({ message: "missing" })).type).toBe("provider.not-found")
|
||||
expect(toSessionError(new QuotaExceeded({ message: "quota" })).type).toBe("provider.quota")
|
||||
expect(toSessionError(new ContentPolicy({ message: "blocked" })).type).toBe("provider.content-filter")
|
||||
expect(toSessionError(new ContextOverflow({ message: "too long" })).type).toBe("provider.context-overflow")
|
||||
expect(toSessionError(new ConnectionError({ message: "reset" })).type).toBe("provider.transport")
|
||||
expect(toSessionError(new TimeoutError({ message: "timed out" })).type).toBe("provider.timeout")
|
||||
expect(toSessionError(new ServerError({ message: "internal", status: 500 })).type).toBe("provider.internal")
|
||||
expect(toSessionError(new MalformedResponse({ message: "output" })).type).toBe("provider.invalid-output")
|
||||
expect(toSessionError(new BadRequest({ message: "request" })).type).toBe("provider.invalid-request")
|
||||
expect(
|
||||
toSessionError(
|
||||
llm(
|
||||
new NoRouteReason({
|
||||
route: "route",
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
),
|
||||
new NoRoute({
|
||||
route: RouteID.make("route"),
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
).type,
|
||||
).toBe("provider.no-route")
|
||||
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
|
||||
expect(toSessionError(new APIError({ message: "unknown", status: 418 })).type).toBe("provider.unknown")
|
||||
})
|
||||
|
||||
test("preserves the permission rejection type without exposing internal fields", () => {
|
||||
@@ -71,23 +69,31 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
test("retries only rate limits, server errors, connection failures, and timeouts", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
|
||||
llm(new TransportReason({ message: "transport" })),
|
||||
new RateLimit({ message: "rate" }),
|
||||
new ServerError({ message: "internal", status: 500 }),
|
||||
new ConnectionError({ message: "reset" }),
|
||||
new TimeoutError({ message: "timed out" }),
|
||||
]
|
||||
const ineligible = [
|
||||
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
|
||||
llm(new QuotaExceededReason({ message: "quota" })),
|
||||
llm(new ContentPolicyReason({ message: "blocked" })),
|
||||
llm(new InvalidProviderOutputReason({ message: "output" })),
|
||||
llm(new InvalidRequestReason({ message: "request" })),
|
||||
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
|
||||
llm(new UnknownProviderReason({ message: "unknown" })),
|
||||
new Authentication({ message: "auth" }),
|
||||
new PermissionDenied({ message: "forbidden" }),
|
||||
new NotFound({ message: "missing" }),
|
||||
new QuotaExceeded({ message: "quota" }),
|
||||
new ContentPolicy({ message: "blocked" }),
|
||||
new ContextOverflow({ message: "too long" }),
|
||||
new MalformedResponse({ message: "output" }),
|
||||
new BadRequest({ message: "request" }),
|
||||
new NoRoute({
|
||||
route: RouteID.make("route"),
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
new APIError({ message: "unknown" }),
|
||||
]
|
||||
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual(ineligible.map(() => false))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { LLMError, TransportReason } from "@opencode-ai/llm"
|
||||
import { ConnectionError } from "@opencode-ai/llm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -25,17 +25,10 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Event
|
||||
describe("SessionExecution lifecycle", () => {
|
||||
test("classifies success and typed failure terminals", () => {
|
||||
expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
|
||||
expect(
|
||||
SessionExecution.terminal(
|
||||
Exit.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Disconnected" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
|
||||
expect(SessionExecution.terminal(Exit.fail(new ConnectionError({ message: "Disconnected" })))).toEqual({
|
||||
type: "failed",
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
})
|
||||
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
|
||||
expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({
|
||||
type: "failed",
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
APIError,
|
||||
BadRequest,
|
||||
ConnectionError,
|
||||
ContextOverflow,
|
||||
LLMClient,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Model,
|
||||
RateLimit,
|
||||
ToolFailure,
|
||||
TransportReason,
|
||||
InvalidRequestReason,
|
||||
RateLimitReason,
|
||||
type LLMClientShape,
|
||||
type LLMError,
|
||||
type LLMRequest,
|
||||
} from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
@@ -68,8 +70,9 @@ import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const requests: LLMRequest[] = []
|
||||
type ScriptedResponse = LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
|
||||
let response: LLMEvent[] = []
|
||||
let responses: LLMEvent[][] | undefined
|
||||
let responses: ScriptedResponse[] | undefined
|
||||
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
||||
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
|
||||
let streamGate: Deferred.Deferred<void> | undefined
|
||||
@@ -92,9 +95,12 @@ const client = Layer.succeed(
|
||||
responseStream = undefined
|
||||
return stream
|
||||
}
|
||||
const scripted = responses === undefined ? response : (responses.shift() ?? [])
|
||||
const events = streamFailure
|
||||
? Stream.fail(streamFailure)
|
||||
: Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
|
||||
: Array.isArray(scripted)
|
||||
? Stream.fromIterable(scripted)
|
||||
: scripted
|
||||
if (!streamGate) return events
|
||||
return Stream.unwrap(
|
||||
(streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
|
||||
@@ -480,26 +486,16 @@ const setup = Effect.gen(function* () {
|
||||
return yield* SessionV2.Service
|
||||
})
|
||||
|
||||
const providerUnavailable = () =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Provider unavailable" }),
|
||||
})
|
||||
const providerUnavailable = () => new ConnectionError({ message: "Provider unavailable" })
|
||||
|
||||
const invalidRequest = () =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidRequestReason({ message: "Invalid request" }),
|
||||
})
|
||||
const contextOverflow = () => new ContextOverflow({ message: "prompt too long" })
|
||||
|
||||
const rateLimited = (retryAfterMs?: number) =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
|
||||
})
|
||||
const failingResponse = (events: LLMEvent[], failure: LLMError): Stream.Stream<LLMEvent, LLMError> =>
|
||||
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(failure)))
|
||||
|
||||
const invalidRequest = () => new BadRequest({ message: "Invalid request" })
|
||||
|
||||
const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs })
|
||||
|
||||
const setupOverflowRecovery = Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -1646,14 +1642,14 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
response = [LLMEvent.providerError({ message: "summary unavailable" })]
|
||||
responseStream = Stream.fail(new APIError({ message: "summary unavailable" }))
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
error: { type: "provider.unknown", message: "summary unavailable" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1763,7 +1759,7 @@ describe("SessionRunnerLLM", () => {
|
||||
currentModel = compactModel
|
||||
requests.length = 0
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
|
||||
Stream.fail(new BadRequest({ message: "Unsupported parameter: max_output_tokens" })),
|
||||
reply.text("Must not run", "text-after-failed-compaction"),
|
||||
]
|
||||
yield* admit(session, "Recent exact request ".repeat(180))
|
||||
@@ -1786,10 +1782,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
],
|
||||
failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow()),
|
||||
reply.text("## Objective\n- Recover overflow", "text-summary"),
|
||||
reply.text("Recovered", "text-final"),
|
||||
]
|
||||
@@ -1816,7 +1809,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setupOverflowRecovery
|
||||
currentModel = model
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
Stream.fail(contextOverflow()),
|
||||
reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
|
||||
reply.text("Recovered", "text-final-unknown-limit"),
|
||||
]
|
||||
@@ -1836,7 +1829,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setupOverflowRecovery
|
||||
currentModel = undersizedContextModel
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
Stream.fail(contextOverflow()),
|
||||
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
||||
reply.text("Recovered", "text-final-undersized-limit"),
|
||||
]
|
||||
@@ -1854,10 +1847,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("persists a second context overflow after one recovery", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
const overflow = () => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
]
|
||||
const overflow = () => failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow())
|
||||
responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()]
|
||||
yield* admit(session, "Continue")
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
@@ -1873,16 +1863,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("recovers once from a raw context overflow failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responseStream = Stream.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidRequestReason({
|
||||
message: "prompt too long",
|
||||
classification: "context-overflow",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
responseStream = Stream.fail(contextOverflow())
|
||||
responses = [
|
||||
reply.text("## Objective\n- Recover raw overflow", "text-summary"),
|
||||
reply.text("Recovered", "text-final"),
|
||||
@@ -1901,10 +1882,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("publishes the original overflow when recovery summarization fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
[LLMEvent.providerError({ message: "summary unavailable" })],
|
||||
]
|
||||
responses = [Stream.fail(contextOverflow()), Stream.fail(new APIError({ message: "summary unavailable" }))]
|
||||
yield* admit(session, "Continue")
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
@@ -1915,7 +1893,7 @@ describe("SessionRunnerLLM", () => {
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "auto",
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
error: { type: "provider.unknown", message: "summary unavailable" },
|
||||
}),
|
||||
)
|
||||
expect(context.slice(-3)).toMatchObject([
|
||||
@@ -1929,10 +1907,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("interrupts overflow recovery while the summary provider is running", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
reply.text("## Objective\n- Interrupted", "text-summary"),
|
||||
]
|
||||
responses = [Stream.fail(contextOverflow()), reply.text("## Objective\n- Interrupted", "text-summary")]
|
||||
const firstGate = yield* Deferred.make<void>()
|
||||
const summaryGate = yield* Deferred.make<void>()
|
||||
streamGate = firstGate
|
||||
@@ -3515,7 +3490,10 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail durably")
|
||||
|
||||
response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
responseStream = failingResponse(
|
||||
[LLMEvent.stepStart({ index: 0 })],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -3532,7 +3510,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail before step")
|
||||
|
||||
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
responseStream = Stream.fail(new APIError({ message: "Provider unavailable" }))
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -3620,13 +3598,15 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail after output")
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-partial" }),
|
||||
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
||||
LLMEvent.textEnd({ id: "text-partial" }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
]
|
||||
responseStream = failingResponse(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-partial" }),
|
||||
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
||||
LLMEvent.textEnd({ id: "text-partial" }),
|
||||
],
|
||||
contextOverflow(),
|
||||
)
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
@@ -3784,11 +3764,13 @@ describe("SessionRunnerLLM", () => {
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
responseStream = failingResponse(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
||||
],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
@@ -3815,11 +3797,10 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail hosted tool durably")
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
hostedCall("call-hosted-provider-error", "effect"),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
responseStream = failingResponse(
|
||||
[LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-provider-error", "effect")],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -3846,11 +3827,13 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Defect while provider fails")
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
responseStream = failingResponse(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
||||
],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
|
||||
@@ -182,8 +182,8 @@ The dependency arrow points down: `providers/*.ts` files import protocol routes
|
||||
- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field.
|
||||
- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
|
||||
- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
|
||||
- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures.
|
||||
- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
|
||||
- `eventError(route, message, ...)` — typed `MalformedResponse` constructor for stream-time decode failures.
|
||||
- `validateWith(decoder)` — maps Schema decode errors to `BadRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
|
||||
- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering.
|
||||
|
||||
If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating.
|
||||
@@ -291,7 +291,7 @@ Use this order for every protocol module:
|
||||
|
||||
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
|
||||
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
|
||||
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
||||
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event for each completed response. Provider-reported failures (SSE error events, exception frames) fail the stream with a typed `LLMError` via `classifyApiFailure` — never an ordinary event. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
||||
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
|
||||
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
|
||||
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
|
||||
|
||||
@@ -2,7 +2,7 @@ export { LLMClient } from "./route/client"
|
||||
export { Auth } from "./route/auth"
|
||||
export { Provider } from "./provider"
|
||||
export { ProviderPackage } from "./provider-package"
|
||||
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
|
||||
export { classifyApiFailure, isContextOverflow, type ApiFailure } from "./provider-error"
|
||||
export type {
|
||||
RouteModelInput,
|
||||
RouteRoutedModelInput,
|
||||
|
||||
+6
-14
@@ -3,8 +3,8 @@ import { LLMClient } from "./route/client"
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
InvalidProviderOutputReason,
|
||||
LLMError,
|
||||
MalformedResponse,
|
||||
type LLMError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
@@ -121,22 +121,14 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
||||
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
|
||||
)
|
||||
if (!call || !LLMEvent.is.toolCall(call))
|
||||
return yield* new LLMError({
|
||||
module: "LLM",
|
||||
method: "generateObject",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
|
||||
}),
|
||||
return yield* new MalformedResponse({
|
||||
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
|
||||
})
|
||||
const object = yield* tool._decode(call.input).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new LLMError({
|
||||
module: "LLM",
|
||||
method: "generateObject",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
message: `generateObject: tool input failed schema decode: ${error.message}`,
|
||||
}),
|
||||
new MalformedResponse({
|
||||
message: `generateObject: tool input failed schema decode: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import * as Cache from "./utils/cache"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
@@ -832,15 +832,11 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
||||
return message || type || "Anthropic Messages stream error"
|
||||
}
|
||||
|
||||
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
||||
state,
|
||||
[
|
||||
LLMEvent.providerError({
|
||||
message: providerErrorMessage(event),
|
||||
classification: isContextOverflow(event.error?.message ?? "") ? "context-overflow" : undefined,
|
||||
}),
|
||||
],
|
||||
]
|
||||
const onError = (event: AnthropicEvent) =>
|
||||
classifyApiFailure({
|
||||
message: providerErrorMessage(event),
|
||||
code: event.error?.type,
|
||||
})
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
||||
@@ -848,7 +844,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||
if (event.type === "error") return Effect.succeed(onError(state, event))
|
||||
if (event.type === "error") return Effect.fail(onError(event))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { BedrockEventStream } from "./bedrock-event-stream"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||
import { BedrockAuth } from "./utils/bedrock-auth"
|
||||
import { BedrockCache } from "./utils/bedrock-cache"
|
||||
@@ -586,27 +586,20 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
|
||||
}
|
||||
|
||||
if (event.internalServerException || event.modelStreamErrorException || event.serviceUnavailableException) {
|
||||
const message =
|
||||
event.internalServerException?.message ??
|
||||
event.modelStreamErrorException?.message ??
|
||||
event.serviceUnavailableException?.message ??
|
||||
"Bedrock Converse stream error"
|
||||
return [state, [LLMEvent.providerError({ message })]] as const
|
||||
}
|
||||
|
||||
if (event.validationException || event.throttlingException) {
|
||||
const message =
|
||||
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
|
||||
return [
|
||||
state,
|
||||
[
|
||||
LLMEvent.providerError({
|
||||
message,
|
||||
classification: event.validationException && isContextOverflow(message) ? "context-overflow" : undefined,
|
||||
}),
|
||||
],
|
||||
const exception = (
|
||||
[
|
||||
["internalServerException", event.internalServerException],
|
||||
["modelStreamErrorException", event.modelStreamErrorException],
|
||||
["serviceUnavailableException", event.serviceUnavailableException],
|
||||
["throttlingException", event.throttlingException],
|
||||
["validationException", event.validationException],
|
||||
] as const
|
||||
).find((entry) => entry[1] !== undefined)
|
||||
if (exception) {
|
||||
return yield* classifyApiFailure({
|
||||
message: exception[1]?.message ?? "Bedrock Converse stream error",
|
||||
code: exception[0],
|
||||
})
|
||||
}
|
||||
|
||||
return [state, []] as const
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
@@ -606,9 +606,9 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
const NO_EVENTS: StepResult["1"] = []
|
||||
|
||||
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
||||
// `finish` event; `response.failed` is a hard failure that emits a
|
||||
// `provider-error`. All three end the stream — kept in one set so `step` and
|
||||
// the protocol's `terminal` predicate stay in sync.
|
||||
// `finish` event; `response.failed` is a hard failure that fails the stream
|
||||
// with a classified `LLMError`. All three end the stream — kept in one set so
|
||||
// `step` and the protocol's `terminal` predicate stay in sync.
|
||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
@@ -907,24 +907,11 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st
|
||||
return message || code || fallback
|
||||
}
|
||||
|
||||
const providerError = (event: OpenAIResponsesEvent, fallback: string) => {
|
||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
const message = providerErrorMessage(event, fallback)
|
||||
return LLMEvent.providerError({
|
||||
message,
|
||||
classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined,
|
||||
const providerError = (event: OpenAIResponsesEvent, fallback: string) =>
|
||||
classifyApiFailure({
|
||||
message: providerErrorMessage(event, fallback),
|
||||
code: event.code || event.error?.code || event.response?.error?.code || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[providerError(event, "OpenAI Responses response failed")],
|
||||
]
|
||||
|
||||
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[providerError(event, "OpenAI Responses stream error")],
|
||||
]
|
||||
|
||||
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
||||
@@ -950,8 +937,8 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||
return Effect.succeed(onResponseFinish(state, event))
|
||||
if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event))
|
||||
if (event.type === "error") return Effect.succeed(onError(state, event))
|
||||
if (event.type === "response.failed") return Effect.fail(providerError(event, "OpenAI Responses response failed"))
|
||||
if (event.type === "error") return Effect.fail(providerError(event, "OpenAI Responses stream error"))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Effect, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
BadRequest,
|
||||
MalformedResponse,
|
||||
type LLMError,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -88,11 +88,7 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
|
||||
}
|
||||
|
||||
export const eventError = (route: string, message: string, raw?: string) =>
|
||||
new LLMError({
|
||||
module: "ProviderShared",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ route, message, raw }),
|
||||
})
|
||||
new MalformedResponse({ route, message, raw })
|
||||
|
||||
export const parseJson = (route: string, input: string, message: string) =>
|
||||
Effect.try({
|
||||
@@ -252,15 +248,9 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
|
||||
* Canonical invalid-request constructor. Lift one-line `const invalid =
|
||||
* (message) => invalidRequest(message)` aliases out of every
|
||||
* route so the error constructor lives in one place. If we ever extend
|
||||
* `InvalidRequestReason` with route context or trace metadata, the change
|
||||
* lands here.
|
||||
* `BadRequest` with route context or trace metadata, the change lands here.
|
||||
*/
|
||||
export const invalidRequest = (message: string) =>
|
||||
new LLMError({
|
||||
module: "ProviderShared",
|
||||
method: "request",
|
||||
reason: new InvalidRequestReason({ message }),
|
||||
})
|
||||
export const invalidRequest = (message: string) => new BadRequest({ message })
|
||||
|
||||
export const matchToolChoice = <Auto, None, Required, Tool>(
|
||||
route: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
|
||||
import { isLLMError, LLMEvent, type LLMError, type ProviderMetadata, type ToolCall } from "../../schema"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||
|
||||
type StreamKey = string | number
|
||||
@@ -95,7 +95,7 @@ const appendTool = <K extends StreamKey>(
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { Schema } from "effect"
|
||||
import { LLMError, ProviderErrorEvent } from "./schema"
|
||||
import {
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ContentPolicy,
|
||||
ContextOverflow,
|
||||
HttpContext,
|
||||
HttpRateLimitDetails,
|
||||
NotFound,
|
||||
PermissionDenied,
|
||||
ProviderMetadata,
|
||||
QuotaExceeded,
|
||||
RateLimit,
|
||||
ServerError,
|
||||
type LLMError,
|
||||
} from "./schema"
|
||||
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
@@ -27,7 +41,102 @@ const patterns = [
|
||||
export const isContextOverflow = (message: string) =>
|
||||
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
|
||||
|
||||
export const isContextOverflowFailure = (failure: unknown) =>
|
||||
failure instanceof LLMError
|
||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"])
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const SERVER_ERROR_STATUS = (status: number) => status >= 500 || status === 529
|
||||
|
||||
const CODE_CLASSIFICATION: Record<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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Config, Effect, Redacted } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
|
||||
import { Authentication, BadRequest, type LLMError, type LLMRequest } from "../schema"
|
||||
|
||||
export class MissingCredentialError extends Error {
|
||||
readonly _tag = "MissingCredentialError"
|
||||
@@ -135,16 +135,9 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
|
||||
}
|
||||
|
||||
const toLLMError = (error: AuthError): LLMError => {
|
||||
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
|
||||
return new LLMError({
|
||||
module: "Auth",
|
||||
method: "apply",
|
||||
reason:
|
||||
error instanceof MissingCredentialError
|
||||
? new AuthenticationReason({ message: error.message, kind: "missing" })
|
||||
: new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }),
|
||||
})
|
||||
}
|
||||
if (error instanceof MissingCredentialError) return new Authentication({ message: error.message })
|
||||
if (error instanceof Config.ConfigError)
|
||||
return new BadRequest({ message: `Failed to resolve auth config: ${error.message}` })
|
||||
return error
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
isLLMError,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Model,
|
||||
ModelLimits,
|
||||
LLMError as LLMErrorClass,
|
||||
PreparedRequest,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
@@ -225,10 +225,39 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
|
||||
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
|
||||
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))
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal contract for every route, native or synthetic: a successful
|
||||
* stream emits exactly one `finish`, and nothing after it. EOF before
|
||||
* `finish` means the provider stream was truncated (proxy cut, silent
|
||||
* drop) and must fail rather than let a partial response settle as
|
||||
* complete. Applied after protocol parsing so `stream.onHalt` flushes are
|
||||
* still subject to it.
|
||||
*/
|
||||
const enforceTerminal = (route: string) => (events: Stream.Stream<LLMEvent, LLMError>) => {
|
||||
let finished = false
|
||||
return events.pipe(
|
||||
Stream.mapEffect((event) => {
|
||||
if (finished)
|
||||
return Effect.fail(
|
||||
ProviderShared.eventError(route, `Provider emitted ${event.type} after the terminal finish event`),
|
||||
)
|
||||
if (event.type === "finish") finished = true
|
||||
return Effect.succeed(event)
|
||||
}),
|
||||
Stream.concat(
|
||||
Stream.suspend(() =>
|
||||
finished
|
||||
? Stream.empty
|
||||
: Stream.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||
): Route<Body, Prepared> {
|
||||
@@ -383,7 +412,10 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compile(request)
|
||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||
const route = `${compiled.request.model.provider}/${compiled.route.id}`
|
||||
return compiled.route
|
||||
.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||
.pipe(enforceTerminal(route))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Context, Effect, Layer } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
Headers,
|
||||
@@ -8,21 +8,15 @@ import {
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
ConnectionError,
|
||||
HttpContext,
|
||||
HttpRateLimitDetails,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
TimeoutError,
|
||||
type LLMError,
|
||||
} from "../schema"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
|
||||
export interface Interface {
|
||||
readonly execute: (
|
||||
@@ -85,8 +79,6 @@ const requestId = (headers: Record<string, string>) => {
|
||||
)
|
||||
}
|
||||
|
||||
const providerInternalStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529
|
||||
|
||||
const retryAfterMs = (headers: Record<string, string>) => {
|
||||
const millis = Number(headers["retry-after-ms"])
|
||||
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||
@@ -219,56 +211,21 @@ const responseHttp = (input: {
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
|
||||
const statusReason = (input: {
|
||||
readonly status: number
|
||||
readonly message: string
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly http: HttpContext
|
||||
}) => {
|
||||
const body = input.http.body ?? ""
|
||||
if (/content[-_\s]?policy|content_filter|safety/i.test(body)) {
|
||||
return new ContentPolicyReason({ message: input.message, http: input.http })
|
||||
}
|
||||
if (input.status === 401) {
|
||||
return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http })
|
||||
}
|
||||
if (input.status === 403) {
|
||||
return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http })
|
||||
}
|
||||
if (input.status === 429) {
|
||||
if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) {
|
||||
return new QuotaExceededReason({ message: input.message, http: input.http })
|
||||
}
|
||||
return new RateLimitReason({
|
||||
message: input.message,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
if (
|
||||
input.status === 400 ||
|
||||
input.status === 404 ||
|
||||
input.status === 409 ||
|
||||
input.status === 413 ||
|
||||
input.status === 422
|
||||
) {
|
||||
return new InvalidRequestReason({
|
||||
message: input.message,
|
||||
classification: isContextOverflow(body) ? "context-overflow" : undefined,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
if (input.status >= 500 || providerInternalStatus(input.status)) {
|
||||
return new ProviderInternalReason({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http })
|
||||
const decodeBodyJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
// Provider machine code from a JSON error body (`error.code` / `error.type`),
|
||||
// fed to the shared classifier so code-based rules (overflow, quota) work on
|
||||
// HTTP rejections too. Truncated or non-JSON bodies yield undefined.
|
||||
const providerCode = (body: string | undefined) => {
|
||||
if (!body) return undefined
|
||||
const decoded = Option.getOrUndefined(decodeBodyJson(body))
|
||||
if (typeof decoded !== "object" || decoded === null) return undefined
|
||||
const error = (decoded as Record<string, unknown>).error
|
||||
if (typeof error !== "object" || error === null) return undefined
|
||||
const fields = error as Record<string, unknown>
|
||||
if (typeof fields.code === "string") return fields.code
|
||||
if (typeof fields.type === "string") return fields.type
|
||||
return undefined
|
||||
}
|
||||
|
||||
const statusError =
|
||||
@@ -281,58 +238,55 @@ const statusError =
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(body, request)
|
||||
return yield* new LLMError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
reason: statusReason({
|
||||
status: response.status,
|
||||
message: providerMessage(response.status, details),
|
||||
retryAfterMs: retryAfter,
|
||||
return yield* classifyApiFailure({
|
||||
status: response.status,
|
||||
message: providerMessage(response.status, details),
|
||||
code: providerCode(details.body),
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
requestID: requestId(headers),
|
||||
http: responseHttp({
|
||||
request,
|
||||
response,
|
||||
redactedNames,
|
||||
body: details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
http: responseHttp({
|
||||
request,
|
||||
response,
|
||||
redactedNames,
|
||||
body: details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
const toHttpError = (redactedNames: ReadonlyArray<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 kind?: string | undefined
|
||||
readonly request?: HttpClientRequest.HttpClientRequest | undefined
|
||||
}) =>
|
||||
new LLMError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
reason: new TransportReason({
|
||||
message: input.message,
|
||||
kind: input.kind,
|
||||
url: input.request ? redactUrl(input.request.url) : undefined,
|
||||
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
|
||||
}),
|
||||
new ConnectionError({
|
||||
message: input.message,
|
||||
kind: input.kind,
|
||||
url: input.request ? redactUrl(input.request.url) : undefined,
|
||||
http: httpContext(input.request),
|
||||
cause: error,
|
||||
})
|
||||
|
||||
if (Cause.isTimeoutError(error)) {
|
||||
return transportError({ message: error.message, kind: "Timeout" })
|
||||
return new TimeoutError({ message: error.message })
|
||||
}
|
||||
if (!HttpClientError.isHttpClientError(error)) {
|
||||
return transportError({ message: "HTTP transport failed" })
|
||||
return connectionError({ message: "HTTP transport failed" })
|
||||
}
|
||||
const request = "request" in error ? error.request : undefined
|
||||
if (error.reason._tag === "TransportError") {
|
||||
return transportError({
|
||||
return connectionError({
|
||||
message: error.reason.description ?? "HTTP transport failed",
|
||||
kind: error.reason._tag,
|
||||
request,
|
||||
})
|
||||
}
|
||||
return transportError({
|
||||
return connectionError({
|
||||
message: `HTTP transport failed: ${error.reason._tag}`,
|
||||
kind: error.reason._tag,
|
||||
request,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { LLMError, TransportReason } from "../../schema"
|
||||
import { ConnectionError, type LLMError } from "../../schema"
|
||||
import * as HttpTransport from "./http"
|
||||
import type { Transport } from "./index"
|
||||
|
||||
@@ -27,15 +27,10 @@ type WebSocketConstructorWithHeaders = new (
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {}
|
||||
|
||||
const transportError = (
|
||||
method: string,
|
||||
_method: string,
|
||||
message: string,
|
||||
input: { readonly url?: string; readonly kind?: string } = {},
|
||||
) =>
|
||||
new LLMError({
|
||||
module: "WebSocketExecutor",
|
||||
method,
|
||||
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
||||
})
|
||||
) => new ConnectionError({ message, url: input.url, kind: input.kind })
|
||||
|
||||
const eventMessage = (event: Event) => {
|
||||
if ("message" in event && typeof event.message === "string") return event.message
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
||||
|
||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
||||
method: Schema.String,
|
||||
url: Schema.String,
|
||||
@@ -31,118 +28,150 @@ export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
|
||||
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,
|
||||
parameter: Schema.optional(Schema.String),
|
||||
classification: Schema.optional(ProviderFailureClassification),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
status: Schema.optional(Schema.Number),
|
||||
code: Schema.optional(Schema.String),
|
||||
requestID: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
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}`
|
||||
}
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}
|
||||
|
||||
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.Error.Authentication")({
|
||||
_tag: Schema.tag("Authentication"),
|
||||
message: Schema.String,
|
||||
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
/** Provider rejected the request as invalid (400/409/422, `invalid_request_error`, ...). */
|
||||
export class BadRequest extends Schema.TaggedErrorClass<BadRequest>()("LLM.BadRequest", {
|
||||
...apiFailureFields,
|
||||
parameter: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.RateLimit")({
|
||||
_tag: Schema.tag("RateLimit"),
|
||||
message: Schema.String,
|
||||
/** Credentials are missing, invalid, or expired (401). */
|
||||
export class Authentication extends Schema.TaggedErrorClass<Authentication>()("LLM.Authentication", {
|
||||
...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),
|
||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({
|
||||
_tag: Schema.tag("QuotaExceeded"),
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
/** Account-level quota or billing exhaustion. Unlike `RateLimit`, waiting does not help. */
|
||||
export class QuotaExceeded extends Schema.TaggedErrorClass<QuotaExceeded>()("LLM.QuotaExceeded", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({
|
||||
_tag: Schema.tag("ContentPolicy"),
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
/** Provider refused the content for policy/safety reasons. */
|
||||
export class ContentPolicy extends Schema.TaggedErrorClass<ContentPolicy>()("LLM.ContentPolicy", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({
|
||||
_tag: Schema.tag("ProviderInternal"),
|
||||
message: Schema.String,
|
||||
status: Schema.Number,
|
||||
/**
|
||||
* The request exceeds the model's context window. Designated tag because
|
||||
* Core recovers from it structurally (compaction) rather than surfacing it.
|
||||
* Upgraded from `BadRequest` by the shared classifier in `provider-error.ts`.
|
||||
*/
|
||||
export class ContextOverflow extends Schema.TaggedErrorClass<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),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Transport")({
|
||||
_tag: Schema.tag("Transport"),
|
||||
/** Any other deliberate API rejection that matches no designated tag (402, 405, 410, ...). */
|
||||
export class APIError extends Schema.TaggedErrorClass<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,
|
||||
kind: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
||||
"LLM.Error.InvalidProviderOutput",
|
||||
)({
|
||||
_tag: Schema.tag("InvalidProviderOutput"),
|
||||
/** The request or stream read timed out before the provider answered. */
|
||||
export class TimeoutError extends Schema.TaggedErrorClass<TimeoutError>()("LLM.TimeoutError", {
|
||||
message: Schema.String,
|
||||
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,
|
||||
route: Schema.optional(Schema.String),
|
||||
raw: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.Error.UnknownProvider")({
|
||||
_tag: Schema.tag("UnknownProvider"),
|
||||
message: Schema.String,
|
||||
status: Schema.optional(Schema.Number),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export const LLMErrorReason = Schema.Union([
|
||||
InvalidRequestReason,
|
||||
NoRouteReason,
|
||||
AuthenticationReason,
|
||||
RateLimitReason,
|
||||
QuotaExceededReason,
|
||||
ContentPolicyReason,
|
||||
ProviderInternalReason,
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
UnknownProviderReason,
|
||||
]).pipe(Schema.toTaggedUnion("_tag"))
|
||||
export type LLMErrorReason = Schema.Schema.Type<typeof LLMErrorReason>
|
||||
|
||||
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
|
||||
module: Schema.String,
|
||||
method: Schema.String,
|
||||
reason: LLMErrorReason,
|
||||
/** Request construction failed locally: the selected model resolves to no executable route. */
|
||||
export class NoRoute extends Schema.TaggedErrorClass<NoRoute>()("LLM.NoRoute", {
|
||||
route: RouteID,
|
||||
provider: ProviderID,
|
||||
model: ModelID,
|
||||
}) {
|
||||
override readonly cause = this.reason
|
||||
|
||||
override get message() {
|
||||
return `${this.module}.${this.method}: ${this.reason.message}`
|
||||
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
|
||||
}
|
||||
}
|
||||
|
||||
const members = [
|
||||
BadRequest,
|
||||
Authentication,
|
||||
PermissionDenied,
|
||||
NotFound,
|
||||
RateLimit,
|
||||
QuotaExceeded,
|
||||
ContentPolicy,
|
||||
ContextOverflow,
|
||||
ServerError,
|
||||
APIError,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
MalformedResponse,
|
||||
NoRoute,
|
||||
] as const
|
||||
|
||||
export const LLMErrorSchema = Schema.Union(members)
|
||||
|
||||
/**
|
||||
* Every failure of one LLM request. `LLMEvent` streams carry output only;
|
||||
* all failures — HTTP rejections, in-stream provider error events, transport
|
||||
* failures, and protocol-contract violations — exit through this union on
|
||||
* the stream's error channel.
|
||||
*/
|
||||
export type LLMError = typeof LLMErrorSchema.Type
|
||||
|
||||
export const isLLMError = (value: unknown): value is LLMError =>
|
||||
members.some((member) => value instanceof member)
|
||||
|
||||
/**
|
||||
* Failure type for tool execute handlers. Handlers must map their internal
|
||||
* errors to this shape; the runtime catches `ToolFailure`s and surfaces them
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Schema } from "effect"
|
||||
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
|
||||
import { ModelSchema } from "./options"
|
||||
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
|
||||
import { ProviderFailureClassification } from "./errors"
|
||||
|
||||
/**
|
||||
* Token usage reported by an LLM provider.
|
||||
@@ -197,14 +196,6 @@ export const Finish = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Event.Finish" })
|
||||
export type Finish = Schema.Schema.Type<typeof Finish>
|
||||
|
||||
export const ProviderErrorEvent = Schema.Struct({
|
||||
type: Schema.tag("provider-error"),
|
||||
message: Schema.String,
|
||||
classification: Schema.optional(ProviderFailureClassification),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ProviderError" })
|
||||
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
|
||||
|
||||
const llmEventTagged = Schema.Union([
|
||||
StepStart,
|
||||
TextStart,
|
||||
@@ -221,7 +212,6 @@ const llmEventTagged = Schema.Union([
|
||||
ToolError,
|
||||
StepFinish,
|
||||
Finish,
|
||||
ProviderErrorEvent,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
|
||||
@@ -271,7 +261,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
...input,
|
||||
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
|
||||
}),
|
||||
providerError: ProviderErrorEvent.make,
|
||||
is: {
|
||||
stepStart: llmEventTagged.guards["step-start"],
|
||||
textStart: llmEventTagged.guards["text-start"],
|
||||
@@ -288,7 +277,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
toolError: llmEventTagged.guards["tool-error"],
|
||||
stepFinish: llmEventTagged.guards["step-finish"],
|
||||
finish: llmEventTagged.guards.finish,
|
||||
providerError: llmEventTagged.guards["provider-error"],
|
||||
},
|
||||
})
|
||||
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
||||
@@ -374,13 +362,6 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
||||
finishReason: event.reason,
|
||||
}
|
||||
}
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
return {
|
||||
...state,
|
||||
events,
|
||||
finishReason: state.finishReason ?? "error",
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
events,
|
||||
@@ -589,7 +570,7 @@ export namespace LLMResponse {
|
||||
/** Purely fold one provider-neutral event into the attempt assembly state. */
|
||||
export const reduce = reduceResponseState
|
||||
|
||||
/** Return a completed response only after a terminal finish or provider error. */
|
||||
/** Return a completed response only after a terminal finish event. */
|
||||
export const complete = (state: State): LLMResponse | undefined =>
|
||||
state.finishReason === undefined
|
||||
? undefined
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -418,9 +418,6 @@ const layer = Layer.effect(
|
||||
return
|
||||
}
|
||||
|
||||
case "provider-error":
|
||||
throw new Error(value.message)
|
||||
|
||||
case "step-start":
|
||||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||
yield* session.updatePart({
|
||||
|
||||
@@ -219,8 +219,7 @@ const fragmentFailureLLM = Layer.succeed(
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-1", text: "thinking" }),
|
||||
LLMEvent.textStart({ id: "text-1" }),
|
||||
LLMEvent.textDelta({ id: "text-1", text: "partial" }),
|
||||
LLMEvent.providerError({ message: "provider boom" }),
|
||||
),
|
||||
).pipe(Stream.concat(Stream.fail(new Error("provider boom")))),
|
||||
}),
|
||||
)
|
||||
const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]])
|
||||
|
||||
Reference in New Issue
Block a user