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 Presets
+
+
+
+
+ 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.
+
+
+
+
+
+
+ );
+};
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
{
- const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
+ const provider = providerOptions.find(
+ (p) => p.apiUrl === value,
+ );
if (provider) {
form.setValue("name", provider.name);
form.setValue("apiUrl", provider.apiUrl);
@@ -222,15 +229,20 @@ export const HandleAi = ({ aiId }: Props) => {
- {AI_PROVIDERS.map((provider) => (
-
+ {providerOptions.map((provider) => (
+
{provider.name}
))}
- 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({