From 4b5dca1a5ba2abe5cd6c5abe511895c827df2ad2 Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Thu, 4 Jun 2026 22:06:46 +0200 Subject: [PATCH 1/5] fix(gateway): openai-compatible error envelopes Gateway-level errors (auth, usage/rate limit, validation, timeouts) were returned as `{error: true, status, message}` instead of OpenAI's `{error: {message, type, param, code}}`, breaking OpenAI SDK clients. Route the global error handler and the remaining ad-hoc error returns through shared builders: OpenAI shape for chat/embeddings/images/etc and Anthropic's `{type: "error", error: {type, message}}` for /v1/messages. Co-Authored-By: Claude Opus 4.8 --- apps/gateway/src/anthropic/anthropic.ts | 8 +- apps/gateway/src/api.spec.ts | 35 ++++-- apps/gateway/src/app.ts | 86 +++++-------- apps/gateway/src/chat-custom-provider.e2e.ts | 8 +- apps/gateway/src/lib/error-response.spec.ts | 67 +++++++++++ apps/gateway/src/lib/error-response.ts | 120 +++++++++++++++++++ apps/gateway/src/moderations/moderations.ts | 12 +- 7 files changed, 256 insertions(+), 80 deletions(-) create mode 100644 apps/gateway/src/lib/error-response.spec.ts create mode 100644 apps/gateway/src/lib/error-response.ts diff --git a/apps/gateway/src/anthropic/anthropic.ts b/apps/gateway/src/anthropic/anthropic.ts index e23bc5b9ef..697e5e1f8a 100644 --- a/apps/gateway/src/anthropic/anthropic.ts +++ b/apps/gateway/src/anthropic/anthropic.ts @@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception"; import { streamSSE } from "hono/streaming"; import { app } from "@/app.js"; +import { buildAnthropicErrorBody } from "@/lib/error-response.js"; import { extractAnthropicSessionId } from "@/lib/session-id.js"; import { logger, toError } from "@llmgateway/logger"; @@ -578,11 +579,10 @@ anthropic.openapi(messages, async (c) => { } return c.json( - { - error: true, - status: response.status, + buildAnthropicErrorBody({ message: `Request failed: ${errorData}`, - }, + status: response.status, + }), response.status as 400 | 401 | 402 | 403 | 404 | 429 | 500, ); } diff --git a/apps/gateway/src/api.spec.ts b/apps/gateway/src/api.spec.ts index 40493ceb55..8a176606f3 100644 --- a/apps/gateway/src/api.spec.ts +++ b/apps/gateway/src/api.spec.ts @@ -536,7 +536,7 @@ describe("api", () => { expect(res.status).toBe(403); const json = await res.json(); - expect(json.message).toContain( + expect(json.error.message).toContain( "Provider openai is in the denied providers list", ); }); @@ -573,7 +573,9 @@ describe("api", () => { expect(res.status).toBe(402); const json = await res.json(); - expect(json.message).toBe("Organization org-id has insufficient credits"); + expect(json.error.message).toBe( + "Organization org-id has insufficient credits", + ); }); test("/v1/embeddings hybrid fallback requires credits", async () => { @@ -607,7 +609,7 @@ describe("api", () => { expect(res.status).toBe(402); const json = await res.json(); - expect(json.message).toBe( + expect(json.error.message).toBe( "No API key set for provider and organization has insufficient credits", ); }); @@ -647,7 +649,9 @@ describe("api", () => { expect(res.status).toBe(402); const json = await res.json(); - expect(json.message).toContain("insufficient credits for data retention"); + expect(json.error.message).toContain( + "insufficient credits for data retention", + ); }); test("/v1/embeddings google-ai-studio single input", async () => { @@ -2620,7 +2624,7 @@ describe("api", () => { expect(res.status).toBe(400); const json = await res.json(); - expect(json.message).toContain("does not support reasoning"); + expect(json.error.message).toContain("does not support reasoning"); }); test("Max tokens validation error when exceeding model limit", async () => { @@ -2662,9 +2666,11 @@ describe("api", () => { expect(res.status).toBe(400); const json = await res.json(); - expect(json.message).toContain("exceeds the maximum output tokens allowed"); - expect(json.message).toContain("10000"); - expect(json.message).toContain("8192"); + expect(json.error.message).toContain( + "exceeds the maximum output tokens allowed", + ); + expect(json.error.message).toContain("10000"); + expect(json.error.message).toContain("8192"); }); test("Max tokens validation allows valid token count", async () => { @@ -2733,7 +2739,7 @@ describe("api", () => { "Provider-specific model error:", JSON.stringify(json, null, 2), ); - expect(json.message).toContain("not supported"); + expect(json.error.message).toContain("not supported"); }); // invalid model test @@ -2820,6 +2826,13 @@ describe("api", () => { }), }); expect(res.status).toBe(401); + const json = await res.json(); + expect(json.error).toMatchObject({ + type: "invalid_request_error", + param: null, + code: "invalid_api_key", + }); + expect(typeof json.error.message).toBe("string"); }); // test for explicitly specifying a provider in the format "provider/model" @@ -3972,7 +3985,7 @@ describe("api", () => { expect(res.status).toBe(402); const json = await res.json(); - expect(json.message).toBe( + expect(json.error.message).toBe( "No API key set for provider and organization has insufficient credits", ); } finally { @@ -4010,7 +4023,7 @@ describe("api", () => { expect(res.status).toBe(402); const json = await res.json(); - expect(json.message).toBe( + expect(json.error.message).toBe( "Organization org-id has insufficient credits", ); } finally { diff --git a/apps/gateway/src/app.ts b/apps/gateway/src/app.ts index 56f155b689..35c816c489 100644 --- a/apps/gateway/src/app.ts +++ b/apps/gateway/src/app.ts @@ -27,6 +27,10 @@ import { anthropic } from "./anthropic/anthropic.js"; import { chat } from "./chat/chat.js"; import { embeddingsRoute } from "./embeddings/route.js"; import { imagesRoute } from "./images/route.js"; +import { + buildAnthropicErrorBody, + buildOpenAIErrorBody, +} from "./lib/error-response.js"; import { mcpHandler, registerMcpOAuthRoutes } from "./mcp/mcp.js"; import { tracingMiddleware } from "./middleware/tracing.js"; import { models } from "./models/route.js"; @@ -35,6 +39,7 @@ import { responses } from "./responses/responses.js"; import { videosRoute } from "./videos/route.js"; import type { ServerTypes } from "./vars.js"; +import type { Context } from "hono"; export const config = { servers: [ @@ -117,6 +122,21 @@ app.use("*", async (c, next) => { return await next(); }); +// Renders a gateway-level error in a provider-compatible shape. The Anthropic +// `/v1/messages` endpoint expects Anthropic's `{ type: "error", error: {...} }` +// envelope; every other (OpenAI-compatible) endpoint expects OpenAI's +// `{ error: { message, type, param, code } }` envelope. +function renderGatewayError( + c: Context, + status: number, + message: string, +) { + if (c.req.path.startsWith("/v1/messages")) { + return c.json(buildAnthropicErrorBody({ message, status }), status as any); + } + return c.json(buildOpenAIErrorBody({ message, status }), status as any); +} + app.onError((error, c) => { if (error instanceof UnsupportedAudioFormatError) { logger.warn("Unsupported audio format", { @@ -124,26 +144,12 @@ app.onError((error, c) => { format: error.format, providerTarget: error.providerTarget, }); - return c.json( - { - error: true, - status: 400, - message: error.message, - }, - 400, - ); + return renderGatewayError(c, 400, error.message); } if (error instanceof InvalidFileContentError) { logger.warn("Invalid file content", { message: error.message }); - return c.json( - { - error: true, - status: 400, - message: error.message, - }, - 400, - ); + return renderGatewayError(c, 400, error.message); } if (error instanceof UnsupportedDocumentFormatError) { @@ -152,16 +158,7 @@ app.onError((error, c) => { mimeType: error.mimeType, providerTarget: error.providerTarget, }); - return c.json( - { - error: true, - status: 400, - message: error.message, - mimeType: error.mimeType, - providerTarget: error.providerTarget, - }, - 400, - ); + return renderGatewayError(c, 400, error.message); } if (error instanceof HTTPException) { @@ -173,15 +170,7 @@ app.onError((error, c) => { logger.warn("HTTP client error", { status, message: error.message }); } - return c.json( - { - error: true, - status, - message: error.message || "An error occurred", - ...(error.res ? { details: error.res } : {}), - }, - status, - ); + return renderGatewayError(c, status, error.message || "An error occurred"); } // Handle timeout errors (from AbortSignal.timeout) - these are expected @@ -192,14 +181,7 @@ app.onError((error, c) => { path: c.req.path, method: c.req.method, }); - return c.json( - { - error: true, - status: 504, - message: "Gateway Timeout", - }, - 504, - ); + return renderGatewayError(c, 504, "Gateway Timeout"); } // Handle client disconnection (AbortError) - the client closed the @@ -210,26 +192,12 @@ app.onError((error, c) => { path: c.req.path, method: c.req.method, }); - return c.json( - { - error: true, - status: 499, - message: "Client Closed Request", - }, - 499 as any, - ); + return renderGatewayError(c, 499, "Client Closed Request"); } // For any other errors (non-HTTPException), return 500 Internal Server Error logger.error("Unhandled error", toError(error)); - return c.json( - { - error: true, - status: 500, - message: "Internal Server Error", - }, - 500, - ); + return renderGatewayError(c, 500, "Internal Server Error"); }); const root = createRoute({ diff --git a/apps/gateway/src/chat-custom-provider.e2e.ts b/apps/gateway/src/chat-custom-provider.e2e.ts index e83c45e2aa..b523c57470 100644 --- a/apps/gateway/src/chat-custom-provider.e2e.ts +++ b/apps/gateway/src/chat-custom-provider.e2e.ts @@ -146,7 +146,7 @@ describe("Custom Provider E2E", () => { const json = await res.json(); expect(res.status).toBe(400); - expect(json.message).toContain( + expect(json.error.message).toContain( "Custom providers are not supported in credits mode", ); }); @@ -168,7 +168,7 @@ describe("Custom Provider E2E", () => { const json = await res.json(); expect(res.status).toBe(400); - expect(json.message).toContain( + expect(json.error.message).toContain( "Custom models require a provider key configured in your organization settings", ); }); @@ -244,7 +244,7 @@ describe("Custom Provider E2E", () => { // Credits mode doesn't support custom providers expect(res.status).toBe(400); - expect((await res.json()).message).toContain( + expect((await res.json()).error.message).toContain( "Custom providers are not supported in credits mode", ); }); @@ -268,7 +268,7 @@ describe("Custom Provider E2E", () => { const json = await res.json(); expect(res.status).toBe(400); - expect(json.message).toContain("not found"); + expect(json.error.message).toContain("not found"); }); }); }); diff --git a/apps/gateway/src/lib/error-response.spec.ts b/apps/gateway/src/lib/error-response.spec.ts new file mode 100644 index 0000000000..e84957ba4f --- /dev/null +++ b/apps/gateway/src/lib/error-response.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "vitest"; + +import { + buildAnthropicErrorBody, + buildOpenAIErrorBody, + getAnthropicErrorType, + getOpenAIErrorMeta, +} from "./error-response.js"; + +describe("error-response", () => { + test("builds OpenAI envelope with status-derived type and code", () => { + expect( + buildOpenAIErrorBody({ + message: "Unauthorized: LLMGateway API key reached its usage limit.", + status: 401, + }), + ).toEqual({ + error: { + message: "Unauthorized: LLMGateway API key reached its usage limit.", + type: "invalid_request_error", + param: null, + code: "invalid_api_key", + }, + }); + }); + + test("maps rate limit and server errors to OpenAI types", () => { + expect(getOpenAIErrorMeta(429)).toEqual({ + type: "rate_limit_error", + code: "rate_limit_exceeded", + }); + expect(getOpenAIErrorMeta(500)).toEqual({ type: "api_error", code: null }); + expect(getOpenAIErrorMeta(400)).toEqual({ + type: "invalid_request_error", + code: null, + }); + }); + + test("allows explicit type/code/param overrides", () => { + expect( + buildOpenAIErrorBody({ + message: "bad input", + status: 400, + code: "invalid_json", + param: "messages", + }), + ).toEqual({ + error: { + message: "bad input", + type: "invalid_request_error", + param: "messages", + code: "invalid_json", + }, + }); + }); + + test("builds Anthropic envelope with status-derived type", () => { + expect(buildAnthropicErrorBody({ message: "denied", status: 403 })).toEqual( + { + type: "error", + error: { type: "permission_error", message: "denied" }, + }, + ); + expect(getAnthropicErrorType(429)).toBe("rate_limit_error"); + expect(getAnthropicErrorType(500)).toBe("api_error"); + }); +}); diff --git a/apps/gateway/src/lib/error-response.ts b/apps/gateway/src/lib/error-response.ts new file mode 100644 index 0000000000..e98321af65 --- /dev/null +++ b/apps/gateway/src/lib/error-response.ts @@ -0,0 +1,120 @@ +// Helpers for rendering gateway-level errors (auth, rate limit, validation, +// timeouts, etc.) in a provider-compatible shape. OpenAI-compatible endpoints +// receive `{ error: { message, type, param, code } }`; the Anthropic +// `/v1/messages` endpoint receives `{ type: "error", error: { type, message } }`. + +export interface OpenAIErrorBody { + error: { + message: string; + type: string; + param: string | null; + code: string | null; + }; +} + +export interface AnthropicErrorBody { + type: "error"; + error: { + type: string; + message: string; + }; +} + +// Maps an HTTP status code to the canonical OpenAI `error.type`/`error.code` +// pair. Used as the default when a caller does not provide a more specific one. +export function getOpenAIErrorMeta(status: number): { + type: string; + code: string | null; +} { + switch (status) { + case 400: + return { type: "invalid_request_error", code: null }; + case 401: + return { type: "invalid_request_error", code: "invalid_api_key" }; + case 402: + return { type: "invalid_request_error", code: "billing_error" }; + case 403: + return { type: "invalid_request_error", code: "permission_denied" }; + case 404: + return { type: "invalid_request_error", code: "not_found" }; + case 408: + return { type: "timeout_error", code: "timeout" }; + case 413: + return { type: "invalid_request_error", code: "request_too_large" }; + case 415: + return { type: "invalid_request_error", code: "unsupported_media_type" }; + case 429: + return { type: "rate_limit_error", code: "rate_limit_exceeded" }; + case 499: + return { type: "invalid_request_error", code: "request_cancelled" }; + case 504: + return { type: "timeout_error", code: "timeout" }; + default: + if (status >= 500) { + return { type: "api_error", code: null }; + } + return { type: "invalid_request_error", code: null }; + } +} + +// Maps an HTTP status code to the canonical Anthropic `error.type`. +export function getAnthropicErrorType(status: number): string { + switch (status) { + case 400: + return "invalid_request_error"; + case 401: + return "authentication_error"; + case 402: + return "billing_error"; + case 403: + return "permission_error"; + case 404: + return "not_found_error"; + case 408: + case 504: + return "timeout_error"; + case 413: + return "request_too_large"; + case 429: + return "rate_limit_error"; + case 529: + return "overloaded_error"; + default: + if (status >= 500) { + return "api_error"; + } + return "invalid_request_error"; + } +} + +export function buildOpenAIErrorBody(opts: { + message: string; + status: number; + type?: string; + code?: string | null; + param?: string | null; +}): OpenAIErrorBody { + const meta = getOpenAIErrorMeta(opts.status); + return { + error: { + message: opts.message, + type: opts.type ?? meta.type, + param: opts.param ?? null, + code: opts.code !== undefined ? opts.code : meta.code, + }, + }; +} + +export function buildAnthropicErrorBody(opts: { + message: string; + status: number; + type?: string; +}): AnthropicErrorBody { + return { + type: "error", + error: { + type: opts.type ?? getAnthropicErrorType(opts.status), + message: opts.message, + }, + }; +} diff --git a/apps/gateway/src/moderations/moderations.ts b/apps/gateway/src/moderations/moderations.ts index 42631af1c7..c0f2190bc5 100644 --- a/apps/gateway/src/moderations/moderations.ts +++ b/apps/gateway/src/moderations/moderations.ts @@ -19,6 +19,7 @@ import { findProjectById, findProviderKey, } from "@/lib/cached-queries.js"; +import { buildOpenAIErrorBody } from "@/lib/error-response.js"; import { extractApiToken } from "@/lib/extract-api-token.js"; import { calculateDataStorageCost, insertLog } from "@/lib/logs.js"; import { createCombinedSignal, isTimeoutError } from "@/lib/timeout-config.js"; @@ -651,8 +652,15 @@ moderations.openapi(createModeration, async (c): Promise => { return c.json( (typeof upstreamJson === "string" - ? { error: { message: upstreamJson } } - : upstreamJson) ?? { error: true }, + ? buildOpenAIErrorBody({ + message: upstreamJson, + status: upstreamResponse.status, + }) + : upstreamJson) ?? + buildOpenAIErrorBody({ + message: "An error occurred", + status: upstreamResponse.status, + }), upstreamResponse.status as | 400 | 401 From 8d4b25d3d41e59dc377cfbb3c1f950cc5a2bc582 Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Thu, 4 Jun 2026 22:20:40 +0200 Subject: [PATCH 2/5] fix(gateway): mirror legacy message/status for compat Temporarily include the deprecated top-level `message` and `status` fields alongside the new OpenAI/Anthropic error envelopes so existing consumers of the old `{error, status, message}` shape keep working during migration. Both fields are marked deprecated and will be removed. Co-Authored-By: Claude Opus 4.8 --- apps/gateway/src/lib/error-response.spec.ts | 6 +++++ apps/gateway/src/lib/error-response.ts | 26 +++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/apps/gateway/src/lib/error-response.spec.ts b/apps/gateway/src/lib/error-response.spec.ts index e84957ba4f..b65d8286d7 100644 --- a/apps/gateway/src/lib/error-response.spec.ts +++ b/apps/gateway/src/lib/error-response.spec.ts @@ -21,6 +21,8 @@ describe("error-response", () => { param: null, code: "invalid_api_key", }, + message: "Unauthorized: LLMGateway API key reached its usage limit.", + status: 401, }); }); @@ -51,6 +53,8 @@ describe("error-response", () => { param: "messages", code: "invalid_json", }, + message: "bad input", + status: 400, }); }); @@ -59,6 +63,8 @@ describe("error-response", () => { { type: "error", error: { type: "permission_error", message: "denied" }, + message: "denied", + status: 403, }, ); expect(getAnthropicErrorType(429)).toBe("rate_limit_error"); diff --git a/apps/gateway/src/lib/error-response.ts b/apps/gateway/src/lib/error-response.ts index e98321af65..e26c7c6eb7 100644 --- a/apps/gateway/src/lib/error-response.ts +++ b/apps/gateway/src/lib/error-response.ts @@ -10,6 +10,16 @@ export interface OpenAIErrorBody { param: string | null; code: string | null; }; + /** + * @deprecated Backwards-compat mirror of the legacy `{ error, status, message }` + * shape. Read `error.message` instead; this top-level field will be removed soon. + */ + message: string; + /** + * @deprecated Backwards-compat mirror of the legacy `{ error, status, message }` + * shape. Use the HTTP response status instead; this field will be removed soon. + */ + status: number; } export interface AnthropicErrorBody { @@ -18,6 +28,16 @@ export interface AnthropicErrorBody { type: string; message: string; }; + /** + * @deprecated Backwards-compat mirror of the legacy `{ error, status, message }` + * shape. Read `error.message` instead; this top-level field will be removed soon. + */ + message: string; + /** + * @deprecated Backwards-compat mirror of the legacy `{ error, status, message }` + * shape. Use the HTTP response status instead; this field will be removed soon. + */ + status: number; } // Maps an HTTP status code to the canonical OpenAI `error.type`/`error.code` @@ -102,6 +122,9 @@ export function buildOpenAIErrorBody(opts: { param: opts.param ?? null, code: opts.code !== undefined ? opts.code : meta.code, }, + // Deprecated legacy fields, kept temporarily for backwards compatibility. + message: opts.message, + status: opts.status, }; } @@ -116,5 +139,8 @@ export function buildAnthropicErrorBody(opts: { type: opts.type ?? getAnthropicErrorType(opts.status), message: opts.message, }, + // Deprecated legacy fields, kept temporarily for backwards compatibility. + message: opts.message, + status: opts.status, }; } From 6c3bacf5ed39e03a901a6be9990e0bc4e5c62366 Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Thu, 4 Jun 2026 22:26:35 +0200 Subject: [PATCH 3/5] docs: add error handling reference page Document the OpenAI-compatible error envelope returned across all endpoints, the status-code-to-type/code mapping, streaming error behavior, the Anthropic error shape for /v1/messages, and the deprecated legacy top-level message/status compat fields. Cross-link from the rate limits page. Co-Authored-By: Claude Opus 4.8 --- .../docs/content/resources/error-handling.mdx | 104 ++++++++++++++++++ apps/docs/content/resources/meta.json | 6 + apps/docs/content/resources/rate-limits.mdx | 2 + 3 files changed, 112 insertions(+) create mode 100644 apps/docs/content/resources/error-handling.mdx create mode 100644 apps/docs/content/resources/meta.json diff --git a/apps/docs/content/resources/error-handling.mdx b/apps/docs/content/resources/error-handling.mdx new file mode 100644 index 0000000000..bcb70cfb8b --- /dev/null +++ b/apps/docs/content/resources/error-handling.mdx @@ -0,0 +1,104 @@ +--- +title: Error Handling +description: How LLMGateway returns errors in an OpenAI-compatible format across all endpoints. +icon: TriangleAlert +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +# Error Handling + +LLMGateway returns errors in the same format as the OpenAI API, so existing OpenAI SDKs and tooling can parse gateway errors without changes. This applies to **every** error — both errors forwarded from upstream providers and errors raised by the gateway itself (authentication failures, usage limits, validation problems, timeouts, and so on). + +## Error Format + +Errors on the OpenAI-compatible endpoints (`/v1/chat/completions`, `/v1/embeddings`, `/v1/images`, `/v1/models`, `/v1/moderations`, `/v1/responses`, `/v1/videos`) use the standard OpenAI error envelope: + +```json +{ + "error": { + "message": "Unauthorized: LLMGateway API key reached its usage limit.", + "type": "invalid_request_error", + "param": null, + "code": "invalid_api_key" + } +} +``` + +| Field | Description | +| --------------- | --------------------------------------------------------------------------------- | +| `error.message` | Human-readable description of what went wrong. | +| `error.type` | High-level error category (see the table below). | +| `error.param` | The request parameter that caused the error, or `null` when not parameter-spec't. | +| `error.code` | A more specific machine-readable code, or `null` when no specific code applies. | + +The HTTP status code on the response always matches the error and is the authoritative signal — read it from the response status line rather than the body. + +## Status Codes + +The gateway maps HTTP status codes to OpenAI error types and codes as follows: + +| Status | `type` | `code` | +| ------ | ----------------------- | ------------------------ | +| 400 | `invalid_request_error` | _(varies / `null`)_ | +| 401 | `invalid_request_error` | `invalid_api_key` | +| 402 | `invalid_request_error` | `billing_error` | +| 403 | `invalid_request_error` | `permission_denied` | +| 404 | `invalid_request_error` | `not_found` | +| 408 | `timeout_error` | `timeout` | +| 413 | `invalid_request_error` | `request_too_large` | +| 415 | `invalid_request_error` | `unsupported_media_type` | +| 429 | `rate_limit_error` | `rate_limit_exceeded` | +| 5xx | `api_error` | _(`null`)_ | + + + Validation errors raised before a request reaches a provider often include a + more specific `code` and a `param` pointing at the offending field — for + example `invalid_json`, `model_not_found`, or + `unsupported_parameter_combination`. + + +## Streaming Errors + +For streaming requests (`"stream": true`), an error that occurs **after** the stream has started is delivered as an SSE `error` event whose payload uses the same `{ "error": { ... } }` envelope. Errors that occur **before** streaming begins (such as authentication failures) are returned as a normal JSON error response with the appropriate status code. + +## Anthropic Endpoint + +The Anthropic-compatible Messages endpoint (`/v1/messages`) returns errors in Anthropic's native format instead, so the Anthropic SDK can parse them: + +```json +{ + "type": "error", + "error": { + "type": "authentication_error", + "message": "Unauthorized: invalid API key." + } +} +``` + +## Deprecated Compatibility Fields + + + For backwards compatibility during the migration to the OpenAI-compatible + format, error responses currently also include top-level `message` and + `status` fields that mirror the previous error shape. **These fields are + deprecated and will be removed in a future release.** Read `error.message` + from the envelope and use the HTTP response status code instead. + + +```json +{ + "error": { + "message": "Unauthorized: LLMGateway API key reached its usage limit.", + "type": "invalid_request_error", + "param": null, + "code": "invalid_api_key" + }, + "message": "Unauthorized: LLMGateway API key reached its usage limit.", + "status": 401 +} +``` + +## Related + +- [Rate Limits](/resources/rate-limits) — details on `429` responses and rate limit headers. diff --git a/apps/docs/content/resources/meta.json b/apps/docs/content/resources/meta.json new file mode 100644 index 0000000000..2f4243b751 --- /dev/null +++ b/apps/docs/content/resources/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Resources", + "description": "Reference material for working with the LLM Gateway API", + "icon": "BookOpen", + "pages": ["error-handling", "rate-limits"] +} diff --git a/apps/docs/content/resources/rate-limits.mdx b/apps/docs/content/resources/rate-limits.mdx index 8f1d4de0f9..1fab6f1f79 100644 --- a/apps/docs/content/resources/rate-limits.mdx +++ b/apps/docs/content/resources/rate-limits.mdx @@ -68,6 +68,8 @@ When you exceed your rate limit, you'll receive a `429 Too Many Requests` respon } ``` +This uses the standard OpenAI-compatible error envelope — see [Error Handling](/resources/error-handling) for the full format and status-code reference. + ## Best Practices ### Upgrading Your Limits From 9551f832de7931ddcda5029d3e15397e10dec872 Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Thu, 4 Jun 2026 22:37:00 +0200 Subject: [PATCH 4/5] fix(gateway): address review feedback - Replace `as any` status casts in renderGatewayError with Hono's ContentfulStatusCode. - Derive the Anthropic streaming error type from the HTTP status so streamed /v1/messages errors match the non-streaming path (previously the OpenAI envelope `type` leaked through the translator). - Docs: narrow OpenAI-format scope wording, note the /v1/messages exception, fix a typo, and add 499/504 rows to the status table. Co-Authored-By: Claude Opus 4.8 --- .../docs/content/resources/error-handling.mdx | 18 ++++++----- apps/gateway/src/anthropic/anthropic.ts | 32 ++++++++++++++----- apps/gateway/src/app.ts | 6 ++-- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/apps/docs/content/resources/error-handling.mdx b/apps/docs/content/resources/error-handling.mdx index bcb70cfb8b..e7b8a558b5 100644 --- a/apps/docs/content/resources/error-handling.mdx +++ b/apps/docs/content/resources/error-handling.mdx @@ -1,6 +1,6 @@ --- title: Error Handling -description: How LLMGateway returns errors in an OpenAI-compatible format across all endpoints. +description: How LLMGateway returns errors in an OpenAI-compatible format on the OpenAI-compatible endpoints. icon: TriangleAlert --- @@ -8,7 +8,7 @@ import { Callout } from "fumadocs-ui/components/callout"; # Error Handling -LLMGateway returns errors in the same format as the OpenAI API, so existing OpenAI SDKs and tooling can parse gateway errors without changes. This applies to **every** error — both errors forwarded from upstream providers and errors raised by the gateway itself (authentication failures, usage limits, validation problems, timeouts, and so on). +On the OpenAI-compatible endpoints, LLMGateway returns errors in the same format as the OpenAI API, so existing OpenAI SDKs and tooling can parse gateway errors without changes. This applies to errors forwarded from upstream providers as well as errors raised by the gateway itself (authentication failures, usage limits, validation problems, timeouts, and so on). The Anthropic-compatible Messages endpoint (`/v1/messages`) instead returns Anthropic-native errors — see [Anthropic Endpoint](#anthropic-endpoint) below. ## Error Format @@ -25,12 +25,12 @@ Errors on the OpenAI-compatible endpoints (`/v1/chat/completions`, `/v1/embeddin } ``` -| Field | Description | -| --------------- | --------------------------------------------------------------------------------- | -| `error.message` | Human-readable description of what went wrong. | -| `error.type` | High-level error category (see the table below). | -| `error.param` | The request parameter that caused the error, or `null` when not parameter-spec't. | -| `error.code` | A more specific machine-readable code, or `null` when no specific code applies. | +| Field | Description | +| --------------- | ----------------------------------------------------------------------------------- | +| `error.message` | Human-readable description of what went wrong. | +| `error.type` | High-level error category (see the table below). | +| `error.param` | The request parameter that caused the error, or `null` when not parameter-specific. | +| `error.code` | A more specific machine-readable code, or `null` when no specific code applies. | The HTTP status code on the response always matches the error and is the authoritative signal — read it from the response status line rather than the body. @@ -49,6 +49,8 @@ The gateway maps HTTP status codes to OpenAI error types and codes as follows: | 413 | `invalid_request_error` | `request_too_large` | | 415 | `invalid_request_error` | `unsupported_media_type` | | 429 | `rate_limit_error` | `rate_limit_exceeded` | +| 499 | `invalid_request_error` | `request_cancelled` | +| 504 | `timeout_error` | `timeout` | | 5xx | `api_error` | _(`null`)_ | diff --git a/apps/gateway/src/anthropic/anthropic.ts b/apps/gateway/src/anthropic/anthropic.ts index 697e5e1f8a..be27c4fa98 100644 --- a/apps/gateway/src/anthropic/anthropic.ts +++ b/apps/gateway/src/anthropic/anthropic.ts @@ -3,7 +3,10 @@ import { HTTPException } from "hono/http-exception"; import { streamSSE } from "hono/streaming"; import { app } from "@/app.js"; -import { buildAnthropicErrorBody } from "@/lib/error-response.js"; +import { + buildAnthropicErrorBody, + getAnthropicErrorType, +} from "@/lib/error-response.js"; import { extractAnthropicSessionId } from "@/lib/session-id.js"; import { logger, toError } from "@llmgateway/logger"; @@ -558,14 +561,27 @@ anthropic.openapi(messages, async (c) => { } catch { parsedError = null; } - const errorEvent = buildAnthropicErrorEvent( - parsedError ?? { - error: { - message: errorData || response.statusText, - type: "api_error", - }, + // Derive the Anthropic error type from the HTTP status so streamed + // errors match the non-streaming path. The upstream here is our own + // /v1/chat/completions, which returns an OpenAI envelope whose `type` + // (e.g. invalid_request_error) would otherwise pass through verbatim. + const innerMessage = + parsedError && + typeof parsedError === "object" && + "error" in parsedError && + parsedError.error && + typeof parsedError.error === "object" && + "message" in parsedError.error && + typeof parsedError.error.message === "string" + ? parsedError.error.message + : errorData || response.statusText; + const errorEvent = buildAnthropicErrorEvent({ + type: "error", + error: { + type: getAnthropicErrorType(response.status), + message: innerMessage, }, - ); + }); return streamSSE(c, async (stream) => { await stream.writeSSE({ data: JSON.stringify(errorEvent), diff --git a/apps/gateway/src/app.ts b/apps/gateway/src/app.ts index 35c816c489..255f421685 100644 --- a/apps/gateway/src/app.ts +++ b/apps/gateway/src/app.ts @@ -40,6 +40,7 @@ import { videosRoute } from "./videos/route.js"; import type { ServerTypes } from "./vars.js"; import type { Context } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; export const config = { servers: [ @@ -131,10 +132,11 @@ function renderGatewayError( status: number, message: string, ) { + const jsonStatus = status as ContentfulStatusCode; if (c.req.path.startsWith("/v1/messages")) { - return c.json(buildAnthropicErrorBody({ message, status }), status as any); + return c.json(buildAnthropicErrorBody({ message, status }), jsonStatus); } - return c.json(buildOpenAIErrorBody({ message, status }), status as any); + return c.json(buildOpenAIErrorBody({ message, status }), jsonStatus); } app.onError((error, c) => { From b66012dd67b7c0db06f188630797c299f4a30c13 Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Thu, 4 Jun 2026 22:49:38 +0200 Subject: [PATCH 5/5] test(gateway): update error snapshot to new envelope The missing-provider-key test asserted the legacy `{error, status, message}` body via an inline snapshot. Update it to the OpenAI-compatible envelope (with the temporary deprecated top-level message/status fields). Co-Authored-By: Claude Opus 4.8 --- apps/gateway/src/api.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/gateway/src/api.spec.ts b/apps/gateway/src/api.spec.ts index 8a176606f3..e3462cee63 100644 --- a/apps/gateway/src/api.spec.ts +++ b/apps/gateway/src/api.spec.ts @@ -3074,7 +3074,7 @@ describe("api", () => { expect(res.status).toBe(400); const errorMessage = await res.text(); expect(errorMessage).toMatchInlineSnapshot( - `"{"error":true,"status":400,"message":"No API key set for provider: openai. Please add a provider key in your settings or add credits and switch to credits or hybrid mode."}"`, + `"{"error":{"message":"No API key set for provider: openai. Please add a provider key in your settings or add credits and switch to credits or hybrid mode.","type":"invalid_request_error","param":null,"code":null},"message":"No API key set for provider: openai. Please add a provider key in your settings or add credits and switch to credits or hybrid mode.","status":400}"`, ); });