diff --git a/messages/en-US.json b/messages/en-US.json index 2fc4e778e..fa9202b2f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1689,7 +1689,7 @@ "aiProviderRoutingModeUrl": "Upstream URL", "aiProviderRoutingModeUrlDescription": "Call a public or private API base URL", "aiProviderRoutingModeTarget": "Site Targets", - "aiProviderRoutingModeTargetDescription": "Route through HTTPS targets on your sites", + "aiProviderRoutingModeTargetDescription": "Route through targets on your sites", "aiProviderRoutingModeTargetNote": "After creating this provider, configure site targets on the Network Settings tab.", "aiProviderTargetNoOne": "This provider doesn't have any targets. Add a target to route requests through your sites.", "aiProviderSkipTlsVerification": "Skip TLS Verification", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx index 2aa06d01d..fbe9669cf 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -225,11 +225,6 @@ export default function AiProviderNetworkPage() { }} /> - - {t( - "aiProviderRoutingModeDescription" - )} - )} @@ -237,6 +232,36 @@ export default function AiProviderNetworkPage() { )} + + ( + + + + + + + )} + /> + + {showUpstream && ( )} - - - ( - - - - - - - )} - /> - diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 3111f0b3e..b84462153 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -371,11 +371,6 @@ export default function CreateAiProviderPage() { }} /> - - {t( - "aiProviderRoutingModeDescription" - )} - )} @@ -383,6 +378,36 @@ export default function CreateAiProviderPage() { )} + + ( + + + + + + + )} + /> + + {showUpstream && ( )} - - - ( - - - - - - - )} - /> - diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx index d3d9b817e..1107762c6 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx @@ -114,14 +114,20 @@ export default function GeneralForm() { .refine( (data) => { // For non-HTTP resources, proxyPort should be defined - if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { + if ( + !["http", "ssh", "rdp", "vnc", "inference"].includes( + resource.mode + ) + ) { return data.proxyPort !== undefined; } // For HTTP resources, proxyPort should be undefined return data.proxyPort === undefined; }, { - message: !["http", "ssh", "rdp", "vnc"].includes(resource.mode) + message: !["http", "ssh", "rdp", "vnc", "inference"].includes( + resource.mode + ) ? "Port number is required for non-HTTP resources" : "Port number should not be set for HTTP resources", path: ["proxyPort"] @@ -153,7 +159,7 @@ export default function GeneralForm() { let resourcePolicyId: number | null | undefined; - if (!["tcp", "udp"].includes(resource.mode)) { + if (!["tcp", "udp", "inference"].includes(resource.mode)) { if (hasResourcePolicies || selectedSharedPolicyId === null) { resourcePolicyId = selectedSharedPolicyId; } @@ -339,7 +345,7 @@ export default function GeneralForm() { /> - {!["http", "ssh", "rdp", "vnc"].includes( + {!["http", "ssh", "rdp", "vnc", "inference"].includes( resource.mode ) && ( @@ -393,7 +399,7 @@ export default function GeneralForm() { )} - {["http", "ssh", "rdp", "vnc"].includes( + {["http", "ssh", "rdp", "vnc", "inference"].includes( resource.mode ) && ( @@ -453,9 +459,11 @@ export default function GeneralForm() { )} - { !["tcp", "udp"].includes( - resource.mode - ) && !env.flags.disableEnterpriseFeatures && ( + {!["tcp", "udp", "inference"].includes( + resource.mode + ) && + !env.flags + .disableEnterpriseFeatures && ( <> diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx new file mode 100644 index 000000000..8ea8d2dd5 --- /dev/null +++ b/src/app/[orgId]/settings/resources/public/[niceId]/inference/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +export const metadata: Metadata = { + title: "Public Resource" +}; + +export default async function PublicResourceInferencePage(props: { + params: Promise<{ niceId: string; orgId: string }>; +}) { + const params = await props.params; + redirect( + `/${params.orgId}/settings/resources/public/${params.niceId}/providers` + ); +} diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index 1599cdceb..1c786be19 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -82,16 +82,29 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { redirect(`/${params.orgId}/settings/resources`); } - const navItems = [ - { - title: t("general"), - href: `/{orgId}/settings/resources/public/{niceId}/general` - }, - { - title: t(`${resource.mode}Settings`), - href: `/{orgId}/settings/resources/public/{niceId}/${resource.mode}` - } - ]; + const isInference = resource.mode === "inference"; + + const navItems = isInference + ? [ + { + title: t("general"), + href: `/{orgId}/settings/resources/public/{niceId}/general` + }, + { + title: t("aiResourceProviders"), + href: `/{orgId}/settings/resources/public/{niceId}/providers` + } + ] + : [ + { + title: t("general"), + href: `/{orgId}/settings/resources/public/{niceId}/general` + }, + { + title: t(`${resource.mode}Settings`), + href: `/{orgId}/settings/resources/public/{niceId}/${resource.mode}` + } + ]; if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { navItems.push( diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx new file mode 100644 index 000000000..67ef0fa9e --- /dev/null +++ b/src/app/[orgId]/settings/resources/public/[niceId]/providers/page.tsx @@ -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 { useResourceContext } from "@app/hooks/useResourceContext"; +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 PublicResourceProvidersPage() { + const t = useTranslations(); + const router = useRouter(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const queryClient = useQueryClient(); + const { resource } = useResourceContext(); + + useEffect(() => { + if (resource.mode !== "inference") { + router.replace( + `/${resource.orgId}/settings/resources/public/${resource.niceId}/general` + ); + } + }, [router, resource.mode, resource.niceId, resource.orgId]); + + const formSchema = useMemo( + () => + z.object({ + providerIds: z + .array(z.number().int().positive()) + .min(1, t("aiResourceProvidersRequired")) + }), + [t] + ); + type FormValues = z.infer; + + const [selectedProviders, setSelectedProviders] = useState< + SelectedAiProvider[] + >([]); + + const attachedQuery = useQuery({ + ...resourceQueries.resourceAiProviders({ + resourceId: resource.resourceId + }), + enabled: resource.mode === "inference" + }); + + const form = useForm({ + 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(`/resource/${resource.resourceId}/ai-providers`, { + providers: data.providerIds.map((providerId) => ({ + providerId, + modelAccessMode: "catalog" + })) + }); + + await queryClient.invalidateQueries( + resourceQueries.resourceAiProviders({ + resourceId: resource.resourceId + }) + ); + + toast({ + title: t("success"), + description: t("aiResourceProvidersUpdated") + }); + } catch (error) { + toast({ + variant: "destructive", + title: t("aiResourceProvidersErrorUpdate"), + description: formatAxiosError( + error, + t("aiResourceProvidersErrorUpdate") + ) + }); + } + }, null); + + if (resource.mode !== "inference") { + return null; + } + + return ( + + + + + {t("aiResourceProviders")} + + + {t("aiResourceProvidersDescription")} + + + + + +
+ + + + ( + + + {t( + "aiResourceProviders" + )} + + + { + setSelectedProviders( + providers + ); + form.setValue( + "providerIds", + providers.map( + (p) => + parseInt( + p.id, + 10 + ) + ), + { + shouldValidate: true + } + ); + }} + /> + + + {t( + "aiResourceProvidersHelp" + )} + + + + )} + /> + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index eaf4e5527..c0e0a0f8e 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -72,6 +72,10 @@ import { LocalTarget, ProxyResourceTargetsForm } from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm"; +import { + AiProvidersSelector, + type SelectedAiProvider +} from "@app/components/AiProvidersSelector"; import { AxiosResponse } from "axios"; import { ChevronsUpDown, ExternalLink } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -245,6 +249,10 @@ export default function Page() { // Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit) const [targets, setTargets] = useState([]); + const [selectedProviders, setSelectedProviders] = useState< + SelectedAiProvider[] + >([]); + const [showProvidersError, setShowProvidersError] = useState(false); // SSH-specific state const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( @@ -333,7 +341,7 @@ export default function Page() { !env.flags.disableEnterpriseFeatures; const availableTypes = useMemo((): NewResourceType[] => { - const base: NewResourceType[] = ["http"]; + const base: NewResourceType[] = ["http", "inference"]; if (enterpriseModesAllowed) { base.push("ssh", "rdp", "vnc"); } @@ -347,6 +355,9 @@ export default function Page() { if (!availableTypes.includes(resourceType)) { setResourceType("http"); } + if (resourceType !== "inference") { + setShowProvidersError(false); + } }, [availableTypes, resourceType]); const baseResourceFormSchema = useMemo( @@ -478,29 +489,42 @@ export default function Page() { ? finalizeSubdomainSanitize(httpData.subdomain, true) : undefined; - const effectiveMode = isNative - ? "native" - : standardDaemonLocation; - const portVal = sshDaemonPortForm.getValues().authDaemonPort; - const effectivePort = - !isNative && - standardDaemonLocation === "remote" && - pamMode === "push" && - portVal - ? Number(portVal) - : undefined; - Object.assign(payload, { subdomain: sanitizedSubdomain ? toASCII(sanitizedSubdomain) : undefined, domainId: httpData.domainId, protocol: "tcp", - mode: resourceType, - pamMode, - authDaemonMode: effectiveMode, - authDaemonPort: effectivePort || undefined + mode: resourceType }); + + if (resourceType === "inference") { + Object.assign(payload, { + aiProviders: selectedProviders.map((provider) => ({ + providerId: parseInt(provider.id, 10), + modelAccessMode: "catalog" + })) + }); + } else if (resourceType === "ssh") { + const effectiveMode = isNative + ? "native" + : standardDaemonLocation; + const portVal = + sshDaemonPortForm.getValues().authDaemonPort; + const effectivePort = + !isNative && + standardDaemonLocation === "remote" && + pamMode === "push" && + portVal + ? Number(portVal) + : undefined; + + Object.assign(payload, { + pamMode, + authDaemonMode: effectiveMode, + authDaemonPort: effectivePort || undefined + }); + } } else { const tcpUdpData = tcpUdpForm.getValues(); Object.assign(payload, { @@ -529,7 +553,11 @@ export default function Page() { const newNiceId = res.data.data.niceId; setNiceId(newNiceId); - if (resourceType === "http") { + if (resourceType === "inference") { + router.push( + `/${orgId}/settings/resources/public/${newNiceId}/general` + ); + } else if (resourceType === "http") { if (targets.length > 0) { try { for (const target of targets) { @@ -754,16 +782,17 @@ export default function Page() { let typeLabels: Partial> = { http: "HTTP", + inference: t("createInternalResourceDialogModeInference"), tcp: "TCP", udp: "UDP" }; if (enterpriseModesAllowed) { - typeLabels = { + typeLabels = { ...typeLabels, ssh: "SSH", rdp: "RDP", - vnc: "VNC", + vnc: "VNC" }; } @@ -1376,6 +1405,71 @@ export default function Page() { /> )} + {resourceType === "inference" && ( + + + + {t("aiResourceProviders")} + + + {t( + "aiResourceProvidersDescription" + )} + + + + + + +
+ + { + setSelectedProviders( + providers + ); + if ( + providers.length > + 0 + ) { + setShowProvidersError( + false + ); + } + }} + /> +

