mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-21 22:05:23 +02:00
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.
This commit is contained in:
@@ -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 = () => {
|
||||
</CardTitle>
|
||||
<CardDescription>Manage your AI configurations</CardDescription>
|
||||
</div>
|
||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
||||
<div className="flex flex-row gap-2">
|
||||
<HandleAiProviders />
|
||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{isPending ? (
|
||||
|
||||
@@ -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<typeof Schema>;
|
||||
|
||||
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<Schema>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="cursor-pointer space-x-3">
|
||||
<ServerIcon className="h-4 w-4" />
|
||||
Custom Presets
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Custom AI Providers</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No custom providers defined. The built-in provider list will be
|
||||
used.
|
||||
</p>
|
||||
)}
|
||||
{fields.map((fieldItem, index) => (
|
||||
<div key={fieldItem.id} className="flex gap-2 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`providers.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Internal LLM" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`providers.${index}.apiUrl`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-[2]">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://llm.internal.company/v1"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => append({ name: "", apiUrl: "" })}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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) => {
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
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) => {
|
||||
<SelectValue placeholder="Select a provider preset..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AI_PROVIDERS.map((provider) => (
|
||||
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
|
||||
{providerOptions.map((provider) => (
|
||||
<SelectItem
|
||||
key={`${provider.name}-${provider.apiUrl}`}
|
||||
value={provider.apiUrl}
|
||||
>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
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"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -260,6 +272,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://api.openai.com/v1"
|
||||
disabled={hasCustomProviders}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
@@ -271,7 +284,9 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
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"}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<typeof aiCustomProviderSchema>;
|
||||
|
||||
const parseOrgMetadata = (metadata: string | null) => {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata || "{}");
|
||||
return typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} 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({
|
||||
|
||||
Reference in New Issue
Block a user