mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-05 12:10:52 +02:00
add basic ui for private inference resource
This commit is contained in:
@@ -232,9 +232,9 @@ export const resourceAiProviders = pgTable(
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
modelAccessMode: varchar("modelAccessMode")
|
||||
.$type<"passthrough" | "catalog" | "allowlist">()
|
||||
.$type<"catalog" | "allowlist">()
|
||||
.notNull()
|
||||
.default("passthrough")
|
||||
.default("catalog")
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
||||
);
|
||||
@@ -523,9 +523,9 @@ export const siteResourceAiProviders = pgTable(
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
modelAccessMode: varchar("modelAccessMode")
|
||||
.$type<"passthrough" | "catalog" | "allowlist">()
|
||||
.$type<"catalog" | "allowlist">()
|
||||
.notNull()
|
||||
.default("passthrough")
|
||||
.default("catalog")
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
||||
);
|
||||
|
||||
@@ -229,9 +229,9 @@ export const resourceAiProviders = sqliteTable(
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
modelAccessMode: text("modelAccessMode")
|
||||
.$type<"passthrough" | "catalog" | "allowlist">()
|
||||
.$type<"catalog" | "allowlist">()
|
||||
.notNull()
|
||||
.default("passthrough")
|
||||
.default("catalog")
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
||||
);
|
||||
@@ -508,9 +508,9 @@ export const siteResourceAiProviders = sqliteTable(
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
modelAccessMode: text("modelAccessMode")
|
||||
.$type<"passthrough" | "catalog" | "allowlist">()
|
||||
.$type<"catalog" | "allowlist">()
|
||||
.notNull()
|
||||
.default("passthrough")
|
||||
.default("catalog")
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
||||
);
|
||||
|
||||
@@ -13,11 +13,7 @@ import { z } from "zod";
|
||||
|
||||
type DbOrTrx = Transaction | typeof db;
|
||||
|
||||
export const modelAccessModeSchema = z.enum([
|
||||
"passthrough",
|
||||
"catalog",
|
||||
"allowlist"
|
||||
]);
|
||||
export const modelAccessModeSchema = z.enum(["catalog", "allowlist"]);
|
||||
|
||||
export type ModelAccessMode = z.infer<typeof modelAccessModeSchema>;
|
||||
|
||||
@@ -50,10 +46,7 @@ function normalizeAttachments(
|
||||
): ResourceAiProviderAttachment[] {
|
||||
const byProvider = new Map<number, ModelAccessMode>();
|
||||
for (const input of inputs) {
|
||||
byProvider.set(
|
||||
input.providerId,
|
||||
input.modelAccessMode ?? "passthrough"
|
||||
);
|
||||
byProvider.set(input.providerId, input.modelAccessMode ?? "catalog");
|
||||
}
|
||||
return [...byProvider.entries()].map(([providerId, modelAccessMode]) => ({
|
||||
providerId,
|
||||
@@ -61,9 +54,61 @@ function normalizeAttachments(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure enabled catalog modelKeys are unique across attached providers.
|
||||
* Catalog attachments contribute all enabled models on the provider.
|
||||
* Allowlist attachments contribute nothing until models are allowlisted
|
||||
* (those are checked when the allowlist is set).
|
||||
*/
|
||||
export async function assertNoOverlappingModelKeys(
|
||||
attachments: ResourceAiProviderAttachment[],
|
||||
trx: DbOrTrx = db
|
||||
): Promise<InferenceFieldsError | null> {
|
||||
const catalogProviderIds = attachments
|
||||
.filter((a) => a.modelAccessMode === "catalog")
|
||||
.map((a) => a.providerId);
|
||||
|
||||
if (catalogProviderIds.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const models = await trx
|
||||
.select({
|
||||
providerId: aiModels.providerId,
|
||||
modelKey: aiModels.modelKey
|
||||
})
|
||||
.from(aiModels)
|
||||
.where(
|
||||
and(
|
||||
inArray(aiModels.providerId, catalogProviderIds),
|
||||
eq(aiModels.enabled, true)
|
||||
)
|
||||
);
|
||||
|
||||
const keyToProviders = new Map<string, number[]>();
|
||||
for (const model of models) {
|
||||
const existing = keyToProviders.get(model.modelKey) ?? [];
|
||||
if (!existing.includes(model.providerId)) {
|
||||
existing.push(model.providerId);
|
||||
}
|
||||
keyToProviders.set(model.modelKey, existing);
|
||||
}
|
||||
|
||||
const overlaps = [...keyToProviders.entries()].filter(
|
||||
([, providerIds]) => providerIds.length > 1
|
||||
);
|
||||
if (overlaps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keys = overlaps.map(([key]) => key).sort();
|
||||
return {
|
||||
error: `Model keys must be unique across providers on a resource. Overlapping keys: ${keys.join(", ")}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate provider attachments for an org.
|
||||
* At most one passthrough provider is allowed per resource.
|
||||
*/
|
||||
export async function resolveProviderAttachments(input: {
|
||||
orgId: string;
|
||||
@@ -78,15 +123,6 @@ export async function resolveProviderAttachments(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const passthroughCount = attachments.filter(
|
||||
(a) => a.modelAccessMode === "passthrough"
|
||||
).length;
|
||||
if (passthroughCount > 1) {
|
||||
return {
|
||||
error: "A resource may have at most one AI provider in passthrough mode"
|
||||
};
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -119,6 +155,11 @@ export async function resolveProviderAttachments(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const overlapError = await assertNoOverlappingModelKeys(attachments);
|
||||
if (overlapError) {
|
||||
return overlapError;
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
|
||||
@@ -331,10 +331,6 @@ async function providerMatchesModel(
|
||||
requestedModel: string,
|
||||
allowedModelIds: number[]
|
||||
): Promise<boolean> {
|
||||
if (attachment.modelAccessMode === "passthrough") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [matchedModel] = await db
|
||||
.select({
|
||||
modelId: aiModels.modelId,
|
||||
@@ -366,26 +362,7 @@ async function selectProvider(
|
||||
allowedModelIds: number[],
|
||||
requestedModel: string | undefined
|
||||
): Promise<ProviderSelection> {
|
||||
const passthroughAttachments = attachments.filter(
|
||||
(a) => a.modelAccessMode === "passthrough"
|
||||
);
|
||||
const hasRestricted = attachments.some(
|
||||
(a) =>
|
||||
a.modelAccessMode === "catalog" || a.modelAccessMode === "allowlist"
|
||||
);
|
||||
|
||||
if (!requestedModel) {
|
||||
if (hasRestricted) {
|
||||
return {
|
||||
ok: false,
|
||||
status: HttpCode.FORBIDDEN,
|
||||
message:
|
||||
"This resource restricts access to specific models; a model must be specified"
|
||||
};
|
||||
}
|
||||
if (passthroughAttachments.length === 1) {
|
||||
return { ok: true, provider: passthroughAttachments[0].provider };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: HttpCode.FORBIDDEN,
|
||||
@@ -395,10 +372,6 @@ async function selectProvider(
|
||||
|
||||
const candidates: ProviderAttachment[] = [];
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.modelAccessMode === "passthrough") {
|
||||
candidates.push(attachment);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
await providerMatchesModel(
|
||||
attachment,
|
||||
@@ -422,11 +395,6 @@ async function selectProvider(
|
||||
};
|
||||
}
|
||||
|
||||
// Zero candidates: fall back to a single passthrough attachment if present
|
||||
if (passthroughAttachments.length === 1) {
|
||||
return { ok: true, provider: passthroughAttachments[0].provider };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: HttpCode.FORBIDDEN,
|
||||
|
||||
@@ -109,7 +109,7 @@ const createHttpResourceSchema = z
|
||||
.array(resourceAiProviderAttachmentSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
"For inference-mode resources: AI providers to attach. Each entry may set modelAccessMode (passthrough, catalog, or allowlist); defaults to passthrough. At most one passthrough provider is allowed."
|
||||
"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."
|
||||
)
|
||||
})
|
||||
.refine(
|
||||
@@ -397,9 +397,7 @@ async function createHttpResource(
|
||||
requireAtLeastOne: true
|
||||
});
|
||||
if (isInferenceFieldsError(resolved)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, resolved.error)
|
||||
);
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, resolved.error));
|
||||
}
|
||||
providerAttachments = resolved;
|
||||
} else if (aiProviderInputs && aiProviderInputs.length > 0) {
|
||||
|
||||
@@ -27,7 +27,7 @@ registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource/{resourceId}/ai-providers",
|
||||
description:
|
||||
"Replace the AI providers attached to an inference resource. At least one provider is required. At most one may use passthrough mode.",
|
||||
"Replace the AI providers attached to an inference resource. At least one provider is required. Model keys must be unique across attached catalog providers.",
|
||||
tags: [OpenAPITags.PublicResource],
|
||||
request: {
|
||||
params: setResourceAiProvidersParamsSchema,
|
||||
@@ -116,7 +116,9 @@ export async function setResourceAiProviders(
|
||||
requireAtLeastOne: true
|
||||
});
|
||||
if (isInferenceFieldsError(attachments)) {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
|
||||
);
|
||||
}
|
||||
|
||||
await setPublicResourceAiProviders(resourceId, attachments);
|
||||
|
||||
@@ -90,7 +90,7 @@ const createSiteResourceSchema = z
|
||||
.array(resourceAiProviderAttachmentSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
"For inference-mode site resources: AI providers to attach. Each entry may set modelAccessMode (passthrough, catalog, or allowlist); defaults to passthrough. At most one passthrough provider is allowed."
|
||||
"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."
|
||||
)
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -27,7 +27,7 @@ registry.registerPath({
|
||||
method: "post",
|
||||
path: "/site-resource/{siteResourceId}/ai-providers",
|
||||
description:
|
||||
"Replace the AI providers attached to an inference site resource. At least one provider is required. At most one may use passthrough mode.",
|
||||
"Replace the AI providers attached to an inference site resource. At least one provider is required. Model keys must be unique across attached catalog providers.",
|
||||
tags: [OpenAPITags.PrivateResource],
|
||||
request: {
|
||||
params: setSiteResourceAiProvidersParamsSchema,
|
||||
@@ -118,7 +118,9 @@ export async function setSiteResourceAiProviders(
|
||||
requireAtLeastOne: true
|
||||
});
|
||||
if (isInferenceFieldsError(attachments)) {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
|
||||
);
|
||||
}
|
||||
|
||||
await replaceAttachments(siteResourceId, attachments);
|
||||
|
||||
Reference in New Issue
Block a user