mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-08 21:48:02 +02:00
support allow list and blocklist
This commit is contained in:
+10
-3
@@ -1667,7 +1667,7 @@
|
|||||||
"aiProviderTypeCustom": "Custom",
|
"aiProviderTypeCustom": "Custom",
|
||||||
"aiProviderTypeOpenaiDescription": "OpenAI API with default upstream URL",
|
"aiProviderTypeOpenaiDescription": "OpenAI API with default upstream URL",
|
||||||
"aiProviderTypeAnthropicDescription": "Anthropic API with default upstream URL",
|
"aiProviderTypeAnthropicDescription": "Anthropic API with default upstream URL",
|
||||||
"aiProviderTypeGoogleGeminiDescription": "Google Gemini OpenAI-compatible endpoint",
|
"aiProviderTypeGoogleGeminiDescription": "Google Gemini generateContent API",
|
||||||
"aiProviderTypeVertexAiDescription": "Google Vertex AI; upstream URL required",
|
"aiProviderTypeVertexAiDescription": "Google Vertex AI; upstream URL required",
|
||||||
"aiProviderTypeBedrockDescription": "Amazon Bedrock Runtime",
|
"aiProviderTypeBedrockDescription": "Amazon Bedrock Runtime",
|
||||||
"aiProviderTypeMicrosoftFoundryDescription": "Microsoft Foundry; upstream URL required",
|
"aiProviderTypeMicrosoftFoundryDescription": "Microsoft Foundry; upstream URL required",
|
||||||
@@ -1759,13 +1759,20 @@
|
|||||||
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
|
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
|
||||||
"aiProviderErrorNoUpdate": "AI provider is not available to update",
|
"aiProviderErrorNoUpdate": "AI provider is not available to update",
|
||||||
"aiProviderModels": "Models",
|
"aiProviderModels": "Models",
|
||||||
"aiProviderModelsDescription": "Define model names available on this provider. Requests must match one of these keys. Use * and ? as wildcards (for example gpt-4* or claude-?).",
|
"aiProviderModelsDescription": "Define allow and block patterns for this provider. Requests must match an allow pattern and must not match a block pattern. Use * and ? as wildcards (for example gpt-4* or claude-?). An empty allow list denies all models.",
|
||||||
"aiProviderModelsPlaceholder": "Model name or pattern (e.g. gpt-4*)",
|
"aiProviderModelsPlaceholder": "Model name or pattern (e.g. gpt-4*)",
|
||||||
|
"aiProviderModelsAllow": "Allow List",
|
||||||
|
"aiProviderModelsAllowDescription": "Models that may be used through this provider. Empty means deny all.",
|
||||||
|
"aiProviderModelsAllowPlaceholder": "Allowed model or pattern (e.g. gpt-4*)",
|
||||||
|
"aiProviderModelsBlock": "Block List",
|
||||||
|
"aiProviderModelsBlockDescription": "Models to deny even if they match an allow pattern.",
|
||||||
|
"aiProviderModelsBlockPlaceholder": "Blocked model or pattern (e.g. gpt-4o-mini)",
|
||||||
|
"aiProviderModelsOverlapError": "These patterns cannot be on both lists: {keys}",
|
||||||
"aiProviderModelsUpdated": "Models updated",
|
"aiProviderModelsUpdated": "Models updated",
|
||||||
"aiProviderModelsErrorUpdate": "Failed to update models",
|
"aiProviderModelsErrorUpdate": "Failed to update models",
|
||||||
"aiResourceProviders": "Providers",
|
"aiResourceProviders": "Providers",
|
||||||
"aiResourceProvidersDescription": "Choose which AI providers this inference resource can use",
|
"aiResourceProvidersDescription": "Choose which AI providers this inference resource can use",
|
||||||
"aiResourceProvidersHelp": "Models must be defined on each provider. Exact names and patterns that conflict (identical keys, or an exact key matching another provider's pattern) are not allowed across selected providers.",
|
"aiResourceProvidersHelp": "Each attached provider uses its own allow and block lists. Allow patterns that conflict across attached providers are not allowed.",
|
||||||
"aiResourceProvidersSelect": "Select providers",
|
"aiResourceProvidersSelect": "Select providers",
|
||||||
"aiResourceProvidersEmpty": "No AI providers found",
|
"aiResourceProvidersEmpty": "No AI providers found",
|
||||||
"aiResourceProvidersUpdated": "Providers updated",
|
"aiResourceProvidersUpdated": "Providers updated",
|
||||||
|
|||||||
@@ -231,10 +231,10 @@ export const resourceAiProviders = pgTable(
|
|||||||
providerId: integer("providerId")
|
providerId: integer("providerId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
modelAccessMode: varchar("modelAccessMode")
|
accessMode: varchar("accessMode")
|
||||||
.$type<"catalog" | "allowlist">()
|
.$type<"inherit" | "select">()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("catalog")
|
.default("inherit")
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
||||||
);
|
);
|
||||||
@@ -247,7 +247,11 @@ export const resourceAiModels = pgTable(
|
|||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
modelId: integer("modelId")
|
modelId: integer("modelId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiModels.modelId, { onDelete: "cascade" })
|
.references(() => aiModels.modelId, { onDelete: "cascade" }),
|
||||||
|
listType: varchar("listType")
|
||||||
|
.$type<"allow" | "block">()
|
||||||
|
.notNull()
|
||||||
|
.default("allow")
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
||||||
);
|
);
|
||||||
@@ -522,10 +526,10 @@ export const siteResourceAiProviders = pgTable(
|
|||||||
providerId: integer("providerId")
|
providerId: integer("providerId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
modelAccessMode: varchar("modelAccessMode")
|
accessMode: varchar("accessMode")
|
||||||
.$type<"catalog" | "allowlist">()
|
.$type<"inherit" | "select">()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("catalog")
|
.default("inherit")
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
||||||
);
|
);
|
||||||
@@ -540,7 +544,11 @@ export const siteResourceAiModels = pgTable(
|
|||||||
}),
|
}),
|
||||||
modelId: integer("modelId")
|
modelId: integer("modelId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiModels.modelId, { onDelete: "cascade" })
|
.references(() => aiModels.modelId, { onDelete: "cascade" }),
|
||||||
|
listType: varchar("listType")
|
||||||
|
.$type<"allow" | "block">()
|
||||||
|
.notNull()
|
||||||
|
.default("allow")
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
|
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
|
||||||
);
|
);
|
||||||
@@ -1678,6 +1686,10 @@ export const aiModels = pgTable(
|
|||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
modelKey: varchar("modelKey").notNull(),
|
modelKey: varchar("modelKey").notNull(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
|
listType: varchar("listType")
|
||||||
|
.$type<"allow" | "block">()
|
||||||
|
.notNull()
|
||||||
|
.default("allow"),
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||||
|
|||||||
@@ -228,10 +228,10 @@ export const resourceAiProviders = sqliteTable(
|
|||||||
providerId: integer("providerId")
|
providerId: integer("providerId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
modelAccessMode: text("modelAccessMode")
|
accessMode: text("accessMode")
|
||||||
.$type<"catalog" | "allowlist">()
|
.$type<"inherit" | "select">()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("catalog")
|
.default("inherit")
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
||||||
);
|
);
|
||||||
@@ -244,7 +244,11 @@ export const resourceAiModels = sqliteTable(
|
|||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
modelId: integer("modelId")
|
modelId: integer("modelId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiModels.modelId, { onDelete: "cascade" })
|
.references(() => aiModels.modelId, { onDelete: "cascade" }),
|
||||||
|
listType: text("listType")
|
||||||
|
.$type<"allow" | "block">()
|
||||||
|
.notNull()
|
||||||
|
.default("allow")
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
||||||
);
|
);
|
||||||
@@ -507,10 +511,10 @@ export const siteResourceAiProviders = sqliteTable(
|
|||||||
providerId: integer("providerId")
|
providerId: integer("providerId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
modelAccessMode: text("modelAccessMode")
|
accessMode: text("accessMode")
|
||||||
.$type<"catalog" | "allowlist">()
|
.$type<"inherit" | "select">()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("catalog")
|
.default("inherit")
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
||||||
);
|
);
|
||||||
@@ -525,7 +529,11 @@ export const siteResourceAiModels = sqliteTable(
|
|||||||
}),
|
}),
|
||||||
modelId: integer("modelId")
|
modelId: integer("modelId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiModels.modelId, { onDelete: "cascade" })
|
.references(() => aiModels.modelId, { onDelete: "cascade" }),
|
||||||
|
listType: text("listType")
|
||||||
|
.$type<"allow" | "block">()
|
||||||
|
.notNull()
|
||||||
|
.default("allow")
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
|
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
|
||||||
);
|
);
|
||||||
@@ -1660,6 +1668,10 @@ export const aiModels = sqliteTable(
|
|||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
modelKey: text("modelKey").notNull(),
|
modelKey: text("modelKey").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
|
listType: text("listType")
|
||||||
|
.$type<"allow" | "block">()
|
||||||
|
.notNull()
|
||||||
|
.default("allow"),
|
||||||
enabled: integer("enabled", { mode: "boolean" })
|
enabled: integer("enabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
|
|||||||
@@ -40,18 +40,37 @@ function paramModel(req: Request): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Join base URL with a path, avoiding double slashes and a duplicated trailing
|
* Join a provider base URL with an inbound request path.
|
||||||
* /v1 when the inbound path already starts with /v1 and the base ends with /v1.
|
|
||||||
*/
|
*/
|
||||||
export function joinUpstreamUrl(baseUrl: string, path: string): string {
|
export function joinUpstreamUrl(baseUrl: string, path: string): string {
|
||||||
const base = baseUrl.replace(/\/+$/, "");
|
const base = baseUrl.replace(/\/+$/, "");
|
||||||
let suffix = path.startsWith("/") ? path : `/${path}`;
|
let suffix = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
|
||||||
if (
|
let basePathname = "/";
|
||||||
base.endsWith("/v1") &&
|
try {
|
||||||
(suffix === "/v1" || suffix.startsWith("/v1/"))
|
basePathname = new URL(base).pathname.replace(/\/+$/, "") || "/";
|
||||||
) {
|
} catch {
|
||||||
suffix = suffix.slice("/v1".length) || "/";
|
// Fall through with "/" non-absolute bases are not expected in
|
||||||
|
// production, but keep joining usable for malformed input.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basePathname !== "/") {
|
||||||
|
const baseSegs = basePathname.split("/").filter(Boolean);
|
||||||
|
const pathSegs = suffix.split("/").filter(Boolean);
|
||||||
|
const max = Math.min(baseSegs.length, pathSegs.length);
|
||||||
|
let overlap = 0;
|
||||||
|
for (let n = max; n >= 1; n--) {
|
||||||
|
const baseSuffix = baseSegs.slice(-n);
|
||||||
|
const pathPrefix = pathSegs.slice(0, n);
|
||||||
|
if (baseSuffix.every((seg, i) => seg === pathPrefix[i])) {
|
||||||
|
overlap = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (overlap > 0) {
|
||||||
|
const remaining = pathSegs.slice(overlap);
|
||||||
|
suffix = remaining.length > 0 ? `/${remaining.join("/")}` : "/";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (suffix === "/") {
|
if (suffix === "/") {
|
||||||
@@ -175,7 +194,7 @@ export const AI_PROVIDER_CAPABILITY_DEFAULTS: Record<
|
|||||||
> = {
|
> = {
|
||||||
openai: ["openai_chat"],
|
openai: ["openai_chat"],
|
||||||
anthropic: ["anthropic_messages"],
|
anthropic: ["anthropic_messages"],
|
||||||
googleGemini: ["openai_chat"],
|
googleGemini: ["gemini_generate_content"],
|
||||||
vertexAi: ["google_generate_content"],
|
vertexAi: ["google_generate_content"],
|
||||||
bedrock: ["bedrock_converse"],
|
bedrock: ["bedrock_converse"],
|
||||||
microsoftFoundry: ["openai_chat"],
|
microsoftFoundry: ["openai_chat"],
|
||||||
|
|||||||
+364
-212
@@ -14,13 +14,17 @@ import { modelKeysConflict } from "@server/lib/aiModelKeyMatch";
|
|||||||
|
|
||||||
type DbOrTrx = Transaction | typeof db;
|
type DbOrTrx = Transaction | typeof db;
|
||||||
|
|
||||||
export const modelAccessModeSchema = z.enum(["catalog", "allowlist"]);
|
export const modelListTypeSchema = z.enum(["allow", "block"]);
|
||||||
|
|
||||||
export type ModelAccessMode = z.infer<typeof modelAccessModeSchema>;
|
export type ModelListType = z.infer<typeof modelListTypeSchema>;
|
||||||
|
|
||||||
|
export const accessModeSchema = z.enum(["inherit", "select"]);
|
||||||
|
|
||||||
|
export type AccessMode = z.infer<typeof accessModeSchema>;
|
||||||
|
|
||||||
export const resourceAiProviderAttachmentSchema = z.strictObject({
|
export const resourceAiProviderAttachmentSchema = z.strictObject({
|
||||||
providerId: z.number().int().positive(),
|
providerId: z.number().int().positive(),
|
||||||
modelAccessMode: modelAccessModeSchema.optional()
|
accessMode: accessModeSchema.optional().default("inherit")
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ResourceAiProviderInput = z.infer<
|
export type ResourceAiProviderInput = z.infer<
|
||||||
@@ -29,9 +33,16 @@ export type ResourceAiProviderInput = z.infer<
|
|||||||
|
|
||||||
export type ResourceAiProviderAttachment = {
|
export type ResourceAiProviderAttachment = {
|
||||||
providerId: number;
|
providerId: number;
|
||||||
modelAccessMode: ModelAccessMode;
|
accessMode: AccessMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resourceAiModelEntrySchema = z.strictObject({
|
||||||
|
modelId: z.number().int().positive(),
|
||||||
|
listType: modelListTypeSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResourceAiModelEntry = z.infer<typeof resourceAiModelEntrySchema>;
|
||||||
|
|
||||||
export type InferenceFieldsError = {
|
export type InferenceFieldsError = {
|
||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
@@ -42,58 +53,153 @@ export function isInferenceFieldsError(
|
|||||||
return "error" in value;
|
return "error" in value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve which allow/block patterns apply for an attachment.
|
||||||
|
* inherit → provider lists; select → resource-selected lists (replace).
|
||||||
|
*/
|
||||||
|
export function resolveEffectiveLists(input: {
|
||||||
|
accessMode: AccessMode;
|
||||||
|
providerAllows: string[];
|
||||||
|
providerBlocks: string[];
|
||||||
|
resourceAllows: string[];
|
||||||
|
resourceBlocks: string[];
|
||||||
|
}): { allows: string[]; blocks: string[] } {
|
||||||
|
if (input.accessMode === "select") {
|
||||||
|
return {
|
||||||
|
allows: input.resourceAllows,
|
||||||
|
blocks: input.resourceBlocks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allows: input.providerAllows,
|
||||||
|
blocks: input.providerBlocks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAttachments(
|
function normalizeAttachments(
|
||||||
inputs: ResourceAiProviderInput[]
|
inputs: ResourceAiProviderInput[]
|
||||||
): ResourceAiProviderAttachment[] {
|
): ResourceAiProviderAttachment[] {
|
||||||
const byProvider = new Map<number, ModelAccessMode>();
|
const byProviderId = new Map<number, AccessMode>();
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
byProvider.set(input.providerId, input.modelAccessMode ?? "catalog");
|
byProviderId.set(input.providerId, input.accessMode ?? "inherit");
|
||||||
}
|
}
|
||||||
return [...byProvider.entries()].map(([providerId, modelAccessMode]) => ({
|
return [...byProviderId.entries()].map(([providerId, accessMode]) => ({
|
||||||
providerId,
|
providerId,
|
||||||
modelAccessMode
|
accessMode
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EffectiveAllowRow = {
|
||||||
|
providerId: number;
|
||||||
|
modelKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure enabled catalog modelKeys do not conflict across attached providers.
|
* Ensure effective allow modelKeys do not conflict across attached providers.
|
||||||
* Catalog attachments contribute all enabled models on the provider.
|
* inherit uses provider allows; select uses resource-selected allows (or the
|
||||||
* Allowlist attachments contribute nothing until models are allowlisted
|
* optional override map). Block patterns are ignored for overlap checks.
|
||||||
* (those are checked when the allowlist is set).
|
|
||||||
*
|
|
||||||
* Conflicts: identical keys, or an exact key that matches another provider's
|
|
||||||
* pattern. Full glob intersections are left to runtime ambiguity errors.
|
|
||||||
*/
|
*/
|
||||||
export async function assertNoOverlappingModelKeys(
|
export async function assertNoOverlappingModelKeys(
|
||||||
attachments: ResourceAiProviderAttachment[],
|
attachments: ResourceAiProviderAttachment[],
|
||||||
trx: DbOrTrx = db
|
options: {
|
||||||
|
trx?: DbOrTrx;
|
||||||
|
resourceId?: number;
|
||||||
|
siteResourceId?: number;
|
||||||
|
selectedAllowsByProvider?: Map<number, string[]>;
|
||||||
|
} = {}
|
||||||
): Promise<InferenceFieldsError | null> {
|
): Promise<InferenceFieldsError | null> {
|
||||||
const catalogProviderIds = attachments
|
const trx = options.trx ?? db;
|
||||||
.filter((a) => a.modelAccessMode === "catalog")
|
|
||||||
.map((a) => a.providerId);
|
|
||||||
|
|
||||||
if (catalogProviderIds.length < 2) {
|
if (attachments.length < 2) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = await trx
|
const inheritProviderIds = attachments
|
||||||
.select({
|
.filter((a) => a.accessMode === "inherit")
|
||||||
providerId: aiModels.providerId,
|
.map((a) => a.providerId);
|
||||||
modelKey: aiModels.modelKey
|
const selectProviderIds = attachments
|
||||||
})
|
.filter((a) => a.accessMode === "select")
|
||||||
.from(aiModels)
|
.map((a) => a.providerId);
|
||||||
.where(
|
|
||||||
and(
|
const effectiveAllows: EffectiveAllowRow[] = [];
|
||||||
inArray(aiModels.providerId, catalogProviderIds),
|
|
||||||
eq(aiModels.enabled, true)
|
if (inheritProviderIds.length > 0) {
|
||||||
)
|
const providerAllows = await trx
|
||||||
);
|
.select({
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
modelKey: aiModels.modelKey
|
||||||
|
})
|
||||||
|
.from(aiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(aiModels.providerId, inheritProviderIds),
|
||||||
|
eq(aiModels.enabled, true),
|
||||||
|
eq(aiModels.listType, "allow")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
effectiveAllows.push(...providerAllows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectProviderIds.length > 0) {
|
||||||
|
if (options.selectedAllowsByProvider) {
|
||||||
|
for (const providerId of selectProviderIds) {
|
||||||
|
const keys =
|
||||||
|
options.selectedAllowsByProvider.get(providerId) ?? [];
|
||||||
|
for (const modelKey of keys) {
|
||||||
|
effectiveAllows.push({ providerId, modelKey });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (options.resourceId !== undefined) {
|
||||||
|
const rows = await trx
|
||||||
|
.select({
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
modelKey: aiModels.modelKey
|
||||||
|
})
|
||||||
|
.from(resourceAiModels)
|
||||||
|
.innerJoin(
|
||||||
|
aiModels,
|
||||||
|
eq(resourceAiModels.modelId, aiModels.modelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAiModels.resourceId, options.resourceId),
|
||||||
|
inArray(aiModels.providerId, selectProviderIds),
|
||||||
|
eq(resourceAiModels.listType, "allow"),
|
||||||
|
eq(aiModels.enabled, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
effectiveAllows.push(...rows);
|
||||||
|
} else if (options.siteResourceId !== undefined) {
|
||||||
|
const rows = await trx
|
||||||
|
.select({
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
modelKey: aiModels.modelKey
|
||||||
|
})
|
||||||
|
.from(siteResourceAiModels)
|
||||||
|
.innerJoin(
|
||||||
|
aiModels,
|
||||||
|
eq(siteResourceAiModels.modelId, aiModels.modelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteResourceAiModels.siteResourceId,
|
||||||
|
options.siteResourceId
|
||||||
|
),
|
||||||
|
inArray(aiModels.providerId, selectProviderIds),
|
||||||
|
eq(siteResourceAiModels.listType, "allow"),
|
||||||
|
eq(aiModels.enabled, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
effectiveAllows.push(...rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const conflictPairs: string[] = [];
|
const conflictPairs: string[] = [];
|
||||||
for (let i = 0; i < models.length; i++) {
|
for (let i = 0; i < effectiveAllows.length; i++) {
|
||||||
for (let j = i + 1; j < models.length; j++) {
|
for (let j = i + 1; j < effectiveAllows.length; j++) {
|
||||||
const left = models[i];
|
const left = effectiveAllows[i];
|
||||||
const right = models[j];
|
const right = effectiveAllows[j];
|
||||||
if (left.providerId === right.providerId) {
|
if (left.providerId === right.providerId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -124,6 +230,8 @@ export async function resolveProviderAttachments(input: {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
attachments: ResourceAiProviderInput[];
|
attachments: ResourceAiProviderInput[];
|
||||||
requireAtLeastOne: boolean;
|
requireAtLeastOne: boolean;
|
||||||
|
resourceId?: number;
|
||||||
|
siteResourceId?: number;
|
||||||
}): Promise<ResourceAiProviderAttachment[] | InferenceFieldsError> {
|
}): Promise<ResourceAiProviderAttachment[] | InferenceFieldsError> {
|
||||||
const attachments = normalizeAttachments(input.attachments);
|
const attachments = normalizeAttachments(input.attachments);
|
||||||
|
|
||||||
@@ -165,7 +273,10 @@ export async function resolveProviderAttachments(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlapError = await assertNoOverlappingModelKeys(attachments);
|
const overlapError = await assertNoOverlappingModelKeys(attachments, {
|
||||||
|
resourceId: input.resourceId,
|
||||||
|
siteResourceId: input.siteResourceId
|
||||||
|
});
|
||||||
if (overlapError) {
|
if (overlapError) {
|
||||||
return overlapError;
|
return overlapError;
|
||||||
}
|
}
|
||||||
@@ -188,6 +299,11 @@ export async function assertInferenceModeAllowsProviderFields(input: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach providers to a resource. Inherit attachments use the provider lists
|
||||||
|
* as-is (resource model rows for those providers are pruned). Select
|
||||||
|
* attachments keep resource-selected allow/block subsets.
|
||||||
|
*/
|
||||||
export async function setPublicResourceAiProviders(
|
export async function setPublicResourceAiProviders(
|
||||||
resourceId: number,
|
resourceId: number,
|
||||||
attachments: ResourceAiProviderAttachment[],
|
attachments: ResourceAiProviderAttachment[],
|
||||||
@@ -202,12 +318,16 @@ export async function setPublicResourceAiProviders(
|
|||||||
attachments.map((a) => ({
|
attachments.map((a) => ({
|
||||||
resourceId,
|
resourceId,
|
||||||
providerId: a.providerId,
|
providerId: a.providerId,
|
||||||
modelAccessMode: a.modelAccessMode
|
accessMode: a.accessMode
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prunePublicResourceAllowlistToAllowlistProviders(resourceId, trx);
|
await prunePublicResourceModelsToSelectProviders(
|
||||||
|
resourceId,
|
||||||
|
attachments,
|
||||||
|
trx
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setSiteResourceAiProviders(
|
export async function setSiteResourceAiProviders(
|
||||||
@@ -224,12 +344,103 @@ export async function setSiteResourceAiProviders(
|
|||||||
attachments.map((a) => ({
|
attachments.map((a) => ({
|
||||||
siteResourceId,
|
siteResourceId,
|
||||||
providerId: a.providerId,
|
providerId: a.providerId,
|
||||||
modelAccessMode: a.modelAccessMode
|
accessMode: a.accessMode
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await pruneSiteResourceAllowlistToAllowlistProviders(siteResourceId, trx);
|
await pruneSiteResourceModelsToSelectProviders(
|
||||||
|
siteResourceId,
|
||||||
|
attachments,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep resource model rows only for providers in select mode.
|
||||||
|
*/
|
||||||
|
async function prunePublicResourceModelsToSelectProviders(
|
||||||
|
resourceId: number,
|
||||||
|
attachments: ResourceAiProviderAttachment[],
|
||||||
|
trx: DbOrTrx
|
||||||
|
): Promise<void> {
|
||||||
|
const selectProviderIds = attachments
|
||||||
|
.filter((a) => a.accessMode === "select")
|
||||||
|
.map((a) => a.providerId);
|
||||||
|
|
||||||
|
if (selectProviderIds.length === 0) {
|
||||||
|
await trx
|
||||||
|
.delete(resourceAiModels)
|
||||||
|
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await trx
|
||||||
|
.select({
|
||||||
|
modelId: resourceAiModels.modelId,
|
||||||
|
providerId: aiModels.providerId
|
||||||
|
})
|
||||||
|
.from(resourceAiModels)
|
||||||
|
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
|
||||||
|
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||||
|
|
||||||
|
const allowed = new Set(selectProviderIds);
|
||||||
|
const toRemove = existing
|
||||||
|
.filter((row) => !allowed.has(row.providerId))
|
||||||
|
.map((row) => row.modelId);
|
||||||
|
|
||||||
|
if (toRemove.length > 0) {
|
||||||
|
await trx
|
||||||
|
.delete(resourceAiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAiModels.resourceId, resourceId),
|
||||||
|
inArray(resourceAiModels.modelId, toRemove)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneSiteResourceModelsToSelectProviders(
|
||||||
|
siteResourceId: number,
|
||||||
|
attachments: ResourceAiProviderAttachment[],
|
||||||
|
trx: DbOrTrx
|
||||||
|
): Promise<void> {
|
||||||
|
const selectProviderIds = attachments
|
||||||
|
.filter((a) => a.accessMode === "select")
|
||||||
|
.map((a) => a.providerId);
|
||||||
|
|
||||||
|
if (selectProviderIds.length === 0) {
|
||||||
|
await trx
|
||||||
|
.delete(siteResourceAiModels)
|
||||||
|
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await trx
|
||||||
|
.select({
|
||||||
|
modelId: siteResourceAiModels.modelId,
|
||||||
|
providerId: aiModels.providerId
|
||||||
|
})
|
||||||
|
.from(siteResourceAiModels)
|
||||||
|
.innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId))
|
||||||
|
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
|
||||||
|
|
||||||
|
const allowed = new Set(selectProviderIds);
|
||||||
|
const toRemove = existing
|
||||||
|
.filter((row) => !allowed.has(row.providerId))
|
||||||
|
.map((row) => row.modelId);
|
||||||
|
|
||||||
|
if (toRemove.length > 0) {
|
||||||
|
await trx
|
||||||
|
.delete(siteResourceAiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResourceAiModels.siteResourceId, siteResourceId),
|
||||||
|
inArray(siteResourceAiModels.modelId, toRemove)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearPublicResourceAiConfig(
|
export async function clearPublicResourceAiConfig(
|
||||||
@@ -256,120 +467,14 @@ export async function clearSiteResourceAiConfig(
|
|||||||
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
|
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prunePublicResourceAllowlistToAllowlistProviders(
|
|
||||||
resourceId: number,
|
|
||||||
trx: DbOrTrx = db
|
|
||||||
): Promise<void> {
|
|
||||||
const allowlistProviders = await trx
|
|
||||||
.select({ providerId: resourceAiProviders.providerId })
|
|
||||||
.from(resourceAiProviders)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourceAiProviders.resourceId, resourceId),
|
|
||||||
eq(resourceAiProviders.modelAccessMode, "allowlist")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (allowlistProviders.length === 0) {
|
|
||||||
await trx
|
|
||||||
.delete(resourceAiModels)
|
|
||||||
.where(eq(resourceAiModels.resourceId, resourceId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validModels = await trx
|
|
||||||
.select({ modelId: aiModels.modelId })
|
|
||||||
.from(aiModels)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
aiModels.providerId,
|
|
||||||
allowlistProviders.map((p) => p.providerId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const validIds = validModels.map((m) => m.modelId);
|
|
||||||
|
|
||||||
const existing = await trx
|
|
||||||
.select({ modelId: resourceAiModels.modelId })
|
|
||||||
.from(resourceAiModels)
|
|
||||||
.where(eq(resourceAiModels.resourceId, resourceId));
|
|
||||||
|
|
||||||
const toRemove = existing
|
|
||||||
.map((e) => e.modelId)
|
|
||||||
.filter((id) => !validIds.includes(id));
|
|
||||||
|
|
||||||
if (toRemove.length > 0) {
|
|
||||||
await trx
|
|
||||||
.delete(resourceAiModels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourceAiModels.resourceId, resourceId),
|
|
||||||
inArray(resourceAiModels.modelId, toRemove)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pruneSiteResourceAllowlistToAllowlistProviders(
|
|
||||||
siteResourceId: number,
|
|
||||||
trx: DbOrTrx = db
|
|
||||||
): Promise<void> {
|
|
||||||
const allowlistProviders = await trx
|
|
||||||
.select({ providerId: siteResourceAiProviders.providerId })
|
|
||||||
.from(siteResourceAiProviders)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteResourceAiProviders.siteResourceId, siteResourceId),
|
|
||||||
eq(siteResourceAiProviders.modelAccessMode, "allowlist")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (allowlistProviders.length === 0) {
|
|
||||||
await trx
|
|
||||||
.delete(siteResourceAiModels)
|
|
||||||
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validModels = await trx
|
|
||||||
.select({ modelId: aiModels.modelId })
|
|
||||||
.from(aiModels)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
aiModels.providerId,
|
|
||||||
allowlistProviders.map((p) => p.providerId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const validIds = validModels.map((m) => m.modelId);
|
|
||||||
|
|
||||||
const existing = await trx
|
|
||||||
.select({ modelId: siteResourceAiModels.modelId })
|
|
||||||
.from(siteResourceAiModels)
|
|
||||||
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
|
|
||||||
|
|
||||||
const toRemove = existing
|
|
||||||
.map((e) => e.modelId)
|
|
||||||
.filter((id) => !validIds.includes(id));
|
|
||||||
|
|
||||||
if (toRemove.length > 0) {
|
|
||||||
await trx
|
|
||||||
.delete(siteResourceAiModels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteResourceAiModels.siteResourceId, siteResourceId),
|
|
||||||
inArray(siteResourceAiModels.modelId, toRemove)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listPublicResourceAiProviders(resourceId: number) {
|
export async function listPublicResourceAiProviders(resourceId: number) {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
providerId: resourceAiProviders.providerId,
|
providerId: resourceAiProviders.providerId,
|
||||||
modelAccessMode: resourceAiProviders.modelAccessMode,
|
|
||||||
name: aiProviders.name,
|
name: aiProviders.name,
|
||||||
type: aiProviders.type,
|
type: aiProviders.type,
|
||||||
enabled: aiProviders.enabled
|
enabled: aiProviders.enabled,
|
||||||
|
accessMode: resourceAiProviders.accessMode
|
||||||
})
|
})
|
||||||
.from(resourceAiProviders)
|
.from(resourceAiProviders)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
@@ -383,10 +488,10 @@ export async function listSiteResourceAiProviders(siteResourceId: number) {
|
|||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
providerId: siteResourceAiProviders.providerId,
|
providerId: siteResourceAiProviders.providerId,
|
||||||
modelAccessMode: siteResourceAiProviders.modelAccessMode,
|
|
||||||
name: aiProviders.name,
|
name: aiProviders.name,
|
||||||
type: aiProviders.type,
|
type: aiProviders.type,
|
||||||
enabled: aiProviders.enabled
|
enabled: aiProviders.enabled,
|
||||||
|
accessMode: siteResourceAiProviders.accessMode
|
||||||
})
|
})
|
||||||
.from(siteResourceAiProviders)
|
.from(siteResourceAiProviders)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
@@ -397,15 +502,15 @@ export async function listSiteResourceAiProviders(siteResourceId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowlist APIs require an inference resource with at least one
|
* Model list APIs require an inference resource with at least one select-mode
|
||||||
* attached provider in allowlist mode.
|
* attached provider.
|
||||||
*/
|
*/
|
||||||
export async function assertPublicAllowlistApiEligible(resource: {
|
export async function assertPublicModelListApiEligible(resource: {
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
mode: string;
|
mode: string;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
if (resource.mode !== "inference") {
|
if (resource.mode !== "inference") {
|
||||||
return "AI model allowlists are only supported on inference-mode resources";
|
return "AI model lists are only supported on inference-mode resources";
|
||||||
}
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@@ -414,23 +519,23 @@ export async function assertPublicAllowlistApiEligible(resource: {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(resourceAiProviders.resourceId, resource.resourceId),
|
eq(resourceAiProviders.resourceId, resource.resourceId),
|
||||||
eq(resourceAiProviders.modelAccessMode, "allowlist")
|
eq(resourceAiProviders.accessMode, "select")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return "Attach at least one AI provider with modelAccessMode=allowlist before managing allowed models";
|
return "Set at least one attached AI provider to select mode before managing model lists";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assertSiteAllowlistApiEligible(siteResource: {
|
export async function assertSiteModelListApiEligible(siteResource: {
|
||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
mode: string;
|
mode: string;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
if (siteResource.mode !== "inference") {
|
if (siteResource.mode !== "inference") {
|
||||||
return "AI model allowlists are only supported on inference-mode resources";
|
return "AI model lists are only supported on inference-mode resources";
|
||||||
}
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@@ -442,33 +547,36 @@ export async function assertSiteAllowlistApiEligible(siteResource: {
|
|||||||
siteResourceAiProviders.siteResourceId,
|
siteResourceAiProviders.siteResourceId,
|
||||||
siteResource.siteResourceId
|
siteResource.siteResourceId
|
||||||
),
|
),
|
||||||
eq(siteResourceAiProviders.modelAccessMode, "allowlist")
|
eq(siteResourceAiProviders.accessMode, "select")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return "Attach at least one AI provider with modelAccessMode=allowlist before managing allowed models";
|
return "Set at least one attached AI provider to select mode before managing model lists";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Models must belong to providers attached to this resource in allowlist mode,
|
* Resource model entries must belong to select-mode attached providers, and
|
||||||
* and those providers must belong to the resource's org.
|
* listType must match the provider catalog entry (allow→allow, block→block).
|
||||||
*/
|
*/
|
||||||
export async function assertModelsBelongToPublicAllowlistProviders(input: {
|
export async function assertPublicResourceModelEntriesValid(input: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
modelIds: number[];
|
models: ResourceAiModelEntry[];
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
const uniqueIds = [...new Set(input.modelIds)];
|
const uniqueModels = dedupeModelEntries(input.models);
|
||||||
if (uniqueIds.length === 0) {
|
if (uniqueModels.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowlistProviders = await db
|
const attachments = await db
|
||||||
.select({ providerId: resourceAiProviders.providerId })
|
.select({
|
||||||
|
providerId: resourceAiProviders.providerId,
|
||||||
|
accessMode: resourceAiProviders.accessMode
|
||||||
|
})
|
||||||
.from(resourceAiProviders)
|
.from(resourceAiProviders)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
aiProviders,
|
aiProviders,
|
||||||
@@ -477,49 +585,33 @@ export async function assertModelsBelongToPublicAllowlistProviders(input: {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(resourceAiProviders.resourceId, input.resourceId),
|
eq(resourceAiProviders.resourceId, input.resourceId),
|
||||||
eq(resourceAiProviders.modelAccessMode, "allowlist"),
|
|
||||||
eq(aiProviders.orgId, input.orgId)
|
eq(aiProviders.orgId, input.orgId)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allowlistProviders.length === 0) {
|
return assertModelEntriesValid({
|
||||||
return "No allowlist AI providers are attached to this resource";
|
orgId: input.orgId,
|
||||||
}
|
modelEntries: uniqueModels,
|
||||||
|
attachments,
|
||||||
const validModels = await db
|
resourceLabel: "resource"
|
||||||
.select({ modelId: aiModels.modelId })
|
});
|
||||||
.from(aiModels)
|
|
||||||
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(aiModels.modelId, uniqueIds),
|
|
||||||
inArray(
|
|
||||||
aiModels.providerId,
|
|
||||||
allowlistProviders.map((p) => p.providerId)
|
|
||||||
),
|
|
||||||
eq(aiProviders.orgId, input.orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (validModels.length !== uniqueIds.length) {
|
|
||||||
return "One or more model IDs do not exist or do not belong to an allowlist provider on this resource";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assertModelsBelongToSiteAllowlistProviders(input: {
|
export async function assertSiteResourceModelEntriesValid(input: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
modelIds: number[];
|
models: ResourceAiModelEntry[];
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
const uniqueIds = [...new Set(input.modelIds)];
|
const uniqueModels = dedupeModelEntries(input.models);
|
||||||
if (uniqueIds.length === 0) {
|
if (uniqueModels.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowlistProviders = await db
|
const attachments = await db
|
||||||
.select({ providerId: siteResourceAiProviders.providerId })
|
.select({
|
||||||
|
providerId: siteResourceAiProviders.providerId,
|
||||||
|
accessMode: siteResourceAiProviders.accessMode
|
||||||
|
})
|
||||||
.from(siteResourceAiProviders)
|
.from(siteResourceAiProviders)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
aiProviders,
|
aiProviders,
|
||||||
@@ -531,32 +623,92 @@ export async function assertModelsBelongToSiteAllowlistProviders(input: {
|
|||||||
siteResourceAiProviders.siteResourceId,
|
siteResourceAiProviders.siteResourceId,
|
||||||
input.siteResourceId
|
input.siteResourceId
|
||||||
),
|
),
|
||||||
eq(siteResourceAiProviders.modelAccessMode, "allowlist"),
|
|
||||||
eq(aiProviders.orgId, input.orgId)
|
eq(aiProviders.orgId, input.orgId)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allowlistProviders.length === 0) {
|
return assertModelEntriesValid({
|
||||||
return "No allowlist AI providers are attached to this site resource";
|
orgId: input.orgId,
|
||||||
|
modelEntries: uniqueModels,
|
||||||
|
attachments,
|
||||||
|
resourceLabel: "site resource"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeModelEntries(
|
||||||
|
models: ResourceAiModelEntry[]
|
||||||
|
): ResourceAiModelEntry[] {
|
||||||
|
const byModelId = new Map(
|
||||||
|
models.map((m) => [m.modelId, m.listType] as const)
|
||||||
|
);
|
||||||
|
return [...byModelId.entries()].map(([modelId, listType]) => ({
|
||||||
|
modelId,
|
||||||
|
listType
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertModelEntriesValid(input: {
|
||||||
|
orgId: string;
|
||||||
|
modelEntries: ResourceAiModelEntry[];
|
||||||
|
attachments: ResourceAiProviderAttachment[];
|
||||||
|
resourceLabel: string;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const selectProviderIds = input.attachments
|
||||||
|
.filter((a) => a.accessMode === "select")
|
||||||
|
.map((a) => a.providerId);
|
||||||
|
|
||||||
|
if (selectProviderIds.length === 0) {
|
||||||
|
return "Set at least one attached AI provider to select mode before managing model lists";
|
||||||
}
|
}
|
||||||
|
|
||||||
const validModels = await db
|
const modelIds = input.modelEntries.map((m) => m.modelId);
|
||||||
.select({ modelId: aiModels.modelId })
|
const catalogRows = await db
|
||||||
|
.select({
|
||||||
|
modelId: aiModels.modelId,
|
||||||
|
modelKey: aiModels.modelKey,
|
||||||
|
listType: aiModels.listType,
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
enabled: aiModels.enabled
|
||||||
|
})
|
||||||
.from(aiModels)
|
.from(aiModels)
|
||||||
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
|
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(aiModels.modelId, uniqueIds),
|
inArray(aiModels.modelId, modelIds),
|
||||||
inArray(
|
inArray(aiModels.providerId, selectProviderIds),
|
||||||
aiModels.providerId,
|
|
||||||
allowlistProviders.map((p) => p.providerId)
|
|
||||||
),
|
|
||||||
eq(aiProviders.orgId, input.orgId)
|
eq(aiProviders.orgId, input.orgId)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (validModels.length !== uniqueIds.length) {
|
if (catalogRows.length !== modelIds.length) {
|
||||||
return "One or more model IDs do not exist or do not belong to an allowlist provider on this site resource";
|
return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogById = new Map(catalogRows.map((row) => [row.modelId, row]));
|
||||||
|
const selectedAllowsByProvider = new Map<number, string[]>();
|
||||||
|
for (const entry of input.modelEntries) {
|
||||||
|
const catalog = catalogById.get(entry.modelId);
|
||||||
|
if (!catalog) {
|
||||||
|
return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`;
|
||||||
|
}
|
||||||
|
if (catalog.listType !== entry.listType) {
|
||||||
|
return `Model ${entry.modelId} must use listType "${catalog.listType}" to match the provider catalog entry`;
|
||||||
|
}
|
||||||
|
if (!catalog.enabled) {
|
||||||
|
return `Model ${entry.modelId} is disabled on its provider`;
|
||||||
|
}
|
||||||
|
if (entry.listType === "allow") {
|
||||||
|
const keys = selectedAllowsByProvider.get(catalog.providerId) ?? [];
|
||||||
|
keys.push(catalog.modelKey);
|
||||||
|
selectedAllowsByProvider.set(catalog.providerId, keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlapError = await assertNoOverlappingModelKeys(input.attachments, {
|
||||||
|
selectedAllowsByProvider
|
||||||
|
});
|
||||||
|
if (overlapError) {
|
||||||
|
return overlapError.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -83,3 +83,42 @@ export function modelKeysConflict(a: string, b: string): boolean {
|
|||||||
|
|
||||||
return modelKeyMatches(b, a);
|
return modelKeyMatches(b, a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-layer policy: empty allowlist denies all. Blocklist only applies
|
||||||
|
* after an allow match.
|
||||||
|
*/
|
||||||
|
export function isAllowedByLists(
|
||||||
|
requested: string,
|
||||||
|
allows: string[],
|
||||||
|
blocks: string[]
|
||||||
|
): boolean {
|
||||||
|
if (allows.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!allows.some((pattern) => modelKeyMatches(pattern, requested))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (blocks.some((pattern) => modelKeyMatches(pattern, requested))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Among allow patterns that match `requested`, return the most specific one,
|
||||||
|
* or null if none match.
|
||||||
|
*/
|
||||||
|
export function mostSpecificMatchingAllow(
|
||||||
|
requested: string,
|
||||||
|
allows: string[]
|
||||||
|
): string | null {
|
||||||
|
const matching = allows.filter((pattern) =>
|
||||||
|
modelKeyMatches(pattern, requested)
|
||||||
|
);
|
||||||
|
if (matching.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
matching.sort(compareModelKeySpecificity);
|
||||||
|
return matching[0];
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const AI_PROVIDER_DEFAULTS: Record<
|
|||||||
authType: "x-api-key"
|
authType: "x-api-key"
|
||||||
},
|
},
|
||||||
googleGemini: {
|
googleGemini: {
|
||||||
upstreamUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
upstreamUrl: "https://generativelanguage.googleapis.com",
|
||||||
authType: "x-goog-api-key"
|
authType: "x-goog-api-key"
|
||||||
},
|
},
|
||||||
vertexAi: {
|
vertexAi: {
|
||||||
|
|||||||
@@ -38,10 +38,15 @@ import { isIpInCidr } from "@server/lib/ip";
|
|||||||
import { localCache } from "@server/lib/cache";
|
import { localCache } from "@server/lib/cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import type { ModelAccessMode } from "@server/lib/aiInferenceResource";
|
import {
|
||||||
|
resolveEffectiveLists,
|
||||||
|
type AccessMode,
|
||||||
|
type ModelListType
|
||||||
|
} from "@server/lib/aiInferenceResource";
|
||||||
import {
|
import {
|
||||||
compareModelKeySpecificity,
|
compareModelKeySpecificity,
|
||||||
modelKeyMatches
|
isAllowedByLists,
|
||||||
|
mostSpecificMatchingAllow
|
||||||
} from "@server/lib/aiModelKeyMatch";
|
} from "@server/lib/aiModelKeyMatch";
|
||||||
import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch";
|
import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch";
|
||||||
|
|
||||||
@@ -96,7 +101,19 @@ async function findClientByIp(ip: string): Promise<CachedClient> {
|
|||||||
|
|
||||||
type ProviderAttachment = {
|
type ProviderAttachment = {
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
modelAccessMode: ModelAccessMode;
|
accessMode: AccessMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResourceModelPattern = {
|
||||||
|
providerId: number;
|
||||||
|
modelKey: string;
|
||||||
|
listType: ModelListType;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderPatternLists = {
|
||||||
|
allows: string[];
|
||||||
|
blocks: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolvedTarget = {
|
type ResolvedTarget = {
|
||||||
@@ -104,7 +121,7 @@ type ResolvedTarget = {
|
|||||||
siteResourceId: number | null;
|
siteResourceId: number | null;
|
||||||
orgId: string | null;
|
orgId: string | null;
|
||||||
attachments: ProviderAttachment[];
|
attachments: ProviderAttachment[];
|
||||||
allowlistedModelIds: Set<number>;
|
resourceListsByProvider: Map<number, ProviderPatternLists>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProviderSelection =
|
type ProviderSelection =
|
||||||
@@ -231,8 +248,8 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
|||||||
if (resourceRow) {
|
if (resourceRow) {
|
||||||
const attachmentRows = await db
|
const attachmentRows = await db
|
||||||
.select({
|
.select({
|
||||||
modelAccessMode: resourceAiProviders.modelAccessMode,
|
provider: aiProviders,
|
||||||
provider: aiProviders
|
accessMode: resourceAiProviders.accessMode
|
||||||
})
|
})
|
||||||
.from(resourceAiProviders)
|
.from(resourceAiProviders)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
@@ -252,38 +269,26 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
|||||||
|
|
||||||
const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({
|
const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({
|
||||||
provider: a.provider,
|
provider: a.provider,
|
||||||
modelAccessMode: a.modelAccessMode as ModelAccessMode
|
accessMode: a.accessMode
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const allowlistProviderIds = attachments
|
const resourcePatterns = await db
|
||||||
.filter((a) => a.modelAccessMode === "allowlist")
|
.select({
|
||||||
.map((a) => a.provider.providerId);
|
providerId: aiModels.providerId,
|
||||||
const allowlistedModelIds = new Set<number>();
|
modelKey: aiModels.modelKey,
|
||||||
if (allowlistProviderIds.length > 0) {
|
listType: resourceAiModels.listType,
|
||||||
const restrictions = await db
|
enabled: aiModels.enabled
|
||||||
.select({ modelId: resourceAiModels.modelId })
|
})
|
||||||
.from(resourceAiModels)
|
.from(resourceAiModels)
|
||||||
.innerJoin(
|
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
|
||||||
aiModels,
|
.where(eq(resourceAiModels.resourceId, resourceRow.resourceId));
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resourceId: resourceRow.resourceId,
|
resourceId: resourceRow.resourceId,
|
||||||
siteResourceId: null,
|
siteResourceId: null,
|
||||||
orgId: resourceRow.orgId,
|
orgId: resourceRow.orgId,
|
||||||
attachments,
|
attachments,
|
||||||
allowlistedModelIds
|
resourceListsByProvider: groupPatternsByProvider(resourcePatterns)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,8 +310,8 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
|||||||
if (siteResourceRow) {
|
if (siteResourceRow) {
|
||||||
const attachmentRows = await db
|
const attachmentRows = await db
|
||||||
.select({
|
.select({
|
||||||
modelAccessMode: siteResourceAiProviders.modelAccessMode,
|
provider: aiProviders,
|
||||||
provider: aiProviders
|
accessMode: siteResourceAiProviders.accessMode
|
||||||
})
|
})
|
||||||
.from(siteResourceAiProviders)
|
.from(siteResourceAiProviders)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
@@ -329,50 +334,65 @@ async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
|||||||
|
|
||||||
const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({
|
const attachments: ProviderAttachment[] = attachmentRows.map((a) => ({
|
||||||
provider: a.provider,
|
provider: a.provider,
|
||||||
modelAccessMode: a.modelAccessMode as ModelAccessMode
|
accessMode: a.accessMode
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const allowlistProviderIds = attachments
|
const resourcePatterns = await db
|
||||||
.filter((a) => a.modelAccessMode === "allowlist")
|
.select({
|
||||||
.map((a) => a.provider.providerId);
|
providerId: aiModels.providerId,
|
||||||
const allowlistedModelIds = new Set<number>();
|
modelKey: aiModels.modelKey,
|
||||||
if (allowlistProviderIds.length > 0) {
|
listType: siteResourceAiModels.listType,
|
||||||
const restrictions = await db
|
enabled: aiModels.enabled
|
||||||
.select({ modelId: siteResourceAiModels.modelId })
|
})
|
||||||
.from(siteResourceAiModels)
|
.from(siteResourceAiModels)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
aiModels,
|
aiModels,
|
||||||
eq(siteResourceAiModels.modelId, aiModels.modelId)
|
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 {
|
return {
|
||||||
resourceId: null,
|
resourceId: null,
|
||||||
siteResourceId: siteResourceRow.siteResourceId,
|
siteResourceId: siteResourceRow.siteResourceId,
|
||||||
orgId: siteResourceRow.orgId,
|
orgId: siteResourceRow.orgId,
|
||||||
attachments,
|
attachments,
|
||||||
allowlistedModelIds
|
resourceListsByProvider: groupPatternsByProvider(resourcePatterns)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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(
|
async function selectProvider(
|
||||||
attachments: ProviderAttachment[],
|
attachments: ProviderAttachment[],
|
||||||
allowlistedModelIds: Set<number>,
|
resourceListsByProvider: Map<number, ProviderPatternLists>,
|
||||||
requestedModel: string | undefined
|
requestedModel: string | undefined
|
||||||
): Promise<ProviderSelection> {
|
): Promise<ProviderSelection> {
|
||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
@@ -383,10 +403,10 @@ async function selectProvider(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerById = new Map(
|
const attachmentByProviderId = new Map(
|
||||||
attachments.map((a) => [a.provider.providerId, a])
|
attachments.map((a) => [a.provider.providerId, a])
|
||||||
);
|
);
|
||||||
const providerIds = [...providerById.keys()];
|
const providerIds = [...attachmentByProviderId.keys()];
|
||||||
if (providerIds.length === 0) {
|
if (providerIds.length === 0) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -397,48 +417,54 @@ async function selectProvider(
|
|||||||
|
|
||||||
const providerModels = await db
|
const providerModels = await db
|
||||||
.select({
|
.select({
|
||||||
modelId: aiModels.modelId,
|
|
||||||
providerId: aiModels.providerId,
|
providerId: aiModels.providerId,
|
||||||
modelKey: aiModels.modelKey,
|
modelKey: aiModels.modelKey,
|
||||||
|
listType: aiModels.listType,
|
||||||
enabled: aiModels.enabled
|
enabled: aiModels.enabled
|
||||||
})
|
})
|
||||||
.from(aiModels)
|
.from(aiModels)
|
||||||
.where(inArray(aiModels.providerId, providerIds));
|
.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 = {
|
type ModelCandidate = {
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
modelKey: string;
|
modelKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const candidates: ModelCandidate[] = [];
|
const candidates: ModelCandidate[] = [];
|
||||||
for (const model of providerModels) {
|
for (const [providerId, attachment] of attachmentByProviderId) {
|
||||||
if (!model.enabled) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
const matchingAllow = mostSpecificMatchingAllow(requestedModel, allows);
|
||||||
if (!modelKeyMatches(model.modelKey, requestedModel)) {
|
if (!matchingAllow) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
candidates.push({
|
||||||
const attachment = providerById.get(model.providerId);
|
provider: attachment.provider,
|
||||||
if (!attachment) {
|
modelKey: matchingAllow
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
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);
|
const requestUser = await resolveRequestUser(req, resourceId, orgId);
|
||||||
if (requestUser) {
|
if (requestUser) {
|
||||||
@@ -529,7 +556,7 @@ export async function handleAiGatewayProxy(
|
|||||||
|
|
||||||
const selection = await selectProvider(
|
const selection = await selectProvider(
|
||||||
capableAttachments,
|
capableAttachments,
|
||||||
allowlistedModelIds,
|
resourceListsByProvider,
|
||||||
requestedModel
|
requestedModel
|
||||||
);
|
);
|
||||||
if (!selection.ok) {
|
if (!selection.ok) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
|
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
|
||||||
|
import { modelListTypeSchema } from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
providerId: z.coerce.number().int().positive()
|
providerId: z.coerce.number().int().positive()
|
||||||
@@ -17,7 +18,8 @@ const paramsSchema = z.strictObject({
|
|||||||
const bodySchema = z.strictObject({
|
const bodySchema = z.strictObject({
|
||||||
modelKey: z.string().nonempty(),
|
modelKey: z.string().nonempty(),
|
||||||
name: z.string().nonempty(),
|
name: z.string().nonempty(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional(),
|
||||||
|
listType: modelListTypeSchema.optional().default("allow")
|
||||||
});
|
});
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
@@ -69,7 +71,7 @@ export async function createAiModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { providerId } = parsedParams.data;
|
const { providerId } = parsedParams.data;
|
||||||
const { modelKey, name, enabled } = parsedBody.data;
|
const { modelKey, name, enabled, listType } = parsedBody.data;
|
||||||
|
|
||||||
const [provider] =
|
const [provider] =
|
||||||
req.aiProvider && req.aiProvider.providerId === providerId
|
req.aiProvider && req.aiProvider.providerId === providerId
|
||||||
@@ -116,6 +118,7 @@ export async function createAiModel(
|
|||||||
providerId,
|
providerId,
|
||||||
modelKey,
|
modelKey,
|
||||||
name,
|
name,
|
||||||
|
listType,
|
||||||
enabled: enabled ?? true,
|
enabled: enabled ?? true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { and, eq, ne } from "drizzle-orm";
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
|
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
|
||||||
|
import { modelListTypeSchema } from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
modelId: z.coerce.number().int().positive()
|
modelId: z.coerce.number().int().positive()
|
||||||
@@ -17,7 +18,8 @@ const paramsSchema = z.strictObject({
|
|||||||
const bodySchema = z.strictObject({
|
const bodySchema = z.strictObject({
|
||||||
modelKey: z.string().nonempty().optional(),
|
modelKey: z.string().nonempty().optional(),
|
||||||
name: z.string().nonempty().optional(),
|
name: z.string().nonempty().optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional(),
|
||||||
|
listType: modelListTypeSchema.optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
@@ -128,6 +130,9 @@ export async function updateAiModel(
|
|||||||
if (body.enabled !== undefined) {
|
if (body.enabled !== undefined) {
|
||||||
updateData.enabled = body.enabled;
|
updateData.enabled = body.enabled;
|
||||||
}
|
}
|
||||||
|
if (body.listType !== undefined) {
|
||||||
|
updateData.listType = body.listType;
|
||||||
|
}
|
||||||
|
|
||||||
const [model] = await db
|
const [model] = await db
|
||||||
.update(aiModels)
|
.update(aiModels)
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import {
|
import {
|
||||||
assertPublicAllowlistApiEligible,
|
assertPublicModelListApiEligible,
|
||||||
assertModelsBelongToPublicAllowlistProviders
|
assertPublicResourceModelEntriesValid,
|
||||||
|
modelListTypeSchema
|
||||||
} from "@server/lib/aiInferenceResource";
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
const addAiModelToResourceBodySchema = z.strictObject({
|
const addAiModelToResourceBodySchema = z.strictObject({
|
||||||
modelId: z.int().positive()
|
modelId: z.number().int().positive(),
|
||||||
|
listType: modelListTypeSchema.optional().default("allow")
|
||||||
});
|
});
|
||||||
|
|
||||||
const addAiModelToResourceParamsSchema = z.strictObject({
|
const addAiModelToResourceParamsSchema = z.strictObject({
|
||||||
@@ -24,7 +27,7 @@ registry.registerPath({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/resource/{resourceId}/ai-models/add",
|
path: "/resource/{resourceId}/ai-models/add",
|
||||||
description:
|
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],
|
tags: [OpenAPITags.PublicResource],
|
||||||
request: {
|
request: {
|
||||||
params: addAiModelToResourceParamsSchema,
|
params: addAiModelToResourceParamsSchema,
|
||||||
@@ -70,7 +73,7 @@ export async function addAiModelToResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modelId } = parsedBody.data;
|
const { modelId, listType } = parsedBody.data;
|
||||||
|
|
||||||
const parsedParams = addAiModelToResourceParamsSchema.safeParse(
|
const parsedParams = addAiModelToResourceParamsSchema.safeParse(
|
||||||
req.params
|
req.params
|
||||||
@@ -98,15 +101,15 @@ export async function addAiModelToResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const eligibleError = await assertPublicAllowlistApiEligible(resource);
|
const eligibleError = await assertPublicModelListApiEligible(resource);
|
||||||
if (eligibleError) {
|
if (eligibleError) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelError = await assertModelsBelongToPublicAllowlistProviders({
|
const modelError = await assertPublicResourceModelEntriesValid({
|
||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
resourceId,
|
resourceId,
|
||||||
modelIds: [modelId]
|
models: [{ modelId, listType }]
|
||||||
});
|
});
|
||||||
if (modelError) {
|
if (modelError) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, 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, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import {
|
import {
|
||||||
isInferenceFieldsError,
|
isInferenceFieldsError,
|
||||||
listPublicResourceAiProviders,
|
listPublicResourceAiProviders,
|
||||||
modelAccessModeSchema,
|
|
||||||
resolveProviderAttachments,
|
resolveProviderAttachments,
|
||||||
setPublicResourceAiProviders
|
setPublicResourceAiProviders
|
||||||
} from "@server/lib/aiInferenceResource";
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
const addAiProviderToResourceBodySchema = z.strictObject({
|
const addAiProviderToResourceBodySchema = z.strictObject({
|
||||||
providerId: z.number().int().positive(),
|
providerId: z.number().int().positive()
|
||||||
modelAccessMode: modelAccessModeSchema.optional()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const addAiProviderToResourceParamsSchema = z.strictObject({
|
const addAiProviderToResourceParamsSchema = z.strictObject({
|
||||||
@@ -29,7 +27,7 @@ registry.registerPath({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/resource/{resourceId}/ai-providers/add",
|
path: "/resource/{resourceId}/ai-providers/add",
|
||||||
description:
|
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],
|
tags: [OpenAPITags.PublicResource],
|
||||||
request: {
|
request: {
|
||||||
params: addAiProviderToResourceParamsSchema,
|
params: addAiProviderToResourceParamsSchema,
|
||||||
@@ -77,7 +75,7 @@ export async function addAiProviderToResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { providerId, modelAccessMode } = parsedBody.data;
|
const { providerId } = parsedBody.data;
|
||||||
|
|
||||||
const parsedParams = addAiProviderToResourceParamsSchema.safeParse(
|
const parsedParams = addAiProviderToResourceParamsSchema.safeParse(
|
||||||
req.params
|
req.params
|
||||||
@@ -120,18 +118,21 @@ export async function addAiProviderToResource(
|
|||||||
.filter((a) => a.providerId !== providerId)
|
.filter((a) => a.providerId !== providerId)
|
||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
providerId: a.providerId,
|
providerId: a.providerId,
|
||||||
modelAccessMode: a.modelAccessMode
|
accessMode: a.accessMode
|
||||||
})),
|
})),
|
||||||
{ providerId, modelAccessMode }
|
{ providerId, accessMode: "inherit" as const }
|
||||||
];
|
];
|
||||||
|
|
||||||
const attachments = await resolveProviderAttachments({
|
const attachments = await resolveProviderAttachments({
|
||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
attachments: nextAttachments,
|
attachments: nextAttachments,
|
||||||
requireAtLeastOne: true
|
requireAtLeastOne: true,
|
||||||
|
resourceId
|
||||||
});
|
});
|
||||||
if (isInferenceFieldsError(attachments)) {
|
if (isInferenceFieldsError(attachments)) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await setPublicResourceAiProviders(resourceId, attachments);
|
await setPublicResourceAiProviders(resourceId, attachments);
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ const createHttpResourceSchema = z
|
|||||||
.array(resourceAiProviderAttachmentSchema)
|
.array(resourceAiProviderAttachmentSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.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(
|
.refine(
|
||||||
@@ -391,9 +391,14 @@ async function createHttpResource(
|
|||||||
|
|
||||||
let providerAttachments: ResourceAiProviderAttachment[] = [];
|
let providerAttachments: ResourceAiProviderAttachment[] = [];
|
||||||
if (effectiveMode === "inference") {
|
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({
|
const resolved = await resolveProviderAttachments({
|
||||||
orgId,
|
orgId,
|
||||||
attachments: aiProviderInputs ?? [],
|
attachments: (aiProviderInputs ?? []).map((p) => ({
|
||||||
|
providerId: p.providerId,
|
||||||
|
accessMode: "inherit" as const
|
||||||
|
})),
|
||||||
requireAtLeastOne: false
|
requireAtLeastOne: false
|
||||||
});
|
});
|
||||||
if (isInferenceFieldsError(resolved)) {
|
if (isInferenceFieldsError(resolved)) {
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ async function query(resourceId: number) {
|
|||||||
modelId: aiModels.modelId,
|
modelId: aiModels.modelId,
|
||||||
modelKey: aiModels.modelKey,
|
modelKey: aiModels.modelKey,
|
||||||
name: aiModels.name,
|
name: aiModels.name,
|
||||||
enabled: aiModels.enabled
|
providerId: aiModels.providerId,
|
||||||
|
enabled: aiModels.enabled,
|
||||||
|
listType: resourceAiModels.listType
|
||||||
})
|
})
|
||||||
.from(resourceAiModels)
|
.from(resourceAiModels)
|
||||||
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
|
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
|
||||||
@@ -34,7 +36,7 @@ registry.registerPath({
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/resource/{resourceId}/ai-models",
|
path: "/resource/{resourceId}/ai-models",
|
||||||
description:
|
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],
|
tags: [OpenAPITags.PublicResource],
|
||||||
request: {
|
request: {
|
||||||
params: listResourceAiModelsParamsSchema
|
params: listResourceAiModelsParamsSchema
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export type ListResourceAiProvidersResponse = {
|
|||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/resource/{resourceId}/ai-providers",
|
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],
|
tags: [OpenAPITags.PublicResource],
|
||||||
request: {
|
request: {
|
||||||
params: listResourceAiProvidersParamsSchema
|
params: listResourceAiProvidersParamsSchema
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { assertPublicAllowlistApiEligible } from "@server/lib/aiInferenceResource";
|
import { assertPublicModelListApiEligible } from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
const removeAiModelFromResourceBodySchema = z.strictObject({
|
const removeAiModelFromResourceBodySchema = z.strictObject({
|
||||||
modelId: z.int().positive()
|
modelId: z.int().positive()
|
||||||
@@ -22,7 +22,7 @@ registry.registerPath({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/resource/{resourceId}/ai-models/remove",
|
path: "/resource/{resourceId}/ai-models/remove",
|
||||||
description:
|
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],
|
tags: [OpenAPITags.PublicResource],
|
||||||
request: {
|
request: {
|
||||||
params: removeAiModelFromResourceParamsSchema,
|
params: removeAiModelFromResourceParamsSchema,
|
||||||
@@ -98,7 +98,7 @@ export async function removeAiModelFromResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const eligibleError = await assertPublicAllowlistApiEligible(resource);
|
const eligibleError = await assertPublicModelListApiEligible(resource);
|
||||||
if (eligibleError) {
|
if (eligibleError) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,13 +127,14 @@ export async function removeAiProviderFromResource(
|
|||||||
.filter((a) => a.providerId !== providerId)
|
.filter((a) => a.providerId !== providerId)
|
||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
providerId: a.providerId,
|
providerId: a.providerId,
|
||||||
modelAccessMode: a.modelAccessMode
|
accessMode: a.accessMode
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const attachments = await resolveProviderAttachments({
|
const attachments = await resolveProviderAttachments({
|
||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
attachments: remaining,
|
attachments: remaining,
|
||||||
requireAtLeastOne: false
|
requireAtLeastOne: false,
|
||||||
|
resourceId
|
||||||
});
|
});
|
||||||
if (isInferenceFieldsError(attachments)) {
|
if (isInferenceFieldsError(attachments)) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import {
|
import {
|
||||||
assertPublicAllowlistApiEligible,
|
assertPublicModelListApiEligible,
|
||||||
assertModelsBelongToPublicAllowlistProviders
|
assertPublicResourceModelEntriesValid,
|
||||||
|
resourceAiModelEntrySchema
|
||||||
} from "@server/lib/aiInferenceResource";
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
const setResourceAiModelsBodySchema = z.strictObject({
|
const setResourceAiModelsBodySchema = z.strictObject({
|
||||||
modelIds: z.array(z.int().positive())
|
models: z.array(resourceAiModelEntrySchema)
|
||||||
});
|
});
|
||||||
|
|
||||||
const setResourceAiModelsParamsSchema = z.strictObject({
|
const setResourceAiModelsParamsSchema = z.strictObject({
|
||||||
@@ -25,7 +26,7 @@ registry.registerPath({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/resource/{resourceId}/ai-models",
|
path: "/resource/{resourceId}/ai-models",
|
||||||
description:
|
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],
|
tags: [OpenAPITags.PublicResource],
|
||||||
request: {
|
request: {
|
||||||
params: setResourceAiModelsParamsSchema,
|
params: setResourceAiModelsParamsSchema,
|
||||||
@@ -71,7 +72,7 @@ export async function setResourceAiModels(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modelIds } = parsedBody.data;
|
const { models } = parsedBody.data;
|
||||||
|
|
||||||
const parsedParams = setResourceAiModelsParamsSchema.safeParse(
|
const parsedParams = setResourceAiModelsParamsSchema.safeParse(
|
||||||
req.params
|
req.params
|
||||||
@@ -99,15 +100,22 @@ export async function setResourceAiModels(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const eligibleError = await assertPublicAllowlistApiEligible(resource);
|
const eligibleError = await assertPublicModelListApiEligible(resource);
|
||||||
if (eligibleError) {
|
if (eligibleError) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, 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,
|
orgId: resource.orgId,
|
||||||
resourceId,
|
resourceId,
|
||||||
modelIds
|
models: uniqueModels
|
||||||
});
|
});
|
||||||
if (modelError) {
|
if (modelError) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
|
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
|
||||||
@@ -118,12 +126,14 @@ export async function setResourceAiModels(
|
|||||||
.delete(resourceAiModels)
|
.delete(resourceAiModels)
|
||||||
.where(eq(resourceAiModels.resourceId, resourceId));
|
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||||
|
|
||||||
if (modelIds.length > 0) {
|
if (uniqueModels.length > 0) {
|
||||||
await trx
|
await trx.insert(resourceAiModels).values(
|
||||||
.insert(resourceAiModels)
|
uniqueModels.map((m) => ({
|
||||||
.values(
|
resourceId,
|
||||||
modelIds.map((modelId) => ({ resourceId, modelId }))
|
modelId: m.modelId,
|
||||||
);
|
listType: m.listType
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ registry.registerPath({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/resource/{resourceId}/ai-providers",
|
path: "/resource/{resourceId}/ai-providers",
|
||||||
description:
|
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],
|
tags: [OpenAPITags.PublicResource],
|
||||||
request: {
|
request: {
|
||||||
params: setResourceAiProvidersParamsSchema,
|
params: setResourceAiProvidersParamsSchema,
|
||||||
@@ -113,7 +113,8 @@ export async function setResourceAiProviders(
|
|||||||
const attachments = await resolveProviderAttachments({
|
const attachments = await resolveProviderAttachments({
|
||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
attachments: providers,
|
attachments: providers,
|
||||||
requireAtLeastOne: false
|
requireAtLeastOne: false,
|
||||||
|
resourceId
|
||||||
});
|
});
|
||||||
if (isInferenceFieldsError(attachments)) {
|
if (isInferenceFieldsError(attachments)) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import {
|
import {
|
||||||
assertSiteAllowlistApiEligible,
|
assertSiteModelListApiEligible,
|
||||||
assertModelsBelongToSiteAllowlistProviders
|
assertSiteResourceModelEntriesValid,
|
||||||
|
modelListTypeSchema
|
||||||
} from "@server/lib/aiInferenceResource";
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
const addAiModelToSiteResourceBodySchema = z.strictObject({
|
const addAiModelToSiteResourceBodySchema = z.strictObject({
|
||||||
modelId: z.int().positive()
|
modelId: z.number().int().positive(),
|
||||||
|
listType: modelListTypeSchema.optional().default("allow")
|
||||||
});
|
});
|
||||||
|
|
||||||
const addAiModelToSiteResourceParamsSchema = z.strictObject({
|
const addAiModelToSiteResourceParamsSchema = z.strictObject({
|
||||||
@@ -25,7 +27,7 @@ registry.registerPath({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/site-resource/{siteResourceId}/ai-models/add",
|
path: "/site-resource/{siteResourceId}/ai-models/add",
|
||||||
description:
|
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],
|
tags: [OpenAPITags.PrivateResource],
|
||||||
request: {
|
request: {
|
||||||
params: addAiModelToSiteResourceParamsSchema,
|
params: addAiModelToSiteResourceParamsSchema,
|
||||||
@@ -73,7 +75,7 @@ export async function addAiModelToSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modelId } = parsedBody.data;
|
const { modelId, listType } = parsedBody.data;
|
||||||
|
|
||||||
const parsedParams = addAiModelToSiteResourceParamsSchema.safeParse(
|
const parsedParams = addAiModelToSiteResourceParamsSchema.safeParse(
|
||||||
req.params
|
req.params
|
||||||
@@ -102,15 +104,15 @@ export async function addAiModelToSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eligibleError =
|
const eligibleError =
|
||||||
await assertSiteAllowlistApiEligible(siteResource);
|
await assertSiteModelListApiEligible(siteResource);
|
||||||
if (eligibleError) {
|
if (eligibleError) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelError = await assertModelsBelongToSiteAllowlistProviders({
|
const modelError = await assertSiteResourceModelEntriesValid({
|
||||||
orgId: siteResource.orgId,
|
orgId: siteResource.orgId,
|
||||||
siteResourceId,
|
siteResourceId,
|
||||||
modelIds: [modelId]
|
models: [{ modelId, listType }]
|
||||||
});
|
});
|
||||||
if (modelError) {
|
if (modelError) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
|
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
|
||||||
@@ -135,9 +137,11 @@ export async function addAiModelToSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db.insert(siteResourceAiModels).values({
|
||||||
.insert(siteResourceAiModels)
|
siteResourceId,
|
||||||
.values({ siteResourceId, modelId });
|
modelId,
|
||||||
|
listType
|
||||||
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import {
|
import {
|
||||||
isInferenceFieldsError,
|
isInferenceFieldsError,
|
||||||
listSiteResourceAiProviders,
|
listSiteResourceAiProviders,
|
||||||
modelAccessModeSchema,
|
|
||||||
resolveProviderAttachments,
|
resolveProviderAttachments,
|
||||||
setSiteResourceAiProviders
|
setSiteResourceAiProviders
|
||||||
} from "@server/lib/aiInferenceResource";
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
const addAiProviderToSiteResourceBodySchema = z.strictObject({
|
const addAiProviderToSiteResourceBodySchema = z.strictObject({
|
||||||
providerId: z.number().int().positive(),
|
providerId: z.number().int().positive()
|
||||||
modelAccessMode: modelAccessModeSchema.optional()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const addAiProviderToSiteResourceParamsSchema = z.strictObject({
|
const addAiProviderToSiteResourceParamsSchema = z.strictObject({
|
||||||
@@ -29,7 +27,7 @@ registry.registerPath({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/site-resource/{siteResourceId}/ai-providers/add",
|
path: "/site-resource/{siteResourceId}/ai-providers/add",
|
||||||
description:
|
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],
|
tags: [OpenAPITags.PrivateResource],
|
||||||
request: {
|
request: {
|
||||||
params: addAiProviderToSiteResourceParamsSchema,
|
params: addAiProviderToSiteResourceParamsSchema,
|
||||||
@@ -77,7 +75,7 @@ export async function addAiProviderToSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { providerId, modelAccessMode } = parsedBody.data;
|
const { providerId } = parsedBody.data;
|
||||||
|
|
||||||
const parsedParams = addAiProviderToSiteResourceParamsSchema.safeParse(
|
const parsedParams = addAiProviderToSiteResourceParamsSchema.safeParse(
|
||||||
req.params
|
req.params
|
||||||
@@ -120,18 +118,21 @@ export async function addAiProviderToSiteResource(
|
|||||||
.filter((a) => a.providerId !== providerId)
|
.filter((a) => a.providerId !== providerId)
|
||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
providerId: a.providerId,
|
providerId: a.providerId,
|
||||||
modelAccessMode: a.modelAccessMode
|
accessMode: a.accessMode
|
||||||
})),
|
})),
|
||||||
{ providerId, modelAccessMode }
|
{ providerId, accessMode: "inherit" as const }
|
||||||
];
|
];
|
||||||
|
|
||||||
const attachments = await resolveProviderAttachments({
|
const attachments = await resolveProviderAttachments({
|
||||||
orgId: siteResource.orgId,
|
orgId: siteResource.orgId,
|
||||||
attachments: nextAttachments,
|
attachments: nextAttachments,
|
||||||
requireAtLeastOne: true
|
requireAtLeastOne: true,
|
||||||
|
siteResourceId
|
||||||
});
|
});
|
||||||
if (isInferenceFieldsError(attachments)) {
|
if (isInferenceFieldsError(attachments)) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await setSiteResourceAiProviders(siteResourceId, attachments);
|
await setSiteResourceAiProviders(siteResourceId, attachments);
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const createSiteResourceSchema = z
|
|||||||
.array(resourceAiProviderAttachmentSchema)
|
.array(resourceAiProviderAttachmentSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.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()
|
.strict()
|
||||||
@@ -350,9 +350,14 @@ export async function createSiteResource(
|
|||||||
|
|
||||||
let providerAttachments: ResourceAiProviderAttachment[] = [];
|
let providerAttachments: ResourceAiProviderAttachment[] = [];
|
||||||
if (mode === "inference") {
|
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({
|
const resolved = await resolveProviderAttachments({
|
||||||
orgId,
|
orgId,
|
||||||
attachments: aiProviderInputs ?? [],
|
attachments: (aiProviderInputs ?? []).map((p) => ({
|
||||||
|
providerId: p.providerId,
|
||||||
|
accessMode: "inherit" as const
|
||||||
|
})),
|
||||||
requireAtLeastOne: false
|
requireAtLeastOne: false
|
||||||
});
|
});
|
||||||
if (isInferenceFieldsError(resolved)) {
|
if (isInferenceFieldsError(resolved)) {
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ async function query(siteResourceId: number) {
|
|||||||
modelId: aiModels.modelId,
|
modelId: aiModels.modelId,
|
||||||
modelKey: aiModels.modelKey,
|
modelKey: aiModels.modelKey,
|
||||||
name: aiModels.name,
|
name: aiModels.name,
|
||||||
enabled: aiModels.enabled
|
providerId: aiModels.providerId,
|
||||||
|
enabled: aiModels.enabled,
|
||||||
|
listType: siteResourceAiModels.listType
|
||||||
})
|
})
|
||||||
.from(siteResourceAiModels)
|
.from(siteResourceAiModels)
|
||||||
.innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId))
|
.innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId))
|
||||||
@@ -34,7 +36,7 @@ registry.registerPath({
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/site-resource/{siteResourceId}/ai-models",
|
path: "/site-resource/{siteResourceId}/ai-models",
|
||||||
description:
|
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],
|
tags: [OpenAPITags.PrivateResource],
|
||||||
request: {
|
request: {
|
||||||
params: listSiteResourceAiModelsParamsSchema
|
params: listSiteResourceAiModelsParamsSchema
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export type ListSiteResourceAiProvidersResponse = {
|
|||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/site-resource/{siteResourceId}/ai-providers",
|
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],
|
tags: [OpenAPITags.PrivateResource],
|
||||||
request: {
|
request: {
|
||||||
params: listSiteResourceAiProvidersParamsSchema
|
params: listSiteResourceAiProvidersParamsSchema
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { assertSiteAllowlistApiEligible } from "@server/lib/aiInferenceResource";
|
import { assertSiteModelListApiEligible } from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
const removeAiModelFromSiteResourceBodySchema = z.strictObject({
|
const removeAiModelFromSiteResourceBodySchema = z.strictObject({
|
||||||
modelId: z.int().positive()
|
modelId: z.int().positive()
|
||||||
@@ -22,7 +22,7 @@ registry.registerPath({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/site-resource/{siteResourceId}/ai-models/remove",
|
path: "/site-resource/{siteResourceId}/ai-models/remove",
|
||||||
description:
|
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],
|
tags: [OpenAPITags.PrivateResource],
|
||||||
request: {
|
request: {
|
||||||
params: removeAiModelFromSiteResourceParamsSchema,
|
params: removeAiModelFromSiteResourceParamsSchema,
|
||||||
@@ -98,7 +98,7 @@ export async function removeAiModelFromSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eligibleError =
|
const eligibleError =
|
||||||
await assertSiteAllowlistApiEligible(siteResource);
|
await assertSiteModelListApiEligible(siteResource);
|
||||||
if (eligibleError) {
|
if (eligibleError) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,13 +126,14 @@ export async function removeAiProviderFromSiteResource(
|
|||||||
.filter((a) => a.providerId !== providerId)
|
.filter((a) => a.providerId !== providerId)
|
||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
providerId: a.providerId,
|
providerId: a.providerId,
|
||||||
modelAccessMode: a.modelAccessMode
|
accessMode: a.accessMode
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const attachments = await resolveProviderAttachments({
|
const attachments = await resolveProviderAttachments({
|
||||||
orgId: siteResource.orgId,
|
orgId: siteResource.orgId,
|
||||||
attachments: remaining,
|
attachments: remaining,
|
||||||
requireAtLeastOne: false
|
requireAtLeastOne: false,
|
||||||
|
siteResourceId
|
||||||
});
|
});
|
||||||
if (isInferenceFieldsError(attachments)) {
|
if (isInferenceFieldsError(attachments)) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import {
|
import {
|
||||||
assertSiteAllowlistApiEligible,
|
assertSiteModelListApiEligible,
|
||||||
assertModelsBelongToSiteAllowlistProviders
|
assertSiteResourceModelEntriesValid,
|
||||||
|
resourceAiModelEntrySchema
|
||||||
} from "@server/lib/aiInferenceResource";
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
const setSiteResourceAiModelsBodySchema = z.strictObject({
|
const setSiteResourceAiModelsBodySchema = z.strictObject({
|
||||||
modelIds: z.array(z.int().positive())
|
models: z.array(resourceAiModelEntrySchema)
|
||||||
});
|
});
|
||||||
|
|
||||||
const setSiteResourceAiModelsParamsSchema = z.strictObject({
|
const setSiteResourceAiModelsParamsSchema = z.strictObject({
|
||||||
@@ -25,7 +26,7 @@ registry.registerPath({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/site-resource/{siteResourceId}/ai-models",
|
path: "/site-resource/{siteResourceId}/ai-models",
|
||||||
description:
|
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],
|
tags: [OpenAPITags.PrivateResource],
|
||||||
request: {
|
request: {
|
||||||
params: setSiteResourceAiModelsParamsSchema,
|
params: setSiteResourceAiModelsParamsSchema,
|
||||||
@@ -73,7 +74,7 @@ export async function setSiteResourceAiModels(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modelIds } = parsedBody.data;
|
const { models } = parsedBody.data;
|
||||||
|
|
||||||
const parsedParams = setSiteResourceAiModelsParamsSchema.safeParse(
|
const parsedParams = setSiteResourceAiModelsParamsSchema.safeParse(
|
||||||
req.params
|
req.params
|
||||||
@@ -102,15 +103,22 @@ export async function setSiteResourceAiModels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eligibleError =
|
const eligibleError =
|
||||||
await assertSiteAllowlistApiEligible(siteResource);
|
await assertSiteModelListApiEligible(siteResource);
|
||||||
if (eligibleError) {
|
if (eligibleError) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, 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,
|
orgId: siteResource.orgId,
|
||||||
siteResourceId,
|
siteResourceId,
|
||||||
modelIds
|
models: uniqueModels
|
||||||
});
|
});
|
||||||
if (modelError) {
|
if (modelError) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
|
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
|
||||||
@@ -121,11 +129,12 @@ export async function setSiteResourceAiModels(
|
|||||||
.delete(siteResourceAiModels)
|
.delete(siteResourceAiModels)
|
||||||
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
|
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
|
||||||
|
|
||||||
if (modelIds.length > 0) {
|
if (uniqueModels.length > 0) {
|
||||||
await trx.insert(siteResourceAiModels).values(
|
await trx.insert(siteResourceAiModels).values(
|
||||||
modelIds.map((modelId) => ({
|
uniqueModels.map((m) => ({
|
||||||
siteResourceId,
|
siteResourceId,
|
||||||
modelId
|
modelId: m.modelId,
|
||||||
|
listType: m.listType
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ registry.registerPath({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/site-resource/{siteResourceId}/ai-providers",
|
path: "/site-resource/{siteResourceId}/ai-providers",
|
||||||
description:
|
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],
|
tags: [OpenAPITags.PrivateResource],
|
||||||
request: {
|
request: {
|
||||||
params: setSiteResourceAiProvidersParamsSchema,
|
params: setSiteResourceAiProvidersParamsSchema,
|
||||||
@@ -115,7 +115,8 @@ export async function setSiteResourceAiProviders(
|
|||||||
const attachments = await resolveProviderAttachments({
|
const attachments = await resolveProviderAttachments({
|
||||||
orgId: siteResource.orgId,
|
orgId: siteResource.orgId,
|
||||||
attachments: providers,
|
attachments: providers,
|
||||||
requireAtLeastOne: false
|
requireAtLeastOne: false,
|
||||||
|
siteResourceId
|
||||||
});
|
});
|
||||||
if (isInferenceFieldsError(attachments)) {
|
if (isInferenceFieldsError(attachments)) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Label } from "@app/components/ui/label";
|
||||||
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
|
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
@@ -21,6 +22,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type ModelListType = "allow" | "block";
|
||||||
|
|
||||||
export default function AiProviderModelsPage() {
|
export default function AiProviderModelsPage() {
|
||||||
const { provider } = useAiProviderContext();
|
const { provider } = useAiProviderContext();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -28,8 +31,14 @@ export default function AiProviderModelsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [allowTags, setAllowTags] = useState<Tag[]>([]);
|
||||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
const [blockTags, setBlockTags] = useState<Tag[]>([]);
|
||||||
|
const [activeAllowTagIndex, setActiveAllowTagIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
const [activeBlockTagIndex, setActiveBlockTagIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const modelsQuery = useQuery(
|
const modelsQuery = useQuery(
|
||||||
aiProviderQueries.providerModels({ providerId: provider.providerId })
|
aiProviderQueries.providerModels({ providerId: provider.providerId })
|
||||||
@@ -37,11 +46,21 @@ export default function AiProviderModelsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modelsQuery.data) return;
|
if (!modelsQuery.data) return;
|
||||||
setTags(
|
setAllowTags(
|
||||||
modelsQuery.data.map((model) => ({
|
modelsQuery.data
|
||||||
id: String(model.modelId),
|
.filter((model) => (model.listType ?? "allow") === "allow")
|
||||||
text: model.modelKey
|
.map((model) => ({
|
||||||
}))
|
id: String(model.modelId),
|
||||||
|
text: model.modelKey
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setBlockTags(
|
||||||
|
modelsQuery.data
|
||||||
|
.filter((model) => model.listType === "block")
|
||||||
|
.map((model) => ({
|
||||||
|
id: String(model.modelId),
|
||||||
|
text: model.modelKey
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
}, [modelsQuery.data]);
|
}, [modelsQuery.data]);
|
||||||
|
|
||||||
@@ -52,27 +71,74 @@ export default function AiProviderModelsPage() {
|
|||||||
const existingByKey = new Map(
|
const existingByKey = new Map(
|
||||||
existing.map((model) => [model.modelKey, model])
|
existing.map((model) => [model.modelKey, model])
|
||||||
);
|
);
|
||||||
const nextKeys = new Set(
|
|
||||||
tags.map((tag) => tag.text.trim()).filter(Boolean)
|
const nextAllow = new Set(
|
||||||
|
allowTags.map((tag) => tag.text.trim()).filter(Boolean)
|
||||||
|
);
|
||||||
|
const nextBlock = new Set(
|
||||||
|
blockTags.map((tag) => tag.text.trim()).filter(Boolean)
|
||||||
);
|
);
|
||||||
|
|
||||||
const toCreate = [...nextKeys].filter(
|
const overlap = [...nextAllow].filter((key) => nextBlock.has(key));
|
||||||
(key) => !existingByKey.has(key)
|
if (overlap.length > 0) {
|
||||||
);
|
toast({
|
||||||
const toDelete = existing.filter(
|
variant: "destructive",
|
||||||
(model) => !nextKeys.has(model.modelKey)
|
title: t("aiProviderModelsErrorUpdate"),
|
||||||
);
|
description: t("aiProviderModelsOverlapError", {
|
||||||
|
keys: overlap.join(", ")
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desired = new Map<string, ModelListType>();
|
||||||
|
for (const key of nextAllow) {
|
||||||
|
desired.set(key, "allow");
|
||||||
|
}
|
||||||
|
for (const key of nextBlock) {
|
||||||
|
desired.set(key, "block");
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCreate: { modelKey: string; listType: ModelListType }[] =
|
||||||
|
[];
|
||||||
|
const toUpdate: {
|
||||||
|
modelId: number;
|
||||||
|
listType: ModelListType;
|
||||||
|
}[] = [];
|
||||||
|
const toDelete: number[] = [];
|
||||||
|
|
||||||
|
for (const [modelKey, listType] of desired) {
|
||||||
|
const existingModel = existingByKey.get(modelKey);
|
||||||
|
if (!existingModel) {
|
||||||
|
toCreate.push({ modelKey, listType });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((existingModel.listType ?? "allow") !== listType) {
|
||||||
|
toUpdate.push({
|
||||||
|
modelId: existingModel.modelId,
|
||||||
|
listType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const model of existing) {
|
||||||
|
if (!desired.has(model.modelKey)) {
|
||||||
|
toDelete.push(model.modelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...toCreate.map((modelKey) =>
|
...toCreate.map(({ modelKey, listType }) =>
|
||||||
api.put(`/ai-provider/${provider.providerId}/model`, {
|
api.put(`/ai-provider/${provider.providerId}/model`, {
|
||||||
modelKey,
|
modelKey,
|
||||||
name: modelKey
|
name: modelKey,
|
||||||
|
listType
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
...toDelete.map((model) =>
|
...toUpdate.map(({ modelId, listType }) =>
|
||||||
api.delete(`/ai-model/${model.modelId}`)
|
api.post(`/ai-model/${modelId}`, { listType })
|
||||||
)
|
),
|
||||||
|
...toDelete.map((modelId) => api.delete(`/ai-model/${modelId}`))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await queryClient.invalidateQueries(
|
await queryClient.invalidateQueries(
|
||||||
@@ -113,24 +179,59 @@ export default function AiProviderModelsPage() {
|
|||||||
|
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm variant="half">
|
<SettingsSectionForm variant="half">
|
||||||
<TagInput
|
<div className="space-y-2">
|
||||||
activeTagIndex={activeTagIndex}
|
<Label>{t("aiProviderModelsAllow")}</Label>
|
||||||
setActiveTagIndex={setActiveTagIndex}
|
<TagInput
|
||||||
placeholder={t("aiProviderModelsPlaceholder")}
|
activeTagIndex={activeAllowTagIndex}
|
||||||
size="sm"
|
setActiveTagIndex={setActiveAllowTagIndex}
|
||||||
tags={tags}
|
placeholder={t(
|
||||||
setTags={(newTags) => {
|
"aiProviderModelsAllowPlaceholder"
|
||||||
const next =
|
)}
|
||||||
typeof newTags === "function"
|
size="sm"
|
||||||
? newTags(tags)
|
tags={allowTags}
|
||||||
: newTags;
|
setTags={(newTags) => {
|
||||||
setTags(next as Tag[]);
|
const next =
|
||||||
}}
|
typeof newTags === "function"
|
||||||
allowDuplicates={false}
|
? newTags(allowTags)
|
||||||
sortTags
|
: newTags;
|
||||||
delimiterList={[",", "Enter"]}
|
setAllowTags(next as Tag[]);
|
||||||
disabled={modelsQuery.isLoading || saveLoading}
|
}}
|
||||||
/>
|
allowDuplicates={false}
|
||||||
|
sortTags
|
||||||
|
delimiterList={[",", "Enter"]}
|
||||||
|
disabled={modelsQuery.isLoading || saveLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("aiProviderModelsAllowDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("aiProviderModelsBlock")}</Label>
|
||||||
|
<TagInput
|
||||||
|
activeTagIndex={activeBlockTagIndex}
|
||||||
|
setActiveTagIndex={setActiveBlockTagIndex}
|
||||||
|
placeholder={t(
|
||||||
|
"aiProviderModelsBlockPlaceholder"
|
||||||
|
)}
|
||||||
|
size="sm"
|
||||||
|
tags={blockTags}
|
||||||
|
setTags={(newTags) => {
|
||||||
|
const next =
|
||||||
|
typeof newTags === "function"
|
||||||
|
? newTags(blockTags)
|
||||||
|
: newTags;
|
||||||
|
setBlockTags(next as Tag[]);
|
||||||
|
}}
|
||||||
|
allowDuplicates={false}
|
||||||
|
sortTags
|
||||||
|
delimiterList={[",", "Enter"]}
|
||||||
|
disabled={modelsQuery.isLoading || saveLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("aiProviderModelsBlockDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
|||||||
@@ -130,8 +130,7 @@ export default function PrivateResourceInferencePage() {
|
|||||||
|
|
||||||
await api.post(`/site-resource/${siteResource.id}/ai-providers`, {
|
await api.post(`/site-resource/${siteResource.id}/ai-providers`, {
|
||||||
providers: data.providerIds.map((providerId) => ({
|
providers: data.providerIds.map((providerId) => ({
|
||||||
providerId,
|
providerId
|
||||||
modelAccessMode: "catalog"
|
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,12 +257,10 @@ export default function PrivateResourceInferencePage() {
|
|||||||
cols={2}
|
cols={2}
|
||||||
hideFreeDomain
|
hideFreeDomain
|
||||||
defaultSubdomain={
|
defaultSubdomain={
|
||||||
httpConfigSubdomain ??
|
httpConfigSubdomain ?? undefined
|
||||||
undefined
|
|
||||||
}
|
}
|
||||||
defaultDomainId={
|
defaultDomainId={
|
||||||
httpConfigDomainId ??
|
httpConfigDomainId ?? undefined
|
||||||
undefined
|
|
||||||
}
|
}
|
||||||
defaultFullDomain={
|
defaultFullDomain={
|
||||||
httpConfigFullDomain ??
|
httpConfigFullDomain ??
|
||||||
|
|||||||
@@ -102,8 +102,7 @@ export default function PublicResourceInferencePage() {
|
|||||||
try {
|
try {
|
||||||
await api.post(`/resource/${resource.resourceId}/ai-providers`, {
|
await api.post(`/resource/${resource.resourceId}/ai-providers`, {
|
||||||
providers: data.providerIds.map((providerId) => ({
|
providers: data.providerIds.map((providerId) => ({
|
||||||
providerId,
|
providerId
|
||||||
modelAccessMode: "catalog"
|
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -497,8 +497,7 @@ export default function Page() {
|
|||||||
if (resourceType === "inference") {
|
if (resourceType === "inference") {
|
||||||
Object.assign(payload, {
|
Object.assign(payload, {
|
||||||
aiProviders: selectedProviders.map((provider) => ({
|
aiProviders: selectedProviders.map((provider) => ({
|
||||||
providerId: parseInt(provider.id, 10),
|
providerId: parseInt(provider.id, 10)
|
||||||
modelAccessMode: "catalog"
|
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
} else if (resourceType === "ssh") {
|
} else if (resourceType === "ssh") {
|
||||||
|
|||||||
@@ -217,8 +217,7 @@ export function buildCreateSiteResourcePayload(
|
|||||||
}),
|
}),
|
||||||
...(data.mode === "inference" && {
|
...(data.mode === "inference" && {
|
||||||
aiProviders: (data.providerIds ?? []).map((providerId) => ({
|
aiProviders: (data.providerIds ?? []).map((providerId) => ({
|
||||||
providerId,
|
providerId
|
||||||
modelAccessMode: "catalog" as const
|
|
||||||
})),
|
})),
|
||||||
ssl: data.ssl ?? false,
|
ssl: data.ssl ?? false,
|
||||||
domainId: data.httpConfigDomainId
|
domainId: data.httpConfigDomainId
|
||||||
|
|||||||
+2
-2
@@ -1288,10 +1288,10 @@ export const resourceQueries = {
|
|||||||
AxiosResponse<{
|
AxiosResponse<{
|
||||||
providers: Array<{
|
providers: Array<{
|
||||||
providerId: number;
|
providerId: number;
|
||||||
modelAccessMode: "catalog" | "allowlist";
|
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
accessMode: "inherit" | "select";
|
||||||
}>;
|
}>;
|
||||||
}>
|
}>
|
||||||
>(`/site-resource/${siteResourceId}/ai-providers`, {
|
>(`/site-resource/${siteResourceId}/ai-providers`, {
|
||||||
@@ -1308,10 +1308,10 @@ export const resourceQueries = {
|
|||||||
AxiosResponse<{
|
AxiosResponse<{
|
||||||
providers: Array<{
|
providers: Array<{
|
||||||
providerId: number;
|
providerId: number;
|
||||||
modelAccessMode: "catalog" | "allowlist";
|
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
accessMode: "inherit" | "select";
|
||||||
}>;
|
}>;
|
||||||
}>
|
}>
|
||||||
>(`/resource/${resourceId}/ai-providers`, {
|
>(`/resource/${resourceId}/ai-providers`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user