mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-06 12:41:25 +02:00
gateway endpoint mvp
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
AiProvider,
|
AiProvider,
|
||||||
aiModels,
|
aiModels,
|
||||||
@@ -94,10 +94,12 @@ type ProviderAttachment = {
|
|||||||
|
|
||||||
type ResolvedTarget = {
|
type ResolvedTarget = {
|
||||||
resourceId: number | null;
|
resourceId: number | null;
|
||||||
|
siteResourceId: number | null;
|
||||||
orgId: string | null;
|
orgId: string | null;
|
||||||
attachments: ProviderAttachment[];
|
attachments: ProviderAttachment[];
|
||||||
// model IDs on this resource's allowlist (resource-wide)
|
// Model IDs on the resource allowlist that belong to allowlist-mode
|
||||||
allowedModelIds: number[];
|
// providers. Empty when no attached provider uses allowlist mode.
|
||||||
|
allowlistedModelIds: Set<number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProviderSelection =
|
type ProviderSelection =
|
||||||
@@ -192,6 +194,9 @@ async function resolveRequestUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
||||||
|
// TODO: eventually we need to know if it's a private or public resource
|
||||||
|
// and not just simply check the fullDomain in case there is a private resource with the same fullDomain
|
||||||
|
|
||||||
const [resourceRow] = await db
|
const [resourceRow] = await db
|
||||||
.select({
|
.select({
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
@@ -229,26 +234,40 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAllowlist = attachmentRows.some(
|
const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({
|
||||||
(a) => a.modelAccessMode === "allowlist"
|
provider: a.provider,
|
||||||
);
|
modelAccessMode: a.modelAccessMode as ModelAccessMode
|
||||||
let allowedModelIds: number[] = [];
|
}));
|
||||||
if (hasAllowlist) {
|
|
||||||
|
const allowlistProviderIds = attachments
|
||||||
|
.filter((a) => a.modelAccessMode === "allowlist")
|
||||||
|
.map((a) => a.provider.providerId);
|
||||||
|
const allowlistedModelIds = new Set<number>();
|
||||||
|
if (allowlistProviderIds.length > 0) {
|
||||||
const restrictions = await db
|
const restrictions = await db
|
||||||
.select({ modelId: resourceAiModels.modelId })
|
.select({ modelId: resourceAiModels.modelId })
|
||||||
.from(resourceAiModels)
|
.from(resourceAiModels)
|
||||||
.where(eq(resourceAiModels.resourceId, resourceRow.resourceId));
|
.innerJoin(
|
||||||
allowedModelIds = restrictions.map((r) => r.modelId);
|
aiModels,
|
||||||
|
eq(resourceAiModels.modelId, aiModels.modelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAiModels.resourceId, resourceRow.resourceId),
|
||||||
|
inArray(aiModels.providerId, allowlistProviderIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const row of restrictions) {
|
||||||
|
allowlistedModelIds.add(row.modelId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resourceId: resourceRow.resourceId,
|
resourceId: resourceRow.resourceId,
|
||||||
|
siteResourceId: null,
|
||||||
orgId: resourceRow.orgId,
|
orgId: resourceRow.orgId,
|
||||||
attachments: attachmentRows.map((a) => ({
|
attachments,
|
||||||
provider: a.provider,
|
allowlistedModelIds
|
||||||
modelAccessMode: a.modelAccessMode as ModelAccessMode
|
|
||||||
})),
|
|
||||||
allowedModelIds
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,74 +311,52 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAllowlist = attachmentRows.some(
|
const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({
|
||||||
(a) => a.modelAccessMode === "allowlist"
|
provider: a.provider,
|
||||||
);
|
modelAccessMode: a.modelAccessMode as ModelAccessMode
|
||||||
let allowedModelIds: number[] = [];
|
}));
|
||||||
if (hasAllowlist) {
|
|
||||||
|
const allowlistProviderIds = attachments
|
||||||
|
.filter((a) => a.modelAccessMode === "allowlist")
|
||||||
|
.map((a) => a.provider.providerId);
|
||||||
|
const allowlistedModelIds = new Set<number>();
|
||||||
|
if (allowlistProviderIds.length > 0) {
|
||||||
const restrictions = await db
|
const restrictions = await db
|
||||||
.select({ modelId: siteResourceAiModels.modelId })
|
.select({ modelId: siteResourceAiModels.modelId })
|
||||||
.from(siteResourceAiModels)
|
.from(siteResourceAiModels)
|
||||||
|
.innerJoin(
|
||||||
|
aiModels,
|
||||||
|
eq(siteResourceAiModels.modelId, aiModels.modelId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
eq(
|
and(
|
||||||
siteResourceAiModels.siteResourceId,
|
eq(
|
||||||
siteResourceRow.siteResourceId
|
siteResourceAiModels.siteResourceId,
|
||||||
|
siteResourceRow.siteResourceId
|
||||||
|
),
|
||||||
|
inArray(aiModels.providerId, allowlistProviderIds)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
allowedModelIds = restrictions.map((r) => r.modelId);
|
for (const row of restrictions) {
|
||||||
|
allowlistedModelIds.add(row.modelId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// siteResources have no per-user auth/policy stack today (see
|
|
||||||
// the routing comment in getTraefikConfig.ts), so there's no
|
|
||||||
// resource access token scope to validate a user token against.
|
|
||||||
resourceId: null,
|
resourceId: null,
|
||||||
|
siteResourceId: siteResourceRow.siteResourceId,
|
||||||
orgId: siteResourceRow.orgId,
|
orgId: siteResourceRow.orgId,
|
||||||
attachments: attachmentRows.map((a) => ({
|
attachments,
|
||||||
provider: a.provider,
|
allowlistedModelIds
|
||||||
modelAccessMode: a.modelAccessMode as ModelAccessMode
|
|
||||||
})),
|
|
||||||
allowedModelIds
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function providerMatchesModel(
|
|
||||||
attachment: ProviderAttachment,
|
|
||||||
requestedModel: string,
|
|
||||||
allowedModelIds: number[]
|
|
||||||
): Promise<boolean> {
|
|
||||||
const [matchedModel] = await db
|
|
||||||
.select({
|
|
||||||
modelId: aiModels.modelId,
|
|
||||||
enabled: aiModels.enabled
|
|
||||||
})
|
|
||||||
.from(aiModels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(aiModels.providerId, attachment.provider.providerId),
|
|
||||||
eq(aiModels.modelKey, requestedModel)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!matchedModel) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attachment.modelAccessMode === "catalog") {
|
|
||||||
return matchedModel.enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
// allowlist
|
|
||||||
return allowedModelIds.includes(matchedModel.modelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectProvider(
|
async function selectProvider(
|
||||||
attachments: ProviderAttachment[],
|
attachments: ProviderAttachment[],
|
||||||
allowedModelIds: number[],
|
allowlistedModelIds: Set<number>,
|
||||||
requestedModel: string | undefined
|
requestedModel: string | undefined
|
||||||
): Promise<ProviderSelection> {
|
): Promise<ProviderSelection> {
|
||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
@@ -370,21 +367,55 @@ async function selectProvider(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates: ProviderAttachment[] = [];
|
const providerById = new Map(
|
||||||
for (const attachment of attachments) {
|
attachments.map((a) => [a.provider.providerId, a])
|
||||||
if (
|
);
|
||||||
await providerMatchesModel(
|
const providerIds = [...providerById.keys()];
|
||||||
attachment,
|
if (providerIds.length === 0) {
|
||||||
requestedModel,
|
return {
|
||||||
allowedModelIds
|
ok: false,
|
||||||
|
status: HttpCode.FORBIDDEN,
|
||||||
|
message: `Model "${requestedModel}" is not permitted on this resource`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// One lookup for the requested model key across all attached providers.
|
||||||
|
const matchingModels = await db
|
||||||
|
.select({
|
||||||
|
modelId: aiModels.modelId,
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
enabled: aiModels.enabled
|
||||||
|
})
|
||||||
|
.from(aiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(aiModels.providerId, providerIds),
|
||||||
|
eq(aiModels.modelKey, requestedModel)
|
||||||
)
|
)
|
||||||
) {
|
);
|
||||||
candidates.push(attachment);
|
|
||||||
|
const candidates: AiProvider[] = [];
|
||||||
|
for (const model of matchingModels) {
|
||||||
|
const attachment = providerById.get(model.providerId);
|
||||||
|
if (!attachment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment.modelAccessMode === "catalog") {
|
||||||
|
if (model.enabled) {
|
||||||
|
candidates.push(attachment.provider);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowlist: only models explicitly attached to the resource
|
||||||
|
if (allowlistedModelIds.has(model.modelId)) {
|
||||||
|
candidates.push(attachment.provider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidates.length === 1) {
|
if (candidates.length === 1) {
|
||||||
return { ok: true, provider: candidates[0].provider };
|
return { ok: true, provider: candidates[0] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidates.length > 1) {
|
if (candidates.length > 1) {
|
||||||
@@ -402,15 +433,6 @@ async function selectProvider(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic OpenAI-wire-compatible passthrough. Anthropic's native API uses a
|
|
||||||
// different path/schema; everything else here is OpenAI-compatible today.
|
|
||||||
function getCompletionsPath(type: AiProviderType): string {
|
|
||||||
if (type === "anthropic") {
|
|
||||||
return "/v1/messages";
|
|
||||||
}
|
|
||||||
return "/chat/completions";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function chatCompletions(
|
export async function chatCompletions(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response
|
res: Response
|
||||||
@@ -438,7 +460,9 @@ export async function chatCompletions(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { attachments, allowedModelIds, resourceId, orgId } = target;
|
const { attachments, allowlistedModelIds, resourceId, orgId } = target;
|
||||||
|
|
||||||
|
logger.debug("+++++ gateway target: ", target);
|
||||||
|
|
||||||
// Best-effort identity resolution - not yet enforced, but lets us
|
// Best-effort identity resolution - not yet enforced, but lets us
|
||||||
// start making per-user access decisions (e.g. model/role-based
|
// start making per-user access decisions (e.g. model/role-based
|
||||||
@@ -455,7 +479,7 @@ export async function chatCompletions(
|
|||||||
|
|
||||||
const selection = await selectProvider(
|
const selection = await selectProvider(
|
||||||
attachments,
|
attachments,
|
||||||
allowedModelIds,
|
allowlistedModelIds,
|
||||||
requestedModel
|
requestedModel
|
||||||
);
|
);
|
||||||
if (!selection.ok) {
|
if (!selection.ok) {
|
||||||
@@ -490,16 +514,35 @@ export async function chatCompletions(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUrl = `${upstreamUrl.replace(/\/$/, "")}${getCompletionsPath(
|
const targetUrl = `${upstreamUrl.replace(/\/$/, "")}`;
|
||||||
provider.type as AiProviderType
|
|
||||||
)}`;
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
// Drop hop-by-hop / proxy-only headers. Forwarding Host especially
|
||||||
"Content-Type": "application/json"
|
// breaks Node fetch (TLS/SNI targets the upstream URL while Host
|
||||||
};
|
// still says localhost).
|
||||||
if (authType === "bearer") {
|
const skipHeaders = new Set([
|
||||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
"p-host",
|
||||||
|
"host",
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailers",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
"content-length",
|
||||||
|
"accept-encoding"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (skipHeaders.has(key.toLowerCase()) || value === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
||||||
}
|
}
|
||||||
|
// TODO: temporary hardcoded auth for testing; restore bearer from authType
|
||||||
|
headers["x-api-key"] = apiKey;
|
||||||
|
|
||||||
// No dedicated per-request TLS agent is wired up (no extra deps for
|
// No dedicated per-request TLS agent is wired up (no extra deps for
|
||||||
// this v1 gateway) - toggle the process-wide Node TLS check instead.
|
// this v1 gateway) - toggle the process-wide Node TLS check instead.
|
||||||
@@ -510,13 +553,33 @@ export async function chatCompletions(
|
|||||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = JSON.stringify(req.body);
|
||||||
|
|
||||||
|
logger.debug("AI gateway upstream request", {
|
||||||
|
url: targetUrl,
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: req.body
|
||||||
|
});
|
||||||
|
|
||||||
let upstreamRes: globalThis.Response;
|
let upstreamRes: globalThis.Response;
|
||||||
try {
|
try {
|
||||||
upstreamRes = await fetch(targetUrl, {
|
upstreamRes = await fetch(targetUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(req.body)
|
body
|
||||||
});
|
});
|
||||||
|
} catch (fetchError) {
|
||||||
|
logger.error({
|
||||||
|
message: "AI gateway upstream fetch failed",
|
||||||
|
url: targetUrl,
|
||||||
|
error: fetchError,
|
||||||
|
cause:
|
||||||
|
fetchError instanceof Error
|
||||||
|
? (fetchError as Error & { cause?: unknown }).cause
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
throw fetchError;
|
||||||
} finally {
|
} finally {
|
||||||
if (provider.skipTlsVerification) {
|
if (provider.skipTlsVerification) {
|
||||||
if (restoreTlsReject === undefined) {
|
if (restoreTlsReject === undefined) {
|
||||||
|
|||||||
Reference in New Issue
Block a user