mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-05 20:21:19 +02:00
add crud for adding providers and models to resources
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user