Merge branch 'aig' of https://github.com/fosrl/pangolin into aig

This commit is contained in:
miloschwartz
2026-08-04 17:27:10 -04:00
9 changed files with 123 additions and 54 deletions
+39
View File
@@ -0,0 +1,39 @@
import express from "express";
import helmet from "helmet";
import cors from "cors";
import config from "@server/lib/config";
import logger from "@server/logger";
import {
errorHandlerMiddleware,
notFoundMiddleware
} from "@server/middlewares";
import * as aiGateway from "@server/routers/aiGateway";
const aiGatewayPort = config.getRawConfig().server.ai_gateway_port;
export function createAiGatewayServer() {
const aiGatewayServer = express();
const trustProxy = config.getRawConfig().server.trust_proxy;
if (trustProxy) {
aiGatewayServer.set("trust proxy", trustProxy);
}
aiGatewayServer.use(helmet());
aiGatewayServer.use(cors());
aiGatewayServer.use(express.json());
aiGatewayServer.post("/chat/completions", aiGateway.chatCompletions);
aiGatewayServer.use(notFoundMiddleware);
aiGatewayServer.use(errorHandlerMiddleware);
aiGatewayServer.listen(aiGatewayPort, (err?: any) => {
if (err) throw err;
logger.info(
`AI gateway server is running on http://localhost:${aiGatewayPort}`
);
});
return aiGatewayServer;
}
+3
View File
@@ -5,6 +5,7 @@ import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer"; import { createApiServer } from "./apiServer";
import { createNextServer } from "./nextServer"; import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer"; import { createInternalServer } from "./internalServer";
import { createAiGatewayServer } from "./aiGatewayServer";
import { createIntegrationApiServer } from "./integrationApiServer"; import { createIntegrationApiServer } from "./integrationApiServer";
import { import {
ApiKey, ApiKey,
@@ -49,6 +50,7 @@ async function startServers() {
// Start all servers // Start all servers
const apiServer = createApiServer(); const apiServer = createApiServer();
const internalServer = createInternalServer(); const internalServer = createInternalServer();
const aiGatewayServer = createAiGatewayServer();
const nextServer = await createNextServer(); const nextServer = await createNextServer();
if (config.getRawConfig().traefik.file_mode) { if (config.getRawConfig().traefik.file_mode) {
@@ -67,6 +69,7 @@ async function startServers() {
apiServer, apiServer,
nextServer, nextServer,
internalServer, internalServer,
aiGatewayServer,
integrationServer integrationServer
}; };
} }
+7
View File
@@ -79,7 +79,13 @@ export const configSchema = z
.default(3001) .default(3001)
.transform(stoi) .transform(stoi)
.pipe(portSchema), .pipe(portSchema),
ai_gateway_port: portSchema
.optional()
.default(3005)
.transform(stoi)
.pipe(portSchema),
badger_override: z.string().optional(), badger_override: z.string().optional(),
ai_gateway_override: z.string().optional(),
next_port: portSchema next_port: portSchema
.optional() .optional()
.default(3002) .default(3002)
@@ -139,6 +145,7 @@ export const configSchema = z
integration_port: 3003, integration_port: 3003,
external_port: 3000, external_port: 3000,
internal_port: 3001, internal_port: 3001,
ai_gateway_port: 3005,
next_port: 3002, next_port: 3002,
internal_hostname: "pangolin", internal_hostname: "pangolin",
session_cookie_name: "p_session_token", session_cookie_name: "p_session_token",
+5 -3
View File
@@ -516,9 +516,11 @@ export class TraefikConfigManager {
const maintenanceHost = const maintenanceHost =
config.getRawConfig().server.internal_hostname; config.getRawConfig().server.internal_hostname;
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`; const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
const aiGatewayUrl = `http://${maintenanceHost}:${ const aiGatewayUrl =
config.getRawConfig().server.internal_port config.getRawConfig().server.ai_gateway_override ||
}/api/v1/ai-gateway`; `http://${maintenanceHost}:${
config.getRawConfig().server.ai_gateway_port
}`;
// logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`); // logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`);
traefikConfig = await getTraefikConfig( traefikConfig = await getTraefikConfig(
+1 -1
View File
@@ -785,7 +785,7 @@ export async function getTraefikConfig(
config_output.http.services[serviceName] = { config_output.http.services[serviceName] = {
loadBalancer: { loadBalancer: {
servers: [{ url: `${aiGatewayUrl}/chat/completions` }], servers: [{ url: aiGatewayUrl }],
passHostHeader: true passHostHeader: true
} }
}; };
+56 -39
View File
@@ -433,41 +433,21 @@ export async function getTraefikConfig(
) )
); );
let siteResourcesInference: { const siteResourcesInference = await db
siteResourceId: number; .selectDistinct({
alias: string | null; siteResourceId: siteResources.siteResourceId,
ssl: boolean | null; alias: siteResources.alias,
enabled: boolean | null; ssl: siteResources.ssl,
}[] = []; enabled: siteResources.enabled
if (build == "enterprise") { })
siteResourcesInference = await db .from(siteResources)
.selectDistinct({ .where(
siteResourceId: siteResources.siteResourceId, and(
alias: siteResources.alias, eq(siteResources.mode, "inference"),
ssl: siteResources.ssl, eq(siteResources.enabled, true),
enabled: siteResources.enabled isNotNull(siteResources.alias)
})
.from(siteResources)
.innerJoin(
siteResourceAiProviders,
eq(
siteResources.siteResourceId,
siteResourceAiProviders.siteResourceId
)
) )
.innerJoin( );
aiProviders,
eq(siteResourceAiProviders.providerId, aiProviders.providerId)
)
.where(
and(
eq(siteResources.mode, "inference"),
eq(siteResources.enabled, true),
eq(aiProviders.enabled, true),
isNotNull(siteResources.alias)
)
);
}
let validCerts: CertificateResult[] = []; let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
@@ -1548,6 +1528,20 @@ export async function getTraefikConfig(
} }
if (aiGatewayUrl) { if (aiGatewayUrl) {
// The AI gateway may live on a different host than the inference
// resource itself (e.g. a remote exit node forwarding to the
// central dashboard over a tunnel). passHostHeader would forward
// the resource's own Host, which that external host won't
// recognize, so we pin the Host header to the gateway's own host
// and smuggle the original resource host through in "p-host"
// instead (same pattern as the maintenance-page routes above).
let aiGatewayHost: string | undefined;
try {
aiGatewayHost = new URL(aiGatewayUrl).host;
} catch {
aiGatewayHost = undefined;
}
// Public inference resources: same TLS/cert-resolver handling as // Public inference resources: same TLS/cert-resolver handling as
// plain http-mode resources, but the service points at the AI // plain http-mode resources, but the service points at the AI
// gateway instead of any real backend targets. // gateway instead of any real backend targets.
@@ -1613,10 +1607,21 @@ export async function getTraefikConfig(
} }
} }
const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
config_output.http.middlewares[irHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
}
}
};
const additionalMiddlewares = const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [ const routerMiddlewares = [
badgerMiddlewareName, badgerMiddlewareName,
irHeadersMiddlewareName,
...additionalMiddlewares ...additionalMiddlewares
]; ];
@@ -1647,8 +1652,7 @@ export async function getTraefikConfig(
config_output.http.services[serviceName] = { config_output.http.services[serviceName] = {
loadBalancer: { loadBalancer: {
servers: [{ url: `${aiGatewayUrl}/chat/completions` }], servers: [{ url: aiGatewayUrl }]
passHostHeader: true
} }
}; };
} }
@@ -1701,8 +1705,22 @@ export async function getTraefikConfig(
} }
} }
const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
config_output.http.middlewares[srHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": alias
}
}
};
const additionalMiddlewares = const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
srHeadersMiddlewareName,
...additionalMiddlewares
];
if (sr.ssl) { if (sr.ssl) {
config_output.http.routers[routerName + "-redirect"] = { config_output.http.routers[routerName + "-redirect"] = {
@@ -1722,7 +1740,7 @@ export async function getTraefikConfig(
? config.getRawConfig().traefik.https_entrypoint ? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint : config.getRawConfig().traefik.http_entrypoint
], ],
middlewares: additionalMiddlewares, middlewares: routerMiddlewares,
service: serviceName, service: serviceName,
rule, rule,
priority: 100, priority: 100,
@@ -1731,8 +1749,7 @@ export async function getTraefikConfig(
config_output.http.services[serviceName] = { config_output.http.services[serviceName] = {
loadBalancer: { loadBalancer: {
servers: [{ url: `${aiGatewayUrl}/chat/completions` }], servers: [{ url: aiGatewayUrl }]
passHostHeader: true
} }
}; };
} }
+7 -1
View File
@@ -416,13 +416,19 @@ export async function chatCompletions(
res: Response res: Response
): Promise<any> { ): Promise<any> {
try { try {
const host = (req.headers.host || "").split(":")[0]; const host = (
(req.headers["p-host"] as string | undefined) ||
req.headers.host ||
""
).split(":")[0];
if (!host) { if (!host) {
return res return res
.status(HttpCode.BAD_REQUEST) .status(HttpCode.BAD_REQUEST)
.json({ error: { message: "Missing Host header" } }); .json({ error: { message: "Missing Host header" } });
} }
logger.info(`++++++++++++++AI gateway request for host: ${host}`);
const target = await resolveTarget(host); const target = await resolveTarget(host);
if (!target) { if (!target) {
return res.status(HttpCode.NOT_FOUND).json({ return res.status(HttpCode.NOT_FOUND).json({
-7
View File
@@ -3,7 +3,6 @@ import * as gerbil from "@server/routers/gerbil";
import * as traefik from "@server/routers/traefik"; import * as traefik from "@server/routers/traefik";
import * as resource from "./resource"; import * as resource from "./resource";
import * as badger from "./badger"; import * as badger from "./badger";
import * as aiGateway from "@server/routers/aiGateway";
import * as auth from "@server/routers/auth"; import * as auth from "@server/routers/auth";
import * as supporterKey from "@server/routers/supporterKey"; import * as supporterKey from "@server/routers/supporterKey";
import * as idp from "@server/routers/idp"; import * as idp from "@server/routers/idp";
@@ -65,9 +64,3 @@ badgerRouter.post("/verify-session", badger.verifyResourceSession);
badgerRouter.post("/exchange-session", badger.exchangeSession); 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
);
@@ -20,9 +20,11 @@ export async function traefikConfigProvider(
const maintenancePort = config.getRawConfig().server.next_port; const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost = config.getRawConfig().server.internal_hostname; const maintenanceHost = config.getRawConfig().server.internal_hostname;
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`; const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
const aiGatewayUrl = `http://${maintenanceHost}:${ const aiGatewayUrl =
config.getRawConfig().server.internal_port config.getRawConfig().server.ai_gateway_override ||
}/api/v1/ai-gateway`; `http://${maintenanceHost}:${
config.getRawConfig().server.ai_gateway_port
}`;
const traefikConfig = await getTraefikConfig( const traefikConfig = await getTraefikConfig(
currentExitNodeId, currentExitNodeId,