fix(ai): classify provider failures consistently
This commit is contained in:
parent
87d5b27668
commit
0960ef48fd
9 changed files with 398 additions and 46 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 { classifyProviderFailure, isContextOverflow, isContextOverflowFailure } from "./provider-error"
|
||||
export type {
|
||||
RouteModelInput,
|
||||
RouteRoutedModelInput,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,12 @@ const SERVER_CODES = new Set([
|
|||
"server_is_overloaded",
|
||||
"serviceunavailableexception",
|
||||
])
|
||||
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
||||
const INVALID_REQUEST_CODES = new Set([
|
||||
"invalid_prompt",
|
||||
"invalid_request_error",
|
||||
"request_too_large",
|
||||
"validationexception",
|
||||
])
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
|
|
@ -87,7 +92,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||
clientScoped &&
|
||||
(codes.includes("context_length_exceeded") ||
|
||||
codes.includes("model_context_window_exceeded") ||
|
||||
isContextOverflow(text))
|
||||
isContextOverflow(body) ||
|
||||
isContextOverflow(input.message))
|
||||
)
|
||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
||||
|
|
@ -112,6 +118,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
|
|
@ -131,7 +138,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||
status: input.status,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||
if (
|
||||
input.status === 400 ||
|
||||
input.status === 404 ||
|
||||
|
|
|
|||
|
|
@ -47,15 +47,14 @@ const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name)
|
|||
|
||||
const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name)
|
||||
|
||||
const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray<string | RegExp>) =>
|
||||
export const redactHeaders = (headers: Headers.Input, redactedNames: ReadonlyArray<string | RegExp> = []) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [
|
||||
name,
|
||||
String(value),
|
||||
]),
|
||||
Object.entries(Headers.redact(Headers.fromInput(headers), [...redactedNames, SENSITIVE_NAME])).map(
|
||||
([name, value]) => [name, String(value)],
|
||||
),
|
||||
)
|
||||
|
||||
const redactUrl = (value: string) => {
|
||||
export const redactUrl = (value: string) => {
|
||||
if (!URL.canParse(value)) return REDACTED
|
||||
const url = new URL(value)
|
||||
url.searchParams.forEach((_, key) => {
|
||||
|
|
@ -151,7 +150,12 @@ const responseDetails = (
|
|||
headers: redactHeaders(response.headers, redactedNames),
|
||||
})
|
||||
|
||||
const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
|
||||
interface RedactionRequest {
|
||||
readonly url: string
|
||||
readonly headers: Headers.Input
|
||||
}
|
||||
|
||||
const secretValues = (request: RedactionRequest) => {
|
||||
const values = new Set<string>()
|
||||
const add = (value: string) => {
|
||||
if (value.length < 4) return
|
||||
|
|
@ -176,13 +180,13 @@ const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
|
|||
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
|
||||
// for any field name that looks sensitive) plus literal (replace any actual
|
||||
// secret values we sent in the request, in case the response echoes one back).
|
||||
const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
|
||||
const redactBody = (body: string, request: RedactionRequest) =>
|
||||
Array.from(secretValues(request)).reduce(
|
||||
(text, secret) => text.split(secret).join(REDACTED),
|
||||
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
|
||||
)
|
||||
|
||||
const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
|
||||
export const redactResponseBody = (body: string | void, request: RedactionRequest) => {
|
||||
if (body === undefined) return {}
|
||||
const redacted = redactBody(body, request)
|
||||
if (redacted.length <= BODY_LIMIT) return { body: redacted }
|
||||
|
|
@ -198,7 +202,7 @@ const responseHttp = (input: {
|
|||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly response: HttpClientResponse.HttpClientResponse
|
||||
readonly redactedNames: ReadonlyArray<string | RegExp>
|
||||
readonly body: ReturnType<typeof responseBody>
|
||||
readonly body: ReturnType<typeof redactResponseBody>
|
||||
readonly requestId?: string | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
}) =>
|
||||
|
|
@ -219,7 +223,7 @@ const statusError =
|
|||
const headers = normalizedHeaders(response.headers)
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(body, request)
|
||||
const details = redactResponseBody(body, request)
|
||||
return yield* new LLMError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
|
|
@ -240,7 +244,32 @@ const statusError =
|
|||
})
|
||||
})
|
||||
|
||||
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||
const TIMEOUT_CODES = new Set([
|
||||
"ETIMEDOUT",
|
||||
"UND_ERR_BODY_TIMEOUT",
|
||||
"UND_ERR_CONNECT_TIMEOUT",
|
||||
"UND_ERR_HEADERS_TIMEOUT",
|
||||
])
|
||||
|
||||
const errorCause = (error: unknown) => {
|
||||
if (HttpClientError.isHttpClientError(error) && "cause" in error.reason) return error.reason.cause
|
||||
return error instanceof Error ? error.cause : undefined
|
||||
}
|
||||
|
||||
const errorCode = (error: unknown) => {
|
||||
if (typeof error !== "object" || error === null) return undefined
|
||||
const code = Reflect.get(error, "code")
|
||||
return typeof code === "string" ? code.toUpperCase() : undefined
|
||||
}
|
||||
|
||||
const errorMessage = (error: unknown, fallback: string) => {
|
||||
const cause = errorCause(error)
|
||||
if (cause instanceof Error) return cause.message
|
||||
if (error instanceof Error) return error.message
|
||||
return fallback
|
||||
}
|
||||
|
||||
export const mapHttpClientError = (error: unknown, redactedNames: ReadonlyArray<string | RegExp>) => {
|
||||
const transportError = (input: {
|
||||
readonly message: string
|
||||
readonly kind?: string | undefined
|
||||
|
|
@ -257,23 +286,30 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
|
|||
}),
|
||||
})
|
||||
|
||||
if (Cause.isTimeoutError(error)) {
|
||||
return transportError({ message: error.message, kind: "Timeout" })
|
||||
}
|
||||
const cause = errorCause(error)
|
||||
const code = errorCode(cause) ?? errorCode(error)
|
||||
const request = HttpClientError.isHttpClientError(error) ? error.request : undefined
|
||||
if (
|
||||
Cause.isTimeoutError(error) ||
|
||||
Cause.isTimeoutError(cause) ||
|
||||
(error instanceof Error && error.name === "TimeoutError") ||
|
||||
(cause instanceof Error && cause.name === "TimeoutError") ||
|
||||
(code !== undefined && TIMEOUT_CODES.has(code))
|
||||
)
|
||||
return transportError({ message: errorMessage(error, "HTTP transport timed out"), kind: code ?? "Timeout", request })
|
||||
if (!HttpClientError.isHttpClientError(error)) {
|
||||
return transportError({ message: "HTTP transport failed" })
|
||||
return transportError({ message: errorMessage(error, "HTTP transport failed"), kind: code })
|
||||
}
|
||||
const request = "request" in error ? error.request : undefined
|
||||
if (error.reason._tag === "TransportError") {
|
||||
return transportError({
|
||||
message: error.reason.description ?? "HTTP transport failed",
|
||||
kind: error.reason._tag,
|
||||
message: error.reason.description ?? errorMessage(error, "HTTP transport failed"),
|
||||
kind: code ?? error.reason._tag,
|
||||
request,
|
||||
})
|
||||
}
|
||||
return transportError({
|
||||
message: `HTTP transport failed: ${error.reason._tag}`,
|
||||
kind: error.reason._tag,
|
||||
message: errorMessage(error, `HTTP transport failed: ${error.reason._tag}`),
|
||||
kind: code ?? error.reason._tag,
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
|
@ -287,7 +323,10 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
|||
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||
return yield* http
|
||||
.execute(request)
|
||||
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||
.pipe(
|
||||
Effect.mapError((error) => mapHttpClientError(error, redactedNames)),
|
||||
Effect.flatMap(statusError(request, redactedNames)),
|
||||
)
|
||||
})
|
||||
return Service.of({
|
||||
execute: executeOnce,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Framing } from "../framing"
|
|||
import type { Transport, TransportPrepareInput } from "./index"
|
||||
import * as ProviderShared from "../../protocols/shared"
|
||||
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||
import { mapHttpClientError } from "../executor"
|
||||
|
||||
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
||||
|
||||
|
|
@ -134,14 +135,10 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
|||
.execute(prepared.request)
|
||||
.pipe(
|
||||
Effect.map((response) =>
|
||||
prepared.framing.frame(
|
||||
response.stream.pipe(
|
||||
Stream.mapError((error) =>
|
||||
ProviderShared.eventError(
|
||||
`${request.model.provider}/${request.model.route.id}`,
|
||||
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
|
||||
ProviderShared.errorText(error),
|
||||
),
|
||||
Stream.unwrap(
|
||||
Effect.map(Headers.CurrentRedactedNames, (redactedNames) =>
|
||||
prepared.framing.frame(
|
||||
response.stream.pipe(Stream.mapError((error) => mapHttpClientError(error, redactedNames))),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -63,14 +63,14 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
|
|||
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
|
||||
* exercise transport errors that surface during parsing.
|
||||
*/
|
||||
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
|
||||
export const truncatedStream = (chunks: ReadonlyArray<string>, error: unknown = new Error("connection reset")) =>
|
||||
dynamicResponse((input) =>
|
||||
Effect.sync(() => {
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
||||
controller.error(new Error("connection reset"))
|
||||
controller.error(error)
|
||||
},
|
||||
})
|
||||
return input.respond(stream, { headers: SSE_HEADERS })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { isContextOverflow } from "../src"
|
||||
import { HttpContext, HttpRequestDetails, isContextOverflow } from "../src"
|
||||
import { classifyProviderFailure } from "../src/provider-error"
|
||||
|
||||
describe("provider error classification", () => {
|
||||
|
|
@ -7,6 +7,25 @@ describe("provider error classification", () => {
|
|||
expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
|
||||
})
|
||||
|
||||
test("checks overflow evidence in the message when the response body is uninformative", () => {
|
||||
expect(
|
||||
classifyProviderFailure({
|
||||
message: "Input is too long for requested model",
|
||||
status: 400,
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: "https://provider.test", headers: {} }),
|
||||
body: "{}",
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({ classification: "context-overflow" })
|
||||
})
|
||||
|
||||
test("lets semantic invalid-request codes override server status", () => {
|
||||
expect(classifyProviderFailure({ message: "too large", status: 500, code: "request_too_large" })._tag).toBe(
|
||||
"InvalidRequest",
|
||||
)
|
||||
})
|
||||
|
||||
test("classifies V1 plain-text rate limit fallbacks", () => {
|
||||
expect(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -651,7 +651,22 @@ describe("OpenAI Chat route", () => {
|
|||
])
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
|
||||
|
||||
expect(error.message).toContain("Failed to read openai/openai-chat stream")
|
||||
expect(error).toMatchObject({ reason: { _tag: "Transport", message: "connection reset" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies response body timeouts as transport timeouts", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
truncatedStream([], Object.assign(new Error("body timed out"), { code: "UND_ERR_BODY_TIMEOUT" })),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "Transport", kind: "UND_ERR_BODY_TIMEOUT", message: "body timed out" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue