support allow list and blocklist

This commit is contained in:
miloschwartz
2026-08-07 10:59:35 -04:00
parent 07f628b928
commit 9e7b4afaec
34 changed files with 891 additions and 470 deletions
@@ -9,12 +9,14 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import {
assertSiteAllowlistApiEligible,
assertModelsBelongToSiteAllowlistProviders
assertSiteModelListApiEligible,
assertSiteResourceModelEntriesValid,
modelListTypeSchema
} from "@server/lib/aiInferenceResource";
const addAiModelToSiteResourceBodySchema = z.strictObject({
modelId: z.int().positive()
modelId: z.number().int().positive(),
listType: modelListTypeSchema.optional().default("allow")
});
const addAiModelToSiteResourceParamsSchema = z.strictObject({
@@ -25,7 +27,7 @@ registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-models/add",
description:
"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.",
"Add a single model to an inference site resource allow/block selection. Requires at least one attached AI provider in select mode. The model must belong to a select-mode provider and its listType must match the provider catalog entry. listType defaults to allow.",
tags: [OpenAPITags.PrivateResource],
request: {
params: addAiModelToSiteResourceParamsSchema,
@@ -73,7 +75,7 @@ export async function addAiModelToSiteResource(
);
}
const { modelId } = parsedBody.data;
const { modelId, listType } = parsedBody.data;
const parsedParams = addAiModelToSiteResourceParamsSchema.safeParse(
req.params
@@ -102,15 +104,15 @@ export async function addAiModelToSiteResource(
}
const eligibleError =
await assertSiteAllowlistApiEligible(siteResource);
await assertSiteModelListApiEligible(siteResource);
if (eligibleError) {
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
}
const modelError = await assertModelsBelongToSiteAllowlistProviders({
const modelError = await assertSiteResourceModelEntriesValid({
orgId: siteResource.orgId,
siteResourceId,
modelIds: [modelId]
models: [{ modelId, listType }]
});
if (modelError) {
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
@@ -135,9 +137,11 @@ export async function addAiModelToSiteResource(
);
}
await db
.insert(siteResourceAiModels)
.values({ siteResourceId, modelId });
await db.insert(siteResourceAiModels).values({
siteResourceId,
modelId,
listType
});
return response(res, {
data: {},
@@ -11,14 +11,12 @@ 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()
providerId: z.number().int().positive()
});
const addAiProviderToSiteResourceParamsSchema = z.strictObject({
@@ -29,7 +27,7 @@ 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.",
"Add or replace a single AI provider attachment on an inference site resource. The provider is attached in inherit mode, using its own allow/block lists.",
tags: [OpenAPITags.PrivateResource],
request: {
params: addAiProviderToSiteResourceParamsSchema,
@@ -77,7 +75,7 @@ export async function addAiProviderToSiteResource(
);
}
const { providerId, modelAccessMode } = parsedBody.data;
const { providerId } = parsedBody.data;
const parsedParams = addAiProviderToSiteResourceParamsSchema.safeParse(
req.params
@@ -120,18 +118,21 @@ export async function addAiProviderToSiteResource(
.filter((a) => a.providerId !== providerId)
.map((a) => ({
providerId: a.providerId,
modelAccessMode: a.modelAccessMode
accessMode: a.accessMode
})),
{ providerId, modelAccessMode }
{ providerId, accessMode: "inherit" as const }
];
const attachments = await resolveProviderAttachments({
orgId: siteResource.orgId,
attachments: nextAttachments,
requireAtLeastOne: true
requireAtLeastOne: true,
siteResourceId
});
if (isInferenceFieldsError(attachments)) {
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
return next(
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
);
}
await setSiteResourceAiProviders(siteResourceId, attachments);
@@ -90,7 +90,7 @@ const createSiteResourceSchema = z
.array(resourceAiProviderAttachmentSchema)
.optional()
.describe(
"For inference-mode site resources: AI providers to attach. Each entry may set modelAccessMode (catalog or allowlist); defaults to catalog. Model keys must be unique across attached catalog providers."
"For inference-mode site resources: AI providers to attach. Providers are attached in inherit mode, using each provider's own allow/block lists. Effective allow model keys must be unique across attached providers."
)
})
.strict()
@@ -350,9 +350,14 @@ export async function createSiteResource(
let providerAttachments: ResourceAiProviderAttachment[] = [];
if (mode === "inference") {
// A new site resource has no model selections yet, so providers
// always start in inherit mode; select can be enabled afterwards.
const resolved = await resolveProviderAttachments({
orgId,
attachments: aiProviderInputs ?? [],
attachments: (aiProviderInputs ?? []).map((p) => ({
providerId: p.providerId,
accessMode: "inherit" as const
})),
requireAtLeastOne: false
});
if (isInferenceFieldsError(resolved)) {
@@ -19,7 +19,9 @@ async function query(siteResourceId: number) {
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
enabled: aiModels.enabled
providerId: aiModels.providerId,
enabled: aiModels.enabled,
listType: siteResourceAiModels.listType
})
.from(siteResourceAiModels)
.innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId))
@@ -34,7 +36,7 @@ registry.registerPath({
method: "get",
path: "/site-resource/{siteResourceId}/ai-models",
description:
"List catalog models on this site resource's allowlist. Only enforced when modelAccessMode=allowlist; an empty allowlist denies all models.",
"List the models this site resource has selected from its select-mode providers' allow/block lists. Providers in inherit mode are not represented here; they use their own lists.",
tags: [OpenAPITags.PrivateResource],
request: {
params: listSiteResourceAiModelsParamsSchema
@@ -21,7 +21,8 @@ export type ListSiteResourceAiProvidersResponse = {
registry.registerPath({
method: "get",
path: "/site-resource/{siteResourceId}/ai-providers",
description: "List AI providers attached to an inference site resource.",
description:
"List AI providers attached to an inference site resource, including each attachment's accessMode.",
tags: [OpenAPITags.PrivateResource],
request: {
params: listSiteResourceAiProvidersParamsSchema
@@ -8,7 +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";
import { assertSiteModelListApiEligible } from "@server/lib/aiInferenceResource";
const removeAiModelFromSiteResourceBodySchema = z.strictObject({
modelId: z.int().positive()
@@ -22,7 +22,7 @@ registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-models/remove",
description:
"Remove a single catalog model from an inference site resource allowlist. Requires at least one attached AI provider in allowlist mode.",
"Remove a single model from an inference site resource allow/block list. Requires at least one attached AI provider.",
tags: [OpenAPITags.PrivateResource],
request: {
params: removeAiModelFromSiteResourceParamsSchema,
@@ -98,7 +98,7 @@ export async function removeAiModelFromSiteResource(
}
const eligibleError =
await assertSiteAllowlistApiEligible(siteResource);
await assertSiteModelListApiEligible(siteResource);
if (eligibleError) {
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
}
@@ -126,13 +126,14 @@ export async function removeAiProviderFromSiteResource(
.filter((a) => a.providerId !== providerId)
.map((a) => ({
providerId: a.providerId,
modelAccessMode: a.modelAccessMode
accessMode: a.accessMode
}));
const attachments = await resolveProviderAttachments({
orgId: siteResource.orgId,
attachments: remaining,
requireAtLeastOne: false
requireAtLeastOne: false,
siteResourceId
});
if (isInferenceFieldsError(attachments)) {
return next(
@@ -9,12 +9,13 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import {
assertSiteAllowlistApiEligible,
assertModelsBelongToSiteAllowlistProviders
assertSiteModelListApiEligible,
assertSiteResourceModelEntriesValid,
resourceAiModelEntrySchema
} from "@server/lib/aiInferenceResource";
const setSiteResourceAiModelsBodySchema = z.strictObject({
modelIds: z.array(z.int().positive())
models: z.array(resourceAiModelEntrySchema)
});
const setSiteResourceAiModelsParamsSchema = z.strictObject({
@@ -25,7 +26,7 @@ registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-models",
description:
"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.",
"Replace the allow/block model selection for an inference site resource. Requires at least one attached AI provider in select mode. Models must belong to a select-mode provider and their listType must match the provider catalog entry. An empty array clears the selection, which denies all models for select-mode providers.",
tags: [OpenAPITags.PrivateResource],
request: {
params: setSiteResourceAiModelsParamsSchema,
@@ -73,7 +74,7 @@ export async function setSiteResourceAiModels(
);
}
const { modelIds } = parsedBody.data;
const { models } = parsedBody.data;
const parsedParams = setSiteResourceAiModelsParamsSchema.safeParse(
req.params
@@ -102,15 +103,22 @@ export async function setSiteResourceAiModels(
}
const eligibleError =
await assertSiteAllowlistApiEligible(siteResource);
await assertSiteModelListApiEligible(siteResource);
if (eligibleError) {
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
}
const modelError = await assertModelsBelongToSiteAllowlistProviders({
const byModelId = new Map(
models.map((m) => [m.modelId, m.listType] as const)
);
const uniqueModels = [...byModelId.entries()].map(
([modelId, listType]) => ({ modelId, listType })
);
const modelError = await assertSiteResourceModelEntriesValid({
orgId: siteResource.orgId,
siteResourceId,
modelIds
models: uniqueModels
});
if (modelError) {
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
@@ -121,11 +129,12 @@ export async function setSiteResourceAiModels(
.delete(siteResourceAiModels)
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
if (modelIds.length > 0) {
if (uniqueModels.length > 0) {
await trx.insert(siteResourceAiModels).values(
modelIds.map((modelId) => ({
uniqueModels.map((m) => ({
siteResourceId,
modelId
modelId: m.modelId,
listType: m.listType
}))
);
}
@@ -27,7 +27,7 @@ registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-providers",
description:
"Replace the AI providers attached to an inference site resource. An empty list clears all providers. Model keys must be unique across attached catalog providers.",
"Replace the AI providers attached to an inference site resource. Each provider uses accessMode inherit (default, uses the provider's own allow/block lists) or select (uses the site resource's selected subset of that provider's catalog). An empty list clears all providers. Effective allow model keys must be unique across attached providers.",
tags: [OpenAPITags.PrivateResource],
request: {
params: setSiteResourceAiProvidersParamsSchema,
@@ -115,7 +115,8 @@ export async function setSiteResourceAiProviders(
const attachments = await resolveProviderAttachments({
orgId: siteResource.orgId,
attachments: providers,
requireAtLeastOne: false
requireAtLeastOne: false,
siteResourceId
});
if (isInferenceFieldsError(attachments)) {
return next(