add basic ui for private inference resource

This commit is contained in:
miloschwartz
2026-08-04 16:54:23 -04:00
parent c085de1e9e
commit 7759d87835
19 changed files with 828 additions and 208 deletions
+14
View File
@@ -1714,6 +1714,20 @@
"aiProviderQuestionRemove": "Are you sure you want to delete this AI provider?",
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
"aiProviderErrorNoUpdate": "AI provider is not available to update",
"aiProviderModels": "Models",
"aiProviderModelsDescription": "Define model names available on this provider. Requests must use one of these model keys.",
"aiProviderModelsPlaceholder": "Type a model name and press Enter",
"aiProviderModelsUpdated": "Models updated",
"aiProviderModelsErrorUpdate": "Failed to update models",
"aiResourceProviders": "Providers",
"aiResourceProvidersDescription": "Choose which AI providers this inference resource can use",
"aiResourceProvidersHelp": "Models must be defined on each provider. Model names cannot overlap across selected providers.",
"aiResourceProvidersSelect": "Select providers",
"aiResourceProvidersEmpty": "No AI providers found",
"aiResourceProvidersRequired": "Select at least one AI provider",
"aiResourceProvidersUpdated": "Providers updated",
"aiResourceProvidersErrorUpdate": "Failed to update providers",
"aiResourceAliasRequired": "Alias is required for inference resources",
"sidebarApiKeys": "API Keys",
"sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings",
+4 -4
View File
@@ -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] })]
);
+4 -4
View File
@@ -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] })]
);
+60 -19
View File
@@ -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,
+2 -4
View File
@@ -109,7 +109,7 @@ const createHttpResourceSchema = z
.array(resourceAiProviderAttachmentSchema)
.optional()
.describe(
"For inference-mode resources: AI providers to attach. Each entry may set modelAccessMode (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);
@@ -69,6 +69,10 @@ export default async function AiProviderLayout({ children, params }: Props) {
title: t("aiProviderNetworkSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/network"
},
{
title: t("aiProviderModels"),
href: "/{orgId}/settings/ai-providers/{providerId}/models"
},
{
title: t("aiProviderAuthSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/authentication"
@@ -0,0 +1,150 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { Button } from "@app/components/ui/button";
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { aiProviderQueries } from "@app/lib/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
export default function AiProviderModelsPage() {
const { provider } = useAiProviderContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false);
const [tags, setTags] = useState<Tag[]>([]);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const modelsQuery = useQuery(
aiProviderQueries.providerModels({ providerId: provider.providerId })
);
useEffect(() => {
if (!modelsQuery.data) return;
setTags(
modelsQuery.data.map((model) => ({
id: String(model.modelId),
text: model.modelKey
}))
);
}, [modelsQuery.data]);
async function onSave() {
setSaveLoading(true);
try {
const existing = modelsQuery.data ?? [];
const existingByKey = new Map(
existing.map((model) => [model.modelKey, model])
);
const nextKeys = new Set(
tags.map((tag) => tag.text.trim()).filter(Boolean)
);
const toCreate = [...nextKeys].filter(
(key) => !existingByKey.has(key)
);
const toDelete = existing.filter(
(model) => !nextKeys.has(model.modelKey)
);
await Promise.all([
...toCreate.map((modelKey) =>
api.put(`/ai-provider/${provider.providerId}/model`, {
modelKey,
name: modelKey
})
),
...toDelete.map((model) =>
api.delete(`/ai-model/${model.modelId}`)
)
]);
await queryClient.invalidateQueries(
aiProviderQueries.providerModels({
providerId: provider.providerId
})
);
toast({
title: t("success"),
description: t("aiProviderModelsUpdated")
});
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderModelsErrorUpdate"),
description: formatAxiosError(
e,
t("aiProviderModelsErrorUpdate")
)
});
} finally {
setSaveLoading(false);
}
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderModels")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderModelsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<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}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="button"
loading={saveLoading}
disabled={saveLoading || modelsQuery.isLoading}
onClick={onSave}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -24,7 +24,9 @@ import {
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { SwitchInput } from "@app/components/SwitchInput";
import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { asAnyControl, asAnyWatch } from "@app/lib/formControlUtils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo } from "react";
@@ -35,8 +37,12 @@ import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
export default function PrivateResourceGeneralPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const isInference = siteResource.mode === "inference";
const formSchema = useMemo(() => createGeneralFormSchema(t), [t]);
const formSchema = useMemo(
() => createGeneralFormSchema(t, { requireAlias: isInference }),
[t, isInference]
);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
@@ -44,7 +50,8 @@ export default function PrivateResourceGeneralPage() {
defaultValues: {
name: siteResource.name,
niceId: siteResource.niceId,
enabled: siteResource.enabled
enabled: siteResource.enabled,
alias: siteResource.alias ?? null
}
});
@@ -56,7 +63,13 @@ export default function PrivateResourceGeneralPage() {
await save({
name: data.name,
niceId: data.niceId,
enabled: data.enabled
enabled: data.enabled,
...(isInference
? {
mode: "inference" as const,
alias: data.alias
}
: {})
});
}, null);
@@ -152,6 +165,17 @@ export default function PrivateResourceGeneralPage() {
)}
/>
</SettingsFormCell>
{isInference && (
<SettingsFormCell span="half">
<PrivateResourceAliasField
control={asAnyControl(
form.control
)}
watch={asAnyWatch(form.watch)}
labelPrefix="edit"
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
</form>
</Form>
@@ -1,105 +1,15 @@
"use client";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { createInferenceFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceInferenceDestinationFields } from "@app/components/PrivateResourceDestinationFields";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "@app/lib/formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export const metadata: Metadata = {
title: "Private Resource"
};
export default function PrivateResourceInferencePage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(() => createInferenceFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
mode: "inference",
alias: siteResource.alias ?? null
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
mode: "inference",
alias: data.alias
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("hostSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("editInternalResourceDialogDestinationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-host-form"
>
<SettingsFormGrid>
<SettingsFormCell span="full">
<PrivateResourceInferenceDestinationFields
control={asAnyControl(form.control)}
watch={asAnyWatch(form.watch)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-host-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
export default async function PrivateResourceInferencePage(props: {
params: Promise<{ niceId: string; orgId: string }>;
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/resources/private/${params.niceId}/providers`
);
}
@@ -55,20 +55,37 @@ export default async function PrivateResourceLayout(
| "sshSettings"
| "inferenceSettings";
const navItems = [
{
title: t("general"),
href: `/{orgId}/settings/resources/private/{niceId}/general`
},
{
title: t(modeSettingsKey),
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
},
{
title: t("authentication"),
href: `/{orgId}/settings/resources/private/{niceId}/access`
}
];
const isInference = siteResource.mode === "inference";
const navItems = isInference
? [
{
title: t("general"),
href: `/{orgId}/settings/resources/private/{niceId}/general`
},
{
title: t("aiResourceProviders"),
href: `/{orgId}/settings/resources/private/{niceId}/providers`
},
{
title: t("authentication"),
href: `/{orgId}/settings/resources/private/{niceId}/access`
}
]
: [
{
title: t("general"),
href: `/{orgId}/settings/resources/private/{niceId}/general`
},
{
title: t(modeSettingsKey),
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
},
{
title: t("authentication"),
href: `/{orgId}/settings/resources/private/{niceId}/access`
}
];
return (
<>
@@ -0,0 +1,232 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
AiProvidersSelector,
type SelectedAiProvider
} from "@app/components/AiProvidersSelector";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export default function PrivateResourceProvidersPage() {
const t = useTranslations();
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
const { siteResource } = useSiteResourceContext();
useEffect(() => {
if (siteResource.mode !== "inference") {
router.replace(
`/${siteResource.orgId}/settings/resources/private/${siteResource.niceId}/general`
);
}
}, [router, siteResource.mode, siteResource.niceId, siteResource.orgId]);
const formSchema = useMemo(
() =>
z.object({
providerIds: z
.array(z.number().int().positive())
.min(1, t("aiResourceProvidersRequired"))
}),
[t]
);
type FormValues = z.infer<typeof formSchema>;
const [selectedProviders, setSelectedProviders] = useState<
SelectedAiProvider[]
>([]);
const attachedQuery = useQuery({
...resourceQueries.siteResourceAiProviders({
siteResourceId: siteResource.id
}),
enabled: siteResource.mode === "inference"
});
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
providerIds: []
}
});
useEffect(() => {
if (!attachedQuery.data) return;
const providers = attachedQuery.data.map((provider) => ({
id: String(provider.providerId),
text: provider.name
}));
setSelectedProviders(providers);
form.reset({
providerIds: attachedQuery.data.map((p) => p.providerId)
});
}, [attachedQuery.data, form]);
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
await api.post(`/site-resource/${siteResource.id}/ai-providers`, {
providers: data.providerIds.map((providerId) => ({
providerId,
modelAccessMode: "catalog"
}))
});
await queryClient.invalidateQueries(
resourceQueries.siteResourceAiProviders({
siteResourceId: siteResource.id
})
);
toast({
title: t("success"),
description: t("aiResourceProvidersUpdated")
});
} catch (error) {
toast({
variant: "destructive",
title: t("aiResourceProvidersErrorUpdate"),
description: formatAxiosError(
error,
t("aiResourceProvidersErrorUpdate")
)
});
}
}, null);
if (siteResource.mode !== "inference") {
return null;
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiResourceProviders")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiResourceProvidersDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-providers-form"
>
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="providerIds"
render={() => (
<FormItem>
<FormLabel>
{t(
"aiResourceProviders"
)}
</FormLabel>
<FormControl>
<AiProvidersSelector
orgId={
siteResource.orgId
}
selectedProviders={
selectedProviders
}
disabled={
attachedQuery.isLoading ||
saveLoading
}
onSelectProviders={(
providers
) => {
setSelectedProviders(
providers
);
form.setValue(
"providerIds",
providers.map(
(p) =>
parseInt(
p.id,
10
)
),
{
shouldValidate: true
}
);
}}
/>
</FormControl>
<FormDescription>
{t(
"aiResourceProvidersHelp"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-providers-form"
loading={saveLoading}
disabled={attachedQuery.isLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -64,6 +64,10 @@ import {
asAnySetValue,
asAnyWatch
} from "@app/lib/formControlUtils";
import {
AiProvidersSelector,
type SelectedAiProvider
} from "@app/components/AiProvidersSelector";
export default function CreatePrivateResourcePage() {
const params = useParams();
@@ -88,6 +92,9 @@ export default function CreatePrivateResourcePage() {
: null;
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [selectedProviders, setSelectedProviders] = useState<
SelectedAiProvider[]
>([]);
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
@@ -112,7 +119,8 @@ export default function CreatePrivateResourcePage() {
pamMode: "passthrough",
tcpPortRangeString: "*",
udpPortRangeString: "*",
disableIcmp: false
disableIcmp: false,
providerIds: []
}
});
@@ -196,7 +204,9 @@ export default function CreatePrivateResourcePage() {
}
router.push(
`/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
created.mode === "inference"
? `/${orgId}/settings/resources/private/${created.niceId}/general`
: `/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
);
} catch (error) {
toast({
@@ -336,6 +346,13 @@ export default function CreatePrivateResourcePage() {
"destinationPort",
null
);
form.setValue(
"providerIds",
[]
);
setSelectedProviders(
[]
);
} else {
form.setValue(
"destinationPort",
@@ -637,6 +654,76 @@ export default function CreatePrivateResourcePage() {
</SettingsSection>
)}
{mode === "inference" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiResourceProviders")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiResourceProvidersDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="providerIds"
render={() => (
<FormItem>
<FormLabel>
{t(
"aiResourceProviders"
)}
</FormLabel>
<FormControl>
<AiProvidersSelector
orgId={orgId}
selectedProviders={
selectedProviders
}
onSelectProviders={(
providers
) => {
setSelectedProviders(
providers
);
form.setValue(
"providerIds",
providers.map(
(
p
) =>
parseInt(
p.id,
10
)
),
{
shouldValidate: true
}
);
}}
/>
</FormControl>
<FormDescription>
{t(
"aiResourceProvidersHelp"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { aiProviderQueries } from "@app/lib/queries";
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useDebounce } from "use-debounce";
export type SelectedAiProvider = {
id: string;
text: string;
};
export type AiProvidersSelectorProps = {
orgId: string;
selectedProviders?: SelectedAiProvider[];
onSelectProviders: (providers: SelectedAiProvider[]) => void;
disabled?: boolean;
buttonText?: string;
};
export function AiProvidersSelector({
orgId,
selectedProviders = [],
onSelectProviders,
disabled,
buttonText
}: AiProvidersSelectorProps) {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedValue] = useDebounce(searchQuery, 150);
const { data: providers = [] } = useQuery(
aiProviderQueries.orgProviders({
orgId,
query: debouncedValue || undefined
})
);
const options: SelectedAiProvider[] = providers
.filter((provider) => provider.enabled)
.map((provider) => ({
id: String(provider.providerId),
text: provider.name
}));
return (
<MultiSelectTagInput
buttonText={buttonText ?? t("aiResourceProvidersSelect")}
emptyPlaceholder={t("aiResourceProvidersEmpty")}
searchPlaceholder={t("aiProvidersSearch")}
searchQuery={searchQuery}
options={options}
value={selectedProviders}
onChange={onSelectProviders}
onSearch={setSearchQuery}
disabled={disabled}
/>
);
}
+72 -20
View File
@@ -42,6 +42,7 @@ export type PrivateResourceFormValues = {
roles?: PrivateResourceAccessTag[];
users?: PrivateResourceAccessTag[];
clients?: PrivateResourceClient[];
providerIds?: number[];
};
export type SiteResourceAccess = {
@@ -220,7 +221,11 @@ export function buildCreateSiteResourcePayload(
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: undefined
: undefined,
aiProviders: (data.providerIds ?? []).map((providerId) => ({
providerId,
modelAccessMode: "catalog" as const
}))
}),
...((data.mode === "host" || data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString,
@@ -353,19 +358,33 @@ export function siteResourceToFormValues(
};
}
export function createGeneralFormSchema(t: TranslateFn) {
return z.object({
name: z
.string()
.min(1, t("editInternalResourceDialogNameRequired"))
.max(255, t("editInternalResourceDialogNameMaxLength")),
niceId: z
.string()
.min(1)
.max(255)
.regex(/^[a-zA-Z0-9-]+$/),
enabled: z.boolean()
});
export function createGeneralFormSchema(
t: TranslateFn,
options?: { requireAlias?: boolean }
) {
return z
.object({
name: z
.string()
.min(1, t("editInternalResourceDialogNameRequired"))
.max(255, t("editInternalResourceDialogNameMaxLength")),
niceId: z
.string()
.min(1)
.max(255)
.regex(/^[a-zA-Z0-9-]+$/),
enabled: z.boolean(),
alias: z.string().nullish()
})
.superRefine((data, ctx) => {
if (options?.requireAlias && !data.alias?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("aiResourceAliasRequired"),
path: ["alias"]
});
}
});
}
export function createAccessFormSchema() {
@@ -422,7 +441,8 @@ export function createCreateFormSchema(t: TranslateFn) {
pamMode: z.enum(["passthrough", "push"]).optional().nullable(),
tcpPortRangeString: createPortRangeStringSchema(t),
udpPortRangeString: createPortRangeStringSchema(t),
disableIcmp: z.boolean().optional()
disableIcmp: z.boolean().optional(),
providerIds: z.array(z.number().int().positive()).optional()
})
.superRefine((data, ctx) => {
const isNativeSsh =
@@ -434,12 +454,27 @@ export function createCreateFormSchema(t: TranslateFn) {
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t(
"createInternalResourceDialogPleaseSelectSite"
),
message: t("createInternalResourceDialogPleaseSelectSite"),
path: ["siteIds"]
});
}
if (data.mode === "inference") {
const trimmedAlias = data.alias?.trim();
if (!trimmedAlias) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("aiResourceAliasRequired"),
path: ["alias"]
});
}
if (!data.providerIds || data.providerIds.length < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("aiResourceProvidersRequired"),
path: ["providerIds"]
});
}
}
if (
data.mode !== "ssh" &&
data.mode !== "inference" &&
@@ -592,9 +627,26 @@ export function createInferenceFormSchema(t: TranslateFn) {
return z
.object({
mode: z.literal("inference"),
alias: z.string().nullish()
alias: z.string().nullish(),
providerIds: z.array(z.number().int().positive()).optional()
})
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
.superRefine((data, ctx) => {
const trimmedAlias = data.alias?.trim();
if (!trimmedAlias) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("aiResourceAliasRequired"),
path: ["alias"]
});
}
if (!data.providerIds || data.providerIds.length < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("aiResourceProvidersRequired"),
path: ["providerIds"]
});
}
});
}
export function createCidrFormSchema(t: TranslateFn) {
+58
View File
@@ -57,6 +57,10 @@ import type {
} from "@server/routers/siteResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import type { ListTargetsResponse } from "@server/routers/target";
import type {
ListAiModelsResponse,
ListAiProvidersResponse
} from "@server/routers/aiProvider/types";
import type { ListUsersResponse } from "@server/routers/user";
import type ResponseT from "@server/types/Response";
import {
@@ -1174,6 +1178,36 @@ export const aiProviderQueries = {
return res.data.data.targets;
}
}),
providerModels: ({ providerId }: { providerId: number }) =>
queryOptions({
queryKey: ["AI_PROVIDERS", providerId, "MODELS"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListAiModelsResponse>
>(`/ai-provider/${providerId}/models`, {
params: { page: 1, pageSize: 1000 },
signal
});
return res.data.data.models;
}
}),
orgProviders: ({ orgId, query }: { orgId: string; query?: string }) =>
queryOptions({
queryKey: ["AI_PROVIDERS", orgId, "LIST", query ?? ""] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListAiProvidersResponse>
>(`/org/${orgId}/ai-providers`, {
params: {
page: 1,
pageSize: 100,
...(query ? { query } : {})
},
signal
});
return res.data.data.providers;
}
})
};
@@ -1242,6 +1276,30 @@ export const resourceQueries = {
return res.data.data.clients;
}
}),
siteResourceAiProviders: ({ siteResourceId }: { siteResourceId: number }) =>
queryOptions({
queryKey: [
"SITE_RESOURCES",
siteResourceId,
"AI_PROVIDERS"
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<{
providers: Array<{
providerId: number;
modelAccessMode: "catalog" | "allowlist";
name: string;
type: string;
enabled: boolean;
}>;
}>
>(`/site-resource/${siteResourceId}/ai-providers`, {
signal
});
return res.data.data.providers;
}
}),
resourceTargets: ({ resourceId }: { resourceId: number }) =>
queryOptions({
queryKey: ["RESOURCES", resourceId, "TARGETS"] as const,