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" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,13 @@ import type {
|
|||
SharedV3ProviderOptions,
|
||||
} from "@ai-sdk/provider"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
classifyProviderFailure,
|
||||
FinishReason,
|
||||
HttpContext,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
InvalidRequestReason,
|
||||
InvalidProviderOutputReason,
|
||||
LLMEvent,
|
||||
LLMError,
|
||||
|
|
@ -23,13 +29,28 @@ import {
|
|||
ProviderID,
|
||||
ProviderMetadata,
|
||||
ToolResultValue,
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import {
|
||||
APICallError,
|
||||
EmptyResponseBodyError,
|
||||
InvalidArgumentError,
|
||||
InvalidPromptError,
|
||||
InvalidResponseDataError,
|
||||
JSONParseError,
|
||||
LoadAPIKeyError,
|
||||
LoadSettingError,
|
||||
NoContentGeneratedError,
|
||||
NoSuchModelError,
|
||||
TypeValidationError,
|
||||
UnsupportedFunctionalityError,
|
||||
} from "@ai-sdk/provider"
|
||||
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
|
|
@ -40,6 +61,8 @@ type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
|
|||
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
|
||||
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
|
||||
|
||||
class ChunkTimeoutError extends Error {}
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: ModelV2.Info
|
||||
readonly package: string
|
||||
|
|
@ -64,7 +87,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
|||
async pull(ctrl) {
|
||||
const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
const err = new Error("SSE read timed out")
|
||||
const err = new ChunkTimeoutError("SSE read timed out")
|
||||
ctl.abort(err)
|
||||
void reader.cancel(err)
|
||||
reject(err)
|
||||
|
|
@ -716,10 +739,37 @@ 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) })
|
||||
if (error instanceof LLMError) return error
|
||||
const cause = error instanceof Error ? error.cause : undefined
|
||||
const failures = [error, cause]
|
||||
const code = failures.map(machineCode).find((value) => value !== undefined)
|
||||
const reason = (() => {
|
||||
if (
|
||||
error instanceof ChunkTimeoutError ||
|
||||
failures.some((failure) => failure instanceof Error && failure.name === "TimeoutError") ||
|
||||
(code !== undefined && TRANSPORT_TIMEOUT_CODES.has(code))
|
||||
)
|
||||
return new TransportReason({ message: errorMessage(error), kind: code ?? "Timeout" })
|
||||
if (APICallError.isInstance(error)) return apiCallReason(error)
|
||||
if (code !== undefined && TRANSPORT_CONNECTION_CODES.has(code))
|
||||
return new TransportReason({ message: errorMessage(error), kind: code })
|
||||
const malformed = failures.find(isMalformedError)
|
||||
if (malformed) return new InvalidProviderOutputReason({ message: malformed.message })
|
||||
if (LoadAPIKeyError.isInstance(error))
|
||||
return new AuthenticationReason({ message: error.message, kind: "missing" })
|
||||
if (NoSuchModelError.isInstance(error)) return new InvalidRequestReason({ message: error.message })
|
||||
if (
|
||||
LoadSettingError.isInstance(error) ||
|
||||
InvalidPromptError.isInstance(error) ||
|
||||
InvalidArgumentError.isInstance(error) ||
|
||||
UnsupportedFunctionalityError.isInstance(error)
|
||||
)
|
||||
return new InvalidRequestReason({ message: error.message })
|
||||
const providerCode = apiFailureCode(error)
|
||||
if (providerCode)
|
||||
return classifyProviderFailure({ message: apiFailureMessage(error), code: providerCode })
|
||||
return new UnknownProviderReason({ message: errorMessage(error) })
|
||||
})()
|
||||
return new LLMError({
|
||||
module: "AISDK",
|
||||
method,
|
||||
|
|
@ -727,4 +777,107 @@ function llmError(method: string, error: unknown) {
|
|||
})
|
||||
}
|
||||
|
||||
function apiCallReason(error: APICallError) {
|
||||
const malformed = isMalformedError(error.cause) ? error.cause : undefined
|
||||
if (error.statusCode !== undefined && error.statusCode < 400 && malformed)
|
||||
return new InvalidProviderOutputReason({ message: malformed.message })
|
||||
const code = apiFailureCode(error.data) ?? apiFailureCode(error.responseBody)
|
||||
if (error.statusCode === undefined) {
|
||||
if (code) return classifyProviderFailure({ message: error.message, code })
|
||||
if (error.isRetryable)
|
||||
return new TransportReason({ message: error.message, url: RequestExecutor.redactUrl(error.url) })
|
||||
return new UnknownProviderReason({ message: error.message })
|
||||
}
|
||||
const body = RequestExecutor.redactResponseBody(error.responseBody, {
|
||||
url: error.url,
|
||||
headers: error.responseHeaders ?? {},
|
||||
})
|
||||
return classifyProviderFailure({
|
||||
message: error.message,
|
||||
status: error.statusCode,
|
||||
code,
|
||||
retryAfterMs: retryAfterMs(error.responseHeaders),
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: RequestExecutor.redactUrl(error.url), headers: {} }),
|
||||
response: new HttpResponseDetails({
|
||||
status: error.statusCode,
|
||||
headers: RequestExecutor.redactHeaders(error.responseHeaders ?? {}),
|
||||
}),
|
||||
...body,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const TRANSPORT_TIMEOUT_CODES = new Set([
|
||||
"ETIMEDOUT",
|
||||
"UND_ERR_BODY_TIMEOUT",
|
||||
"UND_ERR_CONNECT_TIMEOUT",
|
||||
"UND_ERR_HEADERS_TIMEOUT",
|
||||
])
|
||||
const TRANSPORT_CONNECTION_CODES = new Set([
|
||||
"EAI_AGAIN",
|
||||
"ECONNREFUSED",
|
||||
"ECONNRESET",
|
||||
"EHOSTUNREACH",
|
||||
"ENETUNREACH",
|
||||
"ENOTFOUND",
|
||||
"EPIPE",
|
||||
"UND_ERR_SOCKET",
|
||||
])
|
||||
|
||||
function retryAfterMs(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)
|
||||
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now())
|
||||
}
|
||||
|
||||
function field(error: unknown, name: string) {
|
||||
return typeof error === "object" && error !== null ? Reflect.get(error, name) : undefined
|
||||
}
|
||||
|
||||
function machineCode(error: unknown) {
|
||||
const code = field(error, "code")
|
||||
return typeof code === "string" ? code.toUpperCase() : undefined
|
||||
}
|
||||
|
||||
function apiFailureCode(error: unknown): string | undefined {
|
||||
if (typeof error === "string") {
|
||||
const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(error))
|
||||
return apiFailureCode(decoded)
|
||||
}
|
||||
const code = field(error, "code")
|
||||
if (typeof code === "string") return code
|
||||
const type = field(error, "type")
|
||||
if (typeof type === "string" && type !== "error") return type
|
||||
const nested = field(error, "error")
|
||||
return nested === undefined || nested === error ? undefined : apiFailureCode(nested)
|
||||
}
|
||||
|
||||
function apiFailureMessage(error: unknown) {
|
||||
const message = field(error, "message")
|
||||
if (typeof message === "string") return message
|
||||
const nested = field(field(error, "error"), "message")
|
||||
return typeof nested === "string" ? nested : String(error)
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function isMalformedError(error: unknown): error is Error {
|
||||
return (
|
||||
InvalidResponseDataError.isInstance(error) ||
|
||||
JSONParseError.isInstance(error) ||
|
||||
TypeValidationError.isInstance(error) ||
|
||||
EmptyResponseBodyError.isInstance(error) ||
|
||||
NoContentGeneratedError.isInstance(error)
|
||||
)
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"
|
||||
import {
|
||||
APICallError,
|
||||
InvalidResponseDataError,
|
||||
type LanguageModelV3,
|
||||
type LanguageModelV3CallOptions,
|
||||
type LanguageModelV3StreamPart,
|
||||
} from "@ai-sdk/provider"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { LLM, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient } from "@opencode-ai/ai/route"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AISDK.locationLayer)
|
||||
|
|
@ -19,6 +25,49 @@ const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
|||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
const failingLanguage = (error: unknown): LanguageModelV3 => ({
|
||||
specificationVersion: "v3",
|
||||
provider: "test-provider",
|
||||
modelId: "api-model",
|
||||
supportedUrls: {},
|
||||
doGenerate: async () => {
|
||||
throw error
|
||||
},
|
||||
doStream: async () => {
|
||||
throw error
|
||||
},
|
||||
})
|
||||
|
||||
const streamingLanguage = (...events: LanguageModelV3StreamPart[]): LanguageModelV3 => ({
|
||||
...failingLanguage(new Error("unused")),
|
||||
doStream: async () => ({
|
||||
stream: new ReadableStream<LanguageModelV3StreamPart>({
|
||||
start(controller) {
|
||||
events.forEach((event) => controller.enqueue(event))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
request: { body: {} },
|
||||
}),
|
||||
})
|
||||
|
||||
const streamFailure = (language: LanguageModelV3) =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = {}
|
||||
})
|
||||
yield* aisdk.hook.language((event) => {
|
||||
event.language = language
|
||||
})
|
||||
const resolved = yield* aisdk.model(model("@ai-sdk/openai"))
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(request)
|
||||
return yield* resolved.route
|
||||
.streamPrepared(prepared.body, request, { http: { execute: () => Effect.die("unused") } })
|
||||
.pipe(Stream.runDrain, Effect.flip)
|
||||
})
|
||||
|
||||
it.effect("keys language models by package and flattened overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
|
@ -238,3 +287,77 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
|||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies AI SDK API failures with redacted diagnostics", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(
|
||||
new APICallError({
|
||||
message: "Quota exceeded",
|
||||
url: "https://provider.test/v1?key=url-secret",
|
||||
requestBodyValues: {},
|
||||
statusCode: 429,
|
||||
responseHeaders: { "retry-after-ms": "250", "x-api-key": "header-secret" },
|
||||
responseBody: '{"error":{"code":"insufficient_quota","api_key":"body-secret"}}',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: {
|
||||
_tag: "QuotaExceeded",
|
||||
http: {
|
||||
request: { url: "https://provider.test/v1?key=%3Credacted%3E" },
|
||||
response: { headers: { "x-api-key": "<redacted>" } },
|
||||
body: '{"error":{"code":"insufficient_quota","api_key":"<redacted>"}}',
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies AI SDK timeouts", () =>
|
||||
Effect.gen(function* () {
|
||||
const timeout = yield* streamFailure(failingLanguage(new DOMException("timed out", "TimeoutError")))
|
||||
|
||||
expect(timeout).toMatchObject({ reason: { _tag: "Transport", kind: "Timeout" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies AI SDK connection failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const reset = yield* streamFailure(
|
||||
failingLanguage(Object.assign(new Error("connection reset"), { code: "ECONNRESET" })),
|
||||
)
|
||||
|
||||
expect(reset).toMatchObject({ reason: { _tag: "Transport", kind: "ECONNRESET" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies structured AI SDK stream errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
streamingLanguage({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } }),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", message: "Overloaded" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies malformed AI SDK response causes", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(
|
||||
new APICallError({
|
||||
message: "Failed to process response",
|
||||
url: "https://provider.test/v1",
|
||||
requestBodyValues: {},
|
||||
statusCode: 200,
|
||||
cause: new InvalidResponseDataError({ data: { invalid: true } }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidProviderOutput" } })
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue