diff --git a/apps/gateway/src/app.ts b/apps/gateway/src/app.ts index 255f421685..065d309909 100644 --- a/apps/gateway/src/app.ts +++ b/apps/gateway/src/app.ts @@ -30,6 +30,7 @@ import { imagesRoute } from "./images/route.js"; import { buildAnthropicErrorBody, buildOpenAIErrorBody, + getUpstreamErrorCause, } from "./lib/error-response.js"; import { mcpHandler, registerMcpOAuthRoutes } from "./mcp/mcp.js"; import { tracingMiddleware } from "./middleware/tracing.js"; @@ -165,8 +166,19 @@ app.onError((error, c) => { if (error instanceof HTTPException) { const status = error.status; - - if (status >= 500) { + const upstreamCause = getUpstreamErrorCause(error.cause); + + if (upstreamCause) { + // Expected upstream/gateway provider error (e.g. a provider 429) + // forwarded from an internal chat-completions call. It is already + // recorded in the log table with its unifiedFinishReason, so surface + // it as a warning rather than an ERROR-level log. + logger.warn("Provider error", { + status, + message: error.message, + unifiedFinishReason: upstreamCause.unifiedFinishReason, + }); + } else if (status >= 500) { logger.error("HTTP 500 exception", error); } else { logger.warn("HTTP client error", { status, message: error.message }); diff --git a/apps/gateway/src/images/images.ts b/apps/gateway/src/images/images.ts index 8d24856afb..1e33637802 100644 --- a/apps/gateway/src/images/images.ts +++ b/apps/gateway/src/images/images.ts @@ -2,12 +2,14 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; import { app } from "@/app.js"; +import { upstreamErrorCause } from "@/lib/error-response.js"; import { parseDataUrl, processImageUrl } from "@llmgateway/actions"; import { logger, toError } from "@llmgateway/logger"; import type { ServerTypes } from "@/vars.js"; import type { Context } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; const imageGenerationsRequestSchema = z.object({ prompt: z.string().min(1).openapi({ @@ -338,15 +340,23 @@ async function forwardToChatCompletions( }); const errorData = await response.text(); let errorMessage = `Image generation failed with status ${response.status}`; + let upstreamFinishReason: string | undefined; try { const parsed = JSON.parse(errorData); errorMessage = parsed?.error?.message ?? parsed?.message ?? errorMessage; + const errorType = parsed?.error?.type ?? parsed?.error?.code; + if (errorType === "upstream_error" || errorType === "gateway_error") { + upstreamFinishReason = errorType; + } } catch { // use default message } - throw new HTTPException(response.status as any, { + throw new HTTPException(response.status as ContentfulStatusCode, { message: errorMessage, + ...(upstreamFinishReason + ? { cause: upstreamErrorCause(upstreamFinishReason) } + : {}), }); } diff --git a/apps/gateway/src/lib/error-response.spec.ts b/apps/gateway/src/lib/error-response.spec.ts index b65d8286d7..62f7aacaae 100644 --- a/apps/gateway/src/lib/error-response.spec.ts +++ b/apps/gateway/src/lib/error-response.spec.ts @@ -5,6 +5,8 @@ import { buildOpenAIErrorBody, getAnthropicErrorType, getOpenAIErrorMeta, + getUpstreamErrorCause, + upstreamErrorCause, } from "./error-response.js"; describe("error-response", () => { @@ -70,4 +72,27 @@ describe("error-response", () => { expect(getAnthropicErrorType(429)).toBe("rate_limit_error"); expect(getAnthropicErrorType(500)).toBe("api_error"); }); + + test("round-trips the upstream error cause marker", () => { + const cause = upstreamErrorCause("upstream_error"); + expect(cause).toEqual({ + upstreamError: true, + unifiedFinishReason: "upstream_error", + }); + expect(getUpstreamErrorCause(cause)).toBe(cause); + expect(getUpstreamErrorCause(upstreamErrorCause("gateway_error"))).toEqual({ + upstreamError: true, + unifiedFinishReason: "gateway_error", + }); + }); + + test("rejects non-marker causes", () => { + expect(getUpstreamErrorCause(undefined)).toBeNull(); + expect(getUpstreamErrorCause(null)).toBeNull(); + expect(getUpstreamErrorCause(new Error("boom"))).toBeNull(); + expect(getUpstreamErrorCause({ upstreamError: true })).toBeNull(); + expect( + getUpstreamErrorCause({ unifiedFinishReason: "upstream_error" }), + ).toBeNull(); + }); }); diff --git a/apps/gateway/src/lib/error-response.ts b/apps/gateway/src/lib/error-response.ts index e26c7c6eb7..2f9831dc1a 100644 --- a/apps/gateway/src/lib/error-response.ts +++ b/apps/gateway/src/lib/error-response.ts @@ -128,6 +128,37 @@ export function buildOpenAIErrorBody(opts: { }; } +// Marker attached as the `cause` of an HTTPException when an internal +// chat-completions call surfaces an expected upstream/gateway provider error +// (e.g. a provider 429). These are already recorded in the log table with their +// unifiedFinishReason, so the global error handler should surface them as +// warnings rather than ERROR-level logs that trip error alerting. +export interface UpstreamErrorCause { + upstreamError: true; + unifiedFinishReason: string; +} + +export function upstreamErrorCause( + unifiedFinishReason: string, +): UpstreamErrorCause { + return { upstreamError: true, unifiedFinishReason }; +} + +export function getUpstreamErrorCause( + cause: unknown, +): UpstreamErrorCause | null { + if ( + cause && + typeof cause === "object" && + (cause as Partial).upstreamError === true && + typeof (cause as Partial).unifiedFinishReason === + "string" + ) { + return cause as UpstreamErrorCause; + } + return null; +} + export function buildAnthropicErrorBody(opts: { message: string; status: number;