mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-17 09:49:55 +02:00
format custom error codes according to capability
This commit is contained in:
@@ -8,8 +8,11 @@ export type AiCapabilityRoute = {
|
|||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AiProtocolFamily = "openai" | "anthropic" | "google" | "bedrock";
|
||||||
|
|
||||||
export type AiCapabilityDefinition = {
|
export type AiCapabilityDefinition = {
|
||||||
id: AiCapability;
|
id: AiCapability;
|
||||||
|
protocolFamily: AiProtocolFamily;
|
||||||
routes: AiCapabilityRoute[];
|
routes: AiCapabilityRoute[];
|
||||||
extractModel: (req: Request) => string | undefined;
|
extractModel: (req: Request) => string | undefined;
|
||||||
resolveUpstreamUrl: (
|
resolveUpstreamUrl: (
|
||||||
@@ -18,41 +21,8 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -137,6 +107,7 @@ export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
|||||||
{
|
{
|
||||||
openai_chat: {
|
openai_chat: {
|
||||||
id: "openai_chat",
|
id: "openai_chat",
|
||||||
|
protocolFamily: "openai",
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "POST", path: "/v1/chat/completions" },
|
{ method: "POST", path: "/v1/chat/completions" },
|
||||||
{ method: "POST", path: "/chat/completions" }
|
{ method: "POST", path: "/chat/completions" }
|
||||||
@@ -144,29 +115,29 @@ 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",
|
||||||
|
protocolFamily: "openai",
|
||||||
routes: [{ method: "POST", path: "/v1/responses" }],
|
routes: [{ method: "POST", path: "/v1/responses" }],
|
||||||
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",
|
||||||
|
protocolFamily: "anthropic",
|
||||||
routes: [{ method: "POST", path: "/v1/messages" }],
|
routes: [{ method: "POST", path: "/v1/messages" }],
|
||||||
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",
|
||||||
|
protocolFamily: "google",
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -180,11 +151,11 @@ 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",
|
||||||
|
protocolFamily: "google",
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -199,11 +170,11 @@ 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",
|
||||||
|
protocolFamily: "google",
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -220,11 +191,11 @@ 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",
|
||||||
|
protocolFamily: "bedrock",
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "POST", path: "/model/:model/invoke" },
|
{ method: "POST", path: "/model/:model/invoke" },
|
||||||
{
|
{
|
||||||
@@ -238,11 +209,11 @@ 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",
|
||||||
|
protocolFamily: "bedrock",
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "POST", path: "/model/:model/converse" },
|
{ method: "POST", path: "/model/:model/converse" },
|
||||||
{ method: "POST", path: "/model/:model/converse-stream" }
|
{ method: "POST", path: "/model/:model/converse-stream" }
|
||||||
@@ -253,8 +224,7 @@ 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
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AI_CAPABILITY_DEFS,
|
AI_CAPABILITY_DEFS,
|
||||||
OPENAI_AUTH_ERROR_BODY,
|
type AiCapability,
|
||||||
type AiCapability
|
type AiProtocolFamily
|
||||||
} from "@server/lib/aiCapabilities";
|
} from "@server/lib/aiCapabilities";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
@@ -11,17 +11,129 @@ export type ClientErrorResponse = {
|
|||||||
body: string;
|
body: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AiCapabilityErrorKind =
|
||||||
|
| "authentication"
|
||||||
|
| "invalid_request"
|
||||||
|
| "not_found"
|
||||||
|
| "permission"
|
||||||
|
| "rate_limit"
|
||||||
|
| "internal";
|
||||||
|
|
||||||
|
const AUTH_MESSAGE = "Invalid API key provided.";
|
||||||
|
|
||||||
|
type KindFields = {
|
||||||
|
openaiType: string;
|
||||||
|
openaiCode: string | null;
|
||||||
|
anthropicType: string;
|
||||||
|
googleStatus: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_FIELDS: Record<AiCapabilityErrorKind, KindFields> = {
|
||||||
|
authentication: {
|
||||||
|
openaiType: "authentication_error",
|
||||||
|
openaiCode: "invalid_api_key",
|
||||||
|
anthropicType: "authentication_error",
|
||||||
|
googleStatus: "UNAUTHENTICATED"
|
||||||
|
},
|
||||||
|
invalid_request: {
|
||||||
|
openaiType: "invalid_request_error",
|
||||||
|
openaiCode: null,
|
||||||
|
anthropicType: "invalid_request_error",
|
||||||
|
googleStatus: "INVALID_ARGUMENT"
|
||||||
|
},
|
||||||
|
not_found: {
|
||||||
|
openaiType: "invalid_request_error",
|
||||||
|
openaiCode: null,
|
||||||
|
anthropicType: "not_found_error",
|
||||||
|
googleStatus: "NOT_FOUND"
|
||||||
|
},
|
||||||
|
permission: {
|
||||||
|
openaiType: "invalid_request_error",
|
||||||
|
openaiCode: null,
|
||||||
|
anthropicType: "permission_error",
|
||||||
|
googleStatus: "PERMISSION_DENIED"
|
||||||
|
},
|
||||||
|
rate_limit: {
|
||||||
|
openaiType: "rate_limit_error",
|
||||||
|
openaiCode: "rate_limit_exceeded",
|
||||||
|
anthropicType: "rate_limit_error",
|
||||||
|
googleStatus: "RESOURCE_EXHAUSTED"
|
||||||
|
},
|
||||||
|
internal: {
|
||||||
|
openaiType: "api_error",
|
||||||
|
openaiCode: null,
|
||||||
|
anthropicType: "api_error",
|
||||||
|
googleStatus: "INTERNAL"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveProtocolFamily(
|
||||||
|
capability: AiCapability | null
|
||||||
|
): AiProtocolFamily {
|
||||||
|
if (capability == null) {
|
||||||
|
return "openai";
|
||||||
|
}
|
||||||
|
return AI_CAPABILITY_DEFS[capability].protocolFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a protocol-native error body for the given capability.
|
||||||
|
* Message stays contextual; only the envelope/machine fields follow the
|
||||||
|
* capability's native API shape.
|
||||||
|
*/
|
||||||
|
export function buildAiCapabilityErrorBody(
|
||||||
|
capability: AiCapability | null,
|
||||||
|
kind: AiCapabilityErrorKind,
|
||||||
|
message: string,
|
||||||
|
httpStatus?: number
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const family = resolveProtocolFamily(capability);
|
||||||
|
const fields = KIND_FIELDS[kind];
|
||||||
|
|
||||||
|
switch (family) {
|
||||||
|
case "openai":
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
type: fields.openaiType,
|
||||||
|
param: null,
|
||||||
|
code: fields.openaiCode
|
||||||
|
}
|
||||||
|
};
|
||||||
|
case "anthropic":
|
||||||
|
return {
|
||||||
|
type: "error",
|
||||||
|
error: {
|
||||||
|
type: fields.anthropicType,
|
||||||
|
message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
case "google":
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: httpStatus ?? HttpCode.BAD_REQUEST,
|
||||||
|
message,
|
||||||
|
status: fields.googleStatus
|
||||||
|
}
|
||||||
|
};
|
||||||
|
case "bedrock":
|
||||||
|
return { message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildInferenceAuthClientError(
|
export function buildInferenceAuthClientError(
|
||||||
capability: AiCapability | null
|
capability: AiCapability | null
|
||||||
): ClientErrorResponse {
|
): ClientErrorResponse {
|
||||||
const body =
|
|
||||||
capability != null
|
|
||||||
? AI_CAPABILITY_DEFS[capability].authErrorBody
|
|
||||||
: OPENAI_AUTH_ERROR_BODY;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: HttpCode.UNAUTHORIZED,
|
statusCode: HttpCode.UNAUTHORIZED,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(
|
||||||
|
buildAiCapabilityErrorBody(
|
||||||
|
capability,
|
||||||
|
"authentication",
|
||||||
|
AUTH_MESSAGE,
|
||||||
|
HttpCode.UNAUTHORIZED
|
||||||
|
)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ import {
|
|||||||
providerHasCapability,
|
providerHasCapability,
|
||||||
type AiCapability
|
type AiCapability
|
||||||
} from "@server/lib/aiCapabilities";
|
} from "@server/lib/aiCapabilities";
|
||||||
|
import {
|
||||||
|
buildAiCapabilityErrorBody,
|
||||||
|
type AiCapabilityErrorKind
|
||||||
|
} from "@server/lib/aiGatewayAuthError";
|
||||||
import { proxyAiGatewayToSiteTarget } from "@server/routers/aiGateway/targetRouting";
|
import { proxyAiGatewayToSiteTarget } from "@server/routers/aiGateway/targetRouting";
|
||||||
import {
|
import {
|
||||||
SESSION_COOKIE_NAME,
|
SESSION_COOKIE_NAME,
|
||||||
@@ -150,7 +154,12 @@ type ResolvedTarget = {
|
|||||||
|
|
||||||
type ProviderSelection =
|
type ProviderSelection =
|
||||||
| { ok: true; provider: AiProvider }
|
| { ok: true; provider: AiProvider }
|
||||||
| { ok: false; status: number; message: string };
|
| {
|
||||||
|
ok: false;
|
||||||
|
status: number;
|
||||||
|
kind: AiCapabilityErrorKind;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type RequestUser = {
|
export type RequestUser = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -241,10 +250,6 @@ async function resolveRequestUser(
|
|||||||
const virtualApiKeyId =
|
const virtualApiKeyId =
|
||||||
getRequestHeader(req, remoteHeaders.virtual_api_key_id) || null;
|
getRequestHeader(req, remoteHeaders.virtual_api_key_id) || null;
|
||||||
const userId = getRequestHeader(req, remoteHeaders.user_id);
|
const userId = getRequestHeader(req, remoteHeaders.user_id);
|
||||||
logger.debug("+++++++AI gateway request identity from trust header", {
|
|
||||||
virtualApiKeyId,
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const username =
|
const username =
|
||||||
getRequestHeader(req, remoteHeaders.user) || userId;
|
getRequestHeader(req, remoteHeaders.user) || userId;
|
||||||
@@ -499,6 +504,7 @@ async function selectProvider(
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: HttpCode.FORBIDDEN,
|
status: HttpCode.FORBIDDEN,
|
||||||
|
kind: "invalid_request",
|
||||||
message: "A model must be specified for this resource"
|
message: "A model must be specified for this resource"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -511,6 +517,7 @@ async function selectProvider(
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: HttpCode.FORBIDDEN,
|
status: HttpCode.FORBIDDEN,
|
||||||
|
kind: "permission",
|
||||||
message: `Model "${requestedModel}" is not permitted on this resource`
|
message: `Model "${requestedModel}" is not permitted on this resource`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -571,6 +578,7 @@ async function selectProvider(
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: HttpCode.FORBIDDEN,
|
status: HttpCode.FORBIDDEN,
|
||||||
|
kind: "permission",
|
||||||
message: `Model "${requestedModel}" is not permitted on this resource`
|
message: `Model "${requestedModel}" is not permitted on this resource`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -607,6 +615,7 @@ async function selectProvider(
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: HttpCode.FORBIDDEN,
|
status: HttpCode.FORBIDDEN,
|
||||||
|
kind: "permission",
|
||||||
message: `Model "${requestedModel}" is ambiguous across multiple AI providers on this resource. Ask your administrator to configure a more specific allow pattern for this model.`
|
message: `Model "${requestedModel}" is ambiguous across multiple AI providers on this resource. Ask your administrator to configure a more specific allow pattern for this model.`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -745,21 +754,29 @@ export async function handleAiGatewayProxy(
|
|||||||
if (!host) {
|
if (!host) {
|
||||||
return res
|
return res
|
||||||
.status(HttpCode.BAD_REQUEST)
|
.status(HttpCode.BAD_REQUEST)
|
||||||
.json({ error: { message: "Missing Host header" } });
|
.json(
|
||||||
|
buildAiCapabilityErrorBody(
|
||||||
|
capability,
|
||||||
|
"invalid_request",
|
||||||
|
"Missing Host header",
|
||||||
|
HttpCode.BAD_REQUEST
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`AI gateway ${capability} request for host: ${host}`);
|
logger.debug(`AI gateway ${capability} request for host: ${host}`);
|
||||||
logger.debug("AI gateway request headers", {
|
|
||||||
headers: req.headers
|
|
||||||
});
|
|
||||||
|
|
||||||
const target = await resolveTarget(host);
|
const target = await resolveTarget(host);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return res.status(HttpCode.NOT_FOUND).json({
|
return res
|
||||||
error: {
|
.status(HttpCode.NOT_FOUND)
|
||||||
message: "No inference resource found for this host"
|
.json(
|
||||||
}
|
buildAiCapabilityErrorBody(
|
||||||
});
|
capability,
|
||||||
|
"not_found",
|
||||||
|
"No inference resource found for this host",
|
||||||
|
HttpCode.NOT_FOUND
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -777,20 +794,29 @@ export async function handleAiGatewayProxy(
|
|||||||
resourceId != null &&
|
resourceId != null &&
|
||||||
!isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)
|
!isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)
|
||||||
) {
|
) {
|
||||||
return res.status(HttpCode.UNAUTHORIZED).json({
|
return res
|
||||||
error: {
|
.status(HttpCode.UNAUTHORIZED)
|
||||||
message:
|
.json(
|
||||||
"Request must be authenticated via the inference resource"
|
buildAiCapabilityErrorBody(
|
||||||
}
|
capability,
|
||||||
});
|
"authentication",
|
||||||
|
"Request must be authenticated via the inference resource",
|
||||||
|
HttpCode.UNAUTHORIZED
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachments.length === 0) {
|
if (attachments.length === 0) {
|
||||||
return res.status(HttpCode.FORBIDDEN).json({
|
return res
|
||||||
error: {
|
.status(HttpCode.FORBIDDEN)
|
||||||
message: "No AI providers configured for this resource"
|
.json(
|
||||||
}
|
buildAiCapabilityErrorBody(
|
||||||
});
|
capability,
|
||||||
|
"permission",
|
||||||
|
"No AI providers configured for this resource",
|
||||||
|
HttpCode.FORBIDDEN
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const capableAttachments = attachments.filter((a) =>
|
const capableAttachments = attachments.filter((a) =>
|
||||||
@@ -798,11 +824,16 @@ export async function handleAiGatewayProxy(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (capableAttachments.length === 0) {
|
if (capableAttachments.length === 0) {
|
||||||
return res.status(HttpCode.FORBIDDEN).json({
|
return res
|
||||||
error: {
|
.status(HttpCode.FORBIDDEN)
|
||||||
message: `No AI provider on this resource supports ${capability}`
|
.json(
|
||||||
}
|
buildAiCapabilityErrorBody(
|
||||||
});
|
capability,
|
||||||
|
"permission",
|
||||||
|
`No AI provider on this resource supports ${capability}`,
|
||||||
|
HttpCode.FORBIDDEN
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedModel = def.extractModel(req);
|
const requestedModel = def.extractModel(req);
|
||||||
@@ -824,9 +855,16 @@ export async function handleAiGatewayProxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!selection.ok) {
|
if (!selection.ok) {
|
||||||
return res.status(selection.status).json({
|
return res
|
||||||
error: { message: selection.message }
|
.status(selection.status)
|
||||||
});
|
.json(
|
||||||
|
buildAiCapabilityErrorBody(
|
||||||
|
capability,
|
||||||
|
selection.kind,
|
||||||
|
selection.message,
|
||||||
|
selection.status
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { provider } = selection;
|
const { provider } = selection;
|
||||||
@@ -855,11 +893,16 @@ export async function handleAiGatewayProxy(
|
|||||||
siteResourceId,
|
siteResourceId,
|
||||||
userId: requestUser?.userId ?? null
|
userId: requestUser?.userId ?? null
|
||||||
});
|
});
|
||||||
return res.status(HttpCode.TOO_MANY_REQUESTS).json({
|
return res
|
||||||
error: {
|
.status(HttpCode.TOO_MANY_REQUESTS)
|
||||||
message: "AI usage budget exceeded for this request"
|
.json(
|
||||||
}
|
buildAiCapabilityErrorBody(
|
||||||
});
|
capability,
|
||||||
|
"rate_limit",
|
||||||
|
"AI usage budget exceeded for this request",
|
||||||
|
HttpCode.TOO_MANY_REQUESTS
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,21 +928,31 @@ export async function handleAiGatewayProxy(
|
|||||||
const authType = provider.authType as AiProviderAuthType;
|
const authType = provider.authType as AiProviderAuthType;
|
||||||
|
|
||||||
if (!upstreamUrl) {
|
if (!upstreamUrl) {
|
||||||
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
return res
|
||||||
error: {
|
.status(HttpCode.INTERNAL_SERVER_ERROR)
|
||||||
message: "AI provider has no upstream URL configured"
|
.json(
|
||||||
}
|
buildAiCapabilityErrorBody(
|
||||||
});
|
capability,
|
||||||
|
"internal",
|
||||||
|
"AI provider has no upstream URL configured",
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let apiKey: string | null = null;
|
let apiKey: string | null = null;
|
||||||
if (authTypeRequiresApiKey(authType)) {
|
if (authTypeRequiresApiKey(authType)) {
|
||||||
if (!provider.apiKey) {
|
if (!provider.apiKey) {
|
||||||
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
return res
|
||||||
error: {
|
.status(HttpCode.INTERNAL_SERVER_ERROR)
|
||||||
message: "AI provider has no API key configured"
|
.json(
|
||||||
}
|
buildAiCapabilityErrorBody(
|
||||||
});
|
capability,
|
||||||
|
"internal",
|
||||||
|
"AI provider has no API key configured",
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const secret = config.getRawConfig().server.secret!;
|
const secret = config.getRawConfig().server.secret!;
|
||||||
apiKey = decrypt(provider.apiKey, secret);
|
apiKey = decrypt(provider.apiKey, secret);
|
||||||
@@ -1034,8 +1087,15 @@ export async function handleAiGatewayProxy(
|
|||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
return res
|
||||||
error: { message: "Failed to proxy inference request" }
|
.status(HttpCode.INTERNAL_SERVER_ERROR)
|
||||||
});
|
.json(
|
||||||
|
buildAiCapabilityErrorBody(
|
||||||
|
capability,
|
||||||
|
"internal",
|
||||||
|
"Failed to proxy inference request",
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
AI_CAPABILITY_DEFS,
|
AI_CAPABILITY_DEFS,
|
||||||
type AiCapability
|
type AiCapability
|
||||||
} from "@server/lib/aiCapabilities";
|
} from "@server/lib/aiCapabilities";
|
||||||
|
import { buildAiCapabilityErrorBody } from "@server/lib/aiGatewayAuthError";
|
||||||
import {
|
import {
|
||||||
needsStreamUsageInjection,
|
needsStreamUsageInjection,
|
||||||
withStreamUsageOption
|
withStreamUsageOption
|
||||||
@@ -184,11 +185,14 @@ export async function proxyAiGatewayToSiteTarget(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const providerTargets = await getProviderTargets(provider.providerId);
|
const providerTargets = await getProviderTargets(provider.providerId);
|
||||||
if (providerTargets.length === 0) {
|
if (providerTargets.length === 0) {
|
||||||
res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
res.status(HttpCode.INTERNAL_SERVER_ERROR).json(
|
||||||
error: {
|
buildAiCapabilityErrorBody(
|
||||||
message: "AI provider has no reachable site targets configured"
|
capability,
|
||||||
}
|
"internal",
|
||||||
});
|
"AI provider has no reachable site targets configured",
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,11 +211,14 @@ export async function proxyAiGatewayToSiteTarget(
|
|||||||
let apiKey: string | null = null;
|
let apiKey: string | null = null;
|
||||||
if (authTypeRequiresApiKey(authType)) {
|
if (authTypeRequiresApiKey(authType)) {
|
||||||
if (!provider.apiKey) {
|
if (!provider.apiKey) {
|
||||||
res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
res.status(HttpCode.INTERNAL_SERVER_ERROR).json(
|
||||||
error: {
|
buildAiCapabilityErrorBody(
|
||||||
message: "AI provider has no API key configured"
|
capability,
|
||||||
}
|
"internal",
|
||||||
});
|
"AI provider has no API key configured",
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const secret = config.getRawConfig().server.secret!;
|
const secret = config.getRawConfig().server.secret!;
|
||||||
@@ -286,9 +293,14 @@ export async function proxyAiGatewayToSiteTarget(
|
|||||||
? (fetchError as Error & { cause?: unknown }).cause
|
? (fetchError as Error & { cause?: unknown }).cause
|
||||||
: undefined
|
: undefined
|
||||||
});
|
});
|
||||||
res.status(HttpCode.BAD_GATEWAY).json({
|
res.status(HttpCode.BAD_GATEWAY).json(
|
||||||
error: { message: "Failed to reach AI provider target" }
|
buildAiCapabilityErrorBody(
|
||||||
});
|
capability,
|
||||||
|
"internal",
|
||||||
|
"Failed to reach AI provider target",
|
||||||
|
HttpCode.BAD_GATEWAY
|
||||||
|
)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user