mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-03 11:10:51 +02:00
first pass of traefik -> basic gateway
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
import { Request, Response } from "express";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
AiProvider,
|
||||
aiModels,
|
||||
aiProviders,
|
||||
db,
|
||||
resourceAiModels,
|
||||
resources,
|
||||
siteResourceAiModels,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
AiProviderAuthType,
|
||||
AiProviderRoutingMode,
|
||||
AiProviderType,
|
||||
resolveAiProviderConfig
|
||||
} from "@server/lib/aiProviderDefaults";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
type ResolvedTarget = {
|
||||
provider: AiProvider;
|
||||
// null = no restriction; every enabled model on the provider is allowed
|
||||
allowedModelIds: number[] | null;
|
||||
};
|
||||
|
||||
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
||||
const [resourceRow] = await db
|
||||
.select({ resourceId: resources.resourceId, provider: aiProviders })
|
||||
.from(resources)
|
||||
.innerJoin(
|
||||
aiProviders,
|
||||
eq(resources.aiProviderId, aiProviders.providerId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.fullDomain, host),
|
||||
eq(resources.mode, "inference"),
|
||||
eq(resources.enabled, true),
|
||||
eq(aiProviders.enabled, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (resourceRow) {
|
||||
const restrictions = await db
|
||||
.select({ modelId: resourceAiModels.modelId })
|
||||
.from(resourceAiModels)
|
||||
.where(eq(resourceAiModels.resourceId, resourceRow.resourceId));
|
||||
|
||||
return {
|
||||
provider: resourceRow.provider,
|
||||
allowedModelIds: restrictions.length
|
||||
? restrictions.map((r) => r.modelId)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
const [siteResourceRow] = await db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
provider: aiProviders
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(
|
||||
aiProviders,
|
||||
eq(siteResources.aiProviderId, aiProviders.providerId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.alias, host),
|
||||
eq(siteResources.mode, "inference"),
|
||||
eq(siteResources.enabled, true),
|
||||
eq(aiProviders.enabled, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (siteResourceRow) {
|
||||
const restrictions = await db
|
||||
.select({ modelId: siteResourceAiModels.modelId })
|
||||
.from(siteResourceAiModels)
|
||||
.where(
|
||||
eq(
|
||||
siteResourceAiModels.siteResourceId,
|
||||
siteResourceRow.siteResourceId
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
provider: siteResourceRow.provider,
|
||||
allowedModelIds: restrictions.length
|
||||
? restrictions.map((r) => r.modelId)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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(req: Request, res: Response): Promise<any> {
|
||||
try {
|
||||
const host = (req.headers.host || "").split(":")[0];
|
||||
if (!host) {
|
||||
return res
|
||||
.status(HttpCode.BAD_REQUEST)
|
||||
.json({ error: { message: "Missing Host header" } });
|
||||
}
|
||||
|
||||
const target = await resolveTarget(host);
|
||||
if (!target) {
|
||||
return res.status(HttpCode.NOT_FOUND).json({
|
||||
error: {
|
||||
message: "No inference resource found for this host"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { provider, allowedModelIds } = target;
|
||||
const requestedModel =
|
||||
typeof req.body?.model === "string" ? req.body.model : undefined;
|
||||
|
||||
if (allowedModelIds) {
|
||||
if (!requestedModel) {
|
||||
return res.status(HttpCode.FORBIDDEN).json({
|
||||
error: {
|
||||
message:
|
||||
"This resource restricts access to specific models; a model must be specified"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const [matchedModel] = await db
|
||||
.select({ modelId: aiModels.modelId })
|
||||
.from(aiModels)
|
||||
.where(
|
||||
and(
|
||||
eq(aiModels.providerId, provider.providerId),
|
||||
eq(aiModels.modelKey, requestedModel)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (
|
||||
!matchedModel ||
|
||||
!allowedModelIds.includes(matchedModel.modelId)
|
||||
) {
|
||||
return res.status(HttpCode.FORBIDDEN).json({
|
||||
error: {
|
||||
message: `Model "${requestedModel}" is not permitted on this resource`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!provider.apiKey) {
|
||||
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||
error: { message: "AI provider has no API key configured" }
|
||||
});
|
||||
}
|
||||
|
||||
const secret = config.getRawConfig().server.secret!;
|
||||
const apiKey = decrypt(provider.apiKey, secret);
|
||||
|
||||
const { upstreamUrl, authType } = resolveAiProviderConfig({
|
||||
type: provider.type as AiProviderType,
|
||||
upstreamUrl: provider.upstreamUrl,
|
||||
authType: provider.authType as AiProviderAuthType | null,
|
||||
routingMode: provider.routingMode as AiProviderRoutingMode | null
|
||||
});
|
||||
|
||||
if (!upstreamUrl) {
|
||||
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||
error: {
|
||||
message: "AI provider has no upstream URL configured"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const targetUrl = `${upstreamUrl.replace(/\/$/, "")}${getCompletionsPath(
|
||||
provider.type as AiProviderType
|
||||
)}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
if (authType === "bearer") {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
// No dedicated per-request TLS agent is wired up (no extra deps for
|
||||
// this v1 gateway) - toggle the process-wide Node TLS check instead.
|
||||
// Known limitation: this is not safe under concurrent requests mixing
|
||||
// skipTlsVerification providers with strict ones.
|
||||
const restoreTlsReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
||||
if (provider.skipTlsVerification) {
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||||
}
|
||||
|
||||
let upstreamRes: globalThis.Response;
|
||||
try {
|
||||
upstreamRes = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(req.body)
|
||||
});
|
||||
} finally {
|
||||
if (provider.skipTlsVerification) {
|
||||
if (restoreTlsReject === undefined) {
|
||||
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
||||
} else {
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = restoreTlsReject;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = upstreamRes.headers.get("content-type") || "";
|
||||
const isStream =
|
||||
req.body?.stream === true ||
|
||||
contentType.includes("text/event-stream");
|
||||
|
||||
res.status(upstreamRes.status);
|
||||
res.setHeader("Content-Type", contentType || "application/json");
|
||||
|
||||
if (isStream && upstreamRes.body) {
|
||||
res.flushHeaders();
|
||||
const reader = upstreamRes.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
res.write(value);
|
||||
}
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const text = await upstreamRes.text();
|
||||
return res.send(text);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||
error: { message: "Failed to proxy inference request" }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./chatCompletions";
|
||||
@@ -3,6 +3,7 @@ import * as gerbil from "@server/routers/gerbil";
|
||||
import * as traefik from "@server/routers/traefik";
|
||||
import * as resource from "./resource";
|
||||
import * as badger from "./badger";
|
||||
import * as aiGateway from "@server/routers/aiGateway";
|
||||
import * as auth from "@server/routers/auth";
|
||||
import * as supporterKey from "@server/routers/supporterKey";
|
||||
import * as idp from "@server/routers/idp";
|
||||
@@ -63,3 +64,10 @@ internalRouter.use("/badger", badgerRouter);
|
||||
badgerRouter.post("/verify-session", badger.verifyResourceSession);
|
||||
|
||||
badgerRouter.post("/exchange-session", badger.exchangeSession);
|
||||
|
||||
// AI inference gateway - minimal chat-completions proxy for inference-mode
|
||||
// resources/siteResources
|
||||
internalRouter.post(
|
||||
"/ai-gateway/chat/completions",
|
||||
aiGateway.chatCompletions
|
||||
);
|
||||
|
||||
@@ -90,11 +90,22 @@ const createHttpResourceSchema = z
|
||||
domainId: z.string(),
|
||||
stickySession: z.boolean().optional(),
|
||||
postAuthPath: z.string().nullable().optional(),
|
||||
mode: z.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp"]).optional(),
|
||||
mode: z
|
||||
.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp", "inference"])
|
||||
.optional(),
|
||||
// SSH Settings
|
||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||
authDaemonPort: z.int().positive().optional(),
|
||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional()
|
||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
|
||||
// Inference settings
|
||||
aiProviderId: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe(
|
||||
"For inference-mode resources: the AI provider this resource proxies chat completions to."
|
||||
)
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -365,7 +376,8 @@ async function createHttpResource(
|
||||
mode,
|
||||
authDaemonPort,
|
||||
authDaemonMode,
|
||||
pamMode
|
||||
pamMode,
|
||||
aiProviderId
|
||||
} = parsedBody.data;
|
||||
const subdomain = parsedBody.data.subdomain;
|
||||
const stickySession = parsedBody.data.stickySession;
|
||||
@@ -552,7 +564,8 @@ async function createHttpResource(
|
||||
postAuthPath: postAuthPath,
|
||||
wildcard,
|
||||
health: "unknown",
|
||||
defaultResourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
defaultResourcePolicyId: defaultPolicy.resourcePolicyId,
|
||||
aiProviderId: aiProviderId ?? null
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -120,6 +120,15 @@ const updateHttpResourceBodySchema = z
|
||||
.optional()
|
||||
.describe(
|
||||
"ID of the resource policy to apply to this resource. Set to null to remove the resource policy and fall back to the inline policy settings."
|
||||
),
|
||||
aiProviderId: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"For inference-mode resources: the AI provider this resource proxies chat completions to. Set to null to unlink."
|
||||
)
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
|
||||
@@ -78,7 +78,15 @@ const createSiteResourceSchema = z
|
||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
|
||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
|
||||
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
|
||||
subdomain: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
|
||||
aiProviderId: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe(
|
||||
"For inference-mode site resources: the AI provider this resource proxies chat completions to."
|
||||
)
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
@@ -322,7 +330,8 @@ export async function createSiteResource(
|
||||
authDaemonMode,
|
||||
pamMode,
|
||||
domainId,
|
||||
subdomain
|
||||
subdomain,
|
||||
aiProviderId
|
||||
} = parsedBody.data;
|
||||
|
||||
// Backward compatibility: merge deprecated siteId into siteIds array
|
||||
@@ -594,7 +603,8 @@ export async function createSiteResource(
|
||||
domainId,
|
||||
subdomain: finalSubdomain,
|
||||
fullDomain,
|
||||
requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this
|
||||
requiresExitNodeConnection: mode === "inference", // in the future we might want to have different modes that do this
|
||||
aiProviderId: aiProviderId ?? null
|
||||
};
|
||||
if (isLicensedSshPam) {
|
||||
if (authDaemonPort !== undefined)
|
||||
|
||||
@@ -78,7 +78,16 @@ const updateSiteResourceSchema = z
|
||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
|
||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||
domainId: z.string().optional(),
|
||||
subdomain: z.string().optional()
|
||||
subdomain: z.string().optional(),
|
||||
aiProviderId: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"For inference-mode site resources: the AI provider this resource proxies chat completions to. Set to null to unlink."
|
||||
)
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
@@ -329,7 +338,8 @@ export async function updateSiteResource(
|
||||
authDaemonMode,
|
||||
pamMode,
|
||||
domainId,
|
||||
subdomain
|
||||
subdomain,
|
||||
aiProviderId
|
||||
} = parsedBody.data;
|
||||
|
||||
// Backward compatibility: merge deprecated siteId into siteIds array
|
||||
@@ -594,6 +604,7 @@ export async function updateSiteResource(
|
||||
networkId: mode === "inference" ? null : undefined,
|
||||
requiresExitNodeConnection:
|
||||
mode !== undefined ? mode === "inference" : undefined,
|
||||
aiProviderId: aiProviderId,
|
||||
...sshPamSet
|
||||
})
|
||||
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
|
||||
|
||||
@@ -20,6 +20,9 @@ export async function traefikConfigProvider(
|
||||
const maintenancePort = config.getRawConfig().server.next_port;
|
||||
const maintenanceHost = config.getRawConfig().server.internal_hostname;
|
||||
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
|
||||
const aiGatewayUrl = `http://${maintenanceHost}:${
|
||||
config.getRawConfig().server.internal_port
|
||||
}/api/v1/ai-gateway`;
|
||||
|
||||
const traefikConfig = await getTraefikConfig(
|
||||
currentExitNodeId,
|
||||
@@ -28,7 +31,8 @@ export async function traefikConfigProvider(
|
||||
build != "oss", // generate the login pages on the cloud and and enterprise,
|
||||
config.getRawConfig().traefik.allow_raw_resources,
|
||||
pangolinUIUrl,
|
||||
pangolinUIUrl
|
||||
pangolinUIUrl,
|
||||
aiGatewayUrl
|
||||
);
|
||||
|
||||
if (traefikConfig?.http?.middlewares) {
|
||||
|
||||
Reference in New Issue
Block a user