support sending capability specific error codes

This commit is contained in:
miloschwartz
2026-08-12 12:34:25 -04:00
parent 115c3cbf07
commit 93cba1d098
4 changed files with 175 additions and 17 deletions
+104 -8
View File
@@ -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<string, unknown>;
};
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<AiCapability, AiCapabilityDefinition> =
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<AiCapability, AiCapabilityDefinition> =
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<AiCapability, AiCapabilityDefinition> =
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<AiCapability, AiCapabilityDefinition> =
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<AiCapability, AiCapabilityDefinition> =
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<AiCapability, AiCapabilityDefinition> =
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<AiCapability, AiCapabilityDefinition> =
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<AiCapability, AiCapabilityDefinition> =
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 [];
+27
View File
@@ -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)
};
}
+8 -8
View File
@@ -366,10 +366,6 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
.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<ResolvedTarget | null> {
)
]);
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)
);
+36 -1
View File
@@ -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<VerifyUserResponse>(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