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
+115 -88
View File
@@ -38,10 +38,15 @@ import { isIpInCidr } from "@server/lib/ip";
import { localCache } from "@server/lib/cache";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import type { ModelAccessMode } from "@server/lib/aiInferenceResource";
import {
resolveEffectiveLists,
type AccessMode,
type ModelListType
} from "@server/lib/aiInferenceResource";
import {
compareModelKeySpecificity,
modelKeyMatches
isAllowedByLists,
mostSpecificMatchingAllow
} from "@server/lib/aiModelKeyMatch";
import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch";
@@ -96,7 +101,19 @@ async function findClientByIp(ip: string): Promise<CachedClient> {
type ProviderAttachment = {
provider: AiProvider;
modelAccessMode: ModelAccessMode;
accessMode: AccessMode;
};
type ResourceModelPattern = {
providerId: number;
modelKey: string;
listType: ModelListType;
enabled: boolean;
};
type ProviderPatternLists = {
allows: string[];
blocks: string[];
};
type ResolvedTarget = {
@@ -104,7 +121,7 @@ type ResolvedTarget = {
siteResourceId: number | null;
orgId: string | null;
attachments: ProviderAttachment[];
allowlistedModelIds: Set<number>;
resourceListsByProvider: Map<number, ProviderPatternLists>;
};
type ProviderSelection =
@@ -231,8 +248,8 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
if (resourceRow) {
const attachmentRows = await db
.select({
modelAccessMode: resourceAiProviders.modelAccessMode,
provider: aiProviders
provider: aiProviders,
accessMode: resourceAiProviders.accessMode
})
.from(resourceAiProviders)
.innerJoin(
@@ -252,38 +269,26 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({
provider: a.provider,
modelAccessMode: a.modelAccessMode as ModelAccessMode
accessMode: a.accessMode
}));
const allowlistProviderIds = attachments
.filter((a) => a.modelAccessMode === "allowlist")
.map((a) => a.provider.providerId);
const allowlistedModelIds = new Set<number>();
if (allowlistProviderIds.length > 0) {
const restrictions = await db
.select({ modelId: resourceAiModels.modelId })
.from(resourceAiModels)
.innerJoin(
aiModels,
eq(resourceAiModels.modelId, aiModels.modelId)
)
.where(
and(
eq(resourceAiModels.resourceId, resourceRow.resourceId),
inArray(aiModels.providerId, allowlistProviderIds)
)
);
for (const row of restrictions) {
allowlistedModelIds.add(row.modelId);
}
}
const resourcePatterns = await db
.select({
providerId: aiModels.providerId,
modelKey: aiModels.modelKey,
listType: resourceAiModels.listType,
enabled: aiModels.enabled
})
.from(resourceAiModels)
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
.where(eq(resourceAiModels.resourceId, resourceRow.resourceId));
return {
resourceId: resourceRow.resourceId,
siteResourceId: null,
orgId: resourceRow.orgId,
attachments,
allowlistedModelIds
resourceListsByProvider: groupPatternsByProvider(resourcePatterns)
};
}
@@ -305,8 +310,8 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
if (siteResourceRow) {
const attachmentRows = await db
.select({
modelAccessMode: siteResourceAiProviders.modelAccessMode,
provider: aiProviders
provider: aiProviders,
accessMode: siteResourceAiProviders.accessMode
})
.from(siteResourceAiProviders)
.innerJoin(
@@ -329,50 +334,65 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({
provider: a.provider,
modelAccessMode: a.modelAccessMode as ModelAccessMode
accessMode: a.accessMode
}));
const allowlistProviderIds = attachments
.filter((a) => a.modelAccessMode === "allowlist")
.map((a) => a.provider.providerId);
const allowlistedModelIds = new Set<number>();
if (allowlistProviderIds.length > 0) {
const restrictions = await db
.select({ modelId: siteResourceAiModels.modelId })
.from(siteResourceAiModels)
.innerJoin(
aiModels,
eq(siteResourceAiModels.modelId, aiModels.modelId)
const resourcePatterns = await db
.select({
providerId: aiModels.providerId,
modelKey: aiModels.modelKey,
listType: siteResourceAiModels.listType,
enabled: aiModels.enabled
})
.from(siteResourceAiModels)
.innerJoin(
aiModels,
eq(siteResourceAiModels.modelId, aiModels.modelId)
)
.where(
eq(
siteResourceAiModels.siteResourceId,
siteResourceRow.siteResourceId
)
.where(
and(
eq(
siteResourceAiModels.siteResourceId,
siteResourceRow.siteResourceId
),
inArray(aiModels.providerId, allowlistProviderIds)
)
);
for (const row of restrictions) {
allowlistedModelIds.add(row.modelId);
}
}
);
return {
resourceId: null,
siteResourceId: siteResourceRow.siteResourceId,
orgId: siteResourceRow.orgId,
attachments,
allowlistedModelIds
resourceListsByProvider: groupPatternsByProvider(resourcePatterns)
};
}
return null;
}
function groupPatternsByProvider(
patterns: ResourceModelPattern[]
): Map<number, ProviderPatternLists> {
const byProvider = new Map<number, ProviderPatternLists>();
for (const pattern of patterns) {
if (!pattern.enabled) {
continue;
}
let lists = byProvider.get(pattern.providerId);
if (!lists) {
lists = { allows: [], blocks: [] };
byProvider.set(pattern.providerId, lists);
}
if (pattern.listType === "allow") {
lists.allows.push(pattern.modelKey);
} else {
lists.blocks.push(pattern.modelKey);
}
}
return byProvider;
}
async function selectProvider(
attachments: ProviderAttachment[],
allowlistedModelIds: Set<number>,
resourceListsByProvider: Map<number, ProviderPatternLists>,
requestedModel: string | undefined
): Promise<ProviderSelection> {
if (!requestedModel) {
@@ -383,10 +403,10 @@ async function selectProvider(
};
}
const providerById = new Map(
const attachmentByProviderId = new Map(
attachments.map((a) => [a.provider.providerId, a])
);
const providerIds = [...providerById.keys()];
const providerIds = [...attachmentByProviderId.keys()];
if (providerIds.length === 0) {
return {
ok: false,
@@ -397,48 +417,54 @@ async function selectProvider(
const providerModels = await db
.select({
modelId: aiModels.modelId,
providerId: aiModels.providerId,
modelKey: aiModels.modelKey,
listType: aiModels.listType,
enabled: aiModels.enabled
})
.from(aiModels)
.where(inArray(aiModels.providerId, providerIds));
const allowsByProvider = new Map<number, string[]>();
const blocksByProvider = new Map<number, string[]>();
for (const model of providerModels) {
if (!model.enabled) {
continue;
}
const targetMap =
model.listType === "allow" ? allowsByProvider : blocksByProvider;
const existing = targetMap.get(model.providerId) ?? [];
existing.push(model.modelKey);
targetMap.set(model.providerId, existing);
}
type ModelCandidate = {
provider: AiProvider;
modelKey: string;
};
const candidates: ModelCandidate[] = [];
for (const model of providerModels) {
if (!model.enabled) {
for (const [providerId, attachment] of attachmentByProviderId) {
const resourceLists = resourceListsByProvider.get(providerId);
const { allows, blocks } = resolveEffectiveLists({
accessMode: attachment.accessMode,
providerAllows: allowsByProvider.get(providerId) ?? [],
providerBlocks: blocksByProvider.get(providerId) ?? [],
resourceAllows: resourceLists?.allows ?? [],
resourceBlocks: resourceLists?.blocks ?? []
});
if (!isAllowedByLists(requestedModel, allows, blocks)) {
continue;
}
if (!modelKeyMatches(model.modelKey, requestedModel)) {
const matchingAllow = mostSpecificMatchingAllow(requestedModel, allows);
if (!matchingAllow) {
continue;
}
const attachment = providerById.get(model.providerId);
if (!attachment) {
continue;
}
if (attachment.modelAccessMode === "catalog") {
candidates.push({
provider: attachment.provider,
modelKey: model.modelKey
});
continue;
}
if (allowlistedModelIds.has(model.modelId)) {
candidates.push({
provider: attachment.provider,
modelKey: model.modelKey
});
}
candidates.push({
provider: attachment.provider,
modelKey: matchingAllow
});
}
if (candidates.length === 0) {
@@ -504,7 +530,8 @@ export async function handleAiGatewayProxy(
});
}
const { attachments, allowlistedModelIds, resourceId, orgId } = target;
const { attachments, resourceListsByProvider, resourceId, orgId } =
target;
const requestUser = await resolveRequestUser(req, resourceId, orgId);
if (requestUser) {
@@ -529,7 +556,7 @@ export async function handleAiGatewayProxy(
const selection = await selectProvider(
capableAttachments,
allowlistedModelIds,
resourceListsByProvider,
requestedModel
);
if (!selection.ok) {
+5 -2
View File
@@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
import { modelListTypeSchema } from "@server/lib/aiInferenceResource";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
@@ -17,7 +18,8 @@ const paramsSchema = z.strictObject({
const bodySchema = z.strictObject({
modelKey: z.string().nonempty(),
name: z.string().nonempty(),
enabled: z.boolean().optional()
enabled: z.boolean().optional(),
listType: modelListTypeSchema.optional().default("allow")
});
registry.registerPath({
@@ -69,7 +71,7 @@ export async function createAiModel(
}
const { providerId } = parsedParams.data;
const { modelKey, name, enabled } = parsedBody.data;
const { modelKey, name, enabled, listType } = parsedBody.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
@@ -116,6 +118,7 @@ export async function createAiModel(
providerId,
modelKey,
name,
listType,
enabled: enabled ?? true,
createdAt: now,
updatedAt: now
+6 -1
View File
@@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, ne } from "drizzle-orm";
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
import { modelListTypeSchema } from "@server/lib/aiInferenceResource";
const paramsSchema = z.strictObject({
modelId: z.coerce.number().int().positive()
@@ -17,7 +18,8 @@ const paramsSchema = z.strictObject({
const bodySchema = z.strictObject({
modelKey: z.string().nonempty().optional(),
name: z.string().nonempty().optional(),
enabled: z.boolean().optional()
enabled: z.boolean().optional(),
listType: modelListTypeSchema.optional()
});
registry.registerPath({
@@ -128,6 +130,9 @@ export async function updateAiModel(
if (body.enabled !== undefined) {
updateData.enabled = body.enabled;
}
if (body.listType !== undefined) {
updateData.listType = body.listType;
}
const [model] = await db
.update(aiModels)
@@ -9,11 +9,14 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import {
assertPublicAllowlistApiEligible,
assertModelsBelongToPublicAllowlistProviders
assertPublicModelListApiEligible,
assertPublicResourceModelEntriesValid,
modelListTypeSchema
} from "@server/lib/aiInferenceResource";
const addAiModelToResourceBodySchema = z.strictObject({
modelId: z.int().positive()
modelId: z.number().int().positive(),
listType: modelListTypeSchema.optional().default("allow")
});
const addAiModelToResourceParamsSchema = z.strictObject({
@@ -24,7 +27,7 @@ registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-models/add",
description:
"Add a single catalog model to an inference 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 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.PublicResource],
request: {
params: addAiModelToResourceParamsSchema,
@@ -70,7 +73,7 @@ export async function addAiModelToResource(
);
}
const { modelId } = parsedBody.data;
const { modelId, listType } = parsedBody.data;
const parsedParams = addAiModelToResourceParamsSchema.safeParse(
req.params
@@ -98,15 +101,15 @@ export async function addAiModelToResource(
);
}
const eligibleError = await assertPublicAllowlistApiEligible(resource);
const eligibleError = await assertPublicModelListApiEligible(resource);
if (eligibleError) {
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
}
const modelError = await assertModelsBelongToPublicAllowlistProviders({
const modelError = await assertPublicResourceModelEntriesValid({
orgId: resource.orgId,
resourceId,
modelIds: [modelId]
models: [{ modelId, listType }]
});
if (modelError) {
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
@@ -131,7 +134,9 @@ export async function addAiModelToResource(
);
}
await db.insert(resourceAiModels).values({ resourceId, modelId });
await db
.insert(resourceAiModels)
.values({ resourceId, modelId, listType });
return response(res, {
data: {},
@@ -11,14 +11,12 @@ import { OpenAPITags, registry } from "@server/openApi";
import {
isInferenceFieldsError,
listPublicResourceAiProviders,
modelAccessModeSchema,
resolveProviderAttachments,
setPublicResourceAiProviders
} from "@server/lib/aiInferenceResource";
const addAiProviderToResourceBodySchema = z.strictObject({
providerId: z.number().int().positive(),
modelAccessMode: modelAccessModeSchema.optional()
providerId: z.number().int().positive()
});
const addAiProviderToResourceParamsSchema = z.strictObject({
@@ -29,7 +27,7 @@ registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-providers/add",
description:
"Add or replace a single AI provider attachment on an inference resource.",
"Add or replace a single AI provider attachment on an inference resource. The provider is attached in inherit mode, using its own allow/block lists.",
tags: [OpenAPITags.PublicResource],
request: {
params: addAiProviderToResourceParamsSchema,
@@ -77,7 +75,7 @@ export async function addAiProviderToResource(
);
}
const { providerId, modelAccessMode } = parsedBody.data;
const { providerId } = parsedBody.data;
const parsedParams = addAiProviderToResourceParamsSchema.safeParse(
req.params
@@ -120,18 +118,21 @@ export async function addAiProviderToResource(
.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: resource.orgId,
attachments: nextAttachments,
requireAtLeastOne: true
requireAtLeastOne: true,
resourceId
});
if (isInferenceFieldsError(attachments)) {
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
return next(
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
);
}
await setPublicResourceAiProviders(resourceId, attachments);
+7 -2
View File
@@ -109,7 +109,7 @@ const createHttpResourceSchema = z
.array(resourceAiProviderAttachmentSchema)
.optional()
.describe(
"For inference-mode 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 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."
)
})
.refine(
@@ -391,9 +391,14 @@ async function createHttpResource(
let providerAttachments: ResourceAiProviderAttachment[] = [];
if (effectiveMode === "inference") {
// A new 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(resourceId: number) {
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
enabled: aiModels.enabled
providerId: aiModels.providerId,
enabled: aiModels.enabled,
listType: resourceAiModels.listType
})
.from(resourceAiModels)
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
@@ -34,7 +36,7 @@ registry.registerPath({
method: "get",
path: "/resource/{resourceId}/ai-models",
description:
"List catalog models on this resource's allowlist. Only enforced when modelAccessMode=allowlist; an empty allowlist denies all models.",
"List the models this 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.PublicResource],
request: {
params: listResourceAiModelsParamsSchema
@@ -21,7 +21,8 @@ export type ListResourceAiProvidersResponse = {
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/ai-providers",
description: "List AI providers attached to an inference resource.",
description:
"List AI providers attached to an inference resource, including each attachment's accessMode.",
tags: [OpenAPITags.PublicResource],
request: {
params: listResourceAiProvidersParamsSchema
@@ -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 { assertPublicAllowlistApiEligible } from "@server/lib/aiInferenceResource";
import { assertPublicModelListApiEligible } from "@server/lib/aiInferenceResource";
const removeAiModelFromResourceBodySchema = z.strictObject({
modelId: z.int().positive()
@@ -22,7 +22,7 @@ registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-models/remove",
description:
"Remove a single catalog model from an inference resource allowlist. Requires at least one attached AI provider in allowlist mode.",
"Remove a single model from an inference resource allow/block list. Requires at least one attached AI provider.",
tags: [OpenAPITags.PublicResource],
request: {
params: removeAiModelFromResourceParamsSchema,
@@ -98,7 +98,7 @@ export async function removeAiModelFromResource(
);
}
const eligibleError = await assertPublicAllowlistApiEligible(resource);
const eligibleError = await assertPublicModelListApiEligible(resource);
if (eligibleError) {
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
}
@@ -127,13 +127,14 @@ export async function removeAiProviderFromResource(
.filter((a) => a.providerId !== providerId)
.map((a) => ({
providerId: a.providerId,
modelAccessMode: a.modelAccessMode
accessMode: a.accessMode
}));
const attachments = await resolveProviderAttachments({
orgId: resource.orgId,
attachments: remaining,
requireAtLeastOne: false
requireAtLeastOne: false,
resourceId
});
if (isInferenceFieldsError(attachments)) {
return next(
+24 -14
View File
@@ -9,12 +9,13 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import {
assertPublicAllowlistApiEligible,
assertModelsBelongToPublicAllowlistProviders
assertPublicModelListApiEligible,
assertPublicResourceModelEntriesValid,
resourceAiModelEntrySchema
} from "@server/lib/aiInferenceResource";
const setResourceAiModelsBodySchema = z.strictObject({
modelIds: z.array(z.int().positive())
models: z.array(resourceAiModelEntrySchema)
});
const setResourceAiModelsParamsSchema = z.strictObject({
@@ -25,7 +26,7 @@ registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-models",
description:
"Replace the allowlist of catalog models for an inference 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 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.PublicResource],
request: {
params: setResourceAiModelsParamsSchema,
@@ -71,7 +72,7 @@ export async function setResourceAiModels(
);
}
const { modelIds } = parsedBody.data;
const { models } = parsedBody.data;
const parsedParams = setResourceAiModelsParamsSchema.safeParse(
req.params
@@ -99,15 +100,22 @@ export async function setResourceAiModels(
);
}
const eligibleError = await assertPublicAllowlistApiEligible(resource);
const eligibleError = await assertPublicModelListApiEligible(resource);
if (eligibleError) {
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
}
const modelError = await assertModelsBelongToPublicAllowlistProviders({
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 assertPublicResourceModelEntriesValid({
orgId: resource.orgId,
resourceId,
modelIds
models: uniqueModels
});
if (modelError) {
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
@@ -118,12 +126,14 @@ export async function setResourceAiModels(
.delete(resourceAiModels)
.where(eq(resourceAiModels.resourceId, resourceId));
if (modelIds.length > 0) {
await trx
.insert(resourceAiModels)
.values(
modelIds.map((modelId) => ({ resourceId, modelId }))
);
if (uniqueModels.length > 0) {
await trx.insert(resourceAiModels).values(
uniqueModels.map((m) => ({
resourceId,
modelId: m.modelId,
listType: m.listType
}))
);
}
});
@@ -27,7 +27,7 @@ registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-providers",
description:
"Replace the AI providers attached to an inference resource. An empty list clears all providers. Model keys must be unique across attached catalog providers.",
"Replace the AI providers attached to an inference resource. Each provider uses accessMode inherit (default, uses the provider's own allow/block lists) or select (uses the 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.PublicResource],
request: {
params: setResourceAiProvidersParamsSchema,
@@ -113,7 +113,8 @@ export async function setResourceAiProviders(
const attachments = await resolveProviderAttachments({
orgId: resource.orgId,
attachments: providers,
requireAtLeastOne: false
requireAtLeastOne: false,
resourceId
});
if (isInferenceFieldsError(attachments)) {
return next(
@@ -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(