From da7c2ecd4740b3ab84d6ebab64b91551ba4baa0d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 9 Aug 2026 16:07:45 -0500 Subject: [PATCH] refactor(core): use APICallError guard and schema decode for fallback --- packages/core/src/aisdk.ts | 37 ++++++++++++++++++-------------- packages/core/test/aisdk.test.ts | 18 +++++++++++----- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index d963b1c3aa..3dadc760c5 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -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 { - 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: [] }) diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index d2fab968b4..47bf40b749 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -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[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: "Bad Gateway", }),