mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-13 16:00:02 +02:00
support sending capability specific error codes
This commit is contained in:
@@ -18,8 +18,41 @@ export type AiCapabilityDefinition = {
|
|||||||
model: string
|
model: string
|
||||||
) => string;
|
) => string;
|
||||||
isStreaming: (req: Request, contentType: string) => boolean;
|
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 {
|
function bodyModel(req: Request): string | undefined {
|
||||||
return typeof req.body?.model === "string" ? req.body.model : 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,
|
extractModel: bodyModel,
|
||||||
resolveUpstreamUrl: (base, req) =>
|
resolveUpstreamUrl: (base, req) =>
|
||||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
isStreaming: isBodyOrSseStreaming
|
isStreaming: isBodyOrSseStreaming,
|
||||||
|
authErrorBody: OPENAI_AUTH_ERROR_BODY
|
||||||
},
|
},
|
||||||
openai_responses: {
|
openai_responses: {
|
||||||
id: "openai_responses",
|
id: "openai_responses",
|
||||||
@@ -119,7 +153,8 @@ export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
|||||||
extractModel: bodyModel,
|
extractModel: bodyModel,
|
||||||
resolveUpstreamUrl: (base, req) =>
|
resolveUpstreamUrl: (base, req) =>
|
||||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
isStreaming: isBodyOrSseStreaming
|
isStreaming: isBodyOrSseStreaming,
|
||||||
|
authErrorBody: OPENAI_AUTH_ERROR_BODY
|
||||||
},
|
},
|
||||||
anthropic_messages: {
|
anthropic_messages: {
|
||||||
id: "anthropic_messages",
|
id: "anthropic_messages",
|
||||||
@@ -127,7 +162,8 @@ export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
|||||||
extractModel: bodyModel,
|
extractModel: bodyModel,
|
||||||
resolveUpstreamUrl: (base, req) =>
|
resolveUpstreamUrl: (base, req) =>
|
||||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
isStreaming: isBodyOrSseStreaming
|
isStreaming: isBodyOrSseStreaming,
|
||||||
|
authErrorBody: ANTHROPIC_AUTH_ERROR_BODY
|
||||||
},
|
},
|
||||||
gemini_generate_content: {
|
gemini_generate_content: {
|
||||||
id: "gemini_generate_content",
|
id: "gemini_generate_content",
|
||||||
@@ -144,7 +180,8 @@ export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
|||||||
extractModel: paramModel,
|
extractModel: paramModel,
|
||||||
resolveUpstreamUrl: (base, req) =>
|
resolveUpstreamUrl: (base, req) =>
|
||||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
isStreaming: isGeminiStyleStreaming
|
isStreaming: isGeminiStyleStreaming,
|
||||||
|
authErrorBody: GOOGLE_AUTH_ERROR_BODY
|
||||||
},
|
},
|
||||||
google_generate_content: {
|
google_generate_content: {
|
||||||
id: "google_generate_content",
|
id: "google_generate_content",
|
||||||
@@ -162,7 +199,8 @@ export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
|||||||
extractModel: paramModel,
|
extractModel: paramModel,
|
||||||
resolveUpstreamUrl: (base, req) =>
|
resolveUpstreamUrl: (base, req) =>
|
||||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
isStreaming: isGeminiStyleStreaming
|
isStreaming: isGeminiStyleStreaming,
|
||||||
|
authErrorBody: GOOGLE_AUTH_ERROR_BODY
|
||||||
},
|
},
|
||||||
google_raw_predict: {
|
google_raw_predict: {
|
||||||
id: "google_raw_predict",
|
id: "google_raw_predict",
|
||||||
@@ -182,7 +220,8 @@ export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
|||||||
isStreaming: (req, contentType) =>
|
isStreaming: (req, contentType) =>
|
||||||
pathIncludes(req, "streamRawPredict") ||
|
pathIncludes(req, "streamRawPredict") ||
|
||||||
pathIncludes(req, "alt=sse") ||
|
pathIncludes(req, "alt=sse") ||
|
||||||
contentTypeIsSse(contentType)
|
contentTypeIsSse(contentType),
|
||||||
|
authErrorBody: GOOGLE_AUTH_ERROR_BODY
|
||||||
},
|
},
|
||||||
bedrock_model_invoke: {
|
bedrock_model_invoke: {
|
||||||
id: "bedrock_model_invoke",
|
id: "bedrock_model_invoke",
|
||||||
@@ -199,7 +238,8 @@ export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
|||||||
isStreaming: (req, contentType) =>
|
isStreaming: (req, contentType) =>
|
||||||
pathIncludes(req, "invoke-with-response-stream") ||
|
pathIncludes(req, "invoke-with-response-stream") ||
|
||||||
contentTypeIsAmazonEventStream(contentType) ||
|
contentTypeIsAmazonEventStream(contentType) ||
|
||||||
contentTypeIsSse(contentType)
|
contentTypeIsSse(contentType),
|
||||||
|
authErrorBody: BEDROCK_AUTH_ERROR_BODY
|
||||||
},
|
},
|
||||||
bedrock_converse: {
|
bedrock_converse: {
|
||||||
id: "bedrock_converse",
|
id: "bedrock_converse",
|
||||||
@@ -213,7 +253,8 @@ export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
|||||||
isStreaming: (req, contentType) =>
|
isStreaming: (req, contentType) =>
|
||||||
pathIncludes(req, "converse-stream") ||
|
pathIncludes(req, "converse-stream") ||
|
||||||
contentTypeIsAmazonEventStream(contentType) ||
|
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[] {
|
export function parseCapabilities(raw: unknown): AiCapability[] {
|
||||||
if (raw == null) {
|
if (raw == null) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -366,10 +366,6 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
|||||||
.where(eq(resourceAiModels.resourceId, resourceRow.resourceId))
|
.where(eq(resourceAiModels.resourceId, resourceRow.resourceId))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (attachmentRows.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resourceId: resourceRow.resourceId,
|
resourceId: resourceRow.resourceId,
|
||||||
siteResourceId: null,
|
siteResourceId: null,
|
||||||
@@ -427,10 +423,6 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
|||||||
)
|
)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (attachmentRows.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resourceId: null,
|
resourceId: null,
|
||||||
siteResourceId: siteResourceRow.siteResourceId,
|
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) =>
|
const capableAttachments = attachments.filter((a) =>
|
||||||
providerHasCapability(a.provider.capabilities, capability)
|
providerHasCapability(a.provider.capabilities, capability)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ import { z } from "zod";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { getCountryCodeForIp } from "@server/lib/geoip";
|
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||||
import { getAsnForIp } from "@server/lib/asn";
|
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 { verifyPassword } from "@server/auth/password";
|
||||||
import {
|
import {
|
||||||
checkOrgAccessPolicy,
|
checkOrgAccessPolicy,
|
||||||
@@ -89,6 +94,8 @@ type BasicUserData = {
|
|||||||
role: string | null;
|
role: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type { ClientErrorResponse };
|
||||||
|
|
||||||
export type VerifyUserResponse = {
|
export type VerifyUserResponse = {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
headerAuthChallenged?: boolean;
|
headerAuthChallenged?: boolean;
|
||||||
@@ -96,8 +103,27 @@ export type VerifyUserResponse = {
|
|||||||
userData?: BasicUserData;
|
userData?: BasicUserData;
|
||||||
pangolinVersion?: string;
|
pangolinVersion?: string;
|
||||||
dontStripSession?: boolean;
|
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(
|
export async function verifyResourceSession(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -408,7 +434,16 @@ export async function verifyResourceSession(
|
|||||||
parsedBody.data
|
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
|
// check for access token in headers
|
||||||
|
|||||||
Reference in New Issue
Block a user