diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index ba25b59a3..a19a5d215 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -37,6 +37,7 @@ import type { ToolResultMessage, } from "../types.ts"; import { appendAssistantMessageDiagnostic } from "../utils/diagnostics.ts"; +import { withCauseDetail } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; @@ -1163,7 +1164,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( stream.end(); } catch (error) { output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = error instanceof Error ? withCauseDetail(error) : JSON.stringify(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } diff --git a/packages/ai/src/utils/error-body.ts b/packages/ai/src/utils/error-body.ts index d35f51a0e..f94a029de 100644 --- a/packages/ai/src/utils/error-body.ts +++ b/packages/ai/src/utils/error-body.ts @@ -46,15 +46,42 @@ export function normalizeProviderError(error: unknown): NormalizedProviderError const status = extractStatus(sdkError); const body = extractBody(sdkError); const messageCarriesBody = body === undefined || error.message.includes(body); + const message = status === undefined && body === undefined ? withCauseDetail(error) : error.message; return { status, body, - message: error.message, + message, messageCarriesBody, } satisfies NormalizedProviderError; } +const MAX_CAUSE_DEPTH = 5; + +/** + * Connection-level failures carry no status or body, and SDKs replace the + * reason with a generic message (`openai`: `"Connection error."`). The real + * reason sits in the `cause` chain, often two levels deep (undici's + * `"fetch failed"` wraps `connect ECONNREFUSED ...`). Append the deepest + * cause so callers that surface only the message keep the reason. + */ +export function withCauseDetail(error: Error): string { + let deepest: Error | undefined; + let current: unknown = error.cause; + for (let depth = 0; depth < MAX_CAUSE_DEPTH && current instanceof Error; depth++) { + deepest = current; + current = current.cause; + } + if (!deepest) return error.message; + let detail = (deepest.message || deepest.name).trim(); + const code = (deepest as Error & { code?: unknown }).code; + if ((typeof code === "string" || typeof code === "number") && !detail.includes(String(code))) { + detail = detail ? `${detail} (${code})` : String(code); + } + if (!detail || error.message.includes(detail)) return error.message; + return error.message ? `${error.message}: ${detail}` : detail; +} + /** * Probe the HTTP status, first numeric hit wins, in SDK-field order: * `statusCode` (Mistral) → `status` (`openai`, `@google/genai`) → diff --git a/packages/ai/test/error-body.test.ts b/packages/ai/test/error-body.test.ts index c2824e8be..b6e236770 100644 --- a/packages/ai/test/error-body.test.ts +++ b/packages/ai/test/error-body.test.ts @@ -5,13 +5,17 @@ // Bedrock ServiceException), plus the non-Error fallback, truncation, the empty // parsed-body edge case, and the formatProviderError compose helper. +import type { AddressInfo } from "node:net"; +import { createServer } from "node:net"; import { getHeapStatistics } from "node:v8"; +import OpenAI from "openai"; import { describe, expect, it } from "vitest"; import { formatProviderError, MAX_PROVIDER_ERROR_BODY_CHARS, normalizeProviderError, truncateErrorText, + withCauseDetail, } from "../src/utils/error-body.ts"; import { collectGarbage } from "./memory-helpers.ts"; @@ -248,3 +252,42 @@ describe("truncateErrorText", () => { expect(getHeapStatistics().used_heap_size - before).toBeLessThan(1_000_000); }); }); + +describe("connection error cause detail", () => { + it("appends the deepest cause of a connection error with no status or body", () => { + const socketError = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:8080"), { code: "ECONNREFUSED" }); + const fetchError = new TypeError("fetch failed", { cause: socketError }); + const error = new Error("Connection error.", { cause: fetchError }); + + expect(formatProviderError(normalizeProviderError(error))).toBe( + "Connection error.: connect ECONNREFUSED 127.0.0.1:8080", + ); + }); + + it("adds the error code when the cause message lacks it", () => { + const cause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" }); + const error = new Error("Connection error.", { cause }); + + expect(withCauseDetail(error)).toBe("Connection error.: other side closed (UND_ERR_SOCKET)"); + }); + + it("leaves errors without a cause unchanged", () => { + expect(withCauseDetail(new Error("Connection error."))).toBe("Connection error."); + }); + + it("surfaces the reason from a real openai SDK connection failure", async () => { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as AddressInfo).port; + await new Promise((resolve) => server.close(() => resolve())); + + const client = new OpenAI({ apiKey: "test", baseURL: `http://127.0.0.1:${port}/v1`, maxRetries: 0 }); + const error = await client.chat.completions + .create({ model: "m", messages: [{ role: "user", content: "hi" }] }) + .catch((e: unknown) => e); + + expect(formatProviderError(normalizeProviderError(error))).toBe( + `Connection error.: connect ECONNREFUSED 127.0.0.1:${port}`, + ); + }); +});