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?", "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.", "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",
"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", "sidebarApiKeys": "API Keys",
"sidebarProvisioning": "Provisioning", "sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings", "sidebarSettings": "Settings",
+4 -4
View File
@@ -232,9 +232,9 @@ export const resourceAiProviders = pgTable(
.notNull() .notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }), .references(() => aiProviders.providerId, { onDelete: "cascade" }),
modelAccessMode: varchar("modelAccessMode") modelAccessMode: varchar("modelAccessMode")
.$type<"passthrough" | "catalog" | "allowlist">() .$type<"catalog" | "allowlist">()
.notNull() .notNull()
.default("passthrough") .default("catalog")
}, },
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })] (t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
); );
@@ -523,9 +523,9 @@ export const siteResourceAiProviders = pgTable(
.notNull() .notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }), .references(() => aiProviders.providerId, { onDelete: "cascade" }),
modelAccessMode: varchar("modelAccessMode") modelAccessMode: varchar("modelAccessMode")
.$type<"passthrough" | "catalog" | "allowlist">() .$type<"catalog" | "allowlist">()
.notNull() .notNull()
.default("passthrough") .default("catalog")
}, },
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })] (t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
); );
+4 -4
View File
@@ -229,9 +229,9 @@ export const resourceAiProviders = sqliteTable(
.notNull() .notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }), .references(() => aiProviders.providerId, { onDelete: "cascade" }),
modelAccessMode: text("modelAccessMode") modelAccessMode: text("modelAccessMode")
.$type<"passthrough" | "catalog" | "allowlist">() .$type<"catalog" | "allowlist">()
.notNull() .notNull()
.default("passthrough") .default("catalog")
}, },
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })] (t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
); );
@@ -508,9 +508,9 @@ export const siteResourceAiProviders = sqliteTable(
.notNull() .notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }), .references(() => aiProviders.providerId, { onDelete: "cascade" }),
modelAccessMode: text("modelAccessMode") modelAccessMode: text("modelAccessMode")
.$type<"passthrough" | "catalog" | "allowlist">() .$type<"catalog" | "allowlist">()
.notNull() .notNull()
.default("passthrough") .default("catalog")
}, },
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })] (t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
); );
+60 -19
View File
@@ -13,11 +13,7 @@ import { z } from "zod";
type DbOrTrx = Transaction | typeof db; type DbOrTrx = Transaction | typeof db;
export const modelAccessModeSchema = z.enum([ export const modelAccessModeSchema = z.enum(["catalog", "allowlist"]);
"passthrough",
"catalog",
"allowlist"
]);
export type ModelAccessMode = z.infer<typeof modelAccessModeSchema>; export type ModelAccessMode = z.infer<typeof modelAccessModeSchema>;
@@ -50,10 +46,7 @@ function normalizeAttachments(
): ResourceAiProviderAttachment[] { ): ResourceAiProviderAttachment[] {
const byProvider = new Map<number, ModelAccessMode>(); const byProvider = new Map<number, ModelAccessMode>();
for (const input of inputs) { for (const input of inputs) {
byProvider.set( byProvider.set(input.providerId, input.modelAccessMode ?? "catalog");
input.providerId,
input.modelAccessMode ?? "passthrough"
);
} }
return [...byProvider.entries()].map(([providerId, modelAccessMode]) => ({ return [...byProvider.entries()].map(([providerId, modelAccessMode]) => ({
providerId, 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. * Validate provider attachments for an org.
* At most one passthrough provider is allowed per resource.
*/ */
export async function resolveProviderAttachments(input: { export async function resolveProviderAttachments(input: {
orgId: string; 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) { if (attachments.length === 0) {
return []; return [];
} }
@@ -119,6 +155,11 @@ export async function resolveProviderAttachments(input: {
}; };
} }
const overlapError = await assertNoOverlappingModelKeys(attachments);
if (overlapError) {
return overlapError;
}
return attachments; return attachments;
} }
@@ -331,10 +331,6 @@ async function providerMatchesModel(
requestedModel: string, requestedModel: string,
allowedModelIds: number[] allowedModelIds: number[]
): Promise<boolean> { ): Promise<boolean> {
if (attachment.modelAccessMode === "passthrough") {
return true;
}
const [matchedModel] = await db const [matchedModel] = await db
.select({ .select({
modelId: aiModels.modelId, modelId: aiModels.modelId,
@@ -366,26 +362,7 @@ async function selectProvider(
allowedModelIds: number[], allowedModelIds: number[],
requestedModel: string | undefined requestedModel: string | undefined
): Promise<ProviderSelection> { ): Promise<ProviderSelection> {
const passthroughAttachments = attachments.filter(
(a) => a.modelAccessMode === "passthrough"
);
const hasRestricted = attachments.some(
(a) =>
a.modelAccessMode === "catalog" || a.modelAccessMode === "allowlist"
);
if (!requestedModel) { 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 { return {
ok: false, ok: false,
status: HttpCode.FORBIDDEN, status: HttpCode.FORBIDDEN,
@@ -395,10 +372,6 @@ async function selectProvider(
const candidates: ProviderAttachment[] = []; const candidates: ProviderAttachment[] = [];
for (const attachment of attachments) { for (const attachment of attachments) {
if (attachment.modelAccessMode === "passthrough") {
candidates.push(attachment);
continue;
}
if ( if (
await providerMatchesModel( await providerMatchesModel(
attachment, 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 { return {
ok: false, ok: false,
status: HttpCode.FORBIDDEN, status: HttpCode.FORBIDDEN,
+2 -4
View File
@@ -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 (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( .refine(
@@ -397,9 +397,7 @@ async function createHttpResource(
requireAtLeastOne: true requireAtLeastOne: true
}); });
if (isInferenceFieldsError(resolved)) { if (isInferenceFieldsError(resolved)) {
return next( return next(createHttpError(HttpCode.BAD_REQUEST, resolved.error));
createHttpError(HttpCode.BAD_REQUEST, resolved.error)
);
} }
providerAttachments = resolved; providerAttachments = resolved;
} else if (aiProviderInputs && aiProviderInputs.length > 0) { } else if (aiProviderInputs && aiProviderInputs.length > 0) {
@@ -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. 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], tags: [OpenAPITags.PublicResource],
request: { request: {
params: setResourceAiProvidersParamsSchema, params: setResourceAiProvidersParamsSchema,
@@ -116,7 +116,9 @@ export async function setResourceAiProviders(
requireAtLeastOne: true requireAtLeastOne: true
}); });
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);
@@ -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 (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() .strict()
@@ -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. 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], tags: [OpenAPITags.PrivateResource],
request: { request: {
params: setSiteResourceAiProvidersParamsSchema, params: setSiteResourceAiProvidersParamsSchema,
@@ -118,7 +118,9 @@ export async function setSiteResourceAiProviders(
requireAtLeastOne: true requireAtLeastOne: true
}); });
if (isInferenceFieldsError(attachments)) { if (isInferenceFieldsError(attachments)) {
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error)); return next(
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
);
} }
await replaceAttachments(siteResourceId, attachments); await replaceAttachments(siteResourceId, attachments);
@@ -69,6 +69,10 @@ export default async function AiProviderLayout({ children, params }: Props) {
title: t("aiProviderNetworkSettings"), title: t("aiProviderNetworkSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/network" href: "/{orgId}/settings/ai-providers/{providerId}/network"
}, },
{
title: t("aiProviderModels"),
href: "/{orgId}/settings/ai-providers/{providerId}/models"
},
{ {
title: t("aiProviderAuthSettings"), title: t("aiProviderAuthSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/authentication" 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"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
import { createGeneralFormSchema } from "@app/lib/privateResourceForm"; import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { asAnyControl, asAnyWatch } from "@app/lib/formControlUtils";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useActionState, useMemo } from "react"; import { useActionState, useMemo } from "react";
@@ -35,8 +37,12 @@ import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
export default function PrivateResourceGeneralPage() { export default function PrivateResourceGeneralPage() {
const t = useTranslations(); const t = useTranslations();
const { save, siteResource } = useSaveSiteResource(); 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>; type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({ const form = useForm<FormValues>({
@@ -44,7 +50,8 @@ export default function PrivateResourceGeneralPage() {
defaultValues: { defaultValues: {
name: siteResource.name, name: siteResource.name,
niceId: siteResource.niceId, niceId: siteResource.niceId,
enabled: siteResource.enabled enabled: siteResource.enabled,
alias: siteResource.alias ?? null
} }
}); });
@@ -56,7 +63,13 @@ export default function PrivateResourceGeneralPage() {
await save({ await save({
name: data.name, name: data.name,
niceId: data.niceId, niceId: data.niceId,
enabled: data.enabled enabled: data.enabled,
...(isInference
? {
mode: "inference" as const,
alias: data.alias
}
: {})
}); });
}, null); }, null);
@@ -152,6 +165,17 @@ export default function PrivateResourceGeneralPage() {
)} )}
/> />
</SettingsFormCell> </SettingsFormCell>
{isInference && (
<SettingsFormCell span="half">
<PrivateResourceAliasField
control={asAnyControl(
form.control
)}
watch={asAnyWatch(form.watch)}
labelPrefix="edit"
/>
</SettingsFormCell>
)}
</SettingsFormGrid> </SettingsFormGrid>
</form> </form>
</Form> </Form>
@@ -1,105 +1,15 @@
"use client"; import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { export const metadata: Metadata = {
SettingsContainer, title: "Private Resource"
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 default function PrivateResourceInferencePage() { export default async function PrivateResourceInferencePage(props: {
const t = useTranslations(); params: Promise<{ niceId: string; orgId: string }>;
const { save, siteResource } = useSaveSiteResource(); }) {
const [selectedSites, setSelectedSites] = useState(() => const params = await props.params;
buildSelectedSitesForResource(siteResource) redirect(
); `/${params.orgId}/settings/resources/private/${params.niceId}/providers`
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>
); );
} }
@@ -55,20 +55,37 @@ export default async function PrivateResourceLayout(
| "sshSettings" | "sshSettings"
| "inferenceSettings"; | "inferenceSettings";
const navItems = [ const isInference = siteResource.mode === "inference";
{
title: t("general"), const navItems = isInference
href: `/{orgId}/settings/resources/private/{niceId}/general` ? [
}, {
{ title: t("general"),
title: t(modeSettingsKey), href: `/{orgId}/settings/resources/private/{niceId}/general`
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}` },
}, {
{ title: t("aiResourceProviders"),
title: t("authentication"), href: `/{orgId}/settings/resources/private/{niceId}/providers`
href: `/{orgId}/settings/resources/private/{niceId}/access` },
} {
]; 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 ( 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, asAnySetValue,
asAnyWatch asAnyWatch
} from "@app/lib/formControlUtils"; } from "@app/lib/formControlUtils";
import {
AiProvidersSelector,
type SelectedAiProvider
} from "@app/components/AiProvidersSelector";
export default function CreatePrivateResourcePage() { export default function CreatePrivateResourcePage() {
const params = useParams(); const params = useParams();
@@ -88,6 +92,9 @@ export default function CreatePrivateResourcePage() {
: null; : null;
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]); const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [selectedProviders, setSelectedProviders] = useState<
SelectedAiProvider[]
>([]);
const formSchema = useMemo(() => createCreateFormSchema(t), [t]); const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -112,7 +119,8 @@ export default function CreatePrivateResourcePage() {
pamMode: "passthrough", pamMode: "passthrough",
tcpPortRangeString: "*", tcpPortRangeString: "*",
udpPortRangeString: "*", udpPortRangeString: "*",
disableIcmp: false disableIcmp: false,
providerIds: []
} }
}); });
@@ -196,7 +204,9 @@ export default function CreatePrivateResourcePage() {
} }
router.push( 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) { } catch (error) {
toast({ toast({
@@ -336,6 +346,13 @@ export default function CreatePrivateResourcePage() {
"destinationPort", "destinationPort",
null null
); );
form.setValue(
"providerIds",
[]
);
setSelectedProviders(
[]
);
} else { } else {
form.setValue( form.setValue(
"destinationPort", "destinationPort",
@@ -637,6 +654,76 @@ export default function CreatePrivateResourcePage() {
</SettingsSection> </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"> <div className="flex justify-end space-x-2 mt-8">
<Button <Button
type="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[]; roles?: PrivateResourceAccessTag[];
users?: PrivateResourceAccessTag[]; users?: PrivateResourceAccessTag[];
clients?: PrivateResourceClient[]; clients?: PrivateResourceClient[];
providerIds?: number[];
}; };
export type SiteResourceAccess = { export type SiteResourceAccess = {
@@ -220,7 +221,11 @@ export function buildCreateSiteResourcePayload(
typeof data.alias === "string" && typeof data.alias === "string" &&
data.alias.trim() data.alias.trim()
? data.alias ? data.alias
: undefined : undefined,
aiProviders: (data.providerIds ?? []).map((providerId) => ({
providerId,
modelAccessMode: "catalog" as const
}))
}), }),
...((data.mode === "host" || data.mode === "cidr") && { ...((data.mode === "host" || data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString, tcpPortRangeString: data.tcpPortRangeString,
@@ -353,19 +358,33 @@ export function siteResourceToFormValues(
}; };
} }
export function createGeneralFormSchema(t: TranslateFn) { export function createGeneralFormSchema(
return z.object({ t: TranslateFn,
name: z options?: { requireAlias?: boolean }
.string() ) {
.min(1, t("editInternalResourceDialogNameRequired")) return z
.max(255, t("editInternalResourceDialogNameMaxLength")), .object({
niceId: z name: z
.string() .string()
.min(1) .min(1, t("editInternalResourceDialogNameRequired"))
.max(255) .max(255, t("editInternalResourceDialogNameMaxLength")),
.regex(/^[a-zA-Z0-9-]+$/), niceId: z
enabled: z.boolean() .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() { export function createAccessFormSchema() {
@@ -422,7 +441,8 @@ export function createCreateFormSchema(t: TranslateFn) {
pamMode: z.enum(["passthrough", "push"]).optional().nullable(), pamMode: z.enum(["passthrough", "push"]).optional().nullable(),
tcpPortRangeString: createPortRangeStringSchema(t), tcpPortRangeString: createPortRangeStringSchema(t),
udpPortRangeString: createPortRangeStringSchema(t), udpPortRangeString: createPortRangeStringSchema(t),
disableIcmp: z.boolean().optional() disableIcmp: z.boolean().optional(),
providerIds: z.array(z.number().int().positive()).optional()
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
const isNativeSsh = const isNativeSsh =
@@ -434,12 +454,27 @@ export function createCreateFormSchema(t: TranslateFn) {
) { ) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: t( message: t("createInternalResourceDialogPleaseSelectSite"),
"createInternalResourceDialogPleaseSelectSite"
),
path: ["siteIds"] 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 ( if (
data.mode !== "ssh" && data.mode !== "ssh" &&
data.mode !== "inference" && data.mode !== "inference" &&
@@ -592,9 +627,26 @@ export function createInferenceFormSchema(t: TranslateFn) {
return z return z
.object({ .object({
mode: z.literal("inference"), 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) { export function createCidrFormSchema(t: TranslateFn) {
+58
View File
@@ -57,6 +57,10 @@ import type {
} from "@server/routers/siteResource"; } from "@server/routers/siteResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource"; import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import type { ListTargetsResponse } from "@server/routers/target"; 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 { ListUsersResponse } from "@server/routers/user";
import type ResponseT from "@server/types/Response"; import type ResponseT from "@server/types/Response";
import { import {
@@ -1174,6 +1178,36 @@ export const aiProviderQueries = {
return res.data.data.targets; 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; 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 }) => resourceTargets: ({ resourceId }: { resourceId: number }) =>
queryOptions({ queryOptions({
queryKey: ["RESOURCES", resourceId, "TARGETS"] as const, queryKey: ["RESOURCES", resourceId, "TARGETS"] as const,