+ {t( + "aiResourceProvidersHelp" + )} +

+ {showProvidersError && ( +

+ {t( + "aiResourceProvidersRequired" + )} +

+ )} +
+
+
+
+
+
+ )} +
diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 09f451a69..48d7b4486 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -312,6 +312,12 @@ export default function PublicResourcesTable({ { value: "vnc", label: t("vncTitle") + }, + { + value: "inference", + label: t( + "createInternalResourceDialogModeInference" + ) } ]} selectedValue={ @@ -334,7 +340,11 @@ export default function PublicResourcesTable({ ? resourceRow.ssl ? "HTTPS" : "HTTP" - : resourceRow.mode?.toUpperCase()} + : resourceRow.mode === "inference" + ? t( + "createInternalResourceDialogModeInference" + ) + : resourceRow.mode?.toUpperCase()} ); } @@ -428,7 +438,7 @@ export default function PublicResourcesTable({ const resourceRow = row.original; if ( - !["http", "ssh", "rdp", "vnc"].includes( + !["http", "ssh", "rdp", "vnc", "inference"].includes( resourceRow.mode || "" ) ) { @@ -894,7 +904,9 @@ function ResourceEnabledForm({ resource, onToggleResourceEnabled }: ResourceEnabledFormProps) { - const enabled = ["http", "ssh", "rdp", "vnc"].includes(resource.mode || "") + const enabled = ["http", "ssh", "rdp", "vnc", "inference"].includes( + resource.mode || "" + ) ? !!resource.domainId && resource.enabled : resource.enabled; const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled); @@ -912,7 +924,7 @@ function ResourceEnabledForm({ */} - {["http", "ssh", "rdp", "vnc"].includes(resource.mode) ? ( + {isDomainResource ? ( <> URL @@ -94,33 +100,39 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { ? resource.ssl ? "HTTPS" : "HTTP" - : resource.mode?.toUpperCase()} + : resource.mode === "inference" + ? t( + "createInternalResourceDialogModeInference" + ) + : resource.mode?.toUpperCase()} )} - - - {t("authentication")} - - - {authInfo.password || - authInfo.pincode || - authInfo.sso || - authInfo.whitelist || - authInfo.headerAuth ? ( -
- - {t("protected")} -
- ) : ( -
- - {t("notProtected")} -
- )} -
-
+ {showAuth && ( + + + {t("authentication")} + + + {authInfo.password || + authInfo.pincode || + authInfo.sso || + authInfo.whitelist || + authInfo.headerAuth ? ( +
+ + {t("protected")} +
+ ) : ( +
+ + {t("notProtected")} +
+ )} +
+
+ )} ) : ( <> @@ -138,7 +150,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {t("port")} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 27cf1f7db..9a0cb2403 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1300,6 +1300,26 @@ export const resourceQueries = { return res.data.data.providers; } }), + resourceAiProviders: ({ resourceId }: { resourceId: number }) => + queryOptions({ + queryKey: ["RESOURCES", resourceId, "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; + }>; + }> + >(`/resource/${resourceId}/ai-providers`, { + signal + }); + return res.data.data.providers; + } + }), resourceTargets: ({ resourceId }: { resourceId: number }) => queryOptions({ queryKey: ["RESOURCES", resourceId, "TARGETS"] as const,