support allow list and blocklist

This commit is contained in:
miloschwartz
2026-08-07 10:59:35 -04:00
parent 07f628b928
commit 9e7b4afaec
34 changed files with 891 additions and 470 deletions
@@ -12,6 +12,7 @@ import {
} from "@app/components/Settings";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { Button } from "@app/components/ui/button";
import { Label } from "@app/components/ui/label";
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
@@ -21,6 +22,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
type ModelListType = "allow" | "block";
export default function AiProviderModelsPage() {
const { provider } = useAiProviderContext();
const { env } = useEnvContext();
@@ -28,8 +31,14 @@ export default function AiProviderModelsPage() {
const queryClient = useQueryClient();
const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false);
const [tags, setTags] = useState<Tag[]>([]);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const [allowTags, setAllowTags] = useState<Tag[]>([]);
const [blockTags, setBlockTags] = useState<Tag[]>([]);
const [activeAllowTagIndex, setActiveAllowTagIndex] = useState<
number | null
>(null);
const [activeBlockTagIndex, setActiveBlockTagIndex] = useState<
number | null
>(null);
const modelsQuery = useQuery(
aiProviderQueries.providerModels({ providerId: provider.providerId })
@@ -37,11 +46,21 @@ export default function AiProviderModelsPage() {
useEffect(() => {
if (!modelsQuery.data) return;
setTags(
modelsQuery.data.map((model) => ({
id: String(model.modelId),
text: model.modelKey
}))
setAllowTags(
modelsQuery.data
.filter((model) => (model.listType ?? "allow") === "allow")
.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]);
@@ -52,27 +71,74 @@ export default function AiProviderModelsPage() {
const existingByKey = new Map(
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(
(key) => !existingByKey.has(key)
);
const toDelete = existing.filter(
(model) => !nextKeys.has(model.modelKey)
);
const overlap = [...nextAllow].filter((key) => nextBlock.has(key));
if (overlap.length > 0) {
toast({
variant: "destructive",
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([
...toCreate.map((modelKey) =>
...toCreate.map(({ modelKey, listType }) =>
api.put(`/ai-provider/${provider.providerId}/model`, {
modelKey,
name: modelKey
name: modelKey,
listType
})
),
...toDelete.map((model) =>
api.delete(`/ai-model/${model.modelId}`)
)
...toUpdate.map(({ modelId, listType }) =>
api.post(`/ai-model/${modelId}`, { listType })
),
...toDelete.map((modelId) => api.delete(`/ai-model/${modelId}`))
]);
await queryClient.invalidateQueries(
@@ -113,24 +179,59 @@ export default function AiProviderModelsPage() {
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<TagInput
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder={t("aiProviderModelsPlaceholder")}
size="sm"
tags={tags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(tags)
: newTags;
setTags(next as Tag[]);
}}
allowDuplicates={false}
sortTags
delimiterList={[",", "Enter"]}
disabled={modelsQuery.isLoading || saveLoading}
/>
<div className="space-y-2">
<Label>{t("aiProviderModelsAllow")}</Label>
<TagInput
activeTagIndex={activeAllowTagIndex}
setActiveTagIndex={setActiveAllowTagIndex}
placeholder={t(
"aiProviderModelsAllowPlaceholder"
)}
size="sm"
tags={allowTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(allowTags)
: newTags;
setAllowTags(next as Tag[]);
}}
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>
</SettingsSectionBody>
@@ -130,8 +130,7 @@ export default function PrivateResourceInferencePage() {
await api.post(`/site-resource/${siteResource.id}/ai-providers`, {
providers: data.providerIds.map((providerId) => ({
providerId,
modelAccessMode: "catalog"
providerId
}))
});
@@ -258,12 +257,10 @@ export default function PrivateResourceInferencePage() {
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ??
undefined
httpConfigSubdomain ?? undefined
}
defaultDomainId={
httpConfigDomainId ??
undefined
httpConfigDomainId ?? undefined
}
defaultFullDomain={
httpConfigFullDomain ??
@@ -102,8 +102,7 @@ export default function PublicResourceInferencePage() {
try {
await api.post(`/resource/${resource.resourceId}/ai-providers`, {
providers: data.providerIds.map((providerId) => ({
providerId,
modelAccessMode: "catalog"
providerId
}))
});
@@ -497,8 +497,7 @@ export default function Page() {
if (resourceType === "inference") {
Object.assign(payload, {
aiProviders: selectedProviders.map((provider) => ({
providerId: parseInt(provider.id, 10),
modelAccessMode: "catalog"
providerId: parseInt(provider.id, 10)
}))
});
} else if (resourceType === "ssh") {
+1 -2
View File
@@ -217,8 +217,7 @@ export function buildCreateSiteResourcePayload(
}),
...(data.mode === "inference" && {
aiProviders: (data.providerIds ?? []).map((providerId) => ({
providerId,
modelAccessMode: "catalog" as const
providerId
})),
ssl: data.ssl ?? false,
domainId: data.httpConfigDomainId
+2 -2
View File
@@ -1288,10 +1288,10 @@ export const resourceQueries = {
AxiosResponse<{
providers: Array<{
providerId: number;
modelAccessMode: "catalog" | "allowlist";
name: string;
type: string;
enabled: boolean;
accessMode: "inherit" | "select";
}>;
}>
>(`/site-resource/${siteResourceId}/ai-providers`, {
@@ -1308,10 +1308,10 @@ export const resourceQueries = {
AxiosResponse<{
providers: Array<{
providerId: number;
modelAccessMode: "catalog" | "allowlist";
name: string;
type: string;
enabled: boolean;
accessMode: "inherit" | "select";
}>;
}>
>(`/resource/${resourceId}/ai-providers`, {