From 7ba3853bab2c86713aa56dd11d2ec59905a2a7ab Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 21 Jul 2026 03:19:06 -0600 Subject: [PATCH] feat(ai): allow organizations to define custom AI provider presets Organization admins can define their own AI providers (name + API URL) from the AI settings section. When at least one custom provider is defined, it replaces the built-in provider list in the Add AI form, the API URL is auto-filled and locked, and the backend rejects any configuration whose URL is not in the allowed list. --- .../components/dashboard/settings/ai-form.tsx | 6 +- .../settings/handle-ai-providers.tsx | 158 ++++++++++++++++++ .../dashboard/settings/handle-ai.tsx | 25 ++- apps/dokploy/server/api/routers/ai.ts | 16 ++ packages/server/src/db/schema/ai.ts | 9 + packages/server/src/services/ai.ts | 68 +++++++- 6 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 apps/dokploy/components/dashboard/settings/handle-ai-providers.tsx diff --git a/apps/dokploy/components/dashboard/settings/ai-form.tsx b/apps/dokploy/components/dashboard/settings/ai-form.tsx index c3518fdcc..743092525 100644 --- a/apps/dokploy/components/dashboard/settings/ai-form.tsx +++ b/apps/dokploy/components/dashboard/settings/ai-form.tsx @@ -13,6 +13,7 @@ import { } from "@/components/ui/card"; import { api } from "@/utils/api"; import { HandleAi } from "./handle-ai"; +import { HandleAiProviders } from "./handle-ai-providers"; export const AiForm = () => { const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery(); @@ -30,7 +31,10 @@ export const AiForm = () => { Manage your AI configurations - {aiConfigs && aiConfigs?.length > 0 && } +
+ + {aiConfigs && aiConfigs?.length > 0 && } +
{isPending ? ( diff --git a/apps/dokploy/components/dashboard/settings/handle-ai-providers.tsx b/apps/dokploy/components/dashboard/settings/handle-ai-providers.tsx new file mode 100644 index 000000000..32743a7c2 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/handle-ai-providers.tsx @@ -0,0 +1,158 @@ +"use client"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { PlusIcon, ServerIcon, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; + +const Schema = z.object({ + providers: z.array( + z.object({ + name: z.string().min(1, { message: "Name is required" }), + apiUrl: z.string().url({ message: "Please enter a valid URL" }), + }), + ), +}); + +type Schema = z.infer; + +export const HandleAiProviders = () => { + const utils = api.useUtils(); + const [open, setOpen] = useState(false); + const { data: providers } = api.ai.getCustomProviders.useQuery(); + const { mutateAsync, isPending } = api.ai.saveCustomProviders.useMutation(); + + const form = useForm({ + resolver: zodResolver(Schema), + defaultValues: { + providers: [], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "providers", + }); + + useEffect(() => { + if (open) { + form.reset({ providers: providers ?? [] }); + } + }, [open, providers, form]); + + const onSubmit = async (data: Schema) => { + try { + await mutateAsync({ providers: data.providers }); + await utils.ai.getCustomProviders.invalidate(); + toast.success("Custom providers saved successfully"); + setOpen(false); + } catch (error) { + toast.error("Failed to save custom providers", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + return ( + + + + + + + Custom AI Providers + + Define your own AI providers, like an internal LLM platform. When at + least one is defined, only these providers can be used in AI + configurations. + + +
+ + {fields.length === 0 && ( +

+ No custom providers defined. The built-in provider list will be + used. +

+ )} + {fields.map((fieldItem, index) => ( +
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + +
+ ))} +
+ + +
+
+ +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/handle-ai.tsx b/apps/dokploy/components/dashboard/settings/handle-ai.tsx index 6b96236f2..b7cf186e7 100644 --- a/apps/dokploy/components/dashboard/settings/handle-ai.tsx +++ b/apps/dokploy/components/dashboard/settings/handle-ai.tsx @@ -99,6 +99,11 @@ export const HandleAi = ({ aiId }: Props) => { enabled: !!aiId, }, ); + const { data: customProviders } = api.ai.getCustomProviders.useQuery(); + const hasCustomProviders = (customProviders?.length ?? 0) > 0; + const providerOptions: { name: string; apiUrl: string }[] = hasCustomProviders + ? (customProviders ?? []) + : [...AI_PROVIDERS]; const { mutateAsync, isPending } = aiId ? api.ai.update.useMutation() : api.ai.create.useMutation(); @@ -210,7 +215,9 @@ export const HandleAi = ({ aiId }: Props) => { Provider

- Quick-fill provider name and URL, or configure manually below + {hasCustomProviders + ? "Select one of the providers defined by your organization" + : "Quick-fill provider name and URL, or configure manually below"}

@@ -260,6 +272,7 @@ export const HandleAi = ({ aiId }: Props) => { { field.onChange(e); @@ -271,7 +284,9 @@ export const HandleAi = ({ aiId }: Props) => { /> - The base URL for your AI provider's API + {hasCustomProviders + ? "The API URL is defined by your organization's providers" + : "The base URL for your AI provider's API"} diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 81e03fe26..26fa9ddb5 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -1,6 +1,7 @@ import { IS_CLOUD } from "@dokploy/server/constants"; import { apiCreateAi, + apiSaveAiCustomProviders, apiUpdateAi, deploySuggestionSchema, } from "@dokploy/server/db/schema/ai"; @@ -13,7 +14,9 @@ import { deleteAiSettings, getAiSettingById, getAiSettingsByOrganizationId, + getCustomAiProviders, saveAiSettings, + saveCustomAiProviders, suggestVariants, } from "@dokploy/server/services/ai"; import { createComposeByTemplate } from "@dokploy/server/services/compose"; @@ -200,6 +203,19 @@ export const aiRouter = createTRPCRouter({ return await deleteAiSettings(input.aiId); }), + getCustomProviders: protectedProcedure.query(async ({ ctx }) => { + return await getCustomAiProviders(ctx.session.activeOrganizationId); + }), + + saveCustomProviders: adminProcedure + .input(apiSaveAiCustomProviders) + .mutation(async ({ ctx, input }) => { + return await saveCustomAiProviders( + ctx.session.activeOrganizationId, + input.providers, + ); + }), + getEnabledProviders: protectedProcedure.query(async ({ ctx }) => { const settings = await getAiSettingsByOrganizationId( ctx.session.activeOrganizationId, diff --git a/packages/server/src/db/schema/ai.ts b/packages/server/src/db/schema/ai.ts index 558f2648e..759bb6e87 100644 --- a/packages/server/src/db/schema/ai.ts +++ b/packages/server/src/db/schema/ai.ts @@ -54,6 +54,15 @@ export const apiUpdateAi = createSchema }) .omit({ organizationId: true }); +export const aiCustomProviderSchema = z.object({ + name: z.string().min(1, { message: "Name is required" }), + apiUrl: z.string().url({ message: "Please enter a valid URL" }), +}); + +export const apiSaveAiCustomProviders = z.object({ + providers: z.array(aiCustomProviderSchema), +}); + export const deploySuggestionSchema = z.object({ environmentId: z.string().min(1), id: z.string().min(1), diff --git a/packages/server/src/services/ai.ts b/packages/server/src/services/ai.ts index 8eace69f1..0554f10db 100644 --- a/packages/server/src/services/ai.ts +++ b/packages/server/src/services/ai.ts @@ -1,5 +1,6 @@ import { db } from "@dokploy/server/db"; -import { ai } from "@dokploy/server/db/schema"; +import { ai, organization } from "@dokploy/server/db/schema"; +import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai"; import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider"; import { TRPCError } from "@trpc/server"; import { generateText, Output } from "ai"; @@ -48,9 +49,74 @@ export const getAiSettingById = async (aiId: string) => { return aiSetting; }; +type AiCustomProvider = z.infer; + +const parseOrgMetadata = (metadata: string | null) => { + try { + const parsed = JSON.parse(metadata || "{}"); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : {}; + } catch { + return {}; + } +}; + +export const getCustomAiProviders = async (organizationId: string) => { + const org = await db.query.organization.findFirst({ + where: eq(organization.id, organizationId), + }); + const metadata = parseOrgMetadata(org?.metadata ?? null); + const result = z + .array(aiCustomProviderSchema) + .safeParse(metadata.aiProviders); + return result.success ? result.data : []; +}; + +export const saveCustomAiProviders = async ( + organizationId: string, + providers: AiCustomProvider[], +) => { + const org = await db.query.organization.findFirst({ + where: eq(organization.id, organizationId), + }); + if (!org) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Organization not found", + }); + } + const metadata = parseOrgMetadata(org.metadata); + metadata.aiProviders = providers; + await db + .update(organization) + .set({ metadata: JSON.stringify(metadata) }) + .where(eq(organization.id, organizationId)); + return providers; +}; + +const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, ""); + export const saveAiSettings = async (organizationId: string, settings: any) => { const aiId = settings.aiId; + if (settings.apiUrl) { + const customProviders = await getCustomAiProviders(organizationId); + if (customProviders.length > 0) { + const isAllowed = customProviders.some( + (provider) => + normalizeApiUrl(provider.apiUrl) === normalizeApiUrl(settings.apiUrl), + ); + if (!isAllowed) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "This API URL is not in your organization's allowed AI providers", + }); + } + } + } + return db .insert(ai) .values({