gateway endpoint mvp

This commit is contained in:
miloschwartz
2026-08-05 10:24:26 -04:00
parent 7afddb5eb5
commit bc80f91a45
+157 -94
View File
@@ -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) {