mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-09 22:17:58 +02:00
support allow list and blocklist
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user