Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/gateway/src/anthropic/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
);
}
Expand Down
35 changes: 24 additions & 11 deletions apps/gateway/src/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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",
);
});
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
86 changes: 27 additions & 59 deletions apps/gateway/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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: [
Expand Down Expand Up @@ -117,33 +122,34 @@ 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<ServerTypes>,
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Anthropic error types for streaming

When /v1/messages handles a streaming request, it forwards to /v1/chat/completions; gateway auth/limit failures from that internal call now come back from this line as an OpenAI envelope (type: invalid_request_error, code: invalid_api_key). The existing Anthropic streaming translator prefers error.type over error.code, so streamed Anthropic clients receive an invalid_request_error event instead of the authentication_error/billing_error shape that the non-streaming path now returns, making SDK error handling inconsistent for the same auth/credit failures. Please either return Anthropic-shaped errors for these internal calls or map the new OpenAI codes/statuses in the streaming translator.

Useful? React with 👍 / 👎.

}

app.onError((error, c) => {
if (error instanceof UnsupportedAudioFormatError) {
logger.warn("Unsupported audio format", {
message: error.message,
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) {
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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({
Expand Down
8 changes: 4 additions & 4 deletions apps/gateway/src/chat-custom-provider.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
Expand All @@ -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",
);
});
Expand Down Expand Up @@ -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",
);
});
Expand All @@ -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");
});
});
});
67 changes: 67 additions & 0 deletions apps/gateway/src/lib/error-response.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading