refactor(core): use APICallError guard and schema decode for fallback
This commit is contained in:
+21
-16
@@ -1,6 +1,7 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { APICallError } from "@ai-sdk/provider"
|
||||
import type {
|
||||
JSONSchema7,
|
||||
JSONValue,
|
||||
@@ -731,7 +732,18 @@ function llmError(method: string, error: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
const decodeErrorJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const ProviderErrorBody = Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
code: Schema.optionalKey(Schema.String),
|
||||
error: Schema.optionalKey(
|
||||
Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
code: Schema.optionalKey(Schema.String),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const decodeErrorData = Schema.decodeUnknownOption(ProviderErrorBody)
|
||||
const decodeErrorBody = Schema.decodeUnknownOption(Schema.fromJsonString(ProviderErrorBody))
|
||||
|
||||
// AI SDK errors such as AI_APICallError can carry an empty message while still
|
||||
// holding structured provider details. Derive a safe non-empty fallback from
|
||||
@@ -739,21 +751,14 @@ const decodeErrorJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
function unknownErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.trim() !== "") return message
|
||||
const record = isRecord(error) ? error : {}
|
||||
const status = typeof record.statusCode === "number" ? record.statusCode : undefined
|
||||
const data = isRecord(record.data)
|
||||
? record.data
|
||||
: typeof record.responseBody === "string"
|
||||
? Option.getOrUndefined(decodeErrorJson(record.responseBody))
|
||||
: undefined
|
||||
const detail = isRecord(data) ? (isRecord(data.error) ? data.error : data) : {}
|
||||
if (typeof detail.message === "string" && detail.message.trim() !== "") return detail.message
|
||||
const prefix = status === undefined ? "Provider request failed" : `Provider request failed with HTTP ${status}`
|
||||
return typeof detail.code === "string" && detail.code.trim() !== "" ? `${prefix}: ${detail.code}` : prefix
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
if (!APICallError.isInstance(error)) return "Provider request failed"
|
||||
const body =
|
||||
Option.getOrUndefined(decodeErrorData(error.data)) ?? Option.getOrUndefined(decodeErrorBody(error.responseBody))
|
||||
const detail = body?.error ?? body
|
||||
if (detail?.message) return detail.message
|
||||
const prefix =
|
||||
error.statusCode === undefined ? "Provider request failed" : `Provider request failed with HTTP ${error.statusCode}`
|
||||
return detail?.code ? `${prefix}: ${detail.code}` : prefix
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { APICallError } from "@ai-sdk/provider"
|
||||
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
@@ -369,15 +370,22 @@ it.effect("preserves non-empty AI SDK error messages", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const apiCallError = (input: Partial<ConstructorParameters<typeof APICallError>[0]>) =>
|
||||
new APICallError({
|
||||
message: "",
|
||||
url: "https://api.example.com/chat",
|
||||
requestBodyValues: { messages: [{ role: "user", content: "private prompt" }] },
|
||||
responseHeaders: { authorization: "Bearer secret-token" },
|
||||
...input,
|
||||
})
|
||||
|
||||
it.effect("derives status and code when the AI SDK error message is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
Object.assign(new Error(""), {
|
||||
apiCallError({
|
||||
statusCode: 404,
|
||||
responseBody: '{"error":{"message":"","code":"not_found"}}',
|
||||
data: { error: { message: "", code: "not_found" } },
|
||||
responseHeaders: { authorization: "Bearer secret-token" },
|
||||
requestBodyValues: { messages: [{ role: "user", content: "private prompt" }] },
|
||||
}),
|
||||
)
|
||||
expect(error.reason.message).toBe("Provider request failed with HTTP 404: not_found")
|
||||
@@ -392,7 +400,7 @@ it.effect("derives status and code when the AI SDK error message is empty", () =
|
||||
it.effect("prefers a structured provider message over the code fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
Object.assign(new Error(""), {
|
||||
apiCallError({
|
||||
statusCode: 404,
|
||||
data: { error: { message: "The requested model does not exist", code: "not_found" } },
|
||||
}),
|
||||
@@ -404,7 +412,7 @@ it.effect("prefers a structured provider message over the code fallback", () =>
|
||||
it.effect("falls back to the status alone for malformed response bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
Object.assign(new Error(""), {
|
||||
apiCallError({
|
||||
statusCode: 502,
|
||||
responseBody: "<html>Bad Gateway</html>",
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user