mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-15 08:49:59 +02:00
Include inference support in blueprints
This commit is contained in:
@@ -0,0 +1,286 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
aiModels,
|
||||||
|
aiProviders,
|
||||||
|
resourceAiModels,
|
||||||
|
siteResourceAiModels,
|
||||||
|
Transaction
|
||||||
|
} from "@server/db";
|
||||||
|
import {
|
||||||
|
AccessMode,
|
||||||
|
ModelListType,
|
||||||
|
clearPublicResourceAiConfig,
|
||||||
|
clearSiteResourceAiConfig,
|
||||||
|
isInferenceFieldsError,
|
||||||
|
resolveProviderAttachments,
|
||||||
|
setPublicResourceAiProviders,
|
||||||
|
setSiteResourceAiProviders
|
||||||
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
|
export type BlueprintAiModelInput = {
|
||||||
|
model: string;
|
||||||
|
listType: ModelListType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlueprintAiProviderInput = {
|
||||||
|
provider: string;
|
||||||
|
accessMode: AccessMode;
|
||||||
|
enabled: boolean;
|
||||||
|
models: BlueprintAiModelInput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resolveProviderNiceIds(
|
||||||
|
orgId: string,
|
||||||
|
niceIds: string[],
|
||||||
|
trx: Transaction
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const unique = [...new Set(niceIds)];
|
||||||
|
if (unique.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await trx
|
||||||
|
.select({
|
||||||
|
providerId: aiProviders.providerId,
|
||||||
|
niceId: aiProviders.niceId
|
||||||
|
})
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiProviders.orgId, orgId),
|
||||||
|
inArray(aiProviders.niceId, unique)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const byNiceId = new Map(rows.map((r) => [r.niceId, r.providerId]));
|
||||||
|
const missing = unique.filter((id) => !byNiceId.has(id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`AI provider(s) not found in this org: ${missing.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return byNiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveModelKeys(
|
||||||
|
providers: BlueprintAiProviderInput[],
|
||||||
|
providerIdByNiceId: Map<string, number>,
|
||||||
|
trx: Transaction
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const providerIds = [
|
||||||
|
...new Set(
|
||||||
|
providers
|
||||||
|
.filter((p) => p.models.length > 0)
|
||||||
|
.map((p) => providerIdByNiceId.get(p.provider)!)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (providerIds.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await trx
|
||||||
|
.select({
|
||||||
|
modelId: aiModels.modelId,
|
||||||
|
modelKey: aiModels.modelKey,
|
||||||
|
providerId: aiModels.providerId
|
||||||
|
})
|
||||||
|
.from(aiModels)
|
||||||
|
.where(inArray(aiModels.providerId, providerIds));
|
||||||
|
|
||||||
|
const byProviderAndKey = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
byProviderAndKey.set(`${row.providerId}::${row.modelKey}`, row.modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelIdByEntryKey = new Map<string, number>();
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const provider of providers) {
|
||||||
|
const providerId = providerIdByNiceId.get(provider.provider)!;
|
||||||
|
for (const m of provider.models) {
|
||||||
|
const modelId = byProviderAndKey.get(`${providerId}::${m.model}`);
|
||||||
|
if (modelId === undefined) {
|
||||||
|
missing.push(`${provider.provider}/${m.model}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
modelIdByEntryKey.set(`${provider.provider}::${m.model}`, modelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`AI model(s) not found: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return modelIdByEntryKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateModelEntries(input: {
|
||||||
|
orgId: string;
|
||||||
|
entries: { modelId: number; listType: ModelListType }[];
|
||||||
|
selectProviderIds: number[];
|
||||||
|
trx: Transaction;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (input.entries.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.selectProviderIds.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Set at least one attached AI provider to access-mode 'select' before declaring models"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelIds = input.entries.map((e) => e.modelId);
|
||||||
|
const catalogRows = await input.trx
|
||||||
|
.select({
|
||||||
|
modelId: aiModels.modelId,
|
||||||
|
listType: aiModels.listType,
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
enabled: aiModels.enabled
|
||||||
|
})
|
||||||
|
.from(aiModels)
|
||||||
|
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(aiModels.modelId, modelIds),
|
||||||
|
inArray(aiModels.providerId, input.selectProviderIds),
|
||||||
|
eq(aiProviders.orgId, input.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const catalogById = new Map(catalogRows.map((row) => [row.modelId, row]));
|
||||||
|
for (const entry of input.entries) {
|
||||||
|
const catalog = catalogById.get(entry.modelId);
|
||||||
|
if (!catalog) {
|
||||||
|
throw new Error(
|
||||||
|
`Model ${entry.modelId} does not exist or does not belong to a select-mode attached provider`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (catalog.listType !== entry.listType) {
|
||||||
|
throw new Error(
|
||||||
|
`Model ${entry.modelId} must use list-type "${catalog.listType}" to match the provider catalog entry`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!catalog.enabled) {
|
||||||
|
throw new Error(`Model ${entry.modelId} is disabled on its provider`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncInferenceAiConfigInput = {
|
||||||
|
orgId: string;
|
||||||
|
trx: Transaction;
|
||||||
|
mode: string;
|
||||||
|
providers: BlueprintAiProviderInput[];
|
||||||
|
} & (
|
||||||
|
| { scope: "public"; resourceId: number }
|
||||||
|
| { scope: "site"; siteResourceId: number }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully declarative: makes the resource's attached AI providers/models match
|
||||||
|
* exactly what the blueprint declares (omitted providers/models are removed).
|
||||||
|
* Non-inference resources have any leftover AI config cleared.
|
||||||
|
*/
|
||||||
|
export async function syncInferenceAiConfig(
|
||||||
|
input: SyncInferenceAiConfigInput
|
||||||
|
): Promise<void> {
|
||||||
|
const { orgId, trx, mode } = input;
|
||||||
|
|
||||||
|
if (mode !== "inference") {
|
||||||
|
if (input.scope === "public") {
|
||||||
|
await clearPublicResourceAiConfig(input.resourceId, trx);
|
||||||
|
} else {
|
||||||
|
await clearSiteResourceAiConfig(input.siteResourceId, trx);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerIdByNiceId = await resolveProviderNiceIds(
|
||||||
|
orgId,
|
||||||
|
input.providers.map((p) => p.provider),
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedAttachments = await resolveProviderAttachments({
|
||||||
|
orgId,
|
||||||
|
attachments: input.providers.map((p) => ({
|
||||||
|
providerId: providerIdByNiceId.get(p.provider)!,
|
||||||
|
accessMode: p.accessMode,
|
||||||
|
enabled: p.enabled
|
||||||
|
})),
|
||||||
|
requireAtLeastOne: false
|
||||||
|
});
|
||||||
|
if (isInferenceFieldsError(resolvedAttachments)) {
|
||||||
|
throw new Error(resolvedAttachments.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.scope === "public") {
|
||||||
|
await setPublicResourceAiProviders(
|
||||||
|
input.resourceId,
|
||||||
|
resolvedAttachments,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await setSiteResourceAiProviders(
|
||||||
|
input.siteResourceId,
|
||||||
|
resolvedAttachments,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelIdByEntryKey = await resolveModelKeys(
|
||||||
|
input.providers,
|
||||||
|
providerIdByNiceId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
const modelEntries = input.providers.flatMap((p) =>
|
||||||
|
p.models.map((m) => ({
|
||||||
|
modelId: modelIdByEntryKey.get(`${p.provider}::${m.model}`)!,
|
||||||
|
listType: m.listType
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectProviderIds = resolvedAttachments
|
||||||
|
.filter((a) => a.accessMode === "select")
|
||||||
|
.map((a) => a.providerId);
|
||||||
|
|
||||||
|
await validateModelEntries({
|
||||||
|
orgId,
|
||||||
|
entries: modelEntries,
|
||||||
|
selectProviderIds,
|
||||||
|
trx
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.scope === "public") {
|
||||||
|
await trx
|
||||||
|
.delete(resourceAiModels)
|
||||||
|
.where(eq(resourceAiModels.resourceId, input.resourceId));
|
||||||
|
if (modelEntries.length > 0) {
|
||||||
|
await trx.insert(resourceAiModels).values(
|
||||||
|
modelEntries.map((m) => ({
|
||||||
|
resourceId: input.resourceId,
|
||||||
|
modelId: m.modelId,
|
||||||
|
listType: m.listType
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(siteResourceAiModels)
|
||||||
|
.where(
|
||||||
|
eq(siteResourceAiModels.siteResourceId, input.siteResourceId)
|
||||||
|
);
|
||||||
|
if (modelEntries.length > 0) {
|
||||||
|
await trx.insert(siteResourceAiModels).values(
|
||||||
|
modelEntries.map((m) => ({
|
||||||
|
siteResourceId: input.siteResourceId,
|
||||||
|
modelId: m.modelId,
|
||||||
|
listType: m.listType
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,11 +29,13 @@ import { tierMatrix } from "../billing/tierMatrix";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { LimitId } from "../billing";
|
import { LimitId } from "../billing";
|
||||||
import { usageService } from "../billing/usageService";
|
import { usageService } from "../billing/usageService";
|
||||||
|
import { syncInferenceAiConfig } from "./aiProviders";
|
||||||
|
|
||||||
async function getDomainForSiteResource(
|
async function getDomainForSiteResource(
|
||||||
siteResourceId: number | undefined,
|
siteResourceId: number | undefined,
|
||||||
fullDomain: string,
|
fullDomain: string,
|
||||||
orgId: string,
|
orgId: string,
|
||||||
|
isInference: boolean,
|
||||||
trx: Transaction
|
trx: Transaction
|
||||||
): Promise<{ subdomain: string | null; domainId: string }> {
|
): Promise<{ subdomain: string | null; domainId: string }> {
|
||||||
const [fullDomainExists] = await trx
|
const [fullDomainExists] = await trx
|
||||||
@@ -43,6 +45,9 @@ async function getDomainForSiteResource(
|
|||||||
and(
|
and(
|
||||||
eq(siteResources.fullDomain, fullDomain),
|
eq(siteResources.fullDomain, fullDomain),
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
|
// exclude looking at the ones on exit nodes if this is an inference resource,
|
||||||
|
// and vice versa, so inference and non-inference resources can share a full-domain
|
||||||
|
ne(siteResources.requiresExitNodeConnection, !isInference),
|
||||||
siteResourceId
|
siteResourceId
|
||||||
? ne(siteResources.siteResourceId, siteResourceId)
|
? ne(siteResources.siteResourceId, siteResourceId)
|
||||||
: isNotNull(siteResources.siteResourceId)
|
: isNotNull(siteResources.siteResourceId)
|
||||||
@@ -214,7 +219,7 @@ export async function updatePrivateResources(
|
|||||||
resourceStatusFromSite = siteSingle.status ?? "approved";
|
resourceStatusFromSite = siteSingle.status ?? "approved";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allSites.length === 0) {
|
if (resourceData.mode !== "inference" && allSites.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No valid sites found for private private resource ${resourceNiceId} in org ${orgId}`
|
`No valid sites found for private private resource ${resourceNiceId} in org ${orgId}`
|
||||||
);
|
);
|
||||||
@@ -231,11 +236,16 @@ export async function updatePrivateResources(
|
|||||||
let domainInfo:
|
let domainInfo:
|
||||||
| { subdomain: string | null; domainId: string }
|
| { subdomain: string | null; domainId: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (resourceData["full-domain"] && resourceData.mode === "http") {
|
if (
|
||||||
|
resourceData["full-domain"] &&
|
||||||
|
(resourceData.mode === "http" ||
|
||||||
|
resourceData.mode === "inference")
|
||||||
|
) {
|
||||||
domainInfo = await getDomainForSiteResource(
|
domainInfo = await getDomainForSiteResource(
|
||||||
existingResource.siteResourceId,
|
existingResource.siteResourceId,
|
||||||
resourceData["full-domain"],
|
resourceData["full-domain"],
|
||||||
orgId,
|
orgId,
|
||||||
|
resourceData.mode === "inference",
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -265,6 +275,8 @@ export async function updatePrivateResources(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isInference = resourceData.mode === "inference";
|
||||||
|
|
||||||
// Update existing resource
|
// Update existing resource
|
||||||
const [updatedResource] = await trx
|
const [updatedResource] = await trx
|
||||||
.update(siteResources)
|
.update(siteResources)
|
||||||
@@ -279,13 +291,15 @@ export async function updatePrivateResources(
|
|||||||
alias: resourceData.alias || null,
|
alias: resourceData.alias || null,
|
||||||
disableIcmp:
|
disableIcmp:
|
||||||
resourceData["disable-icmp"] ||
|
resourceData["disable-icmp"] ||
|
||||||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
|
(resourceData.mode == "http" || isInference
|
||||||
|
? true
|
||||||
|
: false), // default to true for http/inference resources, otherwise false
|
||||||
tcpPortRangeString:
|
tcpPortRangeString:
|
||||||
resourceData.mode == "http"
|
resourceData.mode == "http" || isInference
|
||||||
? "443,80"
|
? "443,80"
|
||||||
: resourceData["tcp-ports"],
|
: resourceData["tcp-ports"],
|
||||||
udpPortRangeString:
|
udpPortRangeString:
|
||||||
resourceData.mode == "http"
|
resourceData.mode == "http" || isInference
|
||||||
? ""
|
? ""
|
||||||
: resourceData["udp-ports"],
|
: resourceData["udp-ports"],
|
||||||
fullDomain: resourceData["full-domain"] || null,
|
fullDomain: resourceData["full-domain"] || null,
|
||||||
@@ -295,7 +309,9 @@ export async function updatePrivateResources(
|
|||||||
authDaemonMode:
|
authDaemonMode:
|
||||||
resourceData["auth-daemon"]?.mode || "native",
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||||
status: resourceStatusFromSite
|
status: resourceStatusFromSite,
|
||||||
|
networkId: isInference ? null : undefined,
|
||||||
|
requiresExitNodeConnection: isInference
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -307,7 +323,19 @@ export async function updatePrivateResources(
|
|||||||
|
|
||||||
const siteResourceId = existingResource.siteResourceId;
|
const siteResourceId = existingResource.siteResourceId;
|
||||||
|
|
||||||
if (updatedResource.networkId) {
|
if (isInference) {
|
||||||
|
// inference resources are not attached to any site network
|
||||||
|
if (existingResource.networkId) {
|
||||||
|
await trx
|
||||||
|
.delete(siteNetworks)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
siteNetworks.networkId,
|
||||||
|
existingResource.networkId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (updatedResource.networkId) {
|
||||||
await trx
|
await trx
|
||||||
.delete(siteNetworks)
|
.delete(siteNetworks)
|
||||||
.where(
|
.where(
|
||||||
@@ -322,6 +350,23 @@ export async function updatePrivateResources(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await syncInferenceAiConfig({
|
||||||
|
orgId,
|
||||||
|
trx,
|
||||||
|
mode: resourceData.mode,
|
||||||
|
scope: "site",
|
||||||
|
siteResourceId,
|
||||||
|
providers: resourceData["ai-providers"].map((p) => ({
|
||||||
|
provider: p.provider,
|
||||||
|
accessMode: p["access-mode"],
|
||||||
|
enabled: p.enabled,
|
||||||
|
models: p.models.map((m) => ({
|
||||||
|
model: m.model,
|
||||||
|
listType: m["list-type"]
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.delete(clientSiteResources)
|
.delete(clientSiteResources)
|
||||||
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
|
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
|
||||||
@@ -501,14 +546,20 @@ export async function updatePrivateResources(
|
|||||||
releaseAliasLock = release;
|
releaseAliasLock = release;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isInference = resourceData.mode === "inference";
|
||||||
|
|
||||||
let domainInfo:
|
let domainInfo:
|
||||||
| { subdomain: string | null; domainId: string }
|
| { subdomain: string | null; domainId: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (resourceData["full-domain"] && resourceData.mode === "http") {
|
if (
|
||||||
|
resourceData["full-domain"] &&
|
||||||
|
(resourceData.mode === "http" || isInference)
|
||||||
|
) {
|
||||||
domainInfo = await getDomainForSiteResource(
|
domainInfo = await getDomainForSiteResource(
|
||||||
undefined,
|
undefined,
|
||||||
resourceData["full-domain"],
|
resourceData["full-domain"],
|
||||||
orgId,
|
orgId,
|
||||||
|
isInference,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -534,13 +585,16 @@ export async function updatePrivateResources(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [network] = await trx
|
let network: typeof networks.$inferSelect | undefined;
|
||||||
.insert(networks)
|
if (!isInference) {
|
||||||
.values({
|
[network] = await trx
|
||||||
scope: "resource",
|
.insert(networks)
|
||||||
orgId: orgId
|
.values({
|
||||||
})
|
scope: "resource",
|
||||||
.returning();
|
orgId: orgId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
// Create new resource
|
// Create new resource
|
||||||
const [newResource] = await trx
|
const [newResource] = await trx
|
||||||
@@ -548,8 +602,8 @@ export async function updatePrivateResources(
|
|||||||
.values({
|
.values({
|
||||||
orgId: orgId,
|
orgId: orgId,
|
||||||
niceId: resourceNiceId,
|
niceId: resourceNiceId,
|
||||||
networkId: network.networkId,
|
networkId: network ? network.networkId : null,
|
||||||
defaultNetworkId: network.networkId,
|
defaultNetworkId: network ? network.networkId : null,
|
||||||
name: resourceData.name || resourceNiceId,
|
name: resourceData.name || resourceNiceId,
|
||||||
mode: resourceData.mode,
|
mode: resourceData.mode,
|
||||||
ssl: resourceData.ssl,
|
ssl: resourceData.ssl,
|
||||||
@@ -561,13 +615,15 @@ export async function updatePrivateResources(
|
|||||||
aliasAddress: aliasAddress,
|
aliasAddress: aliasAddress,
|
||||||
disableIcmp:
|
disableIcmp:
|
||||||
resourceData["disable-icmp"] ||
|
resourceData["disable-icmp"] ||
|
||||||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
|
(resourceData.mode == "http" || isInference
|
||||||
|
? true
|
||||||
|
: false), // default to true for http/inference resources, otherwise false
|
||||||
tcpPortRangeString:
|
tcpPortRangeString:
|
||||||
resourceData.mode == "http"
|
resourceData.mode == "http" || isInference
|
||||||
? "443,80"
|
? "443,80"
|
||||||
: resourceData["tcp-ports"],
|
: resourceData["tcp-ports"],
|
||||||
udpPortRangeString:
|
udpPortRangeString:
|
||||||
resourceData.mode == "http"
|
resourceData.mode == "http" || isInference
|
||||||
? ""
|
? ""
|
||||||
: resourceData["udp-ports"],
|
: resourceData["udp-ports"],
|
||||||
fullDomain: resourceData["full-domain"] || null,
|
fullDomain: resourceData["full-domain"] || null,
|
||||||
@@ -577,7 +633,8 @@ export async function updatePrivateResources(
|
|||||||
authDaemonMode:
|
authDaemonMode:
|
||||||
resourceData["auth-daemon"]?.mode || "native",
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||||
status: resourceStatusFromSite
|
status: resourceStatusFromSite,
|
||||||
|
requiresExitNodeConnection: isInference
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -585,13 +642,32 @@ export async function updatePrivateResources(
|
|||||||
|
|
||||||
const siteResourceId = newResource.siteResourceId;
|
const siteResourceId = newResource.siteResourceId;
|
||||||
|
|
||||||
for (const site of allSites) {
|
if (network) {
|
||||||
await trx.insert(siteNetworks).values({
|
for (const site of allSites) {
|
||||||
siteId: site.siteId,
|
await trx.insert(siteNetworks).values({
|
||||||
networkId: network.networkId
|
siteId: site.siteId,
|
||||||
});
|
networkId: network.networkId
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await syncInferenceAiConfig({
|
||||||
|
orgId,
|
||||||
|
trx,
|
||||||
|
mode: resourceData.mode,
|
||||||
|
scope: "site",
|
||||||
|
siteResourceId,
|
||||||
|
providers: resourceData["ai-providers"].map((p) => ({
|
||||||
|
provider: p.provider,
|
||||||
|
accessMode: p["access-mode"],
|
||||||
|
enabled: p.enabled,
|
||||||
|
models: p.models.map((m) => ({
|
||||||
|
model: m.model,
|
||||||
|
listType: m["list-type"]
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
const [adminRole] = await trx
|
const [adminRole] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import createHttpError from "http-errors";
|
|||||||
import next from "next";
|
import next from "next";
|
||||||
import { LimitId } from "../billing";
|
import { LimitId } from "../billing";
|
||||||
import { usageService } from "../billing/usageService";
|
import { usageService } from "../billing/usageService";
|
||||||
|
import { syncInferenceAiConfig } from "./aiProviders";
|
||||||
|
|
||||||
export type PublicResourcesResults = {
|
export type PublicResourcesResults = {
|
||||||
proxyResource: Resource;
|
proxyResource: Resource;
|
||||||
@@ -287,7 +288,7 @@ export async function updatePublicResources(
|
|||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
let domain;
|
let domain;
|
||||||
if (
|
if (
|
||||||
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
["http", "ssh", "rdp", "vnc", "inference"].includes(resourceData.mode || "")
|
||||||
) {
|
) {
|
||||||
if (resourceData["full-domain"]?.startsWith("*.")) {
|
if (resourceData["full-domain"]?.startsWith("*.")) {
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
@@ -371,12 +372,12 @@ export async function updatePublicResources(
|
|||||||
name: resourceData.name || "Unnamed Resource",
|
name: resourceData.name || "Unnamed Resource",
|
||||||
|
|
||||||
mode: resourceData.mode,
|
mode: resourceData.mode,
|
||||||
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
proxyPort: ["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||||
resourceData.mode || ""
|
resourceData.mode || ""
|
||||||
)
|
)
|
||||||
? null
|
? null
|
||||||
: resourceData["proxy-port"],
|
: resourceData["proxy-port"],
|
||||||
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
|
fullDomain: ["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||||
resourceData.mode || ""
|
resourceData.mode || ""
|
||||||
)
|
)
|
||||||
? resourceData["full-domain"]
|
? resourceData["full-domain"]
|
||||||
@@ -567,12 +568,13 @@ export async function updatePublicResources(
|
|||||||
.update(resources)
|
.update(resources)
|
||||||
.set({
|
.set({
|
||||||
name: resourceData.name || "Unnamed Resource",
|
name: resourceData.name || "Unnamed Resource",
|
||||||
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
mode: resourceData.mode,
|
||||||
|
proxyPort: ["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||||
resourceData.mode || ""
|
resourceData.mode || ""
|
||||||
)
|
)
|
||||||
? null
|
? null
|
||||||
: resourceData["proxy-port"],
|
: resourceData["proxy-port"],
|
||||||
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
|
fullDomain: ["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||||
resourceData.mode || ""
|
resourceData.mode || ""
|
||||||
)
|
)
|
||||||
? resourceData["full-domain"]
|
? resourceData["full-domain"]
|
||||||
@@ -674,6 +676,25 @@ export async function updatePublicResources(
|
|||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await syncInferenceAiConfig({
|
||||||
|
orgId,
|
||||||
|
trx,
|
||||||
|
mode: resourceData.mode || "",
|
||||||
|
scope: "public",
|
||||||
|
resourceId: existingResource.resourceId,
|
||||||
|
providers: (resourceData["ai-providers"] || []).map(
|
||||||
|
(p) => ({
|
||||||
|
provider: p.provider,
|
||||||
|
accessMode: p["access-mode"],
|
||||||
|
enabled: p.enabled,
|
||||||
|
models: p.models.map((m) => ({
|
||||||
|
model: m.model,
|
||||||
|
listType: m["list-type"]
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingResourceTargets = await trx
|
const existingResourceTargets = await trx
|
||||||
@@ -754,7 +775,7 @@ export async function updatePublicResources(
|
|||||||
: undefined),
|
: undefined),
|
||||||
rewritePathType: targetData["rewrite-match"],
|
rewritePathType: targetData["rewrite-match"],
|
||||||
priority: targetData.priority,
|
priority: targetData.priority,
|
||||||
mode: resourceData.mode
|
mode: resourceData.mode as Target["mode"]
|
||||||
})
|
})
|
||||||
.where(eq(targets.targetId, existingTarget.targetId))
|
.where(eq(targets.targetId, existingTarget.targetId))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -1059,7 +1080,7 @@ export async function updatePublicResources(
|
|||||||
|
|
||||||
let domain;
|
let domain;
|
||||||
if (
|
if (
|
||||||
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
["http", "ssh", "rdp", "vnc", "inference"].includes(resourceData.mode || "")
|
||||||
) {
|
) {
|
||||||
if (resourceData["full-domain"]?.startsWith("*.")) {
|
if (resourceData["full-domain"]?.startsWith("*.")) {
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
@@ -1156,12 +1177,12 @@ export async function updatePublicResources(
|
|||||||
status: resourceStatusFromSite,
|
status: resourceStatusFromSite,
|
||||||
name: resourceData.name || "Unnamed Resource",
|
name: resourceData.name || "Unnamed Resource",
|
||||||
mode: resourceData.mode,
|
mode: resourceData.mode,
|
||||||
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
proxyPort: ["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||||
resourceData.mode || ""
|
resourceData.mode || ""
|
||||||
)
|
)
|
||||||
? null
|
? null
|
||||||
: resourceData["proxy-port"],
|
: resourceData["proxy-port"],
|
||||||
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
|
fullDomain: ["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||||
resourceData.mode || ""
|
resourceData.mode || ""
|
||||||
)
|
)
|
||||||
? resourceData["full-domain"]
|
? resourceData["full-domain"]
|
||||||
@@ -1218,6 +1239,23 @@ export async function updatePublicResources(
|
|||||||
|
|
||||||
resource = newResource;
|
resource = newResource;
|
||||||
|
|
||||||
|
await syncInferenceAiConfig({
|
||||||
|
orgId,
|
||||||
|
trx,
|
||||||
|
mode: resourceData.mode || "",
|
||||||
|
scope: "public",
|
||||||
|
resourceId: newResource.resourceId,
|
||||||
|
providers: (resourceData["ai-providers"] || []).map((p) => ({
|
||||||
|
provider: p.provider,
|
||||||
|
accessMode: p["access-mode"],
|
||||||
|
enabled: p.enabled,
|
||||||
|
models: p.models.map((m) => ({
|
||||||
|
model: m.model,
|
||||||
|
listType: m["list-type"]
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
await trx.insert(roleResources).values({
|
await trx.insert(roleResources).values({
|
||||||
roleId: adminRole.roleId,
|
roleId: adminRole.roleId,
|
||||||
resourceId: newResource.resourceId
|
resourceId: newResource.resourceId
|
||||||
|
|||||||
@@ -183,6 +183,34 @@ export const HeaderSchema = z.object({
|
|||||||
value: z.string().min(1)
|
value: z.string().min(1)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const AiProviderModelEntrySchema = z.object({
|
||||||
|
model: z.string().min(1),
|
||||||
|
"list-type": z.enum(["allow", "block"])
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AiProviderAttachmentSchema = z
|
||||||
|
.object({
|
||||||
|
provider: z.string().min(1),
|
||||||
|
"access-mode": z
|
||||||
|
.enum(["inherit", "select"])
|
||||||
|
.optional()
|
||||||
|
.default("inherit"),
|
||||||
|
enabled: z.boolean().optional().default(true),
|
||||||
|
models: z.array(AiProviderModelEntrySchema).optional().default([])
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(provider) => {
|
||||||
|
if (provider.models.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return provider["access-mode"] === "select";
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["models"],
|
||||||
|
error: "'models' can only be set on a provider with access-mode 'select'"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const AuthDaemonSchema = z
|
export const AuthDaemonSchema = z
|
||||||
.object({
|
.object({
|
||||||
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
|
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
|
||||||
@@ -209,7 +237,9 @@ export const PublicResourceSchema = z
|
|||||||
protocol: z
|
protocol: z
|
||||||
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
|
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
|
||||||
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
|
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
|
||||||
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
|
mode: z
|
||||||
|
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc", "inference"])
|
||||||
|
.optional(),
|
||||||
policy: z.string().optional(),
|
policy: z.string().optional(),
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
scheme: z.enum(["http", "https"]).optional(),
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
@@ -226,7 +256,8 @@ export const PublicResourceSchema = z
|
|||||||
"auth-daemon": AuthDaemonSchema.optional(),
|
"auth-daemon": AuthDaemonSchema.optional(),
|
||||||
"proxy-protocol": z.boolean().optional(),
|
"proxy-protocol": z.boolean().optional(),
|
||||||
"proxy-protocol-version": z.int().min(1).optional(),
|
"proxy-protocol-version": z.int().min(1).optional(),
|
||||||
labels: z.array(z.string().min(1)).optional()
|
labels: z.array(z.string().min(1)).optional(),
|
||||||
|
"ai-providers": z.array(AiProviderAttachmentSchema).optional()
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(resource) => {
|
(resource) => {
|
||||||
@@ -315,11 +346,13 @@ export const PublicResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
|
// If protocol/mode is http, ssh, rdp, vnc, or inference, it must have a full-domain
|
||||||
const effectiveProtocol = resource.mode ?? resource.protocol;
|
const effectiveProtocol = resource.mode ?? resource.protocol;
|
||||||
if (
|
if (
|
||||||
effectiveProtocol !== undefined &&
|
effectiveProtocol !== undefined &&
|
||||||
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
|
["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||||
|
effectiveProtocol
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
resource["full-domain"] !== undefined &&
|
resource["full-domain"] !== undefined &&
|
||||||
@@ -330,7 +363,43 @@ export const PublicResourceSchema = z
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ["full-domain"],
|
path: ["full-domain"],
|
||||||
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
|
error: "When protocol is 'http', 'ssh', 'rdp', 'vnc', or 'inference', a 'full-domain' must be provided"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(resource) => {
|
||||||
|
if (isTargetsOnlyResource(resource)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveMode = resource.mode ?? resource.protocol;
|
||||||
|
if (effectiveMode !== "inference") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resource.targets.every((target) => target == null);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["targets"],
|
||||||
|
error: "When mode is 'inference', 'targets' must not be provided"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(resource) => {
|
||||||
|
if (isTargetsOnlyResource(resource)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveMode = resource.mode ?? resource.protocol;
|
||||||
|
if (effectiveMode === "inference") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (resource["ai-providers"]?.length ?? 0) === 0;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["ai-providers"],
|
||||||
|
error: "'ai-providers' can only be set when mode is 'inference'"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
@@ -464,7 +533,7 @@ export function isTargetsOnlyResource(resource: any): boolean {
|
|||||||
export const PrivateResourceSchema = z
|
export const PrivateResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
|
||||||
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
|
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
|
||||||
sites: z.array(z.string()).optional().default([]),
|
sites: z.array(z.string()).optional().default([]),
|
||||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||||
@@ -495,16 +564,17 @@ export const PrivateResourceSchema = z
|
|||||||
users: z.array(z.string()).optional().default([]),
|
users: z.array(z.string()).optional().default([]),
|
||||||
machines: z.array(z.string()).optional().default([]),
|
machines: z.array(z.string()).optional().default([]),
|
||||||
labels: z.array(z.string().min(1)).optional().default([]),
|
labels: z.array(z.string().min(1)).optional().default([]),
|
||||||
"auth-daemon": AuthDaemonSchema.optional()
|
"auth-daemon": AuthDaemonSchema.optional(),
|
||||||
|
"ai-providers": z.array(AiProviderAttachmentSchema).optional().default([])
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// destination is optional only for ssh+native; required for everything else
|
// destination is optional only for ssh+native or inference; required for everything else
|
||||||
const isNativeSSH =
|
const isNativeSSH =
|
||||||
data.mode === "ssh" &&
|
data.mode === "ssh" &&
|
||||||
(data["auth-daemon"] === undefined ||
|
(data["auth-daemon"] === undefined ||
|
||||||
data["auth-daemon"].mode === "native");
|
data["auth-daemon"].mode === "native");
|
||||||
if (!isNativeSSH && !data.destination) {
|
if (data.mode !== "inference" && !isNativeSSH && !data.destination) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -512,7 +582,19 @@ export const PrivateResourceSchema = z
|
|||||||
{
|
{
|
||||||
path: ["destination"],
|
path: ["destination"],
|
||||||
message:
|
message:
|
||||||
"destination is required unless mode is 'ssh' with auth-daemon mode 'native'"
|
"destination is required unless mode is 'ssh' with auth-daemon mode 'native', or mode is 'inference'"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.mode === "inference") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (data["ai-providers"]?.length ?? 0) === 0;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["ai-providers"],
|
||||||
|
error: "'ai-providers' can only be set when mode is 'inference'"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
|
|||||||
Reference in New Issue
Block a user