From 93cba1d098e55bdf719cd7b61a8aaf4fb1535870 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 12 Aug 2026 12:34:25 -0400 Subject: [PATCH] support sending capability specific error codes --- server/lib/aiCapabilities.ts | 112 +++++++++++++++++++++++-- server/lib/aiGatewayAuthError.ts | 27 ++++++ server/routers/aiGateway/pipeline.ts | 16 ++-- server/routers/badger/verifySession.ts | 37 +++++++- 4 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 server/lib/aiGatewayAuthError.ts diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts index 268acd36e..c9f78f10e 100644 --- a/server/lib/aiCapabilities.ts +++ b/server/lib/aiCapabilities.ts @@ -18,8 +18,41 @@ export type AiCapabilityDefinition = { model: string ) => string; isStreaming: (req: Request, contentType: string) => boolean; + /** Protocol-shaped body returned when gateway auth fails for this capability. */ + authErrorBody: Record; }; +const AUTH_MESSAGE = "Invalid API key provided."; + +export const OPENAI_AUTH_ERROR_BODY = { + error: { + message: AUTH_MESSAGE, + type: "authentication_error", + param: null, + code: "invalid_api_key" + } +} as const; + +const ANTHROPIC_AUTH_ERROR_BODY = { + type: "error", + error: { + type: "authentication_error", + message: AUTH_MESSAGE + } +} as const; + +const GOOGLE_AUTH_ERROR_BODY = { + error: { + code: 401, + message: AUTH_MESSAGE, + status: "UNAUTHENTICATED" + } +} as const; + +const BEDROCK_AUTH_ERROR_BODY = { + message: "The security token included in the request is invalid." +} as const; + function bodyModel(req: Request): string | undefined { return typeof req.body?.model === "string" ? req.body.model : undefined; } @@ -111,7 +144,8 @@ export const AI_CAPABILITY_DEFS: Record = extractModel: bodyModel, resolveUpstreamUrl: (base, req) => joinUpstreamUrl(base, pathFromRequest(req)), - isStreaming: isBodyOrSseStreaming + isStreaming: isBodyOrSseStreaming, + authErrorBody: OPENAI_AUTH_ERROR_BODY }, openai_responses: { id: "openai_responses", @@ -119,7 +153,8 @@ export const AI_CAPABILITY_DEFS: Record = extractModel: bodyModel, resolveUpstreamUrl: (base, req) => joinUpstreamUrl(base, pathFromRequest(req)), - isStreaming: isBodyOrSseStreaming + isStreaming: isBodyOrSseStreaming, + authErrorBody: OPENAI_AUTH_ERROR_BODY }, anthropic_messages: { id: "anthropic_messages", @@ -127,7 +162,8 @@ export const AI_CAPABILITY_DEFS: Record = extractModel: bodyModel, resolveUpstreamUrl: (base, req) => joinUpstreamUrl(base, pathFromRequest(req)), - isStreaming: isBodyOrSseStreaming + isStreaming: isBodyOrSseStreaming, + authErrorBody: ANTHROPIC_AUTH_ERROR_BODY }, gemini_generate_content: { id: "gemini_generate_content", @@ -144,7 +180,8 @@ export const AI_CAPABILITY_DEFS: Record = extractModel: paramModel, resolveUpstreamUrl: (base, req) => joinUpstreamUrl(base, pathFromRequest(req)), - isStreaming: isGeminiStyleStreaming + isStreaming: isGeminiStyleStreaming, + authErrorBody: GOOGLE_AUTH_ERROR_BODY }, google_generate_content: { id: "google_generate_content", @@ -162,7 +199,8 @@ export const AI_CAPABILITY_DEFS: Record = extractModel: paramModel, resolveUpstreamUrl: (base, req) => joinUpstreamUrl(base, pathFromRequest(req)), - isStreaming: isGeminiStyleStreaming + isStreaming: isGeminiStyleStreaming, + authErrorBody: GOOGLE_AUTH_ERROR_BODY }, google_raw_predict: { id: "google_raw_predict", @@ -182,7 +220,8 @@ export const AI_CAPABILITY_DEFS: Record = isStreaming: (req, contentType) => pathIncludes(req, "streamRawPredict") || pathIncludes(req, "alt=sse") || - contentTypeIsSse(contentType) + contentTypeIsSse(contentType), + authErrorBody: GOOGLE_AUTH_ERROR_BODY }, bedrock_model_invoke: { id: "bedrock_model_invoke", @@ -199,7 +238,8 @@ export const AI_CAPABILITY_DEFS: Record = isStreaming: (req, contentType) => pathIncludes(req, "invoke-with-response-stream") || contentTypeIsAmazonEventStream(contentType) || - contentTypeIsSse(contentType) + contentTypeIsSse(contentType), + authErrorBody: BEDROCK_AUTH_ERROR_BODY }, bedrock_converse: { id: "bedrock_converse", @@ -213,7 +253,8 @@ export const AI_CAPABILITY_DEFS: Record = isStreaming: (req, contentType) => pathIncludes(req, "converse-stream") || contentTypeIsAmazonEventStream(contentType) || - contentTypeIsSse(contentType) + contentTypeIsSse(contentType), + authErrorBody: BEDROCK_AUTH_ERROR_BODY } }; @@ -224,6 +265,61 @@ export function isAiCapability(value: unknown): value is AiCapability { ); } +/** + * Convert an Express-style route path from AI_CAPABILITY_DEFS into a RegExp. + * Handles `:param` segments and escaped literal colons (`\:`). + */ +export function routePatternToRegExp(routePath: string): RegExp { + let pattern = ""; + for (let i = 0; i < routePath.length; i++) { + const ch = routePath[i]; + if ( + ch === "\\" && + i + 1 < routePath.length && + routePath[i + 1] === ":" + ) { + pattern += ":"; + i++; + continue; + } + if (ch === ":") { + // Named param: consume until next / or end + i++; + while ( + i < routePath.length && + routePath[i] !== "/" && + !(routePath[i] === "\\" && routePath[i + 1] === ":") + ) { + i++; + } + i--; // loop will ++ + pattern += "[^/]+"; + continue; + } + // Escape regex special chars + if (/[.*+?^${}()|[\]\\]/.test(ch)) { + pattern += "\\" + ch; + } else { + pattern += ch; + } + } + return new RegExp(`^${pattern}$`); +} + +export function resolveAiCapabilityFromPath(path: string): AiCapability | null { + const pathname = path.split("?")[0] || "/"; + const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`; + + for (const def of Object.values(AI_CAPABILITY_DEFS)) { + for (const route of def.routes) { + if (routePatternToRegExp(route.path).test(normalized)) { + return def.id; + } + } + } + return null; +} + export function parseCapabilities(raw: unknown): AiCapability[] { if (raw == null) { return []; diff --git a/server/lib/aiGatewayAuthError.ts b/server/lib/aiGatewayAuthError.ts new file mode 100644 index 000000000..55e4d5e28 --- /dev/null +++ b/server/lib/aiGatewayAuthError.ts @@ -0,0 +1,27 @@ +import { + AI_CAPABILITY_DEFS, + OPENAI_AUTH_ERROR_BODY, + type AiCapability +} from "@server/lib/aiCapabilities"; +import HttpCode from "@server/types/HttpCode"; + +export type ClientErrorResponse = { + statusCode?: number; + contentType?: string; + body: string; +}; + +export function buildInferenceAuthClientError( + capability: AiCapability | null +): ClientErrorResponse { + const body = + capability != null + ? AI_CAPABILITY_DEFS[capability].authErrorBody + : OPENAI_AUTH_ERROR_BODY; + + return { + statusCode: HttpCode.UNAUTHORIZED, + contentType: "application/json", + body: JSON.stringify(body) + }; +} diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 5aa69d7fc..d77937cd3 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -366,10 +366,6 @@ async function resolveTarget(host: string): Promise { .where(eq(resourceAiModels.resourceId, resourceRow.resourceId)) ]); - if (attachmentRows.length === 0) { - return null; - } - return { resourceId: resourceRow.resourceId, siteResourceId: null, @@ -427,10 +423,6 @@ async function resolveTarget(host: string): Promise { ) ]); - if (attachmentRows.length === 0) { - return null; - } - return { resourceId: null, siteResourceId: siteResourceRow.siteResourceId, @@ -756,6 +748,14 @@ export async function handleAiGatewayProxy( }); } + if (attachments.length === 0) { + return res.status(HttpCode.FORBIDDEN).json({ + error: { + message: "No AI providers configured for this resource" + } + }); + } + const capableAttachments = attachments.filter((a) => providerHasCapability(a.provider.capabilities, capability) ); diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index f5b6a25b9..706626373 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -48,6 +48,11 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { getCountryCodeForIp } from "@server/lib/geoip"; import { getAsnForIp } from "@server/lib/asn"; +import { + buildInferenceAuthClientError, + type ClientErrorResponse +} from "@server/lib/aiGatewayAuthError"; +import { resolveAiCapabilityFromPath } from "@server/lib/aiCapabilities"; import { verifyPassword } from "@server/auth/password"; import { checkOrgAccessPolicy, @@ -89,6 +94,8 @@ type BasicUserData = { role: string | null; }; +export type { ClientErrorResponse }; + export type VerifyUserResponse = { valid: boolean; headerAuthChallenged?: boolean; @@ -96,8 +103,27 @@ export type VerifyUserResponse = { userData?: BasicUserData; pangolinVersion?: string; dontStripSession?: boolean; + clientError?: ClientErrorResponse; }; +function notAllowedWithClientError( + res: Response, + clientError: ClientErrorResponse +) { + const data = { + data: { + valid: false, + clientError, + pangolinVersion: APP_VERSION + }, + success: true, + error: false, + message: "Access denied", + status: HttpCode.OK + }; + return response(res, data); +} + export async function verifyResourceSession( req: Request, res: Response, @@ -408,7 +434,16 @@ export async function verifyResourceSession( parsedBody.data ); - return notAllowed(res, redirectPath, resource.orgId); + // Browsers go to the resource auth / API key page. API clients get + // a capability-shaped JSON auth error instead of a redirect. + if (clientIsBrowser) { + return notAllowed(res, redirectPath, resource.orgId); + } + + return notAllowedWithClientError( + res, + buildInferenceAuthClientError(resolveAiCapabilityFromPath(path)) + ); } // check for access token in headers