refactor(llm): replace LLMError reasons with flat tagged union
Replace the LLMError { module, method, reason } wrapper with a flat
tagged union (LLM.BadRequest, LLM.Authentication, LLM.PermissionDenied,
LLM.NotFound, LLM.RateLimit, LLM.QuotaExceeded, LLM.ContentPolicy,
LLM.ContextOverflow, LLM.ServerError, LLM.APIError, LLM.ConnectionError,
LLM.TimeoutError, LLM.MalformedResponse, LLM.NoRoute) plus an isLLMError
guard. Add one shared classifyApiFailure classifier used by the HTTP
executor and the AI SDK adapter so both surfaces classify identically,
preserving status, headers, body, and retry-after.
Core policy moves onto tags: retry RateLimit | ServerError |
ConnectionError | TimeoutError; toSessionError adds
provider.context-overflow, provider.timeout, and provider.not-found.
The provider-error stream event and the runner's held-back overflow
handling are unchanged here; isContextOverflowFailure now bridges old
events and new tags until the event is removed.
This commit is contained in:
parent
4e74f77e44
commit
fce506b3f9
29 changed files with 581 additions and 448 deletions
|
|
@ -2,7 +2,7 @@ export { LLMClient } from "./route/client"
|
|||
export { Auth } from "./route/auth"
|
||||
export { Provider } from "./provider"
|
||||
export { ProviderPackage } from "./provider-package"
|
||||
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
|
||||
export { classifyApiFailure, isContextOverflow, isContextOverflowFailure, type ApiFailure } from "./provider-error"
|
||||
export type {
|
||||
RouteModelInput,
|
||||
RouteRoutedModelInput,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,22 @@
|
|||
import { Schema } from "effect"
|
||||
import { LLMError, ProviderErrorEvent } from "./schema"
|
||||
import {
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ContentPolicy,
|
||||
ContextOverflow,
|
||||
HttpContext,
|
||||
HttpRateLimitDetails,
|
||||
NotFound,
|
||||
PermissionDenied,
|
||||
ProviderErrorEvent,
|
||||
ProviderMetadata,
|
||||
QuotaExceeded,
|
||||
RateLimit,
|
||||
ServerError,
|
||||
isLLMError,
|
||||
type LLMError,
|
||||
} from "./schema"
|
||||
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
|
|
@ -28,6 +45,106 @@ export const isContextOverflow = (message: string) =>
|
|||
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
|
||||
|
||||
export const isContextOverflowFailure = (failure: unknown) =>
|
||||
failure instanceof LLMError
|
||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
||||
isLLMError(failure)
|
||||
? failure._tag === "LLM.ContextOverflow"
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"])
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const SERVER_ERROR_STATUS = (status: number) => status >= 500 || status === 529
|
||||
|
||||
const CODE_CLASSIFICATION: Record<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,7 +225,7 @@ 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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -31,118 +31,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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue