diff --git a/frontend/app/src/api/llms.ts b/frontend/app/src/api/llms.ts new file mode 100644 index 00000000..d8efdf34 --- /dev/null +++ b/frontend/app/src/api/llms.ts @@ -0,0 +1,116 @@ +import { BASE_URL, buildUrlParams, handleErrors, handleResponse, opaqueCookieHeader, type Page, type PageParams, zodPage } from '@/lib/request'; +import { zodJsonDate } from '@/lib/zod'; +import { z, type ZodType, type ZodTypeDef } from 'zod'; + +export interface LLM { + id: number; + name: string; + provider: string; + model: string; + config?: any; + is_default: boolean; + created_at: Date | null; + updated_at: Date | null; +} + +export interface LlmOption { + provider: string; + default_model: string; + model_description: string; + credentials_display_name: string; + credentials_description: string; + credentials_type: 'str' | 'dict'; + default_credentials: any; +} + +export interface CreateLLM { + name: string; + provider: string; + model: string; + config?: any; + is_default?: boolean; + credentials: string | object; +} + +const llmSchema = z.object({ + id: z.number(), + name: z.string(), + provider: z.string(), + model: z.string(), + config: z.any(), + is_default: z.boolean(), + created_at: zodJsonDate().nullable(), + updated_at: zodJsonDate().nullable(), +}) satisfies ZodType; + +const llmOptionSchema = z.object({ + provider: z.string(), + default_model: z.string(), + model_description: z.string(), + credentials_display_name: z.string(), + credentials_description: z.string(), +}).and(z.discriminatedUnion('credentials_type', [ + z.object({ + credentials_type: z.literal('str'), + default_credentials: z.string(), + }), + z.object({ + credentials_type: z.literal('dict'), + default_credentials: z.object({}).passthrough(), + }), +])) satisfies ZodType; + +export async function listLlmOptions () { + return await fetch(`${BASE_URL}/api/v1/admin/llm-options`, { + headers: { + ...await opaqueCookieHeader(), + }, + }) + .then(handleResponse(llmOptionSchema.array())); +} + +export async function listLlms ({ page = 1, size = 10 }: PageParams = {}): Promise> { + return await fetch(BASE_URL + '/api/v1/admin/llms' + '?' + buildUrlParams({ page, size }), { + headers: await opaqueCookieHeader(), + }) + .then(handleResponse(zodPage(llmSchema))); +} + +export async function getLlm (id: number): Promise { + return await fetch(BASE_URL + `/api/v1/admin/llms/${id}`, { + headers: await opaqueCookieHeader(), + }).then(handleResponse(llmSchema)); +} + +export async function createLlm (create: CreateLLM) { + return await fetch(BASE_URL + `/api/v1/admin/llms`, { + method: 'POST', + body: JSON.stringify(create), + headers: { + 'Content-Type': 'application/json', + ...await opaqueCookieHeader(), + }, + }).then(handleResponse(llmSchema)); +} + +export async function deleteLlm (id: number) { + await fetch(BASE_URL + `/api/v1/admin/llms/${id}`, { + method: 'DELETE', + headers: await opaqueCookieHeader(), + }).then(handleErrors); +} + +export async function testLlm (createLLM: CreateLLM) { + return await fetch(`${BASE_URL}/api/v1/admin/llms/test`, { + method: 'POST', + body: JSON.stringify(createLLM), + headers: { + 'Content-Type': 'application/json', + ...await opaqueCookieHeader(), + }, + }) + .then(handleResponse(z.object({ + success: z.boolean(), + error: z.string().optional(), + }))); +} diff --git a/frontend/app/src/app/(main)/(admin)/llms/[id]/page.tsx b/frontend/app/src/app/(main)/(admin)/llms/[id]/page.tsx new file mode 100644 index 00000000..20ba1b9a --- /dev/null +++ b/frontend/app/src/app/(main)/(admin)/llms/[id]/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { deleteLlm, getLlm } from '@/api/llms'; +import { AdminPageHeading } from '@/components/admin-page-heading'; +import { DangerousActionButton } from '@/components/dangerous-action-button'; +import { DateFormat } from '@/components/date-format'; +import { OptionDetail } from '@/components/option-detail'; +import { Loader2Icon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useTransition } from 'react'; +import useSWR from 'swr'; + +export default function Page ({ params }: { params: { id: string } }) { + const router = useRouter(); + const { data } = useSWR(`api.llms.get?id=${params.id}`, () => getLlm(parseInt(params.id))); + const [transitioning, startTransition] = useTransition(); + + return ( + <> + }, + ]} + /> +
+
+ + + + + + } /> + } /> +
+
+ { + await deleteLlm(parseInt(params.id)); + startTransition(() => { + router.push('/llms'); + }); + }} + > + Delete + +
+
+ + ); +} diff --git a/frontend/app/src/app/(main)/(admin)/llms/create/page.tsx b/frontend/app/src/app/(main)/(admin)/llms/create/page.tsx new file mode 100644 index 00000000..3eb5cdac --- /dev/null +++ b/frontend/app/src/app/(main)/(admin)/llms/create/page.tsx @@ -0,0 +1,294 @@ +'use client'; + +import { createLlm, listLlmOptions, type LlmOption, testLlm } from '@/api/llms'; +import { AdminPageHeading } from '@/components/admin-page-heading'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader2Icon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { type ReactNode, useEffect, useState, useTransition } from 'react'; +import { useForm, useFormContext, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; +import useSWR from 'swr'; +import { z } from 'zod'; + +const unsetForm = z.object({ + name: z.string().min(1, 'Must not empty'), + provider: z.string().min(1, 'Must not empty'), + config: z.object({}).passthrough().optional(), + is_default: z.boolean().optional(), +}); + +const strCredentialForm = unsetForm.extend({ + model: z.string().min(1, 'Must not empty'), + credentials: z.string().min(1, 'Must not empty'), +}); + +const dictCredentialForm = unsetForm.extend({ + model: z.string().min(1, 'Must not empty'), + credentials: z.object({}).passthrough(), +}); + +export default function Page () { + const router = useRouter(); + const [transitioning, startTransition] = useTransition(); + const [provider, setProvider] = useState(); + + const form = useForm({ + resolver: zodResolver( + provider + ? provider.credentials_type === 'str' + ? strCredentialForm + : provider.credentials_type === 'dict' + ? dictCredentialForm + : unsetForm + : unsetForm), + defaultValues: { + name: '', + provider: '', + model: '', + credentials: '', + is_default: false, + }, + }); + + useEffect(() => { + if (provider) { + form.reset({ + ...form.getValues(), + model: provider.default_model, + credentials: provider.credentials_type === 'dict' ? undefined : '', + }); + } else { + const { name, is_default } = form.getValues(); + form.reset({ + name, + is_default, + provider: '', + credentials: '', + model: '', + }); + } + }, [provider]); + + const handleSubmit = form.handleSubmit(async (values) => { + const { error, success } = await testLlm(values); + if (!success) { + form.setError('root', { message: error || 'Unknown error' }); + return; + } + const llm = await createLlm(values); + toast('LLM successfully created.'); + startTransition(() => { + router.push(`/llms/${llm.id}`); + }); + }); + + const error = form.formState.errors.root; + + return ( + <> + +
+ + ( + + Name + + + + + + )} + /> + + ( + +
+ Default LLM + + Enable will unset original default LLM. + +
+ + + + +
+ )} + /> + {error && + Failed to create LLM + {error.message} + } + + + + + ); +} + +function useLlmOptions () { + const { data: options } = useSWR('api.llms.list-options', listLlmOptions); + + return options; +} + +function LlmFields ({ onSelectProvider }: { onSelectProvider: (llmOption: LlmOption | undefined) => void }) { + const providerOptions = useLlmOptions(); + const provider = useWatch<{ provider: string }>({ name: 'provider' }); + + const providerOption = providerOptions?.find(o => o.provider === provider); + + let fields: ReactNode; + + const form = useFormContext(); + + if (!providerOption) { + fields =
Select provider...
; + } else { + fields = ( + <> + ( + + Model + + + + + + {providerOption.model_description} + + + )} + /> + ( + + {providerOption.credentials_display_name} + + {providerOption.credentials_type === 'str' + ? + :