add crud for adding providers and models to resources

This commit is contained in:
miloschwartz
2026-08-04 15:54:24 -04:00
parent ed8545f8a2
commit e38359c74f
39 changed files with 2343 additions and 438 deletions
@@ -1,11 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
siteResources,
siteResourceAiModels,
aiModels
} from "@server/db";
import { db, siteResources, siteResourceAiModels } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -13,6 +8,10 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import {
assertSiteAllowlistApiEligible,
assertModelsBelongToSiteAllowlistProviders
} from "@server/lib/aiInferenceResource";
const addAiModelToSiteResourceBodySchema = z.strictObject({
modelId: z.int().positive()
@@ -26,7 +25,7 @@ registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-models/add",
description:
"Add a single AI model to a site resource's model restriction allow-list.",
"Add a single catalog model to an inference site resource allowlist. Requires at least one attached AI provider in allowlist mode. The model must belong to a provider attached in allowlist mode.",
tags: [OpenAPITags.PrivateResource],
request: {
params: addAiModelToSiteResourceParamsSchema,
@@ -102,33 +101,19 @@ export async function addAiModelToSiteResource(
);
}
if (!siteResource.aiProviderId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Site resource has no AI provider linked"
)
);
const eligibleError =
await assertSiteAllowlistApiEligible(siteResource);
if (eligibleError) {
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
}
const [model] = await db
.select()
.from(aiModels)
.where(
and(
eq(aiModels.modelId, modelId),
eq(aiModels.providerId, siteResource.aiProviderId)
)
)
.limit(1);
if (!model) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Model not found or does not belong to this site resource's AI provider"
)
);
const modelError = await assertModelsBelongToSiteAllowlistProviders({
orgId: siteResource.orgId,
siteResourceId,
modelIds: [modelId]
});
if (modelError) {
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
}
const existingEntry = await db
@@ -0,0 +1,152 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, siteResources } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import {
isInferenceFieldsError,
listSiteResourceAiProviders,
modelAccessModeSchema,
resolveProviderAttachments,
setSiteResourceAiProviders
} from "@server/lib/aiInferenceResource";
const addAiProviderToSiteResourceBodySchema = z.strictObject({
providerId: z.number().int().positive(),
modelAccessMode: modelAccessModeSchema.optional()
});
const addAiProviderToSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-providers/add",
description:
"Add or replace a single AI provider attachment on an inference site resource.",
tags: [OpenAPITags.PrivateResource],
request: {
params: addAiProviderToSiteResourceParamsSchema,
body: {
content: {
"application/json": {
schema: addAiProviderToSiteResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function addAiProviderToSiteResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = addAiProviderToSiteResourceBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { providerId, modelAccessMode } = parsedBody.data;
const parsedParams = addAiProviderToSiteResourceParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteResourceId } = parsedParams.data;
const [siteResource] = await db
.select()
.from(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.limit(1);
if (!siteResource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Site resource not found")
);
}
if (siteResource.mode !== "inference") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI providers can only be attached to inference-mode resources"
)
);
}
const existing = await listSiteResourceAiProviders(siteResourceId);
const nextAttachments = [
...existing
.filter((a) => a.providerId !== providerId)
.map((a) => ({
providerId: a.providerId,
modelAccessMode: a.modelAccessMode
})),
{ providerId, modelAccessMode }
];
const attachments = await resolveProviderAttachments({
orgId: siteResource.orgId,
attachments: nextAttachments,
requireAtLeastOne: true
});
if (isInferenceFieldsError(attachments)) {
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
}
await setSiteResourceAiProviders(siteResourceId, attachments);
return response(res, {
data: {},
success: true,
error: false,
message: "AI provider added to site resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -39,6 +39,13 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica
import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService";
import { LimitId } from "@server/lib/billing";
import {
isInferenceFieldsError,
resolveProviderAttachments,
resourceAiProviderAttachmentSchema,
setSiteResourceAiProviders,
type ResourceAiProviderAttachment
} from "@server/lib/aiInferenceResource";
const createSiteResourceParamsSchema = z.strictObject({
orgId: z.string()
@@ -79,13 +86,11 @@ const createSiteResourceSchema = z
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
aiProviderId: z
.number()
.int()
.positive()
aiProviders: z
.array(resourceAiProviderAttachmentSchema)
.optional()
.describe(
"For inference-mode site resources: the AI provider this resource proxies chat completions to."
"For inference-mode site resources: AI providers to attach. Each entry may set modelAccessMode (passthrough, catalog, or allowlist); defaults to passthrough. At most one passthrough provider is allowed."
)
})
.strict()
@@ -334,7 +339,7 @@ export async function createSiteResource(
pamMode,
domainId,
subdomain,
aiProviderId
aiProviders: aiProviderInputs
} = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
@@ -343,6 +348,28 @@ export async function createSiteResource(
siteIds.push(siteId);
}
let providerAttachments: ResourceAiProviderAttachment[] = [];
if (mode === "inference") {
const resolved = await resolveProviderAttachments({
orgId,
attachments: aiProviderInputs ?? [],
requireAtLeastOne: true
});
if (isInferenceFieldsError(resolved)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, resolved.error)
);
}
providerAttachments = resolved;
} else if (aiProviderInputs && aiProviderInputs.length > 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI providers can only be attached to inference-mode resources"
)
);
}
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
@@ -608,8 +635,7 @@ export async function createSiteResource(
domainId,
subdomain: finalSubdomain,
fullDomain,
requiresExitNodeConnection: mode === "inference", // in the future we might want to have different modes that do this
aiProviderId: aiProviderId ?? null
requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this
};
if (isLicensedSshPam) {
if (authDaemonPort !== undefined)
@@ -625,6 +651,14 @@ export async function createSiteResource(
const siteResourceId = newSiteResource.siteResourceId;
if (providerAttachments.length > 0) {
await setSiteResourceAiProviders(
siteResourceId,
providerAttachments,
trx
);
}
//////////////////// update the associations ////////////////////
if (network) {
+4
View File
@@ -21,3 +21,7 @@ export * from "./listSiteResourceAiModels";
export * from "./setSiteResourceAiModels";
export * from "./addAiModelToSiteResource";
export * from "./removeAiModelFromSiteResource";
export * from "./listSiteResourceAiProviders";
export * from "./setSiteResourceAiProviders";
export * from "./addAiProviderToSiteResource";
export * from "./removeAiProviderFromSiteResource";
@@ -1,11 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
siteResources,
siteResourceAiModels,
aiModels
} from "@server/db";
import { db, siteResources, siteResourceAiModels, aiModels } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -27,10 +22,7 @@ async function query(siteResourceId: number) {
enabled: aiModels.enabled
})
.from(siteResourceAiModels)
.innerJoin(
aiModels,
eq(siteResourceAiModels.modelId, aiModels.modelId)
)
.innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId))
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
}
@@ -42,7 +34,7 @@ registry.registerPath({
method: "get",
path: "/site-resource/{siteResourceId}/ai-models",
description:
"List the AI models a site resource is restricted to. An empty list means the site resource is not restricted and every enabled model on its linked AI provider is allowed.",
"List catalog models on this site resource's allowlist. Only enforced when modelAccessMode=allowlist; an empty allowlist denies all models.",
tags: [OpenAPITags.PrivateResource],
request: {
params: listSiteResourceAiModelsParamsSchema
@@ -0,0 +1,94 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, siteResources } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { listSiteResourceAiProviders as listAttachments } from "@server/lib/aiInferenceResource";
const listSiteResourceAiProvidersParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
});
export type ListSiteResourceAiProvidersResponse = {
providers: Awaited<ReturnType<typeof listAttachments>>;
};
registry.registerPath({
method: "get",
path: "/site-resource/{siteResourceId}/ai-providers",
description: "List AI providers attached to an inference site resource.",
tags: [OpenAPITags.PrivateResource],
request: {
params: listSiteResourceAiProvidersParamsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function listSiteResourceAiProviders(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = listSiteResourceAiProvidersParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteResourceId } = parsedParams.data;
const [siteResource] = await db
.select()
.from(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.limit(1);
if (!siteResource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Site resource not found")
);
}
const providers = await listAttachments(siteResourceId);
return response<ListSiteResourceAiProvidersResponse>(res, {
data: { providers },
success: true,
error: false,
message: "Site resource AI providers retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { assertSiteAllowlistApiEligible } from "@server/lib/aiInferenceResource";
const removeAiModelFromSiteResourceBodySchema = z.strictObject({
modelId: z.int().positive()
@@ -21,7 +22,7 @@ registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-models/remove",
description:
"Remove a single AI model from a site resource's model restriction allow-list.",
"Remove a single catalog model from an inference site resource allowlist. Requires at least one attached AI provider in allowlist mode.",
tags: [OpenAPITags.PrivateResource],
request: {
params: removeAiModelFromSiteResourceParamsSchema,
@@ -96,6 +97,12 @@ export async function removeAiModelFromSiteResource(
);
}
const eligibleError =
await assertSiteAllowlistApiEligible(siteResource);
if (eligibleError) {
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
}
const existingEntry = await db
.select()
.from(siteResourceAiModels)
@@ -0,0 +1,164 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, siteResources } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import {
isInferenceFieldsError,
listSiteResourceAiProviders,
resolveProviderAttachments,
setSiteResourceAiProviders
} from "@server/lib/aiInferenceResource";
const removeAiProviderFromSiteResourceBodySchema = z.strictObject({
providerId: z.number().int().positive()
});
const removeAiProviderFromSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-providers/remove",
description:
"Remove an AI provider attachment from an inference site resource. At least one provider must remain.",
tags: [OpenAPITags.PrivateResource],
request: {
params: removeAiProviderFromSiteResourceParamsSchema,
body: {
content: {
"application/json": {
schema: removeAiProviderFromSiteResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function removeAiProviderFromSiteResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody =
removeAiProviderFromSiteResourceBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { providerId } = parsedBody.data;
const parsedParams =
removeAiProviderFromSiteResourceParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteResourceId } = parsedParams.data;
const [siteResource] = await db
.select()
.from(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.limit(1);
if (!siteResource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Site resource not found")
);
}
if (siteResource.mode !== "inference") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI providers can only be attached to inference-mode resources"
)
);
}
const existing = await listSiteResourceAiProviders(siteResourceId);
const found = existing.find((a) => a.providerId === providerId);
if (!found) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"AI provider is not attached to this site resource"
)
);
}
const remaining = existing
.filter((a) => a.providerId !== providerId)
.map((a) => ({
providerId: a.providerId,
modelAccessMode: a.modelAccessMode
}));
if (remaining.length === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one AI provider is required for inference-mode resources"
)
);
}
const attachments = await resolveProviderAttachments({
orgId: siteResource.orgId,
attachments: remaining,
requireAtLeastOne: true
});
if (isInferenceFieldsError(attachments)) {
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
}
await setSiteResourceAiProviders(siteResourceId, attachments);
return response(res, {
data: {},
success: true,
error: false,
message: "AI provider removed from site resource successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -1,18 +1,17 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
siteResources,
siteResourceAiModels,
aiModels
} from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import { db, siteResources, siteResourceAiModels } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import {
assertSiteAllowlistApiEligible,
assertModelsBelongToSiteAllowlistProviders
} from "@server/lib/aiInferenceResource";
const setSiteResourceAiModelsBodySchema = z.strictObject({
modelIds: z.array(z.int().positive())
@@ -26,7 +25,7 @@ registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-models",
description:
"Set the AI models a site resource is restricted to. This replaces all existing restrictions. Pass an empty array to remove the restriction (allow every enabled model on the linked provider).",
"Replace the allowlist of catalog models for an inference site resource. Requires at least one attached AI provider in allowlist mode. Models must belong to a provider attached in allowlist mode. An empty array denies all models.",
tags: [OpenAPITags.PrivateResource],
request: {
params: setSiteResourceAiModelsParamsSchema,
@@ -102,52 +101,33 @@ export async function setSiteResourceAiModels(
);
}
if (modelIds.length > 0) {
if (!siteResource.aiProviderId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Site resource has no AI provider linked"
)
);
}
const eligibleError =
await assertSiteAllowlistApiEligible(siteResource);
if (eligibleError) {
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
}
const validModels = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(
and(
inArray(aiModels.modelId, modelIds),
eq(aiModels.providerId, siteResource.aiProviderId)
)
);
if (validModels.length !== new Set(modelIds).size) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"One or more model IDs do not exist or do not belong to this site resource's AI provider"
)
);
}
const modelError = await assertModelsBelongToSiteAllowlistProviders({
orgId: siteResource.orgId,
siteResourceId,
modelIds
});
if (modelError) {
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
}
await db.transaction(async (trx) => {
await trx
.delete(siteResourceAiModels)
.where(
eq(siteResourceAiModels.siteResourceId, siteResourceId)
);
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
if (modelIds.length > 0) {
await trx
.insert(siteResourceAiModels)
.values(
modelIds.map((modelId) => ({
siteResourceId,
modelId
}))
);
await trx.insert(siteResourceAiModels).values(
modelIds.map((modelId) => ({
siteResourceId,
modelId
}))
);
}
});
@@ -0,0 +1,139 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, siteResources } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import {
isInferenceFieldsError,
resolveProviderAttachments,
resourceAiProviderAttachmentSchema,
setSiteResourceAiProviders as replaceAttachments
} from "@server/lib/aiInferenceResource";
const setSiteResourceAiProvidersBodySchema = z.strictObject({
providers: z.array(resourceAiProviderAttachmentSchema)
});
const setSiteResourceAiProvidersParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-providers",
description:
"Replace the AI providers attached to an inference site resource. At least one provider is required. At most one may use passthrough mode.",
tags: [OpenAPITags.PrivateResource],
request: {
params: setSiteResourceAiProvidersParamsSchema,
body: {
content: {
"application/json": {
schema: setSiteResourceAiProvidersBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function setSiteResourceAiProviders(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = setSiteResourceAiProvidersBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { providers } = parsedBody.data;
const parsedParams = setSiteResourceAiProvidersParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteResourceId } = parsedParams.data;
const [siteResource] = await db
.select()
.from(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.limit(1);
if (!siteResource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Site resource not found")
);
}
if (siteResource.mode !== "inference") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI providers can only be attached to inference-mode resources"
)
);
}
const attachments = await resolveProviderAttachments({
orgId: siteResource.orgId,
attachments: providers,
requireAtLeastOne: true
});
if (isInferenceFieldsError(attachments)) {
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
}
await replaceAttachments(siteResourceId, attachments);
return response(res, {
data: {},
success: true,
error: false,
message: "AI providers set for site resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -29,6 +29,7 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { clearSiteResourceAiConfig } from "@server/lib/aiInferenceResource";
const updateSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
@@ -78,16 +79,7 @@ const updateSiteResourceSchema = z
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
pamMode: z.enum(["passthrough", "push"]).optional(),
domainId: 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."
)
subdomain: z.string().optional()
})
.strict()
.refine(
@@ -341,8 +333,7 @@ export async function updateSiteResource(
authDaemonMode,
pamMode,
domainId,
subdomain,
aiProviderId
subdomain
} = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
@@ -608,12 +599,19 @@ export async function updateSiteResource(
networkId: mode === "inference" ? null : undefined,
requiresExitNodeConnection:
mode !== undefined ? mode === "inference" : undefined,
aiProviderId: aiProviderId,
...sshPamSet
})
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
.returning();
const effectiveMode = mode ?? existingSiteResource.mode;
if (
existingSiteResource.mode === "inference" &&
effectiveMode !== "inference"
) {
await clearSiteResourceAiConfig(siteResourceId, trx);
}
//////////////////// update the associations ////////////////////
if (mode === "inference") {